-
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
Open
0xisk
wants to merge
16
commits into
post-release
Choose a base branch
from
feat/forwarder
base: post-release
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
40bb0b9
chore: ignore .states/ in git
0xisk 6e9a9e9
feat(multisig): add Forwarder + ForwarderPrivate modules
0xisk dc7605e
feat(multisig): add Forwarder preset variants
0xisk 1a8f1a6
build(contracts): add fast-check + v8 coverage, wire 95% threshold
0xisk 828fa2c
test(multisig): add Forwarder simulators + witnesses + test suites
0xisk 9dd9d77
refactor(multisig): nest forwarder presets under presets/ subdirs
0xisk 96c1b9c
feat(multisig): add zero-address + domain-separation guards in Forwar…
0xisk 81e119e
test(multisig): add module-level Forwarder tests via Mock fixtures
0xisk 0b25b72
build(contracts): broaden coverage globs and drop .compact from report
0xisk fa3c316
docs(multisig): fix path comment in preset witness files
0xisk 6641069
refactor(multisig): apply forwarder review fixes
0xisk 9bce9d5
refactor(multisig): remove forwarder _received counter
0xisk 0a7d41b
feat(multisig): make Forwarder parent type-safe
0xisk 5ad2cc4
refactor(multisig): name ForwarderPrivate TS types
0xisk 70742e7
refactor(multisig): drop brace-list module imports
0xisk 189ca65
refactor(multisig): rename salt to opSecret
0xisk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,3 +46,7 @@ coverage | |
| *~ | ||
|
|
||
| *temp | ||
|
|
||
| .claude/ | ||
|
|
||
| .states | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| // 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. | ||
| * | ||
| * The module is generic over the parent's address type `T`. Each preset | ||
| * specializes it to the address kind it forwards to: `ForwarderShielded` | ||
| * uses `ZswapCoinPublicKey`, `ForwarderUnshielded` uses `UserAddress`. | ||
| * Storing the parent as a typed value rather than raw `Bytes<32>` forces | ||
| * every deployer to supply the correct address kind for the coin type. | ||
| * | ||
| * 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 `initialize`. | ||
| */ | ||
| module Forwarder<T> { | ||
| import CompactStandardLibrary; | ||
| import "../security/Initializable" prefix Initializable_; | ||
|
|
||
| // ─── State ────────────────────────────────────────────────────── | ||
|
|
||
| export sealed ledger _parent: T; | ||
|
|
||
| // ─── 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. | ||
| * | ||
| * 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 {T} parent - The parent address. Typed per the specializing | ||
| * preset: `ZswapCoinPublicKey` for shielded, `UserAddress` for | ||
| * unshielded. | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| export circuit initialize(parent: T): [] { | ||
| assert(parent != default<T>, "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 parent's bytes are wrapped as a | ||
| * `ZswapCoinPublicKey` recipient at the send site. | ||
| * | ||
| * @circuitInfo k=15, rows=18573 | ||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must be initialized. | ||
| * | ||
| * @param {ShieldedCoinInfo} coin - The incoming shielded coin. | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| export circuit _depositShielded(coin: ShieldedCoinInfo): [] { | ||
| Initializable_assertInitialized(); | ||
| receiveShielded(disclose(coin)); | ||
| sendImmediateShielded( | ||
| disclose(coin), | ||
| left<ZswapCoinPublicKey, ContractAddress>(ZswapCoinPublicKey { bytes: _parent.bytes }), | ||
| disclose(coin.value) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @description Receives an unshielded amount of `color` and | ||
| * atomically forwards it to `_parent`. The parent's bytes are | ||
| * wrapped as a `UserAddress` recipient at the send site. | ||
| * | ||
| * @circuitInfo k=9, rows=436 | ||
| * | ||
| * Requirements: | ||
| * | ||
| * - Contract must be initialized. | ||
| * | ||
| * @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(); | ||
| receiveUnshielded(disclose(color), disclose(amount)); | ||
| sendUnshielded( | ||
| disclose(color), | ||
| disclose(amount), | ||
| right<ContractAddress, UserAddress>(UserAddress { bytes: _parent.bytes }) | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| // 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, opSecret)` preimage at drain | ||
| * time. | ||
| * | ||
| * Knowledge of the preimage is the sole authorization gate — there is | ||
| * no signer set and no nullifier scheme. The operational secret is | ||
| * held off-chain; the contract never sees it except during a drain. | ||
| * Two forwarders bound to the same parent with different operational | ||
| * secrets 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 `initialize`. The pure | ||
| * `_calculateParentCommitment` helper does not access state and is | ||
| * intentionally callable without initialization. | ||
| */ | ||
| module ForwarderPrivate { | ||
| import CompactStandardLibrary; | ||
| import "../security/Initializable" prefix Initializable_; | ||
| import "../utils/Utils" prefix Utils_; | ||
|
|
||
| // ─── State ────────────────────────────────────────────────────── | ||
|
|
||
| export sealed 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, opSecret])` | ||
| * computed off-chain by the deployer (see `_calculateParentCommitment`). | ||
| * | ||
| * @returns {[]} Empty tuple. | ||
| */ | ||
| export circuit initialize(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, opSecret)` | ||
| * 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, opSecret) == _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>} opSecret - The operational secret. Never appears | ||
| * on the public transcript. | ||
| * | ||
| * @warning **Losing the operational secret is permanent fund loss.** It | ||
| * is the sole drain authorization, with no rotation, revocation, or | ||
| * recovery path. If the operator misplaces it, every shielded coin | ||
| * accumulated at this contract is forever inaccessible, equivalent to | ||
| * losing a hot-wallet private key. Back it up offline before the first | ||
| * deposit and treat it with the same hygiene as a signing key. | ||
| * | ||
| * @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>, | ||
| opSecret: Bytes<32>, | ||
| value: Uint<128> | ||
| ): ShieldedSendResult { | ||
| Initializable_assertInitialized(); | ||
| assert( | ||
| _calculateParentCommitment(parentAddr, opSecret) == _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, opSecret)`. | ||
| * 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>} opSecret - The operational secret. | ||
| * | ||
| * @returns {Bytes<32>} `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. | ||
| */ | ||
| export pure circuit _calculateParentCommitment( | ||
| parentAddr: Bytes<32>, | ||
| opSecret: Bytes<32> | ||
| ): Bytes<32> { | ||
| return persistentHash<Vector<3, Bytes<32>>>( | ||
| [pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret] | ||
| ); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.