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
160 changes: 160 additions & 0 deletions contracts/src/multisig/Forwarder.compact
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>>;
Copy link
Copy Markdown
Contributor

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


// ─── 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the claim is that the parent is immutable, it should be sealed, no?

*
* 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>): [] {
Comment thread
0xisk marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd stick with initialize to be consistent with the rest of the lib and the extensibility pattern

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 })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

  • shielded: Either<ZswapCoinPublicKey, ContractAddress>
  • unshielded: Either<ContractAddress, UserAddress>

Can't we define a generic RecipientType so that consuming contracts must explicitly define the types? Otherwise, I think it's really easy to accidentally do something like pass the bytes of a UserAddress when it should have been a contract

);
}

// ─── 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>));
}
}
175 changes: 175 additions & 0 deletions contracts/src/multisig/ForwarderPrivate.compact
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();
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, 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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,
Comment thread
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]
);
}
}
Loading
Loading