From 43812426c9f005c934e07dac39b0f8ea5d3316b9 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 19 Jun 2026 11:06:12 +0200 Subject: [PATCH 01/17] feat(token): add native shielded token standard Start a clean branch off main carrying only the Native Shielded Token Standard MIP and its reference Compact modules, so the standard can be compiled, deployed, and integration-tested against local infra in isolation from the broader exploration branch. Includes: * mip-xxxx-native-shielded-token.md (concise rewrite) * NativeShieldedToken / NativeShieldedTokenFamily core modules * NativeShieldedTokenDerivedNonce optional extension * Ownable + AccessControl presets for both profiles * Mocks for the simulator/test harness Custody, conversion, and unshielded siblings are intentionally left out; each is tracked by a separate companion MIP. --- .../src/token/NativeShieldedToken.compact | 431 +++++++++++++ .../token/NativeShieldedTokenFamily.compact | 427 +++++++++++++ .../NativeShieldedTokenDerivedNonce.compact | 109 ++++ .../NativeShieldedTokenAccessControl.compact | 166 +++++ ...veShieldedTokenFamilyAccessControl.compact | 171 +++++ .../NativeShieldedTokenFamilyOwnable.compact | 144 +++++ .../NativeShieldedTokenOwnable.compact | 144 +++++ .../mocks/MockNativeShieldedToken.compact | 91 +++ .../MockNativeShieldedTokenFamily.compact | 94 +++ mip-xxxx-native-shielded-token.md | 593 ++++++++++++++++++ 10 files changed, 2370 insertions(+) create mode 100644 contracts/src/token/NativeShieldedToken.compact create mode 100644 contracts/src/token/NativeShieldedTokenFamily.compact create mode 100644 contracts/src/token/extensions/NativeShieldedTokenDerivedNonce.compact create mode 100644 contracts/src/token/presets/NativeShieldedTokenAccessControl.compact create mode 100644 contracts/src/token/presets/NativeShieldedTokenFamilyAccessControl.compact create mode 100644 contracts/src/token/presets/NativeShieldedTokenFamilyOwnable.compact create mode 100644 contracts/src/token/presets/NativeShieldedTokenOwnable.compact create mode 100644 contracts/src/token/test/mocks/MockNativeShieldedToken.compact create mode 100644 contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact create mode 100644 mip-xxxx-native-shielded-token.md diff --git a/contracts/src/token/NativeShieldedToken.compact b/contracts/src/token/NativeShieldedToken.compact new file mode 100644 index 000000000..30ac72042 --- /dev/null +++ b/contracts/src/token/NativeShieldedToken.compact @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/NativeShieldedToken.compact) + +pragma language_version >= 0.21.0; + +/** + * @module NativeShieldedToken + * @description A module for issuing a SINGLE native shielded (Zswap) token — + * the ERC-20-shaped flavor of the native shielded token standard. The token's + * domain separator is fixed at construction; no circuit takes a domain + * parameter. To issue multiple token types from one contract, use + * `NativeShieldedTokenFamily` instead. + * + * This module exposes composable building blocks for minting and burning + * native shielded coins. It does NOT include access control. Consuming + * contracts compose that through the module/contract pattern (Ownable, + * AccessControl, etc.). The mint and burn circuits SHOULD be gated by the + * consumer; they are unrestricted at the module level. + * + * # Design notes + * + * - The domain separator is stored as `sealed ledger _domain` at construction + * and never exposed as a circuit parameter, eliminating caller-supplied + * domain misuse. The coin's `color` is `tokenType(_domain, kernel.self())`, + * computed at call time (never in the constructor). Only this contract can + * ever mint coins of its color. + * + * - Metadata follows the FungibleToken (ERC-20) convention: contract-wide + * `name`, `symbol`, and `decimals`, fixed at construction. + * + * - Supply totals are scalar ledger cells (cheaper circuits than the + * token-family flavor's per-domain maps). + * + * - Mint amounts are `Uint<64>`; burn amounts are `Uint<128>`. This asymmetry + * is imposed by the protocol primitives (`mintShieldedToken` caps at 64; + * `sendShielded` accepts 128) and is not a choice the module makes. + * + * # Minting and recipient privacy + * + * - `_mint` accepts a caller-supplied nonce, mirroring the protocol's + * `mintShieldedToken` one-to-one. The caller is fully responsible for + * nonce uniqueness: reusing a nonce for the same (value, recipient) + * produces a duplicate commitment, which the ledger rejects. + * + * - With a secret, cryptographically random nonce the mint is + * recipient-private: the commitment cannot be linked to a recipient + * without the nonce. This also supports operator-driven flows where the + * commitment must be computed off-chain before submission. + * + * - For mints that require no caller-managed nonce, compose the standalone + * `NativeShieldedTokenDerivedNonce` extension and pass its `_deriveNonce()` + * output as the nonce, at the cost of making those mints recipient-public. + * + * # Burn variants + * + * - `_burn` destroys value from a coin provided by the transaction itself + * (e.g. a user's wallet pays the contract within the same transaction). + * It receives the coin and spends it via `sendImmediateShielded`, which is + * the required spend path for coins created within the current transaction. + * + * - `_burnFromContract` destroys value from a coin the contract already + * holds (a real Merkle-tree entry with a valid `mt_index`). It spends via + * `sendShielded`; any change is auto-received by the contract and returned + * so the consumer can persist it. + * + * # Supply accounting + * + * - `totalMinted()` is EXACT: color derivation guarantees that every coin of + * this contract's color originates from this module's mints. + * - `totalBurned()` is a LOWER BOUND: holders can send coins directly to the + * burn address without going through the contract, and such burns are + * invisible to ledger state. + * - `totalSupply()` = minted - burned is therefore an UPPER BOUND on + * circulating supply. Exact circulating supply is not knowable for native + * shielded tokens; these are the strongest guarantees available. + * + * # Wallet visibility + * + * - Contract-initiated sends do not create coin ciphertexts, so recipient + * wallets cannot detect coins minted or refunded to them by scanning the + * chain. The returned `ShieldedCoinInfo` values are the only copies of the + * corresponding coins' info: DApps SHOULD capture and deliver them out of + * band; dropping them strands value irrecoverably. + * + * # Composition + * + * - Dual-representation tokens (shielded + unshielded with conversion) MUST + * build on the token-family modules and the `NativeTokenConverter` + * extension, not on this module: this module and `NativeUnshieldedToken` each store a + * load-bearing sealed `_domain` written by their `initialize`, but the + * shared `Initializable` flag allows only one `initialize` call per + * contract. + * + * # Out of scope (phase two, pending contract-to-contract support) + * + * - `balanceOf`, `allowance`, and transfer mediation are not representable + * for native shielded UTXOs: once a user holds a coin, the contract cannot + * observe or restrict its movement. Custom spend logic depends on protocol + * features that have not landed yet. + */ +module NativeShieldedToken { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + import "../utils/Utils" prefix Utils_; + + /** + * @description Domain separator fixed at construction; with the contract + * address it determines this token's color. + */ + export sealed ledger _domain: Bytes<32>; + /** + * @description Exact amount minted. See "Supply accounting". + */ + export ledger _totalMinted: Uint<128>; + /** + * @description Contract-mediated amount burned (lower bound). + */ + export ledger _totalBurned: Uint<128>; + + export sealed ledger _name: Opaque<"string">; + export sealed ledger _symbol: Opaque<"string">; + export sealed ledger _decimals: Uint<8>; + + /** + * @description Initializes the module's domain and metadata. + * @dev This MUST be called in the implementing contract's constructor. + * Failure to do so can lead to an irreparable contract. + * + * @circuitInfo k=9, rows=339 + * + * @param {Bytes<32>} domainSep - Domain separator for this token's color. + * @param {Opaque<"string">} name_ - The name of the token. + * @param {Opaque<"string">} symbol_ - The symbol of the token. + * @param {Uint<8>} decimals_ - The number of decimals used to get the user representation. + * @return {[]} - Empty tuple. + */ + export circuit initialize( + domainSep: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8> + ): [] { + Initializable_initialize(); + _domain = disclose(domainSep); + _name = disclose(name_); + _symbol = disclose(symbol_); + _decimals = disclose(decimals_); + } + + /** + * @description Returns the token name. + * + * @circuitInfo k=6, rows=28 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Opaque<"string">} - The token name. + */ + export circuit name(): Opaque<"string"> { + Initializable_assertInitialized(); + return _name; + } + + /** + * @description Returns the symbol of the token. + * + * @circuitInfo k=6, rows=28 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Opaque<"string">} - The token symbol. + */ + export circuit symbol(): Opaque<"string"> { + Initializable_assertInitialized(); + return _symbol; + } + + /** + * @description Returns the number of decimals used to get its user representation. + * @dev This is a display convention only. The protocol stores and operates + * on integer values without reference to decimals. + * + * @circuitInfo k=6, rows=28 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Uint<8>} - The decimals value. + */ + export circuit decimals(): Uint<8> { + Initializable_assertInitialized(); + return _decimals; + } + + /** + * @description Returns this token's coin color: + * `tokenType(_domain, kernel.self())`, computed at call time. + * + * @circuitInfo k=13, rows=3971 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Bytes<32>} - The coin color. + */ + export circuit tokenColor(): Bytes<32> { + Initializable_assertInitialized(); + return tokenType(_domain, kernel.self()); + } + + /** + * @description Returns the exact amount ever minted. + * + * @circuitInfo k=6, rows=28 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Uint<128>} - The total amount minted. + */ + export circuit totalMinted(): Uint<128> { + Initializable_assertInitialized(); + return _totalMinted; + } + + /** + * @description Returns the contract-mediated amount burned. + * @notice This is a lower bound: coins sent directly to the burn address + * without going through this contract are not counted. + * + * @circuitInfo k=6, rows=28 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Uint<128>} - The total amount burned through this contract. + */ + export circuit totalBurned(): Uint<128> { + Initializable_assertInitialized(); + return _totalBurned; + } + + /** + * @description Returns `totalMinted() - totalBurned()`. + * @notice This is an UPPER BOUND on circulating supply, not an exact value: + * burns that bypass the contract are invisible. See "Supply accounting" in + * the module notes. + * + * @circuitInfo k=9, rows=88 + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Uint<128>} - The upper bound on tokens in existence. + */ + export circuit totalSupply(): Uint<128> { + Initializable_assertInitialized(); + return (_totalMinted - _totalBurned) as Uint<128>; + } + + /** + * @description Mints `amount` of the token to `recipient`, using a + * caller-supplied nonce. + * + * @dev The caller is fully responsible for nonce uniqueness. Reusing a + * nonce for the same (value, recipient) produces a duplicate commitment, + * which the protocol rejects. With a secret random nonce this is the + * recipient-private mint. For mints that require no caller-managed nonce, + * compose the `NativeShieldedTokenDerivedNonce` extension and pass its + * `_deriveNonce()` output. + * + * @notice The returned coin info is the only copy available to the + * recipient; callers SHOULD deliver it out of band. Wallets cannot detect + * contract-minted coins by scanning the chain. + * + * @circuitInfo k=14, rows=11090 + * + * Requirements: + * + * - Contract is initialized. + * - `recipient` is not zero. + * + * @param {Either} recipient - The coin recipient. + * @param {Uint<64>} amount - Quantity to mint. Capped at `Uint<64>` by the protocol. + * @param {Bytes<32>} nonce - Caller-supplied nonce. Must be unique for this + * contract's color. + * @return {ShieldedCoinInfo} - The newly created coin's info (nonce, color, value). + */ + export circuit _mint( + recipient: Either, + amount: Uint<64>, + nonce: Bytes<32> + ): ShieldedCoinInfo { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(recipient), "NativeShieldedToken: invalid recipient"); + + _addMinted(amount); + return mintShieldedToken(_domain, disclose(amount), disclose(nonce), disclose(recipient)); + } + + /** + * @description Burns `amount` from `coin`, a coin provided within the + * current transaction (e.g. paid in by the caller's wallet), and routes the + * remaining change to `refundTo`. + * + * To destroy `coin` in full, pass `coin.value` as `amount`; the change + * branch will not fire and `none` is returned. The `refundTo` value is + * inert in that case but must still be supplied and non-zero. + * + * @dev The coin is received by the contract and spent in the same + * transaction, so the spend goes through `sendImmediateShielded` (the + * transient path). For coins the contract already holds, use + * `_burnFromContract` instead. + * + * @notice The returned refund coin info is the only copy available to + * `refundTo`; callers SHOULD deliver it out of band. Wallets cannot detect + * contract-sent coins by scanning the chain. + * + * @circuitInfo k=16, rows=47786 + * + * Requirements: + * + * - Contract is initialized. + * - `coin.color` is this contract's token color. + * - `amount` is less than or equal to `coin.value`. + * - `refundTo` is not zero (the zero key is the burn address; a zero + * `refundTo` would silently burn the change as well). + * + * @param {ShieldedCoinInfo} coin - The coin to burn from. + * @param {Uint<128>} amount - Value to destroy. Must be <= `coin.value`. + * @param {Either} refundTo - Where to + * route the unspent portion (`coin.value - amount`). + * @return {Maybe} - The refund coin created for `refundTo`, + * or `none` if the coin was burned in full. + */ + export circuit _burn( + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either + ): Maybe { + Initializable_assertInitialized(); + assert(coin.color == tokenType(_domain, kernel.self()), "NativeShieldedToken: wrong token"); + assert(coin.value >= amount, "NativeShieldedToken: insufficient coin value"); + assert(!Utils_isKeyOrAddressZero(refundTo), "NativeShieldedToken: invalid refund target"); + + receiveShielded(disclose(coin)); + const sendRes = sendImmediateShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); + _addBurned(amount); + + if (disclose(sendRes.change.is_some)) { + const refundRes = sendImmediateShielded( + disclose(sendRes.change.value), + disclose(refundTo), + disclose(sendRes.change.value.value) + ); + return some(refundRes.sent); + } + return none(); + } + + /** + * @description Burns `amount` from `coin`, a coin this contract already + * holds (a Merkle-tree entry with a valid `mt_index`). Any change is + * auto-received by the contract and returned. + * + * @dev The consumer SHOULD persist the returned change coin info in its own + * ledger state: the change replaces `coin` as the contract's holding, and + * its info is not otherwise recoverable. + * + * @circuitInfo k=15, rows=23437 + * + * Requirements: + * + * - Contract is initialized. + * - `coin.color` is this contract's token color. + * - `amount` is less than or equal to `coin.value`. + * + * @param {QualifiedShieldedCoinInfo} coin - The contract-held coin to burn from. + * @param {Uint<128>} amount - Value to destroy. Must be <= `coin.value`. + * @return {Maybe} - The change coin retained by the + * contract, or `none` if the coin was burned in full. + */ + export circuit _burnFromContract( + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> + ): Maybe { + Initializable_assertInitialized(); + assert(coin.color == tokenType(_domain, kernel.self()), "NativeShieldedToken: wrong token"); + assert(coin.value >= amount, "NativeShieldedToken: insufficient coin value"); + + const sendRes = sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); + _addBurned(amount); + + return disclose(sendRes.change); + } + + /** + * @description Adds `amount` to the exact minted total. + * @dev Checks for overflow in order to output a readable error message. + * + * @param {Uint<64>} amount - The minted amount. + * @return {[]} - Empty tuple. + */ + circuit _addMinted(amount: Uint<64>): [] { + const MAX_UINT128 = 340282366920938463463374607431768211455; + assert(MAX_UINT128 - _totalMinted >= amount, "NativeShieldedToken: arithmetic overflow"); + _totalMinted = disclose((_totalMinted + amount) as Uint<128>); + } + + /** + * @description Adds `amount` to the contract-mediated burned total. + * @dev No overflow guard is needed: every coin of this contract's color + * originates from `_addMinted`-tracked mints, so burned can never exceed + * the overflow-checked minted total. + * + * @param {Uint<128>} amount - The burned amount. + * @return {[]} - Empty tuple. + */ + circuit _addBurned(amount: Uint<128>): [] { + _totalBurned = disclose((_totalBurned + amount) as Uint<128>); + } +} diff --git a/contracts/src/token/NativeShieldedTokenFamily.compact b/contracts/src/token/NativeShieldedTokenFamily.compact new file mode 100644 index 000000000..3709eba56 --- /dev/null +++ b/contracts/src/token/NativeShieldedTokenFamily.compact @@ -0,0 +1,427 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/NativeShieldedTokenFamily.compact) + +pragma language_version >= 0.21.0; + +/** + * @module NativeShieldedTokenFamily + * @description A module for issuing a FAMILY of native shielded (Zswap) + * token types from one contract, discriminated by a per-call domain. + * + * This flavor exists because of Midnight's composition model: there is no + * contract-to-contract interaction and no way for a contract to deploy + * another, so the Ethereum pattern of one token contract per asset (stamped + * out by a factory) is unavailable. A protocol that issues many related + * tokens — e.g. a DEX minting one liquidity-share token per pair — must do + * so from a single contract, which the protocol's color derivation + * (`tokenType(domain, contractAddress)`) natively supports. For a + * single-token issuer, use `NativeShieldedToken` instead. + * + * This module exposes composable building blocks for minting and burning + * native shielded coins. It does NOT include access control. Consuming + * contracts compose that through the module/contract pattern (Ownable, + * AccessControl, etc.). The mint and burn circuits SHOULD be gated by the + * consumer; they are unrestricted at the module level. + * + * # Design notes + * + * - The `domain` parameter is passed explicitly per-call. The coin's `color` + * is derived as `tokenType(domain, kernel.self())`, so the domain is the + * discriminator that distinguishes the token types within this contract + * (e.g. one LP share token per pair). Only this contract can ever mint + * coins of its colors. + * + * - Metadata is contract-wide FAMILY metadata (`name`, `symbol`, `decimals`), + * following the Uniswap-V2 LP precedent: every token type of this contract + * shares one brand (e.g. all LP shares of a DEX carry the same name and + * symbol). Per-type identity belongs in the consumer's own state (e.g. a + * pair registry mapping domain -> underlying tokens), which is strictly + * more informative than a stored string. `decimals` applies family-wide; + * issuers with heterogeneous decimals per token type should add their own + * per-domain handling. + * + * - Mint amounts are `Uint<64>`; burn amounts are `Uint<128>`. This asymmetry + * is imposed by the protocol primitives (`mintShieldedToken` caps at 64; + * `sendShielded` accepts 128) and is not a choice the module makes. + * + * # Minting and recipient privacy + * + * - `_mint` accepts a caller-supplied nonce, mirroring the protocol's + * `mintShieldedToken` one-to-one. The caller is fully responsible for + * nonce uniqueness: reusing a nonce for the same (domain, value, recipient) + * produces a duplicate commitment, which the ledger rejects. + * + * - With a secret, cryptographically random nonce the mint is + * recipient-private: the commitment cannot be linked to a recipient + * without the nonce. This also supports operator-driven flows where the + * commitment must be computed off-chain before submission. + * + * - For mints that require no caller-managed nonce, compose the standalone + * `NativeShieldedTokenDerivedNonce` extension and pass its `_deriveNonce()` + * output as the nonce, at the cost of making those mints recipient-public. + * + * # Burn variants + * + * - `_burn` destroys value from a coin provided by the transaction itself + * (e.g. a user's wallet pays the contract within the same transaction). + * It receives the coin and spends it via `sendImmediateShielded`, which is + * the required spend path for coins created within the current transaction. + * + * - `_burnFromContract` destroys value from a coin the contract already + * holds (a real Merkle-tree entry with a valid `mt_index`). It spends via + * `sendShielded`; any change is auto-received by the contract and returned + * so the consumer can persist it. + * + * # Supply accounting + * + * - `totalMinted(domain)` is EXACT: color derivation guarantees that every + * coin of this contract's colors originates from this module's mints. + * - `totalBurned(domain)` is a LOWER BOUND: holders can send coins directly + * to the burn address without going through the contract, and such burns + * are invisible to ledger state. + * - `totalSupply(domain)` = minted - burned is therefore an UPPER BOUND on + * circulating supply. Exact circulating supply is not knowable for native + * shielded tokens; these are the strongest guarantees available. + * + * # Wallet visibility + * + * - Contract-initiated sends do not create coin ciphertexts, so recipient + * wallets cannot detect coins minted or refunded to them by scanning the + * chain. The returned `ShieldedCoinInfo` values are the only copies of the + * corresponding coins' info: DApps SHOULD capture and deliver them out of + * band; dropping them strands value irrecoverably. + * + * # Out of scope (phase two, pending contract-to-contract support) + * + * - `balanceOf`, `allowance`, transfer mediation, batch operations, and + * operator approvals are not representable for native shielded UTXOs: once + * a user holds a coin, the contract cannot observe or restrict its + * movement. + */ +module NativeShieldedTokenFamily { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + import "../utils/Utils" prefix Utils_; + + /** + * @description Exact amount minted per domain. See "Supply accounting". + * @type {Map, Uint<128>>} _totalMinted + */ + export ledger _totalMinted: Map, Uint<128>>; + /** + * @description Contract-mediated amount burned per domain (lower bound). + * @type {Map, Uint<128>>} _totalBurned + */ + export ledger _totalBurned: Map, Uint<128>>; + export sealed ledger _name: Opaque<"string">; + export sealed ledger _symbol: Opaque<"string">; + export sealed ledger _decimals: Uint<8>; + + /** + * @description Initializes the module's family metadata. + * @dev This MUST be called in the implementing contract's constructor. + * Failure to do so can lead to an irreparable contract. + * + * @param {Opaque<"string">} name_ - The family name shared by all token types. + * @param {Opaque<"string">} symbol_ - The family symbol shared by all token types. + * @param {Uint<8>} decimals_ - The family-wide number of decimals used to + * get the user representation. + * @return {[]} - Empty tuple. + */ + export circuit initialize( + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8> + ): [] { + Initializable_initialize(); + _name = disclose(name_); + _symbol = disclose(symbol_); + _decimals = disclose(decimals_); + } + + /** + * @description Returns the family name shared by all token types of this + * contract. Per-type identity belongs in the consumer's own state; see the + * module notes. + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Opaque<"string">} - The family name. + */ + export circuit name(): Opaque<"string"> { + Initializable_assertInitialized(); + return _name; + } + + /** + * @description Returns the family symbol shared by all token types of this + * contract. + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Opaque<"string">} - The family symbol. + */ + export circuit symbol(): Opaque<"string"> { + Initializable_assertInitialized(); + return _symbol; + } + + /** + * @description Returns the family-wide number of decimals used to get the + * user representation. + * @dev This is a display convention only. The protocol stores and operates + * on integer values without reference to decimals. + * + * Requirements: + * + * - Contract is initialized. + * + * @return {Uint<8>} - The decimals value. + */ + export circuit decimals(): Uint<8> { + Initializable_assertInitialized(); + return _decimals; + } + + /** + * @description Returns the coin color (token type) for `domain` under this + * contract's address, so integrators do not have to re-derive it by hand. + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @return {Bytes<32>} - The coin color: `tokenType(domain, kernel.self())`. + */ + export circuit tokenColor(domain: Bytes<32>): Bytes<32> { + Initializable_assertInitialized(); + return tokenType(disclose(domain), kernel.self()); + } + + /** + * @description Returns the exact amount ever minted for `domain`. + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @return {Uint<128>} - The total amount minted. + */ + export circuit totalMinted(domain: Bytes<32>): Uint<128> { + Initializable_assertInitialized(); + if (!_totalMinted.member(disclose(domain))) { + return 0; + } + return _totalMinted.lookup(disclose(domain)); + } + + /** + * @description Returns the contract-mediated amount burned for `domain`. + * @notice This is a lower bound: coins sent directly to the burn address + * without going through this contract are not counted. + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @return {Uint<128>} - The total amount burned through this contract. + */ + export circuit totalBurned(domain: Bytes<32>): Uint<128> { + Initializable_assertInitialized(); + if (!_totalBurned.member(disclose(domain))) { + return 0; + } + return _totalBurned.lookup(disclose(domain)); + } + + /** + * @description Returns `totalMinted(domain) - totalBurned(domain)`. + * @notice This is an UPPER BOUND on circulating supply, not an exact value: + * burns that bypass the contract are invisible. See "Supply accounting" in + * the module notes. + * + * Requirements: + * + * - Contract is initialized. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @return {Uint<128>} - The upper bound on tokens in existence. + */ + export circuit totalSupply(domain: Bytes<32>): Uint<128> { + Initializable_assertInitialized(); + return (totalMinted(domain) - totalBurned(domain)) as Uint<128>; + } + + /** + * @description Mints `amount` of the token identified by `domain` to + * `recipient`, using a caller-supplied nonce. + * + * @dev The caller is fully responsible for nonce uniqueness. Reusing a + * nonce for the same (domain, value, recipient) produces a duplicate + * commitment, which the protocol rejects. With a secret random nonce this + * is the recipient-private mint. For mints that require no caller-managed + * nonce, compose the `NativeShieldedTokenDerivedNonce` extension and pass + * its `_deriveNonce()` output. + * + * @notice The returned coin info is the only copy available to the + * recipient; callers SHOULD deliver it out of band. Wallets cannot detect + * contract-minted coins by scanning the chain. + * + * Requirements: + * + * - Contract is initialized. + * - `recipient` is not zero. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. Same domain + same + * contract address produces the same coin color (i.e. same token type). + * @param {Either} recipient - The coin recipient. + * @param {Uint<64>} amount - Quantity to mint. Capped at `Uint<64>` by the protocol. + * @param {Bytes<32>} nonce - Caller-supplied nonce. Must be unique per (domain, contract). + * @return {ShieldedCoinInfo} - The newly created coin's info (nonce, color, value). + */ + export circuit _mint( + domain: Bytes<32>, + recipient: Either, + amount: Uint<64>, + nonce: Bytes<32> + ): ShieldedCoinInfo { + Initializable_assertInitialized(); + assert(!Utils_isKeyOrAddressZero(recipient), "NativeShieldedTokenFamily: invalid recipient"); + + _addMinted(domain, amount); + return mintShieldedToken(disclose(domain), disclose(amount), disclose(nonce), disclose(recipient)); + } + + /** + * @description Burns `amount` from `coin`, a coin provided within the + * current transaction (e.g. paid in by the caller's wallet), and routes the + * remaining change to `refundTo`. + * + * To destroy `coin` in full, pass `coin.value` as `amount`; the change + * branch will not fire and `none` is returned. The `refundTo` value is + * inert in that case but must still be supplied and non-zero. + * + * @dev The coin is received by the contract and spent in the same + * transaction, so the spend goes through `sendImmediateShielded` (the + * transient path). For coins the contract already holds, use + * `_burnFromContract` instead. + * + * @notice The returned refund coin info is the only copy available to + * `refundTo`; callers SHOULD deliver it out of band. Wallets cannot detect + * contract-sent coins by scanning the chain. + * + * Requirements: + * + * - Contract is initialized. + * - `coin.color` is this contract's token type for `domain`. + * - `amount` is less than or equal to `coin.value`. + * - `refundTo` is not zero (the zero key is the burn address; a zero + * `refundTo` would silently burn the change as well). + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @param {ShieldedCoinInfo} coin - The coin to burn from. + * @param {Uint<128>} amount - Value to destroy. Must be <= `coin.value`. + * @param {Either} refundTo - Where to + * route the unspent portion (`coin.value - amount`). + * @return {Maybe} - The refund coin created for `refundTo`, + * or `none` if the coin was burned in full. + */ + export circuit _burn( + domain: Bytes<32>, + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either + ): Maybe { + Initializable_assertInitialized(); + assert(coin.color == tokenType(disclose(domain), kernel.self()), "NativeShieldedTokenFamily: wrong token"); + assert(coin.value >= amount, "NativeShieldedTokenFamily: insufficient coin value"); + assert(!Utils_isKeyOrAddressZero(refundTo), "NativeShieldedTokenFamily: invalid refund target"); + + receiveShielded(disclose(coin)); + const sendRes = sendImmediateShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); + _addBurned(domain, amount); + + if (disclose(sendRes.change.is_some)) { + const refundRes = sendImmediateShielded( + disclose(sendRes.change.value), + disclose(refundTo), + disclose(sendRes.change.value.value) + ); + return some(refundRes.sent); + } + return none(); + } + + /** + * @description Burns `amount` from `coin`, a coin this contract already + * holds (a Merkle-tree entry with a valid `mt_index`). Any change is + * auto-received by the contract and returned. + * + * @dev The consumer SHOULD persist the returned change coin info in its own + * ledger state: the change replaces `coin` as the contract's holding, and + * its info is not otherwise recoverable. + * + * Requirements: + * + * - Contract is initialized. + * - `coin.color` is this contract's token type for `domain`. + * - `amount` is less than or equal to `coin.value`. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @param {QualifiedShieldedCoinInfo} coin - The contract-held coin to burn from. + * @param {Uint<128>} amount - Value to destroy. Must be <= `coin.value`. + * @return {Maybe} - The change coin retained by the + * contract, or `none` if the coin was burned in full. + */ + export circuit _burnFromContract( + domain: Bytes<32>, + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> + ): Maybe { + Initializable_assertInitialized(); + assert(coin.color == tokenType(disclose(domain), kernel.self()), "NativeShieldedTokenFamily: wrong token"); + assert(coin.value >= amount, "NativeShieldedTokenFamily: insufficient coin value"); + + const sendRes = sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); + _addBurned(domain, amount); + + return disclose(sendRes.change); + } + + /** + * @description Adds `amount` to the exact minted total for `domain`. + * @dev Checks for overflow in order to output a readable error message. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @param {Uint<64>} amount - The minted amount. + * @return {[]} - Empty tuple. + */ + circuit _addMinted(domain: Bytes<32>, amount: Uint<64>): [] { + const current = totalMinted(domain); + const MAX_UINT128 = 340282366920938463463374607431768211455; + assert(MAX_UINT128 - current >= amount, "NativeShieldedTokenFamily: arithmetic overflow"); + _totalMinted.insert(disclose(domain), disclose((current + amount) as Uint<128>)); + } + + /** + * @description Adds `amount` to the contract-mediated burned total for `domain`. + * @dev No overflow guard is needed: every coin of this contract's colors + * originates from `_addMinted`-tracked mints, so burned can never exceed + * the overflow-checked minted total. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @param {Uint<128>} amount - The burned amount. + * @return {[]} - Empty tuple. + */ + circuit _addBurned(domain: Bytes<32>, amount: Uint<128>): [] { + const current = totalBurned(domain); + _totalBurned.insert(disclose(domain), disclose((current + amount) as Uint<128>)); + } +} diff --git a/contracts/src/token/extensions/NativeShieldedTokenDerivedNonce.compact b/contracts/src/token/extensions/NativeShieldedTokenDerivedNonce.compact new file mode 100644 index 000000000..9fdaf3643 --- /dev/null +++ b/contracts/src/token/extensions/NativeShieldedTokenDerivedNonce.compact @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/extensions/NativeShieldedTokenDerivedNonce.compact) + +pragma language_version >= 0.21.0; + +/** + * @module NativeShieldedTokenDerivedNonce + * @description Optional standalone extension providing derived coin nonces + * for native shielded token mints, so callers need not manage nonces + * themselves. It owns a nonce evolution chain (`_counter`, `_nonce`) and + * exposes `_deriveNonce` as a building block; it imports no token module. + * + * Pairs with `NativeShieldedToken`, `NativeShieldedTokenFamily`, and + * `NativeTokenConverter`. The consuming contract composes the pieces: + * + * export circuit mint(recipient: ..., amount: Uint<64>): ShieldedCoinInfo { + * return Token__mint(recipient, amount, Derived__deriveNonce()); + * } + * + * # Design notes + * + * - Coin nonces are derived from (not equal to) the chain value: + * `persistentHash([pad(32, "NativeShieldedTokenDerivedNonce:nonce"), chainValue])`. + * The fixed tag puts derived nonces in a namespace an honest caller of the + * base `_mint` will not produce, so a value read from the public `_nonce` + * field and passed as a mint nonce cannot collide with a derived mint. + * + * - One chain serves the whole contract: uniqueness is per chain value, so + * derived nonces never repeat across token types (domains) or across the + * shielded/converter mint paths of one contract. + * + * - Derived nonces are predictable from public state: anyone can recompute + * the coin commitment for candidate recipient keys, so derived-nonce mints + * are recipient-public. For recipient privacy, call the base `_mint` with + * a secret, cryptographically random nonce instead. + * + * - The predictability also enables deliberate collision-griefing: an actor + * with access to a caller-nonce mint can pre-mint a commitment that + * collides with a future derived-nonce mint of the same tuple, making that + * mint fail on duplicate-commitment rejection. Gate both mint paths behind + * access control, and prefer not exposing both to distinct trust levels. A + * failed mint is recoverable: any subsequent derived mint with a different + * tuple advances the chain past the collision. + * + * - `initialize` cannot use the library's `Initializable` flag (the paired + * token module's `initialize` owns it; the flag is shared per contract), + * so the seed write is guarded by an unseeded-chain assertion instead. + * Call it once from the consuming contract's constructor, alongside the + * token module's `initialize`. + */ +module NativeShieldedTokenDerivedNonce { + import CompactStandardLibrary; + + /** + * @description Monotonic index feeding the nonce evolution chain. + */ + export ledger _counter: Counter; + /** + * @description Latest value of the nonce evolution chain. Coin nonces are + * derived from (not equal to) this value; see the module notes. + */ + export ledger _nonce: Bytes<32>; + + /** + * @description Seeds the nonce evolution chain. + * @dev This MUST be called in the implementing contract's constructor, + * alongside the token module's `initialize`. A second call reverts on the + * unseeded-chain assertion, making this constructor-only in practice. + * + * Requirements: + * + * - The chain is not already seeded. + * - `initNonce` is not zero (a zero seed is indistinguishable from an + * unseeded chain). + * + * @param {Bytes<32>} initNonce - Nonce chain seed; subsequent values evolve + * from it via the internal counter. Choose unpredictably (e.g. random bytes). + * @return {[]} - Empty tuple. + */ + export circuit initialize(initNonce: Bytes<32>): [] { + assert(_nonce == default>, "NativeShieldedTokenDerivedNonce: already seeded"); + assert(initNonce != default>, "NativeShieldedTokenDerivedNonce: invalid nonce seed"); + _nonce = disclose(initNonce); + } + + /** + * @description Advances the nonce chain and returns the next derived coin + * nonce, for use as the `nonce` argument of a native token mint or a + * converter shield. + * + * @notice Derivation inputs are public ledger state, so mints using this + * nonce are recipient-public; see the module notes. + * + * Requirements: + * + * - The chain is seeded. + * + * @return {Bytes<32>} - The next derived coin nonce. + */ + export circuit _deriveNonce(): Bytes<32> { + assert(_nonce != default>, "NativeShieldedTokenDerivedNonce: chain not seeded"); + _counter.increment(1); + const chainValue = evolveNonce(_counter, _nonce); + _nonce = chainValue; + return persistentHash>>( + [pad(32, "NativeShieldedTokenDerivedNonce:nonce"), chainValue] + ); + } +} diff --git a/contracts/src/token/presets/NativeShieldedTokenAccessControl.compact b/contracts/src/token/presets/NativeShieldedTokenAccessControl.compact new file mode 100644 index 000000000..27885df29 --- /dev/null +++ b/contracts/src/token/presets/NativeShieldedTokenAccessControl.compact @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/presets/NativeShieldedTokenAccessControl.compact) + +pragma language_version >= 0.21.0; + +/** + * @description Ready-to-deploy single native shielded token, role-gated. + * + * Composes: + * - `NativeShieldedToken` (Fungible profile): metadata, mint, burn, supply. + * - `NativeShieldedTokenDerivedNonce`: derives coin nonces so `mint` needs no + * caller-supplied nonce. This makes mints recipient-public; for a + * recipient-private issuer, call the base module's `_mint` with a secret + * nonce instead of using this preset. + * - `AccessControl`: `MINTER_ROLE` authorizes minting, `BURNER_ROLE` + * authorizes burning, and `DEFAULT_ADMIN_ROLE` administers both. + * + * # Initialization + * + * `AccessControl` does not use `Initializable`, so there is no shared-flag + * conflict with the base module: the constructor calls the base `initialize` + * (which owns the flag), seeds the nonce chain, then grants the admin + * account all three roles via the internal `_grantRole`. + * + * # Authorization + * + * `mint` is gated by `MINTER_ROLE`; `burn`/`burnFromContract` by + * `BURNER_ROLE`. Caller identity is derived from the `wit_AccessControlSK` + * witness; the deployer provides it like any AccessControl consumer. + */ +import CompactStandardLibrary; + +import "../NativeShieldedToken" prefix NativeShieldedToken_; +import "../extensions/NativeShieldedTokenDerivedNonce" prefix Derived_; +import "../../access/AccessControl" prefix AccessControl_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; + +/** + * @description The role that authorizes minting. + */ +export pure circuit MINTER_ROLE(): Bytes<32> { + return pad(32, "NativeShieldedToken.MINTER_ROLE"); +} + +/** + * @description The role that authorizes burning. + */ +export pure circuit BURNER_ROLE(): Bytes<32> { + return pad(32, "NativeShieldedToken.BURNER_ROLE"); +} + +/** + * @description Initializes metadata, the nonce chain, and grants `admin` the + * default-admin, minter, and burner roles. + * + * @param {Bytes<32>} domainSep - Domain separator fixing this token's color. + * @param {Bytes<32>} initNonce - Unpredictable seed for the nonce chain. + * @param {Opaque<"string">} name_ - Token name. + * @param {Opaque<"string">} symbol_ - Token symbol. + * @param {Uint<8>} decimals_ - Display decimals. + * @param {Either, ContractAddress>} admin - Account granted the + * admin, minter, and burner roles (a user account key). + */ +constructor( + domainSep: Bytes<32>, + initNonce: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8>, + admin: Either, ContractAddress> +) { + NativeShieldedToken_initialize(domainSep, name_, symbol_, decimals_); + Derived_initialize(initNonce); + + AccessControl__grantRole(AccessControl_DEFAULT_ADMIN_ROLE(), admin); + AccessControl__grantRole(MINTER_ROLE(), admin); + AccessControl__grantRole(BURNER_ROLE(), admin); +} + +export circuit name(): Opaque<"string"> { + return NativeShieldedToken_name(); +} + +export circuit symbol(): Opaque<"string"> { + return NativeShieldedToken_symbol(); +} + +export circuit decimals(): Uint<8> { + return NativeShieldedToken_decimals(); +} + +export circuit tokenColor(): Bytes<32> { + return NativeShieldedToken_tokenColor(); +} + +export circuit totalMinted(): Uint<128> { + return NativeShieldedToken_totalMinted(); +} + +export circuit totalBurned(): Uint<128> { + return NativeShieldedToken_totalBurned(); +} + +export circuit totalSupply(): Uint<128> { + return NativeShieldedToken_totalSupply(); +} + +export circuit DEFAULT_ADMIN_ROLE(): Bytes<32> { + return AccessControl_DEFAULT_ADMIN_ROLE(); +} + +export circuit hasRole(roleId: Bytes<32>, account: Either, ContractAddress>): Boolean { + return AccessControl_hasRole(roleId, account); +} + +export circuit grantRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { + AccessControl_grantRole(roleId, account); +} + +export circuit revokeRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { + AccessControl_revokeRole(roleId, account); +} + +export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either, ContractAddress>): [] { + AccessControl_renounceRole(roleId, callerConfirmation); +} + +/** + * @description Mints `amount` to `recipient` with an internally derived + * nonce (recipient-public). Requires `MINTER_ROLE`. The returned coin info + * is the only copy available to the recipient; deliver it out of band. + */ +export circuit mint( + recipient: Either, + amount: Uint<64> +): ShieldedCoinInfo { + AccessControl_assertOnlyRole(MINTER_ROLE()); + return NativeShieldedToken__mint(recipient, amount, Derived__deriveNonce()); +} + +/** + * @description Burns `amount` from a coin provided within the current + * transaction, refunding any change to `refundTo`. Requires `BURNER_ROLE`. + */ +export circuit burn( + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either +): Maybe { + AccessControl_assertOnlyRole(BURNER_ROLE()); + return NativeShieldedToken__burn(coin, amount, refundTo); +} + +/** + * @description Burns `amount` from a coin the contract already holds. + * Requires `BURNER_ROLE`. Returns the change retained by the contract. + */ +export circuit burnFromContract( + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> +): Maybe { + AccessControl_assertOnlyRole(BURNER_ROLE()); + return NativeShieldedToken__burnFromContract(coin, amount); +} diff --git a/contracts/src/token/presets/NativeShieldedTokenFamilyAccessControl.compact b/contracts/src/token/presets/NativeShieldedTokenFamilyAccessControl.compact new file mode 100644 index 000000000..2ee626bfd --- /dev/null +++ b/contracts/src/token/presets/NativeShieldedTokenFamilyAccessControl.compact @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/presets/NativeShieldedTokenFamilyAccessControl.compact) + +pragma language_version >= 0.21.0; + +/** + * @description Ready-to-deploy native shielded token family, role-gated. + * + * Composes: + * - `NativeShieldedTokenFamily`: many token types per contract, keyed by a + * per-call `domain`; shared family metadata; per-domain supply. + * - `NativeShieldedTokenDerivedNonce`: one nonce chain serving all domains, + * so `mint` needs no caller-supplied nonce. This makes mints + * recipient-public; for recipient privacy call the base `_mint` with a + * secret nonce instead. + * - `AccessControl`: `MINTER_ROLE` authorizes minting, `BURNER_ROLE` + * authorizes burning, `DEFAULT_ADMIN_ROLE` administers both. + * + * # Authorization model + * + * Roles are contract-wide: a `MINTER_ROLE` holder may mint any domain. + * Issuers needing per-domain roles should compose the base module with their + * own scheme rather than use this preset. + * + * # Initialization + * + * `AccessControl` does not use `Initializable`, so there is no shared-flag + * conflict with the base module: the constructor calls the base `initialize` + * (which owns the flag), seeds the nonce chain, then grants the admin all + * three roles via the internal `_grantRole`. + */ +import CompactStandardLibrary; + +import "../NativeShieldedTokenFamily" prefix NativeShieldedTokenFamily_; +import "../extensions/NativeShieldedTokenDerivedNonce" prefix Derived_; +import "../../access/AccessControl" prefix AccessControl_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; + +/** + * @description The role that authorizes minting any domain. + */ +export pure circuit MINTER_ROLE(): Bytes<32> { + return pad(32, "NativeShieldedTokenFamily.MINTER_ROLE"); +} + +/** + * @description The role that authorizes burning any domain. + */ +export pure circuit BURNER_ROLE(): Bytes<32> { + return pad(32, "NativeShieldedTokenFamily.BURNER_ROLE"); +} + +/** + * @description Initializes family metadata, the nonce chain, and grants + * `admin` the default-admin, minter, and burner roles. + * + * @param {Bytes<32>} initNonce - Unpredictable seed for the nonce chain. + * @param {Opaque<"string">} name_ - Family name shared by all token types. + * @param {Opaque<"string">} symbol_ - Family symbol shared by all token types. + * @param {Uint<8>} decimals_ - Family-wide display decimals. + * @param {Either, ContractAddress>} admin - Account granted the + * admin, minter, and burner roles (a user account key). + */ +constructor( + initNonce: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8>, + admin: Either, ContractAddress> +) { + NativeShieldedTokenFamily_initialize(name_, symbol_, decimals_); + Derived_initialize(initNonce); + + AccessControl__grantRole(AccessControl_DEFAULT_ADMIN_ROLE(), admin); + AccessControl__grantRole(MINTER_ROLE(), admin); + AccessControl__grantRole(BURNER_ROLE(), admin); +} + +export circuit name(): Opaque<"string"> { + return NativeShieldedTokenFamily_name(); +} + +export circuit symbol(): Opaque<"string"> { + return NativeShieldedTokenFamily_symbol(); +} + +export circuit decimals(): Uint<8> { + return NativeShieldedTokenFamily_decimals(); +} + +export circuit tokenColor(domain: Bytes<32>): Bytes<32> { + return NativeShieldedTokenFamily_tokenColor(domain); +} + +export circuit totalMinted(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalMinted(domain); +} + +export circuit totalBurned(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalBurned(domain); +} + +export circuit totalSupply(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalSupply(domain); +} + +export circuit DEFAULT_ADMIN_ROLE(): Bytes<32> { + return AccessControl_DEFAULT_ADMIN_ROLE(); +} + +export circuit hasRole(roleId: Bytes<32>, account: Either, ContractAddress>): Boolean { + return AccessControl_hasRole(roleId, account); +} + +export circuit grantRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { + AccessControl_grantRole(roleId, account); +} + +export circuit revokeRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { + AccessControl_revokeRole(roleId, account); +} + +export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either, ContractAddress>): [] { + AccessControl_renounceRole(roleId, callerConfirmation); +} + +/** + * @description Mints `amount` of `domain`'s token to `recipient` with an + * internally derived nonce (recipient-public). Requires `MINTER_ROLE`. The + * returned coin info is the only copy available to the recipient; deliver it + * out of band. + */ +export circuit mint( + domain: Bytes<32>, + recipient: Either, + amount: Uint<64> +): ShieldedCoinInfo { + AccessControl_assertOnlyRole(MINTER_ROLE()); + return NativeShieldedTokenFamily__mint(domain, recipient, amount, Derived__deriveNonce()); +} + +/** + * @description Burns `amount` of `domain`'s token from a coin provided within + * the current transaction, refunding any change to `refundTo`. Requires + * `BURNER_ROLE`. + */ +export circuit burn( + domain: Bytes<32>, + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either +): Maybe { + AccessControl_assertOnlyRole(BURNER_ROLE()); + return NativeShieldedTokenFamily__burn(domain, coin, amount, refundTo); +} + +/** + * @description Burns `amount` of `domain`'s token from a coin the contract + * already holds. Requires `BURNER_ROLE`. Returns the change retained by the + * contract. + */ +export circuit burnFromContract( + domain: Bytes<32>, + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> +): Maybe { + AccessControl_assertOnlyRole(BURNER_ROLE()); + return NativeShieldedTokenFamily__burnFromContract(domain, coin, amount); +} diff --git a/contracts/src/token/presets/NativeShieldedTokenFamilyOwnable.compact b/contracts/src/token/presets/NativeShieldedTokenFamilyOwnable.compact new file mode 100644 index 000000000..9ffc1a8da --- /dev/null +++ b/contracts/src/token/presets/NativeShieldedTokenFamilyOwnable.compact @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/presets/NativeShieldedTokenFamilyOwnable.compact) + +pragma language_version >= 0.21.0; + +/** + * @description Ready-to-deploy native shielded token family, owner-gated. + * + * Composes: + * - `NativeShieldedTokenFamily`: many token types per contract, keyed by a + * per-call `domain`; shared family metadata; per-domain supply. + * - `NativeShieldedTokenDerivedNonce`: one nonce chain serving all domains, + * so `mint` needs no caller-supplied nonce. This makes mints + * recipient-public; for recipient privacy call the base `_mint` with a + * secret nonce instead. + * - `Ownable`: a single owner authorized to mint and burn across all domains. + * + * # Authorization model + * + * The owner controls every domain. Issuers needing per-domain authorization + * should compose the base module with their own access scheme rather than + * use this preset. + * + * # Initialization + * + * The base module owns the shared `Initializable` flag, so the owner is set + * via Ownable's `_unsafeUncheckedTransferOwnership` (guarded by the same + * checks as `Ownable_initialize`) rather than `Ownable_initialize`. The + * derived-nonce chain uses its own seed guard, not `Initializable`. + */ +import CompactStandardLibrary; + +import "../NativeShieldedTokenFamily" prefix NativeShieldedTokenFamily_; +import "../extensions/NativeShieldedTokenDerivedNonce" prefix Derived_; +import "../../access/Ownable" prefix Ownable_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; + +/** + * @description Initializes family metadata, the nonce chain, and the owner. + * + * @param {Bytes<32>} initNonce - Unpredictable seed for the nonce chain. + * @param {Opaque<"string">} name_ - Family name shared by all token types. + * @param {Opaque<"string">} symbol_ - Family symbol shared by all token types. + * @param {Uint<8>} decimals_ - Family-wide display decimals. + * @param {Either, ContractAddress>} initialOwner - Initial owner + * (a user account key; contract owners are unsupported until C2C lands). + */ +constructor( + initNonce: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8>, + initialOwner: Either, ContractAddress> +) { + NativeShieldedTokenFamily_initialize(name_, symbol_, decimals_); + Derived_initialize(initNonce); + + assert(initialOwner.is_left, "NativeShieldedTokenFamilyOwnable: owner must be a user key"); + assert(!(initialOwner.left == default>), "NativeShieldedTokenFamilyOwnable: invalid initial owner"); + Ownable__unsafeUncheckedTransferOwnership(initialOwner); +} + +export circuit name(): Opaque<"string"> { + return NativeShieldedTokenFamily_name(); +} + +export circuit symbol(): Opaque<"string"> { + return NativeShieldedTokenFamily_symbol(); +} + +export circuit decimals(): Uint<8> { + return NativeShieldedTokenFamily_decimals(); +} + +export circuit tokenColor(domain: Bytes<32>): Bytes<32> { + return NativeShieldedTokenFamily_tokenColor(domain); +} + +export circuit totalMinted(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalMinted(domain); +} + +export circuit totalBurned(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalBurned(domain); +} + +export circuit totalSupply(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalSupply(domain); +} + +export circuit owner(): Either, ContractAddress> { + return Ownable_owner(); +} + +export circuit transferOwnership(newOwner: Either, ContractAddress>): [] { + Ownable_transferOwnership(newOwner); +} + +export circuit renounceOwnership(): [] { + Ownable_renounceOwnership(); +} + +/** + * @description Mints `amount` of `domain`'s token to `recipient` with an + * internally derived nonce (recipient-public). Owner only. The returned coin + * info is the only copy available to the recipient; deliver it out of band. + */ +export circuit mint( + domain: Bytes<32>, + recipient: Either, + amount: Uint<64> +): ShieldedCoinInfo { + Ownable_assertOnlyOwner(); + return NativeShieldedTokenFamily__mint(domain, recipient, amount, Derived__deriveNonce()); +} + +/** + * @description Burns `amount` of `domain`'s token from a coin provided within + * the current transaction, refunding any change to `refundTo`. Owner only. + */ +export circuit burn( + domain: Bytes<32>, + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either +): Maybe { + Ownable_assertOnlyOwner(); + return NativeShieldedTokenFamily__burn(domain, coin, amount, refundTo); +} + +/** + * @description Burns `amount` of `domain`'s token from a coin the contract + * already holds. Owner only. Returns the change retained by the contract. + */ +export circuit burnFromContract( + domain: Bytes<32>, + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> +): Maybe { + Ownable_assertOnlyOwner(); + return NativeShieldedTokenFamily__burnFromContract(domain, coin, amount); +} diff --git a/contracts/src/token/presets/NativeShieldedTokenOwnable.compact b/contracts/src/token/presets/NativeShieldedTokenOwnable.compact new file mode 100644 index 000000000..454a2c0af --- /dev/null +++ b/contracts/src/token/presets/NativeShieldedTokenOwnable.compact @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/presets/NativeShieldedTokenOwnable.compact) + +pragma language_version >= 0.21.0; + +/** + * @description Ready-to-deploy single native shielded token, owner-gated. + * + * Composes: + * - `NativeShieldedToken` (Fungible profile): metadata, mint, burn, supply. + * - `NativeShieldedTokenDerivedNonce`: derives coin nonces so `mint` needs no + * caller-supplied nonce. This makes mints recipient-public; for a + * recipient-private issuer, call the base module's `_mint` with a secret + * nonce instead of using this preset. + * - `Ownable`: a single owner authorized to mint and burn. + * + * # Initialization + * + * The base module owns the shared `Initializable` flag (its `initialize` + * calls `Initializable_initialize`). `Ownable_initialize` would call it a + * second time and revert, so the owner is set here via Ownable's internal + * `_unsafeUncheckedTransferOwnership`, guarded by the same not-zero / + * not-contract checks `Ownable_initialize` performs. The derived-nonce chain + * uses its own seed guard, not `Initializable`, so it composes freely. + * + * # Authorization + * + * `mint`, `burn`, and `burnFromContract` are gated by `assertOnlyOwner`. + * Caller identity is derived from the `wit_OwnableSK` witness; the deployer + * provides it like any Ownable consumer. + */ +import CompactStandardLibrary; + +import "../NativeShieldedToken" prefix NativeShieldedToken_; +import "../extensions/NativeShieldedTokenDerivedNonce" prefix Derived_; +import "../../access/Ownable" prefix Ownable_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; + +/** + * @description Initializes metadata, the nonce chain, and the owner. + * + * @param {Bytes<32>} domainSep - Domain separator fixing this token's color. + * @param {Bytes<32>} initNonce - Unpredictable seed for the nonce chain. + * @param {Opaque<"string">} name_ - Token name. + * @param {Opaque<"string">} symbol_ - Token symbol. + * @param {Uint<8>} decimals_ - Display decimals. + * @param {Either, ContractAddress>} initialOwner - Initial owner + * (a user account key; contract owners are unsupported until C2C lands). + */ +constructor( + domainSep: Bytes<32>, + initNonce: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8>, + initialOwner: Either, ContractAddress> +) { + NativeShieldedToken_initialize(domainSep, name_, symbol_, decimals_); + Derived_initialize(initNonce); + + assert(initialOwner.is_left, "NativeShieldedTokenOwnable: owner must be a user key"); + assert(!(initialOwner.left == default>), "NativeShieldedTokenOwnable: invalid initial owner"); + Ownable__unsafeUncheckedTransferOwnership(initialOwner); +} + +export circuit name(): Opaque<"string"> { + return NativeShieldedToken_name(); +} + +export circuit symbol(): Opaque<"string"> { + return NativeShieldedToken_symbol(); +} + +export circuit decimals(): Uint<8> { + return NativeShieldedToken_decimals(); +} + +export circuit tokenColor(): Bytes<32> { + return NativeShieldedToken_tokenColor(); +} + +export circuit totalMinted(): Uint<128> { + return NativeShieldedToken_totalMinted(); +} + +export circuit totalBurned(): Uint<128> { + return NativeShieldedToken_totalBurned(); +} + +export circuit totalSupply(): Uint<128> { + return NativeShieldedToken_totalSupply(); +} + +export circuit owner(): Either, ContractAddress> { + return Ownable_owner(); +} + +export circuit transferOwnership(newOwner: Either, ContractAddress>): [] { + Ownable_transferOwnership(newOwner); +} + +export circuit renounceOwnership(): [] { + Ownable_renounceOwnership(); +} + +/** + * @description Mints `amount` to `recipient` with an internally derived + * nonce (recipient-public). Owner only. The returned coin info is the only + * copy available to the recipient; deliver it out of band. + */ +export circuit mint( + recipient: Either, + amount: Uint<64> +): ShieldedCoinInfo { + Ownable_assertOnlyOwner(); + return NativeShieldedToken__mint(recipient, amount, Derived__deriveNonce()); +} + +/** + * @description Burns `amount` from a coin provided within the current + * transaction, refunding any change to `refundTo`. Owner only. + */ +export circuit burn( + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either +): Maybe { + Ownable_assertOnlyOwner(); + return NativeShieldedToken__burn(coin, amount, refundTo); +} + +/** + * @description Burns `amount` from a coin the contract already holds. Owner + * only. Returns the change retained by the contract. + */ +export circuit burnFromContract( + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> +): Maybe { + Ownable_assertOnlyOwner(); + return NativeShieldedToken__burnFromContract(coin, amount); +} diff --git a/contracts/src/token/test/mocks/MockNativeShieldedToken.compact b/contracts/src/token/test/mocks/MockNativeShieldedToken.compact new file mode 100644 index 000000000..ff8cdd9c1 --- /dev/null +++ b/contracts/src/token/test/mocks/MockNativeShieldedToken.compact @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../../NativeShieldedToken" prefix NativeShieldedToken_; +import "../../extensions/NativeShieldedTokenDerivedNonce" prefix NativeShieldedTokenDerivedNonce_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; + +constructor( + domainSep: Bytes<32>, + initNonce: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8> +) { + NativeShieldedToken_initialize(domainSep, name_, symbol_, decimals_); + NativeShieldedTokenDerivedNonce_initialize(initNonce); +} + +export circuit name(): Opaque<"string"> { + return NativeShieldedToken_name(); +} + +export circuit symbol(): Opaque<"string"> { + return NativeShieldedToken_symbol(); +} + +export circuit decimals(): Uint<8> { + return NativeShieldedToken_decimals(); +} + +export circuit tokenColor(): Bytes<32> { + return NativeShieldedToken_tokenColor(); +} + +export circuit totalMinted(): Uint<128> { + return NativeShieldedToken_totalMinted(); +} + +export circuit totalBurned(): Uint<128> { + return NativeShieldedToken_totalBurned(); +} + +export circuit totalSupply(): Uint<128> { + return NativeShieldedToken_totalSupply(); +} + +export circuit _mint( + recipient: Either, + amount: Uint<64>, + nonce: Bytes<32> +): ShieldedCoinInfo { + return NativeShieldedToken__mint(recipient, amount, nonce); +} + +export circuit _deriveNonce(): Bytes<32> { + return NativeShieldedTokenDerivedNonce__deriveNonce(); +} + +// Demonstrates the documented composition: base _mint with the extension's +// _deriveNonce building block as the nonce source. +export circuit _mintWithDerivedNonce( + recipient: Either, + amount: Uint<64> +): ShieldedCoinInfo { + return NativeShieldedToken__mint(recipient, amount, NativeShieldedTokenDerivedNonce__deriveNonce()); +} + +export circuit _burn( + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either +): Maybe { + return NativeShieldedToken__burn(coin, amount, refundTo); +} + +export circuit _burnFromContract( + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> +): Maybe { + return NativeShieldedToken__burnFromContract(coin, amount); +} diff --git a/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact b/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact new file mode 100644 index 000000000..2ea9d653a --- /dev/null +++ b/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../../NativeShieldedTokenFamily" prefix NativeShieldedTokenFamily_; +import "../../extensions/NativeShieldedTokenDerivedNonce" prefix NativeShieldedTokenDerivedNonce_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; + +constructor( + initNonce: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8> +) { + NativeShieldedTokenFamily_initialize(name_, symbol_, decimals_); + NativeShieldedTokenDerivedNonce_initialize(initNonce); +} + +export circuit name(): Opaque<"string"> { + return NativeShieldedTokenFamily_name(); +} + +export circuit symbol(): Opaque<"string"> { + return NativeShieldedTokenFamily_symbol(); +} + +export circuit decimals(): Uint<8> { + return NativeShieldedTokenFamily_decimals(); +} + +export circuit tokenColor(domain: Bytes<32>): Bytes<32> { + return NativeShieldedTokenFamily_tokenColor(domain); +} + +export circuit totalMinted(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalMinted(domain); +} + +export circuit totalBurned(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalBurned(domain); +} + +export circuit totalSupply(domain: Bytes<32>): Uint<128> { + return NativeShieldedTokenFamily_totalSupply(domain); +} + +export circuit _mint( + domain: Bytes<32>, + recipient: Either, + amount: Uint<64>, + nonce: Bytes<32> +): ShieldedCoinInfo { + return NativeShieldedTokenFamily__mint(domain, recipient, amount, nonce); +} + +export circuit _deriveNonce(): Bytes<32> { + return NativeShieldedTokenDerivedNonce__deriveNonce(); +} + +// Demonstrates the documented composition: base _mint with the extension's +// _deriveNonce building block as the nonce source. +export circuit _mintWithDerivedNonce( + domain: Bytes<32>, + recipient: Either, + amount: Uint<64> +): ShieldedCoinInfo { + return NativeShieldedTokenFamily__mint(domain, recipient, amount, NativeShieldedTokenDerivedNonce__deriveNonce()); +} + +export circuit _burn( + domain: Bytes<32>, + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either +): Maybe { + return NativeShieldedTokenFamily__burn(domain, coin, amount, refundTo); +} + +export circuit _burnFromContract( + domain: Bytes<32>, + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> +): Maybe { + return NativeShieldedTokenFamily__burnFromContract(domain, coin, amount); +} diff --git a/mip-xxxx-native-shielded-token.md b/mip-xxxx-native-shielded-token.md new file mode 100644 index 000000000..9cf3e54be --- /dev/null +++ b/mip-xxxx-native-shielded-token.md @@ -0,0 +1,593 @@ +--- +MIP: XXXX +Title: Native Shielded Token Standard +Authors: Iskander Andrews @0xisk (OpenZeppelin) +Reviewers: Andrew Fleming @andrew-fleming (OpenZeppelin), Pepe Blasco @pepebndc (OpenZeppelin) +Status: Draft +Category: Standards +Created: 2026-06-10 +Requires: none +Replaces: none +License: Apache-2.0 +--- + + + +## Abstract + +This MIP defines a standard contract interface for native shielded tokens on Midnight. +A native shielded token exists only as [Zswap](https://docs.midnight.network/concepts/zswap) shielded [UTXOs](https://docs.midnight.network/concepts/utxo), not as a balance in contract ledger state. +The issuing contract is not a balance keeper. +Once a coin is minted, it moves wallet-to-wallet at the protocol level with no contract involvement. + +The contract is responsible for four things, and this standard specifies all of them: + +- token metadata (`name`, `symbol`, `decimals`, `tokenColor`), +- issuance (`_mint`, with an optional derived-nonce extension), +- destruction (`_burn`, `_burnFromContract`), +- supply accounting (`totalMinted`, `totalBurned`, and an upper-bound `totalSupply`). + +The interface supports multiple token types per contract through a per-call domain separator, +separates recipient-public from recipient-private minting, +and requires the correct Zswap spend path for each burn: +transient spends for coins provided within the transaction, Merkle-tree spends for contract-held coins. + +A reference implementation ships as the `NativeShieldedToken` module in the [OpenZeppelin Compact Contracts library](https://github.com/OpenZeppelin/compact-contracts). +This standard complements [MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md), which standardizes account-based tokens with UTXO conversion. + +## Motivation + +Native shielded coins are the asset the Midnight protocol operates on directly. +They take part in [Zswap atomic swaps](https://docs.midnight.network/concepts/zswap), transfer peer-to-peer with no contract call, and hide value, sender, and receiver by construction. +That makes them the natural representation for privacy-first assets: phase-one RWA issuance, liquidity-pool share tokens, and confidential payment instruments. + +There is no standard for issuing them. +Every project that mints native shielded tokens rebuilds the same contract surface, and the underlying protocol primitives have several non-obvious failure modes that have already appeared in ecosystem drafts: + +- **Wrong spend path.** A coin received within the current transaction is not yet in the global Zswap commitment tree. + Spending it requires the transient path (`sendImmediateShielded`), not a Merkle-proof spend (`sendShielded`). + Conflating the two produces circuits that cannot be satisfied, or that trust a caller-supplied Merkle index. +- **Lost coins.** Contract-initiated sends create no coin ciphertext, so recipient wallets cannot find minted or refunded coins by scanning the chain. + An interface that discards the protocol's returned coin info strands value. +- **Dishonest supply.** Holders can destroy coins without touching the contract, by sending them to the burn address or submitting an imbalanced Zswap offer. + A contract-tracked "total supply" therefore over-reports. + Standards that present it as exact mislead indexers and integrators. +- **Commitment collisions.** Nonces derived from public ledger state are predictable. + Without care, caller-supplied and contract-derived nonces share one namespace and can be made to collide, which causes mint transactions to be rejected. + +Existing standards do not cover this asset class. +The [OpenZeppelin FungibleToken](https://github.com/OpenZeppelin/compact-contracts/blob/main/contracts/src/token/FungibleToken.compact) is account-based. +[MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md) extends it with conversions between map balances and UTXOs, but the account model stays the source of truth. +For tokens that should exist only in native shielded form, no interface exists. +This MIP fills that gap with a minimal mint/burn standard that encodes the correct protocol usage and states its privacy and accounting guarantees plainly. + +## Specification + +### Terminology + +- **Native shielded token**: a class of Zswap coins sharing one **color**, minted by a contract. + Managed by the Midnight protocol layer, not by contract ledger state. +- **Color (token type)**: `tokenType(domain, contractAddress)` per the [Compact Standard Library](https://docs.midnight.network/compact). + Only the contract at `contractAddress` can ever mint coins of its colors. +- **Domain separator (`domain`)**: a 32-byte value that, together with the contract address, identifies one token type. + A single contract MAY issue multiple token types by using multiple domains. +- **Same-tx coin**: a coin whose commitment is created by an output of the current transaction (for example, a user's wallet pays the contract). + It is not yet in the global commitment tree and MUST be spent via the transient path (`sendImmediateShielded`). +- **Contract-held coin**: a coin owned by the contract with a commitment already in the global Zswap commitment tree, identified by a `QualifiedShieldedCoinInfo` carrying a valid `mt_index`. +- **Burn address**: the all-zero `ZswapCoinPublicKey` returned by `shieldedBurnAddress()`, for which no secret key is known. + Coins sent there are unspendable. + +### Conformance Profiles + +The standard defines two profiles. + +- **Fungible profile** (reference module `NativeShieldedToken`): one token type per contract, the ERC-20-shaped common case. + The domain separator is fixed at construction as `sealed ledger _domain` and is not a circuit parameter, which removes caller-supplied domain misuse. + Supply totals are scalar. +- **Family profile** (reference module `NativeShieldedTokenFamily`): many token types per contract, selected by a per-call `domain` parameter. + This profile exists because of Midnight's composition model. + A contract cannot call or deploy another contract, so a multi-asset protocol (for example, a DEX minting one liquidity-share token per pair) cannot deploy one token contract per asset. + It must issue its whole token family from a single contract. + Supply totals are per-domain maps. + +Both profiles carry the same metadata interface (`name`, `symbol`, `decimals`). +In the Family profile these are family metadata shared by all token types, following the Uniswap-V2 LP precedent: every pair's LP token carries the same name, symbol, and decimals. +Per-type identity belongs in the consumer's own state, such as a pair registry mapping a domain to its underlying tokens. + +The sections below are written for the Family profile, with an explicit `domain` parameter. +The Fungible profile is the same standard with every `domain` parameter removed: the stored `_domain` is used instead, and `totalMinted(domain)` reads as the scalar `totalMinted()`. +All issuance, burn, nonce, metadata, and supply-bound rules are identical across the two profiles. +Neither profile provides balances, operator approvals, or batch transfers (see [Out of Scope](#out-of-scope)). + +### Required State + +Family profile (names per the reference implementation): + +```typescript +export ledger _totalMinted: Map, Uint<128>>; +export ledger _totalBurned: Map, Uint<128>>; + +export sealed ledger _name: Opaque<"string">; +export sealed ledger _symbol: Opaque<"string">; +export sealed ledger _decimals: Uint<8>; +``` + +Fungible profile: + +```typescript +export sealed ledger _domain: Bytes<32>; +export ledger _totalMinted: Uint<128>; +export ledger _totalBurned: Uint<128>; + +export sealed ledger _name: Opaque<"string">; +export sealed ledger _symbol: Opaque<"string">; +export sealed ledger _decimals: Uint<8>; +``` + +- `_totalMinted` and `_totalBurned` hold supply accounting, per domain in the Family profile. + See [Supply Accounting](#supply-accounting). +- Sealed fields are immutable after construction. + In the Fungible profile, the sealed `_domain` write forces token setup into the constructor, by the sealed-write rule. +- All Compact ledger state is public on-chain regardless of `export`. + Omitting `export` does not hide a field. + +### Construction + +This standard does not prescribe an initialization mechanism. +How a contract sets up its state is an implementation concern; only the result is normative. + +`name`, `symbol`, and `decimals`, and the domain separator in the Fungible profile, MUST be set at construction and MUST be immutable thereafter. + +The reference implementation does this with an `initialize` module circuit, invoked once from the consuming contract's constructor. + +### Metadata Circuits + +```typescript +export circuit name(): Opaque<"string"> +export circuit symbol(): Opaque<"string"> +export circuit decimals(): Uint<8> + +// Family profile +export circuit tokenColor(domain: Bytes<32>): Bytes<32> +// Fungible profile +export circuit tokenColor(): Bytes<32> +``` + +- In the Family profile, `name`/`symbol`/`decimals` are family metadata shared by all token types (see [Conformance Profiles](#conformance-profiles)). + `decimals` applies family-wide; an issuer with heterogeneous decimals per token type MUST handle that in its own state. +- `decimals` is a display convention only. + The protocol operates on integer values. +- `tokenColor` MUST return `tokenType(domain, kernel.self())`, computed at call time. + It exists so integrators and future contract-to-contract callers never re-derive the color by hand. + Per the finding in [MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md), the color MUST NOT be precomputed in the constructor: `kernel.self()` resolves differently during constructor execution. + +### Supply Accounting + +```typescript +export circuit totalMinted(domain: Bytes<32>): Uint<128> +export circuit totalBurned(domain: Bytes<32>): Uint<128> +export circuit totalSupply(domain: Bytes<32>): Uint<128> +``` + +Exact circulating supply is not knowable for a native shielded token: coins can be destroyed without involving the contract (see the bypass paths below). +The standard tracks the strongest quantities it can and names them for what they are. + +- `totalMinted(domain)` is **exact**. + Color derivation guarantees every coin of this contract's colors comes from this contract's mint circuits, and all of them MUST increment it. +- `totalBurned(domain)` is a **lower bound**. + It counts only contract-mediated burns. +- `totalSupply(domain)` MUST equal `totalMinted(domain) - totalBurned(domain)`, so it is an **upper bound** on circulating supply. + +```math +\texttt{circulating}(d) \le \texttt{totalSupply}(d) = \texttt{totalMinted}(d) - \texttt{totalBurned}(d) +``` + +Two destruction paths bypass the contract: + +- **Burn-address sends.** A wallet transfers to `shieldedBurnAddress()`. + The amount stays inside a Pedersen commitment, hidden from everyone. +- **Protocol burns.** A Zswap offer with a positive value imbalance, with no contract call. + The amount is public in the value deltas but invisible to contract state. + +Who can know what ([verified empirically](https://github.com/0xisk/exploring-native-shielded-token-indexing)): + +- **The contract** sees only its own mints and burns. + Protocol-level activity never calls it, so no contract-side accounting can do better than these counters. +- **An indexer** can reconstruct exact totals for mints (`shieldedMints` effects), contract burns (disclosed transcript values), protocol burns (value deltas), and per-color pool value (negated delta sum). + It can tighten the bound to `totalSupply(domain)` minus protocol burns. +- **No one** can know the spendable share of the pool. + Burn-address coins stay in the pool, indistinguishable from live coins, so exact circulating supply is unknowable both on-chain and off. + +The counters disclose nothing new. +Mint amounts are already public at the protocol level, and a burn must `disclose` coin value and change regardless: the compiler forces it on every shielded receive and spend primitive. +The counters only standardize what an indexer can already reconstruct. + +Implementations MUST maintain the counters as follows. +Every mint of `amount` under `domain` adds `amount` to `_totalMinted[domain]`, reverting on `Uint<128>` overflow. +Every contract-mediated burn of `amount` adds `amount` to `_totalBurned[domain]`. +Burned can never exceed minted for the same domain, so the `totalSupply` difference cannot underflow. +Integrators SHOULD present `totalSupply` as an upper bound, not as exact circulating supply. + +### Mint Circuit + +```typescript +export circuit _mint( + domain: Bytes<32>, + recipient: Either, + amount: Uint<64>, + nonce: Bytes<32> +): ShieldedCoinInfo +``` + +1. MUST revert if `recipient` is the zero key or zero address. +2. MUST add `amount` to `_totalMinted[domain]`, reverting on overflow. +3. MUST call `mintShieldedToken(domain, amount, nonce, recipient)` and return the resulting `ShieldedCoinInfo`. +4. Contract-initiated outputs carry no coin ciphertext, so wallets cannot currently detect contract-minted coins by scanning the chain. + The returned coin info is the only copy available to the recipient. + Callers SHOULD deliver it to the recipient out of band. +5. The caller is responsible for nonce uniqueness. + Reusing a nonce for the same `(domain, value, recipient)` produces a duplicate commitment, which the ledger rejects. +6. With a secret, cryptographically random nonce, the commitment cannot be linked to a recipient. + This is the recipient-private mint. + For operator-driven flows, the commitment can be computed off-chain before submission. + +The `Uint<64>` amount cap is imposed by the ledger: contract shielded mints are recorded as a `Map<[u8; 32], u64>` in the transaction effects. +Larger issuance requires multiple mints. + +### Extension: Derived-Nonce Minting + +An OPTIONAL extension for an issuer that wants a mint requiring no caller-managed nonce. +It adds the nonce-chain state and one circuit: + +```typescript +export ledger _counter: Counter; +export ledger _nonce: Bytes<32>; + +export circuit _mintWithDerivedNonce( + domain: Bytes<32>, + recipient: Either, + amount: Uint<64> +): ShieldedCoinInfo +``` + +`_mintWithDerivedNonce` MUST behave exactly as `_mint` called with a nonce derived from contract state. +The derivation is not prescribed, but it MUST satisfy these properties: + +1. The chain MUST be seeded at construction, and the seed SHOULD be chosen unpredictably (for example, 32 random bytes). +2. Derived nonces MUST never repeat for the lifetime of the contract. +3. Derived nonces MUST be domain-separated from values an honest `_mint` caller could produce by reading public ledger state (for example, hashed under a fixed tag), so internal and caller nonces cannot collide by accident. +4. The derivation inputs are public ledger state, so the resulting commitment is recomputable by enumerating candidate recipient keys. + Implementations SHOULD document this circuit as recipient-public. + An issuer needing recipient privacy at mint time uses the base `_mint` with a secret nonce. + +The reference implementation (`extensions/NativeShieldedTokenDerivedNonce.compact`) evolves a counter-indexed chain and derives the coin nonce as `persistentHash([pad(32, "NativeShieldedToken:nonce"), chainValue])`. + +### Burn Circuits + +```typescript +export circuit _burn( + domain: Bytes<32>, + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either +): Maybe + +export circuit _burnFromContract( + domain: Bytes<32>, + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> +): Maybe +``` + +**Common behavior:** + +1. MUST revert unless `coin.color == tokenType(domain, kernel.self())`. + This check is the only thing that prevents a burn from destroying, and accounting for, a coin of the wrong token type. + The protocol-level receive does not validate color. +2. MUST revert if `amount > coin.value`. +3. MUST send `amount` to `shieldedBurnAddress()` and add `amount` to `_totalBurned[domain]`. + +**`_burn` (same-tx coin):** + +4. For a coin provided within the current transaction (for example, paid in by the caller's wallet). + MUST call `receiveShielded(coin)` and spend via `sendImmediateShielded`, the transient path. + The signature takes an unqualified `ShieldedCoinInfo` deliberately: a same-tx coin has no meaningful `mt_index`, and accepting one would let the caller supply an arbitrary value. +5. MUST revert if `refundTo` is the zero key or zero address. + The zero key is the burn address, so a zeroed `refundTo` would silently burn the change too. +6. If `amount < coin.value`, the change MUST be forwarded to `refundTo` via a second `sendImmediateShielded`, and the circuit MUST return `some(refundCoin)`, the actual coin info created for `refundTo`. + The caller SHOULD deliver it to `refundTo` out of band. + If `amount == coin.value`, the circuit returns `none`. + +**`_burnFromContract` (contract-held coin):** + +7. For a coin the contract already holds (valid `mt_index` in the global commitment tree). + MUST spend via `sendShielded`. + MUST NOT call `receiveShielded`: the coin is already owned, and claiming a receive would require a fresh output that does not exist. +8. Change from `sendShielded` is auto-received by the contract at the protocol level. + The circuit MUST return it (`Maybe`), and the consuming contract SHOULD persist it in its own ledger state. + The change replaces `coin` as the contract's holding, and its info is not otherwise recoverable. + +### Access Control + +This is an unrestricted module. +The mint and burn circuits are building blocks with no authorization of their own. +A consuming contract MUST gate all four behind an authorization mechanism, for example [Ownable or AccessControl from OpenZeppelin Compact Contracts](https://github.com/OpenZeppelin/compact-contracts), or the hash-based commitment pattern from [MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md). +As in MIP-0004, implementations MUST NOT authenticate callers with `ownPublicKey()`: it is a witness value supplied by the caller's frontend and is not bound to the proof. + +### Out of Scope + +`balanceOf`, `allowance`, transfer mediation, and post-issuance controls (pause, freeze) are not representable for native shielded tokens today. +Once a user holds a coin, the contract cannot observe or restrict its movement. +These depend on protocol capabilities under separate discussion ([MPS-0013](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0013-zswap-business-logic.md), [MPS-0021](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0021-phase2-contract-to-contract.md)) and are deferred to a future revision. + +Two companion MIPs complete the native-token family: the [Native Unshielded Token Standard](./mip-xxxx-native-unshielded-token.md), the transparent sibling of this standard with the same two profiles, and the [Native Token Conversion Extension](./mip-xxxx-native-shielded-token-conversion-extension.md), a stateless module that converts between the two representations by composing both base standards' Family profiles. +Dual-representation tokens MUST build on the Family profiles. +Each Fungible profile stores a load-bearing sealed `_domain` written by its `initialize`, but the shared `Initializable` flag allows only one `initialize` call per contract. +Compact ledger layouts are fixed at deploy, so an issuer that may ever need a transparent representation SHOULD deploy on the Family profiles with the extension compiled in, hardcoding one domain constant for a single-token product. +When both bases are composed in one contract, the consumer MUST call exactly one base's `initialize` and SHOULD expose metadata getters from that base only. + +## Rationale + +### Why a separate standard from MIP-0004? + +MIP-0004 anchors supply in an account-based map and treats UTXOs as a converted representation. +The contract stays the source of truth and `totalSupply` stays exact. +That is the right model when DeFi logic needs balances. +This standard covers the complementary case: assets that should exist only in native shielded form, where the account model adds state, circuits, and a public balance map for no benefit. +The two compose, because a MIP-0004 token's `shield` circuit and this standard's `_mint` use the same protocol primitive. +But their guarantees differ, notably supply exactness, and should not be conflated under one interface. + +### Why two profiles instead of one parameterized module? + +An earlier draft had only the multi-domain module and told single-token consumers to hardcode a domain in wrapper circuits. +That pushed safety onto the consumer: every single-token issuer had to re-implement the domain-hardcoding wrapper that the Fungible profile now provides once, audited. +This recovers MIP-0004's stored-domain safety at the library layer. +The Fungible profile also gets scalar supply cells instead of per-domain maps, which makes cheaper circuits for the common case. + +The profiles are one standard, not two. +All observable coin behavior is identical: nonce rules, spend paths, burn address, supply-bound semantics, and the metadata interface. +A minted coin carries no trace of which profile issued it. +The Ethereum precedent of separate standards (ERC-20 vs ERC-1155) does not apply, because those split over different transfer interfaces, and native tokens have no transfer interface at all: movement is protocol-level Zswap. +The Family profile is not an ERC-1155 analog. +It answers a Midnight-specific composability constraint, described next. + +### Why family metadata? + +The Family profile keeps contract-wide `name`/`symbol`/`decimals` rather than per-domain metadata, following the Uniswap-V2 LP precedent: every pair's LP token carries the same name, symbol, and decimals, and UIs build per-pair display from the pair registry. +A consumer's registry mapping `domain -> (token0, token1)` is strictly more informative than any stored per-domain string. +Per-domain metadata maps would duplicate it, at the cost of three maps, a setter circuit that must be gated and sequenced with domain creation, and an immutability story. +A consumer issuing a heterogeneous token family, where one shared brand really is dishonest, can add its own per-domain metadata in consumer state. +Metadata is plain ledger data with no protocol interaction. + +### Why does the Family profile use per-call `domain`? + +The ERC-20 pattern of one token per contract relies on deployment economics Midnight does not have. +On Ethereum, factories deploy a minimal-proxy clone per token cheaply, so single-asset contracts compose into multi-asset systems at the deployment layer. +On Midnight, one contract is one address with its own circuits and verifier keys, composition happens at compile time, and a contract cannot instantiate another. +A multi-asset protocol, such as a liquidity-pool contract minting one share token per pair, therefore cannot use the clone-factory pattern. +It must issue multiple colors from a single contract, which the protocol's color derivation `tokenType(domain, contractAddress)` supports natively. + +### Forward compatibility with contract-to-contract calls + +Contract-to-contract (C2C) calls do not change the choice between this standard and MIP-0004. +They upgrade both along their own axes. +C2C makes MIP-0004's deferred account-model circuits (`approve`, `transferFrom`) usable. +For native shielded tokens, C2C together with custom spend logic ([MPS-0013](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0013-zswap-business-logic.md), [MPS-0021](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0021-phase2-contract-to-contract.md)) is what unlocks phase-two transfer mediation and post-issuance controls. +Neither standard absorbs the other. +C2C also does not revive the clone-factory pattern: it adds cross-contract calls, not cheap contract instantiation, so the multi-domain motivation above is unaffected. + +This interface is designed to be C2C-ready without changes: + +- Recipients and refund targets are `Either` from day one, in both circuit signatures and supply-map keys. +- `_burnFromContract` already implements the spend path a contract holder of these tokens needs: a Merkle-tree spend of a held coin, with change auto-retained. +- `tokenColor(domain)` lets a C2C caller query the color instead of re-deriving it. +- Fixing the ledger layout now, including the supply maps, means phase-two circuits can be added to a deployed token later through a CMA verifier-key rotation with no ledger-state migration, the only kind of upgrade the CMA supports. + This mirrors the migration plan documented in the OpenZeppelin `FungibleToken` module. + +### Why one mint primitive plus an extension? + +The core `_mint` matches the protocol primitive one to one: the caller supplies the nonce, owns its uniqueness, and gets recipient privacy with a secret uniform nonce. +Derived-nonce minting is convenience on top, and it carries a real trade-off. +It needs nothing from the caller and cannot collide by accident, but every derivation input is public, so commitments are linkable to recipients by enumeration. +Keeping it a separately named, optional extension keeps the conforming core minimal and makes the privacy trade-off visible at the call site, instead of hiding two behaviors behind one circuit. + +### Why two burn variants? + +The Zswap spend path depends on where the coin lives. +A same-tx coin must be spent transiently. +A tree-resident coin must be spent with a Merkle proof. +The two take different input types (`ShieldedCoinInfo` vs `QualifiedShieldedCoinInfo`) and have different change semantics: forward to a refund target, or auto-retain in the contract. +One circuit cannot do both correctly. +An interface that accepts a `QualifiedShieldedCoinInfo` while internally receiving the coin trusts a caller-supplied `mt_index` it cannot use. + +### Why return the refund/change coin? + +Contract-initiated sends create no coin ciphertexts, so the only copy of a refund or change coin's info is the circuit's return value. +Discarding it, as early drafts did, strands value. +Returning `Maybe` makes the delivery obligation explicit and testable. + +### Why supply bounds instead of exact supply? + +The alternative is an exact-looking `totalSupply` counter, and it is strictly worse. +It reports the same number while implying a guarantee the protocol cannot provide, because out-of-band burns are invisible to contract state. +Naming the quantities `totalMinted`, `totalBurned`, and an upper-bound `totalSupply` gives indexers correct semantics. +Supply tracking is in the base standard rather than an optional extension because Compact ledger layouts are fixed at deployment: a consumer that deploys without it can never add it. +The counters also cost no privacy, because mint and burn disclosures are forced by the coin primitives, not by the supply state. +A counter-free burn fails to compile with the same disclosure errors (see [Supply Accounting](#supply-accounting)). + +### Why domain-separate the internal nonce chain? + +The evolved chain values are public. +If a coin nonce equaled the chain value, the most natural misuse of `_mint`, reading the public `_nonce` field and passing it back as the nonce, would collide with an internal mint. +Hashing chain values under a fixed tag (`"NativeShieldedToken:nonce"`) puts internal nonces in a namespace an honest caller will not produce. +It does not stop deliberate collision-griefing (see [Security Considerations](#security-considerations)); it removes the accidental case. + +### Naming + +"Native shielded token" follows the terminology split used across the ecosystem: native (protocol-level UTXO) vs contract-based (ledger-state balances), and shielded vs unshielded. + +**Alternatives considered:** + +- `ZswapToken`: protocol jargon, and Zswap also covers unshielded swap mechanics. +- `ShieldedToken`: ambiguous against shielded contract-based tokens, such as ShieldedAccessControl-style assets. +- `NativeToken`: ambiguous against unshielded native UTXOs. + +For the profiles, the short name `NativeShieldedToken` goes to the Fungible profile (the common case), and the multi-domain module is `NativeShieldedTokenFamily`. +Two suffixes were rejected. +`MultiToken` already means "ERC-1155 with `uri`" in the library, and this profile shares neither that metadata model nor any transfer semantics. +The plural `NativeShieldedTokens` is one letter from the sibling module, a misread and mistype hazard at every import and call site. +"Family" also matches the profile's metadata concept. +"Fungible" is deliberately kept out of the module names: a `NativeFungibleShieldedToken` would invite confusion with the account-based `FungibleToken` module. + +## Path to Active + +### Acceptance Criteria + +- Reference implementation merged into the [OpenZeppelin Compact Contracts library](https://github.com/OpenZeppelin/compact-contracts) with a full simulator-based test suite. +- At least one deployment on Midnight testnet exercising the full circuit surface (construction, both mint paths, both burns, supply getters), including the partial-burn refund path. +- A demonstrated wallet round-trip: mint, out-of-band coin delivery, wallet-to-wallet transfer, contract burn. +- Review and endorsement through the [MIP process](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0001-mip-process.md) workshops. +- A security audit of the reference implementation. + +### Implementation Plan + +1. Land the `NativeShieldedToken` module in OpenZeppelin Compact Contracts (rework of [PR #559](https://github.com/OpenZeppelin/compact-contracts/pull/559), tracking [issue #544](https://github.com/OpenZeppelin/compact-contracts/issues/544)). +2. Add simulator and Vitest coverage for all behaviors specified above, including the revert cases. +3. Provide a composed example (token plus Ownable/AccessControl gating) and DApp-side guidance for out-of-band coin delivery. +4. Deploy to testnet, then submit for formal MIP review. + +## Backwards Compatibility Assessment + +This MIP is purely additive. +It is a new contract standard that requires no protocol or network changes. +Every primitive it uses (`mintShieldedToken`, `receiveShielded`, `sendShielded`, `sendImmediateShielded`, `evolveNonce`, `tokenType`, `shieldedBurnAddress`) exists in the current Compact Standard Library. +It does not modify or conflict with [MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md): the two standards target different asset models and can coexist in one ecosystem, and in one contract where a hybrid design is wanted. +Tokens issued under this standard are ordinary Zswap coins and interoperate with existing wallets, Zswap atomic swaps, and DApps that handle `ShieldedCoinInfo`. + +The standard is also forward compatible with contract-to-contract calls. +Signatures accept `ContractAddress` recipients from day one, and the fixed ledger layout lets phase-two circuits be added to already-deployed tokens through a CMA verifier-key rotation with no state migration (see [Forward compatibility with contract-to-contract calls](#forward-compatibility-with-contract-to-contract-calls)). + +## Security Considerations + +### Unrestricted issuance + +The module-level circuits carry no authorization. +A consumer that exposes `_mint` ungated has an infinitely mintable token. +One that exposes `_burnFromContract` ungated lets anyone destroy treasury holdings. +Consumers MUST gate all mint and burn circuits ([Access Control](#access-control)) and MUST NOT use `ownPublicKey()` for caller verification. + +### Commitment collisions and mint denial-of-service + +Internally derived nonces (the Derived-Nonce Minting extension) are predictable from public state. +An actor with access to `_mint` can precompute a future internal nonce, pre-mint a coin with the same `(nonce, domain, value, recipient)` tuple, and make that specific future `_mintWithDerivedNonce` fail on duplicate-commitment rejection. +The namespace separation removes accidental collisions; this deliberate vector is mitigated operationally. +Gate both mint circuits, and prefer not to expose both for the same domain to different trust levels. +A failed mint is recoverable: any later mint with a different tuple advances the chain past the collision. + +### Recipient linkability of derived-nonce mints + +For `_mintWithDerivedNonce`, the coin commitment is recomputable from public state for any candidate recipient key, so mint recipients are effectively public. +The later spend of the coin stays unlinkable, because nullifier derivation needs the holder's secret key. +An issuer that needs recipient privacy at mint time MUST use `_mint` with a secret uniform nonce. +Declining to `export` the nonce ledger fields does not change this: ledger state is public on-chain regardless. + +### Coin delivery and value loss + +The `ShieldedCoinInfo` returned from a mint and the `Maybe` returned from a burn are the only copies of the corresponding coins' info available to recipients, because no ciphertexts are emitted for contract-initiated outputs. +A DApp integrating this standard SHOULD capture and deliver them; dropping them strands value irrecoverably. +Test suites SHOULD assert on returned coin info, not only on ledger state. + +### Wrong-color burns + +`receiveShielded` validates commitment presence, not color. +The mandated `coin.color == tokenType(domain, kernel.self())` assertion is the only barrier that stops a multi-domain contract from burning token A while accounting the burn against token B's supply, which would corrupt both domains' supply bounds. + +### Burn-address footguns + +`shieldedBurnAddress()` is the all-zero public key, which is also the default value of `ZswapCoinPublicKey`. +The mandated zero-checks on `recipient` (mint) and `refundTo` (burn) exist because a defaulted struct silently routes value to the burn address. + +### Supply interpretation + +`totalSupply` is an upper bound. +Integrators SHOULD present it as such, not as exact circulating supply. +The spec names `totalMinted` and `totalBurned` so UIs can disclose the bound semantics. +`totalMinted` is independently verifiable from the public `shieldedMints` effects, so indexers can flag non-conforming implementations. + +### No post-issuance control + +Once minted, coins are unconditionally transferable bearer instruments. +No pause, freeze, clawback, or transfer restriction is possible at this layer. +An issuer with compliance requirements (for example, a regulated stablecoin) should treat this standard as the phase-one primitive and track [MPS-0013](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0013-zswap-business-logic.md) and [MPS-0021](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0021-phase2-contract-to-contract.md) for custom spend logic. + +## Implementation + +### Components + +1. **New Compact modules.** [`NativeShieldedToken.compact` (Fungible profile) and `NativeShieldedTokenFamily.compact` (Family profile)](https://github.com/OpenZeppelin/compact-contracts/tree/main/contracts/src/token) in the OpenZeppelin Compact Contracts library: all state and circuits specified above, composed with the library's `Initializable` and `Utils` modules, plus the optional `extensions/NativeShieldedTokenDerivedNonce.compact` extension module. +2. **Mocks, simulators, and tests.** `MockNativeShieldedToken.compact` and `MockNativeShieldedTokenFamily.compact` exposing the module circuits, with TypeScript simulators and Vitest suites. +3. **No protocol changes required.** + +### Dependencies + +- [Compact Standard Library](https://docs.midnight.network/compact): `mintShieldedToken`, `receiveShielded`, `sendShielded`, `sendImmediateShielded`, `evolveNonce`, `tokenType`, `shieldedBurnAddress`, `Counter`, `ShieldedCoinInfo`, `QualifiedShieldedCoinInfo`, `Maybe`. +- [OpenZeppelin Compact Contracts](https://github.com/OpenZeppelin/compact-contracts): `Initializable` and `Utils` modules. +- Compact language version >= 0.21.0. + The reference implementation compiles against this toolchain. + +## Testing + +### Unit Tests + +- `initialize`: all circuits revert before initialization; double-initialize reverts; metadata getters return constructor values. +- `_mint`: returns coin info with `color == tokenColor(domain)` and the correct value; the coin nonce equals the caller's nonce; `_totalMinted[domain]` is incremented; revert on zero recipient; overflow guard; distinct domains accumulate independent supplies. +- `_mintWithDerivedNonce` (extension): identical accounting; `_counter` and `_nonce` evolve per the extension's properties; derived nonces never repeat. +- `_burn`: revert on wrong color, on `amount > coin.value`, and on zero `refundTo`; full burn returns `none`; partial burn returns `some(refund)` with `refund.value == coin.value - amount`; `_totalBurned[domain]` is incremented. +- `_burnFromContract`: revert on wrong color and on `amount > coin.value`; change returned and owned by the contract; no receive claim emitted. +- Supply getters: `totalSupply == totalMinted - totalBurned` after arbitrary mint/burn sequences; unknown domains return 0. + +### Integration Tests + +- Round-trip on network: mint to a user wallet, out-of-band delivery, user pays the coin into `_burn`, refund coin spendable by `refundTo`. +- Treasury flow: mint to `kernel.self()`, `_burnFromContract` partial burn, persisted change burnable again. +- Multi-domain isolation: mints and burns under domain A do not affect domain B's supply or color checks. +- Invariant fuzzing: for random operation sequences, `totalMinted` exact vs simulator-observed mints; `circulating <= totalSupply` after including contract-bypassing burns (direct-to-burn-address sends and imbalanced-offer protocol burns). + +## References (Optional) + +- [MIP-0001: Midnight Improvement Proposal Process](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0001-mip-process.md) +- [MIP-0004: Fungible Token Standard with UTXO Conversion Extensions](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md) +- [MPS-0013: zswap-business-logic](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0013-zswap-business-logic.md) +- [MPS-0021: contract-to-contract phase 2](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0021-phase2-contract-to-contract.md) +- [OpenZeppelin Compact Contracts — Repository](https://github.com/OpenZeppelin/compact-contracts) +- [OpenZeppelin Compact Contracts — Issue #544: Add Shielded Native Token standard](https://github.com/OpenZeppelin/compact-contracts/issues/544) +- [OpenZeppelin Compact Contracts — PR #559: Add shielded token](https://github.com/OpenZeppelin/compact-contracts/pull/559) +- [Native shielded token indexing study — empirical decode of mint/burn visibility and supply reconstruction](https://github.com/0xisk/exploring-native-shielded-token-indexing) +- [Midnight Zswap Documentation](https://docs.midnight.network/concepts/zswap) +- [Midnight UTXO Model Documentation](https://docs.midnight.network/concepts/utxo) +- [The Compact Language](https://docs.midnight.network/compact) + +## Acknowledgments + +This proposal builds on the OpenZeppelin Compact Contracts library and its archived shielded-token exploration, on the protocol behavior documented in the Midnight ledger specification, and on issuance patterns seen in ecosystem applications. +Thanks to the Midnight protocol and documentation teams, and to the authors of MIP-0004 for the groundwork on token standards and hash-based caller authentication. + +## Copyright Waiver + +All contributions (code and text) submitted in this MIP must be licensed under the Apache License, Version 2.0. +Submission requires agreement to the Midnight Foundation Contributor License Agreement, which includes the assignment of copyright for your contributions to the Foundation. From 5b003327710e05ede625e90cd47895d592500cd2 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 19 Jun 2026 14:11:02 +0200 Subject: [PATCH 02/17] fix(token): make native shielded token build The native shielded token module set did not compile and lagged the library's current conventions. This lands it cleanly: * extensions: shorten the derived-nonce domain tag to "NativeShieldedToken:nonce" so pad(32, ...) fits 32 bytes. The prior 37-byte tag was a hard compile error, and the shorter tag matches the MIP. * token: drop the Initializable import and track _isInitialized inline per module, following FungibleToken and the per-module init change in the library. The shared Initializable flag collapses across modules in one directory, which the inline flag avoids. * build: raise the language_version pragma to >= 0.23.0 across the native files to match the 0.31.0 toolchain. * remove the four presets; they are out of scope for now. * mip: re-justify the Family-profile requirement via converter composition and drop the stale shared-Initializable-flag rationale. --- .../src/token/NativeShieldedToken.compact | 70 +++++-- .../token/NativeShieldedTokenFamily.compact | 61 +++++-- .../NativeShieldedTokenDerivedNonce.compact | 6 +- .../NativeShieldedTokenAccessControl.compact | 166 ----------------- ...veShieldedTokenFamilyAccessControl.compact | 171 ------------------ .../NativeShieldedTokenFamilyOwnable.compact | 144 --------------- .../NativeShieldedTokenOwnable.compact | 144 --------------- .../mocks/MockNativeShieldedToken.compact | 2 +- .../MockNativeShieldedTokenFamily.compact | 2 +- mip-xxxx-native-shielded-token.md | 9 +- 10 files changed, 111 insertions(+), 664 deletions(-) delete mode 100644 contracts/src/token/presets/NativeShieldedTokenAccessControl.compact delete mode 100644 contracts/src/token/presets/NativeShieldedTokenFamilyAccessControl.compact delete mode 100644 contracts/src/token/presets/NativeShieldedTokenFamilyOwnable.compact delete mode 100644 contracts/src/token/presets/NativeShieldedTokenOwnable.compact diff --git a/contracts/src/token/NativeShieldedToken.compact b/contracts/src/token/NativeShieldedToken.compact index 30ac72042..6ddb16208 100644 --- a/contracts/src/token/NativeShieldedToken.compact +++ b/contracts/src/token/NativeShieldedToken.compact @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Compact Contracts v0.1.0 (token/NativeShieldedToken.compact) -pragma language_version >= 0.21.0; +pragma language_version >= 0.23.0; /** * @module NativeShieldedToken @@ -86,10 +86,11 @@ pragma language_version >= 0.21.0; * * - Dual-representation tokens (shielded + unshielded with conversion) MUST * build on the token-family modules and the `NativeTokenConverter` - * extension, not on this module: this module and `NativeUnshieldedToken` each store a - * load-bearing sealed `_domain` written by their `initialize`, but the - * shared `Initializable` flag allows only one `initialize` call per - * contract. + * extension, not on this module: this module stores a single load-bearing + * sealed `_domain` (one token type), and the converter composes both bases' + * Family profiles. (Initialization is tracked per-module inline now, so the + * former shared-`Initializable`-flag "one `initialize` per contract" limit + * no longer applies.) * * # Out of scope (phase two, pending contract-to-contract support) * @@ -100,9 +101,15 @@ pragma language_version >= 0.21.0; */ module NativeShieldedToken { import CompactStandardLibrary; - import "../security/Initializable" prefix Initializable_; import "../utils/Utils" prefix Utils_; + /** + * @description Initialization flag, tracked per-module to avoid the compiler's + * shared transitive-dependency state bug (LFDT-Minokawa/compact#270). See the + * Initializable module for rationale. + */ + export ledger _isInitialized: Boolean; + /** * @description Domain separator fixed at construction; with the contract * address it determines this token's color. @@ -140,13 +147,42 @@ module NativeShieldedToken { symbol_: Opaque<"string">, decimals_: Uint<8> ): [] { - Initializable_initialize(); + assertNotInitialized(); + _isInitialized = true; _domain = disclose(domainSep); _name = disclose(name_); _symbol = disclose(symbol_); _decimals = disclose(decimals_); } + /** + * @description Asserts that the contract has been initialized, throwing an + * error if not. + * + * Requirements: + * + * - Contract must be initialized. + * + * @return {[]} - Empty tuple. + */ + circuit assertInitialized(): [] { + assert(_isInitialized, "NativeShieldedToken: contract not initialized"); + } + + /** + * @description Asserts that the contract has not been initialized, throwing + * an error if it has. + * + * Requirements: + * + * - Contract must not be initialized. + * + * @return {[]} - Empty tuple. + */ + circuit assertNotInitialized(): [] { + assert(!_isInitialized, "NativeShieldedToken: contract already initialized"); + } + /** * @description Returns the token name. * @@ -159,7 +195,7 @@ module NativeShieldedToken { * @return {Opaque<"string">} - The token name. */ export circuit name(): Opaque<"string"> { - Initializable_assertInitialized(); + assertInitialized(); return _name; } @@ -175,7 +211,7 @@ module NativeShieldedToken { * @return {Opaque<"string">} - The token symbol. */ export circuit symbol(): Opaque<"string"> { - Initializable_assertInitialized(); + assertInitialized(); return _symbol; } @@ -193,7 +229,7 @@ module NativeShieldedToken { * @return {Uint<8>} - The decimals value. */ export circuit decimals(): Uint<8> { - Initializable_assertInitialized(); + assertInitialized(); return _decimals; } @@ -210,7 +246,7 @@ module NativeShieldedToken { * @return {Bytes<32>} - The coin color. */ export circuit tokenColor(): Bytes<32> { - Initializable_assertInitialized(); + assertInitialized(); return tokenType(_domain, kernel.self()); } @@ -226,7 +262,7 @@ module NativeShieldedToken { * @return {Uint<128>} - The total amount minted. */ export circuit totalMinted(): Uint<128> { - Initializable_assertInitialized(); + assertInitialized(); return _totalMinted; } @@ -244,7 +280,7 @@ module NativeShieldedToken { * @return {Uint<128>} - The total amount burned through this contract. */ export circuit totalBurned(): Uint<128> { - Initializable_assertInitialized(); + assertInitialized(); return _totalBurned; } @@ -263,7 +299,7 @@ module NativeShieldedToken { * @return {Uint<128>} - The upper bound on tokens in existence. */ export circuit totalSupply(): Uint<128> { - Initializable_assertInitialized(); + assertInitialized(); return (_totalMinted - _totalBurned) as Uint<128>; } @@ -300,7 +336,7 @@ module NativeShieldedToken { amount: Uint<64>, nonce: Bytes<32> ): ShieldedCoinInfo { - Initializable_assertInitialized(); + assertInitialized(); assert(!Utils_isKeyOrAddressZero(recipient), "NativeShieldedToken: invalid recipient"); _addMinted(amount); @@ -347,7 +383,7 @@ module NativeShieldedToken { amount: Uint<128>, refundTo: Either ): Maybe { - Initializable_assertInitialized(); + assertInitialized(); assert(coin.color == tokenType(_domain, kernel.self()), "NativeShieldedToken: wrong token"); assert(coin.value >= amount, "NativeShieldedToken: insufficient coin value"); assert(!Utils_isKeyOrAddressZero(refundTo), "NativeShieldedToken: invalid refund target"); @@ -393,7 +429,7 @@ module NativeShieldedToken { coin: QualifiedShieldedCoinInfo, amount: Uint<128> ): Maybe { - Initializable_assertInitialized(); + assertInitialized(); assert(coin.color == tokenType(_domain, kernel.self()), "NativeShieldedToken: wrong token"); assert(coin.value >= amount, "NativeShieldedToken: insufficient coin value"); diff --git a/contracts/src/token/NativeShieldedTokenFamily.compact b/contracts/src/token/NativeShieldedTokenFamily.compact index 3709eba56..6d865cc6d 100644 --- a/contracts/src/token/NativeShieldedTokenFamily.compact +++ b/contracts/src/token/NativeShieldedTokenFamily.compact @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Compact Contracts v0.1.0 (token/NativeShieldedTokenFamily.compact) -pragma language_version >= 0.21.0; +pragma language_version >= 0.23.0; /** * @module NativeShieldedTokenFamily @@ -100,9 +100,15 @@ pragma language_version >= 0.21.0; */ module NativeShieldedTokenFamily { import CompactStandardLibrary; - import "../security/Initializable" prefix Initializable_; import "../utils/Utils" prefix Utils_; + /** + * @description Initialization flag, tracked per-module to avoid the compiler's + * shared transitive-dependency state bug (LFDT-Minokawa/compact#270). See the + * Initializable module for rationale. + */ + export ledger _isInitialized: Boolean; + /** * @description Exact amount minted per domain. See "Supply accounting". * @type {Map, Uint<128>>} _totalMinted @@ -133,12 +139,41 @@ module NativeShieldedTokenFamily { symbol_: Opaque<"string">, decimals_: Uint<8> ): [] { - Initializable_initialize(); + assertNotInitialized(); + _isInitialized = true; _name = disclose(name_); _symbol = disclose(symbol_); _decimals = disclose(decimals_); } + /** + * @description Asserts that the contract has been initialized, throwing an + * error if not. + * + * Requirements: + * + * - Contract must be initialized. + * + * @return {[]} - Empty tuple. + */ + circuit assertInitialized(): [] { + assert(_isInitialized, "NativeShieldedTokenFamily: contract not initialized"); + } + + /** + * @description Asserts that the contract has not been initialized, throwing + * an error if it has. + * + * Requirements: + * + * - Contract must not be initialized. + * + * @return {[]} - Empty tuple. + */ + circuit assertNotInitialized(): [] { + assert(!_isInitialized, "NativeShieldedTokenFamily: contract already initialized"); + } + /** * @description Returns the family name shared by all token types of this * contract. Per-type identity belongs in the consumer's own state; see the @@ -151,7 +186,7 @@ module NativeShieldedTokenFamily { * @return {Opaque<"string">} - The family name. */ export circuit name(): Opaque<"string"> { - Initializable_assertInitialized(); + assertInitialized(); return _name; } @@ -166,7 +201,7 @@ module NativeShieldedTokenFamily { * @return {Opaque<"string">} - The family symbol. */ export circuit symbol(): Opaque<"string"> { - Initializable_assertInitialized(); + assertInitialized(); return _symbol; } @@ -183,7 +218,7 @@ module NativeShieldedTokenFamily { * @return {Uint<8>} - The decimals value. */ export circuit decimals(): Uint<8> { - Initializable_assertInitialized(); + assertInitialized(); return _decimals; } @@ -199,7 +234,7 @@ module NativeShieldedTokenFamily { * @return {Bytes<32>} - The coin color: `tokenType(domain, kernel.self())`. */ export circuit tokenColor(domain: Bytes<32>): Bytes<32> { - Initializable_assertInitialized(); + assertInitialized(); return tokenType(disclose(domain), kernel.self()); } @@ -214,7 +249,7 @@ module NativeShieldedTokenFamily { * @return {Uint<128>} - The total amount minted. */ export circuit totalMinted(domain: Bytes<32>): Uint<128> { - Initializable_assertInitialized(); + assertInitialized(); if (!_totalMinted.member(disclose(domain))) { return 0; } @@ -234,7 +269,7 @@ module NativeShieldedTokenFamily { * @return {Uint<128>} - The total amount burned through this contract. */ export circuit totalBurned(domain: Bytes<32>): Uint<128> { - Initializable_assertInitialized(); + assertInitialized(); if (!_totalBurned.member(disclose(domain))) { return 0; } @@ -255,7 +290,7 @@ module NativeShieldedTokenFamily { * @return {Uint<128>} - The upper bound on tokens in existence. */ export circuit totalSupply(domain: Bytes<32>): Uint<128> { - Initializable_assertInitialized(); + assertInitialized(); return (totalMinted(domain) - totalBurned(domain)) as Uint<128>; } @@ -292,7 +327,7 @@ module NativeShieldedTokenFamily { amount: Uint<64>, nonce: Bytes<32> ): ShieldedCoinInfo { - Initializable_assertInitialized(); + assertInitialized(); assert(!Utils_isKeyOrAddressZero(recipient), "NativeShieldedTokenFamily: invalid recipient"); _addMinted(domain, amount); @@ -339,7 +374,7 @@ module NativeShieldedTokenFamily { amount: Uint<128>, refundTo: Either ): Maybe { - Initializable_assertInitialized(); + assertInitialized(); assert(coin.color == tokenType(disclose(domain), kernel.self()), "NativeShieldedTokenFamily: wrong token"); assert(coin.value >= amount, "NativeShieldedTokenFamily: insufficient coin value"); assert(!Utils_isKeyOrAddressZero(refundTo), "NativeShieldedTokenFamily: invalid refund target"); @@ -385,7 +420,7 @@ module NativeShieldedTokenFamily { coin: QualifiedShieldedCoinInfo, amount: Uint<128> ): Maybe { - Initializable_assertInitialized(); + assertInitialized(); assert(coin.color == tokenType(disclose(domain), kernel.self()), "NativeShieldedTokenFamily: wrong token"); assert(coin.value >= amount, "NativeShieldedTokenFamily: insufficient coin value"); diff --git a/contracts/src/token/extensions/NativeShieldedTokenDerivedNonce.compact b/contracts/src/token/extensions/NativeShieldedTokenDerivedNonce.compact index 9fdaf3643..e488767f9 100644 --- a/contracts/src/token/extensions/NativeShieldedTokenDerivedNonce.compact +++ b/contracts/src/token/extensions/NativeShieldedTokenDerivedNonce.compact @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Compact Contracts v0.1.0 (token/extensions/NativeShieldedTokenDerivedNonce.compact) -pragma language_version >= 0.21.0; +pragma language_version >= 0.23.0; /** * @module NativeShieldedTokenDerivedNonce @@ -20,7 +20,7 @@ pragma language_version >= 0.21.0; * # Design notes * * - Coin nonces are derived from (not equal to) the chain value: - * `persistentHash([pad(32, "NativeShieldedTokenDerivedNonce:nonce"), chainValue])`. + * `persistentHash([pad(32, "NativeShieldedToken:nonce"), chainValue])`. * The fixed tag puts derived nonces in a namespace an honest caller of the * base `_mint` will not produce, so a value read from the public `_nonce` * field and passed as a mint nonce cannot collide with a derived mint. @@ -103,7 +103,7 @@ module NativeShieldedTokenDerivedNonce { const chainValue = evolveNonce(_counter, _nonce); _nonce = chainValue; return persistentHash>>( - [pad(32, "NativeShieldedTokenDerivedNonce:nonce"), chainValue] + [pad(32, "NativeShieldedToken:nonce"), chainValue] ); } } diff --git a/contracts/src/token/presets/NativeShieldedTokenAccessControl.compact b/contracts/src/token/presets/NativeShieldedTokenAccessControl.compact deleted file mode 100644 index 27885df29..000000000 --- a/contracts/src/token/presets/NativeShieldedTokenAccessControl.compact +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.1.0 (token/presets/NativeShieldedTokenAccessControl.compact) - -pragma language_version >= 0.21.0; - -/** - * @description Ready-to-deploy single native shielded token, role-gated. - * - * Composes: - * - `NativeShieldedToken` (Fungible profile): metadata, mint, burn, supply. - * - `NativeShieldedTokenDerivedNonce`: derives coin nonces so `mint` needs no - * caller-supplied nonce. This makes mints recipient-public; for a - * recipient-private issuer, call the base module's `_mint` with a secret - * nonce instead of using this preset. - * - `AccessControl`: `MINTER_ROLE` authorizes minting, `BURNER_ROLE` - * authorizes burning, and `DEFAULT_ADMIN_ROLE` administers both. - * - * # Initialization - * - * `AccessControl` does not use `Initializable`, so there is no shared-flag - * conflict with the base module: the constructor calls the base `initialize` - * (which owns the flag), seeds the nonce chain, then grants the admin - * account all three roles via the internal `_grantRole`. - * - * # Authorization - * - * `mint` is gated by `MINTER_ROLE`; `burn`/`burnFromContract` by - * `BURNER_ROLE`. Caller identity is derived from the `wit_AccessControlSK` - * witness; the deployer provides it like any AccessControl consumer. - */ -import CompactStandardLibrary; - -import "../NativeShieldedToken" prefix NativeShieldedToken_; -import "../extensions/NativeShieldedTokenDerivedNonce" prefix Derived_; -import "../../access/AccessControl" prefix AccessControl_; - -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; -export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; - -/** - * @description The role that authorizes minting. - */ -export pure circuit MINTER_ROLE(): Bytes<32> { - return pad(32, "NativeShieldedToken.MINTER_ROLE"); -} - -/** - * @description The role that authorizes burning. - */ -export pure circuit BURNER_ROLE(): Bytes<32> { - return pad(32, "NativeShieldedToken.BURNER_ROLE"); -} - -/** - * @description Initializes metadata, the nonce chain, and grants `admin` the - * default-admin, minter, and burner roles. - * - * @param {Bytes<32>} domainSep - Domain separator fixing this token's color. - * @param {Bytes<32>} initNonce - Unpredictable seed for the nonce chain. - * @param {Opaque<"string">} name_ - Token name. - * @param {Opaque<"string">} symbol_ - Token symbol. - * @param {Uint<8>} decimals_ - Display decimals. - * @param {Either, ContractAddress>} admin - Account granted the - * admin, minter, and burner roles (a user account key). - */ -constructor( - domainSep: Bytes<32>, - initNonce: Bytes<32>, - name_: Opaque<"string">, - symbol_: Opaque<"string">, - decimals_: Uint<8>, - admin: Either, ContractAddress> -) { - NativeShieldedToken_initialize(domainSep, name_, symbol_, decimals_); - Derived_initialize(initNonce); - - AccessControl__grantRole(AccessControl_DEFAULT_ADMIN_ROLE(), admin); - AccessControl__grantRole(MINTER_ROLE(), admin); - AccessControl__grantRole(BURNER_ROLE(), admin); -} - -export circuit name(): Opaque<"string"> { - return NativeShieldedToken_name(); -} - -export circuit symbol(): Opaque<"string"> { - return NativeShieldedToken_symbol(); -} - -export circuit decimals(): Uint<8> { - return NativeShieldedToken_decimals(); -} - -export circuit tokenColor(): Bytes<32> { - return NativeShieldedToken_tokenColor(); -} - -export circuit totalMinted(): Uint<128> { - return NativeShieldedToken_totalMinted(); -} - -export circuit totalBurned(): Uint<128> { - return NativeShieldedToken_totalBurned(); -} - -export circuit totalSupply(): Uint<128> { - return NativeShieldedToken_totalSupply(); -} - -export circuit DEFAULT_ADMIN_ROLE(): Bytes<32> { - return AccessControl_DEFAULT_ADMIN_ROLE(); -} - -export circuit hasRole(roleId: Bytes<32>, account: Either, ContractAddress>): Boolean { - return AccessControl_hasRole(roleId, account); -} - -export circuit grantRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { - AccessControl_grantRole(roleId, account); -} - -export circuit revokeRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { - AccessControl_revokeRole(roleId, account); -} - -export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either, ContractAddress>): [] { - AccessControl_renounceRole(roleId, callerConfirmation); -} - -/** - * @description Mints `amount` to `recipient` with an internally derived - * nonce (recipient-public). Requires `MINTER_ROLE`. The returned coin info - * is the only copy available to the recipient; deliver it out of band. - */ -export circuit mint( - recipient: Either, - amount: Uint<64> -): ShieldedCoinInfo { - AccessControl_assertOnlyRole(MINTER_ROLE()); - return NativeShieldedToken__mint(recipient, amount, Derived__deriveNonce()); -} - -/** - * @description Burns `amount` from a coin provided within the current - * transaction, refunding any change to `refundTo`. Requires `BURNER_ROLE`. - */ -export circuit burn( - coin: ShieldedCoinInfo, - amount: Uint<128>, - refundTo: Either -): Maybe { - AccessControl_assertOnlyRole(BURNER_ROLE()); - return NativeShieldedToken__burn(coin, amount, refundTo); -} - -/** - * @description Burns `amount` from a coin the contract already holds. - * Requires `BURNER_ROLE`. Returns the change retained by the contract. - */ -export circuit burnFromContract( - coin: QualifiedShieldedCoinInfo, - amount: Uint<128> -): Maybe { - AccessControl_assertOnlyRole(BURNER_ROLE()); - return NativeShieldedToken__burnFromContract(coin, amount); -} diff --git a/contracts/src/token/presets/NativeShieldedTokenFamilyAccessControl.compact b/contracts/src/token/presets/NativeShieldedTokenFamilyAccessControl.compact deleted file mode 100644 index 2ee626bfd..000000000 --- a/contracts/src/token/presets/NativeShieldedTokenFamilyAccessControl.compact +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.1.0 (token/presets/NativeShieldedTokenFamilyAccessControl.compact) - -pragma language_version >= 0.21.0; - -/** - * @description Ready-to-deploy native shielded token family, role-gated. - * - * Composes: - * - `NativeShieldedTokenFamily`: many token types per contract, keyed by a - * per-call `domain`; shared family metadata; per-domain supply. - * - `NativeShieldedTokenDerivedNonce`: one nonce chain serving all domains, - * so `mint` needs no caller-supplied nonce. This makes mints - * recipient-public; for recipient privacy call the base `_mint` with a - * secret nonce instead. - * - `AccessControl`: `MINTER_ROLE` authorizes minting, `BURNER_ROLE` - * authorizes burning, `DEFAULT_ADMIN_ROLE` administers both. - * - * # Authorization model - * - * Roles are contract-wide: a `MINTER_ROLE` holder may mint any domain. - * Issuers needing per-domain roles should compose the base module with their - * own scheme rather than use this preset. - * - * # Initialization - * - * `AccessControl` does not use `Initializable`, so there is no shared-flag - * conflict with the base module: the constructor calls the base `initialize` - * (which owns the flag), seeds the nonce chain, then grants the admin all - * three roles via the internal `_grantRole`. - */ -import CompactStandardLibrary; - -import "../NativeShieldedTokenFamily" prefix NativeShieldedTokenFamily_; -import "../extensions/NativeShieldedTokenDerivedNonce" prefix Derived_; -import "../../access/AccessControl" prefix AccessControl_; - -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; -export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; - -/** - * @description The role that authorizes minting any domain. - */ -export pure circuit MINTER_ROLE(): Bytes<32> { - return pad(32, "NativeShieldedTokenFamily.MINTER_ROLE"); -} - -/** - * @description The role that authorizes burning any domain. - */ -export pure circuit BURNER_ROLE(): Bytes<32> { - return pad(32, "NativeShieldedTokenFamily.BURNER_ROLE"); -} - -/** - * @description Initializes family metadata, the nonce chain, and grants - * `admin` the default-admin, minter, and burner roles. - * - * @param {Bytes<32>} initNonce - Unpredictable seed for the nonce chain. - * @param {Opaque<"string">} name_ - Family name shared by all token types. - * @param {Opaque<"string">} symbol_ - Family symbol shared by all token types. - * @param {Uint<8>} decimals_ - Family-wide display decimals. - * @param {Either, ContractAddress>} admin - Account granted the - * admin, minter, and burner roles (a user account key). - */ -constructor( - initNonce: Bytes<32>, - name_: Opaque<"string">, - symbol_: Opaque<"string">, - decimals_: Uint<8>, - admin: Either, ContractAddress> -) { - NativeShieldedTokenFamily_initialize(name_, symbol_, decimals_); - Derived_initialize(initNonce); - - AccessControl__grantRole(AccessControl_DEFAULT_ADMIN_ROLE(), admin); - AccessControl__grantRole(MINTER_ROLE(), admin); - AccessControl__grantRole(BURNER_ROLE(), admin); -} - -export circuit name(): Opaque<"string"> { - return NativeShieldedTokenFamily_name(); -} - -export circuit symbol(): Opaque<"string"> { - return NativeShieldedTokenFamily_symbol(); -} - -export circuit decimals(): Uint<8> { - return NativeShieldedTokenFamily_decimals(); -} - -export circuit tokenColor(domain: Bytes<32>): Bytes<32> { - return NativeShieldedTokenFamily_tokenColor(domain); -} - -export circuit totalMinted(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalMinted(domain); -} - -export circuit totalBurned(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalBurned(domain); -} - -export circuit totalSupply(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalSupply(domain); -} - -export circuit DEFAULT_ADMIN_ROLE(): Bytes<32> { - return AccessControl_DEFAULT_ADMIN_ROLE(); -} - -export circuit hasRole(roleId: Bytes<32>, account: Either, ContractAddress>): Boolean { - return AccessControl_hasRole(roleId, account); -} - -export circuit grantRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { - AccessControl_grantRole(roleId, account); -} - -export circuit revokeRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { - AccessControl_revokeRole(roleId, account); -} - -export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either, ContractAddress>): [] { - AccessControl_renounceRole(roleId, callerConfirmation); -} - -/** - * @description Mints `amount` of `domain`'s token to `recipient` with an - * internally derived nonce (recipient-public). Requires `MINTER_ROLE`. The - * returned coin info is the only copy available to the recipient; deliver it - * out of band. - */ -export circuit mint( - domain: Bytes<32>, - recipient: Either, - amount: Uint<64> -): ShieldedCoinInfo { - AccessControl_assertOnlyRole(MINTER_ROLE()); - return NativeShieldedTokenFamily__mint(domain, recipient, amount, Derived__deriveNonce()); -} - -/** - * @description Burns `amount` of `domain`'s token from a coin provided within - * the current transaction, refunding any change to `refundTo`. Requires - * `BURNER_ROLE`. - */ -export circuit burn( - domain: Bytes<32>, - coin: ShieldedCoinInfo, - amount: Uint<128>, - refundTo: Either -): Maybe { - AccessControl_assertOnlyRole(BURNER_ROLE()); - return NativeShieldedTokenFamily__burn(domain, coin, amount, refundTo); -} - -/** - * @description Burns `amount` of `domain`'s token from a coin the contract - * already holds. Requires `BURNER_ROLE`. Returns the change retained by the - * contract. - */ -export circuit burnFromContract( - domain: Bytes<32>, - coin: QualifiedShieldedCoinInfo, - amount: Uint<128> -): Maybe { - AccessControl_assertOnlyRole(BURNER_ROLE()); - return NativeShieldedTokenFamily__burnFromContract(domain, coin, amount); -} diff --git a/contracts/src/token/presets/NativeShieldedTokenFamilyOwnable.compact b/contracts/src/token/presets/NativeShieldedTokenFamilyOwnable.compact deleted file mode 100644 index 9ffc1a8da..000000000 --- a/contracts/src/token/presets/NativeShieldedTokenFamilyOwnable.compact +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.1.0 (token/presets/NativeShieldedTokenFamilyOwnable.compact) - -pragma language_version >= 0.21.0; - -/** - * @description Ready-to-deploy native shielded token family, owner-gated. - * - * Composes: - * - `NativeShieldedTokenFamily`: many token types per contract, keyed by a - * per-call `domain`; shared family metadata; per-domain supply. - * - `NativeShieldedTokenDerivedNonce`: one nonce chain serving all domains, - * so `mint` needs no caller-supplied nonce. This makes mints - * recipient-public; for recipient privacy call the base `_mint` with a - * secret nonce instead. - * - `Ownable`: a single owner authorized to mint and burn across all domains. - * - * # Authorization model - * - * The owner controls every domain. Issuers needing per-domain authorization - * should compose the base module with their own access scheme rather than - * use this preset. - * - * # Initialization - * - * The base module owns the shared `Initializable` flag, so the owner is set - * via Ownable's `_unsafeUncheckedTransferOwnership` (guarded by the same - * checks as `Ownable_initialize`) rather than `Ownable_initialize`. The - * derived-nonce chain uses its own seed guard, not `Initializable`. - */ -import CompactStandardLibrary; - -import "../NativeShieldedTokenFamily" prefix NativeShieldedTokenFamily_; -import "../extensions/NativeShieldedTokenDerivedNonce" prefix Derived_; -import "../../access/Ownable" prefix Ownable_; - -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; -export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; - -/** - * @description Initializes family metadata, the nonce chain, and the owner. - * - * @param {Bytes<32>} initNonce - Unpredictable seed for the nonce chain. - * @param {Opaque<"string">} name_ - Family name shared by all token types. - * @param {Opaque<"string">} symbol_ - Family symbol shared by all token types. - * @param {Uint<8>} decimals_ - Family-wide display decimals. - * @param {Either, ContractAddress>} initialOwner - Initial owner - * (a user account key; contract owners are unsupported until C2C lands). - */ -constructor( - initNonce: Bytes<32>, - name_: Opaque<"string">, - symbol_: Opaque<"string">, - decimals_: Uint<8>, - initialOwner: Either, ContractAddress> -) { - NativeShieldedTokenFamily_initialize(name_, symbol_, decimals_); - Derived_initialize(initNonce); - - assert(initialOwner.is_left, "NativeShieldedTokenFamilyOwnable: owner must be a user key"); - assert(!(initialOwner.left == default>), "NativeShieldedTokenFamilyOwnable: invalid initial owner"); - Ownable__unsafeUncheckedTransferOwnership(initialOwner); -} - -export circuit name(): Opaque<"string"> { - return NativeShieldedTokenFamily_name(); -} - -export circuit symbol(): Opaque<"string"> { - return NativeShieldedTokenFamily_symbol(); -} - -export circuit decimals(): Uint<8> { - return NativeShieldedTokenFamily_decimals(); -} - -export circuit tokenColor(domain: Bytes<32>): Bytes<32> { - return NativeShieldedTokenFamily_tokenColor(domain); -} - -export circuit totalMinted(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalMinted(domain); -} - -export circuit totalBurned(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalBurned(domain); -} - -export circuit totalSupply(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalSupply(domain); -} - -export circuit owner(): Either, ContractAddress> { - return Ownable_owner(); -} - -export circuit transferOwnership(newOwner: Either, ContractAddress>): [] { - Ownable_transferOwnership(newOwner); -} - -export circuit renounceOwnership(): [] { - Ownable_renounceOwnership(); -} - -/** - * @description Mints `amount` of `domain`'s token to `recipient` with an - * internally derived nonce (recipient-public). Owner only. The returned coin - * info is the only copy available to the recipient; deliver it out of band. - */ -export circuit mint( - domain: Bytes<32>, - recipient: Either, - amount: Uint<64> -): ShieldedCoinInfo { - Ownable_assertOnlyOwner(); - return NativeShieldedTokenFamily__mint(domain, recipient, amount, Derived__deriveNonce()); -} - -/** - * @description Burns `amount` of `domain`'s token from a coin provided within - * the current transaction, refunding any change to `refundTo`. Owner only. - */ -export circuit burn( - domain: Bytes<32>, - coin: ShieldedCoinInfo, - amount: Uint<128>, - refundTo: Either -): Maybe { - Ownable_assertOnlyOwner(); - return NativeShieldedTokenFamily__burn(domain, coin, amount, refundTo); -} - -/** - * @description Burns `amount` of `domain`'s token from a coin the contract - * already holds. Owner only. Returns the change retained by the contract. - */ -export circuit burnFromContract( - domain: Bytes<32>, - coin: QualifiedShieldedCoinInfo, - amount: Uint<128> -): Maybe { - Ownable_assertOnlyOwner(); - return NativeShieldedTokenFamily__burnFromContract(domain, coin, amount); -} diff --git a/contracts/src/token/presets/NativeShieldedTokenOwnable.compact b/contracts/src/token/presets/NativeShieldedTokenOwnable.compact deleted file mode 100644 index 454a2c0af..000000000 --- a/contracts/src/token/presets/NativeShieldedTokenOwnable.compact +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.1.0 (token/presets/NativeShieldedTokenOwnable.compact) - -pragma language_version >= 0.21.0; - -/** - * @description Ready-to-deploy single native shielded token, owner-gated. - * - * Composes: - * - `NativeShieldedToken` (Fungible profile): metadata, mint, burn, supply. - * - `NativeShieldedTokenDerivedNonce`: derives coin nonces so `mint` needs no - * caller-supplied nonce. This makes mints recipient-public; for a - * recipient-private issuer, call the base module's `_mint` with a secret - * nonce instead of using this preset. - * - `Ownable`: a single owner authorized to mint and burn. - * - * # Initialization - * - * The base module owns the shared `Initializable` flag (its `initialize` - * calls `Initializable_initialize`). `Ownable_initialize` would call it a - * second time and revert, so the owner is set here via Ownable's internal - * `_unsafeUncheckedTransferOwnership`, guarded by the same not-zero / - * not-contract checks `Ownable_initialize` performs. The derived-nonce chain - * uses its own seed guard, not `Initializable`, so it composes freely. - * - * # Authorization - * - * `mint`, `burn`, and `burnFromContract` are gated by `assertOnlyOwner`. - * Caller identity is derived from the `wit_OwnableSK` witness; the deployer - * provides it like any Ownable consumer. - */ -import CompactStandardLibrary; - -import "../NativeShieldedToken" prefix NativeShieldedToken_; -import "../extensions/NativeShieldedTokenDerivedNonce" prefix Derived_; -import "../../access/Ownable" prefix Ownable_; - -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; -export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; - -/** - * @description Initializes metadata, the nonce chain, and the owner. - * - * @param {Bytes<32>} domainSep - Domain separator fixing this token's color. - * @param {Bytes<32>} initNonce - Unpredictable seed for the nonce chain. - * @param {Opaque<"string">} name_ - Token name. - * @param {Opaque<"string">} symbol_ - Token symbol. - * @param {Uint<8>} decimals_ - Display decimals. - * @param {Either, ContractAddress>} initialOwner - Initial owner - * (a user account key; contract owners are unsupported until C2C lands). - */ -constructor( - domainSep: Bytes<32>, - initNonce: Bytes<32>, - name_: Opaque<"string">, - symbol_: Opaque<"string">, - decimals_: Uint<8>, - initialOwner: Either, ContractAddress> -) { - NativeShieldedToken_initialize(domainSep, name_, symbol_, decimals_); - Derived_initialize(initNonce); - - assert(initialOwner.is_left, "NativeShieldedTokenOwnable: owner must be a user key"); - assert(!(initialOwner.left == default>), "NativeShieldedTokenOwnable: invalid initial owner"); - Ownable__unsafeUncheckedTransferOwnership(initialOwner); -} - -export circuit name(): Opaque<"string"> { - return NativeShieldedToken_name(); -} - -export circuit symbol(): Opaque<"string"> { - return NativeShieldedToken_symbol(); -} - -export circuit decimals(): Uint<8> { - return NativeShieldedToken_decimals(); -} - -export circuit tokenColor(): Bytes<32> { - return NativeShieldedToken_tokenColor(); -} - -export circuit totalMinted(): Uint<128> { - return NativeShieldedToken_totalMinted(); -} - -export circuit totalBurned(): Uint<128> { - return NativeShieldedToken_totalBurned(); -} - -export circuit totalSupply(): Uint<128> { - return NativeShieldedToken_totalSupply(); -} - -export circuit owner(): Either, ContractAddress> { - return Ownable_owner(); -} - -export circuit transferOwnership(newOwner: Either, ContractAddress>): [] { - Ownable_transferOwnership(newOwner); -} - -export circuit renounceOwnership(): [] { - Ownable_renounceOwnership(); -} - -/** - * @description Mints `amount` to `recipient` with an internally derived - * nonce (recipient-public). Owner only. The returned coin info is the only - * copy available to the recipient; deliver it out of band. - */ -export circuit mint( - recipient: Either, - amount: Uint<64> -): ShieldedCoinInfo { - Ownable_assertOnlyOwner(); - return NativeShieldedToken__mint(recipient, amount, Derived__deriveNonce()); -} - -/** - * @description Burns `amount` from a coin provided within the current - * transaction, refunding any change to `refundTo`. Owner only. - */ -export circuit burn( - coin: ShieldedCoinInfo, - amount: Uint<128>, - refundTo: Either -): Maybe { - Ownable_assertOnlyOwner(); - return NativeShieldedToken__burn(coin, amount, refundTo); -} - -/** - * @description Burns `amount` from a coin the contract already holds. Owner - * only. Returns the change retained by the contract. - */ -export circuit burnFromContract( - coin: QualifiedShieldedCoinInfo, - amount: Uint<128> -): Maybe { - Ownable_assertOnlyOwner(); - return NativeShieldedToken__burnFromContract(coin, amount); -} diff --git a/contracts/src/token/test/mocks/MockNativeShieldedToken.compact b/contracts/src/token/test/mocks/MockNativeShieldedToken.compact index ff8cdd9c1..4ede6e7fe 100644 --- a/contracts/src/token/test/mocks/MockNativeShieldedToken.compact +++ b/contracts/src/token/test/mocks/MockNativeShieldedToken.compact @@ -5,7 +5,7 @@ // corresponding production contract relies on. DO NOT deploy or use this // contract in any production application. -pragma language_version >= 0.21.0; +pragma language_version >= 0.23.0; import CompactStandardLibrary; diff --git a/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact b/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact index 2ea9d653a..5a24382ce 100644 --- a/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact +++ b/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact @@ -5,7 +5,7 @@ // corresponding production contract relies on. DO NOT deploy or use this // contract in any production application. -pragma language_version >= 0.21.0; +pragma language_version >= 0.23.0; import CompactStandardLibrary; diff --git a/mip-xxxx-native-shielded-token.md b/mip-xxxx-native-shielded-token.md index 9cf3e54be..e79466d35 100644 --- a/mip-xxxx-native-shielded-token.md +++ b/mip-xxxx-native-shielded-token.md @@ -337,9 +337,10 @@ These depend on protocol capabilities under separate discussion ([MPS-0013](http Two companion MIPs complete the native-token family: the [Native Unshielded Token Standard](./mip-xxxx-native-unshielded-token.md), the transparent sibling of this standard with the same two profiles, and the [Native Token Conversion Extension](./mip-xxxx-native-shielded-token-conversion-extension.md), a stateless module that converts between the two representations by composing both base standards' Family profiles. Dual-representation tokens MUST build on the Family profiles. -Each Fungible profile stores a load-bearing sealed `_domain` written by its `initialize`, but the shared `Initializable` flag allows only one `initialize` call per contract. +Each Fungible profile stores a single load-bearing sealed `_domain` written by its `initialize`. +The reference modules track initialization per-module via an inline `_isInitialized` flag, not the shared `Initializable` module (which collapses that flag across same-directory modules; see [LFDT-Minokawa/compact#270](https://github.com/OpenZeppelin/compact-contracts/blob/main/contracts/src/security/Initializable.compact)), so each composed base is initialized independently. Compact ledger layouts are fixed at deploy, so an issuer that may ever need a transparent representation SHOULD deploy on the Family profiles with the extension compiled in, hardcoding one domain constant for a single-token product. -When both bases are composed in one contract, the consumer MUST call exactly one base's `initialize` and SHOULD expose metadata getters from that base only. +When both bases are composed in one contract, the consumer initializes each base and SHOULD expose metadata getters from a single base only, since the two carry independent `name`/`symbol`/`decimals`. ## Rationale @@ -539,14 +540,14 @@ An issuer with compliance requirements (for example, a regulated stablecoin) sho ### Components -1. **New Compact modules.** [`NativeShieldedToken.compact` (Fungible profile) and `NativeShieldedTokenFamily.compact` (Family profile)](https://github.com/OpenZeppelin/compact-contracts/tree/main/contracts/src/token) in the OpenZeppelin Compact Contracts library: all state and circuits specified above, composed with the library's `Initializable` and `Utils` modules, plus the optional `extensions/NativeShieldedTokenDerivedNonce.compact` extension module. +1. **New Compact modules.** [`NativeShieldedToken.compact` (Fungible profile) and `NativeShieldedTokenFamily.compact` (Family profile)](https://github.com/OpenZeppelin/compact-contracts/tree/main/contracts/src/token) in the OpenZeppelin Compact Contracts library: all state and circuits specified above, composed with the library's `Utils` module (initialization is tracked inline per-module, not via the shared `Initializable` module), plus the optional `extensions/NativeShieldedTokenDerivedNonce.compact` extension module. 2. **Mocks, simulators, and tests.** `MockNativeShieldedToken.compact` and `MockNativeShieldedTokenFamily.compact` exposing the module circuits, with TypeScript simulators and Vitest suites. 3. **No protocol changes required.** ### Dependencies - [Compact Standard Library](https://docs.midnight.network/compact): `mintShieldedToken`, `receiveShielded`, `sendShielded`, `sendImmediateShielded`, `evolveNonce`, `tokenType`, `shieldedBurnAddress`, `Counter`, `ShieldedCoinInfo`, `QualifiedShieldedCoinInfo`, `Maybe`. -- [OpenZeppelin Compact Contracts](https://github.com/OpenZeppelin/compact-contracts): `Initializable` and `Utils` modules. +- [OpenZeppelin Compact Contracts](https://github.com/OpenZeppelin/compact-contracts): the `Utils` module. Initialization is tracked inline per-module rather than via the shared `Initializable` module. - Compact language version >= 0.21.0. The reference implementation compiles against this toolchain. From 3a0321150fca1fa5074a31c0e8bfe7282d398585 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 22 Jun 2026 16:01:50 +0200 Subject: [PATCH 03/17] refactor(multisig): group modules into subdirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the flat multisig modules into concern-based subdirectories so the package reads as composable building blocks rather than a flat pile: * signer/ — Signer, SignerManager * proposal/ — ProposalManager * treasury/ — ShieldedTreasury, ShieldedTreasuryStateless, UnshieldedTreasury * forwarder/ — ForwarderPrivate, ForwarderShielded, ForwarderUnshielded Presets and mocks keep their locations; only their import paths change. Mocks stay flat, so compiled artifact names (flat by basename) and every TS test import are untouched. The five moved modules that reach into utils/ gain an extra ../ in their import path. Also mark V3's inlined mint/burn with a TODO pointing at the future ShieldedToken module. Pure move plus import-path change, no logic change. compact:multisig compiles 24/24; the multisig suite passes 273/273. Refs: OpenZeppelin/compact-contracts#619 --- .../src/multisig/{ => forwarder}/ForwarderPrivate.compact | 2 +- .../src/multisig/{ => forwarder}/ForwarderShielded.compact | 2 +- .../multisig/{ => forwarder}/ForwarderUnshielded.compact | 0 contracts/src/multisig/presets/ShieldedMultiSig.compact | 6 +++--- contracts/src/multisig/presets/ShieldedMultiSigV2.compact | 6 +++--- contracts/src/multisig/presets/ShieldedMultiSigV3.compact | 7 ++++++- .../multisig/presets/forwarder/ForwarderPrivate.compact | 2 +- .../multisig/presets/forwarder/ForwarderShielded.compact | 2 +- .../multisig/presets/forwarder/ForwarderUnshielded.compact | 2 +- .../src/multisig/{ => proposal}/ProposalManager.compact | 0 contracts/src/multisig/{ => signer}/Signer.compact | 0 contracts/src/multisig/{ => signer}/SignerManager.compact | 0 .../src/multisig/test/mocks/MockForwarderPrivate.compact | 2 +- .../src/multisig/test/mocks/MockForwarderShielded.compact | 2 +- .../multisig/test/mocks/MockForwarderUnshielded.compact | 2 +- .../src/multisig/test/mocks/MockProposalManager.compact | 2 +- .../src/multisig/test/mocks/MockShieldedTreasury.compact | 2 +- .../test/mocks/MockShieldedTreasuryStateless.compact | 2 +- contracts/src/multisig/test/mocks/MockSigner.compact | 4 ++-- .../src/multisig/test/mocks/MockSignerManager.compact | 2 +- .../src/multisig/test/mocks/MockUnshieldedTreasury.compact | 2 +- .../src/multisig/{ => treasury}/ShieldedTreasury.compact | 2 +- .../{ => treasury}/ShieldedTreasuryStateless.compact | 2 +- .../src/multisig/{ => treasury}/UnshieldedTreasury.compact | 2 +- 24 files changed, 30 insertions(+), 25 deletions(-) rename contracts/src/multisig/{ => forwarder}/ForwarderPrivate.compact (99%) rename contracts/src/multisig/{ => forwarder}/ForwarderShielded.compact (99%) rename contracts/src/multisig/{ => forwarder}/ForwarderUnshielded.compact (100%) rename contracts/src/multisig/{ => proposal}/ProposalManager.compact (100%) rename contracts/src/multisig/{ => signer}/Signer.compact (100%) rename contracts/src/multisig/{ => signer}/SignerManager.compact (100%) rename contracts/src/multisig/{ => treasury}/ShieldedTreasury.compact (98%) rename contracts/src/multisig/{ => treasury}/ShieldedTreasuryStateless.compact (97%) rename contracts/src/multisig/{ => treasury}/UnshieldedTreasury.compact (98%) diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/forwarder/ForwarderPrivate.compact similarity index 99% rename from contracts/src/multisig/ForwarderPrivate.compact rename to contracts/src/multisig/forwarder/ForwarderPrivate.compact index 1d35f92a8..f2f086189 100644 --- a/contracts/src/multisig/ForwarderPrivate.compact +++ b/contracts/src/multisig/forwarder/ForwarderPrivate.compact @@ -29,7 +29,7 @@ pragma language_version >= 0.23.0; */ module ForwarderPrivate { import CompactStandardLibrary; - import "../utils/Utils" prefix Utils_; + import "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/ForwarderShielded.compact b/contracts/src/multisig/forwarder/ForwarderShielded.compact similarity index 99% rename from contracts/src/multisig/ForwarderShielded.compact rename to contracts/src/multisig/forwarder/ForwarderShielded.compact index 2dd473a3b..f4c82449c 100644 --- a/contracts/src/multisig/ForwarderShielded.compact +++ b/contracts/src/multisig/forwarder/ForwarderShielded.compact @@ -44,7 +44,7 @@ pragma language_version >= 0.23.0; */ module ForwarderShielded { import CompactStandardLibrary; - import "../utils/Utils" prefix Utils_; + import "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/ForwarderUnshielded.compact b/contracts/src/multisig/forwarder/ForwarderUnshielded.compact similarity index 100% rename from contracts/src/multisig/ForwarderUnshielded.compact rename to contracts/src/multisig/forwarder/ForwarderUnshielded.compact diff --git a/contracts/src/multisig/presets/ShieldedMultiSig.compact b/contracts/src/multisig/presets/ShieldedMultiSig.compact index 8a2f8b5f9..740833a1d 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSig.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSig.compact @@ -27,9 +27,9 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../ProposalManager" prefix Proposal_; -import "../ShieldedTreasury" prefix Treasury_; -import "../SignerManager"> prefix Signer_; +import "../proposal/ProposalManager" prefix Proposal_; +import "../treasury/ShieldedTreasury" prefix Treasury_; +import "../signer/SignerManager"> prefix Signer_; // ─── State ─────────────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact index 0c380dbee..a37c5d7f2 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact @@ -22,9 +22,9 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../ProposalManager" prefix Proposal_; -import "../ShieldedTreasuryStateless" prefix Treasury_; -import "../SignerManager"> prefix Signer_; +import "../proposal/ProposalManager" prefix Proposal_; +import "../treasury/ShieldedTreasuryStateless" prefix Treasury_; +import "../signer/SignerManager"> prefix Signer_; // ─── Types ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index 23d6ba814..fb6719c71 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -40,7 +40,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../Signer"> prefix Signer_; +import "../signer/Signer"> prefix Signer_; import "../../utils/Utils" prefix Utils_; // For testing export { ZswapCoinPublicKey }; @@ -115,6 +115,11 @@ constructor( Signer_initialize<3>(signerCommitments, 2); } +// TODO: the mint/burn token-issuance logic below is slated to move into a +// reusable `ShieldedToken` module so it can be +// composed independently, mirroring how `SignerManager` / `SignatureVerifier` are +// factored. Kept inlined here for now. + // ─── Mint ─────────────────────────────────────────────────────── /** diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact index fb7fbd2e9..235120bdb 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../ForwarderPrivate" prefix ForwarderPrivate_; +import "../../forwarder/ForwarderPrivate" prefix ForwarderPrivate_; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact index 1c84247a5..0193f0a8c 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../ForwarderShielded" prefix Forwarder_; +import "../../forwarder/ForwarderShielded" prefix Forwarder_; export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact index 4ec699f49..68ca2291a 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact @@ -24,7 +24,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../ForwarderUnshielded" prefix Forwarder_; +import "../../forwarder/ForwarderUnshielded" prefix Forwarder_; export { ContractAddress, UserAddress, Either }; diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/proposal/ProposalManager.compact similarity index 100% rename from contracts/src/multisig/ProposalManager.compact rename to contracts/src/multisig/proposal/ProposalManager.compact diff --git a/contracts/src/multisig/Signer.compact b/contracts/src/multisig/signer/Signer.compact similarity index 100% rename from contracts/src/multisig/Signer.compact rename to contracts/src/multisig/signer/Signer.compact diff --git a/contracts/src/multisig/SignerManager.compact b/contracts/src/multisig/signer/SignerManager.compact similarity index 100% rename from contracts/src/multisig/SignerManager.compact rename to contracts/src/multisig/signer/SignerManager.compact diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact index c6a62ff93..018882773 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ForwarderPrivate" prefix ForwarderPrivate_; +import "../../forwarder/ForwarderPrivate" prefix ForwarderPrivate_; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; diff --git a/contracts/src/multisig/test/mocks/MockForwarderShielded.compact b/contracts/src/multisig/test/mocks/MockForwarderShielded.compact index 2f87973ac..568a73bf5 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderShielded.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderShielded.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ForwarderShielded" prefix Forwarder_; +import "../../forwarder/ForwarderShielded" prefix Forwarder_; export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; diff --git a/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact b/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact index 268383b68..653a36140 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ForwarderUnshielded" prefix Forwarder_; +import "../../forwarder/ForwarderUnshielded" prefix Forwarder_; export { ContractAddress, UserAddress, Either }; diff --git a/contracts/src/multisig/test/mocks/MockProposalManager.compact b/contracts/src/multisig/test/mocks/MockProposalManager.compact index caad8c4b4..9fe92d6c8 100644 --- a/contracts/src/multisig/test/mocks/MockProposalManager.compact +++ b/contracts/src/multisig/test/mocks/MockProposalManager.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ProposalManager" prefix Proposal_; +import "../../proposal/ProposalManager" prefix Proposal_; export circuit shieldedUserRecipient(key: ZswapCoinPublicKey): Proposal_Recipient { return Proposal_shieldedUserRecipient(key); diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact index f1dea07d8..9b181553f 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ShieldedTreasury" prefix Treasury_; +import "../../treasury/ShieldedTreasury" prefix Treasury_; export circuit _deposit(coin: ShieldedCoinInfo): [] { return Treasury__deposit(coin); diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact index 403ca14e7..a2b1e7423 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ShieldedTreasuryStateless" prefix Treasury_; +import "../../treasury/ShieldedTreasuryStateless" prefix Treasury_; export circuit _deposit(coin: ShieldedCoinInfo): [] { Treasury__deposit(coin); diff --git a/contracts/src/multisig/test/mocks/MockSigner.compact b/contracts/src/multisig/test/mocks/MockSigner.compact index 302657787..355393d0c 100644 --- a/contracts/src/multisig/test/mocks/MockSigner.compact +++ b/contracts/src/multisig/test/mocks/MockSigner.compact @@ -9,8 +9,8 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../Signer"> prefix Signer_; -import "../../Signer">; +import "../../signer/Signer"> prefix Signer_; +import "../../signer/Signer">; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; export { _signers, _signerCount, _threshold }; diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact index 870aed0cd..ba59e423b 100644 --- a/contracts/src/multisig/test/mocks/MockSignerManager.compact +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../SignerManager"> prefix Signer_; +import "../../signer/SignerManager"> prefix Signer_; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; diff --git a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact index f2c1b7327..6b8a6b21f 100644 --- a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../UnshieldedTreasury" prefix Treasury_; +import "../../treasury/UnshieldedTreasury" prefix Treasury_; export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] { return Treasury__deposit(color, amount); diff --git a/contracts/src/multisig/ShieldedTreasury.compact b/contracts/src/multisig/treasury/ShieldedTreasury.compact similarity index 98% rename from contracts/src/multisig/ShieldedTreasury.compact rename to contracts/src/multisig/treasury/ShieldedTreasury.compact index 4a0130ea9..4a29beb0d 100644 --- a/contracts/src/multisig/ShieldedTreasury.compact +++ b/contracts/src/multisig/treasury/ShieldedTreasury.compact @@ -24,7 +24,7 @@ pragma language_version >= 0.23.0; */ module ShieldedTreasury { import CompactStandardLibrary; - import { selfAsRecipient, UINT128_MAX } from "../utils/Utils" prefix Utils_; + import { selfAsRecipient, UINT128_MAX } from "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact similarity index 97% rename from contracts/src/multisig/ShieldedTreasuryStateless.compact rename to contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact index 698536e3f..96529afa7 100644 --- a/contracts/src/multisig/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact @@ -20,7 +20,7 @@ pragma language_version >= 0.23.0; */ module ShieldedTreasuryStateless { import CompactStandardLibrary; - import { selfAsRecipient } from "../utils/Utils" prefix Utils_; + import { selfAsRecipient } from "../../utils/Utils" prefix Utils_; // ─── Deposit ──────────────────────────────────────────────────── diff --git a/contracts/src/multisig/UnshieldedTreasury.compact b/contracts/src/multisig/treasury/UnshieldedTreasury.compact similarity index 98% rename from contracts/src/multisig/UnshieldedTreasury.compact rename to contracts/src/multisig/treasury/UnshieldedTreasury.compact index 222a2df7d..18fbd5fae 100644 --- a/contracts/src/multisig/UnshieldedTreasury.compact +++ b/contracts/src/multisig/treasury/UnshieldedTreasury.compact @@ -19,7 +19,7 @@ pragma language_version >= 0.23.0; */ module UnshieldedTreasury { import CompactStandardLibrary; - import { UINT128_MAX } from "../utils/Utils" prefix Utils_; + import { UINT128_MAX } from "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── From df3a57a8df14b22f25ca64567cd33083f1de6354 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 22 Jun 2026 16:13:57 +0200 Subject: [PATCH 04/17] refactor(multisig): remove legacy SignerManager module SignerManager is an older generation of the same module as Signer: identical signer-set/threshold state, but without the init-safety guards (assertInitialized / re-init protection) and the custom-setup _setThreshold path that Signer adds. Remove the legacy module together with its mock, simulator, witnesses, and test. The next commit renames Signer into its place; splitting the removal out first lets git record that as a rename (preserving Signer's history) rather than a rewrite of this file. Refs: OpenZeppelin/compact-contracts#619 --- .../src/multisig/signer/SignerManager.compact | 205 ------------------ .../src/multisig/test/SignerManager.test.ts | 201 ----------------- .../test/mocks/MockSignerManager.compact | 50 ----- .../test/simulators/SignerManagerSimulator.ts | 96 -------- .../test/witnesses/SignerManagerWitnesses.ts | 6 - 5 files changed, 558 deletions(-) delete mode 100644 contracts/src/multisig/signer/SignerManager.compact delete mode 100644 contracts/src/multisig/test/SignerManager.test.ts delete mode 100644 contracts/src/multisig/test/mocks/MockSignerManager.compact delete mode 100644 contracts/src/multisig/test/simulators/SignerManagerSimulator.ts delete mode 100644 contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts diff --git a/contracts/src/multisig/signer/SignerManager.compact b/contracts/src/multisig/signer/SignerManager.compact deleted file mode 100644 index 9eb2d4f65..000000000 --- a/contracts/src/multisig/signer/SignerManager.compact +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/SignerManager.compact) - -pragma language_version >= 0.23.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 ────────────────────────────────────────────────────────────────── - - export ledger _signers: Set; - export ledger _signerCount: Uint<8>; - export 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)); - _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/test/SignerManager.test.ts b/contracts/src/multisig/test/SignerManager.test.ts deleted file mode 100644 index 1ead55f75..000000000 --- a/contracts/src/multisig/test/SignerManager.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { - SignerManagerSimulator, - type SignerSet, -} 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: SignerSet = [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: SignerSet = [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/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact deleted file mode 100644 index ba59e423b..000000000 --- a/contracts/src/multisig/test/mocks/MockSignerManager.compact +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. -// This contract exposes internal circuits and bypasses safety checks that the -// corresponding production contract relies on. DO NOT deploy or use this -// contract in any production application. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; - -import "../../signer/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/simulators/SignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts deleted file mode 100644 index 151aee48a..000000000 --- a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-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'; - -/** - * A fixed set of exactly three signers, matching the - * `Vector<3, Either>` the underlying - * `MockSignerManager` constructor expects. - */ -export type SignerSet = readonly [ - Either, - Either, - Either, -]; - -/** - * Type constructor args - */ -type SignerManagerArgs = readonly [signers: SignerSet, 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: SignerSet, - 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/test/witnesses/SignerManagerWitnesses.ts b/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts deleted file mode 100644 index 7bf6a25ad..000000000 --- a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/SignerManagerWitnesses.ts) - -export type SignerManagerPrivateState = Record; -export const SignerManagerPrivateState: SignerManagerPrivateState = {}; -export const SignerManagerWitnesses = () => ({}); From ab066db3d75241f46e5e324c8b01dd0bfd39a4c8 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 22 Jun 2026 16:15:45 +0200 Subject: [PATCH 05/17] refactor(multisig): rename Signer module to SignerManager Rename the hardened Signer into the SignerManager name freed by the previous commit, keeping the more descriptive name on the init-safe implementation. Git records this as a rename, so the module's history, blame, and log --follow carry over. * signer/Signer.compact -> signer/SignerManager.compact (module decl + assert prefix Signer: -> SignerManager:) * mock, simulator, witnesses, and test renamed to match * repoint the ShieldedMultiSigV3 import and migrate the preset tests to the new module name and messages (the init-safe module reports "threshold must not be zero" rather than the legacy "threshold must be > 0") compact:multisig compiles 22/22; multisig suite passes 249/249. Refs: OpenZeppelin/compact-contracts#619 --- .../presets/ShieldedMultiSigV3.compact | 2 +- .../{Signer.compact => SignerManager.compact} | 28 ++++----- .../multisig/test/ShieldedMultiSig.test.ts | 2 +- .../multisig/test/ShieldedMultiSigV2.test.ts | 2 +- .../multisig/test/ShieldedMultiSigV3.test.ts | 6 +- .../{Signer.test.ts => SignerManager.test.ts} | 60 +++++++++---------- ...gner.compact => MockSignerManager.compact} | 4 +- ...Simulator.ts => SignerManagerSimulator.ts} | 34 +++++------ .../test/witnesses/SignerManagerWitnesses.ts | 6 ++ .../test/witnesses/SignerWitnesses.ts | 6 -- 10 files changed, 75 insertions(+), 75 deletions(-) rename contracts/src/multisig/signer/{Signer.compact => SignerManager.compact} (91%) rename contracts/src/multisig/test/{Signer.test.ts => SignerManager.test.ts} (84%) rename contracts/src/multisig/test/mocks/{MockSigner.compact => MockSignerManager.compact} (94%) rename contracts/src/multisig/test/simulators/{SignerSimulator.ts => SignerManagerSimulator.ts} (67%) create mode 100644 contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/SignerWitnesses.ts diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index fb6719c71..c5f9ba623 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -40,7 +40,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../signer/Signer"> prefix Signer_; +import "../signer/SignerManager"> prefix Signer_; import "../../utils/Utils" prefix Utils_; // For testing export { ZswapCoinPublicKey }; diff --git a/contracts/src/multisig/signer/Signer.compact b/contracts/src/multisig/signer/SignerManager.compact similarity index 91% rename from contracts/src/multisig/signer/Signer.compact rename to contracts/src/multisig/signer/SignerManager.compact index 7a8207ec0..cd98b3c8a 100644 --- a/contracts/src/multisig/signer/Signer.compact +++ b/contracts/src/multisig/signer/SignerManager.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/Signer.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/signer/SignerManager.compact) pragma language_version >= 0.23.0; /** - * @module Signer + * @module SignerManager * @description Manages signer registry, threshold enforcement, and signer * validation for multisig governance contracts. * @@ -15,7 +15,7 @@ pragma language_version >= 0.23.0; * - `Bytes<32>` for commitment-based identity (e.g., hash of ECDSA public key) * - `JubjubPoint` for Schnorr/MuSig aggregated key * - * The Signer module does not resolve caller identity. It receives a validated + * The SignerManager module 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. @@ -43,7 +43,7 @@ pragma language_version >= 0.23.0; * call `initialize` outside of the constructor context because * this could corrupt the signer set and threshold configuration. */ -module Signer { +module SignerManager { import CompactStandardLibrary; // ─── State ────────────────────────────────────────────────────────────────── @@ -110,7 +110,7 @@ module Signer { */ export circuit assertSigner(caller: T): [] { assertInitialized(); - assert(isSigner(caller), "Signer: not a signer"); + assert(isSigner(caller), "SignerManager: not a signer"); } /** @@ -129,8 +129,8 @@ module Signer { */ export circuit assertThresholdMet(approvalCount: Uint<8>): [] { assertInitialized(); - assert(_threshold != 0, "Signer: threshold not set"); - assert(approvalCount >= _threshold, "Signer: threshold not met"); + assert(_threshold != 0, "SignerManager: threshold not set"); + assert(approvalCount >= _threshold, "SignerManager: threshold not met"); } // ─── View ────────────────────────────────────────────────────────── @@ -200,7 +200,7 @@ module Signer { export circuit _addSigner(signer: T): [] { assert( !isSigner(signer), - "Signer: signer already active" + "SignerManager: signer already active" ); _signers.insert(disclose(signer)); @@ -225,10 +225,10 @@ module Signer { * @returns {[]} Empty tuple. */ export circuit _removeSigner(signer: T): [] { - assert(isSigner(signer), "Signer: not a signer"); + assert(isSigner(signer), "SignerManager: not a signer"); const newCount = _signerCount - 1 as Uint<8>; - assert(newCount >= _threshold, "Signer: removal would breach threshold"); + assert(newCount >= _threshold, "SignerManager: removal would breach threshold"); _signers.remove(disclose(signer)); _signerCount = newCount; @@ -252,7 +252,7 @@ module Signer { * @returns {[]} Empty tuple. */ export circuit _changeThreshold(newThreshold: Uint<8>): [] { - assert(newThreshold <= _signerCount, "Signer: threshold exceeds signer count"); + assert(newThreshold <= _signerCount, "SignerManager: threshold exceeds signer count"); _setThreshold(newThreshold); } @@ -278,7 +278,7 @@ module Signer { * @returns {[]} Empty tuple. */ export circuit _setThreshold(newThreshold: Uint<8>): [] { - assert(newThreshold != 0, "Signer: threshold must not be zero"); + assert(newThreshold != 0, "SignerManager: threshold must not be zero"); _threshold = disclose(newThreshold); } @@ -294,7 +294,7 @@ module Signer { * @return {[]} - Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "Signer: contract not initialized"); + assert(_isInitialized, "SignerManager: contract not initialized"); } /** @@ -307,6 +307,6 @@ module Signer { * @return {[]} - Empty tuple. */ circuit assertNotInitialized(): [] { - assert(!_isInitialized, "Signer: contract already initialized"); + assert(!_isInitialized, "SignerManager: contract already initialized"); } } diff --git a/contracts/src/multisig/test/ShieldedMultiSig.test.ts b/contracts/src/multisig/test/ShieldedMultiSig.test.ts index 6fb97363e..30742bb40 100644 --- a/contracts/src/multisig/test/ShieldedMultiSig.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSig.test.ts @@ -62,7 +62,7 @@ describe('ShieldedMultiSig', () => { it('should fail with zero threshold', () => { expect(() => { new ShieldedMultiSigSimulator(SIGNERS, 0n); - }).toThrow('SignerManager: threshold must be > 0'); + }).toThrow('SignerManager: threshold must not be zero'); }); it('should fail with threshold exceeding signer count', () => { diff --git a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts index ebe073163..8e1c10480 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts @@ -92,7 +92,7 @@ describe('ShieldedMultiSigV2', () => { it('should fail with zero threshold', () => { expect(() => { new ShieldedMultiSigV2Simulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 0n); - }).toThrow('SignerManager: threshold must be > 0'); + }).toThrow('SignerManager: threshold must not be zero'); }); it('should fail with threshold greater than 2', () => { diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts index 240c9bafe..8e750662b 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts @@ -92,7 +92,7 @@ describe('ShieldedMultiSigV3', () => { TOKEN_DOMAIN, [COMMITMENT1, COMMITMENT1, COMMITMENT2], ); - }).toThrow('Signer: signer already active'); + }).toThrow('SignerManager: signer already active'); }); it('should store token domain', () => { @@ -235,7 +235,7 @@ describe('ShieldedMultiSigV3', () => { [PK1, NON_SIGNER_PK], [DUMMY_SIG, DUMMY_SIG], ); - }).toThrow('Signer: not a signer'); + }).toThrow('SignerManager: not a signer'); }); it('should increment nonce after mint', () => { @@ -330,7 +330,7 @@ describe('ShieldedMultiSigV3', () => { [PK1, NON_SIGNER_PK], [DUMMY_SIG, DUMMY_SIG], ); - }).toThrow('Signer: not a signer'); + }).toThrow('SignerManager: not a signer'); }); it('should reject wrong token color', () => { diff --git a/contracts/src/multisig/test/Signer.test.ts b/contracts/src/multisig/test/SignerManager.test.ts similarity index 84% rename from contracts/src/multisig/test/Signer.test.ts rename to contracts/src/multisig/test/SignerManager.test.ts index d915db18c..9394aa57d 100644 --- a/contracts/src/multisig/test/Signer.test.ts +++ b/contracts/src/multisig/test/SignerManager.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { SignerSimulator } from './simulators/SignerSimulator.js'; +import { SignerManagerSimulator } from './simulators/SignerManagerSimulator.js'; const THRESHOLD = 2n; const IS_INIT = true; @@ -12,13 +12,13 @@ const SIGNERS = [SIGNER, SIGNER2, SIGNER3]; const OTHER = new Uint8Array(32).fill(4); const OTHER2 = new Uint8Array(32).fill(5); -let contract: SignerSimulator; +let contract: SignerManagerSimulator; -describe('Signer', () => { +describe('SignerManager', () => { describe('when not initialized', () => { beforeEach(() => { const isNotInit = false; - contract = new SignerSimulator(SIGNERS, 0n, isNotInit); + contract = new SignerManagerSimulator(SIGNERS, 0n, isNotInit); }); const circuitsRequiringInit: [string, unknown[]][] = [ @@ -31,11 +31,11 @@ describe('Signer', () => { it.each(circuitsRequiringInit)('%s should fail', (circuitName, args) => { expect(() => { ( - contract[circuitName as keyof SignerSimulator] as ( + contract[circuitName as keyof SignerManagerSimulator] as ( ...a: unknown[] ) => unknown )(...args); - }).toThrow('Signer: contract not initialized'); + }).toThrow('SignerManager: contract not initialized'); }); it('isSigner should succeed (no init guard)', () => { @@ -46,25 +46,25 @@ describe('Signer', () => { describe('initialization', () => { it('should fail with a threshold of zero', () => { expect(() => { - new SignerSimulator(SIGNERS, 0n, IS_INIT); - }).toThrow('Signer: threshold must not be zero'); + new SignerManagerSimulator(SIGNERS, 0n, IS_INIT); + }).toThrow('SignerManager: threshold must not be zero'); }); it('should fail when threshold exceeds signer count', () => { expect(() => { - new SignerSimulator(SIGNERS, BigInt(SIGNERS.length) + 1n, IS_INIT); - }).toThrow('Signer: threshold exceeds signer count'); + new SignerManagerSimulator(SIGNERS, BigInt(SIGNERS.length) + 1n, IS_INIT); + }).toThrow('SignerManager: threshold exceeds signer count'); }); it('should fail with duplicate signers', () => { const duplicateSigners = [SIGNER, SIGNER, SIGNER2]; expect(() => { - new SignerSimulator(duplicateSigners, THRESHOLD, IS_INIT); - }).toThrow('Signer: signer already active'); + new SignerManagerSimulator(duplicateSigners, THRESHOLD, IS_INIT); + }).toThrow('SignerManager: signer already active'); }); it('should initialize with threshold equal to signer count', () => { - const contract = new SignerSimulator( + const contract = new SignerManagerSimulator( SIGNERS, BigInt(SIGNERS.length), IS_INIT, @@ -74,7 +74,7 @@ describe('Signer', () => { it('should initialize', () => { expect(() => { - contract = new SignerSimulator(SIGNERS, THRESHOLD, IS_INIT); + contract = new SignerManagerSimulator(SIGNERS, THRESHOLD, IS_INIT); }).not.toThrow(); expect(contract.getThreshold()).toEqual(THRESHOLD); @@ -87,15 +87,15 @@ describe('Signer', () => { }); it('should fail when initialized twice', () => { - contract = new SignerSimulator(SIGNERS, THRESHOLD, IS_INIT); + contract = new SignerManagerSimulator(SIGNERS, THRESHOLD, IS_INIT); expect(() => { contract.initialize(SIGNERS, THRESHOLD); - }).toThrow('Signer: contract already initialized'); + }).toThrow('SignerManager: contract already initialized'); }); }); beforeEach(() => { - contract = new SignerSimulator(SIGNERS, THRESHOLD, IS_INIT); + contract = new SignerManagerSimulator(SIGNERS, THRESHOLD, IS_INIT); }); describe('assertSigner', () => { @@ -106,7 +106,7 @@ describe('Signer', () => { it('should fail with bad signer', () => { expect(() => { contract.assertSigner(OTHER); - }).toThrow('Signer: not a signer'); + }).toThrow('SignerManager: not a signer'); }); }); @@ -122,13 +122,13 @@ describe('Signer', () => { it('should fail when approvals are below threshold', () => { expect(() => { contract.assertThresholdMet(THRESHOLD - 1n); - }).toThrow('Signer: threshold not met'); + }).toThrow('SignerManager: threshold not met'); }); it('should fail with zero approvals', () => { expect(() => { contract.assertThresholdMet(0n); - }).toThrow('Signer: threshold not met'); + }).toThrow('SignerManager: threshold not met'); }); }); @@ -187,7 +187,7 @@ describe('Signer', () => { expect(() => { contract._addSigner(OTHER); - }).toThrow('Signer: signer already active'); + }).toThrow('SignerManager: signer already active'); }); it('should add multiple new signers', () => { @@ -221,7 +221,7 @@ describe('Signer', () => { it('should fail when removing a non-signer', () => { expect(() => { contract._removeSigner(OTHER); - }).toThrow('Signer: not a signer'); + }).toThrow('SignerManager: not a signer'); }); it('should fail when removal would breach threshold', () => { @@ -229,7 +229,7 @@ describe('Signer', () => { expect(() => { contract._removeSigner(SIGNER2); - }).toThrow('Signer: removal would breach threshold'); + }).toThrow('SignerManager: removal would breach threshold'); }); it('should allow removal after threshold is lowered', () => { @@ -274,13 +274,13 @@ describe('Signer', () => { it('should fail with a threshold of zero', () => { expect(() => { contract._changeThreshold(0n); - }).toThrow('Signer: threshold must not be zero'); + }).toThrow('SignerManager: threshold must not be zero'); }); it('should fail when threshold exceeds signer count', () => { expect(() => { contract._changeThreshold(BigInt(SIGNERS.length) + 1n); - }).toThrow('Signer: threshold exceeds signer count'); + }).toThrow('SignerManager: threshold exceeds signer count'); }); it('should allow threshold equal to signer count', () => { @@ -294,7 +294,7 @@ describe('Signer', () => { expect(() => { contract.assertThresholdMet(2n); - }).toThrow('Signer: threshold not met'); + }).toThrow('SignerManager: threshold not met'); expect(() => contract.assertThresholdMet(3n)).not.toThrow(); }); @@ -303,7 +303,7 @@ describe('Signer', () => { describe('_setThreshold', () => { beforeEach(() => { const isNotInit = false; - contract = new SignerSimulator(SIGNERS, 0n, isNotInit); + contract = new SignerManagerSimulator(SIGNERS, 0n, isNotInit); }); it('should have an empty state', () => { @@ -328,14 +328,14 @@ describe('Signer', () => { it('should fail with zero threshold', () => { expect(() => { contract._setThreshold(0n); - }).toThrow('Signer: threshold must not be zero'); + }).toThrow('SignerManager: threshold must not be zero'); }); }); describe('custom setup flow when not initialized', () => { beforeEach(() => { const isNotInit = false; - contract = new SignerSimulator(SIGNERS, 0n, isNotInit); + contract = new SignerManagerSimulator(SIGNERS, 0n, isNotInit); }); it('should have no signers by default', () => { @@ -370,7 +370,7 @@ describe('Signer', () => { it('should fail _changeThreshold before signers are added', () => { expect(() => { contract._changeThreshold(2n); - }).toThrow('Signer: threshold exceeds signer count'); + }).toThrow('SignerManager: threshold exceeds signer count'); }); }); }); diff --git a/contracts/src/multisig/test/mocks/MockSigner.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact similarity index 94% rename from contracts/src/multisig/test/mocks/MockSigner.compact rename to contracts/src/multisig/test/mocks/MockSignerManager.compact index 355393d0c..9bce84848 100644 --- a/contracts/src/multisig/test/mocks/MockSigner.compact +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -9,8 +9,8 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../signer/Signer"> prefix Signer_; -import "../../signer/Signer">; +import "../../signer/SignerManager"> prefix Signer_; +import "../../signer/SignerManager">; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; export { _signers, _signerCount, _threshold }; diff --git a/contracts/src/multisig/test/simulators/SignerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts similarity index 67% rename from contracts/src/multisig/test/simulators/SignerSimulator.ts rename to contracts/src/multisig/test/simulators/SignerManagerSimulator.ts index 37d0fa82d..2e8c3f722 100644 --- a/contracts/src/multisig/test/simulators/SignerSimulator.ts +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -4,47 +4,47 @@ import { } from '@openzeppelin/compact-simulator'; import { ledger, - Contract as MockSigner, -} from '../../../../artifacts/MockSigner/contract/index.js'; + Contract as MockSignerManager, +} from '../../../../artifacts/MockSignerManager/contract/index.js'; import { - SignerPrivateState, - SignerWitnesses, -} from '../witnesses/SignerWitnesses.js'; + SignerManagerPrivateState, + SignerManagerWitnesses, +} from '../witnesses/SignerManagerWitnesses.js'; /** * Type constructor args */ -type SignerArgs = readonly [ +type SignerManagerArgs = readonly [ signers: Uint8Array[], thresh: bigint, isInit: boolean, ]; -const SignerSimulatorBase = createSimulator< - SignerPrivateState, +const SignerManagerSimulatorBase = createSimulator< + SignerManagerPrivateState, ReturnType, - ReturnType, - MockSigner, - SignerArgs + ReturnType, + MockSignerManager, + SignerManagerArgs >({ - contractFactory: (witnesses) => new MockSigner(witnesses), - defaultPrivateState: () => SignerPrivateState, + contractFactory: (witnesses) => new MockSignerManager(witnesses), + defaultPrivateState: () => SignerManagerPrivateState, contractArgs: (signers, thresh, isInit) => [signers, thresh, isInit], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => SignerWitnesses(), + witnessesFactory: () => SignerManagerWitnesses(), }); /** * Signer Simulator */ -export class SignerSimulator extends SignerSimulatorBase { +export class SignerManagerSimulator extends SignerManagerSimulatorBase { constructor( signers: Uint8Array[], thresh: bigint, isInit: boolean, options: BaseSimulatorOptions< - SignerPrivateState, - ReturnType + SignerManagerPrivateState, + ReturnType > = {}, ) { super([signers, thresh, isInit], options); diff --git a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts b/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts new file mode 100644 index 000000000..7bf6a25ad --- /dev/null +++ b/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/SignerManagerWitnesses.ts) + +export type SignerManagerPrivateState = Record; +export const SignerManagerPrivateState: SignerManagerPrivateState = {}; +export const SignerManagerWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/SignerWitnesses.ts b/contracts/src/multisig/test/witnesses/SignerWitnesses.ts deleted file mode 100644 index 1decffc9e..000000000 --- a/contracts/src/multisig/test/witnesses/SignerWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/SignerWitnesses.ts) - -export type SignerPrivateState = Record; -export const SignerPrivateState: SignerPrivateState = {}; -export const SignerWitnesses = () => ({}); From 9e9f1d645f45a4c87fbeca69da792ca89abefe9f Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 24 Jun 2026 01:07:45 +0200 Subject: [PATCH 06/17] feat(token): make supply an opt-in extension Move supply accounting out of the native shielded token core modules into standalone extensions, because tracking it is a privacy trade-off, not just modularity. A contract-mediated burn is amount-private on its own: the coin ops (receiveShielded / sendImmediateShielded / sendShielded) emit only commitments and nullifiers, and the disclose() wrappers they require are compiler permission markers, not disclosure sinks. The sole thing that puts a burned amount into the public transcript is the supply-counter ledger write. So a bare _burn / _burnFromContract hides the amount; composing supply trades that privacy for on-chain auditability. Mint amounts stay public regardless, via the protocol shieldedMints effect. * token: strip _totalMinted / _totalBurned, the totalMinted / totalBurned / totalSupply getters, and _addMinted / _addBurned from NativeShieldedToken and NativeShieldedTokenFamily; mint/burn keep their coin ops and disclose wrappers. * extensions: add NativeShieldedTokenSupply (scalar) and NativeShieldedTokenFamilySupply (per-domain), standalone modules mirroring NativeShieldedTokenDerivedNonce. The consumer pairs _addMinted / _addBurned with the matching mint/burn op. Supply getters no longer gate on init (a counter reading 0 pre-init is correct). * tests/mocks: wire the supply extension into the unit + integration deployables and add unit and integration coverage for the standard. --- .../src/token/NativeShieldedToken.compact | 126 +--- .../token/NativeShieldedTokenFamily.compact | 136 +--- .../NativeShieldedTokenFamilySupply.compact | 153 +++++ .../NativeShieldedTokenSupply.compact | 134 ++++ .../test/NativeShieldedToken.property.test.ts | 63 ++ .../token/test/NativeShieldedToken.test.ts | 291 +++++++++ .../NativeShieldedTokenDerivedNonce.test.ts | 99 +++ .../test/NativeShieldedTokenFamily.test.ts | 192 ++++++ .../mocks/MockNativeShieldedToken.compact | 59 +- .../MockNativeShieldedTokenFamily.compact | 59 +- .../NativeShieldedTokenFamilySimulator.ts | 205 ++++++ .../NativeShieldedTokenSimulator.ts | 216 +++++++ contracts/test/integration/_harness/cma.ts | 251 ++++++++ contracts/test/integration/_harness/deploy.ts | 84 +++ .../test/integration/_harness/effects.ts | 131 ++++ .../integration/_harness/globalTeardown.ts | 14 + .../test/integration/_harness/network.ts | 63 ++ .../test/integration/_harness/ownWallet.ts | 289 +++++++++ .../test/integration/_harness/providers.ts | 56 ++ contracts/test/integration/_harness/wallet.ts | 17 + .../test/integration/_harness/walletPool.ts | 70 +++ .../_mocks/NativeShieldedTokenV1.compact | 147 +++++ .../fixtures/nativeShieldedToken.ts | 238 +++++++ .../test/integration/fixtures/walletPool.ts | 150 +++++ .../specs/nativeShieldedToken/burn.spec.ts | 118 ++++ .../nativeShieldedToken/collision.spec.ts | 70 +++ .../specs/nativeShieldedToken/effects.spec.ts | 69 ++ .../specs/nativeShieldedToken/mint.spec.ts | 105 ++++ .../specs/nativeShieldedToken/privacy.spec.ts | 88 +++ .../specs/nativeShieldedToken/smoke.spec.ts | 83 +++ .../specs/nativeShieldedToken/supply.spec.ts | 68 ++ .../nativeShieldedToken/unrestricted.spec.ts | 46 ++ contracts/vitest.integration-net.config.ts | 26 + contracts/vitest.integration.config.ts | 10 +- mip-xxxx-native-shielded-token.md | 594 ------------------ 35 files changed, 3700 insertions(+), 820 deletions(-) create mode 100644 contracts/src/token/extensions/NativeShieldedTokenFamilySupply.compact create mode 100644 contracts/src/token/extensions/NativeShieldedTokenSupply.compact create mode 100644 contracts/src/token/test/NativeShieldedToken.property.test.ts create mode 100644 contracts/src/token/test/NativeShieldedToken.test.ts create mode 100644 contracts/src/token/test/NativeShieldedTokenDerivedNonce.test.ts create mode 100644 contracts/src/token/test/NativeShieldedTokenFamily.test.ts create mode 100644 contracts/src/token/test/simulators/NativeShieldedTokenFamilySimulator.ts create mode 100644 contracts/src/token/test/simulators/NativeShieldedTokenSimulator.ts create mode 100644 contracts/test/integration/_harness/cma.ts create mode 100644 contracts/test/integration/_harness/deploy.ts create mode 100644 contracts/test/integration/_harness/effects.ts create mode 100644 contracts/test/integration/_harness/globalTeardown.ts create mode 100644 contracts/test/integration/_harness/network.ts create mode 100644 contracts/test/integration/_harness/ownWallet.ts create mode 100644 contracts/test/integration/_harness/providers.ts create mode 100644 contracts/test/integration/_harness/wallet.ts create mode 100644 contracts/test/integration/_harness/walletPool.ts create mode 100644 contracts/test/integration/_mocks/NativeShieldedTokenV1.compact create mode 100644 contracts/test/integration/fixtures/nativeShieldedToken.ts create mode 100644 contracts/test/integration/fixtures/walletPool.ts create mode 100644 contracts/test/integration/specs/nativeShieldedToken/burn.spec.ts create mode 100644 contracts/test/integration/specs/nativeShieldedToken/collision.spec.ts create mode 100644 contracts/test/integration/specs/nativeShieldedToken/effects.spec.ts create mode 100644 contracts/test/integration/specs/nativeShieldedToken/mint.spec.ts create mode 100644 contracts/test/integration/specs/nativeShieldedToken/privacy.spec.ts create mode 100644 contracts/test/integration/specs/nativeShieldedToken/smoke.spec.ts create mode 100644 contracts/test/integration/specs/nativeShieldedToken/supply.spec.ts create mode 100644 contracts/test/integration/specs/nativeShieldedToken/unrestricted.spec.ts create mode 100644 contracts/vitest.integration-net.config.ts delete mode 100644 mip-xxxx-native-shielded-token.md diff --git a/contracts/src/token/NativeShieldedToken.compact b/contracts/src/token/NativeShieldedToken.compact index 6ddb16208..37a819c36 100644 --- a/contracts/src/token/NativeShieldedToken.compact +++ b/contracts/src/token/NativeShieldedToken.compact @@ -63,16 +63,34 @@ pragma language_version >= 0.23.0; * `sendShielded`; any change is auto-received by the contract and returned * so the consumer can persist it. * - * # Supply accounting + * # Supply accounting (opt-in extension) and burn-amount privacy * - * - `totalMinted()` is EXACT: color derivation guarantees that every coin of - * this contract's color originates from this module's mints. - * - `totalBurned()` is a LOWER BOUND: holders can send coins directly to the - * burn address without going through the contract, and such burns are - * invisible to ledger state. - * - `totalSupply()` = minted - burned is therefore an UPPER BOUND on - * circulating supply. Exact circulating supply is not knowable for native - * shielded tokens; these are the strongest guarantees available. + * - This module tracks NO supply totals. Supply accounting is the optional + * `NativeShieldedTokenSupply` extension; compose it only if you need an + * on-chain `totalSupply()`. + * + * - The reason it is opt-in is a privacy trade-off, not just modularity. A + * contract-mediated burn here is amount-PRIVATE: the coin operations + * (`receiveShielded`, `sendImmediateShielded`, `sendShielded`) emit only + * commitments and nullifiers (hashes of the coin), never the plaintext + * value. The `disclose()` wrappers they require are compiler permission + * markers, not disclosure sinks. The ONLY thing that puts a burned amount + * into the public transcript is writing it to a public ledger cell. So a + * bare `_burn` / `_burnFromContract` hides the burned amount; composing the + * supply extension (which writes `_totalBurned`) trades that privacy for + * auditability. + * + * - Mint amounts are public REGARDLESS of the extension: `mintShieldedToken` + * emits the amount in the protocol-level `shieldedMints` effect, which no + * amount of accounting choice can suppress. The extension changes burn + * visibility, not mint visibility. + * + * - When the supply extension IS composed: `totalMinted()` is EXACT (color + * derivation guarantees every coin of this contract's color originates from + * this module's mints); `totalBurned()` is a LOWER BOUND (holders can send + * coins straight to the burn address, bypassing the contract); and + * `totalSupply()` = minted - burned is an UPPER BOUND on circulating supply. + * Exact circulating supply is not knowable for native shielded tokens. * * # Wallet visibility * @@ -115,14 +133,6 @@ module NativeShieldedToken { * address it determines this token's color. */ export sealed ledger _domain: Bytes<32>; - /** - * @description Exact amount minted. See "Supply accounting". - */ - export ledger _totalMinted: Uint<128>; - /** - * @description Contract-mediated amount burned (lower bound). - */ - export ledger _totalBurned: Uint<128>; export sealed ledger _name: Opaque<"string">; export sealed ledger _symbol: Opaque<"string">; @@ -250,59 +260,6 @@ module NativeShieldedToken { return tokenType(_domain, kernel.self()); } - /** - * @description Returns the exact amount ever minted. - * - * @circuitInfo k=6, rows=28 - * - * Requirements: - * - * - Contract is initialized. - * - * @return {Uint<128>} - The total amount minted. - */ - export circuit totalMinted(): Uint<128> { - assertInitialized(); - return _totalMinted; - } - - /** - * @description Returns the contract-mediated amount burned. - * @notice This is a lower bound: coins sent directly to the burn address - * without going through this contract are not counted. - * - * @circuitInfo k=6, rows=28 - * - * Requirements: - * - * - Contract is initialized. - * - * @return {Uint<128>} - The total amount burned through this contract. - */ - export circuit totalBurned(): Uint<128> { - assertInitialized(); - return _totalBurned; - } - - /** - * @description Returns `totalMinted() - totalBurned()`. - * @notice This is an UPPER BOUND on circulating supply, not an exact value: - * burns that bypass the contract are invisible. See "Supply accounting" in - * the module notes. - * - * @circuitInfo k=9, rows=88 - * - * Requirements: - * - * - Contract is initialized. - * - * @return {Uint<128>} - The upper bound on tokens in existence. - */ - export circuit totalSupply(): Uint<128> { - assertInitialized(); - return (_totalMinted - _totalBurned) as Uint<128>; - } - /** * @description Mints `amount` of the token to `recipient`, using a * caller-supplied nonce. @@ -339,7 +296,6 @@ module NativeShieldedToken { assertInitialized(); assert(!Utils_isKeyOrAddressZero(recipient), "NativeShieldedToken: invalid recipient"); - _addMinted(amount); return mintShieldedToken(_domain, disclose(amount), disclose(nonce), disclose(recipient)); } @@ -390,7 +346,6 @@ module NativeShieldedToken { receiveShielded(disclose(coin)); const sendRes = sendImmediateShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); - _addBurned(amount); if (disclose(sendRes.change.is_some)) { const refundRes = sendImmediateShielded( @@ -434,34 +389,7 @@ module NativeShieldedToken { assert(coin.value >= amount, "NativeShieldedToken: insufficient coin value"); const sendRes = sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); - _addBurned(amount); return disclose(sendRes.change); } - - /** - * @description Adds `amount` to the exact minted total. - * @dev Checks for overflow in order to output a readable error message. - * - * @param {Uint<64>} amount - The minted amount. - * @return {[]} - Empty tuple. - */ - circuit _addMinted(amount: Uint<64>): [] { - const MAX_UINT128 = 340282366920938463463374607431768211455; - assert(MAX_UINT128 - _totalMinted >= amount, "NativeShieldedToken: arithmetic overflow"); - _totalMinted = disclose((_totalMinted + amount) as Uint<128>); - } - - /** - * @description Adds `amount` to the contract-mediated burned total. - * @dev No overflow guard is needed: every coin of this contract's color - * originates from `_addMinted`-tracked mints, so burned can never exceed - * the overflow-checked minted total. - * - * @param {Uint<128>} amount - The burned amount. - * @return {[]} - Empty tuple. - */ - circuit _addBurned(amount: Uint<128>): [] { - _totalBurned = disclose((_totalBurned + amount) as Uint<128>); - } } diff --git a/contracts/src/token/NativeShieldedTokenFamily.compact b/contracts/src/token/NativeShieldedTokenFamily.compact index 6d865cc6d..6cef4ac19 100644 --- a/contracts/src/token/NativeShieldedTokenFamily.compact +++ b/contracts/src/token/NativeShieldedTokenFamily.compact @@ -72,16 +72,35 @@ pragma language_version >= 0.23.0; * `sendShielded`; any change is auto-received by the contract and returned * so the consumer can persist it. * - * # Supply accounting + * # Supply accounting (opt-in extension) and burn-amount privacy * - * - `totalMinted(domain)` is EXACT: color derivation guarantees that every - * coin of this contract's colors originates from this module's mints. - * - `totalBurned(domain)` is a LOWER BOUND: holders can send coins directly - * to the burn address without going through the contract, and such burns - * are invisible to ledger state. - * - `totalSupply(domain)` = minted - burned is therefore an UPPER BOUND on - * circulating supply. Exact circulating supply is not knowable for native - * shielded tokens; these are the strongest guarantees available. + * - This module tracks NO supply totals. Per-domain accounting is the optional + * `NativeShieldedTokenFamilySupply` extension; compose it only if you need an + * on-chain `totalSupply(domain)`. + * + * - The reason it is opt-in is a privacy trade-off, not just modularity. A + * contract-mediated burn here is amount-PRIVATE: the coin operations + * (`receiveShielded`, `sendImmediateShielded`, `sendShielded`) emit only + * commitments and nullifiers (hashes of the coin), never the plaintext + * value. The `disclose()` wrappers they require are compiler permission + * markers, not disclosure sinks. The ONLY thing that puts a burned amount + * into the public transcript is writing it to a public ledger cell. So a + * bare `_burn` / `_burnFromContract` hides the burned amount; composing the + * supply extension (which writes `_totalBurned`) trades that privacy for + * auditability. + * + * - Mint amounts are public REGARDLESS of the extension: `mintShieldedToken` + * emits the amount in the protocol-level `shieldedMints` effect, which no + * amount of accounting choice can suppress. The extension changes burn + * visibility, not mint visibility. + * + * - When the supply extension IS composed: `totalMinted(domain)` is EXACT + * (color derivation guarantees every coin of this contract's colors + * originates from this module's mints); `totalBurned(domain)` is a LOWER + * BOUND (holders can send coins straight to the burn address, bypassing the + * contract); and `totalSupply(domain)` = minted - burned is an UPPER BOUND + * on circulating supply. Exact circulating supply is not knowable for native + * shielded tokens. * * # Wallet visibility * @@ -109,16 +128,6 @@ module NativeShieldedTokenFamily { */ export ledger _isInitialized: Boolean; - /** - * @description Exact amount minted per domain. See "Supply accounting". - * @type {Map, Uint<128>>} _totalMinted - */ - export ledger _totalMinted: Map, Uint<128>>; - /** - * @description Contract-mediated amount burned per domain (lower bound). - * @type {Map, Uint<128>>} _totalBurned - */ - export ledger _totalBurned: Map, Uint<128>>; export sealed ledger _name: Opaque<"string">; export sealed ledger _symbol: Opaque<"string">; export sealed ledger _decimals: Uint<8>; @@ -238,62 +247,6 @@ module NativeShieldedTokenFamily { return tokenType(disclose(domain), kernel.self()); } - /** - * @description Returns the exact amount ever minted for `domain`. - * - * Requirements: - * - * - Contract is initialized. - * - * @param {Bytes<32>} domain - 32-byte token discriminator. - * @return {Uint<128>} - The total amount minted. - */ - export circuit totalMinted(domain: Bytes<32>): Uint<128> { - assertInitialized(); - if (!_totalMinted.member(disclose(domain))) { - return 0; - } - return _totalMinted.lookup(disclose(domain)); - } - - /** - * @description Returns the contract-mediated amount burned for `domain`. - * @notice This is a lower bound: coins sent directly to the burn address - * without going through this contract are not counted. - * - * Requirements: - * - * - Contract is initialized. - * - * @param {Bytes<32>} domain - 32-byte token discriminator. - * @return {Uint<128>} - The total amount burned through this contract. - */ - export circuit totalBurned(domain: Bytes<32>): Uint<128> { - assertInitialized(); - if (!_totalBurned.member(disclose(domain))) { - return 0; - } - return _totalBurned.lookup(disclose(domain)); - } - - /** - * @description Returns `totalMinted(domain) - totalBurned(domain)`. - * @notice This is an UPPER BOUND on circulating supply, not an exact value: - * burns that bypass the contract are invisible. See "Supply accounting" in - * the module notes. - * - * Requirements: - * - * - Contract is initialized. - * - * @param {Bytes<32>} domain - 32-byte token discriminator. - * @return {Uint<128>} - The upper bound on tokens in existence. - */ - export circuit totalSupply(domain: Bytes<32>): Uint<128> { - assertInitialized(); - return (totalMinted(domain) - totalBurned(domain)) as Uint<128>; - } - /** * @description Mints `amount` of the token identified by `domain` to * `recipient`, using a caller-supplied nonce. @@ -330,7 +283,6 @@ module NativeShieldedTokenFamily { assertInitialized(); assert(!Utils_isKeyOrAddressZero(recipient), "NativeShieldedTokenFamily: invalid recipient"); - _addMinted(domain, amount); return mintShieldedToken(disclose(domain), disclose(amount), disclose(nonce), disclose(recipient)); } @@ -381,7 +333,6 @@ module NativeShieldedTokenFamily { receiveShielded(disclose(coin)); const sendRes = sendImmediateShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); - _addBurned(domain, amount); if (disclose(sendRes.change.is_some)) { const refundRes = sendImmediateShielded( @@ -425,38 +376,7 @@ module NativeShieldedTokenFamily { assert(coin.value >= amount, "NativeShieldedTokenFamily: insufficient coin value"); const sendRes = sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); - _addBurned(domain, amount); return disclose(sendRes.change); } - - /** - * @description Adds `amount` to the exact minted total for `domain`. - * @dev Checks for overflow in order to output a readable error message. - * - * @param {Bytes<32>} domain - 32-byte token discriminator. - * @param {Uint<64>} amount - The minted amount. - * @return {[]} - Empty tuple. - */ - circuit _addMinted(domain: Bytes<32>, amount: Uint<64>): [] { - const current = totalMinted(domain); - const MAX_UINT128 = 340282366920938463463374607431768211455; - assert(MAX_UINT128 - current >= amount, "NativeShieldedTokenFamily: arithmetic overflow"); - _totalMinted.insert(disclose(domain), disclose((current + amount) as Uint<128>)); - } - - /** - * @description Adds `amount` to the contract-mediated burned total for `domain`. - * @dev No overflow guard is needed: every coin of this contract's colors - * originates from `_addMinted`-tracked mints, so burned can never exceed - * the overflow-checked minted total. - * - * @param {Bytes<32>} domain - 32-byte token discriminator. - * @param {Uint<128>} amount - The burned amount. - * @return {[]} - Empty tuple. - */ - circuit _addBurned(domain: Bytes<32>, amount: Uint<128>): [] { - const current = totalBurned(domain); - _totalBurned.insert(disclose(domain), disclose((current + amount) as Uint<128>)); - } } diff --git a/contracts/src/token/extensions/NativeShieldedTokenFamilySupply.compact b/contracts/src/token/extensions/NativeShieldedTokenFamilySupply.compact new file mode 100644 index 000000000..bf20b53d2 --- /dev/null +++ b/contracts/src/token/extensions/NativeShieldedTokenFamilySupply.compact @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/extensions/NativeShieldedTokenFamilySupply.compact) + +pragma language_version >= 0.23.0; + +/** + * @module NativeShieldedTokenFamilySupply + * @description Optional standalone extension that adds per-domain on-chain + * supply accounting to a `NativeShieldedTokenFamily`. It owns the per-domain + * minted/burned maps and exposes `_addMinted` / `_addBurned` as building + * blocks plus the `totalMinted` / `totalBurned` / `totalSupply` getters, each + * keyed by domain. It imports no token module. + * + * Pairs with `NativeShieldedTokenFamily`. The consuming contract composes the + * pieces, calling the accounting block alongside the matching token op: + * + * export circuit mint( + * domain: Bytes<32>, + * recipient: Either, + * amount: Uint<64>, + * nonce: Bytes<32> + * ): ShieldedCoinInfo { + * const coin = Family__mint(domain, recipient, amount, nonce); + * Supply__addMinted(domain, amount); + * return coin; + * } + * + * export circuit burn( + * domain: Bytes<32>, + * coin: ShieldedCoinInfo, + * amount: Uint<128>, + * refundTo: Either + * ): Maybe { + * const refund = Family__burn(domain, coin, amount, refundTo); + * Supply__addBurned(domain, amount); + * return refund; + * } + * + * # Privacy trade-off (read before composing) + * + * - Composing this extension makes contract-mediated BURN amounts PUBLIC. The + * bare family's `_burn` / `_burnFromContract` are amount-private: their coin + * operations emit only commitments and nullifiers, never the value, and the + * `disclose()` wrappers they require are compiler permission markers, not + * disclosure sinks. The `_addBurned` write here is what puts the burned + * amount into the public transcript. Compose this only when on-chain + * auditability is worth more than burn-amount privacy. + * + * - Mint amounts are already public via the protocol's `shieldedMints` effect, + * so `_addMinted` adds accounting, not disclosure. + * + * # Accounting guarantees (when wired correctly by the consumer) + * + * - `totalMinted(domain)` is EXACT, `totalBurned(domain)` a LOWER BOUND (burns + * that bypass the contract are invisible), and `totalSupply(domain)` = + * minted - burned an UPPER BOUND on circulating supply. These hold only if + * the consumer pairs every `_mint` with `_addMinted` and every burn with + * `_addBurned` under the same domain; the extension cannot enforce the + * pairing itself. + * + * # Initialization + * + * - None: an absent domain key reads as 0. The getters do not gate on the + * family module's init flag (a counter reading 0 before init is correct), so + * init enforcement stays with the family module's mint/burn circuits. + */ +module NativeShieldedTokenFamilySupply { + import CompactStandardLibrary; + + /** + * @description Exact amount minted per domain. See "Accounting guarantees". + * @type {Map, Uint<128>>} _totalMinted + */ + export ledger _totalMinted: Map, Uint<128>>; + /** + * @description Contract-mediated amount burned per domain (lower bound). + * @type {Map, Uint<128>>} _totalBurned + */ + export ledger _totalBurned: Map, Uint<128>>; + + /** + * @description Adds `amount` to the exact minted total for `domain`. Call + * once per successful `NativeShieldedTokenFamily._mint`. + * @dev Checks for overflow in order to output a readable error message. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @param {Uint<64>} amount - The minted amount. + * @return {[]} - Empty tuple. + */ + export circuit _addMinted(domain: Bytes<32>, amount: Uint<64>): [] { + const current = totalMinted(domain); + const MAX_UINT128 = 340282366920938463463374607431768211455; + assert(MAX_UINT128 - current >= amount, "NativeShieldedTokenFamilySupply: arithmetic overflow"); + _totalMinted.insert(disclose(domain), disclose((current + amount) as Uint<128>)); + } + + /** + * @description Adds `amount` to the contract-mediated burned total for + * `domain`. Call once per successful `NativeShieldedTokenFamily._burn` / + * `_burnFromContract`. + * @dev No overflow guard is needed: every coin of the paired contract's + * colors originates from `_addMinted`-tracked mints, so burned can never + * exceed the overflow-checked minted total. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @param {Uint<128>} amount - The burned amount. + * @return {[]} - Empty tuple. + */ + export circuit _addBurned(domain: Bytes<32>, amount: Uint<128>): [] { + const current = totalBurned(domain); + _totalBurned.insert(disclose(domain), disclose((current + amount) as Uint<128>)); + } + + /** + * @description Returns the exact amount ever minted for `domain`. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @return {Uint<128>} - The total amount minted. + */ + export circuit totalMinted(domain: Bytes<32>): Uint<128> { + if (!_totalMinted.member(disclose(domain))) { + return 0; + } + return _totalMinted.lookup(disclose(domain)); + } + + /** + * @description Returns the contract-mediated amount burned for `domain`. + * @notice This is a lower bound: coins sent directly to the burn address + * without going through the contract are not counted. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @return {Uint<128>} - The total amount burned through the contract. + */ + export circuit totalBurned(domain: Bytes<32>): Uint<128> { + if (!_totalBurned.member(disclose(domain))) { + return 0; + } + return _totalBurned.lookup(disclose(domain)); + } + + /** + * @description Returns `totalMinted(domain) - totalBurned(domain)`. + * @notice This is an UPPER BOUND on circulating supply, not an exact value: + * burns that bypass the contract are invisible. See the module notes. + * + * @param {Bytes<32>} domain - 32-byte token discriminator. + * @return {Uint<128>} - The upper bound on tokens in existence. + */ + export circuit totalSupply(domain: Bytes<32>): Uint<128> { + return (totalMinted(domain) - totalBurned(domain)) as Uint<128>; + } +} diff --git a/contracts/src/token/extensions/NativeShieldedTokenSupply.compact b/contracts/src/token/extensions/NativeShieldedTokenSupply.compact new file mode 100644 index 000000000..cebb8b54a --- /dev/null +++ b/contracts/src/token/extensions/NativeShieldedTokenSupply.compact @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.1.0 (token/extensions/NativeShieldedTokenSupply.compact) + +pragma language_version >= 0.23.0; + +/** + * @module NativeShieldedTokenSupply + * @description Optional standalone extension that adds on-chain supply + * accounting to a `NativeShieldedToken`. It owns the minted/burned counters + * and exposes `_addMinted` / `_addBurned` as building blocks plus the + * `totalMinted` / `totalBurned` / `totalSupply` getters. It imports no token + * module. + * + * Pairs with `NativeShieldedToken`. The consuming contract composes the + * pieces, calling the accounting block alongside the matching token op: + * + * export circuit mint( + * recipient: Either, + * amount: Uint<64>, + * nonce: Bytes<32> + * ): ShieldedCoinInfo { + * const coin = Token__mint(recipient, amount, nonce); + * Supply__addMinted(amount); + * return coin; + * } + * + * export circuit burn( + * coin: ShieldedCoinInfo, + * amount: Uint<128>, + * refundTo: Either + * ): Maybe { + * const refund = Token__burn(coin, amount, refundTo); + * Supply__addBurned(amount); + * return refund; + * } + * + * # Privacy trade-off (read before composing) + * + * - Composing this extension makes contract-mediated BURN amounts PUBLIC. The + * bare token's `_burn` / `_burnFromContract` are amount-private: their coin + * operations emit only commitments and nullifiers, never the value, and the + * `disclose()` wrappers they require are compiler permission markers, not + * disclosure sinks. The `_addBurned` write here is what puts the burned + * amount into the public transcript. Compose this only when on-chain + * auditability is worth more than burn-amount privacy. + * + * - Mint amounts are already public via the protocol's `shieldedMints` effect, + * so `_addMinted` adds accounting, not disclosure. + * + * # Accounting guarantees (when wired correctly by the consumer) + * + * - `totalMinted()` is EXACT, `totalBurned()` a LOWER BOUND (burns that bypass + * the contract are invisible), and `totalSupply()` = minted - burned an + * UPPER BOUND on circulating supply. These hold only if the consumer pairs + * every `_mint` with `_addMinted` and every burn with `_addBurned`; the + * extension cannot enforce the pairing itself. + * + * # Initialization + * + * - None: the counters default to 0. The getters do not gate on the token + * module's init flag (a counter reading 0 before init is correct), so init + * enforcement stays with the token module's mint/burn circuits. + */ +module NativeShieldedTokenSupply { + import CompactStandardLibrary; + + /** + * @description Exact amount minted. See "Accounting guarantees". + */ + export ledger _totalMinted: Uint<128>; + /** + * @description Contract-mediated amount burned (lower bound). + */ + export ledger _totalBurned: Uint<128>; + + /** + * @description Adds `amount` to the exact minted total. Call once per + * successful `NativeShieldedToken._mint`. + * @dev Checks for overflow in order to output a readable error message. + * + * @param {Uint<64>} amount - The minted amount. + * @return {[]} - Empty tuple. + */ + export circuit _addMinted(amount: Uint<64>): [] { + const MAX_UINT128 = 340282366920938463463374607431768211455; + assert(MAX_UINT128 - _totalMinted >= amount, "NativeShieldedTokenSupply: arithmetic overflow"); + _totalMinted = disclose((_totalMinted + amount) as Uint<128>); + } + + /** + * @description Adds `amount` to the contract-mediated burned total. Call once + * per successful `NativeShieldedToken._burn` / `_burnFromContract`. + * @dev No overflow guard is needed: every coin of the paired contract's + * color originates from `_addMinted`-tracked mints, so burned can never + * exceed the overflow-checked minted total. + * + * @param {Uint<128>} amount - The burned amount. + * @return {[]} - Empty tuple. + */ + export circuit _addBurned(amount: Uint<128>): [] { + _totalBurned = disclose((_totalBurned + amount) as Uint<128>); + } + + /** + * @description Returns the exact amount ever minted. + * + * @return {Uint<128>} - The total amount minted. + */ + export circuit totalMinted(): Uint<128> { + return _totalMinted; + } + + /** + * @description Returns the contract-mediated amount burned. + * @notice This is a lower bound: coins sent directly to the burn address + * without going through the contract are not counted. + * + * @return {Uint<128>} - The total amount burned through the contract. + */ + export circuit totalBurned(): Uint<128> { + return _totalBurned; + } + + /** + * @description Returns `totalMinted() - totalBurned()`. + * @notice This is an UPPER BOUND on circulating supply, not an exact value: + * burns that bypass the contract are invisible. See the module notes. + * + * @return {Uint<128>} - The upper bound on tokens in existence. + */ + export circuit totalSupply(): Uint<128> { + return (_totalMinted - _totalBurned) as Uint<128>; + } +} diff --git a/contracts/src/token/test/NativeShieldedToken.property.test.ts b/contracts/src/token/test/NativeShieldedToken.property.test.ts new file mode 100644 index 000000000..a2d62ac86 --- /dev/null +++ b/contracts/src/token/test/NativeShieldedToken.property.test.ts @@ -0,0 +1,63 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { NativeShieldedTokenSimulator } from './simulators/NativeShieldedTokenSimulator.js'; + +// Property-based invariant fuzzing over random mint/burn op sequences +// (MIP §Integration "Invariant fuzzing"). Conservation (burned <= minted) is +// respected by construction: each op burns at most the amount it just minted, +// so the sequence never fabricates an over-value burn (the simulator does not +// model coin conservation; see MED-1). + +const b32 = (label: string): Uint8Array => { + const u = new Uint8Array(32); + u.set(new TextEncoder().encode(label).slice(0, 32)); + return u; +}; +const RECIPIENT = utils.createEitherTestUser('RECIPIENT'); +const REFUND_TO = utils.createEitherTestUser('REFUND_TO'); + +describe('NativeShieldedToken — property: supply invariants under random op sequences', () => { + it('should keep totalMinted exact, totalSupply = minted - burned, burned <= minted (INV-2, INV-5)', () => { + fc.assert( + fc.property( + fc.array( + fc.record({ + mint: fc.bigInt({ min: 1n, max: 1_000_000n }), + burnPct: fc.integer({ min: 0, max: 100 }), + }), + { minLength: 1, maxLength: 6 }, + ), + (ops) => { + const token = new NativeShieldedTokenSimulator( + b32('domain'), + b32('seed'), + 'N', + 'S', + 6n, + true, + ); + const color = token.tokenColor(); + let expectedMinted = 0n; + let expectedBurned = 0n; + + ops.forEach((op, i) => { + token._mint(RECIPIENT, op.mint, b32(`m${i}`)); + expectedMinted += op.mint; + const burn = (op.mint * BigInt(op.burnPct)) / 100n; + if (burn > 0n) { + token._burn({ nonce: b32(`c${i}`), color, value: op.mint }, burn, REFUND_TO); + expectedBurned += burn; + } + }); + + expect(token.totalMinted()).toBe(expectedMinted); + expect(token.totalBurned()).toBe(expectedBurned); + expect(token.totalSupply()).toBe(expectedMinted - expectedBurned); + expect(expectedBurned <= expectedMinted).toBe(true); + }, + ), + { numRuns: 15 }, + ); + }, 120_000); +}); diff --git a/contracts/src/token/test/NativeShieldedToken.test.ts b/contracts/src/token/test/NativeShieldedToken.test.ts new file mode 100644 index 000000000..933c1ac62 --- /dev/null +++ b/contracts/src/token/test/NativeShieldedToken.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { + type NativeShieldedTokenSimulator as Sim, + NativeShieldedTokenSimulator, +} from './simulators/NativeShieldedTokenSimulator.js'; + +// Helpers +const b32 = (label: string): Uint8Array => { + const u = new Uint8Array(32); + u.set(new TextEncoder().encode(label).slice(0, 32)); + return u; +}; + +// Users / recipients +const RECIPIENT = utils.createEitherTestUser('RECIPIENT'); +const RECIPIENT_CONTRACT = utils.createEitherTestContractAddress('RECIPIENT_C'); +const REFUND_TO = utils.createEitherTestUser('REFUND_TO'); +const { ZERO_KEY, ZERO_ADDRESS } = utils; + +// Metadata +const NAME = 'Native Shielded Token'; +const SYMBOL = 'NST'; +const DECIMALS = 6n; +const DOMAIN = b32('domain-A'); +const SEED = b32('nonce-seed'); +const INIT = true; +const BAD_INIT = false; + +// Amounts +const AMOUNT = 1_000n; + +const deploy = (init = INIT): NativeShieldedTokenSimulator => + new NativeShieldedTokenSimulator(DOMAIN, SEED, NAME, SYMBOL, DECIMALS, init); + +let token: NativeShieldedTokenSimulator; + +describe('NativeShieldedToken (Fungible profile)', () => { + describe('initialization', () => { + beforeEach(() => { + token = deploy(INIT); + }); + + it('should expose the constructor metadata (INV-14)', () => { + expect(token.name()).toEqual(NAME); + expect(token.symbol()).toEqual(SYMBOL); + expect(token.decimals()).toEqual(DECIMALS); + }); + + it('should report _isInitialized true after construction (INV-15)', () => { + expect(token.isInitialized()).toBe(true); + }); + + it('should compute tokenColor as a 32-byte value at call time (INV-1)', () => { + const color = token.tokenColor(); + expect(color).toBeInstanceOf(Uint8Array); + expect(color.length).toBe(32); + // Stable across calls (same domain + same contract address). + expect(token.tokenColor()).toEqual(color); + }); + + it('should start with zero supply counters (INV-2, INV-4)', () => { + expect(token.totalMinted()).toBe(0n); + expect(token.totalBurned()).toBe(0n); + expect(token.totalSupply()).toBe(0n); + }); + }); + + describe('before initialization', () => { + beforeEach(() => { + token = deploy(BAD_INIT); + }); + + type FailingCircuit = [method: keyof Sim, args: unknown[]]; + const circuitsToFail: FailingCircuit[] = [ + ['name', []], + ['symbol', []], + ['decimals', []], + ['tokenColor', []], + ['_mint', [RECIPIENT, AMOUNT, b32('n')]], + ['_burn', [{ nonce: b32('cn'), color: b32('c'), value: AMOUNT }, AMOUNT, REFUND_TO]], + [ + '_burnFromContract', + [{ nonce: b32('cn'), color: b32('c'), value: AMOUNT, mt_index: 0n }, AMOUNT], + ], + ]; + + it.each(circuitsToFail)( + 'should revert %s before initialize (INV-15)', + (method, args) => { + expect(() => { + (token[method] as (...a: unknown[]) => unknown)(...args); + }).toThrow('NativeShieldedToken: contract not initialized'); + }, + ); + + it('should report zero supply before initialize (supply getters do not gate on init) (INV-2, INV-4)', () => { + // Supply accounting now lives in the standalone NativeShieldedTokenSupply + // extension. Its counters default to 0 and read independently of the + // token module's init flag; init enforcement stays on _mint/_burn. + expect(token.totalMinted()).toBe(0n); + expect(token.totalBurned()).toBe(0n); + expect(token.totalSupply()).toBe(0n); + }); + + it('should revert _deriveNonce before the chain is seeded (INV-13)', () => { + expect(() => token._deriveNonce()).toThrow( + 'NativeShieldedTokenDerivedNonce: chain not seeded', + ); + }); + }); + + describe('_mint', () => { + beforeEach(() => { + token = deploy(INIT); + }); + + it('should return a coin with color = tokenColor, value = amount, nonce = arg (INV-1)', () => { + const nonce = b32('mint-nonce-1'); + const coin = token._mint(RECIPIENT, AMOUNT, nonce); + expect(coin.value).toBe(AMOUNT); + expect(coin.nonce).toEqual(nonce); + expect(coin.color).toEqual(token.tokenColor()); + }); + + it('should mint to a contract-address recipient (INV-1)', () => { + const coin = token._mint(RECIPIENT_CONTRACT, AMOUNT, b32('mint-c')); + expect(coin.value).toBe(AMOUNT); + expect(coin.color).toEqual(token.tokenColor()); + }); + + it('should increment totalMinted by amount (INV-2)', () => { + token._mint(RECIPIENT, AMOUNT, b32('m1')); + expect(token.totalMinted()).toBe(AMOUNT); + token._mint(RECIPIENT, 500n, b32('m2')); + expect(token.totalMinted()).toBe(AMOUNT + 500n); + }); + + it('should revert on a zero recipient key (INV-6)', () => { + expect(() => token._mint(ZERO_KEY, AMOUNT, b32('z'))).toThrow( + 'NativeShieldedToken: invalid recipient', + ); + }); + + it('should revert on a zero recipient address (INV-6)', () => { + expect(() => token._mint(ZERO_ADDRESS, AMOUNT, b32('z'))).toThrow( + 'NativeShieldedToken: invalid recipient', + ); + }); + + // INV-3 (Uint<128> overflow of totalMinted) — the guard + // `MAX_UINT128 - _totalMinted >= amount` is present, but with `amount` + // capped at Uint<64> it would take ~2^64 mints to approach the bound, so + // it is not reachable in a unit test without a state-injection hook. The + // guard itself is trivially correct; left unexercised by design. + it.skip('should revert on Uint<128> overflow of totalMinted (INV-3)', () => {}); + }); + + describe('_burn (same-tx coin)', () => { + let color: Uint8Array; + beforeEach(() => { + token = deploy(INIT); + color = token.tokenColor(); + }); + + const coinOf = (value: bigint, c: Uint8Array = color) => ({ + nonce: b32('coin'), + color: c, + value, + }); + + it('should revert on a wrong-color coin (INV-1)', () => { + expect(() => token._burn(coinOf(AMOUNT, b32('wrong')), AMOUNT, REFUND_TO)).toThrow( + 'NativeShieldedToken: wrong token', + ); + }); + + it('should revert when amount > coin.value (INV-8)', () => { + expect(() => token._burn(coinOf(AMOUNT), AMOUNT + 1n, REFUND_TO)).toThrow( + 'NativeShieldedToken: insufficient coin value', + ); + }); + + it('should revert on a zero refundTo (INV-7)', () => { + expect(() => token._burn(coinOf(AMOUNT), 1n, ZERO_KEY)).toThrow( + 'NativeShieldedToken: invalid refund target', + ); + expect(() => token._burn(coinOf(AMOUNT), 1n, ZERO_ADDRESS)).toThrow( + 'NativeShieldedToken: invalid refund target', + ); + }); + + it('should return none on a full burn (amount == coin.value) (INV-10)', () => { + const res = token._burn(coinOf(AMOUNT), AMOUNT, REFUND_TO); + expect(res.is_some).toBe(false); + }); + + it('should return some(refund) with refund.value == coin.value - amount on a partial burn (INV-10)', () => { + const res = token._burn(coinOf(AMOUNT), 600n, REFUND_TO); + expect(res.is_some).toBe(true); + expect(res.value.value).toBe(AMOUNT - 600n); + }); + + it('should increment totalBurned by amount (INV-4)', () => { + token._burn(coinOf(AMOUNT), AMOUNT, REFUND_TO); + expect(token.totalBurned()).toBe(AMOUNT); + }); + }); + + describe('_burnFromContract (contract-held coin)', () => { + let color: Uint8Array; + beforeEach(() => { + token = deploy(INIT); + color = token.tokenColor(); + }); + + const qCoinOf = (value: bigint, c: Uint8Array = color) => ({ + nonce: b32('qcoin'), + color: c, + value, + mt_index: 0n, + }); + + it('should revert on a wrong-color coin (INV-1)', () => { + expect(() => token._burnFromContract(qCoinOf(AMOUNT, b32('wrong')), AMOUNT)).toThrow( + 'NativeShieldedToken: wrong token', + ); + }); + + it('should revert when amount > coin.value (INV-8)', () => { + expect(() => token._burnFromContract(qCoinOf(AMOUNT), AMOUNT + 1n)).toThrow( + 'NativeShieldedToken: insufficient coin value', + ); + }); + + it('should return change and increment totalBurned on a partial burn (INV-4, INV-10)', () => { + const res = token._burnFromContract(qCoinOf(AMOUNT), 600n); + expect(res.is_some).toBe(true); + expect(token.totalBurned()).toBe(600n); + }); + + it('should return none and increment totalBurned on a full burn (INV-4, INV-10)', () => { + const res = token._burnFromContract(qCoinOf(AMOUNT), AMOUNT); + expect(res.is_some).toBe(false); + expect(token.totalBurned()).toBe(AMOUNT); + }); + }); + + describe('supply accounting', () => { + beforeEach(() => { + token = deploy(INIT); + }); + + it('should report totalSupply == totalMinted - totalBurned after a mint/burn sequence (INV-5)', () => { + const color = token.tokenColor(); + token._mint(RECIPIENT, AMOUNT, b32('s1')); + token._mint(RECIPIENT, 500n, b32('s2')); + token._burn({ nonce: b32('c'), color, value: 400n }, 400n, REFUND_TO); + expect(token.totalMinted()).toBe(1_500n); + expect(token.totalBurned()).toBe(400n); + expect(token.totalSupply()).toBe(1_100n); + }); + + // MED-1: totalSupply underflow safety relies on the proof-loop invariant + // burned <= minted. The simulator does not model coin conservation, so a + // fabricated over-value burn drives burned > minted and the getter + // underflows. This documents the dependency: real safety is proof-loop + // provided, and unit tests must respect conservation (mint before burn). + it('should be drivable into burned > minted under --skip-zk (MED-1 boundary)', () => { + const color = token.tokenColor(); + // No mint; burn a fabricated coin of the right color. + token._burn({ nonce: b32('c'), color, value: AMOUNT }, AMOUNT, REFUND_TO); + expect(token.totalMinted()).toBe(0n); + expect(token.totalBurned()).toBe(AMOUNT); + // burned > minted: totalSupply() either throws (Uint underflow) or wraps. + // Either way it is not a meaningful value — assert the hazard exists. + let threwOrWrapped = false; + try { + const s = token.totalSupply(); + threwOrWrapped = s !== 0n; // a wrap yields a huge number, never 0 here + } catch { + threwOrWrapped = true; + } + expect(threwOrWrapped).toBe(true); + }); + }); + + afterEach(() => { + // no shared resources to tear down (pure simulator) + }); +}); diff --git a/contracts/src/token/test/NativeShieldedTokenDerivedNonce.test.ts b/contracts/src/token/test/NativeShieldedTokenDerivedNonce.test.ts new file mode 100644 index 000000000..e2f115eb6 --- /dev/null +++ b/contracts/src/token/test/NativeShieldedTokenDerivedNonce.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { NativeShieldedTokenSimulator } from './simulators/NativeShieldedTokenSimulator.js'; + +// The derived-nonce extension is exercised through MockNativeShieldedToken, +// which composes it. The mock exposes `initializeNonce` (the chain fields are +// not sealed) so the seed guards are reachable post-deploy. + +const b32 = (label: string): Uint8Array => { + const u = new Uint8Array(32); + u.set(new TextEncoder().encode(label).slice(0, 32)); + return u; +}; +const toHex = (u: Uint8Array): string => Buffer.from(u).toString('hex'); + +const DOMAIN = b32('domain-A'); +const SEED = b32('nonce-seed'); +const ZERO = utils.zeroUint8Array(); +const INIT = true; +const BAD_INIT = false; + +const deploy = (init: boolean): NativeShieldedTokenSimulator => + new NativeShieldedTokenSimulator(DOMAIN, SEED, 'N', 'S', 6n, init); + +describe('NativeShieldedTokenDerivedNonce (extension)', () => { + describe('seeding guards', () => { + it('should revert _deriveNonce before the chain is seeded (INV-13)', () => { + const t = deploy(BAD_INIT); + expect(() => t._deriveNonce()).toThrow( + 'NativeShieldedTokenDerivedNonce: chain not seeded', + ); + }); + + it('should revert initialize on a zero seed (INV-13)', () => { + const t = deploy(BAD_INIT); + expect(() => t.initializeNonce(ZERO)).toThrow( + 'NativeShieldedTokenDerivedNonce: invalid nonce seed', + ); + }); + + it('should seed once then revert a second seed (INV-13)', () => { + const t = deploy(BAD_INIT); + t.initializeNonce(SEED); + expect(() => t.initializeNonce(b32('other-seed'))).toThrow( + 'NativeShieldedTokenDerivedNonce: already seeded', + ); + }); + + it('should revert seeding when the constructor already seeded the chain (INV-13)', () => { + const t = deploy(INIT); + expect(() => t.initializeNonce(b32('other-seed'))).toThrow( + 'NativeShieldedTokenDerivedNonce: already seeded', + ); + }); + + it('should allow _deriveNonce once seeded post-deploy', () => { + const t = deploy(BAD_INIT); + t.initializeNonce(SEED); + expect(() => t._deriveNonce()).not.toThrow(); + }); + }); + + describe('chain progression', () => { + let t: NativeShieldedTokenSimulator; + beforeEach(() => { + t = deploy(INIT); + }); + + it('should advance the counter and chain value on each _deriveNonce call', () => { + expect(t.nonceCounter()).toBe(0n); + const chain0 = t.nonceChainValue(); + t._deriveNonce(); + expect(t.nonceCounter()).toBe(1n); + const chain1 = t.nonceChainValue(); + expect(chain1).not.toEqual(chain0); + t._deriveNonce(); + expect(t.nonceCounter()).toBe(2n); + expect(t.nonceChainValue()).not.toEqual(chain1); + }); + + it('should never repeat a derived nonce across N calls (INV-11)', () => { + const N = 25; + const seen = new Set(); + for (let i = 0; i < N; i++) { + seen.add(toHex(t._deriveNonce())); + } + expect(seen.size).toBe(N); + }); + + it('should produce a derived nonce distinct from the public chain value (INV-12)', () => { + // The derived nonce is Hash(tag, chainValue) — an honest caller echoing + // the public `_nonce` field cannot reproduce it. + for (let i = 0; i < 5; i++) { + const derived = t._deriveNonce(); + expect(derived).not.toEqual(t.nonceChainValue()); + } + }); + }); +}); diff --git a/contracts/src/token/test/NativeShieldedTokenFamily.test.ts b/contracts/src/token/test/NativeShieldedTokenFamily.test.ts new file mode 100644 index 000000000..a354dbcd8 --- /dev/null +++ b/contracts/src/token/test/NativeShieldedTokenFamily.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { + type NativeShieldedTokenFamilySimulator as Sim, + NativeShieldedTokenFamilySimulator, +} from './simulators/NativeShieldedTokenFamilySimulator.js'; + +const b32 = (label: string): Uint8Array => { + const u = new Uint8Array(32); + u.set(new TextEncoder().encode(label).slice(0, 32)); + return u; +}; + +const RECIPIENT = utils.createEitherTestUser('RECIPIENT'); +const REFUND_TO = utils.createEitherTestUser('REFUND_TO'); +const { ZERO_KEY, ZERO_ADDRESS } = utils; + +const NAME = 'Family Token'; +const SYMBOL = 'FAM'; +const DECIMALS = 6n; +const SEED = b32('nonce-seed'); +const DOMAIN_A = b32('domain-A'); +const DOMAIN_B = b32('domain-B'); +const INIT = true; +const BAD_INIT = false; +const AMOUNT = 1_000n; + +const deploy = (init = INIT): NativeShieldedTokenFamilySimulator => + new NativeShieldedTokenFamilySimulator(SEED, NAME, SYMBOL, DECIMALS, init); + +let token: NativeShieldedTokenFamilySimulator; + +describe('NativeShieldedTokenFamily (Family profile)', () => { + describe('initialization', () => { + beforeEach(() => { + token = deploy(INIT); + }); + + it('should expose the family metadata (INV-14)', () => { + expect(token.name()).toEqual(NAME); + expect(token.symbol()).toEqual(SYMBOL); + expect(token.decimals()).toEqual(DECIMALS); + expect(token.isInitialized()).toBe(true); + }); + + it('should return 0 supply for an unknown domain (INV-2)', () => { + expect(token.totalMinted(DOMAIN_A)).toBe(0n); + expect(token.totalBurned(DOMAIN_A)).toBe(0n); + expect(token.totalSupply(DOMAIN_A)).toBe(0n); + }); + }); + + describe('before initialization', () => { + beforeEach(() => { + token = deploy(BAD_INIT); + }); + + type FailingCircuit = [method: keyof Sim, args: unknown[]]; + const circuitsToFail: FailingCircuit[] = [ + ['name', []], + ['symbol', []], + ['decimals', []], + ['tokenColor', [DOMAIN_A]], + ['_mint', [DOMAIN_A, RECIPIENT, AMOUNT, b32('n')]], + ['_burn', [DOMAIN_A, { nonce: b32('cn'), color: b32('c'), value: AMOUNT }, AMOUNT, REFUND_TO]], + [ + '_burnFromContract', + [DOMAIN_A, { nonce: b32('cn'), color: b32('c'), value: AMOUNT, mt_index: 0n }, AMOUNT], + ], + ]; + + it.each(circuitsToFail)( + 'should revert %s before initialize (INV-15)', + (method, args) => { + expect(() => { + (token[method] as (...a: unknown[]) => unknown)(...args); + }).toThrow('NativeShieldedTokenFamily: contract not initialized'); + }, + ); + + it('should report zero supply before initialize for any domain (getters do not gate on init) (INV-2)', () => { + // Per-domain supply accounting now lives in the standalone + // NativeShieldedTokenFamilySupply extension; an absent domain reads as 0 + // independently of the family module's init flag. + expect(token.totalMinted(DOMAIN_A)).toBe(0n); + expect(token.totalBurned(DOMAIN_A)).toBe(0n); + expect(token.totalSupply(DOMAIN_A)).toBe(0n); + }); + }); + + describe('_mint (per domain)', () => { + beforeEach(() => { + token = deploy(INIT); + }); + + it('should return a coin with color = tokenColor(domain), value, nonce (INV-1)', () => { + const nonce = b32('m-a'); + const coin = token._mint(DOMAIN_A, RECIPIENT, AMOUNT, nonce); + expect(coin.value).toBe(AMOUNT); + expect(coin.nonce).toEqual(nonce); + expect(coin.color).toEqual(token.tokenColor(DOMAIN_A)); + }); + + it('should increment totalMinted(domain) by amount (INV-2)', () => { + token._mint(DOMAIN_A, RECIPIENT, AMOUNT, b32('m-a')); + expect(token.totalMinted(DOMAIN_A)).toBe(AMOUNT); + }); + + it('should revert on a zero recipient (INV-6)', () => { + expect(() => token._mint(DOMAIN_A, ZERO_KEY, AMOUNT, b32('z'))).toThrow( + 'NativeShieldedTokenFamily: invalid recipient', + ); + expect(() => token._mint(DOMAIN_A, ZERO_ADDRESS, AMOUNT, b32('z'))).toThrow( + 'NativeShieldedTokenFamily: invalid recipient', + ); + }); + }); + + describe('multi-domain isolation', () => { + beforeEach(() => { + token = deploy(INIT); + }); + + it('should accumulate independent supplies for distinct domains (INV-2)', () => { + token._mint(DOMAIN_A, RECIPIENT, 1_000n, b32('a1')); + token._mint(DOMAIN_B, RECIPIENT, 250n, b32('b1')); + expect(token.totalMinted(DOMAIN_A)).toBe(1_000n); + expect(token.totalMinted(DOMAIN_B)).toBe(250n); + }); + + it('should give distinct colors to distinct domains (INV-1)', () => { + expect(token.tokenColor(DOMAIN_A)).not.toEqual(token.tokenColor(DOMAIN_B)); + }); + + it('should keep domain B unaffected by a burn under domain A', () => { + const colorA = token.tokenColor(DOMAIN_A); + token._mint(DOMAIN_A, RECIPIENT, 1_000n, b32('a1')); + token._mint(DOMAIN_B, RECIPIENT, 1_000n, b32('b1')); + token._burn(DOMAIN_A, { nonce: b32('c'), color: colorA, value: 400n }, 400n, REFUND_TO); + expect(token.totalBurned(DOMAIN_A)).toBe(400n); + expect(token.totalBurned(DOMAIN_B)).toBe(0n); + expect(token.totalSupply(DOMAIN_A)).toBe(600n); + expect(token.totalSupply(DOMAIN_B)).toBe(1_000n); + }); + + it('should reject burning a domain-A coin under domain B (wrong color) (INV-1)', () => { + const colorA = token.tokenColor(DOMAIN_A); + expect(() => + token._burn(DOMAIN_B, { nonce: b32('c'), color: colorA, value: AMOUNT }, AMOUNT, REFUND_TO), + ).toThrow('NativeShieldedTokenFamily: wrong token'); + }); + }); + + describe('_burn (per domain)', () => { + let colorA: Uint8Array; + beforeEach(() => { + token = deploy(INIT); + colorA = token.tokenColor(DOMAIN_A); + }); + + const coinOf = (value: bigint, c: Uint8Array = colorA) => ({ + nonce: b32('coin'), + color: c, + value, + }); + + it('should revert when amount > coin.value (INV-8)', () => { + expect(() => token._burn(DOMAIN_A, coinOf(AMOUNT), AMOUNT + 1n, REFUND_TO)).toThrow( + 'NativeShieldedTokenFamily: insufficient coin value', + ); + }); + + it('should revert on a zero refundTo (INV-7)', () => { + expect(() => token._burn(DOMAIN_A, coinOf(AMOUNT), 1n, ZERO_KEY)).toThrow( + 'NativeShieldedTokenFamily: invalid refund target', + ); + }); + + it('should return none on a full burn and some(refund) on a partial burn (INV-10)', () => { + expect(token._burn(DOMAIN_A, coinOf(AMOUNT), AMOUNT, REFUND_TO).is_some).toBe(false); + const partial = token._burn(DOMAIN_A, coinOf(AMOUNT), 600n, REFUND_TO); + expect(partial.is_some).toBe(true); + expect(partial.value.value).toBe(AMOUNT - 600n); + }); + + it('should report totalSupply(domain) == minted - burned (INV-5)', () => { + token._mint(DOMAIN_A, RECIPIENT, AMOUNT, b32('m')); + token._burn(DOMAIN_A, coinOf(400n), 400n, REFUND_TO); + expect(token.totalSupply(DOMAIN_A)).toBe(AMOUNT - 400n); + }); + }); +}); diff --git a/contracts/src/token/test/mocks/MockNativeShieldedToken.compact b/contracts/src/token/test/mocks/MockNativeShieldedToken.compact index 4ede6e7fe..bc35dd3af 100644 --- a/contracts/src/token/test/mocks/MockNativeShieldedToken.compact +++ b/contracts/src/token/test/mocks/MockNativeShieldedToken.compact @@ -11,18 +11,51 @@ import CompactStandardLibrary; import "../../NativeShieldedToken" prefix NativeShieldedToken_; import "../../extensions/NativeShieldedTokenDerivedNonce" prefix NativeShieldedTokenDerivedNonce_; +import "../../extensions/NativeShieldedTokenSupply" prefix NativeShieldedTokenSupply_; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; +// Ledger field re-exports so unit tests can read state via the typed `Ledger`. +// Metadata and supply totals are reachable through getter circuits; these are +// the fields with no getter that the tests assert on: the init flag and the +// derived-nonce chain (counter + latest chain value). +export { NativeShieldedToken__isInitialized }; +export { + NativeShieldedTokenDerivedNonce__counter, + NativeShieldedTokenDerivedNonce__nonce +}; + +// This mock represents a consumer that wants on-chain supply accounting: it +// composes the optional NativeShieldedTokenSupply extension and pairs its +// _addMinted / _addBurned blocks with the base mint / burn ops below. + +/** + * @description `init` is a test-only flag. When true, the constructor + * initializes both the token module (domain + metadata) and the derived-nonce + * chain. When false, neither is initialized, so tests can exercise the + * pre-initialization revert guards. The token `initialize` is intentionally + * NOT exposed as a circuit: it writes `sealed` ledger fields, which the + * sealed-write rule confines to constructor execution. + */ constructor( domainSep: Bytes<32>, initNonce: Bytes<32>, name_: Opaque<"string">, symbol_: Opaque<"string">, - decimals_: Uint<8> + decimals_: Uint<8>, + init: Boolean ) { - NativeShieldedToken_initialize(domainSep, name_, symbol_, decimals_); + if (disclose(init)) { + NativeShieldedToken_initialize(domainSep, name_, symbol_, decimals_); + NativeShieldedTokenDerivedNonce_initialize(initNonce); + } +} + +// Exposes the extension's seed circuit so tests can exercise the seed-once and +// zero-seed guards post-deploy. The nonce-chain fields (`_counter`, `_nonce`) +// are not sealed, so seeding outside the constructor is legal here. +export circuit initializeNonce(initNonce: Bytes<32>): [] { NativeShieldedTokenDerivedNonce_initialize(initNonce); } @@ -43,15 +76,15 @@ export circuit tokenColor(): Bytes<32> { } export circuit totalMinted(): Uint<128> { - return NativeShieldedToken_totalMinted(); + return NativeShieldedTokenSupply_totalMinted(); } export circuit totalBurned(): Uint<128> { - return NativeShieldedToken_totalBurned(); + return NativeShieldedTokenSupply_totalBurned(); } export circuit totalSupply(): Uint<128> { - return NativeShieldedToken_totalSupply(); + return NativeShieldedTokenSupply_totalSupply(); } export circuit _mint( @@ -59,7 +92,9 @@ export circuit _mint( amount: Uint<64>, nonce: Bytes<32> ): ShieldedCoinInfo { - return NativeShieldedToken__mint(recipient, amount, nonce); + const coin = NativeShieldedToken__mint(recipient, amount, nonce); + NativeShieldedTokenSupply__addMinted(amount); + return coin; } export circuit _deriveNonce(): Bytes<32> { @@ -72,7 +107,9 @@ export circuit _mintWithDerivedNonce( recipient: Either, amount: Uint<64> ): ShieldedCoinInfo { - return NativeShieldedToken__mint(recipient, amount, NativeShieldedTokenDerivedNonce__deriveNonce()); + const coin = NativeShieldedToken__mint(recipient, amount, NativeShieldedTokenDerivedNonce__deriveNonce()); + NativeShieldedTokenSupply__addMinted(amount); + return coin; } export circuit _burn( @@ -80,12 +117,16 @@ export circuit _burn( amount: Uint<128>, refundTo: Either ): Maybe { - return NativeShieldedToken__burn(coin, amount, refundTo); + const refund = NativeShieldedToken__burn(coin, amount, refundTo); + NativeShieldedTokenSupply__addBurned(amount); + return refund; } export circuit _burnFromContract( coin: QualifiedShieldedCoinInfo, amount: Uint<128> ): Maybe { - return NativeShieldedToken__burnFromContract(coin, amount); + const change = NativeShieldedToken__burnFromContract(coin, amount); + NativeShieldedTokenSupply__addBurned(amount); + return change; } diff --git a/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact b/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact index 5a24382ce..78819d36d 100644 --- a/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact +++ b/contracts/src/token/test/mocks/MockNativeShieldedTokenFamily.compact @@ -11,17 +11,50 @@ import CompactStandardLibrary; import "../../NativeShieldedTokenFamily" prefix NativeShieldedTokenFamily_; import "../../extensions/NativeShieldedTokenDerivedNonce" prefix NativeShieldedTokenDerivedNonce_; +import "../../extensions/NativeShieldedTokenFamilySupply" prefix NativeShieldedTokenFamilySupply_; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; +// Ledger field re-exports so unit tests can read state via the typed `Ledger`. +// Family metadata and per-domain supply totals are reachable through getter +// circuits; these are the fields with no getter that the tests assert on: +// the init flag and the derived-nonce chain (counter + latest chain value). +export { NativeShieldedTokenFamily__isInitialized }; +export { + NativeShieldedTokenDerivedNonce__counter, + NativeShieldedTokenDerivedNonce__nonce +}; + +// This mock represents a consumer that wants per-domain supply accounting: it +// composes the optional NativeShieldedTokenFamilySupply extension and pairs +// its _addMinted / _addBurned blocks with the base mint / burn ops below. + +/** + * @description `init` is a test-only flag. When true, the constructor + * initializes both the family module (metadata) and the derived-nonce chain. + * When false, neither is initialized, so tests can exercise the + * pre-initialization revert guards. The family `initialize` is intentionally + * NOT exposed as a circuit: it writes `sealed` ledger fields, which the + * sealed-write rule confines to constructor execution. + */ constructor( initNonce: Bytes<32>, name_: Opaque<"string">, symbol_: Opaque<"string">, - decimals_: Uint<8> + decimals_: Uint<8>, + init: Boolean ) { - NativeShieldedTokenFamily_initialize(name_, symbol_, decimals_); + if (disclose(init)) { + NativeShieldedTokenFamily_initialize(name_, symbol_, decimals_); + NativeShieldedTokenDerivedNonce_initialize(initNonce); + } +} + +// Exposes the extension's seed circuit so tests can exercise the seed-once and +// zero-seed guards post-deploy. The nonce-chain fields (`_counter`, `_nonce`) +// are not sealed, so seeding outside the constructor is legal here. +export circuit initializeNonce(initNonce: Bytes<32>): [] { NativeShieldedTokenDerivedNonce_initialize(initNonce); } @@ -42,15 +75,15 @@ export circuit tokenColor(domain: Bytes<32>): Bytes<32> { } export circuit totalMinted(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalMinted(domain); + return NativeShieldedTokenFamilySupply_totalMinted(domain); } export circuit totalBurned(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalBurned(domain); + return NativeShieldedTokenFamilySupply_totalBurned(domain); } export circuit totalSupply(domain: Bytes<32>): Uint<128> { - return NativeShieldedTokenFamily_totalSupply(domain); + return NativeShieldedTokenFamilySupply_totalSupply(domain); } export circuit _mint( @@ -59,7 +92,9 @@ export circuit _mint( amount: Uint<64>, nonce: Bytes<32> ): ShieldedCoinInfo { - return NativeShieldedTokenFamily__mint(domain, recipient, amount, nonce); + const coin = NativeShieldedTokenFamily__mint(domain, recipient, amount, nonce); + NativeShieldedTokenFamilySupply__addMinted(domain, amount); + return coin; } export circuit _deriveNonce(): Bytes<32> { @@ -73,7 +108,9 @@ export circuit _mintWithDerivedNonce( recipient: Either, amount: Uint<64> ): ShieldedCoinInfo { - return NativeShieldedTokenFamily__mint(domain, recipient, amount, NativeShieldedTokenDerivedNonce__deriveNonce()); + const coin = NativeShieldedTokenFamily__mint(domain, recipient, amount, NativeShieldedTokenDerivedNonce__deriveNonce()); + NativeShieldedTokenFamilySupply__addMinted(domain, amount); + return coin; } export circuit _burn( @@ -82,7 +119,9 @@ export circuit _burn( amount: Uint<128>, refundTo: Either ): Maybe { - return NativeShieldedTokenFamily__burn(domain, coin, amount, refundTo); + const refund = NativeShieldedTokenFamily__burn(domain, coin, amount, refundTo); + NativeShieldedTokenFamilySupply__addBurned(domain, amount); + return refund; } export circuit _burnFromContract( @@ -90,5 +129,7 @@ export circuit _burnFromContract( coin: QualifiedShieldedCoinInfo, amount: Uint<128> ): Maybe { - return NativeShieldedTokenFamily__burnFromContract(domain, coin, amount); + const change = NativeShieldedTokenFamily__burnFromContract(domain, coin, amount); + NativeShieldedTokenFamilySupply__addBurned(domain, amount); + return change; } diff --git a/contracts/src/token/test/simulators/NativeShieldedTokenFamilySimulator.ts b/contracts/src/token/test/simulators/NativeShieldedTokenFamilySimulator.ts new file mode 100644 index 000000000..f5402effb --- /dev/null +++ b/contracts/src/token/test/simulators/NativeShieldedTokenFamilySimulator.ts @@ -0,0 +1,205 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin/compact-simulator'; +import { + type ContractAddress, + type Either, + type Maybe, + type QualifiedShieldedCoinInfo, + type ShieldedCoinInfo, + type ZswapCoinPublicKey, + Contract as MockNativeShieldedTokenFamily, + ledger, +} from '../../../../artifacts/MockNativeShieldedTokenFamily/contract/index.js'; + +/** + * The family module (and the derived-nonce extension) declare no witnesses, so + * the private state is empty and the witnesses object is `{}`. + */ +export type NativeShieldedTokenFamilyPrivateState = Record; +export const NativeShieldedTokenFamilyPrivateState: NativeShieldedTokenFamilyPrivateState = + {}; +export const NativeShieldedTokenFamilyWitnesses = () => ({}); + +/** + * Type constructor args — mirrors `MockNativeShieldedTokenFamily`'s + * constructor: `(initNonce, name, symbol, decimals, init)`. The Family profile + * has no sealed `_domain`; the domain is a per-call circuit parameter instead. + */ +type NativeShieldedTokenFamilyArgs = readonly [ + initNonce: Uint8Array, + name: string, + symbol: string, + decimals: bigint, + init: boolean, +]; + +const NativeShieldedTokenFamilySimulatorBase = createSimulator< + NativeShieldedTokenFamilyPrivateState, + ReturnType, + ReturnType, + MockNativeShieldedTokenFamily, + NativeShieldedTokenFamilyArgs +>({ + contractFactory: (witnesses) => + new MockNativeShieldedTokenFamily( + witnesses, + ), + defaultPrivateState: () => NativeShieldedTokenFamilyPrivateState, + contractArgs: (initNonce, name, symbol, decimals, init) => [ + initNonce, + name, + symbol, + decimals, + init, + ], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => NativeShieldedTokenFamilyWitnesses(), +}); + +/** + * NativeShieldedTokenFamily (Family profile) Simulator. + * + * Same standard as the Fungible profile with an explicit `domain` parameter on + * every issuance / burn / supply circuit. Wraps `MockNativeShieldedTokenFamily`. + */ +export class NativeShieldedTokenFamilySimulator extends NativeShieldedTokenFamilySimulatorBase { + constructor( + initNonce: Uint8Array, + name: string, + symbol: string, + decimals: bigint, + init: boolean, + options: BaseSimulatorOptions< + NativeShieldedTokenFamilyPrivateState, + ReturnType + > = {}, + ) { + super([initNonce, name, symbol, decimals, init], options); + } + + /// + /// Metadata (family-wide) + /// + + /** @description Returns the family name shared by all token types. */ + public name(): string { + return this.circuits.impure.name(); + } + + /** @description Returns the family symbol shared by all token types. */ + public symbol(): string { + return this.circuits.impure.symbol(); + } + + /** @description Returns the family-wide decimals. */ + public decimals(): bigint { + return this.circuits.impure.decimals(); + } + + /** + * @description Returns the coin color for `domain` + * (`tokenType(domain, kernel.self())`), computed at call time. + */ + public tokenColor(domain: Uint8Array): Uint8Array { + return this.circuits.impure.tokenColor(domain); + } + + /// + /// Supply accounting (per domain) + /// + + /** @description Returns the exact amount ever minted for `domain`. */ + public totalMinted(domain: Uint8Array): bigint { + return this.circuits.impure.totalMinted(domain); + } + + /** @description Returns the contract-mediated amount burned for `domain`. */ + public totalBurned(domain: Uint8Array): bigint { + return this.circuits.impure.totalBurned(domain); + } + + /** @description Returns `totalMinted(domain) - totalBurned(domain)`. */ + public totalSupply(domain: Uint8Array): bigint { + return this.circuits.impure.totalSupply(domain); + } + + /// + /// Mint / burn (per domain) + /// + + /** + * @description Mints `amount` of the `domain` token to `recipient` using a + * caller-supplied nonce. + */ + public _mint( + domain: Uint8Array, + recipient: Either, + amount: bigint, + nonce: Uint8Array, + ): ShieldedCoinInfo { + return this.circuits.impure._mint(domain, recipient, amount, nonce); + } + + /** @description Burns `amount` from a same-tx `coin` of `domain`. */ + public _burn( + domain: Uint8Array, + coin: ShieldedCoinInfo, + amount: bigint, + refundTo: Either, + ): Maybe { + return this.circuits.impure._burn(domain, coin, amount, refundTo); + } + + /** @description Burns `amount` from a contract-held `coin` of `domain`. */ + public _burnFromContract( + domain: Uint8Array, + coin: QualifiedShieldedCoinInfo, + amount: bigint, + ): Maybe { + return this.circuits.impure._burnFromContract(domain, coin, amount); + } + + /// + /// Derived-nonce extension + /// + + /** @description Advances the nonce chain and returns the next derived coin nonce. */ + public _deriveNonce(): Uint8Array { + return this.circuits.impure._deriveNonce(); + } + + /** @description The documented composition: base `_mint` with `_deriveNonce()`. */ + public _mintWithDerivedNonce( + domain: Uint8Array, + recipient: Either, + amount: bigint, + ): ShieldedCoinInfo { + return this.circuits.impure._mintWithDerivedNonce(domain, recipient, amount); + } + + /** @description Seeds the derived-nonce chain post-deploy (test-only). */ + public initializeNonce(initNonce: Uint8Array): void { + this.circuits.impure.initializeNonce(initNonce); + } + + /// + /// Ledger reads (fields without getter circuits) + /// + + /** @description Whether the family module has been initialized. */ + public isInitialized(): boolean { + return this.getPublicState().NativeShieldedTokenFamily__isInitialized; + } + + /** @description Current value of the derived-nonce chain counter. */ + public nonceCounter(): bigint { + return this.getPublicState().NativeShieldedTokenDerivedNonce__counter; + } + + /** @description Latest value of the derived-nonce evolution chain (`_nonce`). */ + public nonceChainValue(): Uint8Array { + return this.getPublicState().NativeShieldedTokenDerivedNonce__nonce; + } +} diff --git a/contracts/src/token/test/simulators/NativeShieldedTokenSimulator.ts b/contracts/src/token/test/simulators/NativeShieldedTokenSimulator.ts new file mode 100644 index 000000000..585a24ffc --- /dev/null +++ b/contracts/src/token/test/simulators/NativeShieldedTokenSimulator.ts @@ -0,0 +1,216 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin/compact-simulator'; +import { + type ContractAddress, + type Either, + type Maybe, + type QualifiedShieldedCoinInfo, + type ShieldedCoinInfo, + type ZswapCoinPublicKey, + Contract as MockNativeShieldedToken, + ledger, +} from '../../../../artifacts/MockNativeShieldedToken/contract/index.js'; + +/** + * The native shielded token modules (and the derived-nonce extension) declare + * no witnesses, so the private state is empty and the witnesses object is `{}`. + */ +export type NativeShieldedTokenPrivateState = Record; +export const NativeShieldedTokenPrivateState: NativeShieldedTokenPrivateState = + {}; +export const NativeShieldedTokenWitnesses = () => ({}); + +/** + * Type constructor args — mirrors `MockNativeShieldedToken`'s constructor: + * `(domainSep, initNonce, name, symbol, decimals, init)`. When `init` is + * false the contract is left uninitialized so the pre-init guards are testable. + */ +type NativeShieldedTokenArgs = readonly [ + domain: Uint8Array, + initNonce: Uint8Array, + name: string, + symbol: string, + decimals: bigint, + init: boolean, +]; + +const NativeShieldedTokenSimulatorBase = createSimulator< + NativeShieldedTokenPrivateState, + ReturnType, + ReturnType, + MockNativeShieldedToken, + NativeShieldedTokenArgs +>({ + contractFactory: (witnesses) => + new MockNativeShieldedToken(witnesses), + defaultPrivateState: () => NativeShieldedTokenPrivateState, + contractArgs: (domain, initNonce, name, symbol, decimals, init) => [ + domain, + initNonce, + name, + symbol, + decimals, + init, + ], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => NativeShieldedTokenWitnesses(), +}); + +/** + * NativeShieldedToken (Fungible profile) Simulator. + * + * Wraps the `MockNativeShieldedToken` test contract, which composes the + * `NativeShieldedToken` module with the `NativeShieldedTokenDerivedNonce` + * extension and exposes their internal circuits unrestricted. + */ +export class NativeShieldedTokenSimulator extends NativeShieldedTokenSimulatorBase { + constructor( + domain: Uint8Array, + initNonce: Uint8Array, + name: string, + symbol: string, + decimals: bigint, + init: boolean, + options: BaseSimulatorOptions< + NativeShieldedTokenPrivateState, + ReturnType + > = {}, + ) { + super([domain, initNonce, name, symbol, decimals, init], options); + } + + /// + /// Metadata + /// + + /** @description Returns the token name. */ + public name(): string { + return this.circuits.impure.name(); + } + + /** @description Returns the token symbol. */ + public symbol(): string { + return this.circuits.impure.symbol(); + } + + /** @description Returns the token decimals. */ + public decimals(): bigint { + return this.circuits.impure.decimals(); + } + + /** + * @description Returns this token's coin color + * (`tokenType(_domain, kernel.self())`), computed at call time. + */ + public tokenColor(): Uint8Array { + return this.circuits.impure.tokenColor(); + } + + /// + /// Supply accounting + /// + + /** @description Returns the exact amount ever minted. */ + public totalMinted(): bigint { + return this.circuits.impure.totalMinted(); + } + + /** @description Returns the contract-mediated amount burned (lower bound). */ + public totalBurned(): bigint { + return this.circuits.impure.totalBurned(); + } + + /** @description Returns `totalMinted() - totalBurned()` (upper bound on supply). */ + public totalSupply(): bigint { + return this.circuits.impure.totalSupply(); + } + + /// + /// Mint / burn + /// + + /** + * @description Mints `amount` to `recipient` using a caller-supplied nonce. + * @returns The newly created coin's info (nonce, color, value). + */ + public _mint( + recipient: Either, + amount: bigint, + nonce: Uint8Array, + ): ShieldedCoinInfo { + return this.circuits.impure._mint(recipient, amount, nonce); + } + + /** + * @description Burns `amount` from a same-tx `coin`, routing change to + * `refundTo`. + * @returns The refund coin created for `refundTo`, or `none` on a full burn. + */ + public _burn( + coin: ShieldedCoinInfo, + amount: bigint, + refundTo: Either, + ): Maybe { + return this.circuits.impure._burn(coin, amount, refundTo); + } + + /** + * @description Burns `amount` from a contract-held `coin` (Merkle spend). + * @returns The change coin retained by the contract, or `none` on a full burn. + */ + public _burnFromContract( + coin: QualifiedShieldedCoinInfo, + amount: bigint, + ): Maybe { + return this.circuits.impure._burnFromContract(coin, amount); + } + + /// + /// Derived-nonce extension + /// + + /** @description Advances the nonce chain and returns the next derived coin nonce. */ + public _deriveNonce(): Uint8Array { + return this.circuits.impure._deriveNonce(); + } + + /** + * @description The documented composition: base `_mint` with the extension's + * `_deriveNonce()` output as the nonce. + */ + public _mintWithDerivedNonce( + recipient: Either, + amount: bigint, + ): ShieldedCoinInfo { + return this.circuits.impure._mintWithDerivedNonce(recipient, amount); + } + + /** + * @description Seeds the derived-nonce chain post-deploy. Test-only entry + * point for the seed-once / zero-seed guards. + */ + public initializeNonce(initNonce: Uint8Array): void { + this.circuits.impure.initializeNonce(initNonce); + } + + /// + /// Ledger reads (fields without getter circuits) + /// + + /** @description Whether the token module has been initialized. */ + public isInitialized(): boolean { + return this.getPublicState().NativeShieldedToken__isInitialized; + } + + /** @description Current value of the derived-nonce chain counter. */ + public nonceCounter(): bigint { + return this.getPublicState().NativeShieldedTokenDerivedNonce__counter; + } + + /** @description Latest value of the derived-nonce evolution chain (`_nonce`). */ + public nonceChainValue(): Uint8Array { + return this.getPublicState().NativeShieldedTokenDerivedNonce__nonce; + } +} diff --git a/contracts/test/integration/_harness/cma.ts b/contracts/test/integration/_harness/cma.ts new file mode 100644 index 000000000..928a90c44 --- /dev/null +++ b/contracts/test/integration/_harness/cma.ts @@ -0,0 +1,251 @@ +import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js'; +import { + type ContractMaintenanceAuthority, + type ContractState, + sampleSigningKey, + signData, + type SigningKey, +} from '@midnight-ntwrk/compact-runtime'; +import { + Intent, + MaintenanceUpdate, + type SingleUpdate, + Transaction, +} from '@midnight-ntwrk/ledger-v8'; +import { + submitTx, + type DeployedContract, + type FoundContract, +} from '@midnight-ntwrk/midnight-js-contracts'; +import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; +import { + asContractAddress, + type FinalizedTxData, + type MidnightProviders, + type VerifierKey, +} from '@midnight-ntwrk/midnight-js-types'; +import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils'; + +/** + * Query helpers and upgrade-path wrappers around the CMA primitives exposed by + * `@midnight-ntwrk/midnight-js-contracts`. These are intentionally thin — the + * plan calls for growing this file one helper at a time, as specs demand them. + * + * Today covered: + * - rotateCircuitVK : `remove + insert` round-trip on a single circuit + * - readCmaCounter : current replay-protection counter + * - readContractState : raw on-chain state (for assertions on authority etc.) + * + * Planned next (Milestone 2 companion specs): + * - rotateAuthority(newSigningKey) + * - freeze() (rotate to the empty / ∅ authority) + * - readAuthority() helper returning `{ committee, threshold, counter }` + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyProviders = MidnightProviders; + +/** + * Fetch the on-chain `ContractState` for a deployed contract address via the + * indexer. Returns `undefined` if the indexer hasn't seen the address yet + * (e.g. race right after deploy before the indexer catches up). + */ +export async function readContractState( + providers: AnyProviders, + address: string, +): Promise { + const state = await providers.publicDataProvider.queryContractState(address); + return state ?? undefined; +} + +/** + * Read the current `ContractMaintenanceAuthority` for a contract. Throws if + * the indexer has no record — callers are expected to have just deployed or + * updated the contract. + */ +export async function readAuthority( + providers: AnyProviders, + address: string, +): Promise { + const state = await readContractState(providers, address); + if (!state) { + throw new Error( + `readAuthority: no ContractState available for ${address} yet`, + ); + } + return state.maintenanceAuthority; +} + +/** + * Convenience over `readAuthority(...).counter` — the monotonically increasing + * replay-protection counter bumped by each successful `SingleUpdate`. + */ +export async function readCmaCounter( + providers: AnyProviders, + address: string, +): Promise { + const auth = await readAuthority(providers, address); + return auth.counter; +} + +/** + * Remove + re-insert the current verifier key for a single circuit. + * + * The default `newVk` parameter is the *current* VK fetched from the + * `ZKConfigProvider` — i.e. a round-trip that exercises the CMA pathway + * without actually changing on-chain behaviour. Pass an explicit `newVk` for + * tests that want to observe a genuine behavioural change. + * + * Each call causes the CMA counter to advance by exactly 2 (one SingleUpdate + * for the remove, one for the insert). + */ +/** Either a freshly deployed contract or one rebound via `findDeployedContract`. */ +type AnyDeployed = + | DeployedContract + | FoundContract; + +export async function rotateCircuitVK( + providers: AnyProviders, + deployed: AnyDeployed, + circuitName: ContractNs.ProvableCircuitId, + newVk?: VerifierKey, +): Promise { + const vk = + newVk ?? (await providers.zkConfigProvider.getVerifierKey(circuitName)); + const tx = deployed.circuitMaintenanceTx[circuitName]; + if (!tx) { + throw new Error( + `rotateCircuitVK: deployed contract has no circuit named '${circuitName}'`, + ); + } + await tx.removeVerifierKey(); + await tx.insertVerifierKey(vk); +} + +/** + * Replace the contract's maintenance authority with `newAuthority`. Signed by + * the current authority key stored in the deployed contract's providers. + * + * @returns the `SigningKey` that was installed (so tests can re-sign with it + * or assert its bytes). + */ +export async function rotateAuthority( + deployed: AnyDeployed, + newAuthority: SigningKey, +): Promise { + await deployed.contractMaintenanceTx.replaceAuthority(newAuthority); + return newAuthority; +} + +/** + * Functional equivalent of "freeze the contract" for single-signer CMAs: + * generate a fresh random `SigningKey`, install it as the new authority, then + * deliberately throw away the bytes. Because the current `DeployedContract`'s + * signer is still the *old* key, every subsequent `MaintenanceUpdate` the + * SDK tries to sign will fail verification on-chain — nobody can update again. + * + * This is NOT the protocol-level empty-authority state documented in the + * research report. It's the strongest effect achievable from the high-level + * midnight-js-contracts 4.x surface, which takes a single `SigningKey` rather + * than a full `ContractMaintenanceAuthority` with `committee=[]`. Once the + * ledger-level `MaintenanceUpdate` constructor becomes ergonomic in our + * harness, swap this out for a real empty-authority call. + */ +export async function freeze( + deployed: AnyDeployed, +): Promise { + const abandoned = sampleSigningKey(); + await deployed.contractMaintenanceTx.replaceAuthority(abandoned); + // Intentionally drop `abandoned` — no reference is retained anywhere. +} + +/** + * Submit a `MaintenanceUpdate` carrying *N* `SingleUpdate`s in a single tx. + * + * The SDK's public maintenance API (`circuitMaintenanceTx.X.removeVerifierKey()`, + * `contractMaintenanceTx.replaceAuthority(...)`, etc.) wraps exactly one + * `SingleUpdate` per tx — there's no public path to bundle multiple changes. + * To probe protocol-level questions like "what does the chain do with two + * `ReplaceAuthority`s in one bundle?" or "would the chain accept two + * `VerifierKeyInsert`s on the same operation if we bypass the SDK guard?", + * we have to drop down to the raw ledger-v8 classes and submit by hand. + * + * The flow mirrors what the SDK's internal `unprovenTxFromContractUpdates` + * (at `node_modules/@midnight-ntwrk/midnight-js-contracts/dist/index.mjs`) + * does, with manual signing in place of the contract-executable's + * `addOrReplaceContractOperation` / `removeContractOperation` calls: + * + * 1. Read the current CMA counter (replay protection — must match + * on-chain at submission time). + * 2. Construct `new MaintenanceUpdate(addr, singleUpdates, counter)`. + * 3. Sign `mu.dataToSign` with the contract's signing key (looked up + * from `providers.privateStateProvider`). + * 4. Attach the signature at committee index 0n (single-signer CMA — every + * contract this harness deploys has a one-key authority). + * 5. Wrap in `Intent.new(ttl).addMaintenanceUpdate(signed)`. + * 6. Wrap that in `Transaction.fromParts(networkId, undefined, undefined, intent)`. + * 7. Submit via `submitTx(providers, { unprovenTx })`. + * + * Counter caveat: a `MaintenanceUpdate` carrying *N* `SingleUpdate`s only + * occupies counter value *C*. Whether the chain advances the on-chain + * counter by 1 (one tx = one increment) or by N (one increment per + * SingleUpdate) is itself an open question — observe via `readCmaCounter` + * before/after to find out. + * + * @param counterOverride — optional. By default the helper reads the current + * on-chain counter and signs against it. Pass an explicit value here when + * the test wants to *forge* a stale counter (e.g., the staleCounter spec + * that asserts replay-protection rejection): the MU is built with the + * given counter and signed accordingly, so the chain sees a + * counter-mismatch. + * + * @returns the `FinalizedTxData` from `submitTx`. Throws on submission + * failure (`TxFailedError` from the SDK or wrapped variants — see + * existing patterns in `specs/cma/`). + * + * @example + * await submitRawMaintenanceUpdate(kit.providers, kit.contractAddress, [ + * new ReplaceAuthority(authA), + * new ReplaceAuthority(authB), + * ]); + */ +export async function submitRawMaintenanceUpdate( + providers: AnyProviders, + contractAddress: string, + updates: SingleUpdate[], + counterOverride?: bigint, +): Promise { + const [signingKey, freshCounter] = await Promise.all([ + providers.privateStateProvider.getSigningKey(contractAddress), + readCmaCounter(providers, contractAddress), + ]); + const counter = counterOverride ?? freshCounter; + if (!signingKey) { + throw new Error( + `submitRawMaintenanceUpdate: no signing key for contract ${contractAddress} in privateStateProvider`, + ); + } + + const mu = new MaintenanceUpdate( + asContractAddress(contractAddress), + updates, + counter, + ); + const signature = signData(signingKey, mu.dataToSign); + const signed = mu.addSignature(0n, signature); + + const intent = Intent.new(ttlOneHour()).addMaintenanceUpdate(signed); + const unprovenTx = Transaction.fromParts( + getNetworkId(), + undefined, + undefined, + intent, + ); + // `submitTx`'s providers type is generic over a contract type, but the + // call only reads provider plumbing (publicData, wallet) that's identical + // for any contract. The cast just unifies the generic so `AnyProviders` + // satisfies the parameter. + return submitTx(providers as Parameters[0], { + unprovenTx, + }); +} diff --git a/contracts/test/integration/_harness/deploy.ts b/contracts/test/integration/_harness/deploy.ts new file mode 100644 index 000000000..f9a1d283b --- /dev/null +++ b/contracts/test/integration/_harness/deploy.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + CompiledContract, + Contract as ContractNs, +} from '@midnight-ntwrk/compact-js'; +import { + type DeployContractOptionsWithPrivateState, + type DeployedContract, + deployContract, +} from '@midnight-ntwrk/midnight-js-contracts'; +import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Absolute path to `contracts/artifacts//`. + * Used by `NodeZkConfigProvider`, which expects the directory containing + * `keys/` and `zkir/` (i.e. the module root, not the `contract/` subfolder). + */ +export function moduleRootPath(moduleName: string): string { + // _harness/ is at contracts/test/integration/_harness/ + // module root at contracts/artifacts// + return path.resolve( + currentDir, + '..', + '..', + '..', + 'artifacts', + moduleName, + ); +} + +/** + * Absolute path to `contracts/artifacts//contract/` — where the + * compiled `index.js`, `index.d.ts`, and `contract-info.json` (in compiler/) + * live. Used by `CompiledContract.withCompiledFileAssets`. + */ +export function contractAssetsPath(moduleName: string): string { + return path.join(moduleRootPath(moduleName), 'contract'); +} + +/** + * Generic deploy wrapper. + * + * Each per-module fixture builds its own `CompiledContract` (because + * `witnesses` are module-specific) and passes it here along with providers, + * a private-state id, the initial private-state value, and the contract's + * constructor arguments — all properly typed via `Contract.*` helpers from + * `@midnight-ntwrk/compact-js`, so callers don't need any escape casts. + */ +export async function deployModule( + providers: MidnightProviders< + ContractNs.ProvableCircuitId, + string, + ContractNs.PrivateState + >, + // The third generic of `CompiledContract` (the witnesses map) defaults to + // `never` for empty-witness contracts; accept `any` so both shapes pass. + compiledContract: CompiledContract.CompiledContract< + C, + ContractNs.PrivateState, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >, + privateStateId: string, + initialPrivateState: ContractNs.PrivateState, + args: ContractNs.InitializeParameters, +): Promise> { + // The deployContract options shape is conditional on whether + // `Contract.InitializeParameters` is empty — TypeScript can't reduce + // that conditional under an unbounded `C extends Contract.Any`, so we + // shape the literal once and assert it matches `DeployContractOptionsWithPrivateState`. + // Two-step cast (through `unknown`) because TS rejects the direct cast + // as "neither type sufficiently overlaps" — same conditional-resolution + // issue. Scoped to this single helper. + const options = { + compiledContract, + privateStateId, + initialPrivateState, + args, + } as unknown as DeployContractOptionsWithPrivateState; + return deployContract(providers, options); +} diff --git a/contracts/test/integration/_harness/effects.ts b/contracts/test/integration/_harness/effects.ts new file mode 100644 index 000000000..c8085b72b --- /dev/null +++ b/contracts/test/integration/_harness/effects.ts @@ -0,0 +1,131 @@ +/** + * Decoders for the Zswap effects a contract call records in its transcript. + * + * The indexer does not expose structured effects, but `callTx`'s finalized + * public result carries the deserialized `Transaction` at `.public.tx`. Walk + * `tx.intents.values() -> intent.actions -> ContractCall.{guaranteed,fallible}Transcript + * -> effects` (ledger-v8) to read what a third party could reconstruct from + * public data: minted amounts per token-color domain (`shieldedMints`) and the + * coin commitments a receive/spend claimed. + * + * Types are intentionally loose: the ledger-v8 objects are WASM-backed and not + * part of this repo's typed surface. + */ + +// biome-ignore lint/suspicious/noExplicitAny: WASM-backed ledger-v8 objects +type Any = any; + +/** Hex-encode a Uint8Array (effects map keys are hex strings already; coin + * commitments / public keys come back as bytes or hex depending on the type). */ +export function toHex(v: Uint8Array | string): string { + if (typeof v === 'string') return v; + return Buffer.from(v).toString('hex'); +} + +/** Every ContractCall transcript (guaranteed + fallible) in a finalized tx. */ +function* transcripts(finalizedPublic: Any): Generator { + const tx = finalizedPublic?.tx; + if (!tx?.intents?.values) return; + for (const intent of tx.intents.values()) { + const actions: Any[] = intent?.actions ?? []; + for (const action of actions) { + const g = safeGet(() => action.guaranteedTranscript); + const f = safeGet(() => action.fallibleTranscript); + if (g) yield g; + if (f) yield f; + } + } +} + +function safeGet(fn: () => T): T | undefined { + try { + return fn(); + } catch { + return undefined; + } +} + +/** + * Sum of `shieldedMints` per token-color domain across all contract calls in + * the tx. Keys are 32-byte domain separators (hex); values are minted u64s. + * This is the "independently verifiable from public shieldedMints" quantity. + */ +export function decodeShieldedMints(finalizedPublic: Any): Map { + const out = new Map(); + for (const t of transcripts(finalizedPublic)) { + const sm = safeGet(() => t?.effects?.shieldedMints); + if (sm?.forEach) { + sm.forEach((v: bigint, k: Uint8Array | string) => { + const key = toHex(k); + out.set(key, (out.get(key) ?? 0n) + BigInt(v)); + }); + } + } + return out; +} + +/** Total minted across all colors in the tx. */ +export function totalShieldedMinted(finalizedPublic: Any): bigint { + let sum = 0n; + for (const v of decodeShieldedMints(finalizedPublic).values()) sum += v; + return sum; +} + +/** The coin commitments claimed by `receiveShielded` across the tx. */ +export function decodeClaimedReceives(finalizedPublic: Any): string[] { + const out: string[] = []; + for (const t of transcripts(finalizedPublic)) { + const cr = safeGet(() => t?.effects?.claimedShieldedReceives); + if (Array.isArray(cr)) { + for (const c of cr) out.push(commitmentHex(c)); + } else if (cr?.forEach) { + cr.forEach((c: Any) => out.push(commitmentHex(c))); + } + } + return out; +} + +/** A flat, lower-cased hex dump of every byte-ish field reachable in the tx's + * contract-call effects + transcripts. Used by privacy assertions to prove a + * value (e.g. a recipient public key) does NOT appear anywhere in public data. */ +export function publicEffectsHexBlob(finalizedPublic: Any): string { + const parts: string[] = []; + const push = (v: Any) => { + if (v == null) return; + if (v instanceof Uint8Array) parts.push(toHex(v)); + else if (typeof v === 'string') parts.push(v.toLowerCase()); + else if (typeof v?.toString === 'function') { + const s = v.toString(); + if (typeof s === 'string') parts.push(s.toLowerCase()); + } + }; + for (const t of transcripts(finalizedPublic)) { + const eff = safeGet(() => t?.effects); + push(safeGet(() => t?.toString())); + if (eff) { + for (const field of [ + 'claimedNullifiers', + 'claimedShieldedReceives', + 'claimedShieldedSpends', + 'shieldedMints', + ]) { + const col = safeGet(() => eff[field]); + if (Array.isArray(col)) for (const c of col) push(c); + else if (col?.forEach) col.forEach((v: Any, k: Any) => { + push(k); + push(v); + }); + } + } + } + // Also include the whole serialized tx as a backstop. + push(safeGet(() => Buffer.from(finalizedPublic.tx.serialize()).toString('hex'))); + return parts.join('|'); +} + +function commitmentHex(c: Any): string { + if (c instanceof Uint8Array) return toHex(c); + if (typeof c === 'string') return c.toLowerCase(); + const s = safeGet(() => c?.toString()); + return typeof s === 'string' ? s.toLowerCase() : String(c); +} diff --git a/contracts/test/integration/_harness/globalTeardown.ts b/contracts/test/integration/_harness/globalTeardown.ts new file mode 100644 index 000000000..59ba42cbd --- /dev/null +++ b/contracts/test/integration/_harness/globalTeardown.ts @@ -0,0 +1,14 @@ +import { resetSharedWalletPool } from '../fixtures/walletPool.js'; + +// Wired into `vitest.integration.config.ts` as a `globalSetup` entry. +// Vitest invokes the default export once before the whole suite (no setup +// work needed) and the returned function once after every spec finishes — +// at which point we stop every wallet the process-shared pool built so +// their indexer/node websocket subscriptions close cleanly. Without this +// the suite exits with dangling sockets and a noisy "subscribeRuntimeVersion +// disconnected" line per wallet. +export default async function setup(): Promise<() => Promise> { + return async () => { + await resetSharedWalletPool(); + }; +} diff --git a/contracts/test/integration/_harness/network.ts b/contracts/test/integration/_harness/network.ts new file mode 100644 index 000000000..dacf5ebcc --- /dev/null +++ b/contracts/test/integration/_harness/network.ts @@ -0,0 +1,63 @@ +import { + type NetworkId, + setNetworkId, +} from '@midnight-ntwrk/midnight-js-network-id'; + +/** + * Endpoint configuration for the local stack. Replaces testkit-js' + * `EnvironmentConfiguration` — a plain struct of URLs we own, structurally + * compatible with `OwnWalletProvider`'s `OwnNetworkConfig`. + */ +export interface LocalNetworkConfig { + readonly walletNetworkId: NetworkId; + readonly networkId: string; + readonly indexer: string; + readonly indexerWS: string; + readonly node: string; + readonly nodeWS: string; + readonly proofServer: string; + readonly faucet: string | undefined; +} + +/** + * Prefunded wallet mnemonic for the local `undeployed` network — the canonical + * BIP39 test seed ("abandon" × 23 + "diesel") that `midnight-node --preset=dev` + * recognises as the genesis-funded account. Inlined so the harness no longer + * depends on testkit-js' `TEST_MNEMONIC`. + */ +export const LOCAL_WALLET_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon diesel'; + +/** + * Default endpoints for the local stack brought up by `make env-up`. + * Each is overridable via a MIDNIGHT_* env var so CI / other hosts + * can point the same harness at a relocated stack. + */ +export function networkConfig(): LocalNetworkConfig { + return { + walletNetworkId: 'undeployed' as NetworkId, + networkId: 'undeployed', + indexer: + process.env.MIDNIGHT_INDEXER_URL ?? + 'http://127.0.0.1:8088/api/v4/graphql', + indexerWS: + process.env.MIDNIGHT_INDEXER_WS_URL ?? + 'ws://127.0.0.1:8088/api/v4/graphql/ws', + node: process.env.MIDNIGHT_NODE_URL ?? 'http://127.0.0.1:9944', + nodeWS: 'ws://127.0.0.1:9944', + proofServer: + process.env.MIDNIGHT_PROOF_SERVER_URL ?? 'http://127.0.0.1:6300', + faucet: undefined, + }; +} + +/** + * Set the process-wide network id. Must be called once before any provider + * or wallet is constructed. Idempotent. + */ +let networkIdSet = false; +export function setupNetwork(): void { + if (networkIdSet) return; + setNetworkId((process.env.MIDNIGHT_NETWORK_ID ?? 'undeployed') as NetworkId); + networkIdSet = true; +} diff --git a/contracts/test/integration/_harness/ownWallet.ts b/contracts/test/integration/_harness/ownWallet.ts new file mode 100644 index 000000000..fd2a78dfb --- /dev/null +++ b/contracts/test/integration/_harness/ownWallet.ts @@ -0,0 +1,289 @@ +/** + * Own test wallet provider — a testkit-js-free reconstruction of the wallet + * stack used by `@midnight-ntwrk/midnight-js-contracts#deployContract`. + * + * WHY THIS EXISTS + * --------------- + * The integration harness previously leaned on `@midnight-ntwrk/testkit-js`'s + * `MidnightWalletProvider` / `FluentWalletBuilder`. testkit is a heavy + * dependency (testcontainers, docker orchestration, a fixed env model) of which + * we use almost nothing — we run our own local stack via `make env-up`. All + * testkit gave us here was a thin `WalletProvider`/`MidnightProvider` adapter + * over `@midnight-ntwrk/wallet-sdk` plus seed-derivation glue. + * + * This module reproduces exactly that glue directly on `@midnight-ntwrk/wallet-sdk`, + * so the harness no longer imports testkit. It is a behavioural drop-in for the + * old `buildWallet()` (see wallet.ts) and `WalletPool` seed path. + * + * The construction mirrors testkit's `WalletFactory` / `FluentWalletBuilder`: + * seeds = role-derived sub-seeds from a BIP39 mnemonic or a raw 32-byte seed + * facade = WalletFacade.init({ shielded, unshielded, dust }) over wallet-sdk + * `balanceTx` / `submitTx` delegate to the facade identically to testkit. + * + * The provider deliberately exposes its internals (`facade`, `zswapSecretKeys`, + * `shielded`) so a future coin-injecting shielded wallet can be slotted in via + * `WalletFacade.init`'s custom `shielded` initialiser — the seam that unblocks + * the spend-path (burn / round-trip) integration specs. See + * `NativeShieldedToken-tests.md` "Own wallet tool". + */ +import { + DustSecretKey, + LedgerParameters, + ZswapSecretKeys, +} from '@midnight-ntwrk/ledger-v8'; +import type { + FinalizedTransaction, + TransactionId, +} from '@midnight-ntwrk/midnight-js-protocol/ledger'; +import type { + MidnightProvider, + WalletProvider, +} from '@midnight-ntwrk/midnight-js-types'; +import { + createKeystore, + DustWallet, + HDWallet, + InMemoryTransactionHistoryStorage, + mergeWalletEntries, + PublicKey, + type Role, + Roles, + ShieldedWallet, + UnshieldedWallet, + WalletEntrySchema, + WalletFacade, +} from '@midnight-ntwrk/wallet-sdk'; +import type { NetworkId } from '@midnight-ntwrk/midnight-js-network-id'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import pino, { type Logger } from 'pino'; + +/** + * Minimal endpoint config our wallet needs — a structural subset of the fields + * `networkConfig()` already returns, with no testkit type dependency. + */ +export interface OwnNetworkConfig { + readonly walletNetworkId: NetworkId; + readonly indexer: string; + readonly indexerWS: string; + readonly nodeWS: string; + readonly proofServer: string; +} + +/** + * Wide fee overhead for the local `undeployed` network. Genesis-funded dust at + * preset-dev needs headroom to cover fees on undeployed; mirrors the value the + * old testkit-based `buildWallet` passed via `DustWalletOptions`. + */ +const UNDEPLOYED_FEE_OVERHEAD = 500_000_000_000_000_000n; + +let sharedLogger: Logger | undefined; +function ownLogger(): Logger { + if (!sharedLogger) { + sharedLogger = pino({ level: process.env.LOG_LEVEL ?? 'warn' }); + } + return sharedLogger; +} + +/** The three role sub-seeds derived from a master seed, plus the master. */ +interface DerivedSeeds { + readonly masterSeedHex: string; + readonly shielded: Uint8Array; + readonly unshielded: Uint8Array; + readonly dust: Uint8Array; +} + +/** Derive a role key the way testkit's `deriveKeyForRole` does (account 0, key 0). */ +function deriveKeyForRole(masterSeedHex: string, role: Role): Uint8Array { + if (!masterSeedHex || masterSeedHex.length === 0) { + throw new Error('Own wallet: master seed cannot be empty'); + } + const result = HDWallet.fromSeed(Buffer.from(masterSeedHex, 'hex')); + if (result.type !== 'seedOk') { + throw new Error('Own wallet: invalid seed, failed to create HD wallet'); + } + const derived = result.hdWallet + .selectAccount(0) + .selectRole(role) + .deriveKeyAt(0); + if (derived.type !== 'keyDerived') { + throw new Error(`Own wallet: key derivation failed for role ${role}`); + } + return derived.key; +} + +function seedsFromMasterHex(masterSeedHex: string): DerivedSeeds { + return { + masterSeedHex, + shielded: deriveKeyForRole(masterSeedHex, Roles.Zswap), + unshielded: deriveKeyForRole(masterSeedHex, Roles.NightExternal), + dust: deriveKeyForRole(masterSeedHex, Roles.Dust), + }; +} + +function seedsFromMnemonic(mnemonic: string): DerivedSeeds { + if (!mnemonic || mnemonic.trim().length === 0) { + throw new Error('Own wallet: mnemonic cannot be empty'); + } + return seedsFromMasterHex( + Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex'), + ); +} + +/** + * Map our endpoint config to the wallet-sdk facade configuration object. + * Shape lifted from testkit's `mapEnvironmentToConfiguration`. + */ +function facadeConfiguration(env: OwnNetworkConfig) { + return { + indexerClientConnection: { + indexerHttpUrl: env.indexer, + indexerWsUrl: env.indexerWS, + }, + provingServerUrl: new URL(env.proofServer), + networkId: env.walletNetworkId, + relayURL: new URL(env.nodeWS), + txHistoryStorage: new InMemoryTransactionHistoryStorage( + WalletEntrySchema, + mergeWalletEntries, + ), + costParameters: { feeBlocksMargin: 5 }, + }; +} + +/** + * `WalletProvider` + `MidnightProvider` over a wallet-sdk `WalletFacade`, + * with no testkit dependency. `balanceTx`/`submitTx` are byte-for-byte the + * same delegations testkit's `MidnightWalletProvider` performed. + */ +export class OwnWalletProvider implements WalletProvider, MidnightProvider { + private constructor( + readonly env: OwnNetworkConfig, + readonly facade: WalletFacade, + readonly zswapSecretKeys: ZswapSecretKeys, + readonly dustSecretKey: DustSecretKey, + private readonly unshieldedKeystore: ReturnType, + private readonly logger: Logger, + ) {} + + getCoinPublicKey() { + return this.zswapSecretKeys.coinPublicKey; + } + + getEncryptionPublicKey() { + return this.zswapSecretKeys.encryptionPublicKey; + } + + async balanceTx( + tx: Parameters[0], + ttl: Date = ttlOneHour(), + ): Promise { + const recipe = await this.facade.balanceUnboundTransaction( + tx, + { + shieldedSecretKeys: this.zswapSecretKeys, + dustSecretKey: this.dustSecretKey, + }, + { ttl }, + ); + const signed = await this.facade.signRecipe(recipe, (payload) => + this.unshieldedKeystore.signData(payload), + ); + return this.facade.finalizeRecipe(signed); + } + + submitTx(tx: FinalizedTransaction): Promise { + return this.facade.submitTransaction(tx); + } + + async stop(): Promise { + await this.facade.stop(); + } + + /** Build a provider from a master seed (hex) or BIP39 mnemonic. */ + static async build( + env: OwnNetworkConfig, + keyMaterial: { mnemonic: string } | { seedHex: string }, + options: { waitForFunds?: boolean } = {}, + ): Promise { + const logger = ownLogger(); + const seeds = + 'mnemonic' in keyMaterial + ? seedsFromMnemonic(keyMaterial.mnemonic) + : seedsFromMasterHex(keyMaterial.seedHex); + + const config = facadeConfiguration(env); + const unshieldedKeystore = createKeystore( + seeds.unshielded, + env.walletNetworkId, + ); + + const shielded = ShieldedWallet(config).startWithSeed(seeds.shielded); + const unshielded = UnshieldedWallet({ + ...config, + txHistoryStorage: new InMemoryTransactionHistoryStorage( + WalletEntrySchema, + mergeWalletEntries, + ), + }).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)); + + const dustConfig = { + ...config, + costParameters: { + ledgerParams: LedgerParameters.initialParameters(), + additionalFeeOverhead: + env.walletNetworkId === 'undeployed' ? UNDEPLOYED_FEE_OVERHEAD : 0n, + feeBlocksMargin: 5, + }, + }; + const dust = DustWallet(dustConfig).startWithSeed( + seeds.dust, + LedgerParameters.initialParameters().dust, + ); + + const facade = await WalletFacade.init({ + configuration: config, + shielded: () => shielded, + unshielded: () => unshielded, + dust: () => dust, + }); + + const zswapSecretKeys = ZswapSecretKeys.fromSeed(seeds.shielded); + const dustSecretKey = DustSecretKey.fromSeed(seeds.dust); + + logger.info('Own wallet: starting facade...'); + await facade.start(zswapSecretKeys, dustSecretKey); + if (options.waitForFunds ?? true) { + await waitForShieldedSync(facade, logger); + } + + return new OwnWalletProvider( + env, + facade, + zswapSecretKeys, + dustSecretKey, + unshieldedKeystore, + logger, + ); + } +} + +function ttlOneHour(): Date { + return new Date(Date.now() + 60 * 60 * 1000); +} + +/** + * Block until the shielded wallet reports a synced state. The facade's shielded + * API exposes a `waitForSyncedState`; fall back to a short settle if absent. + */ +async function waitForShieldedSync( + facade: WalletFacade, + logger: Logger, +): Promise { + const shielded = facade.shielded as { + waitForSyncedState?: (gap?: bigint) => Promise; + }; + if (typeof shielded.waitForSyncedState === 'function') { + await shielded.waitForSyncedState(); + logger.info('Own wallet: shielded state synced'); + } +} diff --git a/contracts/test/integration/_harness/providers.ts b/contracts/test/integration/_harness/providers.ts new file mode 100644 index 000000000..7a2cca7a5 --- /dev/null +++ b/contracts/test/integration/_harness/providers.ts @@ -0,0 +1,56 @@ +import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider'; +import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; +import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider'; +import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider'; +import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types'; +import type { OwnWalletProvider } from './ownWallet.js'; + +/** + * Build a fully-wired `MidnightProviders` bundle for a given compiled contract's + * artifact directory. Each module test passes its own `` so the + * ZK config provider reads that module's keys. + * + * Shape ported from midnight-apps/packages/lunarswap-cli/src/api/providers.ts. + * + * @param wallet A started `TestWalletProvider` + * @param artifactPath Absolute path to `contracts/artifacts//contract` + * (the directory containing `contract-info.json` etc.) + * @param privateStateStoreName LevelDB namespace, unique per test contract + * @param circuitKeys Type parameter carrying the module's circuit union + */ +export function buildProviders< + CircuitKey extends string, + PrivateStateId extends string, + PrivateState, +>( + wallet: OwnWalletProvider, + artifactPath: string, + privateStateStoreName: string, +): MidnightProviders { + const zkConfigProvider = new NodeZkConfigProvider(artifactPath); + + const privateStateConfig = { + privateStateStoreName, + accountId: wallet.getCoinPublicKey(), + // Fixed test password: local/undeployed wallets don't need real entropy. + // Chosen to satisfy `validatePassword` (no 3+ consecutive identical chars, + // min-length, mixed classes) deterministically across runs. + privateStoragePasswordProvider: () => 'Compact-Integration-Test-Pw!9', + } as Parameters>[0]; + + return { + privateStateProvider: + levelPrivateStateProvider(privateStateConfig), + publicDataProvider: indexerPublicDataProvider( + wallet.env.indexer, + wallet.env.indexerWS, + ), + zkConfigProvider, + proofProvider: httpClientProofProvider( + wallet.env.proofServer, + zkConfigProvider, + ), + walletProvider: wallet, + midnightProvider: wallet, + }; +} diff --git a/contracts/test/integration/_harness/wallet.ts b/contracts/test/integration/_harness/wallet.ts new file mode 100644 index 000000000..70e0f88ba --- /dev/null +++ b/contracts/test/integration/_harness/wallet.ts @@ -0,0 +1,17 @@ +import { LOCAL_WALLET_MNEMONIC, type LocalNetworkConfig } from './network.js'; +import { OwnWalletProvider } from './ownWallet.js'; + +/** + * Build (and start) a wallet provider from a BIP39 mnemonic, with no testkit-js + * dependency. `OwnWalletProvider` implements both `MidnightProvider` and + * `WalletProvider` expected by `@midnight-ntwrk/midnight-js-contracts#deployContract`. + * + * Default mnemonic is the prefunded genesis account on `midnight-node --preset=dev`. + * Tests that need per-signer isolation pass their own BIP39 phrase. + */ +export async function buildWallet( + env: LocalNetworkConfig, + mnemonic: string = LOCAL_WALLET_MNEMONIC, +): Promise { + return OwnWalletProvider.build(env, { mnemonic }, { waitForFunds: true }); +} diff --git a/contracts/test/integration/_harness/walletPool.ts b/contracts/test/integration/_harness/walletPool.ts new file mode 100644 index 000000000..23a00645b --- /dev/null +++ b/contracts/test/integration/_harness/walletPool.ts @@ -0,0 +1,70 @@ +import type { LocalNetworkConfig } from './network.js'; +import { OwnWalletProvider } from './ownWallet.js'; + +/** + * Multi-signer wallet pool for AccessControl + caller-override CMA tests. + * + * Approach: leverage the four pre-funded genesis seeds that the dev-preset + * Midnight node exposes. Each alias maps to one of those raw 32-byte seeds; + * building a wallet from the seed yields an already-funded provider. No + * derive-and-fund-from-genesis tx is needed — much faster setup. + * + * Limitation: only 3 named aliases are available beyond the deployer (the + * dev preset funds `0x…0001`–`0x…0004`; `0x…0001` is the deployer via + * `buildWallet`, leaving `0x…0002`–`0x…0004` for the pool). Adding more + * aliases requires rotating the same seeds or a derive-and-fund flow. + * + * Uses `OwnWalletProvider` (no testkit-js dependency). + */ + +/** Hex 32-byte seeds prefunded by the dev-preset Midnight node. */ +export const PREFUNDED_HEX_SEEDS: Record = { + ADMIN: '0000000000000000000000000000000000000000000000000000000000000002', + ALICE: '0000000000000000000000000000000000000000000000000000000000000003', + BOB: '0000000000000000000000000000000000000000000000000000000000000004', +}; + +export type PoolAlias = keyof typeof PREFUNDED_HEX_SEEDS; + +export class WalletPool { + private cache = new Map>(); + + constructor(private readonly env: LocalNetworkConfig) {} + + /** + * Build (and start) the wallet for `alias`. Promise-cached so parallel + * `signerFor` calls dedupe. Throws if `alias` isn't a known prefunded slot. + */ + signerFor(alias: string): Promise { + const seed = PREFUNDED_HEX_SEEDS[alias as PoolAlias]; + if (seed === undefined) { + throw new Error( + `WalletPool: unknown alias '${alias}'. Available: ${Object.keys(PREFUNDED_HEX_SEEDS).join(', ')}`, + ); + } + let cached = this.cache.get(alias); + if (!cached) { + cached = OwnWalletProvider.build( + this.env, + { seedHex: seed }, + { waitForFunds: true }, + ); + this.cache.set(alias, cached); + } + return cached; + } + + /** + * Stop every cached wallet and clear the cache. Call from `afterAll()`. + */ + async reset(): Promise { + const entries = Array.from(this.cache.values()); + this.cache.clear(); + await Promise.all( + entries.map(async (p) => { + const w = await p; + await w.stop(); + }), + ); + } +} diff --git a/contracts/test/integration/_mocks/NativeShieldedTokenV1.compact b/contracts/test/integration/_mocks/NativeShieldedTokenV1.compact new file mode 100644 index 000000000..45a5816c5 --- /dev/null +++ b/contracts/test/integration/_mocks/NativeShieldedTokenV1.compact @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// +// WARNING: FOR TESTING PURPOSES ONLY. +// The single deployable used by the native-shielded-token integration suite. +// Composes the Fungible profile (`NativeShieldedToken`) with the optional +// derived-nonce and supply extensions in one compilation unit, so one deploy +// exercises both mint paths (caller-supplied nonce and contract-derived +// nonce), both burns, and supply accounting end-to-end against the local +// stack. +// +// Unrestricted by design: the module circuits carry no authorization (gating +// is a consumer concern; see the MIP §Access Control). This contract is NOT a +// production preset — it is the integration deployable. +// +// DO NOT deploy or use this contract in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +// From contracts/test/integration/_mocks/ up three levels to contracts/, then src/token/. +import "../../../src/token/NativeShieldedToken" prefix NativeShieldedToken_; +import "../../../src/token/extensions/NativeShieldedTokenDerivedNonce" prefix NativeShieldedTokenDerivedNonce_; +import "../../../src/token/extensions/NativeShieldedTokenSupply" prefix NativeShieldedTokenSupply_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; + +// Ledger field re-exports so specs can read state via the typed `Ledger` +// (the indexer `queryContractState` path) without a proving round-trip. The +// supply totals now live on the optional NativeShieldedTokenSupply extension +// this deployable composes (see the supply accounting section below). +export { + NativeShieldedToken__isInitialized, + NativeShieldedToken__domain, + NativeShieldedToken__name, + NativeShieldedToken__symbol, + NativeShieldedToken__decimals +}; +export { + NativeShieldedTokenSupply__totalMinted, + NativeShieldedTokenSupply__totalBurned +}; +export { + NativeShieldedTokenDerivedNonce__counter, + NativeShieldedTokenDerivedNonce__nonce +}; + +/** + * @description Initializes both composed modules. The token module's + * `initialize` writes sealed metadata + domain, so it is constructor-only by + * the sealed-write rule; the derived-nonce chain is seeded alongside it. + */ +constructor( + domainSep: Bytes<32>, + initNonce: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8> +) { + NativeShieldedToken_initialize(domainSep, name_, symbol_, decimals_); + NativeShieldedTokenDerivedNonce_initialize(initNonce); +} + +// ─── Metadata ─── + +export circuit name(): Opaque<"string"> { + return NativeShieldedToken_name(); +} + +export circuit symbol(): Opaque<"string"> { + return NativeShieldedToken_symbol(); +} + +export circuit decimals(): Uint<8> { + return NativeShieldedToken_decimals(); +} + +export circuit tokenColor(): Bytes<32> { + return NativeShieldedToken_tokenColor(); +} + +// ─── Supply accounting (NativeShieldedTokenSupply extension) ─── + +export circuit totalMinted(): Uint<128> { + return NativeShieldedTokenSupply_totalMinted(); +} + +export circuit totalBurned(): Uint<128> { + return NativeShieldedTokenSupply_totalBurned(); +} + +export circuit totalSupply(): Uint<128> { + return NativeShieldedTokenSupply_totalSupply(); +} + +// ─── Mint (caller-supplied nonce — recipient-private path) ─── + +export circuit _mint( + recipient: Either, + amount: Uint<64>, + nonce: Bytes<32> +): ShieldedCoinInfo { + const coin = NativeShieldedToken__mint(recipient, amount, nonce); + NativeShieldedTokenSupply__addMinted(amount); + return coin; +} + +// ─── Mint (derived nonce — recipient-public path) ─── + +export circuit _deriveNonce(): Bytes<32> { + return NativeShieldedTokenDerivedNonce__deriveNonce(); +} + +// The documented composition: base `_mint` with the extension's `_deriveNonce` +// output as the nonce source, plus supply accounting. +export circuit _mintWithDerivedNonce( + recipient: Either, + amount: Uint<64> +): ShieldedCoinInfo { + const coin = NativeShieldedToken__mint(recipient, amount, NativeShieldedTokenDerivedNonce__deriveNonce()); + NativeShieldedTokenSupply__addMinted(amount); + return coin; +} + +// ─── Burn (same-tx coin, transient spend) ─── + +export circuit _burn( + coin: ShieldedCoinInfo, + amount: Uint<128>, + refundTo: Either +): Maybe { + const refund = NativeShieldedToken__burn(coin, amount, refundTo); + NativeShieldedTokenSupply__addBurned(amount); + return refund; +} + +// ─── Burn (contract-held coin, Merkle spend) ─── + +export circuit _burnFromContract( + coin: QualifiedShieldedCoinInfo, + amount: Uint<128> +): Maybe { + const change = NativeShieldedToken__burnFromContract(coin, amount); + NativeShieldedTokenSupply__addBurned(amount); + return change; +} diff --git a/contracts/test/integration/fixtures/nativeShieldedToken.ts b/contracts/test/integration/fixtures/nativeShieldedToken.ts new file mode 100644 index 000000000..7e7736307 --- /dev/null +++ b/contracts/test/integration/fixtures/nativeShieldedToken.ts @@ -0,0 +1,238 @@ +import { CompiledContract } from '@midnight-ntwrk/compact-js'; +import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js'; +import { + type DeployedContract, + type FoundContract, + findDeployedContract, +} from '@midnight-ntwrk/midnight-js-contracts'; +import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types'; +import type { OwnWalletProvider } from '../_harness/ownWallet.js'; +import { + Contract as NativeShieldedTokenV1, + type Ledger as NativeShieldedTokenV1Ledger, + ledger as nativeShieldedTokenLedger, +} from '../../../artifacts/NativeShieldedTokenV1/contract/index.js'; +import { + contractAssetsPath, + deployModule, + moduleRootPath, +} from '../_harness/deploy.js'; +import { networkConfig, setupNetwork } from '../_harness/network.js'; +import { buildProviders } from '../_harness/providers.js'; +import { buildWallet } from '../_harness/wallet.js'; +import type { WalletPool } from '../_harness/walletPool.js'; +import { getSharedSigners, Signers } from './walletPool.js'; + +/** + * NativeShieldedTokenV1 composes the `NativeShieldedToken` (Fungible) module + * with the `NativeShieldedTokenDerivedNonce` extension; neither declares a + * witness, so a single empty record satisfies the runtime. + */ +export type NativeShieldedTokenV1PrivateState = Record; +export const NativeShieldedTokenV1PrivateState: NativeShieldedTokenV1PrivateState = + {}; + +export const NativeShieldedTokenV1PrivateStateId = + 'nativeShieldedTokenV1PrivateState'; + +export type NativeShieldedTokenV1Contract = + NativeShieldedTokenV1; + +/** Union of the contract's provable-circuit names, derived from the artifact. */ +export type NativeShieldedTokenV1CircuitKeys = + ContractNs.ProvableCircuitId; + +export type NativeShieldedTokenV1Providers = MidnightProviders< + NativeShieldedTokenV1CircuitKeys, + typeof NativeShieldedTokenV1PrivateStateId, + NativeShieldedTokenV1PrivateState +>; + +export type DeployedNativeShieldedTokenV1 = + DeployedContract; +export type NativeShieldedTokenV1Handle = + | DeployedNativeShieldedTokenV1 + | FoundContract; + +// NativeShieldedTokenV1 declares no witnesses. Compact-js' `Contract.Witnesses` +// for an empty-witness contract resolves to `never`, so `withWitnesses` +// requires `never`. We pass an empty object cast to `never` to satisfy the +// type system and fill the Witnesses slot the CompiledContract validates. +export const compiledNativeShieldedTokenV1 = CompiledContract.make( + 'NativeShieldedTokenV1', + NativeShieldedTokenV1, +).pipe( + CompiledContract.withWitnesses({} as never), + CompiledContract.withCompiledFileAssets( + contractAssetsPath('NativeShieldedTokenV1'), + ), +); + +/** + * Default domain separator and nonce-chain seed for the deployable. Both are + * fixed 32-byte values; the seed is non-zero (a zero seed is rejected by the + * derived-nonce module's `initialize`). + */ +/** Encode an ASCII label into a fixed 32-byte array (truncated to fit). */ +function bytes32(label: string): Uint8Array { + const b = new Uint8Array(32); + b.set(new TextEncoder().encode(label).slice(0, 32)); + return b; +} + +export const DEFAULT_DOMAIN: Uint8Array = bytes32('nst:default-domain'); + +export const DEFAULT_NONCE_SEED: Uint8Array = bytes32('nst:nonce-seed'); + +export interface DeployNativeShieldedTokenV1Opts { + /** Token name. Default: `'Native Shielded Token'`. */ + name?: string; + /** Token symbol. Default: `'NST'`. */ + symbol?: string; + /** Token decimals. Default: `6`. */ + decimals?: number; + /** Domain separator (fixes the token's color). Default: `DEFAULT_DOMAIN`. */ + domain?: Uint8Array; + /** Nonce-chain seed (must be non-zero). Default: `DEFAULT_NONCE_SEED`. */ + nonceSeed?: Uint8Array; + /** + * Wallet pool to source alias signers from. Default: the process-shared pool + * from `fixtures/walletPool.ts`. Pass a fresh `new WalletPool(env)` for specs + * that need wallet-state isolation; the kit's `teardown()` stops the pool + * only when it owns it. + */ + pool?: WalletPool; +} + +export interface NativeShieldedTokenV1Kit { + /** Original `DeployedContract` handle bound to the genesis/deployer wallet. */ + deployed: DeployedNativeShieldedTokenV1; + /** Genesis-wallet providers (the deployer's bundle). */ + providers: NativeShieldedTokenV1Providers; + /** Genesis-wallet (the deployer). */ + wallet: OwnWalletProvider; + /** Hex-encoded on-chain address of the deployed contract. */ + readonly contractAddress: string; + /** The domain separator the contract was deployed with. */ + readonly domain: Uint8Array; + /** + * Multi-signer helper — `signers.eitherFor('ADMIN' | 'ALICE' | 'BOB')` for + * recipient/refund args, `signers.signerFor(alias)` for raw wallets, + * `signers.contractAddressEither(label)` for ContractAddress destinations. + */ + signers: Signers; + + /** Fetch the latest public ledger via the indexer. */ + readLedger(): Promise; + + /** + * Return a `FoundContract` handle bound to the wallet of `alias`. Subsequent + * `.callTx.foo(...)` calls run as that alias. Cached per alias. + */ + as(alias: string): Promise; + + teardown(): Promise; +} + +/** + * Deploy a fresh `NativeShieldedTokenV1` to the local node and return a kit for + * assertions, transactions, and teardown. + * + * Single-signer for the deployer (TEST_MNEMONIC genesis wallet); multi-signer + * for in-test calls via `kit.signers` (process-shared by default). The module + * is unrestricted, so any alias can mint and burn — there is no admin bootstrap. + */ +export async function deployNativeShieldedTokenV1( + opts: DeployNativeShieldedTokenV1Opts = {}, +): Promise { + setupNetwork(); + const env = networkConfig(); + const wallet = await buildWallet(env); + + const providers = buildProviders< + NativeShieldedTokenV1CircuitKeys, + typeof NativeShieldedTokenV1PrivateStateId, + NativeShieldedTokenV1PrivateState + >( + wallet, + moduleRootPath('NativeShieldedTokenV1'), + `nativeShieldedTokenV1-${Date.now()}`, + ) as NativeShieldedTokenV1Providers; + + const name = opts.name ?? 'Native Shielded Token'; + const symbol = opts.symbol ?? 'NST'; + const decimals = BigInt(opts.decimals ?? 6); + const domain = opts.domain ?? DEFAULT_DOMAIN; + const nonceSeed = opts.nonceSeed ?? DEFAULT_NONCE_SEED; + + const signers = opts.pool ? new Signers(opts.pool) : getSharedSigners(env); + + const deployed = await deployModule( + providers, + compiledNativeShieldedTokenV1, + NativeShieldedTokenV1PrivateStateId, + NativeShieldedTokenV1PrivateState, + [domain, nonceSeed, name, symbol, decimals], + ); + + const contractAddress = deployed.deployTxData.public.contractAddress; + + const handleCache = new Map>(); + + async function buildHandle( + alias: string, + ): Promise { + const aliasWallet = await signers.signerFor(alias); + const aliasProviders = buildProviders< + NativeShieldedTokenV1CircuitKeys, + typeof NativeShieldedTokenV1PrivateStateId, + NativeShieldedTokenV1PrivateState + >( + aliasWallet, + moduleRootPath('NativeShieldedTokenV1'), + `nativeShieldedTokenV1-${alias.toLowerCase()}-${Date.now()}`, + ) as NativeShieldedTokenV1Providers; + return findDeployedContract(aliasProviders, { + compiledContract: compiledNativeShieldedTokenV1, + contractAddress, + privateStateId: NativeShieldedTokenV1PrivateStateId, + initialPrivateState: NativeShieldedTokenV1PrivateState, + }); + } + + return { + deployed, + providers, + wallet, + contractAddress, + domain, + signers, + + async readLedger(): Promise { + const state = + await providers.publicDataProvider.queryContractState(contractAddress); + if (!state) { + throw new Error( + `readLedger: no ContractState available for ${contractAddress}`, + ); + } + return nativeShieldedTokenLedger(state.data); + }, + + async as(alias: string): Promise { + let cached = handleCache.get(alias); + if (!cached) { + cached = buildHandle(alias); + handleCache.set(alias, cached); + } + return cached; + }, + + async teardown(): Promise { + // Pool lifecycle is managed externally (shared pool torn down in vitest's + // globalTeardown; a spec-supplied pool is the spec's responsibility). + // Only stop the deployer wallet here. + await wallet.stop(); + }, + }; +} diff --git a/contracts/test/integration/fixtures/walletPool.ts b/contracts/test/integration/fixtures/walletPool.ts new file mode 100644 index 000000000..a968a4f41 --- /dev/null +++ b/contracts/test/integration/fixtures/walletPool.ts @@ -0,0 +1,150 @@ +import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; +import type { LocalNetworkConfig } from '../_harness/network.js'; +import type { OwnWalletProvider } from '../_harness/ownWallet.js'; +import { WalletPool } from '../_harness/walletPool.js'; + +/** + * Structural mirrors of the artifact-generated Compact types. Every + * artifact regenerates `Either`, `ZswapCoinPublicKey`, and `ContractAddress` + * with the same shapes, so a value built here passes through any + * contract's `callTx.foo(eitherArg)` via TypeScript structural typing — + * no per-artifact import required. + */ +export type ZswapCoinPublicKey = { bytes: Uint8Array }; +export type ContractAddress = { bytes: Uint8Array }; +export type CompactEither = { is_left: boolean; left: A; right: B }; +export type Caller = CompactEither; + +const ZERO_CONTRACT_ADDRESS: ContractAddress = { bytes: new Uint8Array(32) }; +const ZERO_COIN_PUBLIC_KEY: ZswapCoinPublicKey = { bytes: new Uint8Array(32) }; + +/** + * Process-singleton `WalletPool` shared across all integration specs in a run. + * + * Wallet startup is the slowest part of the integration suite — each + * `OwnWalletProvider.start()` performs a full sync against the local + * indexer/node. Sharing one pool across specs means each alias (`ADMIN`, + * `ALICE`, `BOB`) is built and synced exactly once per process; subsequent + * `deployTestTokenV1` calls reuse the already-warm wallets. + * + * The contract is redeployed fresh per spec (each `deployTestTokenV1` returns + * its own `contractAddress`), so contract state never leaks across specs. + * Wallet UTXO/dust state does carry over, which is fine: aliases are funded + * from the dev-preset genesis and the wallet sync layer handles UTXO churn. + * + * Lifecycle: + * - First call: builds the pool against `env`, caches it. + * - Subsequent calls (any env): return the cached pool. The integration + * suite uses one `networkConfig()` per process, so env-mismatch is not + * a real concern; `assertSameEnv` exists as a guardrail. + * - `resetSharedWalletPool()` stops every cached wallet and clears the + * singleton. Wired into vitest's `globalTeardown` so it runs once after + * the whole suite, not per-spec. + * + * Specs that need wallet isolation (rare — e.g., asserting "no prior UTXO + * for ALICE") can pass `{ pool: new WalletPool(env) }` to + * `deployTestTokenV1` and the kit's `teardown()` will own that pool's + * lifecycle. + */ + +/** + * Pool of test signers with EOA-aware helpers. Wraps a `WalletPool` and + * adds the conversions specs actually want at the call site: + * + * - `eitherFor(alias)` — alias's coin public key wrapped as + * `Either` (left side), ready to + * pass into AccessControl / Ownable circuit args. + * - `contractAddressEither(label)` — deterministic 32-byte + * ContractAddress wrapped as the right side of an `Either`. Used by + * specs that need a contract destination (e.g., post-C2C + * `transferOwnership` upgrade tests). No contract is actually deployed + * at the address; it's a stable test value derived from `label`. + * - `signerFor(alias)` / `coinPublicKey(alias)` — escape hatches for + * specs that need the raw `OwnWalletProvider` or the encoded + * bytes outside an `Either`. + * + * Construct one per pool. The shared singleton is exposed via + * `getSharedSigners(env)`. + */ +export class Signers { + constructor(readonly pool: WalletPool) {} + + signerFor(alias: string): Promise { + return this.pool.signerFor(alias); + } + + async coinPublicKey(alias: string): Promise { + const w = await this.pool.signerFor(alias); + return { bytes: encodeCoinPublicKey(w.getCoinPublicKey()) }; + } + + async eitherFor(alias: string): Promise { + const left = await this.coinPublicKey(alias); + return { is_left: true, left, right: ZERO_CONTRACT_ADDRESS }; + } + + contractAddressEither(label: string): Caller { + // Deterministic 32-byte ContractAddress derived from `label`. Stable + // across runs but unique per label so different specs don't collide. + const bytes = new Uint8Array(32); + const seed = new TextEncoder().encode(label); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = seed[i % seed.length] ?? 0; + } + return { + is_left: false, + left: ZERO_COIN_PUBLIC_KEY, + right: { bytes }, + }; + } +} + +let shared: WalletPool | undefined; +let sharedSigners: Signers | undefined; +let sharedEnv: LocalNetworkConfig | undefined; + +export function getSharedWalletPool( + env: LocalNetworkConfig, +): WalletPool { + if (!shared) { + shared = new WalletPool(env); + sharedEnv = env; + return shared; + } + assertSameEnv(sharedEnv as LocalNetworkConfig, env); + return shared; +} + +export function getSharedSigners(env: LocalNetworkConfig): Signers { + if (!sharedSigners) sharedSigners = new Signers(getSharedWalletPool(env)); + return sharedSigners; +} + +export async function resetSharedWalletPool(): Promise { + const current = shared; + shared = undefined; + sharedSigners = undefined; + sharedEnv = undefined; + if (current) await current.reset(); +} + +function assertSameEnv( + a: LocalNetworkConfig, + b: LocalNetworkConfig, +): void { + // Guardrail: every spec in this suite uses the same `networkConfig()` — + // mismatch indicates a misuse (e.g., a spec built its own env before the + // shared pool was reset). Fail loud rather than serve a wallet bound to + // the wrong indexer/node. + if ( + a.indexer !== b.indexer || + a.indexerWS !== b.indexerWS || + a.node !== b.node || + a.proofServer !== b.proofServer + ) { + throw new Error( + 'getSharedWalletPool: env mismatch with cached pool. ' + + 'Call resetSharedWalletPool() before re-targeting a different stack.', + ); + } +} diff --git a/contracts/test/integration/specs/nativeShieldedToken/burn.spec.ts b/contracts/test/integration/specs/nativeShieldedToken/burn.spec.ts new file mode 100644 index 000000000..65ab5490f --- /dev/null +++ b/contracts/test/integration/specs/nativeShieldedToken/burn.spec.ts @@ -0,0 +1,118 @@ +import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + deployNativeShieldedTokenV1, + type NativeShieldedTokenV1Kit, +} from '../../fixtures/nativeShieldedToken.js'; + +function b32(label: string): Uint8Array { + const u = new Uint8Array(32); + u.set(new TextEncoder().encode(label).slice(0, 32)); + return u; +} + +const ZERO_REFUND = { + is_left: true, + left: { bytes: new Uint8Array(32) }, + right: { bytes: new Uint8Array(32) }, +}; + +/** + * Burn revert guards on the proof loop. + * + * `_burn` asserts color, value, and refundTo BEFORE `receiveShielded`, so these + * guards are reachable during circuit execution / proving without the caller + * owning a real coin — the tx fails to construct/prove on the assert. + * + * The HAPPY burn paths (full / partial-with-refund) and the wallet round-trip + * require the caller to already own a SYNCED coin of the contract's color. A + * contract mint emits no coin ciphertext, and the testkit `MidnightWalletProvider` + * exposes no coin-import hook (`ZswapLocalState.watchFor` is ledger-level only), + * so those paths are not expressible against this wallet as shipped. See the + * test artifact "Out of Scope". The unit suite covers the happy `_burn` + * return-shape and accounting (INV-4, INV-10) in the simulator. + * + * Verifies on the proof loop: INV-1 (wrong-color rejection — the only barrier, + * since the protocol receive does not validate color), INV-7 (zero refundTo), + * INV-8 (amount > coin.value). + */ +describe('Burn — revert guards (proof loop)', () => { + let kit: NativeShieldedTokenV1Kit; + let color: Uint8Array; + let refundTo: { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; + }; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1(); + color = (await kit.deployed.callTx.tokenColor()).private.result; + refundTo = { + is_left: true, + left: { bytes: encodeCoinPublicKey(kit.wallet.getCoinPublicKey()) }, + right: { bytes: new Uint8Array(32) }, + }; + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should reject burning a wrong-color coin (INV-1)', async () => { + const coin = { nonce: b32('c'), color: b32('wrong-color'), value: 1_000n }; + await expect(kit.deployed.callTx._burn(coin, 1_000n, refundTo)).rejects.toThrow( + 'NativeShieldedToken: wrong token', + ); + }); + + it('should reject when amount > coin.value (INV-8)', async () => { + const coin = { nonce: b32('c'), color, value: 1_000n }; + await expect(kit.deployed.callTx._burn(coin, 1_001n, refundTo)).rejects.toThrow( + 'NativeShieldedToken: insufficient coin value', + ); + }); + + it('should reject a zero refundTo (INV-7)', async () => { + const coin = { nonce: b32('c'), color, value: 1_000n }; + await expect(kit.deployed.callTx._burn(coin, 500n, ZERO_REFUND)).rejects.toThrow( + 'NativeShieldedToken: invalid refund target', + ); + }); +}); + +/** + * `_burnFromContract` revert guards (proof loop). Same reasoning: color and + * value asserts fire before `sendShielded`. The happy Merkle-spend path + * requires a contract-held coin with a valid `mt_index` (a prior finalized + * mint-to-self plus tree residency) — see the artifact. + * + * Verifies: INV-1 (wrong color), INV-8 (amount > coin.value). + */ +describe('BurnFromContract — revert guards (proof loop)', () => { + let kit: NativeShieldedTokenV1Kit; + let color: Uint8Array; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1(); + color = (await kit.deployed.callTx.tokenColor()).private.result; + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should reject a wrong-color contract-held coin (INV-1)', async () => { + const coin = { nonce: b32('q'), color: b32('wrong-color'), value: 1_000n, mt_index: 0n }; + await expect(kit.deployed.callTx._burnFromContract(coin, 1_000n)).rejects.toThrow( + 'NativeShieldedToken: wrong token', + ); + }); + + it('should reject when amount > coin.value (INV-8)', async () => { + const coin = { nonce: b32('q'), color, value: 1_000n, mt_index: 0n }; + await expect(kit.deployed.callTx._burnFromContract(coin, 1_001n)).rejects.toThrow( + 'NativeShieldedToken: insufficient coin value', + ); + }); +}); diff --git a/contracts/test/integration/specs/nativeShieldedToken/collision.spec.ts b/contracts/test/integration/specs/nativeShieldedToken/collision.spec.ts new file mode 100644 index 000000000..31c75794c --- /dev/null +++ b/contracts/test/integration/specs/nativeShieldedToken/collision.spec.ts @@ -0,0 +1,70 @@ +import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + deployNativeShieldedTokenV1, + type NativeShieldedTokenV1Kit, +} from '../../fixtures/nativeShieldedToken.js'; + +function b32(label: string): Uint8Array { + const u = new Uint8Array(32); + u.set(new TextEncoder().encode(label).slice(0, 32)); + return u; +} + +/** + * Commitment-collision rejection (MIP §Security "Commitment collisions"). + * + * The ledger rejects a duplicate coin commitment. This is the mechanism behind + * both the caller's nonce-uniqueness responsibility and the derived-nonce + * extension's guarantee (INV-11): reusing a nonce for the same + * (value, recipient) reproduces the commitment, which is rejected. + * + * The full collision-griefing vector (pre-minting a commitment that collides + * with a FUTURE `_mintWithDerivedNonce` of the same tuple) is an operational + * risk mitigated by gating both mint paths; its underlying rejection is exactly + * what this spec exercises. Recovery (a later derived mint with a different + * tuple advances past the collision) is the derived-nonce chain progression + * already verified in the mint and unit suites. + */ +describe('Collision — duplicate commitment rejection (INV-11 mechanism)', () => { + let kit: NativeShieldedTokenV1Kit; + let recipient: { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; + }; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1(); + recipient = { + is_left: true, + left: { bytes: encodeCoinPublicKey(kit.wallet.getCoinPublicKey()) }, + right: { bytes: new Uint8Array(32) }, + }; + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should accept a first mint and reject a second reusing the same (nonce, value, recipient)', async () => { + const nonce = b32('collide-nonce'); + // First mint of the tuple finalizes and registers the commitment. + const first = await kit.deployed.callTx._mint(recipient, 1_000n, nonce); + expect(first.private.result.value).toBe(1_000n); + + // Re-using the same tuple reproduces the commitment, which the ledger + // rejects (duplicate commitment). + await expect( + kit.deployed.callTx._mint(recipient, 1_000n, nonce), + ).rejects.toThrow(); + }); + + it('should accept a mint with a different nonce after a collision (recovery)', async () => { + const before = (await kit.readLedger()).NativeShieldedTokenSupply__totalMinted; + const res = await kit.deployed.callTx._mint(recipient, 1_000n, b32('collide-recover')); + expect(res.private.result.value).toBe(1_000n); + const after = (await kit.readLedger()).NativeShieldedTokenSupply__totalMinted; + expect(after).toBe(before + 1_000n); + }); +}); diff --git a/contracts/test/integration/specs/nativeShieldedToken/effects.spec.ts b/contracts/test/integration/specs/nativeShieldedToken/effects.spec.ts new file mode 100644 index 000000000..b0c15c3fc --- /dev/null +++ b/contracts/test/integration/specs/nativeShieldedToken/effects.spec.ts @@ -0,0 +1,69 @@ +import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + decodeShieldedMints, + toHex, + totalShieldedMinted, +} from '../../_harness/effects.js'; +import { + DEFAULT_DOMAIN, + deployNativeShieldedTokenV1, + type NativeShieldedTokenV1Kit, +} from '../../fixtures/nativeShieldedToken.js'; + +function b32(label: string): Uint8Array { + const u = new Uint8Array(32); + u.set(new TextEncoder().encode(label).slice(0, 32)); + return u; +} + +/** + * Supply reconstruction from public effects — proves the MIP claim that + * `totalMinted` is "independently verifiable from the public shieldedMints + * effects" (INV-2). A mint of `amount` under the contract's domain records + * exactly `shieldedMints[domain] == amount` in the contract-call transcript, + * with no other coin minted. This is what lets an indexer flag a + * non-conforming implementation. + */ +describe('Effects — shieldedMints reconstruction (INV-2)', () => { + let kit: NativeShieldedTokenV1Kit; + let selfRecipient: { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; + }; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1(); + selfRecipient = { + is_left: true, + left: { bytes: encodeCoinPublicKey(kit.wallet.getCoinPublicKey()) }, + right: { bytes: new Uint8Array(32) }, + }; + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should record shieldedMints[domain] == minted amount in the tx effects (INV-2)', async () => { + const amount = 777n; + const res = await kit.deployed.callTx._mint(selfRecipient, amount, b32('eff-1')); + + const mints = decodeShieldedMints(res.public); + const domainHex = toHex(DEFAULT_DOMAIN); + + // Exactly one color minted, under this contract's domain, for `amount`. + expect(mints.get(domainHex)).toBe(amount); + expect(totalShieldedMinted(res.public)).toBe(amount); + expect([...mints.keys()]).toEqual([domainHex]); + }); + + it('should reconstruct the derived-nonce mint amount from public effects too (INV-2)', async () => { + const amount = 321n; + const res = await kit.deployed.callTx._mintWithDerivedNonce(selfRecipient, amount); + const mints = decodeShieldedMints(res.public); + expect(mints.get(toHex(DEFAULT_DOMAIN))).toBe(amount); + expect(totalShieldedMinted(res.public)).toBe(amount); + }); +}); diff --git a/contracts/test/integration/specs/nativeShieldedToken/mint.spec.ts b/contracts/test/integration/specs/nativeShieldedToken/mint.spec.ts new file mode 100644 index 000000000..834332bb7 --- /dev/null +++ b/contracts/test/integration/specs/nativeShieldedToken/mint.spec.ts @@ -0,0 +1,105 @@ +import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + deployNativeShieldedTokenV1, + type NativeShieldedTokenV1Kit, +} from '../../fixtures/nativeShieldedToken.js'; + +/** Encode an ASCII label into a fixed 32-byte nonce (truncated to fit). */ +function bytes32(label: string): Uint8Array { + const b = new Uint8Array(32); + b.set(new TextEncoder().encode(label).slice(0, 32)); + return b; +} + +const ZERO_RECIPIENT = { + is_left: true, + left: { bytes: new Uint8Array(32) }, + right: { bytes: new Uint8Array(32) }, +}; + +/** + * Mint spec — drives both mint paths through the full prove -> verify -> apply + * loop against the live stack. + * + * Verifies: INV-1 (color soundness — the minted coin carries this contract's + * color), INV-2 (totalMinted exact), INV-6 (zero-recipient revert), INV-11 + * (derived nonce advances the chain), INV-12 (derived nonce is domain-separated + * from the public chain value). + */ +describe('Mint — NativeShieldedTokenV1 (both paths)', () => { + let kit: NativeShieldedTokenV1Kit; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1(); + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should mint a caller-nonce coin with color = tokenColor, value = amount, nonce = arg (INV-1, INV-2)', async () => { + const alice = await kit.signers.eitherFor('ALICE'); + const handle = await kit.as('ALICE'); + const nonce = bytes32('mint-nonce-alice-1'); + const amount = 1_000n; + + const before = (await kit.readLedger()).NativeShieldedTokenSupply__totalMinted; + + const res = await handle.callTx._mint(alice, amount, nonce); + const coin = res.private.result; + + // Returned coin carries the requested value and nonce ... + expect(coin.value).toBe(amount); + expect(coin.nonce).toEqual(nonce); + + // ... and this contract's color (INV-1). tokenColor() is read through the + // circuit so the comparison is against the contract's own derivation. + const color = (await handle.callTx.tokenColor()).private.result; + expect(coin.color).toEqual(color); + + // totalMinted is exact (INV-2). + const after = (await kit.readLedger()).NativeShieldedTokenSupply__totalMinted; + expect(after).toBe(before + amount); + }); + + it('should mint via the derived-nonce path, advancing the chain (INV-2, INV-11, INV-12)', async () => { + // Submit from the well-funded genesis deployer, minting to its own key. + // Recipient identity is irrelevant to the chain/supply invariants here, and + // minting to the submitter's own key sidesteps the encryption-key resolver + // the wallet SDK needs for a third-party recipient (that is the MIP's + // out-of-band coin-delivery concern, exercised separately by the burn / + // delivery specs). + const recipient = { + is_left: true, + left: { bytes: encodeCoinPublicKey(kit.wallet.getCoinPublicKey()) }, + right: { bytes: new Uint8Array(32) }, + }; + const handle = kit.deployed; + const amount = 500n; + + const l0 = await kit.readLedger(); + const counter0 = l0.NativeShieldedTokenDerivedNonce__counter; + const minted0 = l0.NativeShieldedTokenSupply__totalMinted; + + const res = await handle.callTx._mintWithDerivedNonce(recipient, amount); + const coin = res.private.result; + expect(coin.value).toBe(amount); + + const l1 = await kit.readLedger(); + // INV-11: each derived mint advances the monotonic counter. + expect(l1.NativeShieldedTokenDerivedNonce__counter).toBe(counter0 + 1n); + // INV-2: totalMinted still exact across the composed path. + expect(l1.NativeShieldedTokenSupply__totalMinted).toBe(minted0 + amount); + // INV-12: the derived coin nonce is domain-separated from the public chain + // value an honest `_mint` caller could echo (it is Hash(tag, chainValue)). + expect(coin.nonce).not.toEqual(l1.NativeShieldedTokenDerivedNonce__nonce); + }); + + it('should reject a mint to the zero recipient (INV-6)', async () => { + const handle = await kit.as('ALICE'); + await expect( + handle.callTx._mint(ZERO_RECIPIENT, 1n, bytes32('zero-recipient')), + ).rejects.toThrow('NativeShieldedToken: invalid recipient'); + }); +}); diff --git a/contracts/test/integration/specs/nativeShieldedToken/privacy.spec.ts b/contracts/test/integration/specs/nativeShieldedToken/privacy.spec.ts new file mode 100644 index 000000000..0cb5625af --- /dev/null +++ b/contracts/test/integration/specs/nativeShieldedToken/privacy.spec.ts @@ -0,0 +1,88 @@ +import { randomBytes } from 'node:crypto'; +import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; +import { + coinCommitment, + decodeShieldedCoinInfo, + sampleCoinPublicKey, +} from '@midnight-ntwrk/ledger-v8'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { publicEffectsHexBlob, toHex } from '../../_harness/effects.js'; +import { + deployNativeShieldedTokenV1, + type NativeShieldedTokenV1Kit, +} from '../../fixtures/nativeShieldedToken.js'; + +/** + * Recipient-privacy spec (P0 / HIGH-1) — the standard's defining claim. + * + * The privacy difference between the two mint paths rests entirely on whether + * the coin nonce is secret: + * + * - base `_mint` with a secret, uniform nonce → the nonce never appears in + * public data, so a third party cannot reconstruct the coin and therefore + * cannot recompute its commitment for any candidate recipient key. The + * recipient is UNLINKABLE. + * - `_mintWithDerivedNonce` → the nonce is derived from public ledger state, + * so a third party can reconstruct the coin and recompute its commitment by + * enumerating candidate recipient keys. The recipient is RECOVERABLE (the + * documented trade-off). + * + * All assertions are against the public transaction data (`res.public.tx` + + * effects), never the call argument. + */ +describe('Privacy — recipient (un)linkability (HIGH-1, P0)', () => { + let kit: NativeShieldedTokenV1Kit; + let recipientCpk: string; + let selfRecipient: { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; + }; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1(); + recipientCpk = kit.wallet.getCoinPublicKey(); + selfRecipient = { + is_left: true, + left: { bytes: encodeCoinPublicKey(recipientCpk) }, + right: { bytes: new Uint8Array(32) }, + }; + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should NOT expose a secret-nonce mint recipient in public data (recipient-private)', async () => { + const secretNonce = new Uint8Array(randomBytes(32)); + const res = await kit.deployed.callTx._mint(selfRecipient, 1_000n, secretNonce); + + const blob = publicEffectsHexBlob(res.public); + // The secret nonce is the linchpin: it is not present in any public field, + // so the on-chain commitment cannot be recomputed by an enumerator. + expect(blob.includes(toHex(secretNonce))).toBe(false); + }); + + it('should make a derived-nonce mint recipient recoverable by key enumeration (recipient-public)', async () => { + const res = await kit.deployed.callTx._mintWithDerivedNonce(selfRecipient, 2_000n); + const coin = res.private.result; + + // Reconstruct the coin from data a third party has: the derived nonce is + // public ledger state, the color is public, the value is public via + // shieldedMints. + const ledgerCoin = decodeShieldedCoinInfo({ + color: coin.color, + nonce: coin.nonce, + value: coin.value, + }); + const blob = publicEffectsHexBlob(res.public); + + // The correct recipient key reproduces the on-chain commitment ... + const realCommitment = coinCommitment(ledgerCoin, recipientCpk).toLowerCase(); + expect(blob.includes(realCommitment)).toBe(true); + + // ... a wrong candidate key does not. + const wrongCommitment = coinCommitment(ledgerCoin, sampleCoinPublicKey()).toLowerCase(); + expect(blob.includes(wrongCommitment)).toBe(false); + }); +}); diff --git a/contracts/test/integration/specs/nativeShieldedToken/smoke.spec.ts b/contracts/test/integration/specs/nativeShieldedToken/smoke.spec.ts new file mode 100644 index 000000000..1c5801554 --- /dev/null +++ b/contracts/test/integration/specs/nativeShieldedToken/smoke.spec.ts @@ -0,0 +1,83 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + DEFAULT_NONCE_SEED, + deployNativeShieldedTokenV1, + type NativeShieldedTokenV1Kit, +} from '../../fixtures/nativeShieldedToken.js'; + +/** + * Smoke spec — proves the native-shielded-token integration harness works + * end-to-end against the single deployable (`NativeShieldedTokenV1` = Fungible + * module + derived-nonce extension): + * 1. the local node / indexer / proof server are reachable, + * 2. the contract deploys (both composed modules wired through the + * constructor, all circuits compiled with ZK keys), + * 3. its initial public ledger is queryable: + * - NativeShieldedToken: `_isInitialized = true`, metadata round-trips, + * `totalMinted = totalBurned = 0`, `_domain` is the deployed value, + * - DerivedNonce: `_nonce` seeded (non-zero), `_counter = 0`. + * + * If this passes, every subsequent native-token spec can assume the harness is + * wired correctly. + * + * Verifies: INV-1 (domain present for color derivation), INV-2/INV-4 (counters + * start at 0), INV-13 (chain seeded non-zero), INV-14 (immutable metadata set), + * INV-15 (initialized after constructor). + */ +describe('Smoke — NativeShieldedTokenV1 deploy + initial ledger', () => { + let kit: NativeShieldedTokenV1Kit; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1({ + name: 'Native Shielded Token', + symbol: 'NST', + decimals: 6, + }); + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should deploy NativeShieldedTokenV1 to the local node', () => { + expect(kit.contractAddress).toMatch(/^[0-9a-f]+$/); + }); + + it('should set _isInitialized to true after the constructor (INV-15)', async () => { + const ledger = await kit.readLedger(); + expect(ledger.NativeShieldedToken__isInitialized).toBe(true); + }); + + it('should round-trip immutable metadata name / symbol / decimals (INV-14)', async () => { + const ledger = await kit.readLedger(); + expect(ledger.NativeShieldedToken__name).toBe('Native Shielded Token'); + expect(ledger.NativeShieldedToken__symbol).toBe('NST'); + expect(ledger.NativeShieldedToken__decimals).toBe(6n); + }); + + it('should store the deployed domain separator (INV-1)', async () => { + const ledger = await kit.readLedger(); + expect(ledger.NativeShieldedToken__domain).toBeInstanceOf(Uint8Array); + expect(ledger.NativeShieldedToken__domain.length).toBe(32); + expect(ledger.NativeShieldedToken__domain).toEqual(kit.domain); + }); + + it('should start with totalMinted = totalBurned = 0 (INV-2, INV-4)', async () => { + const ledger = await kit.readLedger(); + expect(ledger.NativeShieldedTokenSupply__totalMinted).toBe(0n); + expect(ledger.NativeShieldedTokenSupply__totalBurned).toBe(0n); + }); + + it('should seed the derived-nonce chain non-zero with counter 0 (INV-13)', async () => { + const ledger = await kit.readLedger(); + expect(ledger.NativeShieldedTokenDerivedNonce__counter).toBe(0n); + expect(ledger.NativeShieldedTokenDerivedNonce__nonce).toEqual( + DEFAULT_NONCE_SEED, + ); + // Non-zero seed: the all-zero value is indistinguishable from an unseeded + // chain and is rejected by the extension's `initialize`. + expect( + ledger.NativeShieldedTokenDerivedNonce__nonce.some((b) => b !== 0), + ).toBe(true); + }); +}); diff --git a/contracts/test/integration/specs/nativeShieldedToken/supply.spec.ts b/contracts/test/integration/specs/nativeShieldedToken/supply.spec.ts new file mode 100644 index 000000000..6ed2e66d6 --- /dev/null +++ b/contracts/test/integration/specs/nativeShieldedToken/supply.spec.ts @@ -0,0 +1,68 @@ +import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + deployNativeShieldedTokenV1, + type NativeShieldedTokenV1Kit, +} from '../../fixtures/nativeShieldedToken.js'; + +function bytes32(label: string): Uint8Array { + const b = new Uint8Array(32); + b.set(new TextEncoder().encode(label).slice(0, 32)); + return b; +} + +/** + * Supply accounting spec (mint side) — proves the exact-minted and + * upper-bound-supply invariants on-chain across a sequence of mints. + * + * Verifies: INV-2 (totalMinted is the exact sum of minted amounts), INV-5 + * (totalSupply == totalMinted - totalBurned; with no contract-mediated burns + * yet, totalSupply == totalMinted and totalBurned == 0). + * + * Bypass-burn and indexer-reconstruction cases (the rest of INV-5 and the + * "independently verifiable from shieldedMints" claim) are added with the + * indexer effect decoder — see the burn / supply-bypass coverage. + */ +describe('Supply — NativeShieldedTokenV1 (mint-side accounting)', () => { + let kit: NativeShieldedTokenV1Kit; + let selfRecipient: { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; + }; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1(); + selfRecipient = { + is_left: true, + left: { bytes: encodeCoinPublicKey(kit.wallet.getCoinPublicKey()) }, + right: { bytes: new Uint8Array(32) }, + }; + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should accumulate totalMinted as the exact sum across mints (INV-2)', async () => { + const amounts = [100n, 250n, 75n]; + let i = 0; + for (const amount of amounts) { + await kit.deployed.callTx._mint(selfRecipient, amount, bytes32(`supply-${i++}`)); + } + const ledger = await kit.readLedger(); + expect(ledger.NativeShieldedTokenSupply__totalMinted).toBe(425n); + expect(ledger.NativeShieldedTokenSupply__totalBurned).toBe(0n); + }); + + it('should report totalSupply == totalMinted - totalBurned (INV-5)', async () => { + const ledger = await kit.readLedger(); + const supply = (await kit.deployed.callTx.totalSupply()).private.result; + expect(supply).toBe( + ledger.NativeShieldedTokenSupply__totalMinted - + ledger.NativeShieldedTokenSupply__totalBurned, + ); + // With no contract-mediated burns, the upper bound equals total minted. + expect(supply).toBe(ledger.NativeShieldedTokenSupply__totalMinted); + }); +}); diff --git a/contracts/test/integration/specs/nativeShieldedToken/unrestricted.spec.ts b/contracts/test/integration/specs/nativeShieldedToken/unrestricted.spec.ts new file mode 100644 index 000000000..5100b353b --- /dev/null +++ b/contracts/test/integration/specs/nativeShieldedToken/unrestricted.spec.ts @@ -0,0 +1,46 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + deployNativeShieldedTokenV1, + type NativeShieldedTokenV1Kit, +} from '../../fixtures/nativeShieldedToken.js'; + +function b32(label: string): Uint8Array { + const u = new Uint8Array(32); + u.set(new TextEncoder().encode(label).slice(0, 32)); + return u; +} + +/** + * Unrestricted-issuance baseline (MIP §Security "Unrestricted issuance"). + * + * The module carries no authorization, so ANY caller — not just the deployer — + * can mint. This is the safety baseline that makes the consumer's gating + * obligation explicit: a consumer that exposes `_mint` ungated has an + * infinitely mintable token. + * + * Authorization gating (INV-16) is out of scope for this suite (presets + * removed; consumer concern). + */ +describe('Unrestricted — any caller can mint (no auth gate)', () => { + let kit: NativeShieldedTokenV1Kit; + + beforeAll(async () => { + kit = await deployNativeShieldedTokenV1(); + }); + + afterAll(async () => { + await kit?.teardown(); + }); + + it('should let a non-deployer (ALICE) mint to herself with no role grant', async () => { + const alice = await kit.signers.eitherFor('ALICE'); + const handle = await kit.as('ALICE'); + + const before = (await kit.readLedger()).NativeShieldedTokenSupply__totalMinted; + const res = await handle.callTx._mint(alice, 1_000n, b32('alice-unrestricted')); + expect(res.private.result.value).toBe(1_000n); + + const after = (await kit.readLedger()).NativeShieldedTokenSupply__totalMinted; + expect(after).toBe(before + 1_000n); + }); +}); diff --git a/contracts/vitest.integration-net.config.ts b/contracts/vitest.integration-net.config.ts new file mode 100644 index 000000000..8fcb1877c --- /dev/null +++ b/contracts/vitest.integration-net.config.ts @@ -0,0 +1,26 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +// Network integration specs for the native shielded token. Unlike the +// simulator-only `vitest.integration.config.ts` suite, these drive the full +// prove -> verify -> apply loop against the local stack (proof-server + +// indexer + node) brought up by `make env-up` / `local-env.yml`. +// +// Ported from PR #489's harness config. Kept separate so the simulator +// integration suite (`test:integration`) needs no running node. +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/integration/specs/nativeShieldedToken/**/*.spec.ts'], + exclude: [...configDefaults.exclude], + reporters: 'verbose', + // One funded genesis wallet and one local node are shared across specs — + // run one file at a time so nonces and wallet UTXOs don't race. + fileParallelism: false, + sequence: { concurrent: false }, + testTimeout: 180_000, + hookTimeout: 300_000, + // Stop the process-shared `WalletPool` once the suite finishes. + globalSetup: ['./test/integration/_harness/globalTeardown.ts'], + }, +}); diff --git a/contracts/vitest.integration.config.ts b/contracts/vitest.integration.config.ts index 24a7d3d85..51aaabbae 100644 --- a/contracts/vitest.integration.config.ts +++ b/contracts/vitest.integration.config.ts @@ -1,13 +1,21 @@ -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; // Integration specs compose multiple production modules into a single contract // and drive them through the simulator. Kept separate from the unit `test` // config (which scans `src/**/*.test.ts`). +// +// The `nativeShieldedToken/` subtree is a network suite (full proof loop +// against a live stack) and is excluded here — it runs under +// `vitest.integration-net.config.ts` via `test:integration:net`. export default defineConfig({ test: { globals: true, environment: 'node', include: ['test/integration/specs/**/*.spec.ts'], + exclude: [ + ...configDefaults.exclude, + 'test/integration/specs/nativeShieldedToken/**', + ], reporters: 'verbose', }, }); diff --git a/mip-xxxx-native-shielded-token.md b/mip-xxxx-native-shielded-token.md deleted file mode 100644 index e79466d35..000000000 --- a/mip-xxxx-native-shielded-token.md +++ /dev/null @@ -1,594 +0,0 @@ ---- -MIP: XXXX -Title: Native Shielded Token Standard -Authors: Iskander Andrews @0xisk (OpenZeppelin) -Reviewers: Andrew Fleming @andrew-fleming (OpenZeppelin), Pepe Blasco @pepebndc (OpenZeppelin) -Status: Draft -Category: Standards -Created: 2026-06-10 -Requires: none -Replaces: none -License: Apache-2.0 ---- - - - -## Abstract - -This MIP defines a standard contract interface for native shielded tokens on Midnight. -A native shielded token exists only as [Zswap](https://docs.midnight.network/concepts/zswap) shielded [UTXOs](https://docs.midnight.network/concepts/utxo), not as a balance in contract ledger state. -The issuing contract is not a balance keeper. -Once a coin is minted, it moves wallet-to-wallet at the protocol level with no contract involvement. - -The contract is responsible for four things, and this standard specifies all of them: - -- token metadata (`name`, `symbol`, `decimals`, `tokenColor`), -- issuance (`_mint`, with an optional derived-nonce extension), -- destruction (`_burn`, `_burnFromContract`), -- supply accounting (`totalMinted`, `totalBurned`, and an upper-bound `totalSupply`). - -The interface supports multiple token types per contract through a per-call domain separator, -separates recipient-public from recipient-private minting, -and requires the correct Zswap spend path for each burn: -transient spends for coins provided within the transaction, Merkle-tree spends for contract-held coins. - -A reference implementation ships as the `NativeShieldedToken` module in the [OpenZeppelin Compact Contracts library](https://github.com/OpenZeppelin/compact-contracts). -This standard complements [MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md), which standardizes account-based tokens with UTXO conversion. - -## Motivation - -Native shielded coins are the asset the Midnight protocol operates on directly. -They take part in [Zswap atomic swaps](https://docs.midnight.network/concepts/zswap), transfer peer-to-peer with no contract call, and hide value, sender, and receiver by construction. -That makes them the natural representation for privacy-first assets: phase-one RWA issuance, liquidity-pool share tokens, and confidential payment instruments. - -There is no standard for issuing them. -Every project that mints native shielded tokens rebuilds the same contract surface, and the underlying protocol primitives have several non-obvious failure modes that have already appeared in ecosystem drafts: - -- **Wrong spend path.** A coin received within the current transaction is not yet in the global Zswap commitment tree. - Spending it requires the transient path (`sendImmediateShielded`), not a Merkle-proof spend (`sendShielded`). - Conflating the two produces circuits that cannot be satisfied, or that trust a caller-supplied Merkle index. -- **Lost coins.** Contract-initiated sends create no coin ciphertext, so recipient wallets cannot find minted or refunded coins by scanning the chain. - An interface that discards the protocol's returned coin info strands value. -- **Dishonest supply.** Holders can destroy coins without touching the contract, by sending them to the burn address or submitting an imbalanced Zswap offer. - A contract-tracked "total supply" therefore over-reports. - Standards that present it as exact mislead indexers and integrators. -- **Commitment collisions.** Nonces derived from public ledger state are predictable. - Without care, caller-supplied and contract-derived nonces share one namespace and can be made to collide, which causes mint transactions to be rejected. - -Existing standards do not cover this asset class. -The [OpenZeppelin FungibleToken](https://github.com/OpenZeppelin/compact-contracts/blob/main/contracts/src/token/FungibleToken.compact) is account-based. -[MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md) extends it with conversions between map balances and UTXOs, but the account model stays the source of truth. -For tokens that should exist only in native shielded form, no interface exists. -This MIP fills that gap with a minimal mint/burn standard that encodes the correct protocol usage and states its privacy and accounting guarantees plainly. - -## Specification - -### Terminology - -- **Native shielded token**: a class of Zswap coins sharing one **color**, minted by a contract. - Managed by the Midnight protocol layer, not by contract ledger state. -- **Color (token type)**: `tokenType(domain, contractAddress)` per the [Compact Standard Library](https://docs.midnight.network/compact). - Only the contract at `contractAddress` can ever mint coins of its colors. -- **Domain separator (`domain`)**: a 32-byte value that, together with the contract address, identifies one token type. - A single contract MAY issue multiple token types by using multiple domains. -- **Same-tx coin**: a coin whose commitment is created by an output of the current transaction (for example, a user's wallet pays the contract). - It is not yet in the global commitment tree and MUST be spent via the transient path (`sendImmediateShielded`). -- **Contract-held coin**: a coin owned by the contract with a commitment already in the global Zswap commitment tree, identified by a `QualifiedShieldedCoinInfo` carrying a valid `mt_index`. -- **Burn address**: the all-zero `ZswapCoinPublicKey` returned by `shieldedBurnAddress()`, for which no secret key is known. - Coins sent there are unspendable. - -### Conformance Profiles - -The standard defines two profiles. - -- **Fungible profile** (reference module `NativeShieldedToken`): one token type per contract, the ERC-20-shaped common case. - The domain separator is fixed at construction as `sealed ledger _domain` and is not a circuit parameter, which removes caller-supplied domain misuse. - Supply totals are scalar. -- **Family profile** (reference module `NativeShieldedTokenFamily`): many token types per contract, selected by a per-call `domain` parameter. - This profile exists because of Midnight's composition model. - A contract cannot call or deploy another contract, so a multi-asset protocol (for example, a DEX minting one liquidity-share token per pair) cannot deploy one token contract per asset. - It must issue its whole token family from a single contract. - Supply totals are per-domain maps. - -Both profiles carry the same metadata interface (`name`, `symbol`, `decimals`). -In the Family profile these are family metadata shared by all token types, following the Uniswap-V2 LP precedent: every pair's LP token carries the same name, symbol, and decimals. -Per-type identity belongs in the consumer's own state, such as a pair registry mapping a domain to its underlying tokens. - -The sections below are written for the Family profile, with an explicit `domain` parameter. -The Fungible profile is the same standard with every `domain` parameter removed: the stored `_domain` is used instead, and `totalMinted(domain)` reads as the scalar `totalMinted()`. -All issuance, burn, nonce, metadata, and supply-bound rules are identical across the two profiles. -Neither profile provides balances, operator approvals, or batch transfers (see [Out of Scope](#out-of-scope)). - -### Required State - -Family profile (names per the reference implementation): - -```typescript -export ledger _totalMinted: Map, Uint<128>>; -export ledger _totalBurned: Map, Uint<128>>; - -export sealed ledger _name: Opaque<"string">; -export sealed ledger _symbol: Opaque<"string">; -export sealed ledger _decimals: Uint<8>; -``` - -Fungible profile: - -```typescript -export sealed ledger _domain: Bytes<32>; -export ledger _totalMinted: Uint<128>; -export ledger _totalBurned: Uint<128>; - -export sealed ledger _name: Opaque<"string">; -export sealed ledger _symbol: Opaque<"string">; -export sealed ledger _decimals: Uint<8>; -``` - -- `_totalMinted` and `_totalBurned` hold supply accounting, per domain in the Family profile. - See [Supply Accounting](#supply-accounting). -- Sealed fields are immutable after construction. - In the Fungible profile, the sealed `_domain` write forces token setup into the constructor, by the sealed-write rule. -- All Compact ledger state is public on-chain regardless of `export`. - Omitting `export` does not hide a field. - -### Construction - -This standard does not prescribe an initialization mechanism. -How a contract sets up its state is an implementation concern; only the result is normative. - -`name`, `symbol`, and `decimals`, and the domain separator in the Fungible profile, MUST be set at construction and MUST be immutable thereafter. - -The reference implementation does this with an `initialize` module circuit, invoked once from the consuming contract's constructor. - -### Metadata Circuits - -```typescript -export circuit name(): Opaque<"string"> -export circuit symbol(): Opaque<"string"> -export circuit decimals(): Uint<8> - -// Family profile -export circuit tokenColor(domain: Bytes<32>): Bytes<32> -// Fungible profile -export circuit tokenColor(): Bytes<32> -``` - -- In the Family profile, `name`/`symbol`/`decimals` are family metadata shared by all token types (see [Conformance Profiles](#conformance-profiles)). - `decimals` applies family-wide; an issuer with heterogeneous decimals per token type MUST handle that in its own state. -- `decimals` is a display convention only. - The protocol operates on integer values. -- `tokenColor` MUST return `tokenType(domain, kernel.self())`, computed at call time. - It exists so integrators and future contract-to-contract callers never re-derive the color by hand. - Per the finding in [MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md), the color MUST NOT be precomputed in the constructor: `kernel.self()` resolves differently during constructor execution. - -### Supply Accounting - -```typescript -export circuit totalMinted(domain: Bytes<32>): Uint<128> -export circuit totalBurned(domain: Bytes<32>): Uint<128> -export circuit totalSupply(domain: Bytes<32>): Uint<128> -``` - -Exact circulating supply is not knowable for a native shielded token: coins can be destroyed without involving the contract (see the bypass paths below). -The standard tracks the strongest quantities it can and names them for what they are. - -- `totalMinted(domain)` is **exact**. - Color derivation guarantees every coin of this contract's colors comes from this contract's mint circuits, and all of them MUST increment it. -- `totalBurned(domain)` is a **lower bound**. - It counts only contract-mediated burns. -- `totalSupply(domain)` MUST equal `totalMinted(domain) - totalBurned(domain)`, so it is an **upper bound** on circulating supply. - -```math -\texttt{circulating}(d) \le \texttt{totalSupply}(d) = \texttt{totalMinted}(d) - \texttt{totalBurned}(d) -``` - -Two destruction paths bypass the contract: - -- **Burn-address sends.** A wallet transfers to `shieldedBurnAddress()`. - The amount stays inside a Pedersen commitment, hidden from everyone. -- **Protocol burns.** A Zswap offer with a positive value imbalance, with no contract call. - The amount is public in the value deltas but invisible to contract state. - -Who can know what ([verified empirically](https://github.com/0xisk/exploring-native-shielded-token-indexing)): - -- **The contract** sees only its own mints and burns. - Protocol-level activity never calls it, so no contract-side accounting can do better than these counters. -- **An indexer** can reconstruct exact totals for mints (`shieldedMints` effects), contract burns (disclosed transcript values), protocol burns (value deltas), and per-color pool value (negated delta sum). - It can tighten the bound to `totalSupply(domain)` minus protocol burns. -- **No one** can know the spendable share of the pool. - Burn-address coins stay in the pool, indistinguishable from live coins, so exact circulating supply is unknowable both on-chain and off. - -The counters disclose nothing new. -Mint amounts are already public at the protocol level, and a burn must `disclose` coin value and change regardless: the compiler forces it on every shielded receive and spend primitive. -The counters only standardize what an indexer can already reconstruct. - -Implementations MUST maintain the counters as follows. -Every mint of `amount` under `domain` adds `amount` to `_totalMinted[domain]`, reverting on `Uint<128>` overflow. -Every contract-mediated burn of `amount` adds `amount` to `_totalBurned[domain]`. -Burned can never exceed minted for the same domain, so the `totalSupply` difference cannot underflow. -Integrators SHOULD present `totalSupply` as an upper bound, not as exact circulating supply. - -### Mint Circuit - -```typescript -export circuit _mint( - domain: Bytes<32>, - recipient: Either, - amount: Uint<64>, - nonce: Bytes<32> -): ShieldedCoinInfo -``` - -1. MUST revert if `recipient` is the zero key or zero address. -2. MUST add `amount` to `_totalMinted[domain]`, reverting on overflow. -3. MUST call `mintShieldedToken(domain, amount, nonce, recipient)` and return the resulting `ShieldedCoinInfo`. -4. Contract-initiated outputs carry no coin ciphertext, so wallets cannot currently detect contract-minted coins by scanning the chain. - The returned coin info is the only copy available to the recipient. - Callers SHOULD deliver it to the recipient out of band. -5. The caller is responsible for nonce uniqueness. - Reusing a nonce for the same `(domain, value, recipient)` produces a duplicate commitment, which the ledger rejects. -6. With a secret, cryptographically random nonce, the commitment cannot be linked to a recipient. - This is the recipient-private mint. - For operator-driven flows, the commitment can be computed off-chain before submission. - -The `Uint<64>` amount cap is imposed by the ledger: contract shielded mints are recorded as a `Map<[u8; 32], u64>` in the transaction effects. -Larger issuance requires multiple mints. - -### Extension: Derived-Nonce Minting - -An OPTIONAL extension for an issuer that wants a mint requiring no caller-managed nonce. -It adds the nonce-chain state and one circuit: - -```typescript -export ledger _counter: Counter; -export ledger _nonce: Bytes<32>; - -export circuit _mintWithDerivedNonce( - domain: Bytes<32>, - recipient: Either, - amount: Uint<64> -): ShieldedCoinInfo -``` - -`_mintWithDerivedNonce` MUST behave exactly as `_mint` called with a nonce derived from contract state. -The derivation is not prescribed, but it MUST satisfy these properties: - -1. The chain MUST be seeded at construction, and the seed SHOULD be chosen unpredictably (for example, 32 random bytes). -2. Derived nonces MUST never repeat for the lifetime of the contract. -3. Derived nonces MUST be domain-separated from values an honest `_mint` caller could produce by reading public ledger state (for example, hashed under a fixed tag), so internal and caller nonces cannot collide by accident. -4. The derivation inputs are public ledger state, so the resulting commitment is recomputable by enumerating candidate recipient keys. - Implementations SHOULD document this circuit as recipient-public. - An issuer needing recipient privacy at mint time uses the base `_mint` with a secret nonce. - -The reference implementation (`extensions/NativeShieldedTokenDerivedNonce.compact`) evolves a counter-indexed chain and derives the coin nonce as `persistentHash([pad(32, "NativeShieldedToken:nonce"), chainValue])`. - -### Burn Circuits - -```typescript -export circuit _burn( - domain: Bytes<32>, - coin: ShieldedCoinInfo, - amount: Uint<128>, - refundTo: Either -): Maybe - -export circuit _burnFromContract( - domain: Bytes<32>, - coin: QualifiedShieldedCoinInfo, - amount: Uint<128> -): Maybe -``` - -**Common behavior:** - -1. MUST revert unless `coin.color == tokenType(domain, kernel.self())`. - This check is the only thing that prevents a burn from destroying, and accounting for, a coin of the wrong token type. - The protocol-level receive does not validate color. -2. MUST revert if `amount > coin.value`. -3. MUST send `amount` to `shieldedBurnAddress()` and add `amount` to `_totalBurned[domain]`. - -**`_burn` (same-tx coin):** - -4. For a coin provided within the current transaction (for example, paid in by the caller's wallet). - MUST call `receiveShielded(coin)` and spend via `sendImmediateShielded`, the transient path. - The signature takes an unqualified `ShieldedCoinInfo` deliberately: a same-tx coin has no meaningful `mt_index`, and accepting one would let the caller supply an arbitrary value. -5. MUST revert if `refundTo` is the zero key or zero address. - The zero key is the burn address, so a zeroed `refundTo` would silently burn the change too. -6. If `amount < coin.value`, the change MUST be forwarded to `refundTo` via a second `sendImmediateShielded`, and the circuit MUST return `some(refundCoin)`, the actual coin info created for `refundTo`. - The caller SHOULD deliver it to `refundTo` out of band. - If `amount == coin.value`, the circuit returns `none`. - -**`_burnFromContract` (contract-held coin):** - -7. For a coin the contract already holds (valid `mt_index` in the global commitment tree). - MUST spend via `sendShielded`. - MUST NOT call `receiveShielded`: the coin is already owned, and claiming a receive would require a fresh output that does not exist. -8. Change from `sendShielded` is auto-received by the contract at the protocol level. - The circuit MUST return it (`Maybe`), and the consuming contract SHOULD persist it in its own ledger state. - The change replaces `coin` as the contract's holding, and its info is not otherwise recoverable. - -### Access Control - -This is an unrestricted module. -The mint and burn circuits are building blocks with no authorization of their own. -A consuming contract MUST gate all four behind an authorization mechanism, for example [Ownable or AccessControl from OpenZeppelin Compact Contracts](https://github.com/OpenZeppelin/compact-contracts), or the hash-based commitment pattern from [MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md). -As in MIP-0004, implementations MUST NOT authenticate callers with `ownPublicKey()`: it is a witness value supplied by the caller's frontend and is not bound to the proof. - -### Out of Scope - -`balanceOf`, `allowance`, transfer mediation, and post-issuance controls (pause, freeze) are not representable for native shielded tokens today. -Once a user holds a coin, the contract cannot observe or restrict its movement. -These depend on protocol capabilities under separate discussion ([MPS-0013](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0013-zswap-business-logic.md), [MPS-0021](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0021-phase2-contract-to-contract.md)) and are deferred to a future revision. - -Two companion MIPs complete the native-token family: the [Native Unshielded Token Standard](./mip-xxxx-native-unshielded-token.md), the transparent sibling of this standard with the same two profiles, and the [Native Token Conversion Extension](./mip-xxxx-native-shielded-token-conversion-extension.md), a stateless module that converts between the two representations by composing both base standards' Family profiles. -Dual-representation tokens MUST build on the Family profiles. -Each Fungible profile stores a single load-bearing sealed `_domain` written by its `initialize`. -The reference modules track initialization per-module via an inline `_isInitialized` flag, not the shared `Initializable` module (which collapses that flag across same-directory modules; see [LFDT-Minokawa/compact#270](https://github.com/OpenZeppelin/compact-contracts/blob/main/contracts/src/security/Initializable.compact)), so each composed base is initialized independently. -Compact ledger layouts are fixed at deploy, so an issuer that may ever need a transparent representation SHOULD deploy on the Family profiles with the extension compiled in, hardcoding one domain constant for a single-token product. -When both bases are composed in one contract, the consumer initializes each base and SHOULD expose metadata getters from a single base only, since the two carry independent `name`/`symbol`/`decimals`. - -## Rationale - -### Why a separate standard from MIP-0004? - -MIP-0004 anchors supply in an account-based map and treats UTXOs as a converted representation. -The contract stays the source of truth and `totalSupply` stays exact. -That is the right model when DeFi logic needs balances. -This standard covers the complementary case: assets that should exist only in native shielded form, where the account model adds state, circuits, and a public balance map for no benefit. -The two compose, because a MIP-0004 token's `shield` circuit and this standard's `_mint` use the same protocol primitive. -But their guarantees differ, notably supply exactness, and should not be conflated under one interface. - -### Why two profiles instead of one parameterized module? - -An earlier draft had only the multi-domain module and told single-token consumers to hardcode a domain in wrapper circuits. -That pushed safety onto the consumer: every single-token issuer had to re-implement the domain-hardcoding wrapper that the Fungible profile now provides once, audited. -This recovers MIP-0004's stored-domain safety at the library layer. -The Fungible profile also gets scalar supply cells instead of per-domain maps, which makes cheaper circuits for the common case. - -The profiles are one standard, not two. -All observable coin behavior is identical: nonce rules, spend paths, burn address, supply-bound semantics, and the metadata interface. -A minted coin carries no trace of which profile issued it. -The Ethereum precedent of separate standards (ERC-20 vs ERC-1155) does not apply, because those split over different transfer interfaces, and native tokens have no transfer interface at all: movement is protocol-level Zswap. -The Family profile is not an ERC-1155 analog. -It answers a Midnight-specific composability constraint, described next. - -### Why family metadata? - -The Family profile keeps contract-wide `name`/`symbol`/`decimals` rather than per-domain metadata, following the Uniswap-V2 LP precedent: every pair's LP token carries the same name, symbol, and decimals, and UIs build per-pair display from the pair registry. -A consumer's registry mapping `domain -> (token0, token1)` is strictly more informative than any stored per-domain string. -Per-domain metadata maps would duplicate it, at the cost of three maps, a setter circuit that must be gated and sequenced with domain creation, and an immutability story. -A consumer issuing a heterogeneous token family, where one shared brand really is dishonest, can add its own per-domain metadata in consumer state. -Metadata is plain ledger data with no protocol interaction. - -### Why does the Family profile use per-call `domain`? - -The ERC-20 pattern of one token per contract relies on deployment economics Midnight does not have. -On Ethereum, factories deploy a minimal-proxy clone per token cheaply, so single-asset contracts compose into multi-asset systems at the deployment layer. -On Midnight, one contract is one address with its own circuits and verifier keys, composition happens at compile time, and a contract cannot instantiate another. -A multi-asset protocol, such as a liquidity-pool contract minting one share token per pair, therefore cannot use the clone-factory pattern. -It must issue multiple colors from a single contract, which the protocol's color derivation `tokenType(domain, contractAddress)` supports natively. - -### Forward compatibility with contract-to-contract calls - -Contract-to-contract (C2C) calls do not change the choice between this standard and MIP-0004. -They upgrade both along their own axes. -C2C makes MIP-0004's deferred account-model circuits (`approve`, `transferFrom`) usable. -For native shielded tokens, C2C together with custom spend logic ([MPS-0013](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0013-zswap-business-logic.md), [MPS-0021](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0021-phase2-contract-to-contract.md)) is what unlocks phase-two transfer mediation and post-issuance controls. -Neither standard absorbs the other. -C2C also does not revive the clone-factory pattern: it adds cross-contract calls, not cheap contract instantiation, so the multi-domain motivation above is unaffected. - -This interface is designed to be C2C-ready without changes: - -- Recipients and refund targets are `Either` from day one, in both circuit signatures and supply-map keys. -- `_burnFromContract` already implements the spend path a contract holder of these tokens needs: a Merkle-tree spend of a held coin, with change auto-retained. -- `tokenColor(domain)` lets a C2C caller query the color instead of re-deriving it. -- Fixing the ledger layout now, including the supply maps, means phase-two circuits can be added to a deployed token later through a CMA verifier-key rotation with no ledger-state migration, the only kind of upgrade the CMA supports. - This mirrors the migration plan documented in the OpenZeppelin `FungibleToken` module. - -### Why one mint primitive plus an extension? - -The core `_mint` matches the protocol primitive one to one: the caller supplies the nonce, owns its uniqueness, and gets recipient privacy with a secret uniform nonce. -Derived-nonce minting is convenience on top, and it carries a real trade-off. -It needs nothing from the caller and cannot collide by accident, but every derivation input is public, so commitments are linkable to recipients by enumeration. -Keeping it a separately named, optional extension keeps the conforming core minimal and makes the privacy trade-off visible at the call site, instead of hiding two behaviors behind one circuit. - -### Why two burn variants? - -The Zswap spend path depends on where the coin lives. -A same-tx coin must be spent transiently. -A tree-resident coin must be spent with a Merkle proof. -The two take different input types (`ShieldedCoinInfo` vs `QualifiedShieldedCoinInfo`) and have different change semantics: forward to a refund target, or auto-retain in the contract. -One circuit cannot do both correctly. -An interface that accepts a `QualifiedShieldedCoinInfo` while internally receiving the coin trusts a caller-supplied `mt_index` it cannot use. - -### Why return the refund/change coin? - -Contract-initiated sends create no coin ciphertexts, so the only copy of a refund or change coin's info is the circuit's return value. -Discarding it, as early drafts did, strands value. -Returning `Maybe` makes the delivery obligation explicit and testable. - -### Why supply bounds instead of exact supply? - -The alternative is an exact-looking `totalSupply` counter, and it is strictly worse. -It reports the same number while implying a guarantee the protocol cannot provide, because out-of-band burns are invisible to contract state. -Naming the quantities `totalMinted`, `totalBurned`, and an upper-bound `totalSupply` gives indexers correct semantics. -Supply tracking is in the base standard rather than an optional extension because Compact ledger layouts are fixed at deployment: a consumer that deploys without it can never add it. -The counters also cost no privacy, because mint and burn disclosures are forced by the coin primitives, not by the supply state. -A counter-free burn fails to compile with the same disclosure errors (see [Supply Accounting](#supply-accounting)). - -### Why domain-separate the internal nonce chain? - -The evolved chain values are public. -If a coin nonce equaled the chain value, the most natural misuse of `_mint`, reading the public `_nonce` field and passing it back as the nonce, would collide with an internal mint. -Hashing chain values under a fixed tag (`"NativeShieldedToken:nonce"`) puts internal nonces in a namespace an honest caller will not produce. -It does not stop deliberate collision-griefing (see [Security Considerations](#security-considerations)); it removes the accidental case. - -### Naming - -"Native shielded token" follows the terminology split used across the ecosystem: native (protocol-level UTXO) vs contract-based (ledger-state balances), and shielded vs unshielded. - -**Alternatives considered:** - -- `ZswapToken`: protocol jargon, and Zswap also covers unshielded swap mechanics. -- `ShieldedToken`: ambiguous against shielded contract-based tokens, such as ShieldedAccessControl-style assets. -- `NativeToken`: ambiguous against unshielded native UTXOs. - -For the profiles, the short name `NativeShieldedToken` goes to the Fungible profile (the common case), and the multi-domain module is `NativeShieldedTokenFamily`. -Two suffixes were rejected. -`MultiToken` already means "ERC-1155 with `uri`" in the library, and this profile shares neither that metadata model nor any transfer semantics. -The plural `NativeShieldedTokens` is one letter from the sibling module, a misread and mistype hazard at every import and call site. -"Family" also matches the profile's metadata concept. -"Fungible" is deliberately kept out of the module names: a `NativeFungibleShieldedToken` would invite confusion with the account-based `FungibleToken` module. - -## Path to Active - -### Acceptance Criteria - -- Reference implementation merged into the [OpenZeppelin Compact Contracts library](https://github.com/OpenZeppelin/compact-contracts) with a full simulator-based test suite. -- At least one deployment on Midnight testnet exercising the full circuit surface (construction, both mint paths, both burns, supply getters), including the partial-burn refund path. -- A demonstrated wallet round-trip: mint, out-of-band coin delivery, wallet-to-wallet transfer, contract burn. -- Review and endorsement through the [MIP process](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0001-mip-process.md) workshops. -- A security audit of the reference implementation. - -### Implementation Plan - -1. Land the `NativeShieldedToken` module in OpenZeppelin Compact Contracts (rework of [PR #559](https://github.com/OpenZeppelin/compact-contracts/pull/559), tracking [issue #544](https://github.com/OpenZeppelin/compact-contracts/issues/544)). -2. Add simulator and Vitest coverage for all behaviors specified above, including the revert cases. -3. Provide a composed example (token plus Ownable/AccessControl gating) and DApp-side guidance for out-of-band coin delivery. -4. Deploy to testnet, then submit for formal MIP review. - -## Backwards Compatibility Assessment - -This MIP is purely additive. -It is a new contract standard that requires no protocol or network changes. -Every primitive it uses (`mintShieldedToken`, `receiveShielded`, `sendShielded`, `sendImmediateShielded`, `evolveNonce`, `tokenType`, `shieldedBurnAddress`) exists in the current Compact Standard Library. -It does not modify or conflict with [MIP-0004](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md): the two standards target different asset models and can coexist in one ecosystem, and in one contract where a hybrid design is wanted. -Tokens issued under this standard are ordinary Zswap coins and interoperate with existing wallets, Zswap atomic swaps, and DApps that handle `ShieldedCoinInfo`. - -The standard is also forward compatible with contract-to-contract calls. -Signatures accept `ContractAddress` recipients from day one, and the fixed ledger layout lets phase-two circuits be added to already-deployed tokens through a CMA verifier-key rotation with no state migration (see [Forward compatibility with contract-to-contract calls](#forward-compatibility-with-contract-to-contract-calls)). - -## Security Considerations - -### Unrestricted issuance - -The module-level circuits carry no authorization. -A consumer that exposes `_mint` ungated has an infinitely mintable token. -One that exposes `_burnFromContract` ungated lets anyone destroy treasury holdings. -Consumers MUST gate all mint and burn circuits ([Access Control](#access-control)) and MUST NOT use `ownPublicKey()` for caller verification. - -### Commitment collisions and mint denial-of-service - -Internally derived nonces (the Derived-Nonce Minting extension) are predictable from public state. -An actor with access to `_mint` can precompute a future internal nonce, pre-mint a coin with the same `(nonce, domain, value, recipient)` tuple, and make that specific future `_mintWithDerivedNonce` fail on duplicate-commitment rejection. -The namespace separation removes accidental collisions; this deliberate vector is mitigated operationally. -Gate both mint circuits, and prefer not to expose both for the same domain to different trust levels. -A failed mint is recoverable: any later mint with a different tuple advances the chain past the collision. - -### Recipient linkability of derived-nonce mints - -For `_mintWithDerivedNonce`, the coin commitment is recomputable from public state for any candidate recipient key, so mint recipients are effectively public. -The later spend of the coin stays unlinkable, because nullifier derivation needs the holder's secret key. -An issuer that needs recipient privacy at mint time MUST use `_mint` with a secret uniform nonce. -Declining to `export` the nonce ledger fields does not change this: ledger state is public on-chain regardless. - -### Coin delivery and value loss - -The `ShieldedCoinInfo` returned from a mint and the `Maybe` returned from a burn are the only copies of the corresponding coins' info available to recipients, because no ciphertexts are emitted for contract-initiated outputs. -A DApp integrating this standard SHOULD capture and deliver them; dropping them strands value irrecoverably. -Test suites SHOULD assert on returned coin info, not only on ledger state. - -### Wrong-color burns - -`receiveShielded` validates commitment presence, not color. -The mandated `coin.color == tokenType(domain, kernel.self())` assertion is the only barrier that stops a multi-domain contract from burning token A while accounting the burn against token B's supply, which would corrupt both domains' supply bounds. - -### Burn-address footguns - -`shieldedBurnAddress()` is the all-zero public key, which is also the default value of `ZswapCoinPublicKey`. -The mandated zero-checks on `recipient` (mint) and `refundTo` (burn) exist because a defaulted struct silently routes value to the burn address. - -### Supply interpretation - -`totalSupply` is an upper bound. -Integrators SHOULD present it as such, not as exact circulating supply. -The spec names `totalMinted` and `totalBurned` so UIs can disclose the bound semantics. -`totalMinted` is independently verifiable from the public `shieldedMints` effects, so indexers can flag non-conforming implementations. - -### No post-issuance control - -Once minted, coins are unconditionally transferable bearer instruments. -No pause, freeze, clawback, or transfer restriction is possible at this layer. -An issuer with compliance requirements (for example, a regulated stablecoin) should treat this standard as the phase-one primitive and track [MPS-0013](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0013-zswap-business-logic.md) and [MPS-0021](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0021-phase2-contract-to-contract.md) for custom spend logic. - -## Implementation - -### Components - -1. **New Compact modules.** [`NativeShieldedToken.compact` (Fungible profile) and `NativeShieldedTokenFamily.compact` (Family profile)](https://github.com/OpenZeppelin/compact-contracts/tree/main/contracts/src/token) in the OpenZeppelin Compact Contracts library: all state and circuits specified above, composed with the library's `Utils` module (initialization is tracked inline per-module, not via the shared `Initializable` module), plus the optional `extensions/NativeShieldedTokenDerivedNonce.compact` extension module. -2. **Mocks, simulators, and tests.** `MockNativeShieldedToken.compact` and `MockNativeShieldedTokenFamily.compact` exposing the module circuits, with TypeScript simulators and Vitest suites. -3. **No protocol changes required.** - -### Dependencies - -- [Compact Standard Library](https://docs.midnight.network/compact): `mintShieldedToken`, `receiveShielded`, `sendShielded`, `sendImmediateShielded`, `evolveNonce`, `tokenType`, `shieldedBurnAddress`, `Counter`, `ShieldedCoinInfo`, `QualifiedShieldedCoinInfo`, `Maybe`. -- [OpenZeppelin Compact Contracts](https://github.com/OpenZeppelin/compact-contracts): the `Utils` module. Initialization is tracked inline per-module rather than via the shared `Initializable` module. -- Compact language version >= 0.21.0. - The reference implementation compiles against this toolchain. - -## Testing - -### Unit Tests - -- `initialize`: all circuits revert before initialization; double-initialize reverts; metadata getters return constructor values. -- `_mint`: returns coin info with `color == tokenColor(domain)` and the correct value; the coin nonce equals the caller's nonce; `_totalMinted[domain]` is incremented; revert on zero recipient; overflow guard; distinct domains accumulate independent supplies. -- `_mintWithDerivedNonce` (extension): identical accounting; `_counter` and `_nonce` evolve per the extension's properties; derived nonces never repeat. -- `_burn`: revert on wrong color, on `amount > coin.value`, and on zero `refundTo`; full burn returns `none`; partial burn returns `some(refund)` with `refund.value == coin.value - amount`; `_totalBurned[domain]` is incremented. -- `_burnFromContract`: revert on wrong color and on `amount > coin.value`; change returned and owned by the contract; no receive claim emitted. -- Supply getters: `totalSupply == totalMinted - totalBurned` after arbitrary mint/burn sequences; unknown domains return 0. - -### Integration Tests - -- Round-trip on network: mint to a user wallet, out-of-band delivery, user pays the coin into `_burn`, refund coin spendable by `refundTo`. -- Treasury flow: mint to `kernel.self()`, `_burnFromContract` partial burn, persisted change burnable again. -- Multi-domain isolation: mints and burns under domain A do not affect domain B's supply or color checks. -- Invariant fuzzing: for random operation sequences, `totalMinted` exact vs simulator-observed mints; `circulating <= totalSupply` after including contract-bypassing burns (direct-to-burn-address sends and imbalanced-offer protocol burns). - -## References (Optional) - -- [MIP-0001: Midnight Improvement Proposal Process](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0001-mip-process.md) -- [MIP-0004: Fungible Token Standard with UTXO Conversion Extensions](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mips/mip-0004-fungible-token-standard-with-utxo.md) -- [MPS-0013: zswap-business-logic](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0013-zswap-business-logic.md) -- [MPS-0021: contract-to-contract phase 2](https://github.com/midnightntwrk/midnight-improvement-proposals/blob/main/mps/mps-0021-phase2-contract-to-contract.md) -- [OpenZeppelin Compact Contracts — Repository](https://github.com/OpenZeppelin/compact-contracts) -- [OpenZeppelin Compact Contracts — Issue #544: Add Shielded Native Token standard](https://github.com/OpenZeppelin/compact-contracts/issues/544) -- [OpenZeppelin Compact Contracts — PR #559: Add shielded token](https://github.com/OpenZeppelin/compact-contracts/pull/559) -- [Native shielded token indexing study — empirical decode of mint/burn visibility and supply reconstruction](https://github.com/0xisk/exploring-native-shielded-token-indexing) -- [Midnight Zswap Documentation](https://docs.midnight.network/concepts/zswap) -- [Midnight UTXO Model Documentation](https://docs.midnight.network/concepts/utxo) -- [The Compact Language](https://docs.midnight.network/compact) - -## Acknowledgments - -This proposal builds on the OpenZeppelin Compact Contracts library and its archived shielded-token exploration, on the protocol behavior documented in the Midnight ledger specification, and on issuance patterns seen in ecosystem applications. -Thanks to the Midnight protocol and documentation teams, and to the authors of MIP-0004 for the groundwork on token standards and hash-based caller authentication. - -## Copyright Waiver - -All contributions (code and text) submitted in this MIP must be licensed under the Apache License, Version 2.0. -Submission requires agreement to the Midnight Foundation Contributor License Agreement, which includes the assignment of copyright for your contributions to the Foundation. From 9ed588c76b2b62448bc57bb81f1ccd663060f776 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 24 Jun 2026 17:51:11 +0200 Subject: [PATCH 07/17] refactor(multisig): regroup into examples and root primitives Reshape the multisig package per the team layout review: reusable primitives sit at the package root, the composable behaviour modules become example contracts, and presets are named for what they do instead of a version number. * Root primitives: move SignerManager to the package root and add the extracted SignatureVerifier (commitment signer registry + threshold ECDSA-commitment verification), the single owner of the registry the signature presets share. * examples/: house the behaviour modules (SignatureTreasury, SignatureMintBurn, ProposalTreasury) and the three forwarder example contracts. * Presets named for behaviour: ShieldedMultiSigV2 becomes NativeShieldedStatelessTreasury, ShieldedMultiSigV3 becomes NativeShieldedMintBurn, ShieldedMultiSig becomes NativeShieldedProposal; add NativeShieldedTokenVault (mint/burn plus treasury under one signer set, the no-C2C composition). * Rename treasury and forwarder modules to NativeShielded / Private naming and repoint every import path, including the test mocks. Compiles green under SKIP_ZK (28/28 multisig contracts). The .ts test stacks (specs, simulators, witnesses) are renamed on disk but their rewiring to the new names is deferred to a follow-up. --- .../src/multisig/SignatureVerifier.compact | 216 ++++++++++ .../{signer => }/SignerManager.compact | 2 +- .../NativeShieldedForwarder.compact} | 6 +- .../NativeUnshieldedForwarder.compact} | 8 +- .../PrivateNativeShieldedForwarder.compact} | 20 +- .../examples/ProposalTreasury.compact | 219 ++++++++++ .../examples/SignatureMintBurn.compact | 186 +++++++++ .../examples/SignatureTreasury.compact | 127 ++++++ ...ompact => NativeShieldedForwarder.compact} | 12 +- ...pact => NativeUnshieldedForwarder.compact} | 12 +- ...=> PrivateNativeShieldedForwarder.compact} | 24 +- .../presets/NativeShieldedMintBurn.compact | 99 +++++ .../presets/NativeShieldedProposal.compact | 123 ++++++ .../NativeShieldedStatelessTreasury.compact | 88 +++++ .../presets/NativeShieldedTokenVault.compact | 137 +++++++ .../multisig/presets/ShieldedMultiSig.compact | 236 ----------- .../presets/ShieldedMultiSigV2.compact | 280 ------------- .../presets/ShieldedMultiSigV3.compact | 373 ------------------ .../test/mocks/MockForwarderPrivate.compact | 2 +- .../test/mocks/MockForwarderShielded.compact | 2 +- .../mocks/MockForwarderUnshielded.compact | 2 +- .../test/mocks/MockShieldedTreasury.compact | 2 +- .../MockShieldedTreasuryStateless.compact | 2 +- .../test/mocks/MockSignatureVerifier.compact | 40 ++ .../test/mocks/MockSignerManager.compact | 4 +- .../test/mocks/MockUnshieldedTreasury.compact | 2 +- ...compact => NativeShieldedTreasury.compact} | 14 +- ...> NativeShieldedTreasuryStateless.compact} | 6 +- ...mpact => NativeUnshieldedTreasury.compact} | 10 +- 29 files changed, 1300 insertions(+), 954 deletions(-) create mode 100644 contracts/src/multisig/SignatureVerifier.compact rename contracts/src/multisig/{signer => }/SignerManager.compact (99%) rename contracts/src/multisig/{presets/forwarder/ForwarderShielded.compact => examples/NativeShieldedForwarder.compact} (89%) rename contracts/src/multisig/{presets/forwarder/ForwarderUnshielded.compact => examples/NativeUnshieldedForwarder.compact} (86%) rename contracts/src/multisig/{presets/forwarder/ForwarderPrivate.compact => examples/PrivateNativeShieldedForwarder.compact} (87%) create mode 100644 contracts/src/multisig/examples/ProposalTreasury.compact create mode 100644 contracts/src/multisig/examples/SignatureMintBurn.compact create mode 100644 contracts/src/multisig/examples/SignatureTreasury.compact rename contracts/src/multisig/forwarder/{ForwarderShielded.compact => NativeShieldedForwarder.compact} (93%) rename contracts/src/multisig/forwarder/{ForwarderUnshielded.compact => NativeUnshieldedForwarder.compact} (93%) rename contracts/src/multisig/forwarder/{ForwarderPrivate.compact => PrivateNativeShieldedForwarder.compact} (89%) create mode 100644 contracts/src/multisig/presets/NativeShieldedMintBurn.compact create mode 100644 contracts/src/multisig/presets/NativeShieldedProposal.compact create mode 100644 contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact create mode 100644 contracts/src/multisig/presets/NativeShieldedTokenVault.compact delete mode 100644 contracts/src/multisig/presets/ShieldedMultiSig.compact delete mode 100644 contracts/src/multisig/presets/ShieldedMultiSigV2.compact delete mode 100644 contracts/src/multisig/presets/ShieldedMultiSigV3.compact create mode 100644 contracts/src/multisig/test/mocks/MockSignatureVerifier.compact rename contracts/src/multisig/treasury/{ShieldedTreasury.compact => NativeShieldedTreasury.compact} (93%) rename contracts/src/multisig/treasury/{ShieldedTreasuryStateless.compact => NativeShieldedTreasuryStateless.compact} (93%) rename contracts/src/multisig/treasury/{UnshieldedTreasury.compact => NativeUnshieldedTreasury.compact} (93%) diff --git a/contracts/src/multisig/SignatureVerifier.compact b/contracts/src/multisig/SignatureVerifier.compact new file mode 100644 index 000000000..b6586472d --- /dev/null +++ b/contracts/src/multisig/SignatureVerifier.compact @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/SignatureVerifier.compact) + +pragma language_version >= 0.23.0; + +/** + * @module SignatureVerifier + * @description Threshold ECDSA-commitment signature verification for multisig + * contracts that collect approvals off-chain. + * + * Signers are identified on-chain by commitments — `persistentHash` of an ECDSA + * public key with an instance salt and a domain separator — held in the + * `SignerManager>` registry. `verify` checks a parallel vector of + * public keys and signatures in a single transaction: each key is hashed into a + * commitment, checked for membership and duplicates, and its signature + * validated, with the valid count folded against the threshold. + * + * This module owns the commitment signer registry: it is the sole importer of + * `SignerManager>` for the signature flow and re-exposes the signer + * surface (`initialize`, `getSignerCount`, `getThreshold`, `isSigner`). + * Consuming contracts import only this module — not `SignerManager` directly — + * so there is a single registry. (Compact only shares a module's ledger state + * across imports that use the same import-path string, so a co-import from a + * different directory would create a second, empty registry.) + * + * @notice ECDSA verification is stubbed (`stubVerifySignature` always returns + * true). Replace it with `ecdsaVerify` once the Compact ECDSA primitive is + * available. + * + * @notice Duplicate detection compares each commitment against the previous one + * only, which is sufficient for at most 2 signers. Larger signer sets need a + * different uniqueness mechanism (sorted commitments or a bitmap). + */ +module SignatureVerifier { + import CompactStandardLibrary; + import "./SignerManager"> prefix Signer_; + + // ─── Types ────────────────────────────────────────────────────── + + /** + * @description Accumulator for fold-based signature verification. Threads the + * valid count, previous commitment (for duplicate detection), and message hash + * through each iteration. + */ + export 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 a domain + * separator to produce a unique, unlinkable commitment. + */ + export struct SignerCommitmentInput { + pk: Bytes<64>, + salt: Bytes<32>, + domain: Bytes<32> + } + + // ─── State ────────────────────────────────────────────────────── + + export ledger _instanceSalt: Bytes<32>; + + // ─── Setup ────────────────────────────────────────────────────── + + /** + * @description Initializes the commitment signer registry and the instance + * salt. Should be called once from the consuming contract's constructor. + * + * @param {Bytes<32>} salt - Cryptographically random instance salt. + * @param {Vector>} signers - The signer commitments. + * @param {Uint<8>} thresh - The minimum number of approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + salt: Bytes<32>, + signers: Vector>, + thresh: Uint<8> + ): [] { + _instanceSalt = disclose(salt); + Signer_initialize(signers, thresh); + } + + // ─── Verification ─────────────────────────────────────────────── + + /** + * @description Verifies a parallel vector of public keys and signatures and + * asserts the threshold is met. Each key is hashed into a commitment, checked + * for duplicates and registry membership, and its signature validated against + * `msgHash`; the valid count is then checked against the threshold. + * + * @notice Duplicate detection is correct for at most 2 signers (see module + * notice). + * + * Requirements: + * + * - Every public key must hash to a registered signer commitment. + * - Every signature must be valid over `msgHash`. + * - Signers must not be duplicates. + * - Valid count must meet the threshold. + * + * @param {Bytes<32>} msgHash - The message hash signers signed off-chain. + * @param {Vector>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector>} signatures - Signatures over `msgHash`. + * @returns {[]} Empty tuple. + */ + export circuit verify<#n>( + msgHash: Bytes<32>, + pubkeys: Vector>, + signatures: Vector> + ): [] { + const initialState = VerificationState { + validCount: 0 as Uint<8>, + prevCommitment: pad(32, ""), + msgHash: msgHash + }; + + const finalState = fold(verifySignature, initialState, pubkeys, signatures); + Signer_assertThresholdMet(finalState.validCount); + } + + /** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer to compute the constructor commitments. + * + * The commitment is `persistentHash(pk, salt, "multisig:signer:")`, where the + * salt is instance-specific (prevents cross-contract correlation) and the + * domain provides separation. + * + * @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:") + }); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Returns the number of registered signers. + * @returns {Uint<8>} The signer count. + */ + export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); + } + + /** + * @description Returns the approval threshold. + * @returns {Uint<8>} The threshold. + */ + export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); + } + + /** + * @description Returns whether the given commitment is a registered signer. + * @param {Bytes<32>} account - The commitment to check. + * @returns {Boolean} True if registered. + */ + export circuit isSigner(account: Bytes<32>): Boolean { + return Signer_isSigner(account); + } + + // ─── Internal ─────────────────────────────────────────────────── + + /** + * @description Fold callback. Verifies one signer's approval: derives the + * commitment, rejects duplicates against the previous commitment, checks + * registry membership, and validates the 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, "SignatureVerifier: duplicate signer"); + + Signer_assertSigner(commitment); + + // TODO: Replace with ecdsaVerify when the Compact ECDSA primitive is available + assert(stubVerifySignature(pubkey, state.msgHash, signature), "SignatureVerifier: invalid signature"); + + return VerificationState { + validCount: state.validCount + 1 as Uint<8>, + prevCommitment: commitment, + msgHash: state.msgHash + }; + } + + /** + * @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; + } +} diff --git a/contracts/src/multisig/signer/SignerManager.compact b/contracts/src/multisig/SignerManager.compact similarity index 99% rename from contracts/src/multisig/signer/SignerManager.compact rename to contracts/src/multisig/SignerManager.compact index cd98b3c8a..bd47b094a 100644 --- a/contracts/src/multisig/signer/SignerManager.compact +++ b/contracts/src/multisig/SignerManager.compact @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/signer/SignerManager.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/SignerManager.compact) pragma language_version >= 0.23.0; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/examples/NativeShieldedForwarder.compact similarity index 89% rename from contracts/src/multisig/presets/forwarder/ForwarderShielded.compact rename to contracts/src/multisig/examples/NativeShieldedForwarder.compact index 0193f0a8c..f0081e802 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/examples/NativeShieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderShielded.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/NativeShieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @title ForwarderShielded + * @title NativeShieldedForwarder (formerly ForwarderShielded) * @description Public-parent forwarder for shielded coins. Receives a * shielded coin and atomically forwards it to the configured parent * recipient, a coin public key. @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../forwarder/ForwarderShielded" prefix Forwarder_; +import "../forwarder/NativeShieldedForwarder" prefix Forwarder_; export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact similarity index 86% rename from contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact rename to contracts/src/multisig/examples/NativeUnshieldedForwarder.compact index 68ca2291a..dea6572ff 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact @@ -1,17 +1,17 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderUnshielded.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/NativeUnshieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @title ForwarderUnshielded + * @title NativeUnshieldedForwarder (formerly ForwarderUnshielded) * @description Public-parent forwarder for unshielded coins. Receives * an unshielded amount of a given color and atomically forwards it to * the configured parent recipient, a user address. * * Unshielded transfers are publicly visible on the chain: depositor, * recipient, color, and amount all appear on the public transcript. - * Use `ForwarderShielded` instead when the deposit kind is shielded. + * Use `NativeShieldedForwarder` instead when the deposit kind is shielded. * * The constructor accepts a `UserAddress`: an atomic forward can only * deliver to a recipient that needs no in-tx claim, and an unshielded @@ -24,7 +24,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../forwarder/ForwarderUnshielded" prefix Forwarder_; +import "../forwarder/NativeUnshieldedForwarder" prefix Forwarder_; export { ContractAddress, UserAddress, Either }; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact similarity index 87% rename from contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact rename to contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact index 235120bdb..adaecc908 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderPrivate.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/PrivateNativeShieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @title ForwarderPrivate + * @title PrivateNativeShieldedForwarder (formerly ForwarderPrivate) * @description Private-parent forwarder. The parent address is hidden * behind a `persistentHash` commitment on the ledger. Coins dwell at * the contract address after deposit; the operator drains them later @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../forwarder/ForwarderPrivate" prefix ForwarderPrivate_; +import "../forwarder/PrivateNativeShieldedForwarder" prefix Forwarder_; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; @@ -35,7 +35,7 @@ export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapC * `(parentAddr, opSecret)` pair that the operator will present at drain. */ constructor(parentCommitment: Bytes<32>) { - ForwarderPrivate_initialize(parentCommitment); + Forwarder_initialize(parentCommitment); } /** @@ -47,7 +47,7 @@ constructor(parentCommitment: Bytes<32>) { * @returns {[]} Empty tuple. */ export circuit deposit(coin: ShieldedCoinInfo): [] { - ForwarderPrivate__deposit(coin); + Forwarder__deposit(coin); } /** @@ -91,7 +91,7 @@ export circuit drain( opSecret: Bytes<32>, value: Uint<128> ): ShieldedSendResult { - return ForwarderPrivate__drain(coin, parent, opSecret, value); + return Forwarder__drain(coin, parent, opSecret, value); } /** @@ -100,7 +100,7 @@ export circuit drain( * @returns {Bytes<32>} The commitment set at deploy. */ export circuit getParentCommitment(): Bytes<32> { - return ForwarderPrivate__parentCommitment; + return Forwarder__parentCommitment; } /** @@ -109,17 +109,17 @@ export circuit getParentCommitment(): Bytes<32> { * constructor argument, and inside `drain` for the preimage check. * * The commitment is domain-tagged - * (`pad(32, "ForwarderPrivate:commitment")`) to prevent preimage + * (`pad(32, "PrivateNativeShieldedForwarder:commitment")`) to prevent preimage * collisions with other `persistentHash` users in the system. * * @param {Bytes<32>} parentAddr - The parent address. * @param {Bytes<32>} opSecret - The operational secret. * - * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. + * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])`. */ export pure circuit calculateParentCommitment( parentAddr: Bytes<32>, opSecret: Bytes<32> ): Bytes<32> { - return ForwarderPrivate__calculateParentCommitment(parentAddr, opSecret); + return Forwarder__calculateParentCommitment(parentAddr, opSecret); } diff --git a/contracts/src/multisig/examples/ProposalTreasury.compact b/contracts/src/multisig/examples/ProposalTreasury.compact new file mode 100644 index 000000000..3ac89c574 --- /dev/null +++ b/contracts/src/multisig/examples/ProposalTreasury.compact @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/ProposalTreasury.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ProposalTreasury + * @description Composable multisig behavior: on-chain proposal governance over a + * shielded treasury, authorized by caller identity. Formerly the body of + * `ShieldedMultiSig`. + * + * Composes `SignerManager>`, + * `ProposalManager`, and `NativeShieldedTreasury`. Signers create, approve, and revoke + * proposals; once the threshold is met, `executeShieldedProposal` transfers from + * the treasury. Unlike the signature-based modules, authorization is by the + * on-chain caller (`getCaller`), not off-chain signatures — so it does NOT use + * `SignatureVerifier` and cannot share a registry with those modules. + * + * @notice Signer identity uses `Either` for + * forward compatibility. Today only `left(ZswapCoinPublicKey)` callers can + * authenticate — `getCaller()` resolves via `ownPublicKey()` and cannot produce + * a right-variant. Contract-address signers may be registered but cannot exercise + * governance until contract-to-contract calls exist. The broad state shape lets + * `getCaller()` be swapped via a CMA circuit upgrade later without a state migration. + */ +module ProposalTreasury { + import CompactStandardLibrary; + import "../proposal/ProposalManager" prefix Proposal_; + import "../treasury/NativeShieldedTreasury" prefix Treasury_; + import "../SignerManager"> prefix Signer_; + + // ─── State ────────────────────────────────────────────────────── + + export ledger _proposalApprovals: Map, Map, Boolean>>; + export ledger _approvalCount: Map, Uint<8>>; + + // ─── Setup ────────────────────────────────────────────────────── + + /** + * @description Initializes the signer registry. Call once from the consuming + * contract's constructor. + * + * @param {Vector>} signers - Signer set. + * @param {Uint<8>} thresh - Minimum approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + signers: Vector>, + thresh: Uint<8> + ): [] { + Signer_initialize(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); + + assert( + to.kind == Proposal_RecipientKind.ShieldedUser + || to.kind == Proposal_RecipientKind.Contract, + "ProposalTreasury: recipient must be a shielded user or contract" + ); + + return Proposal__createProposal(to, color, amount); + } + + export circuit approveProposal(id: Uint<64>): [] { + Proposal_assertProposalActive(id); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert(!isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: already approved"); + + _approveProposal(id, callerPK); + } + + export circuit revokeApproval(id: Uint<64>): [] { + Proposal_assertProposalActive(id); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert(isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: not approved"); + + _revokeApproval(id, callerPK); + } + + export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { + Proposal_assertProposalActive(id); + + const approvalCount = getApprovalCount(id); + Signer_assertThresholdMet(approvalCount); + + const { to, color, amount } = Proposal_getProposal(id); + const result = Treasury__send( + Proposal_toShieldedRecipient(to), + color, + amount, + ); + + 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)); + } + + /** + * @description Returns the caller identity used for signer authentication. + * + * @warning Resolves callers via `ownPublicKey()` only, so a `right(ContractAddress)` + * signer cannot authenticate today. The `Either` shape is kept so `getCaller()` + * can be swapped via a CMA circuit upgrade once contract-to-contract calls exist. + * + * @returns {Either} The caller as a left-variant. + */ + 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)); + } + + 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 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); + } + + 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/examples/SignatureMintBurn.compact b/contracts/src/multisig/examples/SignatureMintBurn.compact new file mode 100644 index 000000000..798c93005 --- /dev/null +++ b/contracts/src/multisig/examples/SignatureMintBurn.compact @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/examples/SignatureMintBurn.compact) + +pragma language_version >= 0.23.0; + +/** + * @module SignatureMintBurn + * @description Composable multisig behavior: signature-authorized mint/burn of a + * native shielded token issued by the consuming contract. Formerly the body of + * `ShieldedMultiSigV3`. + * + * `mint` creates a UTXO of this contract's token type via `mintShieldedToken`; + * `burn` consumes one via `sendShielded` to `shieldedBurnAddress()`. Both require + * threshold ECDSA approval verified against the shared `SignatureVerifier` + * registry. A counter provides replay protection and feeds `evolveNonce` for + * unique coin nonces. Operation-domain prefixes (`multisig:mint:` / + * `multisig:burn:`) stop a signature for one op being replayed as the other. + * + * Initialization is split so this module can be composed: `initialize` seeds the + * shared signer registry, while `initializeToken` seeds this module's own token + * state. A combined contract calls another module's `initialize` once for the + * shared registry, then this module's `initializeToken` (see + * `presets/NativeShieldedTokenVault`). + * + * @notice DEPRECATION: this is a stopgap. From `0.3.0-alpha` it is superseded by + * the reusable Shielded Native Token standard (with a pluggable multisig access + * layer), OpenZeppelin/compact-contracts#544. Prefer that standard once available. + * + * @notice ECDSA verification is stubbed in `SignatureVerifier`. Replace it (and + * `persistentHash` with `keccak256`) once the Compact primitives are available. + */ +module SignatureMintBurn { + import CompactStandardLibrary; + import "../SignatureVerifier" prefix Signature_; + import "../../utils/Utils" prefix Utils_; + + // ─── State ────────────────────────────────────────────────────── + + export ledger _counter: Counter; + export ledger _coinNonce: Bytes<32>; + export sealed ledger _tokenDomain: Bytes<32>; + + // ─── Setup ────────────────────────────────────────────────────── + + /** + * @description Initializes the shared signer registry and instance salt. Call + * once per contract. In a combined contract, call this on exactly one module. + * + * @param {Bytes<32>} salt - Random salt for commitment derivation. + * @param {Vector>} signers - Signer commitments. + * @param {Uint<8>} thresh - Minimum approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + salt: Bytes<32>, + signers: Vector>, + thresh: Uint<8> + ): [] { + Signature_initialize(salt, signers, thresh); + } + + /** + * @description Seeds this module's token state, independent of the signer + * registry. Call once from the consuming contract's constructor. + * + * @param {Bytes<32>} tokenDomain - Domain used with `kernel.self()` to derive + * this contract's token color. + * @param {Bytes<32>} initCoinNonce - Initial coin-nonce seed (random). + * @returns {[]} Empty tuple. + */ + export circuit initializeToken(tokenDomain: Bytes<32>, initCoinNonce: Bytes<32>): [] { + _tokenDomain = disclose(tokenDomain); + _coinNonce = disclose(initCoinNonce); + } + + // ─── Mint ─────────────────────────────────────────────────────── + + /** + * @description Mints a new shielded coin of this contract's token type to the + * recipient, authorized by threshold signatures. The message hash commits to + * the `multisig:mint:` domain, contract address, recipient, counter, and amount. + * + * @param {Uint<64>} amount - The token amount to mint. + * @param {Either} recipient - Recipient. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the mint hash. + * @returns {[]} Empty tuple. + */ + export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> + ): [] { + const opNonce = _counter; + _counter.increment(1); + + const canonRecipient = Utils_canonicalize(recipient); + const recipientHash = persistentHash>(canonRecipient); + + const msgHash = persistentHash>>([ + pad(32, "multisig:mint:"), + kernel.self().bytes, + recipientHash, + opNonce as Bytes<32>, + amount as Bytes<32> + ]); + + Signature_verify<2>(msgHash, pubkeys, signatures); + + _coinNonce = evolveNonce(_counter, _coinNonce); + mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); + } + + // ─── Burn ─────────────────────────────────────────────────────── + + /** + * @description Burns a coin of this contract's token type to + * `shieldedBurnAddress()`, authorized by threshold signatures. Change from a + * partial burn is handled by the transaction layer. The `multisig:burn:` domain + * prefix prevents replay as a mint. + * + * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (operator pool). + * @param {Uint<64>} amount - The token amount to burn. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the burn hash. + * @returns {[]} Empty tuple. + */ + export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> + ): [] { + const opNonce = _counter; + _counter.increment(1); + + const msgHash = persistentHash>>([ + pad(32, "multisig:burn:"), + kernel.self().bytes, + opNonce as Bytes<32>, + amount as Bytes<32> + ]); + + Signature_verify<2>(msgHash, pubkeys, signatures); + + assert(coin.color == tokenType(_tokenDomain, kernel.self()), "SignatureMintBurn: coin not from this contract"); + assert(coin.value >= amount, "SignatureMintBurn: insufficient coin value"); + + sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer. + */ + export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signature__calculateSignerId(pk, salt); + } + + export circuit getNonce(): Uint<64> { + return _counter; + } + + export circuit getTokenDomain(): Bytes<32> { + return _tokenDomain; + } + + export circuit getTokenType(): Bytes<32> { + return tokenType(_tokenDomain, kernel.self()); + } + + export circuit getSignerCount(): Uint<8> { + return Signature_getSignerCount(); + } + + export circuit getThreshold(): Uint<8> { + return Signature_getThreshold(); + } + + export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signature_isSigner(commitment); + } +} diff --git a/contracts/src/multisig/examples/SignatureTreasury.compact b/contracts/src/multisig/examples/SignatureTreasury.compact new file mode 100644 index 000000000..e117ece07 --- /dev/null +++ b/contracts/src/multisig/examples/SignatureTreasury.compact @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/SignatureTreasury.compact) + +pragma language_version >= 0.23.0; + +/** + * @module SignatureTreasury + * @description Composable multisig behavior: signature-authorized, single-tx + * spend from a stateless shielded treasury. Formerly the body of + * `ShieldedMultiSigV2`. + * + * Combines `SignatureVerifier` (commitment signer registry + threshold ECDSA + * verification) with `NativeShieldedTreasuryStateless` (custody + send of native + * shielded tokens). Approvals are collected off-chain; `execute` verifies them + * and sends in a single transaction. A monotonic `_nonce` binds each spend to a + * unique message hash for replay protection. + * + * Import this module at the contract root and wrap it in a thin preset (see + * `presets/NativeShieldedStatelessTreasury`), or compose it with other + * root modules that import the same `../SignatureVerifier` to share one + * signer registry (see `presets/NativeShieldedTokenVault`). + */ +module SignatureTreasury { + import CompactStandardLibrary; + import "../SignatureVerifier" prefix Signature_; + import "../treasury/NativeShieldedTreasuryStateless" prefix Treasury_; + import "../proposal/ProposalManager" prefix Proposal_; + + // ─── State ────────────────────────────────────────────────────── + + export ledger _nonce: Counter; + + // ─── Setup ────────────────────────────────────────────────────── + + /** + * @description Initializes the shared signer registry and instance salt. + * Call once from the consuming contract's constructor. + * + * @param {Bytes<32>} salt - Random salt for commitment derivation. + * @param {Vector>} signers - Signer commitments. + * @param {Uint<8>} thresh - Minimum approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + salt: Bytes<32>, + signers: Vector>, + thresh: Uint<8> + ): [] { + Signature_initialize(salt, signers, thresh); + } + + // ─── Deposit ──────────────────────────────────────────────────── + + /** + * @description Receives a shielded coin into the treasury. No access control; + * anyone may deposit. No coin data is stored on the public ledger. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * @returns {[]} Empty tuple. + */ + export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); + } + + // ─── Execute ──────────────────────────────────────────────────── + + /** + * @description Executes a shielded send authorized by threshold signatures. + * Reads and increments the nonce, reconstructs the off-chain message hash + * `persistentHash(nonce, recipient address, coin color, amount)`, verifies the + * signatures against the shared registry, then sends from the treasury. + * + * @param {Proposal_Recipient} to - The recipient. + * @param {Uint<128>} amount - The amount to send. + * @param {QualifiedShieldedCoinInfo} coin - The coin to spend (operator pool). + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - 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 { + const currentNonce = _nonce; + _nonce.increment(1); + + const msgHash = persistentHash>>([ + currentNonce as Bytes<32>, + to.address, + coin.color, + amount as Bytes<32> + ]); + + Signature_verify<2>(msgHash, pubkeys, signatures); + + return Treasury__send(coin, Proposal_toShieldedRecipient(to), amount); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer. + */ + export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signature__calculateSignerId(pk, salt); + } + + export circuit getNonce(): Uint<64> { + return _nonce; + } + + export circuit getSignerCount(): Uint<8> { + return Signature_getSignerCount(); + } + + export circuit getThreshold(): Uint<8> { + return Signature_getThreshold(); + } + + export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signature_isSigner(commitment); + } +} diff --git a/contracts/src/multisig/forwarder/ForwarderShielded.compact b/contracts/src/multisig/forwarder/NativeShieldedForwarder.compact similarity index 93% rename from contracts/src/multisig/forwarder/ForwarderShielded.compact rename to contracts/src/multisig/forwarder/NativeShieldedForwarder.compact index f4c82449c..b0bb0ed31 100644 --- a/contracts/src/multisig/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/forwarder/NativeShieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ForwarderShielded.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/forwarder/NativeShieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @module ForwarderShielded + * @module NativeShieldedForwarder (formerly ForwarderShielded) * @description Public-parent forwarder for shielded coins. Provides an * atomic forward pattern: receive a shielded coin and immediately send * it to the configured parent recipient. @@ -42,7 +42,7 @@ pragma language_version >= 0.23.0; * permanently. The zero-key guard rejects only the all-zero key, not an * otherwise-unspendable one. */ -module ForwarderShielded { +module NativeShieldedForwarder { import CompactStandardLibrary; import "../../utils/Utils" prefix Utils_; @@ -88,7 +88,7 @@ module ForwarderShielded { * @returns {[]} Empty tuple. */ export circuit initialize(parent: ZswapCoinPublicKey): [] { - assert(!Utils_isKeyZero(parent), "ForwarderShielded: zero parent"); + assert(!Utils_isKeyZero(parent), "NativeShieldedForwarder: zero parent"); assertNotInitialized(); _isInitialized = true; _parent = left(disclose(parent)); @@ -131,7 +131,7 @@ module ForwarderShielded { * @returns {[]} Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "ForwarderShielded: contract not initialized"); + assert(_isInitialized, "NativeShieldedForwarder: contract not initialized"); } /** @@ -144,6 +144,6 @@ module ForwarderShielded { * @returns {[]} Empty tuple. */ circuit assertNotInitialized(): [] { - assert(!_isInitialized, "ForwarderShielded: contract already initialized"); + assert(!_isInitialized, "NativeShieldedForwarder: contract already initialized"); } } diff --git a/contracts/src/multisig/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/forwarder/NativeUnshieldedForwarder.compact similarity index 93% rename from contracts/src/multisig/forwarder/ForwarderUnshielded.compact rename to contracts/src/multisig/forwarder/NativeUnshieldedForwarder.compact index 667f938cb..0fa59f907 100644 --- a/contracts/src/multisig/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/forwarder/NativeUnshieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ForwarderUnshielded.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/forwarder/NativeUnshieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @module ForwarderUnshielded + * @module NativeUnshieldedForwarder (formerly ForwarderUnshielded) * @description Public-parent forwarder for unshielded coins. Provides an * atomic forward pattern: receive an unshielded amount of a given color * and immediately send it to the configured parent recipient. @@ -46,7 +46,7 @@ pragma language_version >= 0.23.0; * permanently. The zero-address guard rejects only the all-zero address, * not an otherwise-unspendable one. */ -module ForwarderUnshielded { +module NativeUnshieldedForwarder { import CompactStandardLibrary; // ─── State ────────────────────────────────────────────────────── @@ -92,7 +92,7 @@ module ForwarderUnshielded { */ export circuit initialize(parent: UserAddress): [] { const isZero = default == parent; - assert(!isZero, "ForwarderUnshielded: zero parent"); + assert(!isZero, "NativeUnshieldedForwarder: zero parent"); assertNotInitialized(); _isInitialized = true; _parent = right(disclose(parent)); @@ -134,7 +134,7 @@ module ForwarderUnshielded { * @returns {[]} Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "ForwarderUnshielded: contract not initialized"); + assert(_isInitialized, "NativeUnshieldedForwarder: contract not initialized"); } /** @@ -147,6 +147,6 @@ module ForwarderUnshielded { * @returns {[]} Empty tuple. */ circuit assertNotInitialized(): [] { - assert(!_isInitialized, "ForwarderUnshielded: contract already initialized"); + assert(!_isInitialized, "NativeUnshieldedForwarder: contract already initialized"); } } diff --git a/contracts/src/multisig/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact similarity index 89% rename from contracts/src/multisig/forwarder/ForwarderPrivate.compact rename to contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact index f2f086189..aec8e3bb9 100644 --- a/contracts/src/multisig/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ForwarderPrivate.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/forwarder/PrivateNativeShieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @module ForwarderPrivate + * @module PrivateNativeShieldedForwarder (formerly ForwarderPrivate) * @description Private-parent forwarder primitives. The parent is a coin * public key, hidden behind a `persistentHash` commitment on the ledger. * Deposits accumulate at the contract (no atomic forward); the operator @@ -27,7 +27,7 @@ pragma language_version >= 0.23.0; * `_calculateParentCommitment` helper does not access state and is * intentionally callable without initialization. */ -module ForwarderPrivate { +module PrivateNativeShieldedForwarder { import CompactStandardLibrary; import "../../utils/Utils" prefix Utils_; @@ -58,13 +58,13 @@ module ForwarderPrivate { * under the domain-tagged hash). * * @param {Bytes<32>} parentCommitment - Domain-tagged - * `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])` + * `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])` * computed off-chain by the deployer (see `_calculateParentCommitment`). * * @returns {[]} Empty tuple. */ export circuit initialize(parentCommitment: Bytes<32>): [] { - assert(parentCommitment != default>, "ForwarderPrivate: zero commitment"); + assert(parentCommitment != default>, "PrivateNativeShieldedForwarder: zero commitment"); assertNotInitialized(); _isInitialized = true; _parentCommitment = disclose(parentCommitment); @@ -142,12 +142,12 @@ module ForwarderPrivate { assertInitialized(); // Reject a zero parent before the commitment gate. - assert(!Utils_isKeyZero(parent), "ForwarderPrivate: zero parent"); + assert(!Utils_isKeyZero(parent), "PrivateNativeShieldedForwarder: zero parent"); // Commitment gate — the preimage is the parent key's 32 bytes. assert( _calculateParentCommitment(parent.bytes, opSecret) == _parentCommitment, - "ForwarderPrivate: invalid parent" + "PrivateNativeShieldedForwarder: invalid parent" ); // Send to the parent coin public key (the `left` arm). `disclose` reveals @@ -180,7 +180,7 @@ module ForwarderPrivate { * Callable without initialization. * * The first hash input is a fixed domain tag - * (`pad(32, "ForwarderPrivate:commitment")`). The tag prevents + * (`pad(32, "PrivateNativeShieldedForwarder:commitment")`). The tag prevents * preimage collisions with other `persistentHash` users in the * system that hash two `Bytes<32>` values — a colliding preimage * crafted under a different domain cannot satisfy this commitment. @@ -188,14 +188,14 @@ module ForwarderPrivate { * @param {Bytes<32>} parentAddr - The parent address. * @param {Bytes<32>} opSecret - The operational secret. * - * @returns {Bytes<32>} `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. + * @returns {Bytes<32>} `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])`. */ export pure circuit _calculateParentCommitment( parentAddr: Bytes<32>, opSecret: Bytes<32> ): Bytes<32> { return persistentHash>>( - [pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret] + [pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret] ); } @@ -211,7 +211,7 @@ module ForwarderPrivate { * @return {[]} - Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "ForwarderPrivate: contract not initialized"); + assert(_isInitialized, "PrivateNativeShieldedForwarder: contract not initialized"); } /** @@ -224,6 +224,6 @@ module ForwarderPrivate { * @return {[]} - Empty tuple. */ circuit assertNotInitialized(): [] { - assert(!_isInitialized, "ForwarderPrivate: contract already initialized"); + assert(!_isInitialized, "PrivateNativeShieldedForwarder: contract already initialized"); } } diff --git a/contracts/src/multisig/presets/NativeShieldedMintBurn.compact b/contracts/src/multisig/presets/NativeShieldedMintBurn.compact new file mode 100644 index 000000000..9ca50d48e --- /dev/null +++ b/contracts/src/multisig/presets/NativeShieldedMintBurn.compact @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/NativeShieldedMintBurn.compact) + +pragma language_version >= 0.23.0; + +/** + * @title NativeShieldedMintBurn (formerly ShieldedMultiSigV3) + * @description Example preset: a deployable multisig token contract. Both mint + * and burn require threshold ECDSA authorization; no single party can create or + * destroy supply. Non-transferable (no transfer/execute surface). + * + * Thin wrapper that composes a single root module, `SignatureMintBurn`. All + * behavior lives there; this contract only supplies a constructor and delegates. + * For the combined mint/burn + treasury variant, see `NativeShieldedTokenVault`. + * + * @notice DEPRECATION: the underlying mint/burn is a stopgap. From `0.3.0-alpha` + * it is superseded by the reusable Shielded Native Token standard (with a + * pluggable multisig access layer), OpenZeppelin/compact-contracts#544. Prefer + * that standard once available; do not build new dependents on this. + */ + +import CompactStandardLibrary; + +import "../examples/SignatureMintBurn" prefix Token_; +// For testing +export { ZswapCoinPublicKey }; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Deploys with 3 signer commitments and a threshold of 2. + * `tokenDomain` derives this contract's token color via + * `tokenType(tokenDomain, kernel.self())`; `initCoinNonce` seeds the mint + * coin-nonce chain. Both `instanceSalt` and `initCoinNonce` must be random. + * + * @param {Bytes<32>} instanceSalt - Random salt for signer commitment derivation. + * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. + * @param {Bytes<32>} tokenDomain - Domain used to derive this contract's token color. + * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. + */ +constructor( + instanceSalt: Bytes<32>, + initCoinNonce: Bytes<32>, + tokenDomain: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, +) { + Token_initialize<3>(instanceSalt, signerCommitments, 2); + Token_initializeToken(tokenDomain, initCoinNonce); +} + +// ─── Circuits (delegated to SignatureMintBurn) ────────────────── + +export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_mint(amount, recipient, pubkeys, signatures); +} + +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_burn(coin, amount, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Token__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return Token_getNonce(); +} + +export circuit getTokenDomain(): Bytes<32> { + return Token_getTokenDomain(); +} + +export circuit getTokenType(): Bytes<32> { + return Token_getTokenType(); +} + +export circuit getSignerCount(): Uint<8> { + return Token_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Token_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Token_isSigner(commitment); +} diff --git a/contracts/src/multisig/presets/NativeShieldedProposal.compact b/contracts/src/multisig/presets/NativeShieldedProposal.compact new file mode 100644 index 000000000..442e9a603 --- /dev/null +++ b/contracts/src/multisig/presets/NativeShieldedProposal.compact @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/NativeShieldedProposal.compact) + +pragma language_version >= 0.23.0; + +/** + * @title NativeShieldedProposal (formerly ShieldedMultiSig) + * @description Example preset: a deployable multisig that governs a shielded + * treasury through an on-chain proposal lifecycle (create / approve / revoke / + * execute), authorized by caller identity. + * + * Thin wrapper that composes a single root module, `ProposalTreasury`. All + * behavior lives there; this contract only supplies a constructor and delegates. + * Authorization is by the on-chain caller (not off-chain signatures), so this + * preset is independent of the signature-based modules. + */ + +import CompactStandardLibrary; + +import "../examples/ProposalTreasury" prefix Proposal_; +import "../proposal/ProposalManager" prefix ProposalManager_; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Deploys the multisig with 3 signers and a threshold. + * + * @param {Vector<3, Either>} signers - Signer set. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +constructor( + signers: Vector<3, Either>, + thresh: Uint<8> +) { + Proposal_initialize<3>(signers, thresh); +} + +// ─── Circuits (delegated to ProposalTreasury) ─────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Proposal_deposit(coin); +} + +export circuit createShieldedProposal( + to: ProposalManager_Recipient, + color: Bytes<32>, + amount: Uint<128> +): Uint<64> { + return Proposal_createShieldedProposal(to, color, amount); +} + +export circuit approveProposal(id: Uint<64>): [] { + Proposal_approveProposal(id); +} + +export circuit revokeApproval(id: Uint<64>): [] { + Proposal_revokeApproval(id); +} + +export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { + return Proposal_executeShieldedProposal(id); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit isProposalApprovedBySigner( + id: Uint<64>, + signer: Either +): Boolean { + return Proposal_isProposalApprovedBySigner(id, signer); +} + +export circuit getApprovalCount(id: Uint<64>): Uint<8> { + return Proposal_getApprovalCount(id); +} + +export circuit getProposal(id: Uint<64>): ProposalManager_Proposal { + return Proposal_getProposal(id); +} + +export circuit getProposalRecipient(id: Uint<64>): ProposalManager_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>): ProposalManager_ProposalStatus { + return Proposal_getProposalStatus(id); +} + +export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Proposal_getTokenBalance(color); +} + +export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + return Proposal_getReceivedTotal(color); +} + +export circuit getSentTotal(color: Bytes<32>): Uint<128> { + return Proposal_getSentTotal(color); +} + +export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return Proposal_getReceivedMinusSent(color); +} + +export circuit getSignerCount(): Uint<8> { + return Proposal_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Proposal_getThreshold(); +} + +export circuit isSigner(account: Either): Boolean { + return Proposal_isSigner(account); +} diff --git a/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact b/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact new file mode 100644 index 000000000..25df5c6c9 --- /dev/null +++ b/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/NativeShieldedStatelessTreasury.compact) + +pragma language_version >= 0.23.0; + +/** + * @title NativeShieldedStatelessTreasury (formerly ShieldedMultiSigV2) + * @description Example preset: a deployable 2-of-3 signature multisig over a + * stateless shielded treasury. + * + * Thin wrapper that composes a single root module, `SignatureTreasury` + * (signature-authorized spend over `NativeShieldedTreasuryStateless`). All behavior + * lives in the module; this contract only supplies a constructor and delegates, + * demonstrating how to deploy that module on its own. For the combined + * mint/burn + treasury variant, see `NativeShieldedTokenVault`. + */ + +import CompactStandardLibrary; + +import "../examples/SignatureTreasury" prefix Treasury_; +import "../proposal/ProposalManager" prefix Proposal_; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Deploys the multisig with 3 signer commitments and a threshold. + * Each commitment is `persistentHash(pk, instanceSalt, "multisig:signer:")`, + * computed off-chain via `_calculateSignerId`. + * + * Requirements: + * + * - `thresh` must be > 0 and <= 2 (matches the 2-signature `execute` surface). + * - `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>, +) { + assert( + thresh <= 2, + "NativeShieldedStatelessTreasury: threshold cannot exceed 2 (execute verifies at most 2 signatures)" + ); + Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); +} + +// ─── Circuits (delegated to SignatureTreasury) ────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury_deposit(coin); +} + +export circuit execute( + to: Proposal_Recipient, + amount: Uint<128>, + coin: QualifiedShieldedCoinInfo, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): ShieldedSendResult { + return Treasury_execute(to, amount, coin, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Treasury__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return Treasury_getNonce(); +} + +export circuit getSignerCount(): Uint<8> { + return Treasury_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Treasury_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Treasury_isSigner(commitment); +} diff --git a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact new file mode 100644 index 000000000..846bfc880 --- /dev/null +++ b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/NativeShieldedTokenVault.compact) + +pragma language_version >= 0.23.0; + +/** + * @title NativeShieldedTokenVault + * @description Example preset combining TWO root modules in one contract: + * `SignatureMintBurn` (issue/destroy this contract's own native shielded token) + * and `SignatureTreasury` (custody + signature-authorized spend). This is the + * composition the no-C2C protocol forces: a contract that both mints its own + * token and manages a treasury of it, atomically, under one signer set. + * + * Both modules import the same `../SignatureVerifier`, so the compiler + * deduplicates that state into a single signer registry shared by `mint`, `burn`, + * and `execute`. The constructor initializes that shared registry once (via the + * treasury module) and seeds the token state separately. + * + * @notice DEPRECATION: the mint/burn half is a stopgap superseded by the Shielded + * Native Token standard (OpenZeppelin/compact-contracts#544) from `0.3.0-alpha`. + */ + +import CompactStandardLibrary; + +import "../examples/SignatureTreasury" prefix Treasury_; +import "../examples/SignatureMintBurn" prefix Token_; +import "../proposal/ProposalManager" prefix Proposal_; +// For testing +export { ZswapCoinPublicKey }; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Deploys with 3 signer commitments and a threshold. Initializes the + * shared signer registry once (through the treasury module), then seeds the token + * module's own state. + * + * Requirements: + * + * - `thresh` must be > 0 and <= 2 (mint/burn/execute each verify 2 signatures). + * - `signerCommitments` must not contain duplicates. + * - `instanceSalt` and `initCoinNonce` should be cryptographically random. + * + * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. + * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. + * @param {Bytes<32>} tokenDomain - Domain used to derive this contract's token color. + * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +constructor( + instanceSalt: Bytes<32>, + initCoinNonce: Bytes<32>, + tokenDomain: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, + thresh: Uint<8>, +) { + assert( + thresh <= 2, + "NativeShieldedTokenVault: threshold cannot exceed 2 (each op verifies at most 2 signatures)" + ); + // Initialize the shared signer registry once, through the treasury module. + Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); + // Seed the token module's own state (no second registry init). + Token_initializeToken(tokenDomain, initCoinNonce); +} + +// ─── Treasury (SignatureTreasury) ─────────────────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury_deposit(coin); +} + +export circuit execute( + to: Proposal_Recipient, + amount: Uint<128>, + coin: QualifiedShieldedCoinInfo, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): ShieldedSendResult { + return Treasury_execute(to, amount, coin, pubkeys, signatures); +} + +// ─── Token (SignatureMintBurn) ────────────────────────────────── + +export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_mint(amount, recipient, pubkeys, signatures); +} + +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_burn(coin, amount, pubkeys, signatures); +} + +// ─── Signature Verification ───────────────────────────────────── + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Treasury__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getExecuteNonce(): Uint<64> { + return Treasury_getNonce(); +} + +export circuit getTokenNonce(): Uint<64> { + return Token_getNonce(); +} + +export circuit getTokenDomain(): Bytes<32> { + return Token_getTokenDomain(); +} + +export circuit getTokenType(): Bytes<32> { + return Token_getTokenType(); +} + +export circuit getSignerCount(): Uint<8> { + return Treasury_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Treasury_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Treasury_isSigner(commitment); +} diff --git a/contracts/src/multisig/presets/ShieldedMultiSig.compact b/contracts/src/multisig/presets/ShieldedMultiSig.compact deleted file mode 100644 index 740833a1d..000000000 --- a/contracts/src/multisig/presets/ShieldedMultiSig.compact +++ /dev/null @@ -1,236 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/ShieldedMultiSig.compact) - -pragma language_version >= 0.23.0; - -/** - * @module ShieldedMultiSig - * @description A shielded multisig preset composing `SignerManager`, - * `ProposalManager`, and `ShieldedTreasury`. Signers approve proposals that - * transfer shielded tokens out of the treasury once the configured threshold - * is met. - * - * @notice Signer identity uses `Either` - * in state for forward compatibility. In the current protocol, only - * `left(ZswapCoinPublicKey)` callers can authenticate — `getCaller()` resolves - * via `ownPublicKey()` and has no way to produce a right-variant today. - * Registering contract-address signers is permitted but those signers cannot - * exercise governance (create/approve/revoke) until contract-to-contract calls - * are supported. Choose your signer set accordingly. - * - * @notice The state shape is deliberately kept broad so that, once - * contract-to-contract calls are supported, `getCaller()` can be swapped via - * a CMA (Contract Maintenance Authorities) circuit upgrade without a state - * migration. Existing deployments would then gain working contract-signer - * authentication. - */ - -import CompactStandardLibrary; - -import "../proposal/ProposalManager" prefix Proposal_; -import "../treasury/ShieldedTreasury" prefix Treasury_; -import "../signer/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); - - assert( - to.kind == Proposal_RecipientKind.ShieldedUser - || to.kind == Proposal_RecipientKind.Contract, - "ShieldedMultiSig: recipient must be a shielded user or contract" - ); - - 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)); -} - -/** - * @description Returns the caller identity used for signer authentication. - * - * @warning Currently resolves callers via `ownPublicKey()` only, so any signer - * registered as a `right(ContractAddress)` variant cannot authenticate through - * this circuit today. Ledger fields keep the `Either` - * shape so that, once contract-to-contract calls are supported, `getCaller()` - * can be replaced via a CMA (Contract Maintenance Authorities) circuit upgrade - * to detect the contract-call context and return the appropriate variant — - * without a state migration. - * - * @returns {Either} The caller wrapped as a left-variant. - */ -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 deleted file mode 100644 index a37c5d7f2..000000000 --- a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact +++ /dev/null @@ -1,280 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/ShieldedMultiSigV2.compact) - -pragma language_version >= 0.23.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 "../proposal/ProposalManager" prefix Proposal_; -import "../treasury/ShieldedTreasuryStateless" prefix Treasury_; -import "../signer/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. - */ -export 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. - */ -export 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 <= 2 (matches the 2-signature `execute` surface). - * - `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>, -) { - assert( - thresh <= 2, - "ShieldedMultiSigV2: threshold cannot exceed 2 (execute verifies at most 2 signatures)" - ); - _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/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact deleted file mode 100644 index c5f9ba623..000000000 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ /dev/null @@ -1,373 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/ShieldedMultiSigV3.compact) - -pragma language_version >= 0.23.0; - -/** - * @title ShieldedMultiSigV3 - * @description Privacy-preserving multisig token contract for tokenised - * deposits. Both minting and burning require threshold ECDSA - * authorization. No single party can unilaterally create or destroy tokens. - * - * Designed with the following features: - * - Supply only enters through authorized mint (no open deposit) - * - Supply only exits through authorized burn (no arbitrary transfers) - * - Non-transferability enforced by absence of transfer/execute circuits - * - Change coins from partial burns handled by the transaction layer - * - Operator discovers coins via ZswapOutput events from the indexer - * - * Uses Midnight's native shielded token primitives: - * - * - `mint` creates a new UTXO via `mintShieldedToken`, addressed to the - * contract. No external coin input; supply is produced on-chain. - * - `burn` consumes a UTXO via `sendShielded` to `burnAddress()`. Only - * coins of this contract's token type can be burned. Change is handled - * automatically by the transaction layer. - * - * Signer identities are stored as commitments: hashes of ECDSA public keys - * combined with an instance salt and domain separator. A counter - * provides replay protection, and also feeds `evolveNonce` to produce - * unique coin nonces on each mint. - * - * Operation domain prefixes ("multisig:mint:" / "multisig:burn:") in the message - * hash prevent signatures for one operation type from being replayed as - * the other. - * - * @notice ECDSA verification is stubbed. Replace `stubVerifySignature` with - * `ecdsaVerify`, and `persistentHash` with `keccak256`, once the Compact - * ECDSA and Keccak primitives are available. - */ - -import CompactStandardLibrary; - -import "../signer/SignerManager"> prefix Signer_; -import "../../utils/Utils" prefix Utils_; -// For testing -export { ZswapCoinPublicKey }; - -// ─── Types ────────────────────────────────────────────────────── - -/** - * @description Accumulator for fold-based signature verification. - * Threads the valid count, previous commitment (for duplicate - * detection), and message hash through each iteration. - */ -struct VerificationState { - validCount: Uint<8>, - prevCommitment: Bytes<32>, - msgHash: Bytes<32> -} - -/** - * @description Input to persistentHash for computing signer commitments. - * Combines the ECDSA public key with an instance-specific salt and - * domain separator to produce a unique, unlinkable commitment. - */ -struct SignerCommitmentInput { - pk: Bytes<64>, - salt: Bytes<32>, - domain: Bytes<32> -} - -// ─── State ────────────────────────────────────────────────────── - -export ledger _counter: Counter; -export ledger _coinNonce: Bytes<32>; -export ledger _instanceSalt: Bytes<32>; -export sealed ledger _tokenDomain: Bytes<32>; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys the contract with 3 signer commitments and - * a threshold of 2. - * - * Each commitment is computed off-chain as: - * `persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })` - * where domain is `pad(32, "multisig:signer:")`. - * - * `tokenDomain` is used with `kernel.self()` to derive the token color - * via `tokenType(_tokenDomain, kernel.self())`. Only coins of this color - * can be burned through this contract. - * - * `initCoinNonce` seeds the `evolveNonce` chain used to produce unique - * mint nonces. It must be cryptographically random. - * - * Requirements: - * - * - `signerCommitments` must not contain duplicates. - * - `instanceSalt` and `initCoinNonce` should be cryptographically random. - * - * @param {Bytes<32>} instanceSalt - Random salt for signer commitment derivation. - * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. - * @param {Bytes<32>} tokenDomain - Domain string used to derive this contract's token color. - * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. - */ -constructor( - instanceSalt: Bytes<32>, - initCoinNonce: Bytes<32>, - tokenDomain: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, -) { - _instanceSalt = disclose(instanceSalt); - _coinNonce = disclose(initCoinNonce); - _tokenDomain = disclose(tokenDomain); - Signer_initialize<3>(signerCommitments, 2); -} - -// TODO: the mint/burn token-issuance logic below is slated to move into a -// reusable `ShieldedToken` module so it can be -// composed independently, mirroring how `SignerManager` / `SignatureVerifier` are -// factored. Kept inlined here for now. - -// ─── Mint ─────────────────────────────────────────────────────── - -/** - * @description Mints a new shielded coin addressed to the specified - * recipient, authorized by threshold signatures. - * - * Creates a new UTXO of this contract's token type via `mintShieldedToken`, - * addressed to the provided recipient. No external coin input is required; - * supply is produced on-chain. - * - * The message hash commits to: - * - operation domain ("multisig:mint:" for burn op replay protection) - * - contract address (cross-instance replay protection) - * - recipient (redirection protection) - * - counter value (replay protection) - * - amount - * - * Coin nonce uniqueness is guaranteed by `evolveNonce(_counter, _coinNonce)` - * after the counter has been incremented, binding each mint's nonce to a - * distinct counter value. - * - * @notice Replace `persistentHash` with `keccak256` and `stubVerifySignature` - * with `ecdsaVerify` once the Compact ECDSA and Keccak primitives are - * available, to match the custodian's HSM signing format. - * - * Requirements: - * - * - Both public keys must hash to registered signer commitments. - * - Both signatures must be valid over the mint message hash. - * - Signers must not be duplicates. - * - Threshold must be met. - * - * @param {Uint<64>} amount - The token amount to mint. - * @param {Either} recipient - The address to receive the minted tokens. - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the mint hash. - */ -export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - const opNonce = _counter; - _counter.increment(1); - - // Canonicalize recipient to ensure garbage values don't sully the hash - const canonRecipient = Utils_canonicalize(recipient); - const recipientHash = persistentHash>(canonRecipient); - - const msgHash = persistentHash>>([ - pad(32, "multisig:mint:"), - kernel.self().bytes, - recipientHash, - opNonce as Bytes<32>, - amount as Bytes<32> - ]); - - const initialState = VerificationState { - validCount: 0 as Uint<8>, - prevCommitment: pad(32, ""), - msgHash: msgHash - }; - - const finalState = fold(verifySignature, initialState, pubkeys, signatures); - // Thresh check not needed as it will always be 2 - // Leaving it as defense-in-depth - Signer_assertThresholdMet(finalState.validCount); - - _coinNonce = evolveNonce(_counter, _coinNonce); - mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); -} - -// ─── Burn ─────────────────────────────────────────────────────── - -/** - * @description Burns a shielded coin of this contract's token type, - * authorized by threshold signatures. - * - * Sends the specified amount of the supplied coin to `burnAddress()` via - * `sendShielded`. The nullifier is submitted on-chain, permanently marking - * the UTXO as spent. - * - * Change handling is automatic: if `amount < coin.value`, the transaction - * layer creates a change output addressed back to the contract. The operator - * discovers this change coin via `nextZswapLocalState.outputs` in the - * transaction's private result, then discovers its mt_index from the - * indexer events using the standard flow. No contract-level change logic - * is required. - * - * Only coins of this contract's token type can be burned. The operator - * supplies the `QualifiedShieldedCoinInfo` from the off-chain UTXO pool. - * - * The "multisig:burn:" domain prefix ensures burn signatures cannot be replayed - * as mint operations for the same parameters. - * - * @notice Replace `persistentHash` with `keccak256` and `stubVerifySignature` - * with `ecdsaVerify` once the Compact ECDSA and Keccak primitives are - * available, to match the custodian's HSM signing format. - * - * Requirements: - * - * - Both public keys must hash to registered signer commitments. - * - Both signatures must be valid over the burn message hash. - * - Signers must not be duplicates. - * - Threshold must be met. - * - coin.color must equal tokenType(_tokenDomain, kernel.self()). - * - coin.value must be >= amount. - * - * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (from operator's UTXO pool). - * @param {Uint<64>} amount - The token amount to burn. - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the burn hash. - */ -export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - const opNonce = _counter; - _counter.increment(1); - - const msgHash = persistentHash>>([ - pad(32, "multisig:burn:"), - kernel.self().bytes, - opNonce as Bytes<32>, - amount as Bytes<32> - ]); - - const initialState = VerificationState { - validCount: 0 as Uint<8>, - prevCommitment: pad(32, ""), - msgHash: msgHash - }; - - const finalState = fold(verifySignature, initialState, pubkeys, signatures); - // Thresh check not needed as it will always be 2 - // Leaving it as defense-in-depth - Signer_assertThresholdMet(finalState.validCount); - - assert(coin.color == tokenType(_tokenDomain, kernel.self()), "Multisig: coin not from this contract"); - assert(coin.value >= amount, "Multisig: insufficient coin value"); - - sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); -} - -// ─── Signature Verification ───────────────────────────────────── - -/** - * @description Fold callback. Verifies one signer's approval. - * - * Computes the signer's commitment from their public key and the instance - * salt, checks for duplicates against the previous commitment, verifies - * registry membership, and validates the ECDSA signature. - * - * @notice Circuit signatures are fixed at Vector<2, ...>, so this contract - * supports only a fixed 2-of-3 configuration. Supporting more signatures - * (e.g. 3-of-N) requires a separate variant with a larger vector and a - * different duplicate-detection mechanism (sorted commitments or bitmap). - * - * @param {VerificationState} state - Accumulator threaded through fold. - * @param {Bytes<64>} pubkey - The signer's ECDSA public key. - * @param {Bytes<64>} signature - The signer's signature over msgHash. - * @returns {VerificationState} Updated accumulator. - */ -circuit verifySignature( - state: VerificationState, - pubkey: Bytes<64>, - signature: Bytes<64> -): VerificationState { - const commitment = _calculateSignerId(pubkey, _instanceSalt); - - // Duplicate detection — sufficient for 2 signers only - assert(commitment != state.prevCommitment, "Multisig: duplicate signer"); - - Signer_assertSigner(commitment); - - // TODO: Replace with ecdsaVerify + keccak256 when primitives are available - assert(stubVerifySignature(pubkey, state.msgHash, signature), "Multisig: invalid signature"); - - return VerificationState { - validCount: state.validCount + 1 as Uint<8>, - prevCommitment: commitment, - msgHash: state.msgHash - }; -} - -/** - * @description Computes a signer commitment from an ECDSA public key. - * - * The commitment is persistentHash(pk, salt, domain) where: - * - pk: the signer's ECDSA public key (64 bytes) - * - salt: instance-specific random value (prevents cross-contract correlation) - * - domain: "multisig:signer:" (domain separation) - * - * Pure circuit — callable off-chain by the deployer to compute - * commitments for the constructor. - * - * @param {Bytes<64>} pk - The ECDSA public key. - * @param {Bytes<32>} salt - The instance salt. - * @returns {Bytes<32>} The signer commitment. - */ -export pure circuit _calculateSignerId( - pk: Bytes<64>, - salt: Bytes<32> -): Bytes<32> { - return persistentHash(SignerCommitmentInput { - pk: pk, - salt: salt, - domain: pad(32, "multisig:signer:") - }); -} - -/** - * @description Stub for ECDSA signature verification. - * Always returns true. MUST be replaced before any non-test deployment. - */ -circuit stubVerifySignature( - pubkey: Bytes<64>, - msgHash: Bytes<32>, - signature: Bytes<64> -): Boolean { - return true; -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return _counter; -} - -export circuit getTokenDomain(): Bytes<32> { - return _tokenDomain; -} - -export circuit getTokenType(): Bytes<32> { - return tokenType(_tokenDomain, kernel.self()); -} - -export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signer_isSigner(commitment); -} diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact index 018882773..28f6637c3 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../forwarder/ForwarderPrivate" prefix ForwarderPrivate_; +import "../../forwarder/PrivateNativeShieldedForwarder" prefix ForwarderPrivate_; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; diff --git a/contracts/src/multisig/test/mocks/MockForwarderShielded.compact b/contracts/src/multisig/test/mocks/MockForwarderShielded.compact index 568a73bf5..e5ffffa23 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderShielded.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderShielded.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../forwarder/ForwarderShielded" prefix Forwarder_; +import "../../forwarder/NativeShieldedForwarder" prefix Forwarder_; export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; diff --git a/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact b/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact index 653a36140..3817f6148 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../forwarder/ForwarderUnshielded" prefix Forwarder_; +import "../../forwarder/NativeUnshieldedForwarder" prefix Forwarder_; export { ContractAddress, UserAddress, Either }; diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact index 9b181553f..9e2e5799b 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../treasury/ShieldedTreasury" prefix Treasury_; +import "../../treasury/NativeShieldedTreasury" prefix Treasury_; export circuit _deposit(coin: ShieldedCoinInfo): [] { return Treasury__deposit(coin); diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact index a2b1e7423..d31cf19ec 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../treasury/ShieldedTreasuryStateless" prefix Treasury_; +import "../../treasury/NativeShieldedTreasuryStateless" prefix Treasury_; export circuit _deposit(coin: ShieldedCoinInfo): [] { Treasury__deposit(coin); diff --git a/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact b/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact new file mode 100644 index 000000000..06939a213 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../SignatureVerifier" prefix Signature_; + +constructor(salt: Bytes<32>, signers: Vector<3, Bytes<32>>, thresh: Uint<8>) { + Signature_initialize<3>(salt, signers, thresh); +} + +export circuit verify( + msgHash: Bytes<32>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + return Signature_verify<2>(msgHash, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signature__calculateSignerId(pk, salt); +} + +export circuit getSignerCount(): Uint<8> { + return Signature_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signature_getThreshold(); +} + +export circuit isSigner(account: Bytes<32>): Boolean { + return Signature_isSigner(account); +} diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact index 9bce84848..4e2e56600 100644 --- a/contracts/src/multisig/test/mocks/MockSignerManager.compact +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -9,8 +9,8 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../signer/SignerManager"> prefix Signer_; -import "../../signer/SignerManager">; +import "../../SignerManager"> prefix Signer_; +import "../../SignerManager">; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; export { _signers, _signerCount, _threshold }; diff --git a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact index 6b8a6b21f..71b7d9d55 100644 --- a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../treasury/UnshieldedTreasury" prefix Treasury_; +import "../../treasury/NativeUnshieldedTreasury" prefix Treasury_; export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] { return Treasury__deposit(color, amount); diff --git a/contracts/src/multisig/treasury/ShieldedTreasury.compact b/contracts/src/multisig/treasury/NativeShieldedTreasury.compact similarity index 93% rename from contracts/src/multisig/treasury/ShieldedTreasury.compact rename to contracts/src/multisig/treasury/NativeShieldedTreasury.compact index 4a29beb0d..099cf6f3e 100644 --- a/contracts/src/multisig/treasury/ShieldedTreasury.compact +++ b/contracts/src/multisig/treasury/NativeShieldedTreasury.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ShieldedTreasury.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/treasury/NativeShieldedTreasury.compact) pragma language_version >= 0.23.0; /** - * @module ShieldedTreasury + * @module NativeShieldedTreasury * @description Manages shielded (private) token deposits, accounting, * and transfers for multisig governance contracts. * @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; * enforcement. The consuming contract must gate these behind its own * authorization policy. */ -module ShieldedTreasury { +module NativeShieldedTreasury { import CompactStandardLibrary; import { selfAsRecipient, UINT128_MAX } from "../../utils/Utils" prefix Utils_; @@ -60,7 +60,7 @@ module ShieldedTreasury { */ export circuit _deposit(coin: ShieldedCoinInfo): [] { const currentReceived = getReceivedTotal(coin.color); - assert(currentReceived <= Utils_UINT128_MAX() - coin.value, "ShieldedTreasury: overflow"); + assert(currentReceived <= Utils_UINT128_MAX() - coin.value, "NativeShieldedTreasury: overflow"); receiveShielded(disclose(coin)); @@ -109,13 +109,13 @@ module ShieldedTreasury { color: Bytes<32>, amount: Uint<128> ): ShieldedSendResult { - assert(_coins.member(disclose(color)), "ShieldedTreasury: no balance"); + assert(_coins.member(disclose(color)), "NativeShieldedTreasury: no balance"); const coin = _coins.lookup(disclose(color)); - assert(coin.value >= amount, "ShieldedTreasury: coin value insufficient"); + assert(coin.value >= amount, "NativeShieldedTreasury: coin value insufficient"); const currentSent = getSentTotal(color); - assert(currentSent <= Utils_UINT128_MAX() - amount, "ShieldedTreasury: overflow"); + assert(currentSent <= Utils_UINT128_MAX() - amount, "NativeShieldedTreasury: overflow"); const result = sendShielded(coin, disclose(recipient), disclose(amount)); diff --git a/contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact b/contracts/src/multisig/treasury/NativeShieldedTreasuryStateless.compact similarity index 93% rename from contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact rename to contracts/src/multisig/treasury/NativeShieldedTreasuryStateless.compact index 96529afa7..f9a5bbeae 100644 --- a/contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/treasury/NativeShieldedTreasuryStateless.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ShieldedTreasuryStateless.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/treasury/NativeShieldedTreasuryStateless.compact) pragma language_version >= 0.23.0; /** - * @module ShieldedTreasury + * @module NativeShieldedTreasuryStateless * @description Manages shielded (private) token deposits, accounting, * and transfers for multisig governance contracts. * @@ -18,7 +18,7 @@ pragma language_version >= 0.23.0; * purposes. The canonical balance query is `getTokenBalance`, which * reads the actual coin value from the UTXO map. */ -module ShieldedTreasuryStateless { +module NativeShieldedTreasuryStateless { import CompactStandardLibrary; import { selfAsRecipient } from "../../utils/Utils" prefix Utils_; diff --git a/contracts/src/multisig/treasury/UnshieldedTreasury.compact b/contracts/src/multisig/treasury/NativeUnshieldedTreasury.compact similarity index 93% rename from contracts/src/multisig/treasury/UnshieldedTreasury.compact rename to contracts/src/multisig/treasury/NativeUnshieldedTreasury.compact index 18fbd5fae..3f9e4d9e4 100644 --- a/contracts/src/multisig/treasury/UnshieldedTreasury.compact +++ b/contracts/src/multisig/treasury/NativeUnshieldedTreasury.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/UnshieldedTreasury.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/treasury/NativeUnshieldedTreasury.compact) pragma language_version >= 0.23.0; /** - * @module UnshieldedTreasury + * @module NativeUnshieldedTreasury * @description Manages unshielded (transparent) token deposits and * transfers for multisig governance contracts. * @@ -17,7 +17,7 @@ pragma language_version >= 0.23.0; * enforcement. The consuming contract must gate these behind its own * authorization policy. */ -module UnshieldedTreasury { +module NativeUnshieldedTreasury { import CompactStandardLibrary; import { UINT128_MAX } from "../../utils/Utils" prefix Utils_; @@ -53,7 +53,7 @@ module UnshieldedTreasury { export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] { assert( unshieldedBalanceLte(disclose(color), Utils_UINT128_MAX() - disclose(amount)), - "UnshieldedTreasury: overflow" + "NativeUnshieldedTreasury: overflow" ); receiveUnshielded(disclose(color), disclose(amount)); @@ -88,7 +88,7 @@ module UnshieldedTreasury { ): [] { assert( unshieldedBalanceGte(disclose(color), disclose(amount)), - "UnshieldedTreasury: insufficient balance" + "NativeUnshieldedTreasury: insufficient balance" ); const bal = getTokenBalance(color); From c9dd1172a4130dad541b6db0c6638948310ec69d Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 24 Jun 2026 19:22:06 +0200 Subject: [PATCH 08/17] refactor(multisig): split signer manager by scheme Make signature verification a swappable per-scheme module, since Compact cannot make `verify` generic over a signature scheme. * SignerManager stays the general signer registry (signer set, threshold, membership, add/remove), generic over the identity type: > commitments for the signature path, > for caller-authorized governance. * EcdsaSignerManager (formerly SignatureVerifier) wraps SignerManager> and adds threshold ECDSA-commitment verification (instance salt, commitment derivation, verify). The cryptographic check is stubbed until the Compact ECDSA primitive lands. It is the single entrance the signature examples import; a future SchnorrSignerManager is a sibling of the same shape. * SignatureTreasury, SignatureMintBurn, and the NativeShieldedTokenVault preset import EcdsaSignerManager. ProposalTreasury (caller-auth V1) keeps using SignerManager directly. The signer count stays a single source of truth: one SignerManager registry under EcdsaSignerManager, shared across the Vault's mint/burn/execute via the same import path. Compiles green under SKIP_ZK (28/28 multisig contracts). The .ts test stacks remain renamed-on-disk and deferred to a follow-up. --- ...ier.compact => EcdsaSignerManager.compact} | 42 ++++++++------ contracts/src/multisig/SignerManager.compact | 58 +++++++++---------- .../examples/ProposalTreasury.compact | 2 +- .../examples/SignatureMintBurn.compact | 20 +++---- .../examples/SignatureTreasury.compact | 18 +++--- .../presets/NativeShieldedTokenVault.compact | 2 +- .../test/mocks/MockSignatureVerifier.compact | 2 +- 7 files changed, 73 insertions(+), 71 deletions(-) rename contracts/src/multisig/{SignatureVerifier.compact => EcdsaSignerManager.compact} (82%) diff --git a/contracts/src/multisig/SignatureVerifier.compact b/contracts/src/multisig/EcdsaSignerManager.compact similarity index 82% rename from contracts/src/multisig/SignatureVerifier.compact rename to contracts/src/multisig/EcdsaSignerManager.compact index b6586472d..bcd108630 100644 --- a/contracts/src/multisig/SignatureVerifier.compact +++ b/contracts/src/multisig/EcdsaSignerManager.compact @@ -1,27 +1,31 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/SignatureVerifier.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/EcdsaSignerManager.compact) pragma language_version >= 0.23.0; /** - * @module SignatureVerifier - * @description Threshold ECDSA-commitment signature verification for multisig - * contracts that collect approvals off-chain. + * @module EcdsaSignerManager + * @description ECDSA signature scheme manager — the signer entrance examples + * import for ECDSA-authorized multisig. Wraps the general `SignerManager>` + * registry and adds threshold ECDSA-commitment verification on top of it. * * Signers are identified on-chain by commitments — `persistentHash` of an ECDSA - * public key with an instance salt and a domain separator — held in the - * `SignerManager>` registry. `verify` checks a parallel vector of - * public keys and signatures in a single transaction: each key is hashed into a - * commitment, checked for membership and duplicates, and its signature - * validated, with the valid count folded against the threshold. + * public key with an instance salt and a domain separator — held in one + * `SignerManager>`. `verify` checks a parallel vector of public keys + * and signatures in a single transaction: each key is hashed into a commitment, + * checked for membership and duplicates, and its signature validated, with the + * valid count folded against the threshold. * - * This module owns the commitment signer registry: it is the sole importer of - * `SignerManager>` for the signature flow and re-exposes the signer - * surface (`initialize`, `getSignerCount`, `getThreshold`, `isSigner`). - * Consuming contracts import only this module — not `SignerManager` directly — - * so there is a single registry. (Compact only shares a module's ledger state - * across imports that use the same import-path string, so a co-import from a - * different directory would create a second, empty registry.) + * This is the ECDSA member of the per-scheme manager family. Compact cannot make + * `verify` generic over a signature scheme, so each scheme is its own manager (a + * future `SchnorrSignerManager` is a sibling) that wraps the same general + * `SignerManager` registry. Caller-authorized contracts that need no signature + * verification use `SignerManager` directly (see `examples/ProposalTreasury`). + * + * The registry state (signer set, count, threshold) lives in one place — the + * underlying `SignerManager>` — and every `<#n>` circuit reads it, so + * the signer count is a single source of truth. Composing modules import the + * same `../EcdsaSignerManager` path so they share that one registry. * * @notice ECDSA verification is stubbed (`stubVerifySignature` always returns * true). Replace it with `ecdsaVerify` once the Compact ECDSA primitive is @@ -31,7 +35,7 @@ pragma language_version >= 0.23.0; * only, which is sufficient for at most 2 signers. Larger signer sets need a * different uniqueness mechanism (sorted commitments or a bitmap). */ -module SignatureVerifier { +module EcdsaSignerManager { import CompactStandardLibrary; import "./SignerManager"> prefix Signer_; @@ -188,12 +192,12 @@ module SignatureVerifier { const commitment = _calculateSignerId(pubkey, _instanceSalt); // Duplicate detection — sufficient for 2 signers only - assert(commitment != state.prevCommitment, "SignatureVerifier: duplicate signer"); + assert(commitment != state.prevCommitment, "EcdsaSignerManager: duplicate signer"); Signer_assertSigner(commitment); // TODO: Replace with ecdsaVerify when the Compact ECDSA primitive is available - assert(stubVerifySignature(pubkey, state.msgHash, signature), "SignatureVerifier: invalid signature"); + assert(stubVerifySignature(pubkey, state.msgHash, signature), "EcdsaSignerManager: invalid signature"); return VerificationState { validCount: state.validCount + 1 as Uint<8>, diff --git a/contracts/src/multisig/SignerManager.compact b/contracts/src/multisig/SignerManager.compact index bd47b094a..3a1f057bb 100644 --- a/contracts/src/multisig/SignerManager.compact +++ b/contracts/src/multisig/SignerManager.compact @@ -5,43 +5,41 @@ pragma language_version >= 0.23.0; /** * @module SignerManager - * @description Manages signer registry, threshold enforcement, and signer - * validation for multisig governance contracts. + * @description The general signer registry for multisig: signer set, threshold + * enforcement, and signer validation. Every multisig builds on this; a signature + * scheme manager (e.g. `EcdsaSignerManager`) wraps it and adds the verification. * - * Parameterized over the signer identity type `T`, allowing the consuming - * contract to choose the identity mechanism at import time. Common - * instantiations include: + * Parameterized over the signer identity type `T`, so the consuming contract + * picks the identity mechanism at import time. Common instantiations: * - * - `Bytes<32>` for commitment-based identity (e.g., hash of ECDSA public key) - * - `JubjubPoint` for Schnorr/MuSig aggregated key + * - `Bytes<32>` for commitment-based identity (hash of a public key) — used by + * the signature scheme managers. + * - `Either` for on-chain caller identity — + * used by caller-authorized governance (see `examples/ProposalTreasury`). * - * The SignerManager module 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. + * This module does not resolve caller identity or verify signatures. It receives + * a validated identity from the layer above and checks it against the registry. + * Keeping it scheme-agnostic is what lets it serve both the `Bytes<32>` signature + * path and the `Either` caller path from one generic module. * - * The signer count and threshold are of type `Uint<8>`, limiting the - * maximum number of signers and threshold to 255. This is sufficient - * for any practical multisig use case. For large-scale governance - * requiring more signers, consider a Merkle tree-based variant. + * The signer count and threshold are `Uint<8>`, capping signers/threshold at 255 + * — sufficient for any practical multisig. For large-scale governance, consider + * a Merkle tree-based variant. * - * Multi-step signer reconfigurations (e.g., removing a signer and - * lowering the threshold) may produce intermediate states where the - * module's invariants temporarily hold but the contract's intended - * configuration is incomplete. This is a contract-layer concern. - * Contracts should either perform reconfigurations atomically in a - * single circuit or use a configuration nonce to invalidate proposals - * created under a stale signer set. + * Multi-step signer reconfigurations (e.g., removing a signer and lowering the + * threshold) may produce intermediate states where the module's invariants hold + * but the contract's intended configuration is incomplete. This is a + * contract-layer concern: reconfigure atomically in one circuit, or use a + * configuration nonce to invalidate proposals created under a stale signer set. * - * Underscore-prefixed circuits (_addSigner, _removeSigner, - * _changeThreshold) have no access control enforcement. The consuming - * contract must gate these behind its own authorization policy. + * Underscore-prefixed circuits (_addSigner, _removeSigner, _changeThreshold) + * have no access control. The consuming contract must gate them behind its own + * authorization policy. * - * Contracts may handle their own initialization and this module - * supports custom flows. Thus, contracts may choose to not - * call `initialize` in the contract's constructor. Contracts MUST NOT - * call `initialize` outside of the constructor context because - * this could corrupt the signer set and threshold configuration. + * Contracts may handle their own initialization and this module supports custom + * flows, so a contract may choose not to call `initialize` in its constructor. + * Contracts MUST NOT call `initialize` outside the constructor context, as this + * could corrupt the signer set and threshold configuration. */ module SignerManager { import CompactStandardLibrary; diff --git a/contracts/src/multisig/examples/ProposalTreasury.compact b/contracts/src/multisig/examples/ProposalTreasury.compact index 3ac89c574..a62b6c663 100644 --- a/contracts/src/multisig/examples/ProposalTreasury.compact +++ b/contracts/src/multisig/examples/ProposalTreasury.compact @@ -14,7 +14,7 @@ pragma language_version >= 0.23.0; * proposals; once the threshold is met, `executeShieldedProposal` transfers from * the treasury. Unlike the signature-based modules, authorization is by the * on-chain caller (`getCaller`), not off-chain signatures — so it does NOT use - * `SignatureVerifier` and cannot share a registry with those modules. + * `EcdsaSignerManager` (the signature entrance) and cannot share a registry with it. * * @notice Signer identity uses `Either` for * forward compatibility. Today only `left(ZswapCoinPublicKey)` callers can diff --git a/contracts/src/multisig/examples/SignatureMintBurn.compact b/contracts/src/multisig/examples/SignatureMintBurn.compact index 798c93005..8814359a2 100644 --- a/contracts/src/multisig/examples/SignatureMintBurn.compact +++ b/contracts/src/multisig/examples/SignatureMintBurn.compact @@ -11,7 +11,7 @@ pragma language_version >= 0.23.0; * * `mint` creates a UTXO of this contract's token type via `mintShieldedToken`; * `burn` consumes one via `sendShielded` to `shieldedBurnAddress()`. Both require - * threshold ECDSA approval verified against the shared `SignatureVerifier` + * threshold ECDSA approval verified against the shared `EcdsaSignerManager` * registry. A counter provides replay protection and feeds `evolveNonce` for * unique coin nonces. Operation-domain prefixes (`multisig:mint:` / * `multisig:burn:`) stop a signature for one op being replayed as the other. @@ -26,12 +26,12 @@ pragma language_version >= 0.23.0; * the reusable Shielded Native Token standard (with a pluggable multisig access * layer), OpenZeppelin/compact-contracts#544. Prefer that standard once available. * - * @notice ECDSA verification is stubbed in `SignatureVerifier`. Replace it (and + * @notice ECDSA verification is stubbed in `EcdsaSignerManager`. Replace it (and * `persistentHash` with `keccak256`) once the Compact primitives are available. */ module SignatureMintBurn { import CompactStandardLibrary; - import "../SignatureVerifier" prefix Signature_; + import "../EcdsaSignerManager" prefix Signer_; import "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── @@ -56,7 +56,7 @@ module SignatureMintBurn { signers: Vector>, thresh: Uint<8> ): [] { - Signature_initialize(salt, signers, thresh); + Signer_initialize(salt, signers, thresh); } /** @@ -106,7 +106,7 @@ module SignatureMintBurn { amount as Bytes<32> ]); - Signature_verify<2>(msgHash, pubkeys, signatures); + Signer_verify<2>(msgHash, pubkeys, signatures); _coinNonce = evolveNonce(_counter, _coinNonce); mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); @@ -142,7 +142,7 @@ module SignatureMintBurn { amount as Bytes<32> ]); - Signature_verify<2>(msgHash, pubkeys, signatures); + Signer_verify<2>(msgHash, pubkeys, signatures); assert(coin.color == tokenType(_tokenDomain, kernel.self()), "SignatureMintBurn: coin not from this contract"); assert(coin.value >= amount, "SignatureMintBurn: insufficient coin value"); @@ -157,7 +157,7 @@ module SignatureMintBurn { * callable off-chain by the deployer. */ export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Signature__calculateSignerId(pk, salt); + return Signer__calculateSignerId(pk, salt); } export circuit getNonce(): Uint<64> { @@ -173,14 +173,14 @@ module SignatureMintBurn { } export circuit getSignerCount(): Uint<8> { - return Signature_getSignerCount(); + return Signer_getSignerCount(); } export circuit getThreshold(): Uint<8> { - return Signature_getThreshold(); + return Signer_getThreshold(); } export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signature_isSigner(commitment); + return Signer_isSigner(commitment); } } diff --git a/contracts/src/multisig/examples/SignatureTreasury.compact b/contracts/src/multisig/examples/SignatureTreasury.compact index e117ece07..3b9a97de5 100644 --- a/contracts/src/multisig/examples/SignatureTreasury.compact +++ b/contracts/src/multisig/examples/SignatureTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; * spend from a stateless shielded treasury. Formerly the body of * `ShieldedMultiSigV2`. * - * Combines `SignatureVerifier` (commitment signer registry + threshold ECDSA + * Combines `EcdsaSignerManager` (commitment signer registry + threshold ECDSA * verification) with `NativeShieldedTreasuryStateless` (custody + send of native * shielded tokens). Approvals are collected off-chain; `execute` verifies them * and sends in a single transaction. A monotonic `_nonce` binds each spend to a @@ -17,12 +17,12 @@ pragma language_version >= 0.23.0; * * Import this module at the contract root and wrap it in a thin preset (see * `presets/NativeShieldedStatelessTreasury`), or compose it with other - * root modules that import the same `../SignatureVerifier` to share one + * root modules that import the same `../EcdsaSignerManager` to share one * signer registry (see `presets/NativeShieldedTokenVault`). */ module SignatureTreasury { import CompactStandardLibrary; - import "../SignatureVerifier" prefix Signature_; + import "../EcdsaSignerManager" prefix Signer_; import "../treasury/NativeShieldedTreasuryStateless" prefix Treasury_; import "../proposal/ProposalManager" prefix Proposal_; @@ -46,7 +46,7 @@ module SignatureTreasury { signers: Vector>, thresh: Uint<8> ): [] { - Signature_initialize(salt, signers, thresh); + Signer_initialize(salt, signers, thresh); } // ─── Deposit ──────────────────────────────────────────────────── @@ -94,7 +94,7 @@ module SignatureTreasury { amount as Bytes<32> ]); - Signature_verify<2>(msgHash, pubkeys, signatures); + Signer_verify<2>(msgHash, pubkeys, signatures); return Treasury__send(coin, Proposal_toShieldedRecipient(to), amount); } @@ -106,7 +106,7 @@ module SignatureTreasury { * callable off-chain by the deployer. */ export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Signature__calculateSignerId(pk, salt); + return Signer__calculateSignerId(pk, salt); } export circuit getNonce(): Uint<64> { @@ -114,14 +114,14 @@ module SignatureTreasury { } export circuit getSignerCount(): Uint<8> { - return Signature_getSignerCount(); + return Signer_getSignerCount(); } export circuit getThreshold(): Uint<8> { - return Signature_getThreshold(); + return Signer_getThreshold(); } export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signature_isSigner(commitment); + return Signer_isSigner(commitment); } } diff --git a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact index 846bfc880..a7a33d952 100644 --- a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact +++ b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact @@ -11,7 +11,7 @@ pragma language_version >= 0.23.0; * composition the no-C2C protocol forces: a contract that both mints its own * token and manages a treasury of it, atomically, under one signer set. * - * Both modules import the same `../SignatureVerifier`, so the compiler + * Both modules import the same `../EcdsaSignerManager`, so the compiler * deduplicates that state into a single signer registry shared by `mint`, `burn`, * and `execute`. The constructor initializes that shared registry once (via the * treasury module) and seeds the token state separately. diff --git a/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact b/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact index 06939a213..e41b2ab56 100644 --- a/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact +++ b/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../SignatureVerifier" prefix Signature_; +import "../../EcdsaSignerManager" prefix Signature_; constructor(salt: Bytes<32>, signers: Vector<3, Bytes<32>>, thresh: Uint<8>) { Signature_initialize<3>(salt, signers, thresh); From 23aa9d27d8bbf4c6c3fcb75be33dd3c530aa3413 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:10:30 +0200 Subject: [PATCH 09/17] fix(multisig): keep private forwarder commitment domain within 32 bytes The commitment domain separator padded the full descriptive string "PrivateNativeShieldedForwarder:commitment" (41 bytes) to 32, which exceeds the pad width and fails to compile. Use the module name alone (30 bytes) as the unique domain tag. This unblocks MockForwarderPrivate and the private-forwarder suite, which the compiler wrapper had been masking as a false success. --- .../forwarder/PrivateNativeShieldedForwarder.compact | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact index aec8e3bb9..9bc2d8461 100644 --- a/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact +++ b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact @@ -188,14 +188,16 @@ module PrivateNativeShieldedForwarder { * @param {Bytes<32>} parentAddr - The parent address. * @param {Bytes<32>} opSecret - The operational secret. * - * @returns {Bytes<32>} `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])`. + * @returns {Bytes<32>} `persistentHash([pad(32, "PrivateNativeShieldedForwarder"), parentAddr, opSecret])`. */ export pure circuit _calculateParentCommitment( parentAddr: Bytes<32>, opSecret: Bytes<32> ): Bytes<32> { + // Domain separator. Must be <= 32 bytes; the module name (30 bytes) is used + // verbatim as a unique tag for this commitment. return persistentHash>>( - [pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret] + [pad(32, "PrivateNativeShieldedForwarder"), parentAddr, opSecret] ); } From 2c248533532ef56f0e91e42eec7e1259553c0f24 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:11:24 +0200 Subject: [PATCH 10/17] refactor(multisig): defer forwarder example wrappers to preset branch The deployable forwarder example contracts (examples/*Forwarder) are thin top-level wrappers in the same category as the presets, and their basenames collide with the forwarder modules in the shared flat artifact namespace. Remove them (and their now-stale preset tests and simulators) from this branch; they return with the presets in a follow-up branch. The forwarder modules and their Mock-based tests stay and remain the coverage for forwarder behavior. --- .../examples/NativeShieldedForwarder.compact | 62 --------- .../NativeUnshieldedForwarder.compact | 64 --------- .../PrivateNativeShieldedForwarder.compact | 125 ------------------ .../test/presets/ForwarderPrivate.test.ts | 74 ----------- .../test/presets/ForwarderShielded.test.ts | 41 ------ .../test/presets/ForwarderUnshielded.test.ts | 37 ------ .../presets/ForwarderPrivateSimulator.ts | 67 ---------- .../presets/ForwarderShieldedSimulator.ts | 50 ------- .../presets/ForwarderUnshieldedSimulator.ts | 49 ------- 9 files changed, 569 deletions(-) delete mode 100644 contracts/src/multisig/examples/NativeShieldedForwarder.compact delete mode 100644 contracts/src/multisig/examples/NativeUnshieldedForwarder.compact delete mode 100644 contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact delete mode 100644 contracts/src/multisig/test/presets/ForwarderPrivate.test.ts delete mode 100644 contracts/src/multisig/test/presets/ForwarderShielded.test.ts delete mode 100644 contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts delete mode 100644 contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts delete mode 100644 contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts delete mode 100644 contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts diff --git a/contracts/src/multisig/examples/NativeShieldedForwarder.compact b/contracts/src/multisig/examples/NativeShieldedForwarder.compact deleted file mode 100644 index f0081e802..000000000 --- a/contracts/src/multisig/examples/NativeShieldedForwarder.compact +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/NativeShieldedForwarder.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedForwarder (formerly ForwarderShielded) - * @description Public-parent forwarder for shielded coins. Receives a - * shielded coin and atomically forwards it to the configured parent - * recipient, a coin public key. - * - * The parent recipient is set at deploy time. This preset exposes no - * setter, so the parent is fixed for the life of the contract. Anyone - * may call `deposit`; the recipient is fixed, so there is no need for - * access control. - * - * The constructor accepts a `ZswapCoinPublicKey`: an atomic forward can - * only deliver to a recipient that needs no in-tx claim, and a shielded - * send to a non-participating contract is rejected. The parent is stored - * generically (`Either`) on the ledger so a future circuit upgrade can - * add contract-address support without a state migration. - */ - -import CompactStandardLibrary; -import "../forwarder/NativeShieldedForwarder" prefix Forwarder_; - -export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; - -/** - * @description Deploys the forwarder bound to a specific parent - * recipient. - * - * @param {ZswapCoinPublicKey} parent - The coin public key that receives - * every forwarded coin. - */ -constructor(parent: ZswapCoinPublicKey) { - Forwarder_initialize(parent); -} - -/** - * @description Receives a shielded coin and atomically forwards it to - * the configured parent. The coin is claimed via `receiveShielded` and - * immediately re-sent via `sendImmediateShielded`. - * - * @param {ShieldedCoinInfo} coin - The incoming shielded coin. - * - * @returns {[]} Empty tuple. - */ -export circuit deposit(coin: ShieldedCoinInfo): [] { - Forwarder__deposit(coin); -} - -/** - * @description Returns the configured parent recipient as stored on the - * ledger (the generic `Either`; the `left` arm holds the coin public key). - * - * @returns {Either} The parent - * recipient set at deploy. - */ -export circuit getParent(): Either { - return Forwarder__parent; -} diff --git a/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact b/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact deleted file mode 100644 index dea6572ff..000000000 --- a/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/NativeUnshieldedForwarder.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeUnshieldedForwarder (formerly ForwarderUnshielded) - * @description Public-parent forwarder for unshielded coins. Receives - * an unshielded amount of a given color and atomically forwards it to - * the configured parent recipient, a user address. - * - * Unshielded transfers are publicly visible on the chain: depositor, - * recipient, color, and amount all appear on the public transcript. - * Use `NativeShieldedForwarder` instead when the deposit kind is shielded. - * - * The constructor accepts a `UserAddress`: an atomic forward can only - * deliver to a recipient that needs no in-tx claim, and an unshielded - * send to a non-participating contract is rejected. The parent is stored - * generically (`Either`) on the ledger so a future circuit upgrade can - * add contract-address support without a state migration. - * - * The parent recipient is set at deploy time. This preset exposes no - * setter, so the parent is fixed for the life of the contract. - */ - -import CompactStandardLibrary; -import "../forwarder/NativeUnshieldedForwarder" prefix Forwarder_; - -export { ContractAddress, UserAddress, Either }; - -/** - * @description Deploys the forwarder bound to a specific parent - * recipient. - * - * @param {UserAddress} parent - The user address that receives every - * forwarded amount. - */ -constructor(parent: UserAddress) { - Forwarder_initialize(parent); -} - -/** - * @description Receives an unshielded amount of `color` and atomically - * forwards it to the configured parent. - * - * @param {Bytes<32>} color - The token color. - * @param {Uint<128>} amount - The amount to deposit. - * - * @returns {[]} Empty tuple. - */ -export circuit deposit(color: Bytes<32>, amount: Uint<128>): [] { - Forwarder__deposit(color, amount); -} - -/** - * @description Returns the configured parent recipient as stored on the - * ledger (the generic `Either`; the `right` arm holds the user address). - * - * @returns {Either} The parent recipient - * set at deploy. - */ -export circuit getParent(): Either { - return Forwarder__parent; -} diff --git a/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact b/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact deleted file mode 100644 index adaecc908..000000000 --- a/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/PrivateNativeShieldedForwarder.compact) - -pragma language_version >= 0.23.0; - -/** - * @title PrivateNativeShieldedForwarder (formerly ForwarderPrivate) - * @description Private-parent forwarder. The parent address is hidden - * behind a `persistentHash` commitment on the ledger. Coins dwell at - * the contract address after deposit; the operator drains them later - * by presenting the `(parentAddr, opSecret)` preimage at drain time. - * - * Knowledge of the preimage is the sole authorization gate. The - * operational secret is held off-chain by the deployer; losing it is - * equivalent to loss of a hot-wallet key. Two forwarders bound to the - * same parent with different operational secrets produce different - * commitments and are unlinkable on-chain. - * - * @notice Each forwarder is bound to a single parent at deploy. To - * change the parent, deploy a new forwarder; the old one remains - * functional for outstanding coins until drained. - */ - -import CompactStandardLibrary; -import "../forwarder/PrivateNativeShieldedForwarder" prefix Forwarder_; - -export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; - -/** - * @description Deploys the forwarder bound to a specific parent - * commitment. The deployer computes the commitment off-chain as - * `calculateParentCommitment(parentAddr, opSecret)` and passes it here. - * - * @param {Bytes<32>} parentCommitment - The commitment to the - * `(parentAddr, opSecret)` pair that the operator will present at drain. - */ -constructor(parentCommitment: Bytes<32>) { - Forwarder_initialize(parentCommitment); -} - -/** - * @description Receives a shielded coin into the forwarder's custody. - * No ledger write — the coin sits at the contract address until drained. - * - * @param {ShieldedCoinInfo} coin - The incoming shielded coin. - * - * @returns {[]} Empty tuple. - */ -export circuit deposit(coin: ShieldedCoinInfo): [] { - Forwarder__deposit(coin); -} - -/** - * @description Spends a previously-deposited shielded coin to the parent, - * a coin public key. The caller proves knowledge of `(parent, opSecret)` - * matching the stored commitment; the coin is sent as a shielded note, so the - * parent stays hidden on-chain. If the input coin's value exceeds `value`, - * change is re-emitted back to the contract for future drains. - * - * The parent is a `ZswapCoinPublicKey`, never a `ContractAddress`: a shielded - * send to a contract publishes that contract's address in cleartext, which - * would defeat the private-parent guarantee. - * - * Requirements: - * - * - `parent` must not be the zero key. - * - `calculateParentCommitment(parent.bytes, opSecret)` must equal the stored - * `_parentCommitment`. - * - `coin.value` must be >= `value` (enforced by `sendShielded`). - * - * @param {QualifiedShieldedCoinInfo} coin - The coin to spend. - * @param {ZswapCoinPublicKey} parent - The parent coin public key. Its 32 - * bytes are the preimage to the stored commitment. - * @param {Bytes<32>} opSecret - The operational secret. Never appears - * on the public transcript. - * - * @warning **Losing the operational secret is permanent fund loss.** It - * is the sole drain authorization. No rotation, revocation, or recovery - * path exists. If the operator loses it, every shielded coin accumulated - * at this contract becomes inaccessible. Back it up offline before the - * first deposit. - * - * @param {Uint<128>} value - The amount to send. - * - * @returns {ShieldedSendResult} The result containing the sent coin and - * any change. - */ -export circuit drain( - coin: QualifiedShieldedCoinInfo, - parent: ZswapCoinPublicKey, - opSecret: Bytes<32>, - value: Uint<128> -): ShieldedSendResult { - return Forwarder__drain(coin, parent, opSecret, value); -} - -/** - * @description Returns the stored parent commitment. - * - * @returns {Bytes<32>} The commitment set at deploy. - */ -export circuit getParentCommitment(): Bytes<32> { - return Forwarder__parentCommitment; -} - -/** - * @description Computes the parent commitment from a `(parentAddr, opSecret)` - * pair. Pure circuit — used off-chain by the deployer to compute the - * constructor argument, and inside `drain` for the preimage check. - * - * The commitment is domain-tagged - * (`pad(32, "PrivateNativeShieldedForwarder:commitment")`) to prevent preimage - * collisions with other `persistentHash` users in the system. - * - * @param {Bytes<32>} parentAddr - The parent address. - * @param {Bytes<32>} opSecret - The operational secret. - * - * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])`. - */ -export pure circuit calculateParentCommitment( - parentAddr: Bytes<32>, - opSecret: Bytes<32> -): Bytes<32> { - return Forwarder__calculateParentCommitment(parentAddr, opSecret); -} diff --git a/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts b/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts deleted file mode 100644 index 75a212595..000000000 --- a/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ForwarderPrivateSimulator } from '../simulators/presets/ForwarderPrivateSimulator.js'; - -const PARENT_BYTES = utils.createEitherTestUser('PARENT').left.bytes; -const OP_SECRET = new Uint8Array(32).fill(0xaa); -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; - -// The drain parent is a `ZswapCoinPublicKey` (`{ bytes }`); the commitment is -// over its raw 32 bytes (`calculateParentCommitment(parent.bytes, opSecret)`). -function key(bytes: Uint8Array) { - return { bytes }; -} - -function makeCoin(color: Uint8Array, value: bigint) { - return { nonce: new Uint8Array(32), color, value }; -} - -function makeQualifiedCoin(color: Uint8Array, value: bigint, mtIndex: bigint) { - return { nonce: new Uint8Array(32), color, value, mt_index: mtIndex }; -} - -function commitment(parent: Uint8Array, opSecret: Uint8Array): Uint8Array { - return ForwarderPrivateSimulator.calculateParentCommitment(parent, opSecret); -} - -describe('ForwarderPrivate preset', () => { - it('should store the parentCommitment passed to the constructor', () => { - const c = commitment(PARENT_BYTES, OP_SECRET); - const fwd = new ForwarderPrivateSimulator(c); - expect(fwd.getParentCommitment()).toEqual(c); - }); - - it('should expose deposit and forward to _deposit', () => { - const fwd = new ForwarderPrivateSimulator( - commitment(PARENT_BYTES, OP_SECRET), - ); - expect(() => fwd.deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); - }); - - it('should expose drain and forward to _drain', () => { - const fwd = new ForwarderPrivateSimulator( - commitment(PARENT_BYTES, OP_SECRET), - ); - fwd.deposit(makeCoin(COLOR, AMOUNT)); - const result = fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - key(PARENT_BYTES), - OP_SECRET, - AMOUNT, - ); - expect(result.sent.value).toEqual(AMOUNT); - }); - - it('should expose calculateParentCommitment as a static pure helper', () => { - const c1 = commitment(PARENT_BYTES, OP_SECRET); - const c2 = commitment(PARENT_BYTES, OP_SECRET); - expect(c1).toEqual(c2); - }); - - it('should propagate the zero-commitment guard from the module', () => { - expect(() => new ForwarderPrivateSimulator(new Uint8Array(32))).toThrow( - 'ForwarderPrivate: zero commitment', - ); - }); - - it('should expose the public ledger state', () => { - const fwd = new ForwarderPrivateSimulator( - commitment(PARENT_BYTES, OP_SECRET), - ); - expect(fwd.getPublicState()).toBeDefined(); - }); -}); diff --git a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts deleted file mode 100644 index 1f034b203..000000000 --- a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ForwarderShieldedSimulator } from '../simulators/presets/ForwarderShieldedSimulator.js'; - -// The constructor takes a `ZswapCoinPublicKey` (the supported arm). The -// `_parent` ledger field stays a generic `Either`; `initialize` stores the key -// in the `left` arm, which is what `getParent` reads back. A contract-address -// parent is not expressible today (see the module header). -const PARENT = utils.createEitherTestUser('PARENT').left; -const ZERO_KEY = utils.ZERO_KEY.left; -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; - -function makeCoin(color: Uint8Array, value: bigint) { - return { nonce: new Uint8Array(32), color, value }; -} - -describe('ForwarderShielded preset', () => { - it('should store the parent passed to the constructor in the left arm', () => { - const fwd = new ForwarderShieldedSimulator(PARENT); - const parent = fwd.getParent(); - expect(parent.is_left).toBe(true); - expect(parent.left).toEqual(PARENT); - }); - - it('should expose deposit and forward to _deposit', () => { - const fwd = new ForwarderShieldedSimulator(PARENT); - expect(() => fwd.deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); - }); - - it('should propagate the zero-parent guard from the module', () => { - expect(() => new ForwarderShieldedSimulator(ZERO_KEY)).toThrow( - 'ForwarderShielded: zero parent', - ); - }); - - it('should expose the public ledger state', () => { - const fwd = new ForwarderShieldedSimulator(PARENT); - expect(fwd.getPublicState()).toBeDefined(); - }); -}); diff --git a/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts b/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts deleted file mode 100644 index 5d81cda30..000000000 --- a/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ForwarderUnshieldedSimulator } from '../simulators/presets/ForwarderUnshieldedSimulator.js'; - -// The constructor takes a `UserAddress` (the supported arm). The `_parent` -// ledger field stays a generic `Either`; `initialize` stores the address in the -// `right` arm, which is what `getParent` reads back. A contract-address parent -// is not expressible today (see the module header). -const PARENT = utils.createEitherTestUserAddress('PARENT').right; -const ZERO_ADDR = utils.ZERO_USER_ADDRESS.right; -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; - -describe('ForwarderUnshielded preset', () => { - it('should store the parent passed to the constructor in the right arm', () => { - const fwd = new ForwarderUnshieldedSimulator(PARENT); - const parent = fwd.getParent(); - expect(parent.is_left).toBe(false); - expect(parent.right).toEqual(PARENT); - }); - - it('should expose deposit and forward to _deposit', () => { - const fwd = new ForwarderUnshieldedSimulator(PARENT); - expect(() => fwd.deposit(COLOR, AMOUNT)).not.toThrow(); - }); - - it('should propagate the zero-parent guard from the module', () => { - expect(() => new ForwarderUnshieldedSimulator(ZERO_ADDR)).toThrow( - 'ForwarderUnshielded: zero parent', - ); - }); - - it('should expose the public ledger state', () => { - const fwd = new ForwarderUnshieldedSimulator(PARENT); - expect(fwd.getPublicState()).toBeDefined(); - }); -}); diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts deleted file mode 100644 index 2b90a5e0b..000000000 --- a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - Contract as ForwarderPrivate, - ledger, - pureCircuits, - type QualifiedShieldedCoinInfo, - type ShieldedCoinInfo, - type ShieldedSendResult, - type ZswapCoinPublicKey, -} from '../../../../../artifacts/ForwarderPrivate/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../../EmptyWitnesses.js'; - -type ForwarderPrivateArgs = readonly [parentCommitment: Uint8Array]; - -const ForwarderPrivateSimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - ForwarderPrivate, - ForwarderPrivateArgs ->({ - contractFactory: (witnesses) => - new ForwarderPrivate(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (parentCommitment) => [parentCommitment], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class ForwarderPrivateSimulator extends ForwarderPrivateSimulatorBase { - constructor( - parentCommitment: Uint8Array, - options: BaseSimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ) { - super([parentCommitment], options); - } - - public static calculateParentCommitment( - parentAddr: Uint8Array, - opSecret: Uint8Array, - ): Uint8Array { - return pureCircuits.calculateParentCommitment(parentAddr, opSecret); - } - - public deposit(coin: ShieldedCoinInfo) { - return this.circuits.impure.deposit(coin); - } - - public drain( - coin: QualifiedShieldedCoinInfo, - parent: ZswapCoinPublicKey, - opSecret: Uint8Array, - value: bigint, - ): ShieldedSendResult { - return this.circuits.impure.drain(coin, parent, opSecret, value); - } - - public getParentCommitment(): Uint8Array { - return this.circuits.impure.getParentCommitment(); - } -} diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts deleted file mode 100644 index aec81b82d..000000000 --- a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - type ContractAddress, - type Either, - Contract as ForwarderShielded, - ledger, - type ShieldedCoinInfo, - type ZswapCoinPublicKey, -} from '../../../../../artifacts/ForwarderShielded/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../../EmptyWitnesses.js'; - -type ForwarderShieldedArgs = readonly [parent: ZswapCoinPublicKey]; - -const ForwarderShieldedSimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - ForwarderShielded, - ForwarderShieldedArgs ->({ - contractFactory: (witnesses) => - new ForwarderShielded(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (parent) => [parent], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class ForwarderShieldedSimulator extends ForwarderShieldedSimulatorBase { - constructor( - parent: ZswapCoinPublicKey, - options: BaseSimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ) { - super([parent], options); - } - - public deposit(coin: ShieldedCoinInfo) { - return this.circuits.impure.deposit(coin); - } - - public getParent(): Either { - return this.circuits.impure.getParent(); - } -} diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts deleted file mode 100644 index 78dc9adbe..000000000 --- a/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - type ContractAddress, - type Either, - Contract as ForwarderUnshielded, - ledger, - type UserAddress, -} from '../../../../../artifacts/ForwarderUnshielded/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../../EmptyWitnesses.js'; - -type ForwarderUnshieldedArgs = readonly [parent: UserAddress]; - -const ForwarderUnshieldedSimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - ForwarderUnshielded, - ForwarderUnshieldedArgs ->({ - contractFactory: (witnesses) => - new ForwarderUnshielded(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (parent) => [parent], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class ForwarderUnshieldedSimulator extends ForwarderUnshieldedSimulatorBase { - constructor( - parent: UserAddress, - options: BaseSimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ) { - super([parent], options); - } - - public deposit(color: Uint8Array, amount: bigint) { - return this.circuits.impure.deposit(color, amount); - } - - public getParent(): Either { - return this.circuits.impure.getParent(); - } -} From 3b1e138fcbc3b15bd269e9c6dc4b2454dfaaad6c Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:11:53 +0200 Subject: [PATCH 11/17] test(multisig): collapse per-contract witnesses into shared EmptyWitnesses Every multisig contract has empty private state and declares no witnesses, so each per-contract *Witnesses.ts was byte-identical to the shared EmptyWitnesses.ts (the forwarder simulators already used it). Delete them all and repoint the remaining simulators (SignerManager, ProposalManager, ShieldedTreasury) at EmptyWitnesses, matching where main is heading and shrinking the rebase surface. --- .../simulators/ProposalManagerSimulator.ts | 21 ++++++++----------- .../simulators/ShieldedTreasurySimulator.ts | 21 ++++++++----------- .../test/simulators/SignerManagerSimulator.ts | 21 ++++++++----------- .../witnesses/ProposalManagerWitnesses.ts | 6 ------ .../witnesses/ShieldedMultiSigV2Witnesses.ts | 7 ------- .../witnesses/ShieldedMultiSigV3Witnesses.ts | 7 ------- .../witnesses/ShieldedMultiSigWitnesses.ts | 6 ------ .../witnesses/ShieldedTreasuryWitnesses.ts | 6 ------ .../test/witnesses/SignerManagerWitnesses.ts | 6 ------ .../witnesses/UnshieldedTreasuryWitnesses.ts | 7 ------- 10 files changed, 27 insertions(+), 81 deletions(-) delete mode 100644 contracts/src/multisig/test/witnesses/ProposalManagerWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/ShieldedMultiSigV2Witnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/ShieldedMultiSigV3Witnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/ShieldedMultiSigWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/ShieldedTreasuryWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/UnshieldedTreasuryWitnesses.ts diff --git a/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts index f43e676a9..5d83e8866 100644 --- a/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts @@ -7,10 +7,7 @@ import { Contract as MockProposalManager, pureCircuits, } from '../../../../artifacts/MockProposalManager/contract/index.js'; -import { - ProposalManagerPrivateState, - ProposalManagerWitnesses, -} from '../witnesses/ProposalManagerWitnesses.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type Recipient = { kind: number; address: Uint8Array }; type Proposal = { @@ -23,25 +20,25 @@ type Proposal = { type ProposalManagerArgs = readonly []; const ProposalManagerSimulatorBase = createSimulator< - ProposalManagerPrivateState, + EmptyPrivateState, ReturnType, - ReturnType, - MockProposalManager, + ReturnType, + MockProposalManager, ProposalManagerArgs >({ contractFactory: (witnesses) => - new MockProposalManager(witnesses), - defaultPrivateState: () => ProposalManagerPrivateState, + new MockProposalManager(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: () => [], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ProposalManagerWitnesses(), + witnessesFactory: () => emptyWitnesses(), }); export class ProposalManagerSimulator extends ProposalManagerSimulatorBase { constructor( options: BaseSimulatorOptions< - ProposalManagerPrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ) { super([], options); diff --git a/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts b/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts index 6cbfb61cf..ef7cf475a 100644 --- a/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts +++ b/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts @@ -6,10 +6,7 @@ import { ledger, Contract as MockShieldedTreasury, } from '../../../../artifacts/MockShieldedTreasury/contract/index.js'; -import { - ShieldedTreasuryPrivateState, - ShieldedTreasuryWitnesses, -} from '../witnesses/ShieldedTreasuryWitnesses.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; type ShieldedSendResult = { @@ -20,25 +17,25 @@ type ShieldedSendResult = { type ShieldedTreasuryArgs = readonly []; const ShieldedTreasurySimulatorBase = createSimulator< - ShieldedTreasuryPrivateState, + EmptyPrivateState, ReturnType, - ReturnType, - MockShieldedTreasury, + ReturnType, + MockShieldedTreasury, ShieldedTreasuryArgs >({ contractFactory: (witnesses) => - new MockShieldedTreasury(witnesses), - defaultPrivateState: () => ShieldedTreasuryPrivateState, + new MockShieldedTreasury(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: () => [], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedTreasuryWitnesses(), + witnessesFactory: () => emptyWitnesses(), }); export class ShieldedTreasurySimulator extends ShieldedTreasurySimulatorBase { constructor( options: BaseSimulatorOptions< - ShieldedTreasuryPrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ) { super([], options); diff --git a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts index 2e8c3f722..201f7402f 100644 --- a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -6,10 +6,7 @@ import { ledger, Contract as MockSignerManager, } from '../../../../artifacts/MockSignerManager/contract/index.js'; -import { - SignerManagerPrivateState, - SignerManagerWitnesses, -} from '../witnesses/SignerManagerWitnesses.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; /** * Type constructor args @@ -21,17 +18,17 @@ type SignerManagerArgs = readonly [ ]; const SignerManagerSimulatorBase = createSimulator< - SignerManagerPrivateState, + EmptyPrivateState, ReturnType, - ReturnType, - MockSignerManager, + ReturnType, + MockSignerManager, SignerManagerArgs >({ - contractFactory: (witnesses) => new MockSignerManager(witnesses), - defaultPrivateState: () => SignerManagerPrivateState, + contractFactory: (witnesses) => new MockSignerManager(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: (signers, thresh, isInit) => [signers, thresh, isInit], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => SignerManagerWitnesses(), + witnessesFactory: () => emptyWitnesses(), }); /** @@ -43,8 +40,8 @@ export class SignerManagerSimulator extends SignerManagerSimulatorBase { thresh: bigint, isInit: boolean, options: BaseSimulatorOptions< - SignerManagerPrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ) { super([signers, thresh, isInit], options); diff --git a/contracts/src/multisig/test/witnesses/ProposalManagerWitnesses.ts b/contracts/src/multisig/test/witnesses/ProposalManagerWitnesses.ts deleted file mode 100644 index 0d9fd801f..000000000 --- a/contracts/src/multisig/test/witnesses/ProposalManagerWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/ProposalManagerWitnesses.ts) - -export type ProposalManagerPrivateState = Record; -export const ProposalManagerPrivateState: ProposalManagerPrivateState = {}; -export const ProposalManagerWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/ShieldedMultiSigV2Witnesses.ts b/contracts/src/multisig/test/witnesses/ShieldedMultiSigV2Witnesses.ts deleted file mode 100644 index c580fd175..000000000 --- a/contracts/src/multisig/test/witnesses/ShieldedMultiSigV2Witnesses.ts +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/ShieldedMultiSigV2Witnesses.ts) - -export type ShieldedMultiSigV2PrivateState = Record; -export const ShieldedMultiSigV2PrivateState: ShieldedMultiSigV2PrivateState = - {}; -export const ShieldedMultiSigV2Witnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/ShieldedMultiSigV3Witnesses.ts b/contracts/src/multisig/test/witnesses/ShieldedMultiSigV3Witnesses.ts deleted file mode 100644 index f726fb96c..000000000 --- a/contracts/src/multisig/test/witnesses/ShieldedMultiSigV3Witnesses.ts +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ShieldedMultiSigV3Witnesses.ts) - -export type ShieldedMultiSigV3PrivateState = Record; -export const ShieldedMultiSigV3PrivateState: ShieldedMultiSigV3PrivateState = - {}; -export const ShieldedMultiSigV3Witnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/ShieldedMultiSigWitnesses.ts b/contracts/src/multisig/test/witnesses/ShieldedMultiSigWitnesses.ts deleted file mode 100644 index 9f36b434c..000000000 --- a/contracts/src/multisig/test/witnesses/ShieldedMultiSigWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/ShieldedMultiSigWitnesses.ts) - -export type ShieldedMultiSigPrivateState = Record; -export const ShieldedMultiSigPrivateState: ShieldedMultiSigPrivateState = {}; -export const ShieldedMultiSigWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/ShieldedTreasuryWitnesses.ts b/contracts/src/multisig/test/witnesses/ShieldedTreasuryWitnesses.ts deleted file mode 100644 index 06b9ba49b..000000000 --- a/contracts/src/multisig/test/witnesses/ShieldedTreasuryWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/ShieldedTreasuryWitnesses.ts) - -export type ShieldedTreasuryPrivateState = Record; -export const ShieldedTreasuryPrivateState: ShieldedTreasuryPrivateState = {}; -export const ShieldedTreasuryWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts b/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts deleted file mode 100644 index 7bf6a25ad..000000000 --- a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/SignerManagerWitnesses.ts) - -export type SignerManagerPrivateState = Record; -export const SignerManagerPrivateState: SignerManagerPrivateState = {}; -export const SignerManagerWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/UnshieldedTreasuryWitnesses.ts b/contracts/src/multisig/test/witnesses/UnshieldedTreasuryWitnesses.ts deleted file mode 100644 index 8e06df028..000000000 --- a/contracts/src/multisig/test/witnesses/UnshieldedTreasuryWitnesses.ts +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/UnshieldedTreasuryWitnesses.ts) - -export type UnshieldedTreasuryPrivateState = Record; -export const UnshieldedTreasuryPrivateState: UnshieldedTreasuryPrivateState = - {}; -export const UnshieldedTreasuryWitnesses = () => ({}); From 5fcd7d5a2ed8071e67e3d6b2acfbfa59d12dc3b6 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:12:40 +0200 Subject: [PATCH 12/17] test(multisig): cover example modules and rename verifier to EcdsaSignerManager The example modules (SignatureTreasury, SignatureMintBurn, ProposalTreasury) are composable behaviors with no constructor, so each gets a Mock top-level wrapper plus a simulator and spec, matching how every other module in the package is tested. The specs carry over from the old ShieldedMultiSigV2/V3/(V1) suites (rename-preserved) and target the modules directly via EmptyWitnesses. Also rename the signature primitive's test stack to match the renamed module: MockSignatureVerifier -> MockEcdsaSignerManager, with a new EcdsaSignerManager spec/simulator and the corrected assert prefix. The example modules' duplicate-signer assertion now reads "EcdsaSignerManager: duplicate signer". --- .../multisig/test/EcdsaSignerManager.test.ts | 84 ++++++++++++++ ...tiSig.test.ts => ProposalTreasury.test.ts} | 24 ++-- ...igV3.test.ts => SignatureMintBurn.test.ts} | 34 +++--- ...igV2.test.ts => SignatureTreasury.test.ts} | 78 ++++++++++--- ...compact => MockEcdsaSignerManager.compact} | 0 .../test/mocks/MockProposalTreasury.compact | 109 ++++++++++++++++++ .../test/mocks/MockSignatureMintBurn.compact | 76 ++++++++++++ .../test/mocks/MockSignatureTreasury.compact | 61 ++++++++++ .../simulators/EcdsaSignerManagerSimulator.ts | 78 +++++++++++++ ...ulator.ts => ProposalTreasurySimulator.ts} | 33 +++--- ...lator.ts => SignatureMintBurnSimulator.ts} | 37 +++--- ...lator.ts => SignatureTreasurySimulator.ts} | 38 +++--- 12 files changed, 543 insertions(+), 109 deletions(-) create mode 100644 contracts/src/multisig/test/EcdsaSignerManager.test.ts rename contracts/src/multisig/test/{ShieldedMultiSig.test.ts => ProposalTreasury.test.ts} (96%) rename contracts/src/multisig/test/{ShieldedMultiSigV3.test.ts => SignatureMintBurn.test.ts} (93%) rename contracts/src/multisig/test/{ShieldedMultiSigV2.test.ts => SignatureTreasury.test.ts} (60%) rename contracts/src/multisig/test/mocks/{MockSignatureVerifier.compact => MockEcdsaSignerManager.compact} (100%) create mode 100644 contracts/src/multisig/test/mocks/MockProposalTreasury.compact create mode 100644 contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact create mode 100644 contracts/src/multisig/test/mocks/MockSignatureTreasury.compact create mode 100644 contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts rename contracts/src/multisig/test/simulators/{ShieldedMultiSigSimulator.ts => ProposalTreasurySimulator.ts} (80%) rename contracts/src/multisig/test/simulators/{ShieldedMultiSigV3Simulator.ts => SignatureMintBurnSimulator.ts} (70%) rename contracts/src/multisig/test/simulators/{ShieldedMultiSigV2Simulator.ts => SignatureTreasurySimulator.ts} (67%) diff --git a/contracts/src/multisig/test/EcdsaSignerManager.test.ts b/contracts/src/multisig/test/EcdsaSignerManager.test.ts new file mode 100644 index 000000000..46d406016 --- /dev/null +++ b/contracts/src/multisig/test/EcdsaSignerManager.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { EcdsaSignerManagerSimulator } from './simulators/EcdsaSignerManagerSimulator.js'; + +const THRESHOLD = 2n; + +// Instance salt and ECDSA public keys (Bytes<64>) used to derive commitments. +const SALT = new Uint8Array(32).fill(7); +const PK1 = new Uint8Array(64).fill(1); +const PK2 = new Uint8Array(64).fill(2); +const PK3 = new Uint8Array(64).fill(3); +const PK_UNKNOWN = new Uint8Array(64).fill(9); + +// A dummy signature and message hash. The ECDSA check is stubbed, so the +// signature bytes are irrelevant to verification today. +const SIG = new Uint8Array(64).fill(0); +const MSG = new Uint8Array(32).fill(5); + +const COMMITMENT1 = EcdsaSignerManagerSimulator.calculateSignerId(PK1, SALT); +const COMMITMENT2 = EcdsaSignerManagerSimulator.calculateSignerId(PK2, SALT); +const COMMITMENT3 = EcdsaSignerManagerSimulator.calculateSignerId(PK3, SALT); +const SIGNERS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; + +let verifier: EcdsaSignerManagerSimulator; + +describe('EcdsaSignerManager', () => { + beforeEach(() => { + verifier = new EcdsaSignerManagerSimulator(SALT, SIGNERS, THRESHOLD); + }); + + describe('initialization', () => { + it('should register all signer commitments', () => { + expect(verifier.getSignerCount()).toEqual(3n); + expect(verifier.getThreshold()).toEqual(2n); + expect(verifier.isSigner(COMMITMENT1)).toEqual(true); + expect(verifier.isSigner(COMMITMENT2)).toEqual(true); + expect(verifier.isSigner(COMMITMENT3)).toEqual(true); + }); + + it('should not recognize an unregistered commitment', () => { + const unknown = EcdsaSignerManagerSimulator.calculateSignerId( + PK_UNKNOWN, + SALT, + ); + expect(verifier.isSigner(unknown)).toEqual(false); + }); + }); + + describe('_calculateSignerId', () => { + it('should be deterministic for the same key and salt', () => { + expect( + EcdsaSignerManagerSimulator.calculateSignerId(PK1, SALT), + ).toStrictEqual(COMMITMENT1); + }); + + it('should produce a different commitment for a different salt', () => { + const otherSalt = new Uint8Array(32).fill(8); + expect( + EcdsaSignerManagerSimulator.calculateSignerId(PK1, otherSalt), + ).not.toStrictEqual(COMMITMENT1); + }); + + it('should produce a different commitment for a different key', () => { + expect(COMMITMENT1).not.toStrictEqual(COMMITMENT2); + }); + }); + + describe('verify', () => { + it('should pass with two distinct registered signers', () => { + expect(() => verifier.verify(MSG, [PK1, PK2], [SIG, SIG])).not.toThrow(); + }); + + it('should reject a duplicate signer', () => { + expect(() => verifier.verify(MSG, [PK1, PK1], [SIG, SIG])).toThrow( + 'EcdsaSignerManager: duplicate signer', + ); + }); + + it('should reject an unregistered signer', () => { + expect(() => verifier.verify(MSG, [PK_UNKNOWN, PK2], [SIG, SIG])).toThrow( + 'SignerManager: not a signer', + ); + }); + }); +}); diff --git a/contracts/src/multisig/test/ShieldedMultiSig.test.ts b/contracts/src/multisig/test/ProposalTreasury.test.ts similarity index 96% rename from contracts/src/multisig/test/ShieldedMultiSig.test.ts rename to contracts/src/multisig/test/ProposalTreasury.test.ts index 30742bb40..7782f7294 100644 --- a/contracts/src/multisig/test/ShieldedMultiSig.test.ts +++ b/contracts/src/multisig/test/ProposalTreasury.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/address.js'; -import { ShieldedMultiSigSimulator } from './simulators/ShieldedMultiSigSimulator.js'; +import { ProposalTreasurySimulator } from './simulators/ProposalTreasurySimulator.js'; const ProposalStatus = { Inactive: 0, Active: 1, Executed: 2, Cancelled: 3 }; const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; @@ -37,44 +37,44 @@ function makeCoin( }; } -let multisig: ShieldedMultiSigSimulator; +let multisig: ProposalTreasurySimulator; -describe('ShieldedMultiSig', () => { +describe('ProposalTreasury', () => { describe('constructor', () => { it('should initialize with signers and threshold', () => { - multisig = new ShieldedMultiSigSimulator(SIGNERS, THRESHOLD); + multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); expect(multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); expect(multisig.getThreshold()).toEqual(THRESHOLD); }); it('should register all signers', () => { - multisig = new ShieldedMultiSigSimulator(SIGNERS, THRESHOLD); + multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); for (const signer of SIGNERS) { expect(multisig.isSigner(signer)).toEqual(true); } }); it('should reject non-signers', () => { - multisig = new ShieldedMultiSigSimulator(SIGNERS, THRESHOLD); + multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); expect(multisig.isSigner(Z_NON_SIGNER)).toEqual(false); }); it('should fail with zero threshold', () => { expect(() => { - new ShieldedMultiSigSimulator(SIGNERS, 0n); + new ProposalTreasurySimulator(SIGNERS, 0n); }).toThrow('SignerManager: threshold must not be zero'); }); it('should fail with threshold exceeding signer count', () => { expect(() => { - new ShieldedMultiSigSimulator(SIGNERS, 4n); + new ProposalTreasurySimulator(SIGNERS, 4n); }).toThrow('SignerManager: threshold exceeds signer count'); }); }); describe('when initialized', () => { beforeEach(() => { - multisig = new ShieldedMultiSigSimulator(SIGNERS, THRESHOLD); + multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); }); describe('deposit', () => { @@ -142,7 +142,7 @@ describe('ShieldedMultiSig', () => { .as(SIGNER1) .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); }).toThrow( - 'ShieldedMultiSig: recipient must be a shielded user or contract', + 'ProposalTreasury: recipient must be a shielded user or contract', ); }); @@ -195,7 +195,7 @@ describe('ShieldedMultiSig', () => { multisig.as(SIGNER1).approveProposal(proposalId); expect(() => { multisig.as(SIGNER1).approveProposal(proposalId); - }).toThrow('Multisig: already approved'); + }).toThrow('ProposalTreasury: already approved'); }); it('should fail for non-existing proposal', () => { @@ -244,7 +244,7 @@ describe('ShieldedMultiSig', () => { it('should fail if not yet approved', () => { expect(() => { multisig.as(SIGNER2).revokeApproval(proposalId); - }).toThrow('Multisig: not approved'); + }).toThrow('ProposalTreasury: not approved'); }); it('should allow re-approval after revoke', () => { diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/SignatureMintBurn.test.ts similarity index 93% rename from contracts/src/multisig/test/ShieldedMultiSigV3.test.ts rename to contracts/src/multisig/test/SignatureMintBurn.test.ts index 8e750662b..f6f63d0f8 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts +++ b/contracts/src/multisig/test/SignatureMintBurn.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/address.js'; import { calculateSignerId, - ShieldedMultiSigV3Simulator, -} from './simulators/ShieldedMultiSigV3Simulator.js'; + SignatureMintBurnSimulator, +} from './simulators/SignatureMintBurnSimulator.js'; // ─── Fixtures ───────────────────────────────────────────────────── @@ -46,12 +46,12 @@ function makeQualifiedCoin( }; } -let multisig: ShieldedMultiSigV3Simulator; +let multisig: SignatureMintBurnSimulator; -describe('ShieldedMultiSigV3', () => { +describe('SignatureMintBurn', () => { describe('constructor', () => { it('should initialize', () => { - multisig = new ShieldedMultiSigV3Simulator( + multisig = new SignatureMintBurnSimulator( INSTANCE_SALT, INIT_COIN_NONCE, TOKEN_DOMAIN, @@ -62,7 +62,7 @@ describe('ShieldedMultiSigV3', () => { }); it('should register all signer commitments', () => { - multisig = new ShieldedMultiSigV3Simulator( + multisig = new SignatureMintBurnSimulator( INSTANCE_SALT, INIT_COIN_NONCE, TOKEN_DOMAIN, @@ -74,7 +74,7 @@ describe('ShieldedMultiSigV3', () => { }); it('should reject a non-signer commitment', () => { - multisig = new ShieldedMultiSigV3Simulator( + multisig = new SignatureMintBurnSimulator( INSTANCE_SALT, INIT_COIN_NONCE, TOKEN_DOMAIN, @@ -86,7 +86,7 @@ describe('ShieldedMultiSigV3', () => { it('should fail with duplicate signer commitments', () => { expect(() => { - new ShieldedMultiSigV3Simulator( + new SignatureMintBurnSimulator( INSTANCE_SALT, INIT_COIN_NONCE, TOKEN_DOMAIN, @@ -96,7 +96,7 @@ describe('ShieldedMultiSigV3', () => { }); it('should store token domain', () => { - multisig = new ShieldedMultiSigV3Simulator( + multisig = new SignatureMintBurnSimulator( INSTANCE_SALT, INIT_COIN_NONCE, TOKEN_DOMAIN, @@ -108,7 +108,7 @@ describe('ShieldedMultiSigV3', () => { describe('when initialized', () => { beforeEach(() => { - multisig = new ShieldedMultiSigV3Simulator( + multisig = new SignatureMintBurnSimulator( INSTANCE_SALT, INIT_COIN_NONCE, TOKEN_DOMAIN, @@ -224,7 +224,7 @@ describe('ShieldedMultiSigV3', () => { [PK1, PK1], [DUMMY_SIG, DUMMY_SIG], ); - }).toThrow('Multisig: duplicate signer'); + }).toThrow('EcdsaSignerManager: duplicate signer'); }); it('should reject a non-signer pubkey', () => { @@ -318,7 +318,7 @@ describe('ShieldedMultiSigV3', () => { const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); expect(() => { multisig.burn(coin, 100n, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('Multisig: duplicate signer'); + }).toThrow('EcdsaSignerManager: duplicate signer'); }); it('should reject a non-signer pubkey', () => { @@ -338,21 +338,21 @@ describe('ShieldedMultiSigV3', () => { const coin = makeQualifiedCoin(wrongColor, 100n); expect(() => { multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('Multisig: coin not from this contract'); + }).toThrow('SignatureMintBurn: coin not from this contract'); }); it('should reject insufficient coin value', () => { const coin = makeQualifiedCoin(multisig.getTokenType(), 10n); expect(() => { multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('Multisig: insufficient coin value'); + }).toThrow('SignatureMintBurn: insufficient coin value'); }); it('should reject when amount exceeds value by 1', () => { const coin = makeQualifiedCoin(multisig.getTokenType(), 99n); expect(() => { multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('Multisig: insufficient coin value'); + }).toThrow('SignatureMintBurn: insufficient coin value'); }); it('should share nonce across mint and burn', () => { @@ -377,7 +377,7 @@ describe('ShieldedMultiSigV3', () => { const altDomain = new Uint8Array(32); Buffer.from('alt:token:').copy(altDomain); - const alt = new ShieldedMultiSigV3Simulator( + const alt = new SignatureMintBurnSimulator( INSTANCE_SALT, INIT_COIN_NONCE, altDomain, @@ -403,7 +403,7 @@ describe('ShieldedMultiSigV3', () => { describe('cross-instance replay', () => { it('should derive different message hashes for different instances', () => { - const instance2 = new ShieldedMultiSigV3Simulator( + const instance2 = new SignatureMintBurnSimulator( INSTANCE_SALT, INIT_COIN_NONCE, TOKEN_DOMAIN, diff --git a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts b/contracts/src/multisig/test/SignatureTreasury.test.ts similarity index 60% rename from contracts/src/multisig/test/ShieldedMultiSigV2.test.ts rename to contracts/src/multisig/test/SignatureTreasury.test.ts index 8e1c10480..592ee639f 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts +++ b/contracts/src/multisig/test/SignatureTreasury.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { ShieldedMultiSigV2Simulator } from './simulators/ShieldedMultiSigV2Simulator.js'; +import { SignatureTreasurySimulator } from './simulators/SignatureTreasurySimulator.js'; const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; @@ -12,15 +12,15 @@ const PK2 = new Uint8Array(64).fill(0x22); const PK3 = new Uint8Array(64).fill(0x33); const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); -const COMMITMENT1 = ShieldedMultiSigV2Simulator.calculateSignerId( +const COMMITMENT1 = SignatureTreasurySimulator.calculateSignerId( PK1, INSTANCE_SALT, ); -const COMMITMENT2 = ShieldedMultiSigV2Simulator.calculateSignerId( +const COMMITMENT2 = SignatureTreasurySimulator.calculateSignerId( PK2, INSTANCE_SALT, ); -const COMMITMENT3 = ShieldedMultiSigV2Simulator.calculateSignerId( +const COMMITMENT3 = SignatureTreasurySimulator.calculateSignerId( PK3, INSTANCE_SALT, ); @@ -66,12 +66,12 @@ function makeQualifiedCoin( }; } -let multisig: ShieldedMultiSigV2Simulator; +let multisig: SignatureTreasurySimulator; -describe('ShieldedMultiSigV2', () => { +describe('SignatureTreasury', () => { describe('constructor', () => { it('should initialize with 2-of-3 threshold', () => { - multisig = new ShieldedMultiSigV2Simulator( + multisig = new SignatureTreasurySimulator( INSTANCE_SALT, SIGNER_COMMITMENTS, 2n, @@ -81,7 +81,7 @@ describe('ShieldedMultiSigV2', () => { }); it('should initialize with 1-of-3 threshold', () => { - multisig = new ShieldedMultiSigV2Simulator( + multisig = new SignatureTreasurySimulator( INSTANCE_SALT, SIGNER_COMMITMENTS, 1n, @@ -91,20 +91,18 @@ describe('ShieldedMultiSigV2', () => { it('should fail with zero threshold', () => { expect(() => { - new ShieldedMultiSigV2Simulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 0n); + new SignatureTreasurySimulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 0n); }).toThrow('SignerManager: threshold must not be zero'); }); - it('should fail with threshold greater than 2', () => { + it('should fail with threshold exceeding signer count', () => { expect(() => { - new ShieldedMultiSigV2Simulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 3n); - }).toThrow( - 'ShieldedMultiSigV2: threshold cannot exceed 2 (execute verifies at most 2 signatures)', - ); + new SignatureTreasurySimulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 4n); + }).toThrow('SignerManager: threshold exceeds signer count'); }); it('should register all signer commitments', () => { - multisig = new ShieldedMultiSigV2Simulator( + multisig = new SignatureTreasurySimulator( INSTANCE_SALT, SIGNER_COMMITMENTS, 2n, @@ -115,22 +113,32 @@ describe('ShieldedMultiSigV2', () => { }); it('should reject a non-signer commitment', () => { - multisig = new ShieldedMultiSigV2Simulator( + multisig = new SignatureTreasurySimulator( INSTANCE_SALT, SIGNER_COMMITMENTS, 2n, ); - const unknown = ShieldedMultiSigV2Simulator.calculateSignerId( + const unknown = SignatureTreasurySimulator.calculateSignerId( NON_SIGNER_PK, INSTANCE_SALT, ); expect(multisig.isSigner(unknown)).toEqual(false); }); + + it('should fail with duplicate signer commitments', () => { + expect(() => { + new SignatureTreasurySimulator( + INSTANCE_SALT, + [COMMITMENT1, COMMITMENT1, COMMITMENT2], + 2n, + ); + }).toThrow('SignerManager: signer already active'); + }); }); describe('when initialized', () => { beforeEach(() => { - multisig = new ShieldedMultiSigV2Simulator( + multisig = new SignatureTreasurySimulator( INSTANCE_SALT, SIGNER_COMMITMENTS, 2n, @@ -151,6 +159,21 @@ describe('ShieldedMultiSigV2', () => { }); }); + describe('_calculateSignerId', () => { + it('should be deterministic for the same key and salt', () => { + expect( + SignatureTreasurySimulator.calculateSignerId(PK1, INSTANCE_SALT), + ).toStrictEqual(COMMITMENT1); + }); + + it('should produce a different commitment for a different salt', () => { + const otherSalt = new Uint8Array(32).fill(0xcc); + expect( + SignatureTreasurySimulator.calculateSignerId(PK1, otherSalt), + ).not.toStrictEqual(COMMITMENT1); + }); + }); + describe('deposit', () => { it('should accept deposits without reverting', () => { expect(() => { @@ -165,7 +188,7 @@ describe('ShieldedMultiSigV2', () => { const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); expect(() => { multisig.execute(to, 100n, coin, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('Multisig: duplicate signer'); + }).toThrow('EcdsaSignerManager: duplicate signer'); }); it('should reject a non-signer pubkey', () => { @@ -182,5 +205,22 @@ describe('ShieldedMultiSigV2', () => { }).toThrow('SignerManager: not a signer'); }); }); + + describe('execute — threshold above the 2-signature surface', () => { + it('should reject when threshold exceeds verifiable signatures', () => { + // A 3-of-3 instance can never satisfy `execute`, which verifies at most + // two signatures. Two valid distinct signers still fall short. + const strict = new SignatureTreasurySimulator( + INSTANCE_SALT, + SIGNER_COMMITMENTS, + 3n, + ); + const to = makeRecipient(new Uint8Array(32).fill(7)); + const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + expect(() => { + strict.execute(to, 100n, coin, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('SignerManager: threshold not met'); + }); + }); }); }); diff --git a/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact b/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact similarity index 100% rename from contracts/src/multisig/test/mocks/MockSignatureVerifier.compact rename to contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact diff --git a/contracts/src/multisig/test/mocks/MockProposalTreasury.compact b/contracts/src/multisig/test/mocks/MockProposalTreasury.compact new file mode 100644 index 000000000..d10c6a29d --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockProposalTreasury.compact @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes the `ProposalTreasury` example module as a deployable +// contract so the simulator can exercise it. DO NOT deploy or use this contract +// in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../examples/ProposalTreasury" prefix Proposal_; +import "../../proposal/ProposalManager" prefix ProposalManager_; + +// ─── Constructor ──────────────────────────────────────────────── + +constructor( + signers: Vector<3, Either>, + thresh: Uint<8> +) { + Proposal_initialize<3>(signers, thresh); +} + +// ─── Circuits (delegated to ProposalTreasury) ─────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Proposal_deposit(coin); +} + +export circuit createShieldedProposal( + to: ProposalManager_Recipient, + color: Bytes<32>, + amount: Uint<128> +): Uint<64> { + return Proposal_createShieldedProposal(to, color, amount); +} + +export circuit approveProposal(id: Uint<64>): [] { + Proposal_approveProposal(id); +} + +export circuit revokeApproval(id: Uint<64>): [] { + Proposal_revokeApproval(id); +} + +export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { + return Proposal_executeShieldedProposal(id); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit isProposalApprovedBySigner( + id: Uint<64>, + signer: Either +): Boolean { + return Proposal_isProposalApprovedBySigner(id, signer); +} + +export circuit getApprovalCount(id: Uint<64>): Uint<8> { + return Proposal_getApprovalCount(id); +} + +export circuit getProposal(id: Uint<64>): ProposalManager_Proposal { + return Proposal_getProposal(id); +} + +export circuit getProposalRecipient(id: Uint<64>): ProposalManager_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>): ProposalManager_ProposalStatus { + return Proposal_getProposalStatus(id); +} + +export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Proposal_getTokenBalance(color); +} + +export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + return Proposal_getReceivedTotal(color); +} + +export circuit getSentTotal(color: Bytes<32>): Uint<128> { + return Proposal_getSentTotal(color); +} + +export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return Proposal_getReceivedMinusSent(color); +} + +export circuit getSignerCount(): Uint<8> { + return Proposal_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Proposal_getThreshold(); +} + +export circuit isSigner(account: Either): Boolean { + return Proposal_isSigner(account); +} diff --git a/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact b/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact new file mode 100644 index 000000000..41d44b852 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes the `SignatureMintBurn` example module as a deployable +// contract so the simulator can exercise it. DO NOT deploy or use this contract +// in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../examples/SignatureMintBurn" prefix Token_; +// For testing +export { ZswapCoinPublicKey }; + +// ─── Constructor ──────────────────────────────────────────────── + +constructor( + instanceSalt: Bytes<32>, + initCoinNonce: Bytes<32>, + tokenDomain: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, +) { + Token_initialize<3>(instanceSalt, signerCommitments, 2); + Token_initializeToken(tokenDomain, initCoinNonce); +} + +// ─── Circuits (delegated to SignatureMintBurn) ────────────────── + +export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_mint(amount, recipient, pubkeys, signatures); +} + +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_burn(coin, amount, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Token__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return Token_getNonce(); +} + +export circuit getTokenDomain(): Bytes<32> { + return Token_getTokenDomain(); +} + +export circuit getTokenType(): Bytes<32> { + return Token_getTokenType(); +} + +export circuit getSignerCount(): Uint<8> { + return Token_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Token_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Token_isSigner(commitment); +} diff --git a/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact b/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact new file mode 100644 index 000000000..8cef871d3 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes the `SignatureTreasury` example module as a deployable +// contract so the simulator can exercise it. DO NOT deploy or use this contract +// in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../examples/SignatureTreasury" prefix Treasury_; +import "../../proposal/ProposalManager" prefix Proposal_; + +// ─── Constructor ──────────────────────────────────────────────── + +constructor( + instanceSalt: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, + thresh: Uint<8>, +) { + Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); +} + +// ─── Circuits (delegated to SignatureTreasury) ────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury_deposit(coin); +} + +export circuit execute( + to: Proposal_Recipient, + amount: Uint<128>, + coin: QualifiedShieldedCoinInfo, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): ShieldedSendResult { + return Treasury_execute(to, amount, coin, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Treasury__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return Treasury_getNonce(); +} + +export circuit getSignerCount(): Uint<8> { + return Treasury_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Treasury_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Treasury_isSigner(commitment); +} diff --git a/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts new file mode 100644 index 000000000..070519ec3 --- /dev/null +++ b/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts @@ -0,0 +1,78 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockEcdsaSignerManager, + pureCircuits, +} from '../../../../artifacts/MockEcdsaSignerManager/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; + +/** + * Type constructor args + */ +type EcdsaSignerManagerArgs = readonly [ + salt: Uint8Array, + signers: Uint8Array[], + thresh: bigint, +]; + +const EcdsaSignerManagerSimulatorBase = createSimulator< + EmptyPrivateState, + ReturnType, + ReturnType, + MockEcdsaSignerManager, + EcdsaSignerManagerArgs +>({ + contractFactory: (witnesses) => + new MockEcdsaSignerManager(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: (salt, signers, thresh) => [salt, signers, thresh], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => emptyWitnesses(), +}); + +/** + * EcdsaSignerManager Simulator + */ +export class EcdsaSignerManagerSimulator extends EcdsaSignerManagerSimulatorBase { + constructor( + salt: Uint8Array, + signers: Uint8Array[], + thresh: bigint, + options: BaseSimulatorOptions< + EmptyPrivateState, + ReturnType + > = {}, + ) { + super([salt, signers, thresh], options); + } + + /** + * Pure commitment derivation — callable without a deployed instance. + */ + public static calculateSignerId(pk: Uint8Array, salt: Uint8Array): Uint8Array { + return pureCircuits._calculateSignerId(pk, salt); + } + + public verify( + msgHash: Uint8Array, + pubkeys: [Uint8Array, Uint8Array], + signatures: [Uint8Array, Uint8Array], + ) { + return this.circuits.impure.verify(msgHash, pubkeys, signatures); + } + + public getSignerCount(): bigint { + return this.circuits.impure.getSignerCount(); + } + + public getThreshold(): bigint { + return this.circuits.impure.getThreshold(); + } + + public isSigner(account: Uint8Array): boolean { + return this.circuits.impure.isSigner(account); + } +} diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts b/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts similarity index 80% rename from contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts rename to contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts index f881384bc..1b45041e5 100644 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts +++ b/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts @@ -5,12 +5,9 @@ import { import { type Ledger, ledger, - Contract as ShieldedMultiSig, -} from '../../../../artifacts/ShieldedMultiSig/contract/index.js'; -import { - ShieldedMultiSigPrivateState, - ShieldedMultiSigWitnesses, -} from '../witnesses/ShieldedMultiSigWitnesses.js'; + Contract as MockProposalTreasury, +} from '../../../../artifacts/MockProposalTreasury/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type EitherPKAddress = { is_left: boolean; @@ -30,33 +27,33 @@ type Proposal = { status: number; }; -type ShieldedMultiSigArgs = readonly [ +type ProposalTreasuryArgs = readonly [ signers: EitherPKAddress[], thresh: bigint, ]; -const ShieldedMultiSigSimulatorBase = createSimulator< - ShieldedMultiSigPrivateState, +const ProposalTreasurySimulatorBase = createSimulator< + EmptyPrivateState, ReturnType, - ReturnType, - ShieldedMultiSig, - ShieldedMultiSigArgs + ReturnType, + MockProposalTreasury, + ProposalTreasuryArgs >({ contractFactory: (witnesses) => - new ShieldedMultiSig(witnesses), - defaultPrivateState: () => ShieldedMultiSigPrivateState, + new MockProposalTreasury(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: (signers, thresh) => [signers, thresh], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedMultiSigWitnesses(), + witnessesFactory: () => emptyWitnesses(), }); -export class ShieldedMultiSigSimulator extends ShieldedMultiSigSimulatorBase { +export class ProposalTreasurySimulator extends ProposalTreasurySimulatorBase { constructor( signers: EitherPKAddress[], thresh: bigint, options: BaseSimulatorOptions< - ShieldedMultiSigPrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ) { super([signers, thresh], options); diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts b/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts similarity index 70% rename from contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts rename to contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts index bbd5bc286..24bd06b1c 100644 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts +++ b/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts @@ -5,50 +5,47 @@ import { import { ledger, pureCircuits, - Contract as ShieldedMultiSigV3Contract, + Contract as MockSignatureMintBurn, type ZswapCoinPublicKey, -} from '../../../../artifacts/ShieldedMultiSigV3/contract/index.js'; -import { - ShieldedMultiSigV3PrivateState, - ShieldedMultiSigV3Witnesses, -} from '../witnesses/ShieldedMultiSigV3Witnesses.js'; +} from '../../../../artifacts/MockSignatureMintBurn/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; -type ShieldedMultiSigV3Args = readonly [ +type SignatureMintBurnArgs = readonly [ instanceSalt: Uint8Array, initCoinNonce: Uint8Array, tokenDomain: Uint8Array, signerCommitments: Uint8Array[], ]; -const ShieldedMultiSigV3SimulatorBase = createSimulator< - ShieldedMultiSigV3PrivateState, +const SignatureMintBurnSimulatorBase = createSimulator< + EmptyPrivateState, ReturnType, - ReturnType, - ShieldedMultiSigV3Contract, - ShieldedMultiSigV3Args + ReturnType, + MockSignatureMintBurn, + SignatureMintBurnArgs >({ contractFactory: (witnesses) => - new ShieldedMultiSigV3Contract(witnesses), - defaultPrivateState: () => ShieldedMultiSigV3PrivateState, - contractArgs: ( + new MockSignatureMintBurn(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: (instanceSalt, initCoinNonce, tokenDomain, signerCommitments) => [ instanceSalt, initCoinNonce, tokenDomain, signerCommitments, - ) => [instanceSalt, initCoinNonce, tokenDomain, signerCommitments], + ], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedMultiSigV3Witnesses(), + witnessesFactory: () => emptyWitnesses(), }); -export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase { +export class SignatureMintBurnSimulator extends SignatureMintBurnSimulatorBase { constructor( instanceSalt: Uint8Array, initCoinNonce: Uint8Array, tokenDomain: Uint8Array, signerCommitments: Uint8Array[], options: BaseSimulatorOptions< - ShieldedMultiSigV3PrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ) { super( diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts b/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts similarity index 67% rename from contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts rename to contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts index 3e091fa58..217862125 100644 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts +++ b/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts @@ -3,15 +3,11 @@ import { createSimulator, } from '@openzeppelin/compact-simulator'; import { - type Ledger, ledger, pureCircuits, - Contract as ShieldedMultiSigV2, -} from '../../../../artifacts/ShieldedMultiSigV2/contract/index.js'; -import { - ShieldedMultiSigV2PrivateState, - ShieldedMultiSigV2Witnesses, -} from '../witnesses/ShieldedMultiSigV2Witnesses.js'; + Contract as MockSignatureTreasury, +} from '../../../../artifacts/MockSignatureTreasury/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type Recipient = { kind: number; address: Uint8Array }; type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; @@ -26,39 +22,39 @@ type ShieldedSendResult = { sent: ShieldedCoinInfo; }; -type ShieldedMultiSigV2Args = readonly [ +type SignatureTreasuryArgs = readonly [ instanceSalt: Uint8Array, signerCommitments: Uint8Array[], thresh: bigint, ]; -const ShieldedMultiSigV2SimulatorBase = createSimulator< - ShieldedMultiSigV2PrivateState, +const SignatureTreasurySimulatorBase = createSimulator< + EmptyPrivateState, ReturnType, - ReturnType, - ShieldedMultiSigV2, - ShieldedMultiSigV2Args + ReturnType, + MockSignatureTreasury, + SignatureTreasuryArgs >({ contractFactory: (witnesses) => - new ShieldedMultiSigV2(witnesses), - defaultPrivateState: () => ShieldedMultiSigV2PrivateState, + new MockSignatureTreasury(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: (instanceSalt, signerCommitments, thresh) => [ instanceSalt, signerCommitments, thresh, ], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedMultiSigV2Witnesses(), + witnessesFactory: () => emptyWitnesses(), }); -export class ShieldedMultiSigV2Simulator extends ShieldedMultiSigV2SimulatorBase { +export class SignatureTreasurySimulator extends SignatureTreasurySimulatorBase { constructor( instanceSalt: Uint8Array, signerCommitments: Uint8Array[], thresh: bigint, options: BaseSimulatorOptions< - ShieldedMultiSigV2PrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ) { super([instanceSalt, signerCommitments, thresh], options); @@ -100,8 +96,4 @@ export class ShieldedMultiSigV2Simulator extends ShieldedMultiSigV2SimulatorBase public isSigner(commitment: Uint8Array): boolean { return this.circuits.impure.isSigner(commitment); } - - public getLedger(): Ledger { - return this.getPublicState(); - } } From 21b1da3b0cd582138547890a00464827a2c4c633 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:12:49 +0200 Subject: [PATCH 13/17] test(multisig): realign forwarder module tests with renamed asserts The forwarder modules were renamed (ForwarderShielded -> NativeShieldedForwarder, etc.) and their assert messages updated, but the kept module tests still expected the old prefixes. Update the toThrow expectations to the current "Native*Forwarder:" / "PrivateNativeShieldedForwarder:" messages. --- contracts/src/multisig/test/Forwarder.test.ts | 8 ++++---- .../src/multisig/test/ForwarderPrivate.test.ts | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/contracts/src/multisig/test/Forwarder.test.ts b/contracts/src/multisig/test/Forwarder.test.ts index 931c34321..3e4f77ab9 100644 --- a/contracts/src/multisig/test/Forwarder.test.ts +++ b/contracts/src/multisig/test/Forwarder.test.ts @@ -37,7 +37,7 @@ describe('ForwarderShielded module', () => { it('should fail initialization with a zero parent', () => { expect( () => new MockForwarderShieldedSimulator(SHIELDED_ZERO, true), - ).toThrow('ForwarderShielded: zero parent'); + ).toThrow('NativeShieldedForwarder: zero parent'); }); it('should store the coin-public-key parent in the left arm', () => { @@ -52,7 +52,7 @@ describe('ForwarderShielded module', () => { it('should fail deposit when not initialized', () => { const mock = new MockForwarderShieldedSimulator(SHIELDED_PARENT, false); expect(() => mock.deposit(makeCoin(COLOR, AMOUNT))).toThrow( - 'ForwarderShielded: contract not initialized', + 'NativeShieldedForwarder: contract not initialized', ); }); }); @@ -76,7 +76,7 @@ describe('ForwarderUnshielded module', () => { it('should fail initialization with a zero parent', () => { expect( () => new MockForwarderUnshieldedSimulator(UNSHIELDED_ZERO, true), - ).toThrow('ForwarderUnshielded: zero parent'); + ).toThrow('NativeUnshieldedForwarder: zero parent'); }); it('should store the user-address parent in the right arm', () => { @@ -97,7 +97,7 @@ describe('ForwarderUnshielded module', () => { false, ); expect(() => mock.deposit(COLOR, AMOUNT)).toThrow( - 'ForwarderUnshielded: contract not initialized', + 'NativeUnshieldedForwarder: contract not initialized', ); }); }); diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts index 8b8901956..38f6d9d3b 100644 --- a/contracts/src/multisig/test/ForwarderPrivate.test.ts +++ b/contracts/src/multisig/test/ForwarderPrivate.test.ts @@ -74,7 +74,7 @@ describe('ForwarderPrivate module', () => { it('should fail initialization with zero commitment', () => { expect(() => new MockForwarderPrivateSimulator(ZERO, true)).toThrow( - 'ForwarderPrivate: zero commitment', + 'PrivateNativeShieldedForwarder: zero commitment', ); }); @@ -99,7 +99,7 @@ describe('ForwarderPrivate module', () => { it('should fail deposit when not initialized', () => { expect(() => mock.deposit(makeCoin(COLOR, AMOUNT))).toThrow( - 'ForwarderPrivate: contract not initialized', + 'PrivateNativeShieldedForwarder: contract not initialized', ); }); @@ -111,7 +111,7 @@ describe('ForwarderPrivate module', () => { OP_SECRET, AMOUNT, ), - ).toThrow('ForwarderPrivate: contract not initialized'); + ).toThrow('PrivateNativeShieldedForwarder: contract not initialized'); }); }); @@ -167,7 +167,7 @@ describe('ForwarderPrivate module', () => { OP_SECRET, AMOUNT, ), - ).toThrow('ForwarderPrivate: invalid parent'); + ).toThrow('PrivateNativeShieldedForwarder: invalid parent'); }); it('should fail drain with wrong opSecret', () => { @@ -178,7 +178,7 @@ describe('ForwarderPrivate module', () => { WRONG_OP_SECRET, AMOUNT, ), - ).toThrow('ForwarderPrivate: invalid parent'); + ).toThrow('PrivateNativeShieldedForwarder: invalid parent'); }); it('should fail drain with both wrong', () => { @@ -189,7 +189,7 @@ describe('ForwarderPrivate module', () => { WRONG_OP_SECRET, AMOUNT, ), - ).toThrow('ForwarderPrivate: invalid parent'); + ).toThrow('PrivateNativeShieldedForwarder: invalid parent'); }); it('should fail drain with value > coin.value', () => { @@ -248,7 +248,7 @@ describe('ForwarderPrivate module', () => { OP_SECRET, AMOUNT, ), - ).toThrow('ForwarderPrivate: zero parent'); + ).toThrow('PrivateNativeShieldedForwarder: zero parent'); }); }); From 8ff181224d1d7953e1e42ce15962f9a1ed666106 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:17:17 +0200 Subject: [PATCH 14/17] refactor(multisig): defer presets to a follow-up branch The deployable preset contracts (NativeShieldedMintBurn, NativeShieldedProposal, NativeShieldedStatelessTreasury, NativeShieldedTokenVault) need rework and move to a separate branch. Remove them here and drop the now-dangling `presets/...` references from the SignatureTreasury and SignatureMintBurn module docs, describing the thin-wrapper composition pattern in prose instead. --- .../examples/SignatureMintBurn.compact | 3 +- .../examples/SignatureTreasury.compact | 6 +- .../presets/NativeShieldedMintBurn.compact | 99 ------------- .../presets/NativeShieldedProposal.compact | 123 ---------------- .../NativeShieldedStatelessTreasury.compact | 88 ----------- .../presets/NativeShieldedTokenVault.compact | 137 ------------------ 6 files changed, 4 insertions(+), 452 deletions(-) delete mode 100644 contracts/src/multisig/presets/NativeShieldedMintBurn.compact delete mode 100644 contracts/src/multisig/presets/NativeShieldedProposal.compact delete mode 100644 contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact delete mode 100644 contracts/src/multisig/presets/NativeShieldedTokenVault.compact diff --git a/contracts/src/multisig/examples/SignatureMintBurn.compact b/contracts/src/multisig/examples/SignatureMintBurn.compact index 8814359a2..d0b9dfb1f 100644 --- a/contracts/src/multisig/examples/SignatureMintBurn.compact +++ b/contracts/src/multisig/examples/SignatureMintBurn.compact @@ -19,8 +19,7 @@ pragma language_version >= 0.23.0; * Initialization is split so this module can be composed: `initialize` seeds the * shared signer registry, while `initializeToken` seeds this module's own token * state. A combined contract calls another module's `initialize` once for the - * shared registry, then this module's `initializeToken` (see - * `presets/NativeShieldedTokenVault`). + * shared registry, then this module's `initializeToken`. * * @notice DEPRECATION: this is a stopgap. From `0.3.0-alpha` it is superseded by * the reusable Shielded Native Token standard (with a pluggable multisig access diff --git a/contracts/src/multisig/examples/SignatureTreasury.compact b/contracts/src/multisig/examples/SignatureTreasury.compact index 3b9a97de5..88a9dc686 100644 --- a/contracts/src/multisig/examples/SignatureTreasury.compact +++ b/contracts/src/multisig/examples/SignatureTreasury.compact @@ -15,10 +15,10 @@ pragma language_version >= 0.23.0; * and sends in a single transaction. A monotonic `_nonce` binds each spend to a * unique message hash for replay protection. * - * Import this module at the contract root and wrap it in a thin preset (see - * `presets/NativeShieldedStatelessTreasury`), or compose it with other + * Import this module at the contract root and wrap it in a thin top-level + * contract that supplies a constructor and delegates, or compose it with other * root modules that import the same `../EcdsaSignerManager` to share one - * signer registry (see `presets/NativeShieldedTokenVault`). + * signer registry. */ module SignatureTreasury { import CompactStandardLibrary; diff --git a/contracts/src/multisig/presets/NativeShieldedMintBurn.compact b/contracts/src/multisig/presets/NativeShieldedMintBurn.compact deleted file mode 100644 index 9ca50d48e..000000000 --- a/contracts/src/multisig/presets/NativeShieldedMintBurn.compact +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/NativeShieldedMintBurn.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedMintBurn (formerly ShieldedMultiSigV3) - * @description Example preset: a deployable multisig token contract. Both mint - * and burn require threshold ECDSA authorization; no single party can create or - * destroy supply. Non-transferable (no transfer/execute surface). - * - * Thin wrapper that composes a single root module, `SignatureMintBurn`. All - * behavior lives there; this contract only supplies a constructor and delegates. - * For the combined mint/burn + treasury variant, see `NativeShieldedTokenVault`. - * - * @notice DEPRECATION: the underlying mint/burn is a stopgap. From `0.3.0-alpha` - * it is superseded by the reusable Shielded Native Token standard (with a - * pluggable multisig access layer), OpenZeppelin/compact-contracts#544. Prefer - * that standard once available; do not build new dependents on this. - */ - -import CompactStandardLibrary; - -import "../examples/SignatureMintBurn" prefix Token_; -// For testing -export { ZswapCoinPublicKey }; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys with 3 signer commitments and a threshold of 2. - * `tokenDomain` derives this contract's token color via - * `tokenType(tokenDomain, kernel.self())`; `initCoinNonce` seeds the mint - * coin-nonce chain. Both `instanceSalt` and `initCoinNonce` must be random. - * - * @param {Bytes<32>} instanceSalt - Random salt for signer commitment derivation. - * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. - * @param {Bytes<32>} tokenDomain - Domain used to derive this contract's token color. - * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. - */ -constructor( - instanceSalt: Bytes<32>, - initCoinNonce: Bytes<32>, - tokenDomain: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, -) { - Token_initialize<3>(instanceSalt, signerCommitments, 2); - Token_initializeToken(tokenDomain, initCoinNonce); -} - -// ─── Circuits (delegated to SignatureMintBurn) ────────────────── - -export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_mint(amount, recipient, pubkeys, signatures); -} - -export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_burn(coin, amount, pubkeys, signatures); -} - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Token__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return Token_getNonce(); -} - -export circuit getTokenDomain(): Bytes<32> { - return Token_getTokenDomain(); -} - -export circuit getTokenType(): Bytes<32> { - return Token_getTokenType(); -} - -export circuit getSignerCount(): Uint<8> { - return Token_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Token_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Token_isSigner(commitment); -} diff --git a/contracts/src/multisig/presets/NativeShieldedProposal.compact b/contracts/src/multisig/presets/NativeShieldedProposal.compact deleted file mode 100644 index 442e9a603..000000000 --- a/contracts/src/multisig/presets/NativeShieldedProposal.compact +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/NativeShieldedProposal.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedProposal (formerly ShieldedMultiSig) - * @description Example preset: a deployable multisig that governs a shielded - * treasury through an on-chain proposal lifecycle (create / approve / revoke / - * execute), authorized by caller identity. - * - * Thin wrapper that composes a single root module, `ProposalTreasury`. All - * behavior lives there; this contract only supplies a constructor and delegates. - * Authorization is by the on-chain caller (not off-chain signatures), so this - * preset is independent of the signature-based modules. - */ - -import CompactStandardLibrary; - -import "../examples/ProposalTreasury" prefix Proposal_; -import "../proposal/ProposalManager" prefix ProposalManager_; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys the multisig with 3 signers and a threshold. - * - * @param {Vector<3, Either>} signers - Signer set. - * @param {Uint<8>} thresh - Minimum approvals required. - */ -constructor( - signers: Vector<3, Either>, - thresh: Uint<8> -) { - Proposal_initialize<3>(signers, thresh); -} - -// ─── Circuits (delegated to ProposalTreasury) ─────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Proposal_deposit(coin); -} - -export circuit createShieldedProposal( - to: ProposalManager_Recipient, - color: Bytes<32>, - amount: Uint<128> -): Uint<64> { - return Proposal_createShieldedProposal(to, color, amount); -} - -export circuit approveProposal(id: Uint<64>): [] { - Proposal_approveProposal(id); -} - -export circuit revokeApproval(id: Uint<64>): [] { - Proposal_revokeApproval(id); -} - -export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { - return Proposal_executeShieldedProposal(id); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit isProposalApprovedBySigner( - id: Uint<64>, - signer: Either -): Boolean { - return Proposal_isProposalApprovedBySigner(id, signer); -} - -export circuit getApprovalCount(id: Uint<64>): Uint<8> { - return Proposal_getApprovalCount(id); -} - -export circuit getProposal(id: Uint<64>): ProposalManager_Proposal { - return Proposal_getProposal(id); -} - -export circuit getProposalRecipient(id: Uint<64>): ProposalManager_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>): ProposalManager_ProposalStatus { - return Proposal_getProposalStatus(id); -} - -export circuit getTokenBalance(color: Bytes<32>): Uint<128> { - return Proposal_getTokenBalance(color); -} - -export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { - return Proposal_getReceivedTotal(color); -} - -export circuit getSentTotal(color: Bytes<32>): Uint<128> { - return Proposal_getSentTotal(color); -} - -export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { - return Proposal_getReceivedMinusSent(color); -} - -export circuit getSignerCount(): Uint<8> { - return Proposal_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Proposal_getThreshold(); -} - -export circuit isSigner(account: Either): Boolean { - return Proposal_isSigner(account); -} diff --git a/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact b/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact deleted file mode 100644 index 25df5c6c9..000000000 --- a/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/NativeShieldedStatelessTreasury.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedStatelessTreasury (formerly ShieldedMultiSigV2) - * @description Example preset: a deployable 2-of-3 signature multisig over a - * stateless shielded treasury. - * - * Thin wrapper that composes a single root module, `SignatureTreasury` - * (signature-authorized spend over `NativeShieldedTreasuryStateless`). All behavior - * lives in the module; this contract only supplies a constructor and delegates, - * demonstrating how to deploy that module on its own. For the combined - * mint/burn + treasury variant, see `NativeShieldedTokenVault`. - */ - -import CompactStandardLibrary; - -import "../examples/SignatureTreasury" prefix Treasury_; -import "../proposal/ProposalManager" prefix Proposal_; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys the multisig with 3 signer commitments and a threshold. - * Each commitment is `persistentHash(pk, instanceSalt, "multisig:signer:")`, - * computed off-chain via `_calculateSignerId`. - * - * Requirements: - * - * - `thresh` must be > 0 and <= 2 (matches the 2-signature `execute` surface). - * - `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>, -) { - assert( - thresh <= 2, - "NativeShieldedStatelessTreasury: threshold cannot exceed 2 (execute verifies at most 2 signatures)" - ); - Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); -} - -// ─── Circuits (delegated to SignatureTreasury) ────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury_deposit(coin); -} - -export circuit execute( - to: Proposal_Recipient, - amount: Uint<128>, - coin: QualifiedShieldedCoinInfo, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): ShieldedSendResult { - return Treasury_execute(to, amount, coin, pubkeys, signatures); -} - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Treasury__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return Treasury_getNonce(); -} - -export circuit getSignerCount(): Uint<8> { - return Treasury_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Treasury_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Treasury_isSigner(commitment); -} diff --git a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact deleted file mode 100644 index a7a33d952..000000000 --- a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/NativeShieldedTokenVault.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedTokenVault - * @description Example preset combining TWO root modules in one contract: - * `SignatureMintBurn` (issue/destroy this contract's own native shielded token) - * and `SignatureTreasury` (custody + signature-authorized spend). This is the - * composition the no-C2C protocol forces: a contract that both mints its own - * token and manages a treasury of it, atomically, under one signer set. - * - * Both modules import the same `../EcdsaSignerManager`, so the compiler - * deduplicates that state into a single signer registry shared by `mint`, `burn`, - * and `execute`. The constructor initializes that shared registry once (via the - * treasury module) and seeds the token state separately. - * - * @notice DEPRECATION: the mint/burn half is a stopgap superseded by the Shielded - * Native Token standard (OpenZeppelin/compact-contracts#544) from `0.3.0-alpha`. - */ - -import CompactStandardLibrary; - -import "../examples/SignatureTreasury" prefix Treasury_; -import "../examples/SignatureMintBurn" prefix Token_; -import "../proposal/ProposalManager" prefix Proposal_; -// For testing -export { ZswapCoinPublicKey }; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys with 3 signer commitments and a threshold. Initializes the - * shared signer registry once (through the treasury module), then seeds the token - * module's own state. - * - * Requirements: - * - * - `thresh` must be > 0 and <= 2 (mint/burn/execute each verify 2 signatures). - * - `signerCommitments` must not contain duplicates. - * - `instanceSalt` and `initCoinNonce` should be cryptographically random. - * - * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. - * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. - * @param {Bytes<32>} tokenDomain - Domain used to derive this contract's token color. - * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. - * @param {Uint<8>} thresh - Minimum approvals required. - */ -constructor( - instanceSalt: Bytes<32>, - initCoinNonce: Bytes<32>, - tokenDomain: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, - thresh: Uint<8>, -) { - assert( - thresh <= 2, - "NativeShieldedTokenVault: threshold cannot exceed 2 (each op verifies at most 2 signatures)" - ); - // Initialize the shared signer registry once, through the treasury module. - Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); - // Seed the token module's own state (no second registry init). - Token_initializeToken(tokenDomain, initCoinNonce); -} - -// ─── Treasury (SignatureTreasury) ─────────────────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury_deposit(coin); -} - -export circuit execute( - to: Proposal_Recipient, - amount: Uint<128>, - coin: QualifiedShieldedCoinInfo, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): ShieldedSendResult { - return Treasury_execute(to, amount, coin, pubkeys, signatures); -} - -// ─── Token (SignatureMintBurn) ────────────────────────────────── - -export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_mint(amount, recipient, pubkeys, signatures); -} - -export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_burn(coin, amount, pubkeys, signatures); -} - -// ─── Signature Verification ───────────────────────────────────── - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Treasury__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getExecuteNonce(): Uint<64> { - return Treasury_getNonce(); -} - -export circuit getTokenNonce(): Uint<64> { - return Token_getNonce(); -} - -export circuit getTokenDomain(): Bytes<32> { - return Token_getTokenDomain(); -} - -export circuit getTokenType(): Bytes<32> { - return Token_getTokenType(); -} - -export circuit getSignerCount(): Uint<8> { - return Treasury_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Treasury_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Treasury_isSigner(commitment); -} From 5b7987863dc112c3c4a1e98e611cf440b8817e89 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:24:23 +0200 Subject: [PATCH 15/17] test(multisig): add NativeShieldedTreasuryStateless coverage Wire the previously-orphan MockShieldedTreasuryStateless to a simulator and spec covering deposit, full/partial send (with change accounting), and the over-send rejection. Uses the shared EmptyWitnesses. --- .../NativeShieldedTreasuryStateless.test.ts | 93 +++++++++++++++++++ ...ativeShieldedTreasuryStatelessSimulator.ts | 66 +++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 contracts/src/multisig/test/NativeShieldedTreasuryStateless.test.ts create mode 100644 contracts/src/multisig/test/simulators/NativeShieldedTreasuryStatelessSimulator.ts diff --git a/contracts/src/multisig/test/NativeShieldedTreasuryStateless.test.ts b/contracts/src/multisig/test/NativeShieldedTreasuryStateless.test.ts new file mode 100644 index 000000000..014015ded --- /dev/null +++ b/contracts/src/multisig/test/NativeShieldedTreasuryStateless.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { NativeShieldedTreasuryStatelessSimulator } from './simulators/NativeShieldedTreasuryStatelessSimulator.js'; + +const COLOR = new Uint8Array(32).fill(1); +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, + }; +} + +function makeQualifiedCoin( + color: Uint8Array, + value: bigint, + mtIndex = 0n, + nonce?: Uint8Array, +): { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; +} { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + mt_index: mtIndex, + }; +} + +let treasury: NativeShieldedTreasuryStatelessSimulator; + +describe('NativeShieldedTreasuryStateless', () => { + beforeEach(() => { + treasury = new NativeShieldedTreasuryStatelessSimulator(); + }); + + describe('_deposit', () => { + it('should accept a deposit without reverting', () => { + expect(() => treasury._deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); + }); + + it('should accept a zero-value deposit', () => { + expect(() => treasury._deposit(makeCoin(COLOR, 0n))).not.toThrow(); + }); + }); + + describe('_send', () => { + beforeEach(() => { + treasury._deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('should send the full coin with no change', () => { + const result = treasury._send( + makeQualifiedCoin(COLOR, AMOUNT), + Z_RECIPIENT, + AMOUNT, + ); + expect(result.sent.value).toEqual(AMOUNT); + expect(result.sent.color).toEqual(COLOR); + expect(result.change.is_some).toEqual(false); + }); + + it('should send a partial amount and return change', () => { + const result = treasury._send( + makeQualifiedCoin(COLOR, AMOUNT), + Z_RECIPIENT, + 400n, + ); + expect(result.sent.value).toEqual(400n); + expect(result.sent.color).toEqual(COLOR); + expect(result.change.is_some).toEqual(true); + expect(result.change.value.value).toEqual(AMOUNT - 400n); + expect(result.change.value.color).toEqual(COLOR); + }); + + it('should reject sending more than the coin holds', () => { + expect(() => + treasury._send(makeQualifiedCoin(COLOR, AMOUNT), Z_RECIPIENT, AMOUNT + 1n), + ).toThrow(); + }); + }); +}); diff --git a/contracts/src/multisig/test/simulators/NativeShieldedTreasuryStatelessSimulator.ts b/contracts/src/multisig/test/simulators/NativeShieldedTreasuryStatelessSimulator.ts new file mode 100644 index 000000000..7a1bdeac1 --- /dev/null +++ b/contracts/src/multisig/test/simulators/NativeShieldedTreasuryStatelessSimulator.ts @@ -0,0 +1,66 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockShieldedTreasuryStateless, +} from '../../../../artifacts/MockShieldedTreasuryStateless/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; + +type EitherRecipient = { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; +}; +type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; +type QualifiedShieldedCoinInfo = { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; +}; +type ShieldedSendResult = { + change: { is_some: boolean; value: ShieldedCoinInfo }; + sent: ShieldedCoinInfo; +}; + +type NativeShieldedTreasuryStatelessArgs = readonly []; + +const NativeShieldedTreasuryStatelessSimulatorBase = createSimulator< + EmptyPrivateState, + ReturnType, + ReturnType, + MockShieldedTreasuryStateless, + NativeShieldedTreasuryStatelessArgs +>({ + contractFactory: (witnesses) => + new MockShieldedTreasuryStateless(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => emptyWitnesses(), +}); + +export class NativeShieldedTreasuryStatelessSimulator extends NativeShieldedTreasuryStatelessSimulatorBase { + constructor( + options: BaseSimulatorOptions< + EmptyPrivateState, + ReturnType + > = {}, + ) { + super([], options); + } + + public _deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure._deposit(coin); + } + + public _send( + coin: QualifiedShieldedCoinInfo, + recipient: EitherRecipient, + amount: bigint, + ): ShieldedSendResult { + return this.circuits.impure._send(coin, recipient, amount); + } +} From fbd9bad8bfe9764899c158784004d4679ff10351 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 19:06:00 +0200 Subject: [PATCH 16/17] feat(multisig): add shielded token preset Add MultisigNativeShieldedToken: a deployable preset composing the NativeShieldedToken standard (core + derived-nonce extension) with EcdsaSignerManager for institutional 2-of-3 ECDSA-authorized mint/burn of a single shielded token. * Coins are contract-owned (mint targets kernel.self()), so there is no coin secret key and no single-sig hop; the mint is constant-shape. * Authorization is by circuit parameters (public keys + signatures), not msg.sender; the preset declares no witnesses. * A dedicated replay nonce is consumed into each op's message hash, and the burn hash binds the spent coin's nonce so a signed burn cannot be redirected to another coin. * Burns are amount-private: the supply extension is intentionally not composed, so no burned-amount cell is published. ECDSA verification remains stubbed and the message hash uses persistentHash pending the Compact ECDSA primitive; not for production deployment until that lands. --- .../MultisigNativeShieldedToken.compact | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 contracts/src/multisig/presets/MultisigNativeShieldedToken.compact diff --git a/contracts/src/multisig/presets/MultisigNativeShieldedToken.compact b/contracts/src/multisig/presets/MultisigNativeShieldedToken.compact new file mode 100644 index 000000000..2210827df --- /dev/null +++ b/contracts/src/multisig/presets/MultisigNativeShieldedToken.compact @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/MultisigNativeShieldedToken.compact) + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +/** + * @title MultisigNativeShieldedToken + * @description A single, deployable preset that composes the Native Shielded + * Token standard (`token/NativeShieldedToken` + the derived-nonce extension) + * with `multisig/EcdsaSignerManager` for institutional M-of-N ECDSA-authorized + * mint and burn of ONE shielded (Zswap) token. Target use: a regulated + * institutional issuer — one omnibus contract, one color, mint/burn only, + * non-transferable, with per-customer balances held OFF-CHAIN in the operator's + * custodial sub-accounts. + * + * This is a top-level deployable contract (not a composable module): the + * constructor wires the three modules together and the exported circuits are + * the institution-facing API. It supersedes the bespoke + * `examples/SignatureMintBurn`, whose hand-rolled token half is replaced by the + * #621 standard. + * + * # Shape (the constraints that force it) + * + * - Coins are CONTRACT-OWNED: `mint` always targets `right(kernel.self())`, + * never a caller-supplied recipient. No coin secret key exists, there is no + * single-sig hop, and the mint is constant-shape. + * - One contract = one color: `tokenType(_domain, kernel.self())` with `_domain` + * sealed at construction. + * - Authorization is cryptographic (a threshold of registered ECDSA signers), + * never `msg.sender`. Public keys and signatures are passed as circuit + * PARAMETERS — there are no witnesses, hence no `witnesses.ts` and no + * off-chain leak surface. + * + * # Privacy posture (Supply extension intentionally NOT composed) + * + * - Burn amounts are AMOUNT-PRIVATE. The bare token's `_burnFromContract` emits + * only commitments and nullifiers; the `disclose()` wrappers it requires are + * compiler permission markers, not disclosure sinks. We deliberately do NOT + * compose `NativeShieldedTokenSupply`, so no `_totalBurned` cell publishes the + * burned amount and there is no on-chain `totalSupply()`. + * - Mint amounts are PUBLIC regardless: `mintShieldedToken` emits the amount in + * the protocol-level `shieldedMints` effect. Reconciliation against the + * off-chain customer ledger uses those public mint effects minus burns. + * - Per-customer balances are NEVER on-chain. + * + * # Replay and coin binding + * + * - `_replayNonce` (a `Counter`) is consumed into every mint/burn `msgHash` and + * incremented, so a threshold-signed authorization is valid for exactly one + * (op, instance, nonce, amount) — and, for burn, one coin. It is a DISTINCT + * cell from the derived coin-nonce chain. + * + * # M-of-N sizing + * + * - This preset is fixed at 2-of-3: it registers 3 signer commitments + * (`Vector<3>` in the constructor) and accepts exactly 2 approvals per op + * (`Vector<2>` in mint/burn). These sizes are compile-time literals (part of + * the verifier key) — a top-level contract cannot be generic over them, and a + * `Vector` size cannot be a runtime constructor argument. The `threshold` IS a + * runtime constructor parameter (a scalar), validated 1 <= threshold <= 3 by + * the registry; for this 2-of-3 shape it must be <= 2 to be satisfiable. A + * different N-of-M shape is a recompiled variant (change the two literals), + * which is also its own verifier key. + * + * @notice ECDSA verification is STUBBED in `EcdsaSignerManager` + * (`stubVerifySignature` always returns true) and the `msgHash` uses + * `persistentHash` rather than `keccak256`. Both stay until the Compact ECDSA + * primitive lands; this contract MUST NOT be deployed to production before the + * destub. The placeholder is acceptable for test/preprod only. + */ + +// #621 token core + derived-nonce extension. Supply extension deliberately omitted. +import "../../token/NativeShieldedToken" prefix Token_; +import "../../token/extensions/NativeShieldedTokenDerivedNonce" prefix TokenNonce_; +// #628 ECDSA M-of-N registry + verification. +import "../EcdsaSignerManager" prefix Signer_; + +// Surface the protocol coin types as named TS artifact aliases so dApp/relayer +// code (the operator's UTXO store) imports them instead of inlining the raw shapes. +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo }; + +// ─── State ────────────────────────────────────────────────────── +// +// The preset owns ONLY the multisig replay nonce. All other ledger cells live +// in the composed modules (prefixed, so no `_isInitialized` collision): +// Token_* : _isInitialized, _domain (sealed), _name/_symbol/_decimals +// TokenNonce_* : _counter, _nonce (coin-nonce chain) +// Signer_* : _instanceSalt, _signers, _signerCount, _threshold, _isInitialized + +/** + * @description Multisig replay nonce. Read into each op's `msgHash` then + * incremented; strictly monotonic over the contract lifetime. Distinct from the + * derived coin-nonce chain. + */ +export ledger _replayNonce: Counter; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Initializes all three composed modules. Each owns an independent + * init flag / seed (compact#270 per-module workaround), so partial + * initialization is possible — this constructor MUST seed all three or the + * first op aborts. + * + * @param {Bytes<32>} domainSep - Token domain separator; with `kernel.self()` + * fixes this contract's immutable color. + * @param {Opaque<"string">} name_ - Token name. + * @param {Opaque<"string">} symbol_ - Token symbol. + * @param {Uint<8>} decimals_ - Display decimals. + * @param {Bytes<32>} instanceSalt - Cryptographically random per-instance salt; + * makes signer commitments cross-instance-unlinkable. + * @param {Vector<3, Bytes<32>>} signerCommitments - The signer commitments + * (computed off-chain via `_calculateSignerId`). No duplicates. + * @param {Uint<8>} threshold - Minimum approvals; 1 <= threshold <= 3. For this + * 2-of-3 shape, must be <= 2 to be satisfiable, since each op presents exactly 2 + * approvals. + * @param {Bytes<32>} initCoinNonce - Random seed for the derived coin-nonce + * chain; non-zero. + */ +constructor( + domainSep: Bytes<32>, + name_: Opaque<"string">, + symbol_: Opaque<"string">, + decimals_: Uint<8>, + instanceSalt: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, + threshold: Uint<8>, + initCoinNonce: Bytes<32> +) { + // _domain is sealed here; the token color is fixed for the contract lifetime. + Token_initialize(domainSep, name_, symbol_, decimals_); + // Seed the coin-nonce chain (non-zero, once). + TokenNonce_initialize(initCoinNonce); + // Threshold validity, no-dup signers, salted commitments. + Signer_initialize<3>(instanceSalt, signerCommitments, threshold); +} + +// ─── Mint ─────────────────────────────────────────────────────── + +/** + * @description Mints `amount` of this contract's token to the contract itself + * (omnibus pool), authorized by a threshold of registered ECDSA signers. + * + * The signers approve `msgHash = H("multisig:mint:" ‖ self ‖ replayNonce ‖ + * amount)`. The recipient is the compile-time constant `right(kernel.self())` + * and is NOT part of the hash or the parameters: the mint is constant-shape and + * cannot be redirected to an external key. + * + * @notice The returned `ShieldedCoinInfo` is the ONLY copy of the minted coin's + * info: contract-initiated sends emit no wallet-scannable ciphertext. The + * operator MUST capture it (with the Zswap output's Merkle index) into its UTXO + * store or the value is irrecoverably stranded. The mint amount is public via + * `shieldedMints` (unavoidable, and needed for off-chain reconciliation). + * + * Requirements: + * + * - All modules initialized. + * - `threshold` valid ECDSA approvals over the mint `msgHash`; pubkeys MUST be + * presented in strictly-increasing commitment order. + * + * @param {Uint<64>} amount - Quantity to mint. Capped at `Uint<64>` by the + * protocol `mintShieldedToken` primitive. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of the approving + * signers, sorted by ascending commitment. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the mint `msgHash`. + * @return {ShieldedCoinInfo} - The newly minted contract-owned coin. + */ +export circuit mint( + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): ShieldedCoinInfo { + // Consume and advance the replay nonce. + const replayNonce = _replayNonce; + _replayNonce.increment(1); + + // Bind op-domain, instance, replay nonce, and amount. + const msgHash = persistentHash>>([ + pad(32, "multisig:mint:"), + kernel.self().bytes, + replayNonce as Bytes<32>, + amount as Bytes<32> + ]); + + // Threshold of distinct registered signers with valid signatures. + Signer_verify<2>(msgHash, pubkeys, signatures); + + // Recipient is the constant self; derived (recipient-public, moot for self) + // coin nonce. `_mint` enforces init + non-zero recipient. + return Token__mint( + right(kernel.self()), + amount, + TokenNonce__deriveNonce() + ); +} + +// ─── Burn ─────────────────────────────────────────────────────── + +/** + * @description Burns `amount` from a contract-held coin (`QualifiedShieldedCoinInfo` + * supplied by the relayer from the operator's UTXO store), authorized by a threshold + * of registered ECDSA signers. One circuit covers both the whole-coin burn + * (`amount == coin.value`, no change) and the partial burn (`amount < + * coin.value`, change auto-received by the contract and returned). + * + * The signers approve `msgHash = H("multisig:burn:" ‖ self ‖ replayNonce ‖ + * amount ‖ coin.nonce)`. The distinct `"multisig:burn:"` domain prevents replay + * as a mint; `coin.nonce` binds the authorization to the exact coin the signers + * approved, so the relayer cannot redirect it to a different coin. + * + * @notice Burn is AMOUNT-PRIVATE (Supply not composed): no public cell records + * the burned value. The returned change coin (if any) is the only copy of its + * info and MUST be captured by the operator. Double-spending the same coin is rejected + * by Zswap's nullifier set at the protocol layer. + * + * Requirements: + * + * - All modules initialized. + * - `threshold` valid ECDSA approvals over the burn `msgHash`; pubkeys MUST be + * presented in strictly-increasing commitment order. + * - `coin.color` is this contract's token color (enforced by `_burnFromContract`). + * - `coin.value >= amount` (enforced by `_burnFromContract`). + * + * @param {QualifiedShieldedCoinInfo} coin - The contract-held coin to burn from. + * @param {Uint<128>} amount - Value to destroy. Capped at `Uint<128>` by + * `sendShielded`. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of the approving + * signers, sorted by ascending commitment. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the burn `msgHash`. + * @return {Maybe} - The change coin retained by the contract, + * or `none` if burned in full. + */ +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<128>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): Maybe { + // Consume and advance the replay nonce. + const replayNonce = _replayNonce; + _replayNonce.increment(1); + + // Bind op-domain, instance, replay nonce, amount, AND the coin's nonce. + const msgHash = persistentHash>>([ + pad(32, "multisig:burn:"), + kernel.self().bytes, + replayNonce as Bytes<32>, + amount as Bytes<32>, + coin.nonce + ]); + + // Threshold of distinct registered signers with valid signatures. + Signer_verify<2>(msgHash, pubkeys, signatures); + + // Color + sufficiency enforced inside _burnFromContract; amount-private (no + // supply accounting). + return Token__burnFromContract(coin, amount); +} + +// ─── Views ────────────────────────────────────────────────────── + +/** + * @description Returns this token's coin color, `tokenType(_domain, self())`. + * @return {Bytes<32>} - The immutable coin color. + */ +export circuit tokenColor(): Bytes<32> { + return Token_tokenColor(); +} + +/** + * @description Returns the current multisig replay nonce (next op binds it). + * @return {Uint<64>} - The replay nonce. + */ +export circuit getNonce(): Uint<64> { + return _replayNonce; +} + +/** + * @description Returns the number of registered signers (3 for this preset). + * @return {Uint<8>} - The signer count. + */ +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +/** + * @description Returns the approval threshold, fixed at construction. + * @return {Uint<8>} - The threshold. + */ +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +/** + * @description Returns whether `commitment` is a registered signer. + * @param {Bytes<32>} commitment - The commitment to check. + * @return {Boolean} - True if registered. + */ +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signer_isSigner(commitment); +} + +/** + * @description Computes a signer commitment from an ECDSA public key and the + * instance salt. Pure — the deployer calls it off-chain to compute the + * constructor commitments. + * @param {Bytes<64>} pk - The ECDSA public key. + * @param {Bytes<32>} salt - The instance salt. + * @return {Bytes<32>} - The signer commitment. + */ +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signer__calculateSignerId(pk, salt); +} From 6ecbb5e00c5a48e86b12d2614d23f7a0f3c1961a Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 19:51:22 +0200 Subject: [PATCH 17/17] refactor(multisig): move examples to integration Move the multisig examples (SignatureMintBurn, SignatureTreasury, ProposalTreasury) out of src/multisig/examples/ into test/integration/_examples/, converting each from a module (+ test mock) into a self-contained top-level contract. They stay as usage examples and now sit where they can double as integration-test fixtures (composing production modules into one deployable contract). Remove the now-redundant unit tests, simulators, and mocks for the three. Integration tests are not added here; they are tracked in #630. Also repoint the preset's doc cross-reference to the moved example. Refs: #628, #630 --- .../examples/ProposalTreasury.compact | 219 -------- .../examples/SignatureMintBurn.compact | 185 ------ .../examples/SignatureTreasury.compact | 127 ----- .../MultisigNativeShieldedToken.compact | 4 +- .../multisig/test/ProposalTreasury.test.ts | 528 ------------------ .../multisig/test/SignatureMintBurn.test.ts | 429 -------------- .../multisig/test/SignatureTreasury.test.ts | 226 -------- .../test/mocks/MockProposalTreasury.compact | 109 ---- .../test/mocks/MockSignatureMintBurn.compact | 76 --- .../test/mocks/MockSignatureTreasury.compact | 61 -- .../simulators/ProposalTreasurySimulator.ts | 155 ----- .../simulators/SignatureMintBurnSimulator.ts | 117 ---- .../simulators/SignatureTreasurySimulator.ts | 99 ---- .../_mocks/MultisigProposalTreasury.compact | 218 ++++++++ .../_mocks/MultisigSignatureMintBurn.compact | 174 ++++++ .../_mocks/MultisigSignatureTreasury.compact | 128 +++++ 16 files changed, 522 insertions(+), 2333 deletions(-) delete mode 100644 contracts/src/multisig/examples/ProposalTreasury.compact delete mode 100644 contracts/src/multisig/examples/SignatureMintBurn.compact delete mode 100644 contracts/src/multisig/examples/SignatureTreasury.compact delete mode 100644 contracts/src/multisig/test/ProposalTreasury.test.ts delete mode 100644 contracts/src/multisig/test/SignatureMintBurn.test.ts delete mode 100644 contracts/src/multisig/test/SignatureTreasury.test.ts delete mode 100644 contracts/src/multisig/test/mocks/MockProposalTreasury.compact delete mode 100644 contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact delete mode 100644 contracts/src/multisig/test/mocks/MockSignatureTreasury.compact delete mode 100644 contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts delete mode 100644 contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts delete mode 100644 contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts create mode 100644 contracts/test/integration/_mocks/MultisigProposalTreasury.compact create mode 100644 contracts/test/integration/_mocks/MultisigSignatureMintBurn.compact create mode 100644 contracts/test/integration/_mocks/MultisigSignatureTreasury.compact diff --git a/contracts/src/multisig/examples/ProposalTreasury.compact b/contracts/src/multisig/examples/ProposalTreasury.compact deleted file mode 100644 index a62b6c663..000000000 --- a/contracts/src/multisig/examples/ProposalTreasury.compact +++ /dev/null @@ -1,219 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/ProposalTreasury.compact) - -pragma language_version >= 0.23.0; - -/** - * @module ProposalTreasury - * @description Composable multisig behavior: on-chain proposal governance over a - * shielded treasury, authorized by caller identity. Formerly the body of - * `ShieldedMultiSig`. - * - * Composes `SignerManager>`, - * `ProposalManager`, and `NativeShieldedTreasury`. Signers create, approve, and revoke - * proposals; once the threshold is met, `executeShieldedProposal` transfers from - * the treasury. Unlike the signature-based modules, authorization is by the - * on-chain caller (`getCaller`), not off-chain signatures — so it does NOT use - * `EcdsaSignerManager` (the signature entrance) and cannot share a registry with it. - * - * @notice Signer identity uses `Either` for - * forward compatibility. Today only `left(ZswapCoinPublicKey)` callers can - * authenticate — `getCaller()` resolves via `ownPublicKey()` and cannot produce - * a right-variant. Contract-address signers may be registered but cannot exercise - * governance until contract-to-contract calls exist. The broad state shape lets - * `getCaller()` be swapped via a CMA circuit upgrade later without a state migration. - */ -module ProposalTreasury { - import CompactStandardLibrary; - import "../proposal/ProposalManager" prefix Proposal_; - import "../treasury/NativeShieldedTreasury" prefix Treasury_; - import "../SignerManager"> prefix Signer_; - - // ─── State ────────────────────────────────────────────────────── - - export ledger _proposalApprovals: Map, Map, Boolean>>; - export ledger _approvalCount: Map, Uint<8>>; - - // ─── Setup ────────────────────────────────────────────────────── - - /** - * @description Initializes the signer registry. Call once from the consuming - * contract's constructor. - * - * @param {Vector>} signers - Signer set. - * @param {Uint<8>} thresh - Minimum approvals required. - * @returns {[]} Empty tuple. - */ - export circuit initialize<#n>( - signers: Vector>, - thresh: Uint<8> - ): [] { - Signer_initialize(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); - - assert( - to.kind == Proposal_RecipientKind.ShieldedUser - || to.kind == Proposal_RecipientKind.Contract, - "ProposalTreasury: recipient must be a shielded user or contract" - ); - - return Proposal__createProposal(to, color, amount); - } - - export circuit approveProposal(id: Uint<64>): [] { - Proposal_assertProposalActive(id); - - const callerPK = getCaller(); - Signer_assertSigner(callerPK); - - assert(!isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: already approved"); - - _approveProposal(id, callerPK); - } - - export circuit revokeApproval(id: Uint<64>): [] { - Proposal_assertProposalActive(id); - - const callerPK = getCaller(); - Signer_assertSigner(callerPK); - - assert(isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: not approved"); - - _revokeApproval(id, callerPK); - } - - export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { - Proposal_assertProposalActive(id); - - const approvalCount = getApprovalCount(id); - Signer_assertThresholdMet(approvalCount); - - const { to, color, amount } = Proposal_getProposal(id); - const result = Treasury__send( - Proposal_toShieldedRecipient(to), - color, - amount, - ); - - 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)); - } - - /** - * @description Returns the caller identity used for signer authentication. - * - * @warning Resolves callers via `ownPublicKey()` only, so a `right(ContractAddress)` - * signer cannot authenticate today. The `Either` shape is kept so `getCaller()` - * can be swapped via a CMA circuit upgrade once contract-to-contract calls exist. - * - * @returns {Either} The caller as a left-variant. - */ - 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)); - } - - 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 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); - } - - 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/examples/SignatureMintBurn.compact b/contracts/src/multisig/examples/SignatureMintBurn.compact deleted file mode 100644 index d0b9dfb1f..000000000 --- a/contracts/src/multisig/examples/SignatureMintBurn.compact +++ /dev/null @@ -1,185 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/examples/SignatureMintBurn.compact) - -pragma language_version >= 0.23.0; - -/** - * @module SignatureMintBurn - * @description Composable multisig behavior: signature-authorized mint/burn of a - * native shielded token issued by the consuming contract. Formerly the body of - * `ShieldedMultiSigV3`. - * - * `mint` creates a UTXO of this contract's token type via `mintShieldedToken`; - * `burn` consumes one via `sendShielded` to `shieldedBurnAddress()`. Both require - * threshold ECDSA approval verified against the shared `EcdsaSignerManager` - * registry. A counter provides replay protection and feeds `evolveNonce` for - * unique coin nonces. Operation-domain prefixes (`multisig:mint:` / - * `multisig:burn:`) stop a signature for one op being replayed as the other. - * - * Initialization is split so this module can be composed: `initialize` seeds the - * shared signer registry, while `initializeToken` seeds this module's own token - * state. A combined contract calls another module's `initialize` once for the - * shared registry, then this module's `initializeToken`. - * - * @notice DEPRECATION: this is a stopgap. From `0.3.0-alpha` it is superseded by - * the reusable Shielded Native Token standard (with a pluggable multisig access - * layer), OpenZeppelin/compact-contracts#544. Prefer that standard once available. - * - * @notice ECDSA verification is stubbed in `EcdsaSignerManager`. Replace it (and - * `persistentHash` with `keccak256`) once the Compact primitives are available. - */ -module SignatureMintBurn { - import CompactStandardLibrary; - import "../EcdsaSignerManager" prefix Signer_; - import "../../utils/Utils" prefix Utils_; - - // ─── State ────────────────────────────────────────────────────── - - export ledger _counter: Counter; - export ledger _coinNonce: Bytes<32>; - export sealed ledger _tokenDomain: Bytes<32>; - - // ─── Setup ────────────────────────────────────────────────────── - - /** - * @description Initializes the shared signer registry and instance salt. Call - * once per contract. In a combined contract, call this on exactly one module. - * - * @param {Bytes<32>} salt - Random salt for commitment derivation. - * @param {Vector>} signers - Signer commitments. - * @param {Uint<8>} thresh - Minimum approvals required. - * @returns {[]} Empty tuple. - */ - export circuit initialize<#n>( - salt: Bytes<32>, - signers: Vector>, - thresh: Uint<8> - ): [] { - Signer_initialize(salt, signers, thresh); - } - - /** - * @description Seeds this module's token state, independent of the signer - * registry. Call once from the consuming contract's constructor. - * - * @param {Bytes<32>} tokenDomain - Domain used with `kernel.self()` to derive - * this contract's token color. - * @param {Bytes<32>} initCoinNonce - Initial coin-nonce seed (random). - * @returns {[]} Empty tuple. - */ - export circuit initializeToken(tokenDomain: Bytes<32>, initCoinNonce: Bytes<32>): [] { - _tokenDomain = disclose(tokenDomain); - _coinNonce = disclose(initCoinNonce); - } - - // ─── Mint ─────────────────────────────────────────────────────── - - /** - * @description Mints a new shielded coin of this contract's token type to the - * recipient, authorized by threshold signatures. The message hash commits to - * the `multisig:mint:` domain, contract address, recipient, counter, and amount. - * - * @param {Uint<64>} amount - The token amount to mint. - * @param {Either} recipient - Recipient. - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - Signatures over the mint hash. - * @returns {[]} Empty tuple. - */ - export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> - ): [] { - const opNonce = _counter; - _counter.increment(1); - - const canonRecipient = Utils_canonicalize(recipient); - const recipientHash = persistentHash>(canonRecipient); - - const msgHash = persistentHash>>([ - pad(32, "multisig:mint:"), - kernel.self().bytes, - recipientHash, - opNonce as Bytes<32>, - amount as Bytes<32> - ]); - - Signer_verify<2>(msgHash, pubkeys, signatures); - - _coinNonce = evolveNonce(_counter, _coinNonce); - mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); - } - - // ─── Burn ─────────────────────────────────────────────────────── - - /** - * @description Burns a coin of this contract's token type to - * `shieldedBurnAddress()`, authorized by threshold signatures. Change from a - * partial burn is handled by the transaction layer. The `multisig:burn:` domain - * prefix prevents replay as a mint. - * - * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (operator pool). - * @param {Uint<64>} amount - The token amount to burn. - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - Signatures over the burn hash. - * @returns {[]} Empty tuple. - */ - export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> - ): [] { - const opNonce = _counter; - _counter.increment(1); - - const msgHash = persistentHash>>([ - pad(32, "multisig:burn:"), - kernel.self().bytes, - opNonce as Bytes<32>, - amount as Bytes<32> - ]); - - Signer_verify<2>(msgHash, pubkeys, signatures); - - assert(coin.color == tokenType(_tokenDomain, kernel.self()), "SignatureMintBurn: coin not from this contract"); - assert(coin.value >= amount, "SignatureMintBurn: insufficient coin value"); - - sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); - } - - // ─── View ─────────────────────────────────────────────────────── - - /** - * @description Computes a signer commitment from an ECDSA public key. Pure — - * callable off-chain by the deployer. - */ - export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Signer__calculateSignerId(pk, salt); - } - - export circuit getNonce(): Uint<64> { - return _counter; - } - - export circuit getTokenDomain(): Bytes<32> { - return _tokenDomain; - } - - export circuit getTokenType(): Bytes<32> { - return tokenType(_tokenDomain, kernel.self()); - } - - export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); - } - - export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); - } - - export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signer_isSigner(commitment); - } -} diff --git a/contracts/src/multisig/examples/SignatureTreasury.compact b/contracts/src/multisig/examples/SignatureTreasury.compact deleted file mode 100644 index 88a9dc686..000000000 --- a/contracts/src/multisig/examples/SignatureTreasury.compact +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/SignatureTreasury.compact) - -pragma language_version >= 0.23.0; - -/** - * @module SignatureTreasury - * @description Composable multisig behavior: signature-authorized, single-tx - * spend from a stateless shielded treasury. Formerly the body of - * `ShieldedMultiSigV2`. - * - * Combines `EcdsaSignerManager` (commitment signer registry + threshold ECDSA - * verification) with `NativeShieldedTreasuryStateless` (custody + send of native - * shielded tokens). Approvals are collected off-chain; `execute` verifies them - * and sends in a single transaction. A monotonic `_nonce` binds each spend to a - * unique message hash for replay protection. - * - * Import this module at the contract root and wrap it in a thin top-level - * contract that supplies a constructor and delegates, or compose it with other - * root modules that import the same `../EcdsaSignerManager` to share one - * signer registry. - */ -module SignatureTreasury { - import CompactStandardLibrary; - import "../EcdsaSignerManager" prefix Signer_; - import "../treasury/NativeShieldedTreasuryStateless" prefix Treasury_; - import "../proposal/ProposalManager" prefix Proposal_; - - // ─── State ────────────────────────────────────────────────────── - - export ledger _nonce: Counter; - - // ─── Setup ────────────────────────────────────────────────────── - - /** - * @description Initializes the shared signer registry and instance salt. - * Call once from the consuming contract's constructor. - * - * @param {Bytes<32>} salt - Random salt for commitment derivation. - * @param {Vector>} signers - Signer commitments. - * @param {Uint<8>} thresh - Minimum approvals required. - * @returns {[]} Empty tuple. - */ - export circuit initialize<#n>( - salt: Bytes<32>, - signers: Vector>, - thresh: Uint<8> - ): [] { - Signer_initialize(salt, signers, thresh); - } - - // ─── Deposit ──────────────────────────────────────────────────── - - /** - * @description Receives a shielded coin into the treasury. No access control; - * anyone may deposit. No coin data is stored on the public ledger. - * - * @param {ShieldedCoinInfo} coin - The incoming shielded coin. - * @returns {[]} Empty tuple. - */ - export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury__deposit(coin); - } - - // ─── Execute ──────────────────────────────────────────────────── - - /** - * @description Executes a shielded send authorized by threshold signatures. - * Reads and increments the nonce, reconstructs the off-chain message hash - * `persistentHash(nonce, recipient address, coin color, amount)`, verifies the - * signatures against the shared registry, then sends from the treasury. - * - * @param {Proposal_Recipient} to - The recipient. - * @param {Uint<128>} amount - The amount to send. - * @param {QualifiedShieldedCoinInfo} coin - The coin to spend (operator pool). - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - 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 { - const currentNonce = _nonce; - _nonce.increment(1); - - const msgHash = persistentHash>>([ - currentNonce as Bytes<32>, - to.address, - coin.color, - amount as Bytes<32> - ]); - - Signer_verify<2>(msgHash, pubkeys, signatures); - - return Treasury__send(coin, Proposal_toShieldedRecipient(to), amount); - } - - // ─── View ─────────────────────────────────────────────────────── - - /** - * @description Computes a signer commitment from an ECDSA public key. Pure — - * callable off-chain by the deployer. - */ - export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Signer__calculateSignerId(pk, salt); - } - - 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/presets/MultisigNativeShieldedToken.compact b/contracts/src/multisig/presets/MultisigNativeShieldedToken.compact index 2210827df..918d3030d 100644 --- a/contracts/src/multisig/presets/MultisigNativeShieldedToken.compact +++ b/contracts/src/multisig/presets/MultisigNativeShieldedToken.compact @@ -18,8 +18,8 @@ import CompactStandardLibrary; * This is a top-level deployable contract (not a composable module): the * constructor wires the three modules together and the exported circuits are * the institution-facing API. It supersedes the bespoke - * `examples/SignatureMintBurn`, whose hand-rolled token half is replaced by the - * #621 standard. + * `test/integration/_mocks/MultisigSignatureMintBurn`, whose hand-rolled token half + * is replaced by the #621 standard. * * # Shape (the constraints that force it) * diff --git a/contracts/src/multisig/test/ProposalTreasury.test.ts b/contracts/src/multisig/test/ProposalTreasury.test.ts deleted file mode 100644 index 7782f7294..000000000 --- a/contracts/src/multisig/test/ProposalTreasury.test.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ProposalTreasurySimulator } from './simulators/ProposalTreasurySimulator.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: ProposalTreasurySimulator; - -describe('ProposalTreasury', () => { - describe('constructor', () => { - it('should initialize with signers and threshold', () => { - multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); - expect(multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); - expect(multisig.getThreshold()).toEqual(THRESHOLD); - }); - - it('should register all signers', () => { - multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); - for (const signer of SIGNERS) { - expect(multisig.isSigner(signer)).toEqual(true); - } - }); - - it('should reject non-signers', () => { - multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); - expect(multisig.isSigner(Z_NON_SIGNER)).toEqual(false); - }); - - it('should fail with zero threshold', () => { - expect(() => { - new ProposalTreasurySimulator(SIGNERS, 0n); - }).toThrow('SignerManager: threshold must not be zero'); - }); - - it('should fail with threshold exceeding signer count', () => { - expect(() => { - new ProposalTreasurySimulator(SIGNERS, 4n); - }).toThrow('SignerManager: threshold exceeds signer count'); - }); - }); - - describe('when initialized', () => { - beforeEach(() => { - multisig = new ProposalTreasurySimulator(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'); - }); - - it('should reject UnshieldedUser recipient kind', () => { - const to = { - kind: RecipientKind.UnshieldedUser, - address: Z_RECIPIENT_PK.bytes, - }; - expect(() => { - multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - }).toThrow( - 'ProposalTreasury: recipient must be a shielded user or contract', - ); - }); - - it('should accept Contract recipient kind', () => { - const to = { - kind: RecipientKind.Contract, - address: new Uint8Array(32).fill(7), - }; - const id = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect(id).toEqual(1n); - expect(multisig.getProposalRecipient(id).kind).toEqual( - RecipientKind.Contract, - ); - }); - }); - - 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('ProposalTreasury: 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('ProposalTreasury: 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/SignatureMintBurn.test.ts b/contracts/src/multisig/test/SignatureMintBurn.test.ts deleted file mode 100644 index f6f63d0f8..000000000 --- a/contracts/src/multisig/test/SignatureMintBurn.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { - calculateSignerId, - SignatureMintBurnSimulator, -} from './simulators/SignatureMintBurnSimulator.js'; - -// ─── Fixtures ───────────────────────────────────────────────────── - -const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); -const INIT_COIN_NONCE = new Uint8Array(32).fill(0xbb); -const TOKEN_DOMAIN = new Uint8Array(32); -Buffer.from('smt:token:').copy(TOKEN_DOMAIN); - -const PK1 = new Uint8Array(64).fill(0x11); -const PK2 = new Uint8Array(64).fill(0x22); -const PK3 = new Uint8Array(64).fill(0x33); -const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); - -const COMMITMENT1 = calculateSignerId(PK1, INSTANCE_SALT); -const COMMITMENT2 = calculateSignerId(PK2, INSTANCE_SALT); -const COMMITMENT3 = calculateSignerId(PK3, INSTANCE_SALT); -const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; - -const DUMMY_SIG = new Uint8Array(64).fill(0xff); - -const USER_RECIPIENT = utils.createEitherTestUser('ALICE'); -const CONTRACT_RECIPIENT = utils.createEitherTestContractAddress('TARGET'); - -function makeQualifiedCoin( - color: Uint8Array, - value: bigint, - mtIndex = 0n, - nonce?: Uint8Array, -): { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -} { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - mt_index: mtIndex, - }; -} - -let multisig: SignatureMintBurnSimulator; - -describe('SignatureMintBurn', () => { - describe('constructor', () => { - it('should initialize', () => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - expect(multisig.getSignerCount()).toEqual(3n); - expect(multisig.getThreshold()).toEqual(2n); - }); - - it('should register all signer commitments', () => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - for (const commitment of SIGNER_COMMITMENTS) { - expect(multisig.isSigner(commitment)).toEqual(true); - } - }); - - it('should reject a non-signer commitment', () => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - const unknown = multisig._calculateSignerId(NON_SIGNER_PK, INSTANCE_SALT); - expect(multisig.isSigner(unknown)).toEqual(false); - }); - - it('should fail with duplicate signer commitments', () => { - expect(() => { - new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - [COMMITMENT1, COMMITMENT1, COMMITMENT2], - ); - }).toThrow('SignerManager: signer already active'); - }); - - it('should store token domain', () => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - expect(multisig.getTokenDomain()).toEqual(TOKEN_DOMAIN); - }); - }); - - describe('when initialized', () => { - beforeEach(() => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - }); - - describe('view', () => { - it('getNonce should start at 0', () => { - expect(multisig.getNonce()).toEqual(0n); - }); - - it('getSignerCount should return 3', () => { - expect(multisig.getSignerCount()).toEqual(3n); - }); - - it('getThreshold should match constructor arg', () => { - expect(multisig.getThreshold()).toEqual(2n); - }); - - it('getTokenType should return non-zero', () => { - expect(multisig.getTokenType()).not.toEqual(new Uint8Array(32)); - }); - - it('getTokenType should be deterministic', () => { - expect(multisig.getTokenType()).toEqual(multisig.getTokenType()); - }); - }); - - describe('_calculateSignerId', () => { - it('should produce deterministic commitments', () => { - const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - expect(c1).toEqual(c2); - }); - - it('should produce different commitments for different keys', () => { - const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = multisig._calculateSignerId(PK2, INSTANCE_SALT); - expect(c1).not.toEqual(c2); - }); - - it('should produce different commitments for different salts', () => { - const salt2 = new Uint8Array(32).fill(0xcc); - const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = multisig._calculateSignerId(PK1, salt2); - expect(c1).not.toEqual(c2); - }); - - it('should match registered commitments', () => { - expect(multisig._calculateSignerId(PK1, INSTANCE_SALT)).toEqual( - COMMITMENT1, - ); - expect(multisig._calculateSignerId(PK2, INSTANCE_SALT)).toEqual( - COMMITMENT2, - ); - expect(multisig._calculateSignerId(PK3, INSTANCE_SALT)).toEqual( - COMMITMENT3, - ); - }); - }); - - describe('mint', () => { - it('should mint to a user recipient with signers 0 and 1', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - }); - - it('should mint to a user recipient with signers 0 and 2', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - }); - - it('should mint to a user recipient with signers 1 and 2', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK2, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - }); - - it('should mint to a contract recipient', () => { - expect(() => { - multisig.mint( - 100n, - CONTRACT_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - }); - - it('should reject duplicate signer', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK1], - [DUMMY_SIG, DUMMY_SIG], - ); - }).toThrow('EcdsaSignerManager: duplicate signer'); - }); - - it('should reject a non-signer pubkey', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ); - }).toThrow('SignerManager: not a signer'); - }); - - it('should increment nonce after mint', () => { - expect(multisig.getNonce()).toEqual(0n); - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - expect(multisig.getNonce()).toEqual(1n); - }); - - it('should increment nonce on each mint', () => { - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - multisig.mint(200n, USER_RECIPIENT, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); - multisig.mint( - 300n, - CONTRACT_RECIPIENT, - [PK2, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - expect(multisig.getNonce()).toEqual(3n); - }); - - it('should accept zero amount', () => { - expect(() => { - multisig.mint(0n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should prevent replay by incrementing nonce', () => { - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - // Second mint with same params succeeds because nonce is different - // (stub ver doesn't actually check signatures) - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - expect(multisig.getNonce()).toEqual(2n); - }); - }); - - describe('burn', () => { - it('should burn with valid coin and signers 0 and 1', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should burn with signers 0 and 2', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should burn with signers 1 and 2', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 100n, [PK2, PK3], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should burn partial amount', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 50n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should handle zero burn amount', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 0n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should reject duplicate signer', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('EcdsaSignerManager: duplicate signer'); - }); - - it('should reject a non-signer pubkey', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn( - coin, - 100n, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ); - }).toThrow('SignerManager: not a signer'); - }); - - it('should reject wrong token color', () => { - const wrongColor = new Uint8Array(32).fill(0xde); - const coin = makeQualifiedCoin(wrongColor, 100n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignatureMintBurn: coin not from this contract'); - }); - - it('should reject insufficient coin value', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 10n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignatureMintBurn: insufficient coin value'); - }); - - it('should reject when amount exceeds value by 1', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 99n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignatureMintBurn: insufficient coin value'); - }); - - it('should share nonce across mint and burn', () => { - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - expect(multisig.getNonce()).toEqual(1n); - - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - multisig.burn(coin, 50n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); - expect(multisig.getNonce()).toEqual(2n); - }); - }); - - describe('domain separation', () => { - it('should isolate signers across instances with different salts', () => { - const salt2 = new Uint8Array(32).fill(0xcc); - const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = multisig._calculateSignerId(PK1, salt2); - expect(c1).not.toEqual(c2); - }); - - it('should derive different token types with different domains', () => { - const altDomain = new Uint8Array(32); - Buffer.from('alt:token:').copy(altDomain); - - const alt = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - altDomain, - SIGNER_COMMITMENTS, - ); - - expect(multisig.getTokenType()).not.toEqual(alt.getTokenType()); - }); - }); - - describe('nonce', () => { - it('should start at 0', () => { - expect(multisig.getNonce()).toEqual(0n); - }); - - it('should increment monotonically', () => { - for (let i = 0; i < 5; i++) { - multisig.mint(1n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - expect(multisig.getNonce()).toEqual(BigInt(i + 1)); - } - }); - }); - - describe('cross-instance replay', () => { - it('should derive different message hashes for different instances', () => { - const instance2 = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - - // With stub verification, both succeed independently. - // Once real ECDSA is available, a signature produced for one - // instance's message hash must not validate against the other's. - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - instance2.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - - expect(multisig.getNonce()).toEqual(1n); - expect(instance2.getNonce()).toEqual(1n); - }); - }); - }); -}); diff --git a/contracts/src/multisig/test/SignatureTreasury.test.ts b/contracts/src/multisig/test/SignatureTreasury.test.ts deleted file mode 100644 index 592ee639f..000000000 --- a/contracts/src/multisig/test/SignatureTreasury.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { SignatureTreasurySimulator } from './simulators/SignatureTreasurySimulator.js'; - -const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; - -const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; - -const PK1 = new Uint8Array(64).fill(0x11); -const PK2 = new Uint8Array(64).fill(0x22); -const PK3 = new Uint8Array(64).fill(0x33); -const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); - -const COMMITMENT1 = SignatureTreasurySimulator.calculateSignerId( - PK1, - INSTANCE_SALT, -); -const COMMITMENT2 = SignatureTreasurySimulator.calculateSignerId( - PK2, - INSTANCE_SALT, -); -const COMMITMENT3 = SignatureTreasurySimulator.calculateSignerId( - PK3, - INSTANCE_SALT, -); -const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; - -const DUMMY_SIG = new Uint8Array(64).fill(0xff); - -function makeRecipient(address: Uint8Array): { - kind: number; - address: Uint8Array; -} { - return { kind: RecipientKind.ShieldedUser, address }; -} - -function makeCoin( - color: Uint8Array, - value: bigint, - nonce?: Uint8Array, -): { nonce: Uint8Array; color: Uint8Array; value: bigint } { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - }; -} - -function makeQualifiedCoin( - color: Uint8Array, - value: bigint, - mtIndex: bigint, - nonce?: Uint8Array, -): { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -} { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - mt_index: mtIndex, - }; -} - -let multisig: SignatureTreasurySimulator; - -describe('SignatureTreasury', () => { - describe('constructor', () => { - it('should initialize with 2-of-3 threshold', () => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - expect(multisig.getSignerCount()).toEqual(3n); - expect(multisig.getThreshold()).toEqual(2n); - }); - - it('should initialize with 1-of-3 threshold', () => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 1n, - ); - expect(multisig.getThreshold()).toEqual(1n); - }); - - it('should fail with zero threshold', () => { - expect(() => { - new SignatureTreasurySimulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 0n); - }).toThrow('SignerManager: threshold must not be zero'); - }); - - it('should fail with threshold exceeding signer count', () => { - expect(() => { - new SignatureTreasurySimulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 4n); - }).toThrow('SignerManager: threshold exceeds signer count'); - }); - - it('should register all signer commitments', () => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - for (const commitment of SIGNER_COMMITMENTS) { - expect(multisig.isSigner(commitment)).toEqual(true); - } - }); - - it('should reject a non-signer commitment', () => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - const unknown = SignatureTreasurySimulator.calculateSignerId( - NON_SIGNER_PK, - INSTANCE_SALT, - ); - expect(multisig.isSigner(unknown)).toEqual(false); - }); - - it('should fail with duplicate signer commitments', () => { - expect(() => { - new SignatureTreasurySimulator( - INSTANCE_SALT, - [COMMITMENT1, COMMITMENT1, COMMITMENT2], - 2n, - ); - }).toThrow('SignerManager: signer already active'); - }); - }); - - describe('when initialized', () => { - beforeEach(() => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - }); - - describe('view', () => { - it('getNonce should start at 0', () => { - expect(multisig.getNonce()).toEqual(0n); - }); - - it('getSignerCount should return 3', () => { - expect(multisig.getSignerCount()).toEqual(3n); - }); - - it('getThreshold should match constructor arg', () => { - expect(multisig.getThreshold()).toEqual(2n); - }); - }); - - describe('_calculateSignerId', () => { - it('should be deterministic for the same key and salt', () => { - expect( - SignatureTreasurySimulator.calculateSignerId(PK1, INSTANCE_SALT), - ).toStrictEqual(COMMITMENT1); - }); - - it('should produce a different commitment for a different salt', () => { - const otherSalt = new Uint8Array(32).fill(0xcc); - expect( - SignatureTreasurySimulator.calculateSignerId(PK1, otherSalt), - ).not.toStrictEqual(COMMITMENT1); - }); - }); - - describe('deposit', () => { - it('should accept deposits without reverting', () => { - expect(() => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - }).not.toThrow(); - }); - }); - - describe('execute', () => { - it('should reject duplicate signer', () => { - const to = makeRecipient(new Uint8Array(32).fill(7)); - const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); - expect(() => { - multisig.execute(to, 100n, coin, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('EcdsaSignerManager: duplicate signer'); - }); - - it('should reject a non-signer pubkey', () => { - const to = makeRecipient(new Uint8Array(32).fill(7)); - const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); - expect(() => { - multisig.execute( - to, - 100n, - coin, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ); - }).toThrow('SignerManager: not a signer'); - }); - }); - - describe('execute — threshold above the 2-signature surface', () => { - it('should reject when threshold exceeds verifiable signatures', () => { - // A 3-of-3 instance can never satisfy `execute`, which verifies at most - // two signatures. Two valid distinct signers still fall short. - const strict = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 3n, - ); - const to = makeRecipient(new Uint8Array(32).fill(7)); - const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); - expect(() => { - strict.execute(to, 100n, coin, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignerManager: threshold not met'); - }); - }); - }); -}); diff --git a/contracts/src/multisig/test/mocks/MockProposalTreasury.compact b/contracts/src/multisig/test/mocks/MockProposalTreasury.compact deleted file mode 100644 index d10c6a29d..000000000 --- a/contracts/src/multisig/test/mocks/MockProposalTreasury.compact +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. -// This contract exposes the `ProposalTreasury` example module as a deployable -// contract so the simulator can exercise it. DO NOT deploy or use this contract -// in any production application. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; - -import "../../examples/ProposalTreasury" prefix Proposal_; -import "../../proposal/ProposalManager" prefix ProposalManager_; - -// ─── Constructor ──────────────────────────────────────────────── - -constructor( - signers: Vector<3, Either>, - thresh: Uint<8> -) { - Proposal_initialize<3>(signers, thresh); -} - -// ─── Circuits (delegated to ProposalTreasury) ─────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Proposal_deposit(coin); -} - -export circuit createShieldedProposal( - to: ProposalManager_Recipient, - color: Bytes<32>, - amount: Uint<128> -): Uint<64> { - return Proposal_createShieldedProposal(to, color, amount); -} - -export circuit approveProposal(id: Uint<64>): [] { - Proposal_approveProposal(id); -} - -export circuit revokeApproval(id: Uint<64>): [] { - Proposal_revokeApproval(id); -} - -export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { - return Proposal_executeShieldedProposal(id); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit isProposalApprovedBySigner( - id: Uint<64>, - signer: Either -): Boolean { - return Proposal_isProposalApprovedBySigner(id, signer); -} - -export circuit getApprovalCount(id: Uint<64>): Uint<8> { - return Proposal_getApprovalCount(id); -} - -export circuit getProposal(id: Uint<64>): ProposalManager_Proposal { - return Proposal_getProposal(id); -} - -export circuit getProposalRecipient(id: Uint<64>): ProposalManager_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>): ProposalManager_ProposalStatus { - return Proposal_getProposalStatus(id); -} - -export circuit getTokenBalance(color: Bytes<32>): Uint<128> { - return Proposal_getTokenBalance(color); -} - -export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { - return Proposal_getReceivedTotal(color); -} - -export circuit getSentTotal(color: Bytes<32>): Uint<128> { - return Proposal_getSentTotal(color); -} - -export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { - return Proposal_getReceivedMinusSent(color); -} - -export circuit getSignerCount(): Uint<8> { - return Proposal_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Proposal_getThreshold(); -} - -export circuit isSigner(account: Either): Boolean { - return Proposal_isSigner(account); -} diff --git a/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact b/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact deleted file mode 100644 index 41d44b852..000000000 --- a/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. -// This contract exposes the `SignatureMintBurn` example module as a deployable -// contract so the simulator can exercise it. DO NOT deploy or use this contract -// in any production application. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; - -import "../../examples/SignatureMintBurn" prefix Token_; -// For testing -export { ZswapCoinPublicKey }; - -// ─── Constructor ──────────────────────────────────────────────── - -constructor( - instanceSalt: Bytes<32>, - initCoinNonce: Bytes<32>, - tokenDomain: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, -) { - Token_initialize<3>(instanceSalt, signerCommitments, 2); - Token_initializeToken(tokenDomain, initCoinNonce); -} - -// ─── Circuits (delegated to SignatureMintBurn) ────────────────── - -export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_mint(amount, recipient, pubkeys, signatures); -} - -export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_burn(coin, amount, pubkeys, signatures); -} - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Token__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return Token_getNonce(); -} - -export circuit getTokenDomain(): Bytes<32> { - return Token_getTokenDomain(); -} - -export circuit getTokenType(): Bytes<32> { - return Token_getTokenType(); -} - -export circuit getSignerCount(): Uint<8> { - return Token_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Token_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Token_isSigner(commitment); -} diff --git a/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact b/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact deleted file mode 100644 index 8cef871d3..000000000 --- a/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. -// This contract exposes the `SignatureTreasury` example module as a deployable -// contract so the simulator can exercise it. DO NOT deploy or use this contract -// in any production application. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; - -import "../../examples/SignatureTreasury" prefix Treasury_; -import "../../proposal/ProposalManager" prefix Proposal_; - -// ─── Constructor ──────────────────────────────────────────────── - -constructor( - instanceSalt: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, - thresh: Uint<8>, -) { - Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); -} - -// ─── Circuits (delegated to SignatureTreasury) ────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury_deposit(coin); -} - -export circuit execute( - to: Proposal_Recipient, - amount: Uint<128>, - coin: QualifiedShieldedCoinInfo, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): ShieldedSendResult { - return Treasury_execute(to, amount, coin, pubkeys, signatures); -} - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Treasury__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return Treasury_getNonce(); -} - -export circuit getSignerCount(): Uint<8> { - return Treasury_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Treasury_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Treasury_isSigner(commitment); -} diff --git a/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts b/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts deleted file mode 100644 index 1b45041e5..000000000 --- a/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - type Ledger, - ledger, - Contract as MockProposalTreasury, -} from '../../../../artifacts/MockProposalTreasury/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.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 ProposalTreasuryArgs = readonly [ - signers: EitherPKAddress[], - thresh: bigint, -]; - -const ProposalTreasurySimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - MockProposalTreasury, - ProposalTreasuryArgs ->({ - contractFactory: (witnesses) => - new MockProposalTreasury(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (signers, thresh) => [signers, thresh], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class ProposalTreasurySimulator extends ProposalTreasurySimulatorBase { - constructor( - signers: EitherPKAddress[], - thresh: bigint, - options: BaseSimulatorOptions< - EmptyPrivateState, - 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/SignatureMintBurnSimulator.ts b/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts deleted file mode 100644 index 24bd06b1c..000000000 --- a/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - pureCircuits, - Contract as MockSignatureMintBurn, - type ZswapCoinPublicKey, -} from '../../../../artifacts/MockSignatureMintBurn/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; - -type SignatureMintBurnArgs = readonly [ - instanceSalt: Uint8Array, - initCoinNonce: Uint8Array, - tokenDomain: Uint8Array, - signerCommitments: Uint8Array[], -]; - -const SignatureMintBurnSimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - MockSignatureMintBurn, - SignatureMintBurnArgs ->({ - contractFactory: (witnesses) => - new MockSignatureMintBurn(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (instanceSalt, initCoinNonce, tokenDomain, signerCommitments) => [ - instanceSalt, - initCoinNonce, - tokenDomain, - signerCommitments, - ], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class SignatureMintBurnSimulator extends SignatureMintBurnSimulatorBase { - constructor( - instanceSalt: Uint8Array, - initCoinNonce: Uint8Array, - tokenDomain: Uint8Array, - signerCommitments: Uint8Array[], - options: BaseSimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ) { - super( - [instanceSalt, initCoinNonce, tokenDomain, signerCommitments], - options, - ); - } - - public _calculateSignerId(pk: Uint8Array, salt: Uint8Array): Uint8Array { - return this.circuits.pure._calculateSignerId(pk, salt); - } - - public mint( - amount: bigint, - recipient: Either, - pubkeys: Uint8Array[], - signatures: Uint8Array[], - ) { - return this.circuits.impure.mint(amount, recipient, pubkeys, signatures); - } - - public burn( - coin: { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; - }, - amount: bigint, - pubkeys: Uint8Array[], - signatures: Uint8Array[], - ) { - return this.circuits.impure.burn(coin, amount, pubkeys, signatures); - } - - public getNonce(): bigint { - return this.circuits.impure.getNonce(); - } - - public getTokenDomain(): Uint8Array { - return this.circuits.impure.getTokenDomain(); - } - - public getTokenType(): Uint8Array { - return this.circuits.impure.getTokenType(); - } - - public getSignerCount(): bigint { - return this.circuits.impure.getSignerCount(); - } - - public getThreshold(): bigint { - return this.circuits.impure.getThreshold(); - } - - public isSigner(commitment: Uint8Array): boolean { - return this.circuits.impure.isSigner(commitment); - } -} - -// Computes signer commitment from `pk`, `salt`, and -// domain ("multisig:signer:"). Pure standalone circuit so commitments can be -// calculated before contract instantiation. -export function calculateSignerId( - pk: Uint8Array, - salt: Uint8Array, -): Uint8Array { - return pureCircuits._calculateSignerId(pk, salt); -} diff --git a/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts b/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts deleted file mode 100644 index 217862125..000000000 --- a/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - pureCircuits, - Contract as MockSignatureTreasury, -} from '../../../../artifacts/MockSignatureTreasury/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; - -type Recipient = { kind: number; address: Uint8Array }; -type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; -type QualifiedShieldedCoinInfo = { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -}; -type ShieldedSendResult = { - change: { is_some: boolean; value: ShieldedCoinInfo }; - sent: ShieldedCoinInfo; -}; - -type SignatureTreasuryArgs = readonly [ - instanceSalt: Uint8Array, - signerCommitments: Uint8Array[], - thresh: bigint, -]; - -const SignatureTreasurySimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - MockSignatureTreasury, - SignatureTreasuryArgs ->({ - contractFactory: (witnesses) => - new MockSignatureTreasury(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (instanceSalt, signerCommitments, thresh) => [ - instanceSalt, - signerCommitments, - thresh, - ], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class SignatureTreasurySimulator extends SignatureTreasurySimulatorBase { - constructor( - instanceSalt: Uint8Array, - signerCommitments: Uint8Array[], - thresh: bigint, - options: BaseSimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ) { - super([instanceSalt, signerCommitments, thresh], options); - } - - public static calculateSignerId( - pk: Uint8Array, - salt: Uint8Array, - ): Uint8Array { - return pureCircuits._calculateSignerId(pk, salt); - } - - public deposit(coin: ShieldedCoinInfo) { - return this.circuits.impure.deposit(coin); - } - - public execute( - to: Recipient, - amount: bigint, - coin: QualifiedShieldedCoinInfo, - pubkeys: Uint8Array[], - signatures: Uint8Array[], - ): ShieldedSendResult { - return this.circuits.impure.execute(to, amount, coin, pubkeys, signatures); - } - - public getNonce(): bigint { - return this.circuits.impure.getNonce(); - } - - public getSignerCount(): bigint { - return this.circuits.impure.getSignerCount(); - } - - public getThreshold(): bigint { - return this.circuits.impure.getThreshold(); - } - - public isSigner(commitment: Uint8Array): boolean { - return this.circuits.impure.isSigner(commitment); - } -} diff --git a/contracts/test/integration/_mocks/MultisigProposalTreasury.compact b/contracts/test/integration/_mocks/MultisigProposalTreasury.compact new file mode 100644 index 000000000..7b2060a5d --- /dev/null +++ b/contracts/test/integration/_mocks/MultisigProposalTreasury.compact @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (test/integration/_mocks/MultisigProposalTreasury.compact) + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +/** + * @title MultisigProposalTreasury (example) + * @description Example top-level contract: on-chain proposal governance over a + * shielded treasury, authorized by caller identity. Formerly the body of + * `ShieldedMultiSig`. Lives under `test/integration/_mocks/` so it serves as + * both a usage example and an integration-test fixture (composes production + * modules into one deployable contract). + * + * Composes `SignerManager>`, + * `ProposalManager`, and `NativeShieldedTreasury`. Signers create, approve, and + * revoke proposals; once the threshold is met, `executeShieldedProposal` + * transfers from the treasury. Unlike the signature-based examples, authorization + * is by the on-chain caller (`getCaller`), not off-chain signatures. + * + * This example is fixed at a 3-signer registry. + * + * @notice Signer identity uses `Either` for + * forward compatibility. Today only `left(ZswapCoinPublicKey)` callers can + * authenticate — `getCaller()` resolves via `ownPublicKey()` and cannot produce + * a right-variant. Contract-address signers may be registered but cannot exercise + * governance until contract-to-contract calls exist. + */ + +import "../../../src/multisig/proposal/ProposalManager" prefix Proposal_; +import "../../../src/multisig/treasury/NativeShieldedTreasury" prefix Treasury_; +import "../../../src/multisig/SignerManager"> prefix Signer_; + +// ─── State ────────────────────────────────────────────────────── + +export ledger _proposalApprovals: Map, Map, Boolean>>; +export ledger _approvalCount: Map, Uint<8>>; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Initializes the signer registry (3 signers). + * + * @param {Vector<3, Either>} signers - Signer set. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +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); + + assert( + to.kind == Proposal_RecipientKind.ShieldedUser + || to.kind == Proposal_RecipientKind.Contract, + "ProposalTreasury: recipient must be a shielded user or contract" + ); + + return Proposal__createProposal(to, color, amount); +} + +export circuit approveProposal(id: Uint<64>): [] { + Proposal_assertProposalActive(id); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert(!isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: already approved"); + + _approveProposal(id, callerPK); +} + +export circuit revokeApproval(id: Uint<64>): [] { + Proposal_assertProposalActive(id); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert(isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: not approved"); + + _revokeApproval(id, callerPK); +} + +export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { + Proposal_assertProposalActive(id); + + const approvalCount = getApprovalCount(id); + Signer_assertThresholdMet(approvalCount); + + const { to, color, amount } = Proposal_getProposal(id); + const result = Treasury__send( + Proposal_toShieldedRecipient(to), + color, + amount, + ); + + 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)); +} + +/** + * @description Returns the caller identity used for signer authentication. + * + * @warning Resolves callers via `ownPublicKey()` only, so a `right(ContractAddress)` + * signer cannot authenticate today. + * + * @returns {Either} The caller as a left-variant. + */ +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)); +} + +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 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); +} + +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/test/integration/_mocks/MultisigSignatureMintBurn.compact b/contracts/test/integration/_mocks/MultisigSignatureMintBurn.compact new file mode 100644 index 000000000..ebef2e51c --- /dev/null +++ b/contracts/test/integration/_mocks/MultisigSignatureMintBurn.compact @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (test/integration/_mocks/MultisigSignatureMintBurn.compact) + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +/** + * @title MultisigSignatureMintBurn (example) + * @description Example top-level contract: signature-authorized mint/burn of a + * native shielded token issued by this contract. Formerly the body of + * `ShieldedMultiSigV3`. Lives under `test/integration/_mocks/` so it serves + * as both a usage example and an integration-test fixture (composes production + * modules into one deployable contract). + * + * `mint` creates a UTXO of this contract's token type via `mintShieldedToken`; + * `burn` consumes one via `sendShielded` to `shieldedBurnAddress()`. Both require + * threshold ECDSA approval verified against the shared `EcdsaSignerManager` + * registry. A counter provides replay protection and feeds `evolveNonce` for + * unique coin nonces. Operation-domain prefixes (`multisig:mint:` / + * `multisig:burn:`) stop a signature for one op being replayed as the other. + * + * This example is fixed at a 3-signer registry with a threshold of 2 and 2 + * presented approvals per operation. + * + * @notice ECDSA verification is stubbed in `EcdsaSignerManager`; replace it (and + * `persistentHash` with `keccak256`) once the Compact primitives are available. + * Not for production deployment. + */ + +import "../../../src/multisig/EcdsaSignerManager" prefix Signer_; +import "../../../src/utils/Utils" prefix Utils_; + +export { ZswapCoinPublicKey }; + +// ─── State ────────────────────────────────────────────────────── + +export ledger _counter: Counter; +export ledger _coinNonce: Bytes<32>; +export sealed ledger _tokenDomain: Bytes<32>; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Initializes the shared signer registry (3 signers, threshold 2) + * and this contract's token state. + * + * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. + * @param {Bytes<32>} initCoinNonce - Initial coin-nonce seed (random). + * @param {Bytes<32>} tokenDomain - Domain used with `kernel.self()` to derive + * this contract's token color. + * @param {Vector<3, Bytes<32>>} signerCommitments - Signer commitments. + */ +constructor( + instanceSalt: Bytes<32>, + initCoinNonce: Bytes<32>, + tokenDomain: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>> +) { + Signer_initialize<3>(instanceSalt, signerCommitments, 2); + _tokenDomain = disclose(tokenDomain); + _coinNonce = disclose(initCoinNonce); +} + +// ─── Mint ─────────────────────────────────────────────────────── + +/** + * @description Mints a new shielded coin of this contract's token type to the + * recipient, authorized by threshold signatures. The message hash commits to + * the `multisig:mint:` domain, contract address, recipient, counter, and amount. + * + * @param {Uint<64>} amount - The token amount to mint. + * @param {Either} recipient - Recipient. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the mint hash. + * @returns {[]} Empty tuple. + */ +export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + const opNonce = _counter; + _counter.increment(1); + + const canonRecipient = Utils_canonicalize(recipient); + const recipientHash = persistentHash>(canonRecipient); + + const msgHash = persistentHash>>([ + pad(32, "multisig:mint:"), + kernel.self().bytes, + recipientHash, + opNonce as Bytes<32>, + amount as Bytes<32> + ]); + + Signer_verify<2>(msgHash, pubkeys, signatures); + + _coinNonce = evolveNonce(_counter, _coinNonce); + mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); +} + +// ─── Burn ─────────────────────────────────────────────────────── + +/** + * @description Burns a coin of this contract's token type to + * `shieldedBurnAddress()`, authorized by threshold signatures. Change from a + * partial burn is handled by the transaction layer. The `multisig:burn:` domain + * prefix prevents replay as a mint. + * + * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (operator pool). + * @param {Uint<64>} amount - The token amount to burn. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the burn hash. + * @returns {[]} Empty tuple. + */ +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + const opNonce = _counter; + _counter.increment(1); + + const msgHash = persistentHash>>([ + pad(32, "multisig:burn:"), + kernel.self().bytes, + opNonce as Bytes<32>, + amount as Bytes<32> + ]); + + Signer_verify<2>(msgHash, pubkeys, signatures); + + assert(coin.color == tokenType(_tokenDomain, kernel.self()), "SignatureMintBurn: coin not from this contract"); + assert(coin.value >= amount, "SignatureMintBurn: insufficient coin value"); + + sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); +} + +// ─── View ─────────────────────────────────────────────────────── + +/** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer. + */ +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signer__calculateSignerId(pk, salt); +} + +export circuit getNonce(): Uint<64> { + return _counter; +} + +export circuit getTokenDomain(): Bytes<32> { + return _tokenDomain; +} + +export circuit getTokenType(): Bytes<32> { + return tokenType(_tokenDomain, kernel.self()); +} + +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signer_isSigner(commitment); +} diff --git a/contracts/test/integration/_mocks/MultisigSignatureTreasury.compact b/contracts/test/integration/_mocks/MultisigSignatureTreasury.compact new file mode 100644 index 000000000..24021fd46 --- /dev/null +++ b/contracts/test/integration/_mocks/MultisigSignatureTreasury.compact @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (test/integration/_mocks/MultisigSignatureTreasury.compact) + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +/** + * @title MultisigSignatureTreasury (example) + * @description Example top-level contract: signature-authorized, single-tx spend + * from a stateless shielded treasury. Formerly the body of `ShieldedMultiSigV2`. + * Lives under `test/integration/_mocks/` so it serves as both a usage example + * and an integration-test fixture (composes production modules into one + * deployable contract). + * + * Combines `EcdsaSignerManager` (commitment signer registry + threshold ECDSA + * verification) with `NativeShieldedTreasuryStateless` (custody + send of native + * shielded tokens). Approvals are collected off-chain; `execute` verifies them + * and sends in a single transaction. A monotonic `_nonce` binds each spend to a + * unique message hash for replay protection. + * + * This example is fixed at a 3-signer registry and 2 presented approvals per + * operation. + * + * @notice ECDSA verification is stubbed in `EcdsaSignerManager`; replace it once + * the Compact primitive is available. Not for production deployment. + */ + +import "../../../src/multisig/EcdsaSignerManager" prefix Signer_; +import "../../../src/multisig/treasury/NativeShieldedTreasuryStateless" prefix Treasury_; +import "../../../src/multisig/proposal/ProposalManager" prefix Proposal_; + +// ─── State ────────────────────────────────────────────────────── + +export ledger _nonce: Counter; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Initializes the shared signer registry and instance salt. + * + * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. + * @param {Vector<3, Bytes<32>>} signerCommitments - Signer commitments. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +constructor( + instanceSalt: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, + thresh: Uint<8> +) { + Signer_initialize<3>(instanceSalt, signerCommitments, thresh); +} + +// ─── Deposit ──────────────────────────────────────────────────── + +/** + * @description Receives a shielded coin into the treasury. No access control; + * anyone may deposit. No coin data is stored on the public ledger. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * @returns {[]} Empty tuple. + */ +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); +} + +// ─── Execute ──────────────────────────────────────────────────── + +/** + * @description Executes a shielded send authorized by threshold signatures. + * Reads and increments the nonce, reconstructs the off-chain message hash + * `persistentHash(nonce, recipient address, coin color, amount)`, verifies the + * signatures against the shared registry, then sends from the treasury. + * + * @param {Proposal_Recipient} to - The recipient. + * @param {Uint<128>} amount - The amount to send. + * @param {QualifiedShieldedCoinInfo} coin - The coin to spend (operator pool). + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - 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 { + const currentNonce = _nonce; + _nonce.increment(1); + + const msgHash = persistentHash>>([ + currentNonce as Bytes<32>, + to.address, + coin.color, + amount as Bytes<32> + ]); + + Signer_verify<2>(msgHash, pubkeys, signatures); + + return Treasury__send(coin, Proposal_toShieldedRecipient(to), amount); +} + +// ─── View ─────────────────────────────────────────────────────── + +/** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer. + */ +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signer__calculateSignerId(pk, salt); +} + +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); +}