Skip to content
Open
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ coverage
*~

*temp

.claude/

.states
3 changes: 3 additions & 0 deletions contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"compact:utils": "compact-compiler --dir utils",
"build": "compact-builder",
"test": "compact-compiler --skip-zk && vitest run",
"test:coverage": "compact-compiler --skip-zk && vitest run --coverage",
"types": "tsc -p tsconfig.json --noEmit",
"clean": "git clean -fXd"
},
Expand All @@ -46,6 +47,8 @@
"@openzeppelin-compact/contracts-simulator": "workspace:^",
"@tsconfig/node24": "^24.0.4",
"@types/node": "24.10.0",
"@vitest/coverage-v8": "^4.1.6",
"fast-check": "^3.23.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vitest": "^4.1.2"
Expand Down
112 changes: 112 additions & 0 deletions contracts/src/multisig/Forwarder.compact
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.
Comment thread
0xisk marked this conversation as resolved.
*
* 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 })
);
}
}
174 changes: 174 additions & 0 deletions contracts/src/multisig/ForwarderPrivate.compact
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();
Comment thread
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,
Comment thread
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]
);
}
}
Loading
Loading