Skip to content

refactor(multisig): make forwarder parent type-safe - #566

Merged
0xisk merged 10 commits into
mainfrom
refactor/forwarder-review-feedback
Jun 19, 2026
Merged

refactor(multisig): make forwarder parent type-safe#566
0xisk merged 10 commits into
mainfrom
refactor/forwarder-review-feedback

Conversation

@0xisk

@0xisk 0xisk commented Jun 9, 2026

Copy link
Copy Markdown
Member

Important

Depends on #526 (feat/forwarder). This branch is stacked on it.
The diff will also show #526's commits until that PR merges into
post-release; review only the refactor(multisig): make forwarder parent type-safe commit here.

Types of changes

What types of changes does your code introduce to OpenZeppelin Midnight Contracts?

  • 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 #565

Addresses the three remaining review threads from @andrew-fleming on #526.
The forwarder is not yet released, so these refine unmerged code.

  • Type safety. The generic Forwarder<T> rebuilt the recipient from
    _parent.bytes and wrapped it in a hard-coded Either arm, so
    instantiating it with the wrong address kind silently mis-encoded the
    recipient. It is replaced by two concrete modules that store the real
    recipient type and pass it straight to the send call:

    • ForwarderShieldedEither<ZswapCoinPublicKey, ContractAddress>
    • ForwarderUnshieldedEither<ContractAddress, UserAddress>

    The deployer now chooses the recipient arm explicitly; there is no way
    to mis-encode it.

  • Immutability. Dropped sealed from the public forwarder parent and
    removed the "immutable after init" claim. A preset that keeps the
    parent fixed simply omits a setter; a consuming contract may add one.
    ForwarderPrivate stays sealed (its commitment is the sole drain gate).

  • Test cleanup. Empty private state + witnesses defined once in
    multisig/test/EmptyWitnesses.ts and shared by the forwarder
    simulators; five redundant per-contract witness files removed. The
    combined mock is split into MockForwarderShielded /
    MockForwarderUnshielded (the two modules cannot share one
    Initializable instance).

PR Checklist

Further comments

A single Either<T1, T2> generic could not serve both deposit kinds
(shielded and unshielded need different arm orderings), so the two-module
split is the type-safe path Andrew preferred. All forwarder module +
preset suites pass (vitest run Forwarder, 40 tests); biome and tsc
are clean.

Summary by CodeRabbit

  • New Features

    • Introduced ForwarderShielded and ForwarderUnshielded modules enabling atomic value forwarding to configurable parent recipients.
    • Added initialization guards ensuring proper contract setup before deposit operations.
  • Improvements

    • Preset contracts now support flexible parent recipient types.

@0xisk
0xisk requested review from a team as code owners June 9, 2026 14:49
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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: 458071a9-22dd-4d5c-8f55-f87c0a5d4e0e

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

Replaces the generic Forwarder<T> compact module with two concrete type-safe modules: ForwarderShielded (parent as Either<ZswapCoinPublicKey, ContractAddress>) and ForwarderUnshielded (parent as Either<ContractAddress, UserAddress>). Preset contracts, mock contracts, TypeScript simulators, and tests are all updated to match. Scattered empty witness boilerplate files are consolidated into a single shared EmptyWitnesses.ts.

Changes

Forwarder module split and test infrastructure update

Layer / File(s) Summary
New ForwarderShielded and ForwarderUnshielded modules
contracts/src/multisig/ForwarderShielded.compact, contracts/src/multisig/ForwarderUnshielded.compact
Adds both concrete forwarder modules with typed Either<> parent state, one-time initialize circuits with zero-address guards, _deposit circuits forwarding shielded/unshielded coins atomically, and assertInitialized/assertNotInitialized guard circuits. Replaces the deleted generic Forwarder<T> module.
Preset contracts updated to Either parents and renamed deposit
contracts/src/multisig/presets/forwarder/ForwarderShielded.compact, contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact
Constructor and getParent types change from raw key/address to Either<> in both presets; depositUnshielded renamed to deposit; delegate calls updated to Forwarder__deposit; export lists extended with ContractAddress/Either.
Shared EmptyWitnesses helper and UserAddress test utils
contracts/src/multisig/test/EmptyWitnesses.ts, contracts/test-utils/address.ts
Introduces a single EmptyPrivateState/emptyWitnesses module replacing five identical per-contract witness files. Adds UserAddress type and createEitherTestUserAddress, ZERO_USER_ADDRESS, createEitherTestUnshieldedContract, ZERO_UNSHIELDED_CONTRACT helpers to the shared address test-utils.
New mock compact contracts and simulator wrappers
contracts/src/multisig/test/mocks/MockForwarderShielded.compact, contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact, contracts/src/multisig/test/simulators/MockForwarderShieldedSimulator.ts, contracts/src/multisig/test/simulators/MockForwarderUnshieldedSimulator.ts, contracts/src/multisig/test/simulators/MockForwarder*Simulator.ts, contracts/src/multisig/test/simulators/presets/Forwarder*Simulator.ts
Adds two new mock compact contracts (conditional initialize, exposed deposit/getParent) and matching TypeScript simulator classes. Updates all existing simulators to use EmptyPrivateState/emptyWitnesses and the new Either parent types; renames depositUnshieldeddeposit; updates getParent return types.
Test suites updated for shielded/unshielded split
contracts/src/multisig/test/Forwarder.test.ts, contracts/src/multisig/test/ForwarderPrivate.test.ts, contracts/src/multisig/test/presets/Forwarder*.test.ts
Rewrites Forwarder.test.ts into separate shielded/unshielded suites covering init success/failure, getParent exposure, init guards for deposit, and deposit smoke tests. Preset tests updated to new parent types and error-message prefixes. ForwarderPrivate.test.ts reformatted (no behavior changes).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • OpenZeppelin/compact-contracts#526: Introduced the original Forwarder<T> generic module and presets that this PR directly replaces with the concrete ForwarderShielded/ForwarderUnshielded split.
  • OpenZeppelin/compact-contracts#562: Both PRs add per-module _isInitialized ledger flags with assertInitialized/assertNotInitialized guard circuits, following the same initialization isolation pattern.
  • OpenZeppelin/compact-contracts#594: Directly related to the earlier forwarder preset implementations that this PR updates.

Suggested reviewers

  • pepebndc

Poem

🐇 A forwarder once generic, now split in two,
With Either arms that know just what to do.
Shielded coins hop left, unshielded goes right,
No more raw .bytes rebuilt in the night.
The witnesses merged — one file for them all,
A tidy warren with no crumbled wall! 🌿

🚥 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 title 'refactor(multisig): make forwarder parent type-safe' directly and clearly summarizes the main change: splitting the generic Forwarder module into type-safe concrete modules.
Linked Issues check ✅ Passed All three coding objectives from issue #565 are fully met: (1) Generic Forwarder replaced with ForwarderShielded and ForwarderUnshielded storing explicit Either<> parents, (2) sealed keyword removed from public parent and immutability claims dropped, (3) empty witnesses consolidated in EmptyWitnesses.ts and shared across simulators.
Out of Scope Changes check ✅ Passed All code changes directly support the three review objectives from #565. Changes include removing the generic Forwarder, adding two concrete modules with Either<> parents, consolidating witness files, updating simulators, tests, and presets—all within the stated scope of the refactor.

✏️ 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 refactor/forwarder-review-feedback

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

@0xisk
0xisk force-pushed the refactor/forwarder-review-feedback branch 2 times, most recently from 2720d91 to 8d33f18 Compare June 9, 2026 15:08

@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.

Changes look good, @0xisk! I just left two questions that I think we should address before merging

Comment thread contracts/src/multisig/test/Forwarder.test.ts
Comment thread contracts/src/multisig/test/EmptyWitnesses.ts
Comment thread contracts/src/multisig/ForwarderShielded.compact Outdated
*/
module ForwarderUnshielded {
import CompactStandardLibrary;
import "../security/Initializable" prefix Initializable_;

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.

Same question here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done. Same inline _isInitialized flag here too, via the #610 rebase.

Comment thread contracts/test-utils/address.ts
0xisk added a commit that referenced this pull request Jun 10, 2026
Drop the shared Initializable import from ForwarderShielded and
ForwarderUnshielded; each now owns its _isInitialized ledger flag and
inlines assertInitialized / assertNotInitialized with module-specific
revert messages.

These are new, unreleased modules, so this avoids shipping the shared
transitive-dependency pattern that collapses two same-directory imports
into one ledger slot (compiler#270), matching the per-module layout used
elsewhere. Tests assert the new ForwarderShielded / ForwarderUnshielded
init messages.

Refs: #566
0xisk added a commit that referenced this pull request Jun 10, 2026
Add init coverage for a contract-address parent on both forwarder
modules, including a zero-contract-address failure case, per review
followup. Adds createEitherTestUnshieldedContract / ZERO_UNSHIELDED_CONTRACT
test helpers for the unshielded contract arm (Either<ContractAddress,
UserAddress>); the shielded arm reuses the existing contract-address
helpers.

Refs: #566
@0xisk

0xisk commented Jun 10, 2026

Copy link
Copy Markdown
Member Author

Thanks @andrew-fleming! Pushed the fixes:

  • Drop the Initializable import from ForwarderShielded / ForwarderUnshielded734a9a1
    Each new module now owns its _isInitialized flag and inlines assertInitialized / assertNotInitialized with module-specific messages, so we don't ship the shared transitive-dependency pattern (compiler#270) in new code. Left Signer / ForwarderPrivate as-is.
  • followup: contract-address parent tests (+ zero-address failure)e520c26
    Both modules now cover a contract-address parent on init, a zero-contract-address failure, and parent read-back. Added createEitherTestUnshieldedContract / ZERO_UNSHIELDED_CONTRACT helpers for the unshielded contract arm.

Verified: vitest run Forwarder (40 tests), tsc, and biome all clean.

@0xisk
0xisk enabled auto-merge (squash) June 10, 2026 12:22
Base automatically changed from post-release to main June 15, 2026 14:43
0xisk added a commit that referenced this pull request Jun 17, 2026
Drop the shared Initializable import from ForwarderShielded and
ForwarderUnshielded; each now owns its _isInitialized ledger flag and
inlines assertInitialized / assertNotInitialized with module-specific
revert messages.

These are new, unreleased modules, so this avoids shipping the shared
transitive-dependency pattern that collapses two same-directory imports
into one ledger slot (compiler#270), matching the per-module layout used
elsewhere. Tests assert the new ForwarderShielded / ForwarderUnshielded
init messages.

Refs: #566
0xisk added a commit that referenced this pull request Jun 17, 2026
Add init coverage for a contract-address parent on both forwarder
modules, including a zero-contract-address failure case, per review
followup. Adds createEitherTestUnshieldedContract / ZERO_UNSHIELDED_CONTRACT
test helpers for the unshielded contract arm (Either<ContractAddress,
UserAddress>); the shielded arm reuses the existing contract-address
helpers.

Refs: #566
@0xisk
0xisk force-pushed the refactor/forwarder-review-feedback branch from e520c26 to ff118f9 Compare June 17, 2026 12:03

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
contracts/src/multisig/ForwarderUnshielded.compact (1)

62-70: 💤 Low value

Consider extracting zero-check to Utils for consistency with ForwarderShielded.

ForwarderShielded uses Utils_isKeyOrAddressZero(parent) while this module inlines the zero check. Both are correct, but extracting a Utils_isContractOrUserAddressZero helper would align the modules and reduce duplication if similar checks are needed elsewhere.

🤖 Prompt for 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.

In `@contracts/src/multisig/ForwarderUnshielded.compact` around lines 62 - 70, The
initialize function in ForwarderUnshielded contains an inlined zero-check for
the parent parameter (the isZero variable assignment that checks either default
ContractAddress or default UserAddress), while ForwarderShielded uses a utility
function Utils_isKeyOrAddressZero for the same purpose. Extract this zero-check
logic into a new utility function in the Utils module (name it
Utils_isContractOrUserAddressZero to reflect that it handles
Either<ContractAddress, UserAddress>) and replace the inline logic in the
initialize function with a call to this new utility function, ensuring
consistency between both ForwarderUnshielded and ForwarderShielded modules.
🤖 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/test-utils/address.ts`:
- Around line 123-125: The encodeToUserAddress function does not enforce a fixed
32-byte width for UserAddress fixtures, allowing bytes to exceed 32 bytes when
inputs are longer than 32 ASCII bytes since toHexPadded pads but never
truncates. Modify the function to slice or truncate the resulting bytes array to
exactly 32 bytes after the Uint8Array.from conversion, ensuring all UserAddress
fixtures have a consistent fixed width regardless of input length.

---

Nitpick comments:
In `@contracts/src/multisig/ForwarderUnshielded.compact`:
- Around line 62-70: The initialize function in ForwarderUnshielded contains an
inlined zero-check for the parent parameter (the isZero variable assignment that
checks either default ContractAddress or default UserAddress), while
ForwarderShielded uses a utility function Utils_isKeyOrAddressZero for the same
purpose. Extract this zero-check logic into a new utility function in the Utils
module (name it Utils_isContractOrUserAddressZero to reflect that it handles
Either<ContractAddress, UserAddress>) and replace the inline logic in the
initialize function with a call to this new utility function, ensuring
consistency between both ForwarderUnshielded and ForwarderShielded modules.
🪄 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: e957fe25-669c-43f3-b1ba-c93c03f86b75

📥 Commits

Reviewing files that changed from the base of the PR and between fb59f2a and ff118f9.

📒 Files selected for processing (26)
  • contracts/src/multisig/Forwarder.compact
  • contracts/src/multisig/ForwarderShielded.compact
  • contracts/src/multisig/ForwarderUnshielded.compact
  • contracts/src/multisig/presets/forwarder/ForwarderShielded.compact
  • contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact
  • contracts/src/multisig/test/EmptyWitnesses.ts
  • 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/MockForwarderShielded.compact
  • contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact
  • 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/MockForwarderShieldedSimulator.ts
  • contracts/src/multisig/test/simulators/MockForwarderSimulator.ts
  • contracts/src/multisig/test/simulators/MockForwarderUnshieldedSimulator.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/test/witnesses/MockForwarderPrivateWitnesses.ts
  • contracts/src/multisig/test/witnesses/MockForwarderWitnesses.ts
  • contracts/src/multisig/test/witnesses/presets/ForwarderPrivateWitnesses.ts
  • contracts/src/multisig/test/witnesses/presets/ForwarderShieldedWitnesses.ts
  • contracts/src/multisig/test/witnesses/presets/ForwarderUnshieldedWitnesses.ts
  • contracts/test-utils/address.ts
💤 Files with no reviewable changes (8)
  • contracts/src/multisig/test/witnesses/MockForwarderPrivateWitnesses.ts
  • contracts/src/multisig/test/witnesses/presets/ForwarderPrivateWitnesses.ts
  • contracts/src/multisig/test/witnesses/presets/ForwarderUnshieldedWitnesses.ts
  • contracts/src/multisig/test/simulators/MockForwarderSimulator.ts
  • contracts/src/multisig/Forwarder.compact
  • contracts/src/multisig/test/mocks/MockForwarder.compact
  • contracts/src/multisig/test/witnesses/presets/ForwarderShieldedWitnesses.ts
  • contracts/src/multisig/test/witnesses/MockForwarderWitnesses.ts

Comment thread contracts/test-utils/address.ts Outdated
Comment on lines +123 to +125
export const encodeToUserAddress = (str: string): UserAddress => ({
bytes: Uint8Array.from(Buffer.from(toHexPadded(str), 'hex')),
});

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Enforce fixed 32-byte width for UserAddress fixtures.

Line 124 can produce bytes.length > 32 for inputs longer than 32 ASCII bytes because toHexPadded pads but never truncates. That can silently create invalid unshielded-recipient fixtures.

Suggested patch
 export const encodeToUserAddress = (str: string): UserAddress => ({
-  bytes: Uint8Array.from(Buffer.from(toHexPadded(str), 'hex')),
-});
+  bytes: (() => {
+    const bytes = Uint8Array.from(Buffer.from(toHexPadded(str), 'hex'));
+    if (bytes.length !== 32) {
+      throw new Error('Invalid Input: `UserAddress` must be exactly 32 bytes');
+    }
+    return bytes;
+  })(),
+});
🤖 Prompt for 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.

In `@contracts/test-utils/address.ts` around lines 123 - 125, The
encodeToUserAddress function does not enforce a fixed 32-byte width for
UserAddress fixtures, allowing bytes to exceed 32 bytes when inputs are longer
than 32 ASCII bytes since toHexPadded pads but never truncates. Modify the
function to slice or truncate the resulting bytes array to exactly 32 bytes
after the Uint8Array.from conversion, ensuring all UserAddress fixtures have a
consistent fixed width regardless of input length.

@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.

I left a few comments but this looks about good to go. I think we should improve our tests by checking the zswap inputs and outputs to ensure the forwarder is forwarding correctly on the zswap level. Let's call it a followup. This is also true of the other modules that handle send and receive

Comment thread contracts/src/multisig/ForwarderUnshielded.compact Outdated
Comment thread contracts/src/multisig/ForwarderUnshielded.compact Outdated
Comment thread contracts/src/multisig/ForwarderUnshielded.compact Outdated
Comment thread contracts/src/multisig/ForwarderShielded.compact Outdated
@0xisk

0xisk commented Jun 18, 2026

Copy link
Copy Markdown
Member Author

I left a few comments but this looks about good to go. I think we should improve our tests by checking the zswap inputs and outputs to ensure the forwarder is forwarding correctly on the zswap level. Let's call it a followup. This is also true of the other modules that handle send and receive

I agree and the best way for doing this is by hacing the simulator runs those tests on the local node infra so we are more sure about that.

@0xisk 0xisk mentioned this pull request Jun 18, 2026
0xisk added 8 commits June 18, 2026 12:30
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
Drop the shared Initializable import from ForwarderShielded and
ForwarderUnshielded; each now owns its _isInitialized ledger flag and
inlines assertInitialized / assertNotInitialized with module-specific
revert messages.

These are new, unreleased modules, so this avoids shipping the shared
transitive-dependency pattern that collapses two same-directory imports
into one ledger slot (compiler#270), matching the per-module layout used
elsewhere. Tests assert the new ForwarderShielded / ForwarderUnshielded
init messages.

Refs: #566
Add init coverage for a contract-address parent on both forwarder
modules, including a zero-contract-address failure case, per review
followup. Adds createEitherTestUnshieldedContract / ZERO_UNSHIELDED_CONTRACT
test helpers for the unshielded contract arm (Either<ContractAddress,
UserAddress>); the shielded arm reuses the existing contract-address
helpers.

Refs: #566
Generalize ForwarderPrivate._drain's parent from Bytes<32> to
Either<ZswapCoinPublicKey, ContractAddress> so the operator selects the
recipient type (coin public key or contract address) at drain time. The
commitment still binds only the 32 address bytes plus opSecret, so the
deployer's off-chain computation is unchanged and the same commitment
authorizes either arm.

* canonicalize the parent first, zeroing the inactive arm, then derive
  the preimage bytes from the active arm so a dual-arm input cannot
  desync the committed bytes from the sent recipient
* reject a zero parent before the commitment gate
* disclose(p) sends to the operator-chosen recipient; both the address
  bytes and the arm selector stay encrypted in the Zswap output
* drop the sealed modifier on _parentCommitment to match the public
  forwarders' _parent; write-once stays enforced by the Initializable
  init gate

Tests drive both arms, zero-parent rejection, and dual-arm
canonicalization; the commitment is read via a new mock getter since a
prefix-imported module ledger field is not in the public reader. _drain
recompiles at k=16, rows=48133.
Extend the ForwarderPrivate module header to reflect the generalized
drain: the operator chooses the recipient arm (coin public key or
contract address) via the Either parent, and the commitment binds the
parent bytes, not the recipient type. Comment-only; no behavior change.
A shielded send to a contract publishes the recipient contract address in
cleartext on the transaction (the protocol routes the coin to a named
contract); a coin-public-key recipient stays hidden in the Zswap note.
Confirmed end-to-end on preprod. The prior _drain accepted an
Either<ZswapCoinPublicKey, ContractAddress> parent, so the contract arm
leaked the parent at every drain — defeating the private-parent guarantee.

Restrict the parent to a ZswapCoinPublicKey:

* _drain / preset drain: parent Either<...> -> ZswapCoinPublicKey, always
  sent via the left arm. Drop the canonicalize + arm-selection (and the
  dual-arm-desync / operator-selected-type concerns they guarded).
* zero-parent guard is now isKeyZero.
* commitment scheme unchanged (over the parent key's 32 bytes); the
  deployer's off-chain computation is unchanged.
* tests: drop the contract-arm / dual-arm cases; drain drives a coin-key
  parent. 24/24 pass.

Breaking for callers (pre-release): drain's parent argument changes type.
_drain recompiles at k=16, rows=41961 (was 48133).
A shielded or unshielded send to a contract recipient is valid only if
that contract claims the output in the same transaction. An atomic
forwarder runs only its own circuit, so a third-party contract parent
never claims the output and every deposit is rejected. Confirmed on
preprod for the shielded case (unclaimed output, node error 186).

Narrow the ForwarderShielded and ForwarderUnshielded circuit parameter
to the deliverable arm (ZswapCoinPublicKey and UserAddress) so a
contract parent cannot be expressed. The _parent ledger field stays a
generic Either, so a future CMA circuit upgrade can add contract support
with no state-layout migration. initialize stores the supported arm via
left()/right(); getParent returns the stored Either.

Update mocks, simulators, and tests for the narrow constructor argument
and drop the obsolete contract-address parent cases.
ForwarderPrivate now uses the inline _isInitialized flag (init-dep
removal from #610, merged during rebase), which asserts
"ForwarderPrivate: contract not initialized". Update the two init-guard
assertions that still expected the old Initializable message.
@0xisk
0xisk force-pushed the refactor/forwarder-review-feedback branch from 07e7aec to 79f8e79 Compare June 18, 2026 10:41
@0xisk

0xisk commented Jun 18, 2026

Copy link
Copy Markdown
Member Author

Forwarder recipient finding + rebased on main

Confirmed on preprod (shielded case) that an atomic forward to a contract recipient cannot be delivered. A shielded/unshielded send to a contract is valid only if that contract claims the output in the same transaction. An atomic forwarder runs only its own circuit, so a third-party contract parent never claims it and the deposit is rejected (unclaimed output, node error 186).

Fix (pushed):

  • ForwarderShielded / ForwarderUnshielded: narrow the circuit parameter to the deliverable arm (ZswapCoinPublicKey / UserAddress), so a contract parent can't be expressed and no forwarder can be deployed in a brick-on-deposit state.
  • _parent stays a generic Either on the ledger. A future CMA circuit upgrade can then add contract delivery (if cross-contract UTXO claiming lands) without a state-layout migration.
  • ForwarderPrivate: coin-public-key only, for the same reason plus a contract recipient would leak the address in cleartext.

Rebased onto main (incl. #610); ForwarderPrivate now uses the inline _isInitialized flag too.

This supersedes the earlier canonicalize / isEitherZero<T1, T2> / contract-address-parent-test suggestions, which no longer apply to the narrowed param. I'll handle the remaining open item (warn that the parent must be able to spend the forwarded funds) next.

Note: the unshielded case is reasoned from the same in-tx-claim rule; it isn't independently lab-confirmed like the shielded one.

0xisk added 2 commits June 18, 2026 12:48
Per review: a forwarder strands every deposit if the parent cannot spend
the forwarded funds, and the zero key/address guard catches only the
all-zero value. Document the deployer's responsibility in both the module
header and `initialize`, for ForwarderShielded and ForwarderUnshielded.
Per review: `toHexPadded` pads but never truncates, so inputs longer than
32 ASCII bytes silently produced oversized UserAddress fixtures. Throw if
the encoded value is not exactly 32 bytes, matching `encodeToAddress`.

@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.

Great work, @0xisk! Non-blocking final suggestions. We can apply the suggestions in a new PR to avoid an approve + merge lag. Your call

Comment on lines +255 to +265
// INV-12 / INV-25: a drain performs no ledger write. `_parentCommitment` is
// written only at init; it is unchanged after a drain and no recipient field
// is added. (Read via the getter circuit — the module is imported with a
// prefix only, so it is not in the public ledger reader.)
//
// INV-17 (recipient privacy): the parent coin public key flows only into the
// `sendShielded` recipient, where it is encrypted inside the Zswap output and
// never appears on the public transcript. Confirmed end-to-end on preprod (a
// coin-public-key recipient occurs 0 times in the published tx); not
// simulator-observable, so it is asserted by the residual-surface check here.
describe('drain — residual public surface (INV-12 / INV-17 / INV-25)', () => {

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.

👍

@@ -0,0 +1,152 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/ForwarderUnshielded.compact)

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.

Suggested change
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/ForwarderUnshielded.compact)
// OpenZeppelin Compact Contracts v0.2.0 (multisig/ForwarderUnshielded.compact)

@@ -0,0 +1,149 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/ForwarderShielded.compact)

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.

Suggested change
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/ForwarderShielded.compact)
// OpenZeppelin Compact Contracts v0.2.0 (multisig/ForwarderShielded.compact)

@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/test/mocks/MockForwarderShielded.compact)

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.

Suggested change
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/test/mocks/MockForwarderShielded.compact)
// OpenZeppelin Compact Contracts v0.2.0 (multisig/test/mocks/MockForwarderShielded.compact)

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.

followup: We should also apply this to all the mocks and remove the version

// WARNING: FOR TESTING PURPOSES ONLY.
// This contract exposes internal circuits and bypasses safety checks that the
// corresponding production contract relies on. DO NOT deploy or use this
// contract in any production application.

@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/test/mocks/MockForwarderUnshielded.compact)

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.

Suggested change
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/test/mocks/MockForwarderUnshielded.compact)
// OpenZeppelin Compact Contracts v0.2.0 (multisig/test/mocks/MockForwarderUnshielded.compact)

@0xisk
0xisk merged commit 5908d2e into main Jun 19, 2026
9 checks passed
@0xisk
0xisk deleted the refactor/forwarder-review-feedback branch June 19, 2026 06:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dev: address remaining Forwarder review feedback from #526

2 participants