Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contracts/src/multisig/ForwarderPrivate.compact
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand Down
2 changes: 1 addition & 1 deletion contracts/src/multisig/ForwarderShielded.compact
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand Down
2 changes: 1 addition & 1 deletion contracts/src/multisig/ForwarderUnshielded.compact
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand Down
34 changes: 34 additions & 0 deletions contracts/src/multisig/ProposalManager.compact
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 33 additions & 3 deletions contracts/src/multisig/ShieldedTreasury.compact
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
*
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* @notice Access control is NOT enforced here.
* The consuming contract must gate this behind its own
* authorization policy.
Expand Down Expand Up @@ -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.
Expand Down
61 changes: 45 additions & 16 deletions contracts/src/multisig/ShieldedTreasuryStateless.compact
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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;
}
}
4 changes: 2 additions & 2 deletions contracts/src/multisig/presets/ShieldedMultiSigV3.compact
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down