-
Notifications
You must be signed in to change notification settings - Fork 26
feat(multisig): add Forwarder + ForwarderPrivate modules #526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: post-release
Are you sure you want to change the base?
Changes from all commits
40bb0b9
6e9a9e9
dc7605e
1a8f1a6
828fa2c
9dd9d77
96c1b9c
81e119e
0b25b72
fa3c316
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,3 +46,7 @@ coverage | |
| *~ | ||
|
|
||
| *temp | ||
|
|
||
| .claude/ | ||
|
|
||
| .states | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| // OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/Forwarder.compact) | ||
|
|
||
| pragma language_version >= 0.21.0; | ||
|
|
||
| /** | ||
| * @module Forwarder | ||
| * @description Public-parent forwarder primitives. Provides an atomic | ||
| * forward pattern: receive a coin (shielded or unshielded) and | ||
| * immediately send it to a hard-coded parent address, while | ||
| * accumulating per-color cumulative receipt totals for audit. | ||
| * | ||
| * Presets (`ForwarderShielded`, `ForwarderUnshielded`) wrap individual | ||
| * deposit paths so each deployable contract is single-purpose. The | ||
| * overflow guard and `_received.insert` are factored into a shared | ||
| * internal `_recordReceived` helper. | ||
| * | ||
| * Underscore-prefixed circuits have no access control. The forwarder is | ||
| * intentionally permissionless — the recipient is hard-coded at deploy, | ||
| * so any caller may deposit without compromising the parent. State | ||
| * access is gated by the `Initializable` module: every circuit asserts | ||
| * the module has been initialized via `_init`. | ||
| */ | ||
| module Forwarder { | ||
| import CompactStandardLibrary; | ||
| import { initialize, assertInitialized } from "../security/Initializable" prefix Initializable_; | ||
| import { UINT128_MAX } from "../utils/Utils" prefix Utils_; | ||
|
|
||
| // ─── State ────────────────────────────────────────────────────── | ||
|
|
||
| export ledger _parent: Bytes<32>; | ||
| export ledger _received: Map<Bytes<32>, Uint<128>>; | ||
|
|
||
| // ─── Init ─────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Initializes the forwarder with a parent address. | ||
| * Called once from the preset constructor. The parent is the | ||
| * recipient of every forwarded coin and is immutable after init. | ||
|
Comment on lines
+38
to
+39
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the claim is that the parent is immutable, it should be |
||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must not be initialized. | ||
| * - `parent` must not be the zero address. A zero parent would | ||
| * forward every deposit to an unspendable address with no recovery | ||
| * path. | ||
| * | ||
| * @param {Bytes<32>} parent - The parent address. | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| export circuit _init(parent: Bytes<32>): [] { | ||
|
0xisk marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd stick with |
||
| assert(parent != default<Bytes<32>>, "Forwarder: zero parent"); | ||
| Initializable_initialize(); | ||
| _parent = disclose(parent); | ||
| } | ||
|
|
||
| // ─── Deposit ──────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Receives a shielded coin and atomically forwards it | ||
| * to `_parent`. The coin is claimed at the protocol level via | ||
| * `receiveShielded`, then immediately re-sent via | ||
| * `sendImmediateShielded`. The per-color cumulative total in | ||
| * `_received` is incremented by `coin.value`. | ||
| * | ||
| * @circuitInfo k=15, rows=24718 | ||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must be initialized. | ||
| * - `_received[coin.color] + coin.value` must not overflow `Uint<128>` | ||
| * (enforced by `_recordReceived`). | ||
| * | ||
| * @param {ShieldedCoinInfo} coin - The incoming shielded coin. | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| export circuit _depositShielded(coin: ShieldedCoinInfo): [] { | ||
| Initializable_assertInitialized(); | ||
| _recordReceived(coin.color, coin.value); | ||
| receiveShielded(disclose(coin)); | ||
| sendImmediateShielded( | ||
| disclose(coin), | ||
| right<ZswapCoinPublicKey, ContractAddress>(ContractAddress { bytes: _parent }), | ||
| disclose(coin.value) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @description Receives an unshielded amount of `color` and | ||
| * atomically forwards it to `_parent`. The per-color cumulative | ||
| * total in `_received` is incremented by `amount`. | ||
| * | ||
| * @circuitInfo k=10, rows=813 | ||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must be initialized. | ||
| * - `_received[color] + amount` must not overflow `Uint<128>` | ||
| * (enforced by `_recordReceived`). | ||
| * | ||
| * @param {Bytes<32>} color - The token color. | ||
| * @param {Uint<128>} amount - The amount to deposit. | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| export circuit _depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { | ||
| Initializable_assertInitialized(); | ||
| _recordReceived(color, amount); | ||
| receiveUnshielded(disclose(color), disclose(amount)); | ||
| sendUnshielded( | ||
| disclose(color), | ||
| disclose(amount), | ||
| left<ContractAddress, UserAddress>(ContractAddress { bytes: _parent }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not crazy about this approach. We're bypassing type safety which I think is quite important with the different param order for:
Can't we define a generic |
||
| ); | ||
| } | ||
|
|
||
| // ─── View ─────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Returns the cumulative received total for `color`, | ||
| * or zero if no deposit of that color has been recorded. | ||
| * | ||
| * @circuitInfo k=9, rows=343 | ||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must be initialized. | ||
| * | ||
| * @param {Bytes<32>} color - The token color. | ||
| * | ||
| * @returns {Uint<128>} Cumulative received total for `color`. | ||
| */ | ||
| export circuit _getReceived(color: Bytes<32>): Uint<128> { | ||
| Initializable_assertInitialized(); | ||
| if (!_received.member(disclose(color))) { | ||
| return 0; | ||
| } | ||
| return _received.lookup(disclose(color)); | ||
| } | ||
|
|
||
| // ─── Internal ─────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Overflow-guarded accumulator. Factored from both | ||
| * deposit paths so the overflow assert and `_received.insert` happen | ||
| * exactly once per deposit regardless of coin kind. | ||
| * | ||
| * @param {Bytes<32>} color - The token color. | ||
| * @param {Uint<128>} value - The value to add to `_received[color]`. | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| circuit _recordReceived(color: Bytes<32>, value: Uint<128>): [] { | ||
| const current = _getReceived(color); | ||
| assert(current <= Utils_UINT128_MAX() - value, "Forwarder: received overflow"); | ||
| _received.insert(disclose(color), disclose(current + value as Uint<128>)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| // OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/ForwarderPrivate.compact) | ||
|
|
||
| pragma language_version >= 0.21.0; | ||
|
|
||
| /** | ||
| * @module ForwarderPrivate | ||
| * @description Private-parent forwarder primitives. The parent address | ||
| * is hidden behind a `persistentHash` commitment on the ledger. | ||
| * Deposits accumulate at the contract (no atomic forward); the operator | ||
| * drains coins by presenting the `(parentAddr, salt)` preimage at drain | ||
| * time. | ||
| * | ||
| * Knowledge of the preimage is the sole authorization gate — there is | ||
| * no signer set and no nullifier scheme. Salt is held off-chain as an | ||
| * operational secret; the contract never sees it except during a drain. | ||
| * Two forwarders bound to the same parent with different salts produce | ||
| * different commitments and are unlinkable on-chain. | ||
| * | ||
| * Underscore-prefixed circuits have no access control beyond the | ||
| * preimage check inside `_drain`. State access is gated by the | ||
| * `Initializable` module: every state-touching circuit asserts the | ||
| * module has been initialized via `_init`. The pure | ||
| * `_calculateParentCommitment` helper does not access state and is | ||
| * intentionally callable without initialization. | ||
| */ | ||
| module ForwarderPrivate { | ||
| import CompactStandardLibrary; | ||
| import { initialize, assertInitialized } from "../security/Initializable" prefix Initializable_; | ||
| import { selfAsRecipient } from "../utils/Utils" prefix Utils_; | ||
|
|
||
| // ─── State ────────────────────────────────────────────────────── | ||
|
|
||
| export ledger _parentCommitment: Bytes<32>; | ||
|
|
||
| // ─── Init ─────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Initializes the forwarder with a parent commitment. | ||
| * Called once from the preset constructor. The commitment is | ||
| * immutable after init. | ||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must not be initialized. | ||
| * - `parentCommitment` must not be the zero bytes. A zero commitment | ||
| * is the sole drain gate and would leave every deposited coin | ||
| * permanently unrecoverable (no preimage exists for `default<Bytes<32>>` | ||
| * under the domain-tagged hash). | ||
| * | ||
| * @param {Bytes<32>} parentCommitment - Domain-tagged | ||
| * `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt])` | ||
| * computed off-chain by the deployer (see `_calculateParentCommitment`). | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| export circuit _init(parentCommitment: Bytes<32>): [] { | ||
| assert(parentCommitment != default<Bytes<32>>, "ForwarderPrivate: zero commitment"); | ||
| Initializable_initialize(); | ||
|
0xisk marked this conversation as resolved.
|
||
| _parentCommitment = disclose(parentCommitment); | ||
| } | ||
|
|
||
| // ─── Deposit ──────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Receives a shielded coin into the forwarder's custody. | ||
| * No ledger write — coins dwell at the contract address until drained. | ||
| * | ||
| * @circuitInfo k=13, rows=6538 | ||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must be initialized. | ||
| * | ||
| * @param {ShieldedCoinInfo} coin - The incoming shielded coin. | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| export circuit _deposit(coin: ShieldedCoinInfo): [] { | ||
| Initializable_assertInitialized(); | ||
| receiveShielded(disclose(coin)); | ||
| } | ||
|
|
||
| // ─── Drain ────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Spends a previously-deposited shielded coin to | ||
| * `parentAddr`. The caller proves knowledge of `(parentAddr, salt)` | ||
| * matching the stored commitment. If `coin.value` exceeds `value`, | ||
| * the change is re-emitted back to the contract for future drains. | ||
| * | ||
| * @circuitInfo k=16, rows=47811 | ||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must be initialized. | ||
| * - `_calculateParentCommitment(parentAddr, salt) == _parentCommitment`. | ||
| * - `coin.value` must be >= `value` (enforced by `sendShielded`). | ||
| * | ||
| * @param {QualifiedShieldedCoinInfo} coin - The coin to spend. | ||
| * @param {Bytes<32>} parentAddr - The parent address. Preimage to the | ||
| * stored commitment. | ||
| * @param {Bytes<32>} salt - The salt. Operational secret; never | ||
| * appears on the public transcript. | ||
| * | ||
| * @warning **Salt loss is permanent fund loss.** The salt is the sole | ||
| * drain authorization. There is no rotation, revocation, or recovery | ||
| * path. If the operator misplaces the salt, every shielded coin | ||
| * accumulated at this contract is forever inaccessible — equivalent | ||
| * to losing a hot-wallet private key. Back the salt up offline before | ||
| * the first deposit and treat it with the same hygiene as a signing | ||
| * key. | ||
|
Comment on lines
+106
to
+112
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we change the name "salt" if it's this important? As noted above in the param definition, maybe "opSecret" or something might be better to signify how crucial it is not to lose it. WDYT? |
||
| * | ||
| * @param {Uint<128>} value - The amount to send. | ||
| * | ||
| * @returns {ShieldedSendResult} The result containing the sent coin | ||
| * and any change. | ||
| */ | ||
| export circuit _drain( | ||
| coin: QualifiedShieldedCoinInfo, | ||
|
0xisk marked this conversation as resolved.
|
||
| parentAddr: Bytes<32>, | ||
| salt: Bytes<32>, | ||
| value: Uint<128> | ||
| ): ShieldedSendResult { | ||
| Initializable_assertInitialized(); | ||
| assert( | ||
| _calculateParentCommitment(parentAddr, salt) == _parentCommitment, | ||
| "ForwarderPrivate: invalid parent" | ||
| ); | ||
|
|
||
| const result = sendShielded( | ||
| disclose(coin), | ||
| right<ZswapCoinPublicKey, ContractAddress>(ContractAddress { bytes: disclose(parentAddr) }), | ||
| disclose(value) | ||
| ); | ||
|
|
||
| if (disclose(result.change.is_some)) { | ||
| sendImmediateShielded( | ||
| disclose(result.change.value), | ||
| Utils_selfAsRecipient(), | ||
| disclose(result.change.value.value) | ||
| ); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| // ─── Pure helpers ─────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Computes the parent commitment from `(parentAddr, salt)`. | ||
| * Pure circuit — used off-chain by the deployer to compute the | ||
| * constructor argument, and inside `_drain` for the preimage check. | ||
| * Callable without initialization. | ||
| * | ||
| * The first hash input is a fixed domain tag | ||
| * (`pad(32, "ForwarderPrivate: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. | ||
| * | ||
| * @param {Bytes<32>} parentAddr - The parent address. | ||
| * @param {Bytes<32>} salt - The salt. | ||
| * | ||
| * @returns {Bytes<32>} `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt])`. | ||
| */ | ||
| export pure circuit _calculateParentCommitment( | ||
| parentAddr: Bytes<32>, | ||
| salt: Bytes<32> | ||
| ): Bytes<32> { | ||
| return persistentHash<Vector<3, Bytes<32>>>( | ||
| [pad(32, "ForwarderPrivate:commitment"), parentAddr, salt] | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I understand the value here. The parent is ultimately responsible for tracking the balances of its children. AFAICT this is just tracking what passes through, right? Is this worth having? Consider too the cumulative state from all deployed children. LMK if I'm missing something