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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Rename the contract-compilation scripts and Turbo tasks from `compact` / `compact:*` to `compile` / `compile:*`, and the Biome scripts from `fmt-and-lint` / `fmt-and-lint:*` to `lint` / `lint:*`. (#680)
- Rename the native shielded token supply extensions to `NativeShieldedTokenPublicSupply` / `NativeShieldedTokenFamilyPublicSupply` (and the shared `NativeShieldedTokenPublicSupplyCore`), making explicit that they track supply on-chain and matching the `ConfidentialFungibleTokenPublicSupply` naming. (#710)

### Fixed

- Guard `UnshieldedTreasury` on its tracked balance instead of the protocol balance, fixing a runtime failure that made deposits revert (#762)

## 0.3.0-alpha (2026-06-30)

### Added
Expand Down
59 changes: 43 additions & 16 deletions contracts/src/multisig/UnshieldedTreasury.compact
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,24 @@ pragma language_version >= 0.23.0;
* @description Manages unshielded (transparent) token deposits and
* transfers for multisig governance contracts.
*
* Balances are tracked per token color in a single map. Protocol-level
* balance comparison circuits (`unshieldedBalanceLte`,
* `unshieldedBalanceGte`) are used for overflow and sufficiency checks,
* avoiding the exact-match problem of `unshieldedBalance`.
* Balances are tracked per token color in `_balances`, this module's own
* accounting and the authority for its overflow and sufficiency guards. The
* protocol balance remains authoritative for what the contract actually holds,
* and the ledger independently enforces conservation — a contract cannot send
* funds it does not have.
*
* @notice The guards deliberately do NOT consult `unshieldedBalance` or its
* comparison circuits. That balance is fixed to the value provided at the start
* of execution, so it cannot observe a credit or debit made earlier in the same
* transaction: guarding on it rejects a legitimate receive-then-spend and misses
* an intra-transaction overspend. Guarding on `_balances` (the same value the
* arithmetic mutates) is correct in both cases, and keeps the named asserts
* reachable for the conditions they describe.
*
* @notice Consumers must route every unshielded credit and debit through
* `_deposit` / `_send`. A flow that calls `receiveUnshielded` or
* `sendUnshielded` directly leaves `_balances` out of step with the protocol
* balance, and `getTokenBalance` will misreport.
*
* Underscore-prefixed circuits (_deposit, _send) have no access control
* enforcement. The consuming contract must gate these behind its own
Expand All @@ -35,7 +49,9 @@ module UnshieldedTreasury {
*
* Zero-value deposits are permitted. While currently a no-op
* economically, they may serve as signaling mechanisms when events
* are supported.
* are supported. On a color the treasury has not held, a zero-value
* operation creates a zero-valued `_balances` entry, which
* `getTokenBalance` reports the same as absence.
*
* @notice Access control is NOT enforced here.
* The consuming contract must gate this behind its own
Expand All @@ -51,21 +67,35 @@ module UnshieldedTreasury {
* @returns {[]} Empty tuple.
*/
export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] {
assert(
unshieldedBalanceLte(disclose(color), Utils_UINT128_MAX() - disclose(amount)),
"UnshieldedTreasury: overflow"
);
// `Utils_UINT128_MAX() - amount` stays in-circuit. Passing it to a protocol
// balance comparison would hand that built-in an operand near 2^128, which
// fails at runtime with "failed to decode for built-in type u64" despite the
// circuit's declared `Uint<128>` parameter.
const bal = getTokenBalance(color);
assert(bal <= Utils_UINT128_MAX() - amount, "UnshieldedTreasury: overflow");

receiveUnshielded(disclose(color), disclose(amount));

const bal = getTokenBalance(color);
_balances.insert(disclose(color), disclose(bal + amount as Uint<128>));
}

// ─── Send ───────────────────────────────────────────────────────

/**
* @description Sends unshielded tokens from the treasury.
* @description Sends unshielded tokens from the treasury. Zero-amount sends
* are permitted and are value-preserving no-ops.
*
* @warning Recipients are not validated. An unspendable recipient (such as a
* zero address) is accepted and permanently removes the funds from the
* contract. This is not a sanctioned burn — no unshielded equivalent of
* `shieldedBurnAddress` is exposed to contracts — so treat recipient
* validation as the consuming contract's responsibility.
*
* @warning Sending to this contract's own address zeroes the tracked balance
* while the funds stay held and spendable at the protocol level, after which
* `_send` will refuse to touch them. Recovery is only possible outside this
* module: a consumer may reconcile the exported `_balances`, or spend the
* funds through its own `sendUnshielded` call.
*
* @notice Access control is NOT enforced here.
* The consuming contract must gate this behind its own
Expand All @@ -86,12 +116,9 @@ module UnshieldedTreasury {
color: Bytes<32>,
amount: Uint<128>
): [] {
assert(
unshieldedBalanceGte(disclose(color), disclose(amount)),
"UnshieldedTreasury: insufficient balance"
);

const bal = getTokenBalance(color);
assert(bal >= amount, "UnshieldedTreasury: insufficient balance");

_balances.insert(disclose(color), disclose(bal - amount as Uint<128>));
sendUnshielded(disclose(color), disclose(amount), disclose(recipient));
}
Expand Down
Loading