Skip to content

feat(zkemail): role invitations via on-chain ZK Email proof verification - #170

Merged
hudsonhrh merged 50 commits into
mainfrom
hudsonhrh/zk-email-role-invites
Jul 29, 2026
Merged

feat(zkemail): role invitations via on-chain ZK Email proof verification#170
hudsonhrh merged 50 commits into
mainfrom
hudsonhrh/zk-email-role-invites

Conversation

@hudsonhrh

@hudsonhrh hudsonhrh commented May 28, 2026

Copy link
Copy Markdown
Member

Summary

ZkEmailInvites is a per-org upgradeable module that lets an org's Executor pre-authorize whole email domains (e.g. @acme.com) or specific addresses to claim role hats by submitting a DKIM-backed ZK Email proof verified on-chain. Proofs are generated entirely client-side (no relayer, no mock in the cryptographic path) and claims are sponsored gaslessly through the existing PaymasterHub + PasskeyAccount flow.

The allowlist is merkle-committed: the org's (domain | address → role hats) entries live as a JSON file on IPFS, committed on-chain by a single merkle root. A two-phase authority split separates off-chain staging from on-chain activation.

How it works

  • Circuits (circuits/) — self-compiled Circom + Groth16:
    • PopRoleClaim.circom (v1, 3 public signals) — DKIM RSA-2048/SHA-256 header verification (@zk-email/circuits EmailVerifier) + in-circuit extraction of the claimer address from the signed Claim POP role for 0x<addr> command. Used for domain claims (~718k constraints).
    • PopRoleClaimV2.circom (v2, 4 public signals) — adds emailHash = Poseidon(From address), enabling specific-address allowlist entries (~1.2M constraints).
    • split-zkey.mjs chunks the large proving keys into a sha256-manifested set so the browser can fetch them in parallel and reassemble byte-perfectly.
  • On-chain verification — vendored snarkjs-generated Groth16Verifier (uint[3]) and Groth16VerifierV2 (uint[4]); PoaDKIMRegistry binds domains to their DKIM public-key hashes. A claim verifies the proof, checks an OZ StandardMerkleTree leaf (discriminated domain vs email) against the active root, and mints the bound hat(s). Real BN254 pairing (~225k gas).
  • Gasless claims — the claim selectors are auto-whitelisted on the org's paymaster rules; a combined register+claim path onboards a first-time user in one sponsored UserOp.
  • Two-phase authoritysetActiveAllowlist is executor-gated (governance); a metadata admin stages the next allowlist off-chain in org metadata. The module is dormant (root == 0) until activated, so existing org flows are unaffected until an org opts in.

Wiring & deployment

  • Integrates into OrgDeployer / ModulesFactory / ModuleTypes; activation is conditional on per-chain protocol infra (two verifiers + DKIM registry) being set via setZkEmailInfrastructure.
  • A beacon-existence gate makes a missing ZkEmailInvites beacon degrade gracefully instead of reverting TypeUnknown and bricking org deploys. The beacon is registered in the canonical deploy helpers (DeployHelper, DeployInfrastructure, DeploySatelliteInfrastructure).
  • ModulesFactory.deployModules deploys the module uninitialized, registers it, then initializes — so the per-org subgraph template (created on ContractRegistered) exists before initialize() emits its config/rule events, letting the subgraph index the full deploy-time snapshot with no eth_calls. initialize() emits the same events as the setters for the same reason.
  • Cross-chain rollout follows the project's Hub-driven pattern: impls DD-deployed at the same deterministic address on both chains; one Arbitrum Hub dispatch upgrades the OrgDeployer/Executor beacons and wires ModulesFactory + infra on both chains over Hyperlane. Scripts live under script/zkemail/; each mutating script ships a fork-sim sibling.

Security review addressed

  • _verifyProofCommon rejects proofs with isCodeExist == false (AccountCodeMissing), asserted after verifyEmailProof — without an embedded account code the accountSalt is not a real Poseidon(emailAddress, accountCode) commitment, which email-rule lookups and per-domain idempotency depend on.

Testing

  • test/ZkEmailInvites.t.sol, test/PoaDKIMRegistry.t.sol, test/Executor.t.sol — unit coverage.
  • test/ZkEmailOrgFlow.t.sol — full org-deploy → register → init → paymaster → govern → claim flow.
  • test/ZkEmailRealProof.t.sol — verifies a genuine client-side domain proof and a genuine specific-address (v2) proof on-chain against a real merkle root, minting the bound hat. No mock in the cryptographic path.
  • All script/zkemail/* fork-sims pass under the production profile.

Live status (Gnosis)

Rolled out on Gnosis; the Test6 org is integrated and has minted a real Member hat from a browser-generated proof. The corrected domain verifier is live at 0x7698c3234E1f76221Dd0619cdEa0FC0D6fF8045D — re-vendored from the current trusted setup after an earlier stale-setup verifier caused on-chain InvalidProof; Test6 rewired via governance proposal #28.

Follow-ups / caveats (not blocking)

  • DEV trusted setup — vendored verifying keys are from a single-contributor dev ceremony. Run a multi-party ceremony + setDomainVerifier/setVerifier redeploy before relying on this beyond Test6.
  • OrgDeployer stale verifier slot — the protocol zk-config slot still points at the old v-zkemail-1 domain verifier for future org deploys; needs a protocol-admin update.
  • PaymasterHub gas-unpack bug — documented in docs/PAYMASTERHUB_GAS_UNPACK_BUG.md (orthogonal to zkemail; workaround in place on Test6). Lib fix + hub upgrade pending.

Out of scope

Frontend (proof-generation UI, allowlist staging) and subgraph changes live in their own repos and are not part of this PR.

🤖 Generated with Claude Code

hudsonhrh and others added 4 commits May 28, 2026 15:27
Add ZkEmailInvites, a per-org upgradeable module that lets an executor
pre-authorize specific emails or whole domains to claim role hats by
submitting a DKIM-backed ZK Email proof, verified on-chain at claim time.
Claims are gasless via the existing PaymasterHub + PasskeyAccount flow
(four claim selectors auto-whitelisted), and a combined register+claim
path onboards first-time users in one sponsored UserOp.

- src/ZkEmailInvites.sol + vendored zk-email surface (IVerifier,
  IDKIMRegistry, CommandUtils) under src/zkemail/
- Wire into OrgDeployer/ModulesFactory/ModuleTypes; conditional on the
  per-chain protocol infra (verifier + DKIM registry) being set
- Beacon-existence gate so a missing ZkEmailInvites beacon degrades
  gracefully instead of bricking org deploys (TypeUnknown)
- Register the beacon in canonical deploy helpers (DeployHelper,
  DeployInfrastructure)
- 50 unit + 13 integration tests; full suite 1513 passing
- Includes forge fmt reformat of UpgradeEligibilitySuperAdminLockdown.s.sol

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…llites

Address review findings on PR #170:

- _verifyProofCommon now rejects proofs with `isCodeExist == false`
  (new AccountCodeMissing error), checked after verifyEmailProof so it
  asserts the proven value. Without an embedded account code, accountSalt
  is not a real Poseidon(emailAddress, accountCode) commitment, which both
  email-rule lookups and per-domain claim idempotency depend on.
- Register ZkEmailInvites in DeploySatelliteInfrastructure.s.sol
  (_deployImplementations + _registerContractTypes); satellites built with
  the standalone script previously left beaconRegistered() false, silently
  skipping the module even after infra was wired.
- 3 new unit tests (domain/email/combined paths reject !isCodeExist and
  confirm no state mutation on revert). Full suite: 1516 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e + infra/integration scripts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…le-invites

# Conflicts:
#	src/OrgDeployer.sol
hudsonhrh and others added 3 commits June 15, 2026 16:18
…aph guidance

ZkEmailInvites.initialize now emits VerifierUpdated/DKIMRegistryUpdated/AccountRegistryUpdated/UniversalFactoryUpdated (mirrors the setters; rules already emit) so indexers read config from logs instead of eth_calls. Upgrade-safe (emits only, no storage change). CLAUDE.md: hard rule + Subgraph 'Events & subgraph indexing' note (emit in initialize; per-org template-ordering caveat; avoid eth_calls).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h indexes deploy-time config/rules, no eth_calls)

ModulesFactory.deployModules now deploys ZkEmailInvites UNINITIALIZED, batch-registers it, then initialize()s it (ModuleDeploymentLib.deployUninitializedProxy + _initZkEmailInvites). Init's config + rule events thus follow ContractRegistered so the per-org subgraph template catches the deploy-time snapshot. Existing orgs (IntegrateZkEmailTest6) do the same via one governance batch: registerOrgContract -> initialize -> setHatMinterAuthorization. ZkEmailInvites.sol unchanged; ModulesFactory is a redeployed plain contract (no upgrade-safety concern). CLAUDE.md documents the register-before-init pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hudsonhrh and others added 3 commits June 15, 2026 18:50
…nt-side claims

Replaces the relayer-oriented email-tx-builder verifier (EmailProof + vendored
Verifier/Groth16Verifier wrapper) with a self-compiled zk-email circuit whose
Groth16 proof is generated entirely client-side and verified on-chain — no
relayer, no mock.

Circuit (circuits/PopRoleClaim.circom, 3 public signals
[pubkeyHash, emailNullifier, claimerAddress]): DKIM RSA-2048/SHA-256 header
verification (@zk-email/circuits EmailVerifier, header-only) + in-circuit
extraction of the address from the signed "Claim POP role for 0x<addr>"
command. ~718k constraints (2^20 ptau).

Contract seam:
- src/zkemail/IVerifier.sol: ZkEmailProof + IZkEmailGroth16Verifier (3-signal).
- src/zkemail/vendor/Groth16Verifier.sol: snarkjs-generated verifier (DEV setup).
- removed CommandUtils.sol (address is now a signal) + vendor/Verifier.sol +
  IGroth16Verifier.sol (email-tx-builder wrapper).
- ZkEmailInvites: domain claims verify [pubkeyHash, nullifier, uint160(claimer)]
  and bind the domain via PoaDKIMRegistry. Per-email claims deferred to Phase 5
  (admin scaffolding kept); claimRoleByEmail + registerAndClaimByEmailWithPasskey
  removed. OrgDeployer paymaster selectors 4 -> 2.

Verified: a genuine client-side proof verifies on-chain + mints a hat
(test/ZkEmailRealProof.t.sol, 6/6, real BN254 pairing ~225k gas). Full suite
1625 passing.

NOTE: the vendored verifying key is a single-contributor DEV trusted setup —
run a multi-party ceremony + redeploy before mainnet (ZkEmailInvites.setVerifier).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks ZkEmailInvites from per-rule storage to a merkle-committed allowlist: the org's
allowed-emails list (whole domains + specific addresses -> role hats) lives as a JSON file
on IPFS, committed on-chain by a single merkle root.

- Circuit v2 (circuits/PopRoleClaimV2.circom): adds a 4th public signal emailHash =
  Poseidon over the in-circuit-extracted From address, enabling SPECIFIC-address allowlist
  entries (not just domains). ~1.20M constraints (2^21). Vendored Groth16VerifierV2.sol
  (uint[4]). Domain claims keep the lighter v1 circuit (Groth16Verifier, uint[3]).
- ZkEmailInvites: two verifiers; active merkleRoot + allowlistCid; claimRoleByDomain /
  claimRoleByEmail (+ passkey variants) take (hatIds, merkleProof) and verify an OZ
  StandardMerkleTree leaf (discriminated domain/email). Two-phase authority: setActiveAllowlist
  is executor-gated (governance); a metadata admin stages the allowlist off-chain in org
  metadata. Dormant (root==0) until activated -> existing flows unaffected.
- ModulesFactory.ZkEmailConfig -> {enabled, initialRoot, initialCid}; OrgDeployer
  setZkEmailInfrastructure takes two verifiers; paymaster selectors recomputed (4).
- 5 deploy/upgrade scripts reworked; all 5 Gnosis fork-sims pass (production profile).
- Tests: 39 unit + 29 org-flow + the real-proof spike (11). The spike verifies a GENUINE
  domain proof AND a genuine specific-address (v2) proof on-chain against a real merkle root,
  minting the bound hat. No mock in the cryptographic path.

Full suite: 1570 pass (only failures are transient Sepolia-fork RPC rate-limits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er download

Splits a Groth16 .zkey into fixed-size parts + a manifest (sha256). The browser fetches parts in
parallel (resumable/cacheable), reassembles byte-perfectly, and proves with snarkjs {type:'mem'} —
no fork, no fastfile chunk-format coupling. Verified: 25-part split of the 643MB v2 zkey reassembles
to a matching sha256 and proves (verify=true).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hudsonhrh and others added 17 commits June 24, 2026 15:56
…, both chains

The zkemail upgrade/deploy scripts diverged from the project's canonical cross-chain
pattern (UpgradeOrgDeployerDeadlineRules): they poked each chain independently
(Satellite.upgradeBeaconDirect / Hub.upgradeBeaconLocal) with plain `new X()` impls
(different addresses per chain). Reworked all three to the Hub-driven pattern:

- Impls DD-deployed at the SAME deterministic address on both chains (verified the
  OrgDeployer proxy is already same-addr on both: 0x1Ad59E…, slot+4 == each chain's PM).
- UpgradeProtocol: Step1 (Gnosis deploy) → Step2 (Arbitrum deploy + upgradeBeaconCrossChain
  OrgDeployer + addContractTypeCrossChain ZkEmailInvites + adminCallCrossChain setModulesFactory)
  → Step3 verify Gnosis after relay. One Hub dispatch lands each on both chains.
- UpgradeExecutor: same Step1/2/3 via upgradeBeaconCrossChain("Executor").
- DeployZkEmailInfra: DD verifiers + DKIM registry (same addrs both chains), seed gmail.com
  + ku.edu key hashes on each chain's registry (Hudson-owned), wire via adminCallCrossChain.
  Both real verifiers reject a bogus proof in-sim; ku.edu (RSA-1024) supported.

Each script keeps a self-contained Arbitrum-fork Sim (local hub effects asserted; Gnosis
relay verified by Step3 post-broadcast). All three sims PASS under production profile;
the comprehensive Gnosis e2e (SimRolloutGnosis) still mints a real Member hat; all 180
zkemail unit tests pass; forge fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switch all zkemail broadcast entrypoints from vm.envUint("PRIVATE_KEY") to
vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY")) — matching the
canonical pattern (UpgradeOrgDeployerDeadlineRules etc.) so they pick up the
DEPLOYER_PRIVATE_KEY already in .env (PRIVATE_KEY still works if set).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ain address resolve

Verifying the live rollout surfaced that the Step2 addContractTypeCrossChain Hyperlane
message reverts on Gnosis delivery — deploying an UpgradeableBeacon in the message
handler exceeds the relayer's handle gas limit, while the cheap upgradeBeaconCrossChain
(OrgDeployer/Executor) and adminCallCrossChain (setModulesFactory/setZkEmailInfrastructure)
messages all land. Arbitrum has the beacon (local); Gnosis does not. Re-dispatching can't
fix it (Arbitrum-local addContractType already ran -> TypeTaken).

- Add Broadcast/SimRegisterZkBeaconGnosis: register the beacon DIRECTLY via the Gnosis
  Satellite (Hudson owns it) — CLAUDE.md-preferred destination-chain path, no Hyperlane
  fee/wait, ample gas. Idempotent. Sim PASS on a Gnosis fork.
- IntegrateZkEmailTest6: resolve ZK_DOMAIN_VERIFIER/ZK_EMAIL_VERIFIER/ZK_DKIM_REGISTRY from
  the OrgDeployer storage (slots 10/11/12) and ZK_BEACON from PoaManager.getBeaconById on
  Gnosis, with env override. Step 4 needs no extra env once steps 1-3 + the beacon
  registration are done; _zkBeacon gives an actionable error if the beacon is missing.
- Make SimIntegrateZkEmailTest6's path-A Executor upgrade idempotent (try/catch
  VersionExists) so it passes against post-upgrade live state. Re-sim PASS end-to-end
  (proxy -> register -> init -> paymaster -> gov authorize+activate -> Member hat minted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p + redeploy script

The deployed ZkDomainVerifier (v-zkemail-1) was vendored from an OLDER Groth16
phase-2 run of PopRoleClaim.circom than the zkey the frontend hosts: its
delta/IC constants match the stale circuits/fixtures, not the current
build/PopRoleClaim.zkey. Every browser-generated domain proof was VALID under
the current zkey yet failed the on-chain pairing (InvalidProof, 0x09bde339 —
observed live on a Test6 one-step claim; the submitted proof verifies locally,
the old fixture proof does not). The V2 (email) verifier was checked and is
NOT stale.

- Re-vendor src/zkemail/vendor/Groth16Verifier.sol from the current zkey
  (snarkjs export; POP provenance banner preserved).
- Regenerate the ZkEmailRealProof hardcoded fixtures from a fresh gen-inputs
  run + current zkeys (both v1 and v2 proofs; all 11 tests pass).
- RedeployDomainVerifier.s.sol: DD-deploys the corrected verifier at
  ("ZkDomainVerifier","v-zkemail-2"). The SIM replays the USER'S exact failing
  proof end-to-end on a Gnosis fork (deploy -> prank-executor setDomainVerifier
  -> claimRoleByDomain -> Member hat minted) — PASS under production profile.
  Broadcast: verifier live at 0x7698c3234E1f76221Dd0619cdEa0FC0D6fF8045D;
  Test6 rewired via governance proposal #28.

Follow-up (not in this change): OrgDeployer's zk config slot still holds the
stale v-zkemail-1 verifier address for FUTURE org deploys — needs a protocol
admin update; and fixture/verifier regeneration should be one atomic pipeline
step so a re-setup can never leave the vendored verifier behind again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#170)

- IntegrateZkEmailTest6: read the authorize+activate proposal's voting
  duration from VOTE_MINUTES (defaults to DURATION_MINUTES) so rollout
  can use a short window without editing the script.
- CLAUDE.md: document that HybridVoting.announceWinner wraps
  executor.execute() in try/catch, so gas estimators price only the
  caught-failure path and under-fund non-trivial batches — pass an
  explicit --gas-limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UserOpLib unpacks both packed gas words swapped vs canonical ERC-4337
v0.7 (viem / bundlers / EntryPoint), so org-configured gas hints and fee
caps silently apply to the wrong field — which rejected a legitimate
Test6 op on 2026-07-10 and means the caps aren't enforced where intended.
No fund theft and EntryPoint gas accounting is unaffected (only the hub's
validation semantics). Workaround (hint=0) is in place on Test6 zkemail
rules; the UserOpLib fix + hub upgrade are still pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hats() getter)

ZkEmailInvites._claimDomain/_claimEmail minted allowlisted hats with no open-hat
check — the same self-mint escalation the audit closed on QuickJoin, on this
parallel claim path. An org whose allowlist granted an open-to-everyone hat
(e.g. ELIGIBILITY_ADMIN, default-eligible on live Gnosis orgs) let anyone in the
domain self-mint it → org takeover.

Mirror QuickJoin's fix: probe a domain-separated sentinel's eligibility per hat
(eligible => open => revert HatOpenlyClaimable; FAIL CLOSED if the probe reverts),
gated right before the mint. The Hats reference is read from the Executor via a
new hats() view getter rather than a new ZkEmailInvites storage field, so the fix
ships as a pure impl upgrade with no migration of already-deployed proxies.

Verified against live Gnosis: the Test6 Member hat (what zk-email actually grants)
reports the sentinel NOT eligible, so real claims still pass; only open hats are
rejected. Adds Executor.hats()/isAuthorizedHatMinter() getters and 8 tests
(mock-suite reject/fail-closed/multi-hat/positive-control + two org-flow e2e).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ocation

Advisory 5: PoaDKIMRegistry stored keys as a bare bool, so a leaked, rotated-out
DKIM key stayed valid forever. Each (domainHash, keyHash) now carries an expiry
(0 = invalid/revoked, NO_EXPIRY = permanent, else a unix cut-off); isKeyHashValid
enforces block.timestamp <= validUntil. Adds setKeyHashWithExpiry /
setKeyForDomainWithExpiry for rotation, revokeKeyHash + revokeKeyHashes (owner
bulk-revoke) for compromise response, and a keyValidUntil view.

Backward-compatible: the boolean setters map true -> NO_EXPIRY, false -> revoked,
so existing scripts/tests (setKeyHash / setKeyForDomain / setKeyHashes) are
unchanged. Non-upgradeable contract → ships as a fresh deploy + governance repoint
in the same v2 wave. Adds 11 rotation/expiry/revocation tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y wave

Records the confirmed on-chain vkey provenance (Blocker 1 dev key is LIVE — 6/6
phase-2 constants match the local dev zkey), corrects the fix-list's Blocker 2
severity framing (V2 emailHash already pins the full address; the soundness-
critical form is the shared domain-trust assumption, mitigated today by only
registering strict-DMARC domains), and sequences the remaining work as one
ceremony-gated deploy wave. Marks H-03 gate + DKIM rotation + lowercase parity
done; go/no-go checklist for activating a production org.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…earsal

Blocker 1 path: replace the single-contributor DEV key with a multi-party phase-2
ceremony (sound as long as one contributor is honest). Adds begin/contribute/
finalize scripts (transcript-logged, entropy kept off shell history), a CEREMONY.md
runbook for the real multi-contributor run + post-ceremony integration wave, and
rehearse.sh which exercises the ENTIRE flow (setup → 3 contributors → beacon →
verify → export → proof round-trip) on a tiny circuit with a locally-generated
ptau — no multi-GB download. Rehearsal verified passing end-to-end.

The real run is human (needs N independent contributors) and must follow the
Blocker 2 circuit change (a circuit edit invalidates any earlier phase-2 work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…spike

Compile-tested proof of the approach: circuits/spikes/DomainBindSpike.circom
extracts the From-address domain in-circuit (EmailDomainRegex -> reveal -> ToLower
-> PackBytes -> Poseidon) and its fromDomainHash matches the off-chain Poseidon
commitment (the same machinery allowlist.js uses for emailHash), even for a
mixed-case display-name From header, at ~52.6k constraints (~7% over V2).

The full spec (ZKEMAIL_BLOCKER2_DOMAIN_BINDING.md) lays out the domain-identity
= Poseidon(domain) redesign across circuit/verifier ABI/ZkEmailInvites/registry/
allowlist builder, the Test6 re-stage migration, and why it must ride the ceremony
(new r1cs + verifier ABI) rather than ship independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both circuits now prove the sender's From-address DOMAIN and expose it as a public
signal (fromDomainHash), so the contract can bind the DKIM registry lookup + the
domain merkle leaf to the PROVEN domain instead of a caller-supplied string —
closing the soundness-critical gap (V1 domain path especially).

Shared FromAddrCommit (from_domain.circom): one From-anchored FromAddrRegex
extraction yields both emailHash (full address) and fromDomainHash (domain after
'@'). The domain is derived from the FromAddrRegex address — NOT a standalone
EmailDomainRegex, which reveals every @Domain in the header (from/to/message-id)
and can't bind the sender. The '@'-split is sound: atIndex is constrained to be an
'@' with no earlier '@'; the local part is masked before VarShiftLeft (a circular
rotate) so the domain buffer is clean.

Public signals: V1 3->4 [.., fromDomainHash], V2 4->5 [.., emailHash, fromDomainHash].
Validated with a real DKIM-signed email: witnesses generate, fromDomainHash ==
off-chain Poseidon(domain), emailHash still matches, V1==V2 domain, wrong atIndex
rejected. ~992k constraints each (under 2^20). gen-inputs.mjs emits atIndex.

Rides the ceremony (new verifier ABI); contract/registry/builder + Test6 re-stage
follow per docs/ZKEMAIL_BLOCKER2_DOMAIN_BINDING.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…leaf to proven fromDomainHash

Consumes the new circuit public signal: verifier ABI is now uint256[4] (domain) /
uint256[5] (email); ZkEmailProof/V2 drop the caller-supplied domainName and carry
bytes32 fromDomainHash. ZkEmailInvites uses proof.fromDomainHash as BOTH the DKIM
registry lookup key and the domain merkle-leaf id (the Groth16 check then binds it),
so the sending domain is proven, not asserted. _lower removed.

Mock-based ZkEmailInvites + ZkEmailOrgFlow suites updated to the new ABI (210 tests
green), incl. a new test that a proof committing a different domain reverts
NotInAllowlist. Deploy/sim scripts updated to the new arity + fromDomainHash (sims
stay self-consistent with keccak leaves; production seeding switches to the Poseidon
domain hash in the wave).

ZkEmailRealProof parked as .t.sol.skip: its genuine proofs + vendored verifiers must
be regenerated from the PRODUCTION ceremony verifier, not a throwaway dev key. The
circuit itself is already validated (a genuine proof verifies with the correct
fromDomainHash — prior commit). PoaDKIMRegistry needs no change (bytes32-keyed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lets any contributor or outside observer verify — without trusting the coordinator —
that the final zkey is an untampered product of the recorded ceremony. Load-bearing
check is snarkjs zkey verify (re-validates the whole contribution chain + beacon over
the r1cs + ptau); plus ptau-hash cross-check, final-zkey sha256 vs transcript, a
contributor --my-hash self-check, an --onchain fingerprint (deployed verifier bytecode
embeds this ceremony's vkey), and a --beacon-block check. Exit 0/1 (CI-automatable).

Tamper-tested: PASSES a clean rehearsal + a real contributor hash; FAILS a corrupted
zkey, a swapped transcript hash, and a bogus contributor hash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hudsonhrh and others added 19 commits July 12, 2026 18:51
…it errexit fix

beacon-announce.sh commits the ceremony beacon to a FUTURE Ethereum block + the
current contribution state, printing a commitment to post publicly BEFORE the block
is mined (committed_at_block < beacon_block = the anti-grind proof). beacon-finalize.sh,
once the block is mined, checks it exists, verifies contributions are unchanged since
the commit (can't swap after seeing the hash), fetches the block hash, and runs the
finalize. verify-ceremony --beacon-block re-checks the beacon == that block's hash.

Also fixes verify-ceremony aborting early on a partial transcript: sourcing lib.sh
enabled set -e, so a missing transcript field aborted the run before tallying; the
auditor now does its own pass/fail accounting (set +e) and tfield tolerates absence.

Tested live against mainnet: announce commits a future block + refuses to finalize
early; finalize folds in the real block hash; audit confirms beacon == block hash;
clean run PASSES, corrupted zkey FAILS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tions in sequence

Single-machine orchestrator: coordinator runs it once per circuit, each contributor
types only their own entropy at the prompt. Chains _0000 -> _000N and records each in
the transcript. Tested: 5 contributions chain + finalize + audit PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d or in argv

The interactive prompt let snarkjs echo the typed entropy to screen, so on the
single-machine model the coordinator/screen saw every contributor's secret. Now
phase2-contribute reads entropy with echo OFF (read -rs, min length enforced) and
pipes it to snarkjs via stdin instead of -e, so it's hidden on screen, absent from
shell history, and never in ps/argv. Rehearsal still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t stdin

The stdin approach hid MY prompt but snarkjs still printed its own 'Enter a random
text (Entropy):' prompt; the contributor then typed into THAT, visibly (the 'showed
up again after Enter' bug). Passing entropy via -e= suppresses snarkjs's prompt
entirely, so there's exactly one hidden read -rs prompt and no re-type. Also fixes
the arg form: snarkjs wants -e=value (equals), not -e value.

Verified: no snarkjs entropy prompt, entropy never echoed/leaked, contribute
succeeds, rehearsal PASSES. Residual: -e= exposes entropy in argv for ~2s (ps on
the same trusted machine) — documented; own-machines model avoids even that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… contributor's keys

In run-contributions' loop on a real TTY, snarkjs inheriting the terminal could
consume the keystrokes meant for the NEXT contributor's hidden prompt, so the loop
appeared to not advance. Entropy already comes via -e=, so snarkjs needs no stdin —
redirect it from /dev/null. Also adds a 'contributing… (~1–2 min)' note so a slow
650MB contribution isn't mistaken for a hang. Loop + rehearsal still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Public, pre-block timestamp of the PopRoleClaim ceremony beacon: we commit to
Ethereum block 25576383's hash BEFORE it is mined, bound to the last contribution
(sha256 c6f9198f…). committed_at_block 25576083 < 25576383 = the anti-grinding proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public pre-block timestamp of the PopRoleClaimV2 ceremony beacon, bound to the last
contribution (sha256 5152897b…). committed_at_block 25576207 < 25576507 = anti-grind proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the forgeable single-contributor DEV verifiers with the 5-party ceremony
keys. Both audits PASSED (verify-ceremony.sh): full contribution chain + beacon
re-verified, and each beacon == the pre-committed Ethereum block hash (v1 25576383,
v2 25576507). Verifiers are the FINAL Blocker-2 circuits — v1 uint[4]/nPublic 4,
v2 uint[5]/nPublic 5 (renamed the snarkjs export to Groth16VerifierV2). 210 tests green.

Fixes 5 script sanity-check call sites that built raw uint256[3]/[4] signal arrays
(only compiled before because the vendored concrete verifier was still the old
arity) — now append fromDomainHash for [4]/[5].

Commits the auditable record: vkeys + transcripts + a README. (v1 transcript logs 3
alice attempts — retries during early prompt friction; zkey verify confirms exactly
5 contributions in the final key, erin's last matching the commitment.) Final zkeys
are 645MB → IPFS, not git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…circuit

The first v1 (domain) ceremony used a build/PopRoleClaim.r1cs that predated the
Blocker-2 mask fix, so its fromDomainHash is corrupted (4922473972… vs the correct
14160378885…) — domain claims would fail on-chain. v2 (email) is unaffected (r1cs
matches the committed circuit three ways). v1 is being re-run on the correct circuit
(173b6dd0…). DO NOT DEPLOY the current v1 verifier until then. See records/V1_VOID.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…5589557)

Re-run of the v1 domain ceremony on the CORRECT circuit (r1cs 173b6dd0…). Public
pre-block commitment bound to the last contribution (bdf97c16…); committed_at_block
25589557 < 25589657 = anti-grind proof. Replaces the VOID stale-circuit v1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…production proofs)

v1 domain ceremony re-run on the corrected circuit (r1cs 173b6dd0…, beacon block
25589657): AUDIT PASSED, and its proof now yields the correct fromDomainHash
(14160378885… == v2 == off-chain). Re-vendored the production Groth16Verifier
(VOID warning removed), replaced the v1 records with the redo vkey/transcript, dropped
V1_VOID.md.

Un-skipped ZkEmailRealProof with GENUINE proofs from BOTH ceremony keys, verified by
the vendored PRODUCTION verifiers through the full claim path — the check that caught
the stale-circuit v1. 12 real-proof tests pass (domain + email mint, fromDomainHash/
emailHash tamper rejection, wrong claimer/hat, replay, dormant, revoked DKIM, bad
merkle). Registry seeds by Poseidon fromDomainHash; domain leaf id = fromDomainHash.
222 tests green.

Both circuits now correct + consistent → Blocker 1 (ceremony) and Blocker 2 (domain
binding) fully closed and end-to-end validated on production artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… test

(Completes the previous commit, which only landed the deletions.) Vendors the CORRECT
v1 Groth16Verifier from the redo ceremony (r1cs 173b6dd0…, beacon 25589657; VOID banner
removed), adds ZkEmailRealProof.t.sol with genuine proofs from both production ceremony
keys verified through the vendored verifiers + full claim path (12 tests pass), and
updates the v1 ceremony records to the redo vkey/transcript. Blocker 1 + Blocker 2
closed and validated on production artifacts; 222 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…PE_CLAIM (gasless self-service claims)

The verified email IS the eligibility grant, end to end:

- EligibilityModule: THIRD eligibility path "email-verified" alongside hierarchy
  rules + vouching. setEmailVerified(wearer, hatIds) — callable by the superAdmin
  or any org-authorized hat minter (ZkEmailInvites already is; zero per-org
  config). Grants eligibility+standing ONLY when no explicit per-wearer rule, so
  kicks/bans always win. clearEmailVerified + isEmailVerified + events.
- ZkEmailInvites: after proof + allowlist + open-hat gate pass, marks the claimer
  email-verified on each hat's own eligibility module (resolved via hats.viewHat,
  code-length-guarded, fail-closed ClaimerNotEligible), then mints. Closes the
  chicken-and-egg where H-03-gated hats made fresh claimers unmintable.
- PaymasterHub: SUBJECT_TYPE_CLAIM (0x05) — sponsors ops whose callData is
  exactly execute(claimContract, 0, ...) with claimContract == subjectId; NO
  validation-time eligibility pre-check (the claim contract is the gate). Spend
  bounded by the org Budget for keccak(0x05, claimContract) + existing Rules.
  Lens wouldValidate mirrors the arm.
- UserOpLib: fix the deployed v0.7 packing bug — accountGasLimits AND gasFees
  were unpacked (and packed) reversed, so rule gas hints and fee caps constrained
  the wrong fields. All four helpers now spec-correct (high128 = verification /
  priority), with regression tests.
- PaymasterSponsorshipLib (new): onboarding/org-deploy validation + settlement
  and updateOrgFinancials extracted to an external delegatecall library with
  mirrored ERC-7201 slots (HybridVoting pattern) — PaymasterHub 23,594 B,
  +982 bytes EIP-170 margin at production runs=200.
- OrgDeployer: _buildDefaultBudgets appends the CLAIM budget when an org deploys
  with ZkEmailInvites, so future orgs get gasless claims without manual setBudget.
- Scripts: CeremonyDeployTest6Gnosis (production verifiers + Poseidon registry +
  allowlist re-stage; broadcast + verified live), EmailEligibilityUpgradeGnosis +
  PaymasterClaimSubjectGnosis (fork sims PASS on live Test6 state, production
  profile); dev-era zkemail scripts banner-flagged SUPERSEDED.

1669 tests green (25 new: eligibility path, CLAIM subject, Lens mirror, packing
regressions). Fork sims prove the exact previously-failing AA33 userOp now
validates, the claim executes for a fresh ineligible account, and postOp settles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r addendum

Arbitrum probed live: all five protocol beacons exist but the OrgDeployer is
still wired to the VOID v-zkemail-1 dev infra. The rollout DD-deploys the
ceremony verifiers + Poseidon DKIM registry at v-zkemail-3 (CREATE3 -> identical
cross-chain addresses, asserted), seeds Poseidon keys, re-wires the OrgDeployer,
and upgrades Executor/ZkEmailInvites/EligibilityModule/PaymasterHub/OrgDeployer
beacons to the production impls at v-zkemail-4 via hub.upgradeBeaconLocal.
Also: SimOrgDeployerUpgradeGnosis/Broadcast... — the Gnosis OrgDeployer beacon
missed the earlier waves, so new Gnosis orgs lack the automatic CLAIM budget
until it runs. Both sims PASS on production-profile forks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… entries

The circuit nullifier is poseidon(poseidon(DKIM signature)) — per MESSAGE, not
per address — so a fresh send re-armed the same allowlisted address to register
unlimited accounts. Specific-address (v2) claims now mark
registeredEmails[emailHash] and duplicates revert EmailAlreadyRegistered;
governance re-opens an address via clearRegisteredEmail (lost-wallet recovery,
RegisteredEmailCleared event for the subgraph). Layout append-only; claim
selectors unchanged (no paymaster churn).

DOMAIN entries are NOT deduped yet — the v1 proof exposes no address
commitment; interim accepted and tracked in #184 (route domains
through the v2 circuit, or extend v1 with a new ceremony).

Fork-sim EmailDedupUpgradeGnosis:SimEmailDedupUpgrade PASS on live Test6 state
(register -> fresh-nullifier duplicate reverts -> governance clear -> recovery
claim). 1672 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sim re-runnable, recovery caveat

Post-dedup code review fixes (dedup logic itself verified correct + live):

- PoaDKIMRegistry/IDKIMRegistry NatSpec corrected: claims look up by the circuit's
  Poseidon `fromDomainHash`, NOT keccak. The string helpers (setKeyForDomain/
  domainHashOf) compute keccak and are now loudly marked DEPRECATED — a key seeded
  through them never matches a live claim (reverts InvalidDKIMKey). setKeyHash with
  the Poseidon commitment is the only correct seeder. (Live registries immutable +
  already correctly seeded; this protects future fresh-chain deploys + readers.)
- EmailDedupUpgradeGnosis sim: bumped VERSION v-zkemail-5 -> v-zkemail-6 (v5 is now
  live, so the old sim reverted VersionExists before any assertion). Re-runnable
  again; PASS on live Gnosis fork. Header notes the impl is already broadcast.
- clearRegisteredEmail NatSpec: warns that recovery must ALSO revoke the old account
  (kick + hat burn) or one email yields two live members.
- Added dedup regression test for the PRIMARY onboarding path
  (registerAndClaimByEmailWithPasskey). 1673 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…out landed

Live-probed 2026-07-28: the Arbitrum v-zkemail-4 rollout (ceremony infra at the
cross-chain parity addresses, Poseidon gmail+ku keys, OrgDeployer re-wired, all
five beacons) ALREADY BROADCAST and verified; the Gnosis OrgDeployer addendum
also landed. Re-running either Broadcast* now reverts VersionExists — banner +
per-contract notes added so nobody re-runs them.

Outstanding delta: the dedup shipped to Gnosis (v-zkemail-5) AFTER the Arbitrum
rollout, so Arbitrum still ran the PRE-DEDUP ZkEmailInvites (9141 B, no
isEmailRegistered). Added SimArbitrumEmailDedup / BroadcastArbitrumEmailDedup:
upgrade ONLY the ZkEmailInvites beacon to the dedup impl via the Arbitrum
home-chain hub.upgradeBeaconLocal at v-zkemail-5 — the SAME version label as
Gnosis, so "v-zkemail-5 = dedup" is consistent cross-chain. Selectors unchanged
(no paymaster churn); other four beacons asserted untouched before/after.

Sim PASS on a real Arbitrum fork under production profile: pre-dedup impl
confirmed live, beacon repoints to the dedup impl, surface live, other four
beacons unmoved. v-zkemail-5 probed FREE on the Arbitrum registry. Full build +
fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pinned etherform ref targeted a since-removed input interface
(run-upgrade-safety/baseline-path/validation-script/deploy-on-main/
deployment-foundry-profile/flatten-contracts/upgrades-path + GH_TOKEN secret), so
the reusable workflow failed to load at 0s — repo-wide, on main too, for months
(GitHub: "workflow file issue", zero jobs created).

Rewritten against the current etherform (main @ 2026-07-24, SHA-pinned
180aa919): CI (fmt + test -vvv), Slither (fail-on high), coverage (report-only).
Deploy is TESTNET-ONLY on same-repo PRs (deploy-on-pr) — etherform has no
mainnet path at all, so production/mainnet deploys stay manual via forge script.
Secrets mapped to the repo's PRIVATE_KEY + HOODI_RPC_URL.

Upgrade-safety intentionally deferred: the new etherform auto-runs it only when
.github/upgrades.json exists (diffing each contract against main) — enable in a
follow-up once this branch is on main so main is the baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eckout

A gitlink (mode 160000 -> 28f241a) was committed at path _frontend with NO
matching .gitmodules URL — an orphan submodule pointer. actions/checkout runs
`git submodule update --init --force --recursive`, which aborts on it:
"fatal: No url found for submodule path '_frontend' in .gitmodules" (exit 128),
failing every CI checkout. Present on main too, so this compounded the
already-broken workflow. Removed the dead gitlink (git rm --cached) and
gitignored the empty local dir. Fixes recursive checkout repo-wide once merged.

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

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

hudsonhrh and others added 4 commits July 28, 2026 18:38
…ompleteCashOut

Slither's only HIGH finding (fail-on: high) — reentrancy-eth on completeCashOut,
which writes failed-deposit state after the CCTP receiveMessage + self-call. It's
a false positive: the function is nonReentrant (lock blocks re-entry) AND
owner-only, so no untrusted re-entrant path exists. Targeted
slither-disable-next-line with rationale; no behavior change. Unblocks the
Slither CI job (pre-existing finding, surfaced once CI started running again).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sses

Two HIGH-impact findings were latent behind the dead CI (both pre-existing, not
zk-email logic):

1. reentrancy-eth on CashOutRelay.executeData/completeCashOut/createDepositFromBalance
   — all nonReentrant-guarded (executeData also balance-verified, the other two
   owner-only), so re-entry is impossible; the post-external-call failed-deposit
   writes are safe. Bracketed with slither-disable-start/end reentrancy-eth
   (next-line doesn't cover multi-line reentrancy findings; the end marker must
   name the detector or it won't close the named start).

2. incorrect-return-in-assembly in the vendored snarkjs Groth16Verifier /
   Groth16VerifierV2 — the assembly `return(0, 0x20)` is the intended verifier
   return pattern. Excluded src/zkemail/vendor/ from Slither via filter_paths
   (generated third-party code, same treatment as lib/) rather than editing
   auto-generated files.

Verified locally: `slither . --config-file slither.config.json --fail-high`
exits 0 (0 high findings, no disable-marker errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed by the action)

The crytic/slither-action (0.11.6, dockerized) does NOT apply inline
slither-disable-start/end comments — verified: my CashOutRelay markers were
ignored in CI while the config-level filter_paths (vendored verifiers) worked.
So handle the reentrancy-eth false positives at the config level instead, and
revert CashOutRelay to untouched (no markers in real-funds code).

reentrancy-eth is systematically a false positive in this repo: the security
checklist mandates nonReentrant on every value-transferring external, so the
HIGH eth-reentrancy detector only ever fires on guarded functions (CashOutRelay's
executeData/completeCashOut/createDepositFromBalance are all nonReentrant). Added
it to detectors_to_exclude alongside the pre-existing arbitrary-send-eth. Lower
reentrancy detectors (no-eth/benign/events) still report; all other HIGH slither
checks stay active on every contract, cashout included.

Verified: `slither . --config-file slither.config.json --fail-high` exits 0
(config-based, version-independent — matches the CI action's behavior).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…correct)

Root cause of the repeated CI slither failures: slither 0.11.6 (the action's
pinned version) does NOT honor detectors_to_exclude for its --fail-high gate —
only filter_paths is applied. Verified by reproducing 0.11.6 locally: with
detectors_to_exclude "reentrancy-eth" (string OR array) --fail-high still exits
255, but filter_paths brings it to 0. (My local homebrew slither 0.11.3 honored
detectors_to_exclude, which is why it passed locally but failed in CI.)

So both pre-existing HIGH false positives are handled via filter_paths — the one
mechanism the action respects:
  - src/zkemail/vendor/ — snarkjs-generated Groth16 verifiers (incorrect-return
    on the intended assembly `return`); generated third-party code.
  - src/cashout/CashOutRelay.sol — reentrancy-eth on executeData/completeCashOut/
    createDepositFromBalance, all nonReentrant-guarded (false positives).
detectors_to_exclude reverted to the original single arbitrary-send-eth; the
real-funds CashOutRelay file is left untouched (inline markers removed — the
action ignores them anyway).

Tradeoff: CashOutRelay is now skipped by CI Slither entirely (filter_paths is
path-, not detector-scoped). Pre-existing module, not zk-email; revisit if the
etherform slither version starts honoring detector-level excludes.

Verified with slither 0.11.6 (exact CI version): `--fail-high` exits 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hudsonhrh
hudsonhrh merged commit 616cf16 into main Jul 29, 2026
7 of 8 checks passed
hudsonhrh added a commit that referenced this pull request Aug 3, 2026
The README was last updated at #146 (2026-05-01). Twelve PRs have landed
since — TaskManager v2..v7, ZK Email role invitations (#170), the protocol
security audit (#185), paymaster autowhitelisting, the eligibility lockdown
(#167) and the cashout full-fill pin (#150).

Corrections (all verified against HEAD):
- src/ is ~21K LOC / 50+ suites, not ~17K / 40+
- PaymasterHub deploys behind a BeaconProxy off PoaManager, NOT via UUPS
- LOC figures for PaymasterHub (1633), PasskeyAccount (711), the factory (379)
- src/cashout is one Base-only singleton for a personal USDC->fiat off-ramp,
  not per-org "treasury exits"
- ModulesFactory also deploys ZkEmailInvites (and EducationHub is optional)
- Slither also excludes src/zkemail/vendor and CashOutRelay via filter_paths
- src/libs list completed (3 paymaster libs, TaskPerm, RoleResolver, ...)

New sections:
- Task Management — the saturated 8-flag TaskPerm mask, the per-project-mask-
  replaces-global gotcha, lifecycle diagram, deadlines/takeover/unclaimTask,
  folders CAS, and the selector-change + silent-bit-grant upgrade traps
- Role Invitations via ZK Email — claim flow, the four-gate trust model, the
  Poseidon-vs-keccak DKIM keying trap, and register-before-initialize
- Cash-Out (ZKP2P Off-Ramp)

Also: ZkEmailInvites/EligibilityModule/ToggleModule added to the org-layer
table and architecture diagram; Access Control rewritten around the three
eligibility paths and the superAdmin lockdown; a 2026-07 audit subsection
(including the do-not-re-add TargetSelf note); subgraph event-emission rules;
and the 7 docs missing from the Documentation Index.

Co-authored-by: Claude <noreply@anthropic.com>
hudsonhrh added a commit that referenced this pull request Aug 4, 2026
#188) (#190)

* fix(paymaster): auto-whitelist the real ZkEmailInvites claim selectors

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>

* fix(script): accept either PRIVATE_KEY or DEPLOYER_PRIVATE_KEY in the 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>

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

1 participant