Skip to content

feat(multisig): add Forwarder + ForwarderPrivate modules#526

Open
0xisk wants to merge 10 commits into
post-releasefrom
feat/forwarder
Open

feat(multisig): add Forwarder + ForwarderPrivate modules#526
0xisk wants to merge 10 commits into
post-releasefrom
feat/forwarder

Conversation

@0xisk
Copy link
Copy Markdown
Member

@0xisk 0xisk commented May 27, 2026

Types of changes

What types of changes does your code introduce to OpenZeppelin Midnight Contracts?
Put an `` in the boxes that apply

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation Update (if none of the other choices apply)

Fixes #474

PR Checklist

Summary by CodeRabbit

  • New Features

    • Multisig forwarder contracts: shielded and unshielded deposits automatically forwarded to a configured parent while tracking per-color cumulative received totals.
    • Private forwarder variant: parent specified via commitment and supports authenticated drains with change handling.
  • Tests

    • Extensive test suites and simulator presets validating initialization, deposits, drains, overflow guards, and property-based checks.
    • Test coverage enabled (v8) with 95% thresholds.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 27, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 48ef30a6-12d2-4d01-a6b5-f67b71e5a45a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR adds two forwarder contracts (public-parent Forwarder and commitment-based ForwarderPrivate), preset wrappers for shielded/unshielded/private deployments, Compact test mocks, TypeScript simulator presets, witness placeholders, comprehensive Vitest tests (including property tests), and Vitest coverage tooling plus minor .gitignore and package.json updates.

Changes

Forwarder Contracts and Testing

Layer / File(s) Summary
Forwarder core module: public parent, deposit accumulation, atomic forward
contracts/src/multisig/Forwarder.compact
Immutable parent address, per-color cumulative _received ledger, initialization circuit, shielded/unshielded deposit circuits that record amounts and atomically forward to parent, overflow-protected accumulation helper, and getReceived view.
ForwarderPrivate core module: commitment-based parent, deposit and drain
contracts/src/multisig/ForwarderPrivate.compact
Stored parent commitment, _init guard, _deposit receives shielded coin into contract, _drain verifies (parentAddr,salt) via _calculateParentCommitment and sends value to parent with change re-emission, plus pure commitment helper.
Preset wrapper contracts for Forwarder variants
contracts/src/multisig/presets/forwarder/ForwarderShielded.compact, ForwarderUnshielded.compact, ForwarderPrivate.compact
Deployable wrappers that initialize parent/commitment in constructors and delegate deposit/drain/query operations to underlying core module circuits.
Test fixtures and mocks
contracts/src/multisig/test/mocks/MockForwarder.compact, MockForwarderPrivate.compact
Test-only Compact mocks that re-export underscore-prefixed core circuits and provide conditional constructor initialization for tests.
Simulator bases and typed presets
contracts/src/multisig/test/simulators/*, contracts/src/multisig/test/simulators/presets/*
Typed TypeScript simulator bases and preset wrappers for MockForwarder, ForwarderShielded, ForwarderUnshielded, ForwarderPrivate, and mock private simulators exposing pure/impure circuit helpers used by tests.
Witness placeholders
contracts/src/multisig/witnesses/*, contracts/src/multisig/witnesses/presets/*
Empty private-state types/constants and witness factory functions for mocks and presets used by simulators.
Test suites for core and preset modules
contracts/src/multisig/test/Forwarder.test.ts, ForwarderPrivate.test.ts, test/presets/Forwarder*.test.ts
Vitest suites covering initialization, init guards, per-color accumulation, overflow boundaries, commitment determinism, drain success/failure, change arithmetic, and property-based tests using fast-check.
Testing tooling and configuration
.gitignore, contracts/package.json, contracts/vitest.config.ts
Adds .claude/ and .states to .gitignore, test:coverage script and @vitest/coverage-v8/fast-check devDependencies, and Vitest test.coverage configuration (v8 provider, text/html reporters, include/exclude patterns, excludeAfterRemap, 95% per-file thresholds).

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 Hops through deposits, tracks them with care,
Colors recorded in ledgers so fair,
Forward they fly to the parent so true,
Private commitments hide who knew,
Tests check the hops — hooray! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(multisig): add Forwarder + ForwarderPrivate modules' clearly and concisely summarizes the main change: adding two new multisig modules (Forwarder and ForwarderPrivate) and their presets, which is the primary focus of the changeset.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #474: provides minimal per-recipient Forwarder contracts with stable addresses for attribution, ensures atomic forwarding of deposits to sealed parent multisig within same transaction, maintains immutable parent address, includes all required guardrails (initialization gating, overflow checks, domain separation), and adds comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are in scope and directly support the Forwarder/ForwarderPrivate implementation: core modules, presets, tests, simulators, witnesses, mock fixtures, coverage config, and gitignore updates. No unrelated refactoring or external changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/forwarder

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Comment @coderabbitai help to get the list of available commands and usage tips.

0xisk added 5 commits May 27, 2026 17:04
Local node-state directory holding wallet seeds and chain snapshots
during development. Should not be tracked.
Two new modules providing forwarder primitives for inbound coin routing
to a designated parent address.

Forwarder (public):
- _depositShielded: atomic receive + forward via Zswap, accumulates per-color total
- _depositUnshielded: same flow for unshielded transfers
- _recordReceived: shared overflow-guarded accounting helper
- _parent + _received ledger fields, immutable parent after init

ForwarderPrivate (private):
- _deposit: receiveShielded only; coins dwell at the contract
- _drain: preimage-gated send with change re-emission to self
- _calculateParentCommitment: persistentHash([parentAddr, salt]) — pure
- _parentCommitment ledger field hides parent under operational salt

Both modules gated by Initializable. No witnesses in v1; all sensitive
data flows through circuit parameters.
Three single-purpose presets composed from the Forwarder /
ForwarderPrivate modules via named imports. Each preset is its own
deployable contract with its own verifier key; a bank picks the right
preset at deploy time based on the coin kind it accepts.

- ForwarderShielded: exposes deposit(coin) for shielded receipts
- ForwarderUnshielded: exposes depositUnshielded(color, amount)
- ForwarderPrivate: exposes deposit + drain + _calculateParentCommitment
  for the private-parent flow

No combined preset — banks that need both shielded and unshielded
deploy two contracts. No ForwarderPrivateUnshielded — unshielded sends
publish the parent on-chain, which defeats the private variant.
- @vitest/coverage-v8 for native v8 coverage with source-map
  back-mapping. The coverage include glob covers TS witnesses /
  simulators and the compactc-generated artifacts/Forwarder*/contract/
  index.js files; v8 follows the .js.map to render coverage pages
  under .compact filenames.
- fast-check for property-based tests (unlinkability across salts,
  per-color accumulation, partial-drain change arithmetic).
- test:coverage script: compactc --skip-zk && vitest run --coverage.
- 95% per-file threshold (lines/branches/functions/statements) as the
  closing gate. Subset runs override with --coverage.thresholds.lines=0
  etc.
Test infrastructure for the three forwarder presets.

- Witness stubs (Record<string, never>) — v1 has no witnesses.
- Simulators wrap the compactc-emitted artifacts via createSimulator,
  expose the public preset surface, and (for ForwarderPrivate) a static
  calculateParentCommitment that delegates to pureCircuits.
- Three test suites covering 31 cases total:
  * constructor + initial state
  * shielded / unshielded deposit accumulation + overflow guard
  * drain auth (correct, wrong parent, wrong salt, both wrong)
  * drain change-coin handling (full vs partial)
  * regression: no ledger mutation across drain failures
  * property tests (fast-check) for accumulation, unlinkability across
    salts, and partial-drain change arithmetic
@0xisk 0xisk force-pushed the feat/forwarder branch from 2c4c750 to 828fa2c Compare May 27, 2026 15:07
@0xisk 0xisk marked this pull request as ready for review May 27, 2026 15:07
@0xisk 0xisk requested review from a team as code owners May 27, 2026 15:07
@0xisk 0xisk requested review from andrew-fleming and pepebndc May 27, 2026 15:08
@0xisk 0xisk self-assigned this May 27, 2026
@0xisk 0xisk added the enhancement New feature or request label May 27, 2026
@0xisk 0xisk moved this from Backlog to Needs Review in OZ Development for Midnight May 27, 2026
@0xisk 0xisk modified the milestone: v2.0.0 May 27, 2026
Copy link
Copy Markdown
Contributor

@pepebndc pepebndc left a comment

Choose a reason for hiding this comment

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

left some comments

Comment thread contracts/src/multisig/Forwarder.compact
Comment thread contracts/src/multisig/ForwarderPrivate.compact
Comment thread contracts/src/multisig/ForwarderPrivate.compact
Comment thread contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact
@pepebndc
Copy link
Copy Markdown
Contributor

Non-blocking suggestions

Three additional things worth considering before this ships as a stable library API.

Overflow test missing in ForwarderShielded

ForwarderUnshielded.test.ts correctly tests the MAX_U128 overflow boundary. ForwarderShielded wraps the same _recordReceived guard and should test it independently — line coverage alone won't catch this behavioral gap.

Double-initialization revert tests

None of the three preset test suites assert that calling the constructor (or _init) a second time reverts. This is a standard OZ Compact test for any module with an initialization guard.

Domain separation in _calculateParentCommitment

persistentHash<Vector<2, Bytes<32>>>([parentAddr, salt]) has no domain tag. If any other circuit in the system uses persistentHash with two Bytes<32> inputs, a preimage crafted in that context could theoretically satisfy this commitment. A fixed domain prefix (e.g. ["ForwarderPrivate", parentAddr, salt]) eliminates the surface entirely.

0xisk added 4 commits May 28, 2026 12:12
Move the three forwarder preset test files, simulators, and witnesses
into matching `presets/` subdirectories so module-level fixtures stay
flat alongside the rest of multisig/ while preset wiring lives in a
clearly-scoped folder.

The preset test files are slimmed to wiring-only checks (constructor
arg storage, exposed circuit forwarding, zero-guard propagation,
public-state accessor). Behavioural coverage moves out to the new
module-level test files in the follow-up commit.

Preset simulator import paths are bumped one level up to reach
witnesses/ and artifacts/ from their new depth.
…der modules

Address PR #526 review feedback:

- `Forwarder._init` rejects `parent == default<Bytes<32>>`; a zero
  recipient would forward every deposit to an unspendable address
  with no recovery path.
- `ForwarderPrivate._init` rejects a zero parentCommitment; since the
  commitment is the sole drain gate, a zero value would lock every
  accumulated coin permanently.
- `ForwarderPrivate._calculateParentCommitment` now hashes
  `[pad(32, "ForwarderPrivate:commitment"), parentAddr, salt]` instead
  of `[parentAddr, salt]`. Domain tag prevents preimage collisions
  with other `persistentHash` users in the system.
- `_drain` `@param salt` carries a prominent `@warning` block: salt
  loss is permanent fund loss (no rotation, revocation, or recovery
  path). Same warning mirrored on the preset's `drain` wrapper.
- Preset `_calculateParentCommitment` re-export renamed to
  `calculateParentCommitment` (no leading underscore); the `_`
  prefix is reserved for module-internal helpers in this codebase.
- `@circuitInfo` for `_drain` bumped 47778 → 47811 rows after the
  Vector<2> → Vector<3> commitment-input shape change.
Address PR #526 review feedback on missing double-init / init-guard
coverage. The preset constructors are the only init entry on the
shipping API, so this layer cannot drive a second `_init` call
without re-exposing the underscore-prefixed circuit. Following the
Signer/MockSigner convention, two mock contracts wrap the modules
directly and expose `initialize`, `deposit*`, `drain`, etc. as
test-only circuits:

- MockForwarder.compact exposes the public Forwarder module
- MockForwarderPrivate.compact exposes the private one

The mocks take an `isInit` constructor flag (false to test the
not-initialized path; true then `initialize(...)` for double-init).
Empty witness stubs and `createSimulator`-based wrappers are added
alongside.

New module-level test files exercise behaviour previously covered
in the preset tests:

- test/Forwarder.test.ts — init guards (zero-parent, double-init,
  late-init), assertInitialized firing on every state-touching
  circuit, `_recordReceived` accumulation via both deposit paths,
  Uint<128> overflow on the unshielded path, Uint<64> Zswap cap on
  the shielded path, property-based accumulation.
- test/ForwarderPrivate.test.ts — init guards (zero-commitment,
  double-init), assertInitialized, `calculateParentCommitment`
  purity + unlinkability, drain happy/failure paths, change
  arithmetic on partial drains, property-based change arithmetic.

971 / 971 tests pass.
Simplify the coverage `include` to glob patterns that cover every
compiled artifact instead of listing each Forwarder file by hand,
and collapse the duplicated `**/` patterns in `witnesses/` and
`simulators/`. Drop `.compact` files from the report after
source-map remap (`excludeAfterRemap: true`).

The compactc-emitted source map is function-entry granularity only
(every statement inside a circuit collapses onto the circuit header
line), so back-projecting v8 branch/line coverage to `.compact`
produces misleading partial coverage on circuits that are fully
exercised in tests — e.g. `ForwarderPrivate._drain`'s
`if (disclose(result.change.is_some))` reports 50 % branches even
though both legs run via the partial-drain and full-drain test
cases plus the property suite.

Coverage now tracks: every TS witness, every test simulator, every
artifacts/*/contract/index.js shim. With this scope all Forwarder
TS files hit 100 % lines / branches / functions / statements; the
JS shim retains real v8 instrumentation for circuit execution.

Upstream tracker for the source-map fidelity work:
LFDT-Minokawa/compact#465
@0xisk
Copy link
Copy Markdown
Member Author

0xisk commented May 28, 2026

Non-blocking suggestions

Three additional things worth considering before this ships as a stable library API.

Overflow test missing in ForwarderShielded

ForwarderUnshielded.test.ts correctly tests the MAX_U128 overflow boundary. ForwarderShielded wraps the same _recordReceived guard and should test it independently — line coverage alone won't catch this behavioral gap.

  • Addressed in 81e119e — added on the shielded path in the new module test. Note: Zswap bounds ShieldedCoinInfo.value: Uint<64>, so the Uint<128> accumulator overflow inside _recordReceived is structurally unreachable from finite shielded deposits. The test instead asserts that a MAX_U128 coin.value is rejected (Zswap upstream) and that the Uint<64> ceiling is accepted; the Uint<128> boundary is exercised directly via the unshielded path which shares the same _recordReceived helper.

Double-initialization revert tests

None of the three preset test suites assert that calling the constructor (or _init) a second time reverts. This is a standard OZ Compact test for any module with an initialization guard.

  • Addressed in 81e119e — preset constructors are the only init entry, so following the Signer/MockSigner convention I added MockForwarder + MockForwarderPrivate test fixtures that expose initialize(...) and the not-initialized path (via an isInit flag), then asserted the double-init revert + the not-initialized-state-touching reverts at the module level.

Domain separation in _calculateParentCommitment

persistentHash<Vector<2, Bytes<32>>>([parentAddr, salt]) has no domain tag. If any other circuit in the system uses persistentHash with two Bytes<32> inputs, a preimage crafted in that context could theoretically satisfy this commitment. A fixed domain prefix (e.g. ["ForwarderPrivate", parentAddr, salt]) eliminates the surface entirely.

  • Addressed in 96c1b9c — commitment is now persistentHash<Vector<3, Bytes<32>>>([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt]). @circuitInfo for _drain updated 47778 → 47811 to reflect the shape change.

@0xisk 0xisk requested a review from pepebndc May 28, 2026 10:21
@0xisk
Copy link
Copy Markdown
Member Author

0xisk commented May 28, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@OpenZeppelin OpenZeppelin deleted a comment from coderabbitai Bot May 28, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

@0xisk
Copy link
Copy Markdown
Member Author

0xisk commented May 28, 2026

@coderabbitai review this pr.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

@0xisk Sure, I'll kick off a review of the PR now!

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@0xisk
Copy link
Copy Markdown
Member Author

0xisk commented May 28, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts`:
- Line 2: Update the header comment on the top of ForwarderPrivateWitnesses.ts
so the file path in the comment matches the actual location by adding the
missing "presets/" segment; replace the existing "// OpenZeppelin Compact
Contracts v0.0.1-alpha.1 (multisig/witnesses/ForwarderPrivateWitnesses.ts)" with
the correct path including "presets" (i.e.,
"(multisig/witnesses/presets/ForwarderPrivateWitnesses.ts)") so the file comment
reflects the true location.

In `@contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts`:
- Line 2: Update the review comment's file attribute to include the missing
presets subdirectory: change the comment metadata file value to
"multisig/witnesses/presets/ForwarderShieldedWitnesses.ts" so it matches the
actual file location (referencing ForwarderShieldedWitnesses.ts) and ensure any
other review entries for this file use the same corrected path.

In `@contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts`:
- Line 2: Update the header comment path to match the actual file location by
adding the missing "presets/" segment; in the file
ForwarderUnshieldedWitnesses.ts (symbol: ForwarderUnshieldedWitnesses) replace
the current top-line comment that omits "presets/" so it reads the correct path
"multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fb8c2a1a-166f-4b43-90d6-c865c64e2903

📥 Commits

Reviewing files that changed from the base of the PR and between 8a43259 and 0b25b72.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (25)
  • .gitignore
  • contracts/package.json
  • contracts/src/multisig/Forwarder.compact
  • contracts/src/multisig/ForwarderPrivate.compact
  • contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact
  • contracts/src/multisig/presets/forwarder/ForwarderShielded.compact
  • contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact
  • contracts/src/multisig/test/Forwarder.test.ts
  • contracts/src/multisig/test/ForwarderPrivate.test.ts
  • contracts/src/multisig/test/mocks/MockForwarder.compact
  • contracts/src/multisig/test/mocks/MockForwarderPrivate.compact
  • contracts/src/multisig/test/presets/ForwarderPrivate.test.ts
  • contracts/src/multisig/test/presets/ForwarderShielded.test.ts
  • contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts
  • contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts
  • contracts/src/multisig/test/simulators/MockForwarderSimulator.ts
  • contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts
  • contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts
  • contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts
  • contracts/src/multisig/witnesses/MockForwarderPrivateWitnesses.ts
  • contracts/src/multisig/witnesses/MockForwarderWitnesses.ts
  • contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts
  • contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts
  • contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts
  • contracts/vitest.config.ts

Comment thread contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts Outdated
Comment thread contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts Outdated
Comment thread contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts Outdated
Header comment on the preset witness files still referenced the
pre-rename location (multisig/witnesses/...) after the move to
presets/. Add the missing presets/ segment so the comment matches
the actual path. Raised by CodeRabbit on #526.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

2 participants