diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/ForwarderPrivate.compact index 7a3daef4..71da91c6 100644 --- a/contracts/src/multisig/ForwarderPrivate.compact +++ b/contracts/src/multisig/ForwarderPrivate.compact @@ -110,7 +110,7 @@ module ForwarderPrivate { * would defeat the private-parent guarantee. A coin public key is encrypted * inside the Zswap output and never appears on the public transcript. * - * @circuitInfo k=16, rows=41961 + * @circuitInfo k=15, rows=23937 * * Requirements: * diff --git a/contracts/src/multisig/ForwarderShielded.compact b/contracts/src/multisig/ForwarderShielded.compact index 10289ad7..879f504f 100644 --- a/contracts/src/multisig/ForwarderShielded.compact +++ b/contracts/src/multisig/ForwarderShielded.compact @@ -103,7 +103,7 @@ module ForwarderShielded { * `sendImmediateShielded` to the stored parent recipient (always the * `left` / coin-public-key arm today). * - * @circuitInfo k=15, rows=18573 + * @circuitInfo k=15, rows=24446 * * Requirements: * diff --git a/contracts/src/multisig/ForwarderUnshielded.compact b/contracts/src/multisig/ForwarderUnshielded.compact index 754935e1..992ff5bb 100644 --- a/contracts/src/multisig/ForwarderUnshielded.compact +++ b/contracts/src/multisig/ForwarderUnshielded.compact @@ -105,7 +105,7 @@ module ForwarderUnshielded { * forwards it to `_parent` via `sendUnshielded` (always the `right` / * user-address arm today). * - * @circuitInfo k=9, rows=436 + * @circuitInfo k=10, rows=565 * * Requirements: * diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/ProposalManager.compact index cdbe4ab9..14a23692 100644 --- a/contracts/src/multisig/ProposalManager.compact +++ b/contracts/src/multisig/ProposalManager.compact @@ -12,12 +12,33 @@ pragma language_version >= 0.23.0; * Recipient type with a RecipientKind tag. Typed helper circuits * provide safe construction of recipients without exposing the * internal Bytes<32> representation to consumers. + * + * @warning PROPOSALS ARE PUBLIC. `_proposals` is exported ledger state, so + * every proposal's recipient address, token color, amount, and status is + * readable off-chain, and `_nextProposalId` reveals how many have been created. + * This holds regardless of which treasury a consumer composes: pairing this + * module with `ShieldedTreasuryStateless` still publishes the full intent of + * every transfer, even though the transfer itself is shielded. The transfer is + * private; the plan is not. + * + * @notice `RecipientKind` is the only thing distinguishing what a recipient's + * 32 bytes mean, the same value is read as a Zswap coin public key, a contract + * address, or a user address depending on the tag. `Contract` is accepted by + * both `toShieldedRecipient` and `toUnshieldedRecipient`, so the tag alone does + * not determine the rail; a consumer composing both treasuries could route the + * same proposal either way. */ module ProposalManager { import CompactStandardLibrary; // ─── Types ────────────────────────────────────────────────────── + /** + * @description Proposal lifecycle states. `Inactive` is the zero value and is + * never stored: `_createProposal` writes `Active`, and every view asserts the + * proposal exists before reading, so a stored proposal can never read + * `Inactive`. + */ export enum ProposalStatus { Inactive, Active, @@ -176,6 +197,19 @@ module ProposalManager { /** * @description Creates a new proposal. * + * @warning The recipient is NOT validated beyond being well-typed. Two + * consequences the consumer must handle: + * + * - A `kind` the execution rail cannot accept (an `UnshieldedUser` recipient + * on a shielded treasury, say) creates a proposal that passes here and then + * reverts in `toShieldedRecipient` at execution. It stays `Active` and + * unexecutable on that rail until `_cancelProposal` retires it. + * - A zero or otherwise unspendable `address` is accepted, and funds sent to + * it on execution are unrecoverable. + * + * @notice `_nextProposalId` is incremented and then read back for the map key, + * making this a pinned read-modify-write. + * * @notice Access control is NOT enforced here. * The consuming contract must gate this behind its own * authorization policy. diff --git a/contracts/src/multisig/ShieldedTreasury.compact b/contracts/src/multisig/ShieldedTreasury.compact index d18b579a..bad12133 100644 --- a/contracts/src/multisig/ShieldedTreasury.compact +++ b/contracts/src/multisig/ShieldedTreasury.compact @@ -18,6 +18,19 @@ pragma language_version >= 0.23.0; * purposes. The canonical balance query is `getTokenBalance`, which * reads the actual coin value from the UTXO map. * + * @warning This treasury's HOLDINGS ARE PUBLIC. `_coins` is exported ledger + * state, so every coin it holds is readable off-chain in plaintext (nonce, + * color, value, and Merkle index) as are the cumulative `_received` and + * `_sent` totals. "Shielded" here refers to the token rail, not to the + * treasury's balance: individual transfers are shielded, but what this + * contract holds is not. Anyone can observe the treasury's balance per color + * and identify the exact UTXO backing it. + * + * Storing the coin is what buys the convenience: the contract can spend later + * without the caller supplying the UTXO. If public holdings are unacceptable, + * use `ShieldedTreasuryStateless`, which stores nothing and has the operator + * supply the coin at spend time. + * * Underscore-prefixed circuits (_deposit, _send) have no access control * enforcement. The consuming contract must gate these behind its own * authorization policy. @@ -42,9 +55,13 @@ module ShieldedTreasury { * The coin is then merged with any existing coin of the same color, * or inserted as a new entry if no coin of that color exists. * - * Zero-value deposits are permitted. While currently a no-op - * economically, they may serve as signaling mechanisms when events - * are supported, or as decoy transactions for privacy. + * Zero-value deposits are permitted and are not always a no-op. On a color the + * treasury does not yet hold, a zero-value coin is inserted and occupies that + * color's single slot until a later deposit merges into it; if a coin of that + * color already exists, the zero merges into it via `mergeCoinImmediate` and + * the stored value is unchanged. They may serve as signaling mechanisms when + * events are supported. They are not useful as privacy decoys, this + * treasury's holdings are public (see the module header). * * @notice Access control is NOT enforced here. * The consuming contract must gate this behind its own @@ -92,6 +109,12 @@ module ShieldedTreasury { * belongs to the treasury and must not be re-spent by the caller (doing * so would double-spend the recorded coin). * + * @notice The returned `ShieldedSendResult` carries the sent and change coins + * in plaintext, so sinking it (returning it from an entry point, writing it + * to ledger, passing it cross-contract) discloses them. `_coins` publishes only + * the retained change coin, and nothing at all when the send leaves no change, + * so the sent coin's plaintext is disclosed beyond anything in public state. + * * @notice Access control is NOT enforced here. * The consuming contract must gate this behind its own * authorization policy. @@ -194,6 +217,13 @@ module ShieldedTreasury { * and cumulative sent totals for a color. Should equal * `getTokenBalance` if accounting is consistent. * + * @notice The subtraction is unguarded. `_send` cannot drive the sent total + * past the received total. Change re-insertion never increments `_received` + * so the invariant holds for any consumer that goes through `_deposit` and + * `_send`. A consumer that writes the exported `_coins` directly can break + * it, and this circuit then fails as an unprovable range violation rather + * than a named assert. + * * @param {Bytes<32>} color - The token color. * * @returns {Uint<128>} Received minus sent. diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact index b123e17e..e8139f1b 100644 --- a/contracts/src/multisig/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/ShieldedTreasuryStateless.compact @@ -4,19 +4,35 @@ pragma language_version >= 0.23.0; /** - * @module ShieldedTreasury - * @description Manages shielded (private) token deposits, accounting, - * and transfers for multisig governance contracts. + * @module ShieldedTreasuryStateless + * @description Manages shielded (private) token deposits and transfers for + * multisig governance contracts, without any ledger state. * - * Coins are stored on the contract ledger in a map keyed by token color, - * with one UTXO per color. Deposits are merged with existing coins of - * the same color via `mergeCoinImmediate`. This simplifies coin selection - * at spend time — the executor doesn't need to choose between multiple - * UTXOs of the same color. + * This module holds NO state: no coin map, no balances, no received/sent + * totals. Deposits call `receiveShielded` only, and spends take the coin as a + * circuit parameter. The operator discovers coin indices from `ZswapOutput` + * events via the indexer, constructs the `QualifiedShieldedCoinInfo` off-chain, + * and supplies it at spend time. Coin data therefore never reaches the public + * ledger, and there is no on-chain balance to query — contrast + * `ShieldedTreasury`, which stores one merged UTXO per color and tracks + * cumulative totals. * - * Cumulative received and sent totals are tracked per color for audit - * purposes. The canonical balance query is `getTokenBalance`, which - * reads the actual coin value from the UTXO map. + * @notice Because nothing is tracked on-chain, this module cannot detect a + * double spend or a coin it does not own; the protocol rejects both at + * application time. Sufficiency is likewise enforced by `sendShielded` rather + * than by an assert here. + * + * @notice "No ledger state" is not the same as "unobservable". A coin + * commitment is a hash of the coin's data, so `sendShielded` can link a coin + * receive to its later spend; `_send` declares that disclosure on the + * consumer's behalf. Consumers that need to make the trade-off themselves + * should note it is only declared here for convenience — it could be moved to + * the caller. Separately, see `_send`'s warning about its return value, which + * carries coin plaintext. + * + * Underscore-prefixed circuits (_deposit, _send) have no access control + * enforcement. The consuming contract must gate these behind its own + * authorization policy. */ module ShieldedTreasuryStateless { import CompactStandardLibrary; @@ -40,13 +56,24 @@ module ShieldedTreasuryStateless { * Executes the shielded send and returns the result untouched. * `sendShielded` already routes any change back to this contract as a * self-owned output, so `result.change` is a live, spendable coin. The - * implementing contract chooses what to do with it: persist it and pass - * it back as the `coin` for a later spend, or spend it onward to a + * implementing contract chooses what to do with it: retain it off-chain and + * pass it back as the `coin` for a later spend, or spend it onward to a * different recipient in the same transaction via `sendImmediateShielded`. * (Re-spending change is only meaningful when it goes somewhere other than * self — `sendShielded` already routes it to self.) No balance or * received/sent accounting is tracked on the ledger. * + * @warning The returned `ShieldedSendResult` holds the sent and change coins + * in plaintext (`nonce`, `color`, `value`). Consuming it in-circuit is safe + * (passing `result.change` to `sendImmediateShielded`, for instance). Returning + * it from your entry point, writing it to ledger, or passing it to another + * contract publishes those coin details, including the amount left over from + * the coin that was spent. Note the compiler will NOT stop you: the + * `disclose` calls below clear the witness taint, so such a return compiles + * with no `disclose` of your own and no marker at the disclosure site. If you + * do not need in-circuit change handling, ignore the result. The change is + * recoverable off-chain from the transaction's private proving state. + * * @notice Access control is NOT enforced here. * The consuming contract must gate this behind its own * authorization policy. @@ -72,9 +99,11 @@ module ShieldedTreasuryStateless { // `_send` returns it untouched: it must not re-spend the change here and // then hand that same (now-nullified) coin back via `result.change`. That // is the double spend a node rejects on the next spend. The implementing - // contract decides what to do with the returned change: persist it and - // spend it later, or route it onward itself. The rule is only that the - // change must never be re-spent AND still treated as held. + // contract decides what to do with the returned change: retain it off-chain + // and spend it later, or route it onward itself. The rule is only that the + // change must never be re-spent AND still treated as held. "Retain" means + // off-chain. This module keeps no state, and writing the coin to ledger + // would publish its nonce, color, and value. return result; } } diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index 23d6ba81..8780101e 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -20,7 +20,7 @@ pragma language_version >= 0.23.0; * * - `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 + * - `burn` consumes a UTXO via `sendShielded` to `shieldedBurnAddress()`. Only * coins of this contract's token type can be burned. Change is handled * automatically by the transaction layer. * @@ -194,7 +194,7 @@ export circuit mint( * @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 + * Sends the specified amount of the supplied coin to `shieldedBurnAddress()` via * `sendShielded`. The nullifier is submitted on-chain, permanently marking * the UTXO as spent. *