Skip to content

fix(paymaster): auto-whitelist the real ZkEmailInvites claim selectors (#188) - #190

Merged
hudsonhrh merged 2 commits into
mainfrom
hudsonhrh/halifax
Aug 4, 2026
Merged

fix(paymaster): auto-whitelist the real ZkEmailInvites claim selectors (#188)#190
hudsonhrh merged 2 commits into
mainfrom
hudsonhrh/halifax

Conversation

@hudsonhrh

Copy link
Copy Markdown
Member

Fixes #188.

The bug

OrgDeployer._appendZkEmailInvitesRules derived its four selectors by hashing pre-Blocker-2 signature strings whose ZkEmailProof tuple ended in string. The live struct ends in bytes32 fromDomainHash, so all four were wrong:

stale (registered) live (real entrypoint)
claimRoleByDomain 0xc8864f92 0x24b5e3ba
claimRoleByEmail 0x50b2f726 0x8c149bab
registerAndClaimByDomainWithPasskey 0xcc1866ac 0x6108482e
registerAndClaimByEmailWithPasskey 0xebd847f2 0x998dc9d6

Every new org with autoWhitelistContracts: true + zk-email enabled got four rules no function answers, and zero sponsorship on the real claim entrypoints — gasless zk-email claims fail rule validation.

git log -S shows both the stale strings and the fromDomainHash struct landed in the same squashed commit (#170): the drift was introduced mid-PR when Blocker-2 domain binding changed ZkEmailProof, and OrgDeployer wasn't updated with it.

Blast radius. _appendZkEmailInvitesRules has never run on-chain — both existing ZkEmailInvites modules (Test6, KUBI on Gnosis) are retrofits whose rules were set by their own scripts. But the zk infra is wired on both chains and the beacon is registered on both, so the bug is armed for the very next deployFullOrgWithZkEmail — i.e. script/org/DeployComfiestHouse.s.sol, which has not been broadcast. Land the v19 upgrade before that.

The fix

src/OrgDeployer.sol — the four selectors become compiler-derived ZkEmailInvites.<fn>.selector, so they cannot drift from the ABI again. The import is free: OrgDeployer already depends on ZkEmailInvites transitively via ModulesFactory, .selector is a compile-time constant, and no creation code is pulled in. Production size actually drops 119 B (22,332 B, 2,244 B of EIP-170 headroom); storage layout is byte-identical.

Two hardening changes in the same builder, both prompted by what made this bug invisible:

  • _appendEducationHubRules now returns the advanced index like every sibling helper. It was the only one returning void with the caller hand-advancing i += 4 — add a rule there and it silently overwrites the zk slots.
  • if (i != count) revert RuleCountMismatch(); replaces the incidental loudness that previously came only from PaymasterHub's zero-address guard.

Tests. The test that should have caught this was tautological by its own admission — its comment read "copied verbatim from OrgDeployer", so it re-derived the same wrong constant and asserted the bug. The other guard, testPaymasterSelectorAccuracy, is a copy of the source literals that never references OrgDeployer at all, and covered only 23 of the 49 signature strings.

test/OrgDeployerPaymasterRules.t.sol (new) replaces that pattern: a harness over the internal pure builder asserting both-direction set equality of all 52 (target, selector) pairs against .selector values, per-branch counts (52/48/48/44 — the first assertion of the count literal anywhere), the zk gas hints, and a literal tripwire on the four zk selectors so a future struct change fails loudly instead of moving both sides together (live orgs' existing rules would need migrating). No fork, no RPC, runs in ~20 ms.

script/upgrades/UpgradeOrgDeployerZkEmailRules.s.sol (new) — OrgDeployer v19 on the fee-free path (Satellite.upgradeBeaconDirect on Gnosis, Hub.upgradeBeaconLocal on Arbitrum; both call the same poaManager.upgradeBeacon, so per-chain bookkeeping is identical to the Hyperlane form without the 0.005 ETH fee or 5-minute relay — and both sides are fork-simulatable). Steps are idempotent. The sims deploy a real fixture org on the fork before and after the upgrade and read PaymasterHub.getRule back, because these selectors are folded constants that never appear literally in the bytecode — grepping the impl proves nothing, only a real deploy shows which rules land.

script/zkemail/IntegrateZkEmailTest6.s.sol — comment only. Its SUPERSEDED constants are the historical record of what was broadcast; the old comment invited copying them forward.

Verification

  • forge test: 1887 passed, 0 failed. forge fmt --check clean. FOUNDRY_PROFILE=production forge build --skip test clean.
  • Fault injection (the guards are load-bearing, not decorative):
    • reintroducing the stale claimRoleByDomain string → 3 of the 5 new tests fail and the ZkEmailOrgFlow E2E test fails;
    • drifting setFolders (one of the 21 previously-unpinned selectors) → the new test fails while testPaymasterSelectorAccuracy stays green.
  • Production-profile fork sims, both chains, PASS:
    BEFORE (v18): 4/4 stale selectors sponsored, 0/4 real claim entrypoints sponsored
    AFTER  (v19): 4/4 real claim entrypoints sponsored (800k/800k/1.2M/1.2M), 0/4 stale
    
  • Version probe (both surfaces, both chains, 2026-08-04): v19 FREE, predicted impl 0x90BAe532D26100a2106c3b16bEA1Ad27D2286b3A. Note the issue says "v18 candidate" — v18 is already live from feat(TaskManager v7): unclaimTask — release a claimed task back to the pool #187, so this ships as v19.

Follow-ups (not in this PR)

  • Test6 on Gnosis carries four orphan allowed rules under the stale selectors (verified live), left by the superseded retrofit script and never revoked when the ceremony added the correct ones. Harmless today — no function answers them, and a bogus call burns the same claim budget a real selector would — but it's an unreviewed grant that a future selector collision would auto-sponsor. Cleanup is one setRulesBatch(allowed=false) via the Satellite; worth folding into the next Test6 admin batch.
  • script/fixes/AddCreateTasksBatchSelectorRules.s.sol hardcodes 0xc18aa1c9 (TaskManager v5 createTasksBatch; live is 0xf31d148f) with no SUPERSEDED banner — same failure class, re-runnable. Ditto 0x48db6f65 for the v5 updateTask across four script/fixes/ files.

🤖 Generated with Claude Code

OrgDeployer._appendZkEmailInvitesRules derived its four selectors by hashing
PRE-Blocker-2 signature strings whose ZkEmailProof tuple ended in `string`. The
live struct ends in `bytes32 fromDomainHash`, so all four were wrong:

  stale 0xc8864f92 / 0x50b2f726 / 0xcc1866ac / 0xebd847f2  (no function answers these)
  live  0x24b5e3ba / 0x8c149bab / 0x6108482e / 0x998dc9d6

Every new org deployed with autoWhitelistContracts + zk-email enabled therefore
got four dead paymaster rules and zero sponsorship on the real claim entrypoints
— gasless zk-email claims fail rule validation. Both the strings and the struct
change landed in the same squashed commit (#170), so the drift was introduced
mid-PR and never caught.

Fixes #188.

- src/OrgDeployer.sol: the four selectors are now compiler-derived
  `ZkEmailInvites.<fn>.selector`, so they cannot drift from the ABI again.
  Hardening in the same builder: `_appendEducationHubRules` returns the advanced
  index like every sibling helper (the caller no longer hand-advances `i += 4`,
  which would silently overwrite the zk slots if a rule were added), and an
  explicit `RuleCountMismatch` invariant replaces the incidental loudness that
  previously came only from PaymasterHub's zero-address guard.

- test/OrgDeployerPaymasterRules.t.sol (new): harness over the internal pure
  builder asserting both-direction set equality of all 52 (target, selector)
  pairs against `.selector` values, per-branch counts (52/48/48/44), the zk gas
  hints, and a literal tripwire on the four zk selectors. This pins all 49
  signature strings — the previous guard, testPaymasterSelectorAccuracy, covered
  only 23 and, being a copy of the same literals, could not catch source drift at
  all.

- test/ZkEmailOrgFlow.t.sol: the test that should have caught this was
  tautological by its own admission ("copied verbatim from OrgDeployer"), so it
  asserted the bug and stayed green. It now derives selectors from the ABI. The
  negative test probed address(0), where a rule can never exist; it now probes
  real deployed modules.

- script/upgrades/UpgradeOrgDeployerZkEmailRules.s.sol (new): OrgDeployer v19
  rollout on the fee-free path (Satellite.upgradeBeaconDirect on Gnosis,
  Hub.upgradeBeaconLocal on Arbitrum). Its sims deploy a real fixture org on the
  fork before AND after the upgrade and read PaymasterHub back, because these
  selectors are folded constants that never appear literally in the bytecode —
  only a real deploy shows which rules land.

- script/zkemail/IntegrateZkEmailTest6.s.sol: comment only. Its SUPERSEDED
  constants are the historical record of what was broadcast; the old comment
  invited copying them forward.

Verification:
- forge test: 1887 passed, 0 failed.
- Fault injection: reintroducing the stale claimRoleByDomain string fails 3 of
  the 5 new tests and the ZkEmailOrgFlow E2E test; drifting a previously
  unpinned selector (setFolders) fails the new test while
  testPaymasterSelectorAccuracy stays green.
- FOUNDRY_PROFILE=production sims PASS on both chains:
    BEFORE (v18): 4/4 stale selectors sponsored, 0/4 real claim entrypoints sponsored
    AFTER  (v19): 4/4 real claim entrypoints sponsored (800k/800k/1.2M/1.2M), 0/4 stale
- Version probe (both surfaces, both chains): v19 FREE, impl
  0x90BAe532D26100a2106c3b16bEA1Ad27D2286b3A. Storage layout unchanged;
  production size 22,332 B (2,244 B headroom, 119 B smaller than v18).

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Metric Coverage
Lines 100.00% (0/0)
Statements 100.00% (0/0)
Branches 100.00% (0/0)
Functions 100.00% (0/0)
Coverage by file
File Lines Statements Branches Functions

… v19 upgrade

`vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY"))` — the spelling the
older upgrade scripts use — evaluates its default eagerly, so it reverts with
`environment variable "DEPLOYER_PRIVATE_KEY" not found` even when PRIVATE_KEY is
set. Verified both env vars now work, and that Step3_Verify needs neither.

Co-Authored-By: Claude <noreply@anthropic.com>
@hudsonhrh
hudsonhrh merged commit 65a5394 into main Aug 4, 2026
8 checks passed
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.

Bug: OrgDeployer auto-whitelist hashes stale pre-Blocker-2 zk-claim signatures — new orgs get dead paymaster rules

1 participant