From 7418a3f886948cd482769e0eb5c29ca03afd38cd Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 1 Aug 2026 01:08:49 -0300 Subject: [PATCH 1/8] fix circuitInfo, fix burnAddress name in doc --- contracts/src/multisig/ForwarderPrivate.compact | 2 +- contracts/src/multisig/ForwarderShielded.compact | 2 +- contracts/src/multisig/ForwarderUnshielded.compact | 2 +- contracts/src/multisig/presets/ShieldedMultiSigV3.compact | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/ForwarderPrivate.compact index 7a3daef49..71da91c69 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 10289ad79..879f504f0 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 754935e1e..992ff5bbf 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/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index 23d6ba814..8780101e8 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. * From e45395ebe23db6584d279983a62b630cb3bc0465 Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 1 Aug 2026 01:28:38 -0300 Subject: [PATCH 2/8] improve stateless shielded treasury docs --- .../ShieldedTreasuryStateless.compact | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact index b123e17e8..2a3020bb3 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, and the change value reveals the + * treasury's remaining holding. 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; } } From 0bfdcaa1d3c6f034252089a8e41fbd2d495b7199 Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 1 Aug 2026 02:11:27 -0300 Subject: [PATCH 3/8] improve docs --- .../src/multisig/ShieldedTreasuryStateless.compact | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact index 2a3020bb3..e8139f1b9 100644 --- a/contracts/src/multisig/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/ShieldedTreasuryStateless.compact @@ -64,14 +64,14 @@ module ShieldedTreasuryStateless { * 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 + * 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, and the change value reveals the - * treasury's remaining holding. Note the compiler will NOT stop you: the + * 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 + * 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. @@ -102,7 +102,7 @@ module ShieldedTreasuryStateless { // 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 + // off-chain. This module keeps no state, and writing the coin to ledger // would publish its nonce, color, and value. return result; } From e39037ca09ccb1a457d7b622866773db71bb45e3 Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 1 Aug 2026 02:21:33 -0300 Subject: [PATCH 4/8] improve shielded treasury docs --- .../src/multisig/ShieldedTreasury.compact | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/contracts/src/multisig/ShieldedTreasury.compact b/contracts/src/multisig/ShieldedTreasury.compact index d18b579ac..08f775ff0 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,11 @@ 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, but they are not a no-op: a zero-value + * coin is inserted into `_coins` and occupies that color's single slot until + * a later deposit merges into it. 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 +107,11 @@ 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. Here that leaks + * nothing new, `_coins` already publishes the change coin. + * * @notice Access control is NOT enforced here. * The consuming contract must gate this behind its own * authorization policy. @@ -194,6 +214,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. From f947457916eaa5e3d8f90bd087ece1b94cc02ee0 Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 1 Aug 2026 12:06:37 -0300 Subject: [PATCH 5/8] improve prop manager docs --- .../src/multisig/ProposalManager.compact | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/ProposalManager.compact index cdbe4ab9d..632759bfc 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,20 @@ 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, permanently `Active` and + * unexecutable. `ShieldedMultiSig` guards this at its call site; a consumer + * that does not is exposed. + * - 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. From ce294cd5f8c9fd1e2cf08292ffc950d1e337aa60 Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 1 Aug 2026 12:26:35 -0300 Subject: [PATCH 6/8] improve prop mgr lifecycle section --- contracts/src/multisig/ProposalManager.compact | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/ProposalManager.compact index 632759bfc..d281c1b25 100644 --- a/contracts/src/multisig/ProposalManager.compact +++ b/contracts/src/multisig/ProposalManager.compact @@ -202,9 +202,11 @@ module ProposalManager { * * - 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, permanently `Active` and - * unexecutable. `ShieldedMultiSig` guards this at its call site; a consumer - * that does not is exposed. + * reverts in `toShieldedRecipient` at execution. It stays `Active` and + * unexecutable on that rail until `_cancelProposal` retires it — and neither + * shipped preset exposes cancellation, so a consumer that omits it has no way + * to retire the proposal. `ShieldedMultiSig` guards the kind at its call site + * instead; a consumer that does neither is exposed. * - A zero or otherwise unspendable `address` is accepted, and funds sent to * it on execution are unrecoverable. * From eb794fc83df5f76dd6a53105f1eb48cf50c2df56 Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 1 Aug 2026 12:27:11 -0300 Subject: [PATCH 7/8] improve prop mgr doc --- contracts/src/multisig/ProposalManager.compact | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/ProposalManager.compact index d281c1b25..14a236925 100644 --- a/contracts/src/multisig/ProposalManager.compact +++ b/contracts/src/multisig/ProposalManager.compact @@ -203,10 +203,7 @@ module ProposalManager { * - 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 — and neither - * shipped preset exposes cancellation, so a consumer that omits it has no way - * to retire the proposal. `ShieldedMultiSig` guards the kind at its call site - * instead; a consumer that does neither is exposed. + * 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. * From 4373a667d24c0f4c1ae7a1291ab8d532f9d8bbad Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 1 Aug 2026 12:27:43 -0300 Subject: [PATCH 8/8] improve shielded treasury doc --- contracts/src/multisig/ShieldedTreasury.compact | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/contracts/src/multisig/ShieldedTreasury.compact b/contracts/src/multisig/ShieldedTreasury.compact index 08f775ff0..bad12133d 100644 --- a/contracts/src/multisig/ShieldedTreasury.compact +++ b/contracts/src/multisig/ShieldedTreasury.compact @@ -55,10 +55,12 @@ 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, but they are not a no-op: a zero-value - * coin is inserted into `_coins` and occupies that color's single slot until - * a later deposit merges into it. They may serve as signaling mechanisms - * when events are supported. They are not useful as privacy decoys — this + * 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. @@ -109,8 +111,9 @@ module ShieldedTreasury { * * @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. Here that leaks - * nothing new, `_coins` already publishes the change coin. + * 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