Skip to content

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

Merged
0xisk merged 17 commits into
post-releasefrom
feat/forwarder
Jun 9, 2026
Merged

feat(multisig): add Forwarder + ForwarderPrivate modules#526
0xisk merged 17 commits into
post-releasefrom
feat/forwarder

Conversation

@0xisk

@0xisk 0xisk commented May 27, 2026

Copy link
Copy Markdown
Member

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

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

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: 36b399fe-1241-43e7-a34b-6a50d0e9cdbe

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

@pepebndc pepebndc left a comment

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.

left some comments

Comment thread contracts/src/multisig/Forwarder.compact Outdated
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

0xisk commented May 28, 2026

Copy link
Copy Markdown
Member Author

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

0xisk commented May 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

0xisk added 3 commits June 3, 2026 14:29
The ForwarderPrivate simulators hand-inlined `ShieldedCoinInfo`,
`QualifiedShieldedCoinInfo`, and `ShieldedSendResult` as structural
types, which drift from the generated artifact.

* Re-export the three STD types from the ForwarderPrivate preset and
  the MockForwarderPrivate fixture so compactc emits named aliases in
  the generated `index.d.ts`.
* Import the named types in both simulators instead of inlining them.

Mirrors the parent-typing change in the preceding commit. No
behavioral change; the 37 forwarder tests still pass.

Refs: #526
Comment 3 (6641069) replaced the brace-list imports in the forwarder
presets and mocks with the bare `prefix` form, but the two module
files were missed and still imported named symbols.

* `Forwarder.compact` now uses `import "../security/Initializable"
  prefix Initializable_;`.
* `ForwarderPrivate.compact` does the same, plus `import
  "../utils/Utils" prefix Utils_;`.

The bare form imports every exported member under the prefix, so the
call sites (`Initializable_initialize`, `Initializable_assertInitialized`,
`Utils_selfAsRecipient`) are unchanged. Matches the other multisig
presets; with no go-to-def in the compact-lsp the brace list bought
nothing. 37 forwarder tests still pass.

Refs: #526
Comment 6: `salt` reads like a throwaway randomizer, but in
ForwarderPrivate it is the sole drain authorization. Losing it loses
every accumulated coin. Rename it to `opSecret` (operational secret)
so the name signals that criticality at every call site.

* Rename the `salt` parameter to `opSecret` in `_drain` and
  `_calculateParentCommitment`, plus the preset and mock wrappers.
* Rename the test constants `SALT` / `WRONG_SALT` to `OP_SECRET` /
  `WRONG_OP_SECRET`, and the simulator and helper params.
* Reword the doc comments to drop the "salt" term and describe the
  value as the operational secret.

Pure rename; no behavioral change. 37 forwarder tests pass.

Refs: #526
@0xisk

0xisk commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

@andrew-fleming Thanks for the good points! 👌 all fixed:

# Thread Commit
1 initialize naming Fixed in 6641069
2 sealed parent Fixed in 6641069
3 named imports vs prefix Fixed in 6641069 (presets/mocks), 70742e7 (modules)
4 _received value Fixed in 9bce9d5
5 recipient type safety Fixed in 0a7d41b
6 saltopSecret Fixed in 189ca65

@0xisk
0xisk requested a review from andrew-fleming June 3, 2026 13:03
@0xisk
0xisk enabled auto-merge (squash) June 8, 2026 13:12

@andrew-fleming andrew-fleming left a comment

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.

Looking good @0xisk! I left some comments

Comment on lines +38 to +39
* Called once from the preset constructor. The parent is the
* recipient of every forwarded coin and is immutable after init.

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 actually think it'd be better if we don't enforce immutability. For contracts that will not change the parent, it's as simple as don't create/expose a circuit for it. For contracts that want to change the parent, this can be easily added...either in the module itself or it can be deferred for the contract to handle. Otherwise, users are forced to do something like create their own workaround or build their own module

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.

Instead of having a bajillion empty witnesses/PS, can we just define it once in multisig/test/ or even in test-utils/ and import it into the simulator? I only ask now in this specific context because it's getting unnecessarily bloated and messy

receiveShielded(disclose(coin));
sendImmediateShielded(
disclose(coin),
left<ZswapCoinPublicKey, ContractAddress>(ZswapCoinPublicKey { bytes: _parent.bytes }),

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 be really careful here. What happens if a user instantiates the generic with ContractAddress? The encoding is different so that'd be a big problem and there's nothing preventing a user from doing this. I think the generic needs to be Either<T1, T2>

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.

Or better yet, to avoid ALL confusion, we could just split this into two modules and define the concrete types (shielded or unshielded Either<>). There's really not a whole lot of duplication in the module...just a single state variable and initializer. WDYT?

@0xisk

0xisk commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

Thanks @andrew-fleming for your followup review. I opened an issue having all those comments will solve them on a following PR.

@0xisk
0xisk merged commit 36b81b6 into post-release Jun 9, 2026
9 checks passed
@github-project-automation github-project-automation Bot moved this from Needs Review to Complete/Stable Release in OZ Development for Midnight Jun 9, 2026
@0xisk
0xisk deleted the feat/forwarder branch June 9, 2026 14:30
0xisk added a commit that referenced this pull request Jun 9, 2026
Address the remaining review feedback from #526, tracked in #565.

* Type safety: replace the generic `Forwarder<T>` module, which
  rebuilt the recipient from `_parent.bytes` and could be mis-encoded
  if instantiated with the wrong address kind, with two concrete
  modules. `ForwarderShielded` stores
  `Either<ZswapCoinPublicKey, ContractAddress>` and `ForwarderUnshielded`
  stores `Either<ContractAddress, UserAddress>`. The parent is passed
  straight to the send call, so the deployer must choose the recipient
  arm explicitly and no encoding ambiguity remains.

* Immutability: drop `sealed` from the public forwarder parent and stop
  claiming it is immutable. A preset that keeps the parent fixed simply
  omits a setter; a consuming contract may add one. ForwarderPrivate is
  left sealed (its commitment is the sole drain gate).

* Tests: define the empty private state and witnesses once in
  `multisig/test/EmptyWitnesses.ts` and import it into the forwarder
  simulators, removing the five redundant per-contract witness files.
  Split the combined mock into `MockForwarderShielded` /
  `MockForwarderUnshielded` (the two modules cannot share one
  `Initializable` instance) and add the matching `Either` test helpers.

Refs: #565
0xisk added a commit that referenced this pull request Jun 9, 2026
Address the remaining review feedback from #526, tracked in #565.

* Type safety: replace the generic `Forwarder<T>` module, which
  rebuilt the recipient from `_parent.bytes` and could be mis-encoded
  if instantiated with the wrong address kind, with two concrete
  modules. `ForwarderShielded` stores
  `Either<ZswapCoinPublicKey, ContractAddress>` and `ForwarderUnshielded`
  stores `Either<ContractAddress, UserAddress>`. The parent is passed
  straight to the send call, so the deployer must choose the recipient
  arm explicitly and no encoding ambiguity remains.

* Immutability: drop `sealed` from the public forwarder parent and stop
  claiming it is immutable. A preset that keeps the parent fixed simply
  omits a setter; a consuming contract may add one. ForwarderPrivate is
  left sealed (its commitment is the sole drain gate).

* Tests: define the empty private state and witnesses once in
  `multisig/test/EmptyWitnesses.ts` and import it into the forwarder
  simulators, removing the five redundant per-contract witness files.
  Split the combined mock into `MockForwarderShielded` /
  `MockForwarderUnshielded` (the two modules cannot share one
  `Initializable` instance) and add the matching `Either` test helpers.

Refs: #565
0xisk added a commit that referenced this pull request Jun 17, 2026
Address the remaining review feedback from #526, tracked in #565.

* Type safety: replace the generic `Forwarder<T>` module, which
  rebuilt the recipient from `_parent.bytes` and could be mis-encoded
  if instantiated with the wrong address kind, with two concrete
  modules. `ForwarderShielded` stores
  `Either<ZswapCoinPublicKey, ContractAddress>` and `ForwarderUnshielded`
  stores `Either<ContractAddress, UserAddress>`. The parent is passed
  straight to the send call, so the deployer must choose the recipient
  arm explicitly and no encoding ambiguity remains.

* Immutability: drop `sealed` from the public forwarder parent and stop
  claiming it is immutable. A preset that keeps the parent fixed simply
  omits a setter; a consuming contract may add one. ForwarderPrivate is
  left sealed (its commitment is the sole drain gate).

* Tests: define the empty private state and witnesses once in
  `multisig/test/EmptyWitnesses.ts` and import it into the forwarder
  simulators, removing the five redundant per-contract witness files.
  Split the combined mock into `MockForwarderShielded` /
  `MockForwarderUnshielded` (the two modules cannot share one
  `Initializable` instance) and add the matching `Either` test helpers.

Refs: #565
0xisk added a commit that referenced this pull request Jun 18, 2026
Address the remaining review feedback from #526, tracked in #565.

* Type safety: replace the generic `Forwarder<T>` module, which
  rebuilt the recipient from `_parent.bytes` and could be mis-encoded
  if instantiated with the wrong address kind, with two concrete
  modules. `ForwarderShielded` stores
  `Either<ZswapCoinPublicKey, ContractAddress>` and `ForwarderUnshielded`
  stores `Either<ContractAddress, UserAddress>`. The parent is passed
  straight to the send call, so the deployer must choose the recipient
  arm explicitly and no encoding ambiguity remains.

* Immutability: drop `sealed` from the public forwarder parent and stop
  claiming it is immutable. A preset that keeps the parent fixed simply
  omits a setter; a consuming contract may add one. ForwarderPrivate is
  left sealed (its commitment is the sole drain gate).

* Tests: define the empty private state and witnesses once in
  `multisig/test/EmptyWitnesses.ts` and import it into the forwarder
  simulators, removing the five redundant per-contract witness files.
  Split the combined mock into `MockForwarderShielded` /
  `MockForwarderUnshielded` (the two modules cannot share one
  `Initializable` instance) and add the matching `Either` test helpers.

Refs: #565
@0xisk 0xisk mentioned this pull request Jun 30, 2026
8 tasks
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: Complete/Stable Release

Development

Successfully merging this pull request may close these issues.

3 participants