feat(zkemail): role invitations via on-chain ZK Email proof verification - #170
Merged
Conversation
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
…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>
…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>
…, 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>
…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>
Contributor
Coverage Report
Coverage by file
|
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ZkEmailInvitesis a per-org upgradeable module that lets an org'sExecutorpre-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/) — self-compiled Circom + Groth16:PopRoleClaim.circom(v1, 3 public signals) — DKIM RSA-2048/SHA-256 header verification (@zk-email/circuitsEmailVerifier) + in-circuit extraction of the claimer address from the signedClaim POP role for 0x<addr>command. Used for domain claims (~718k constraints).PopRoleClaimV2.circom(v2, 4 public signals) — addsemailHash = Poseidon(From address), enabling specific-address allowlist entries (~1.2M constraints).split-zkey.mjschunks the large proving keys into a sha256-manifested set so the browser can fetch them in parallel and reassemble byte-perfectly.Groth16Verifier(uint[3]) andGroth16VerifierV2(uint[4]);PoaDKIMRegistrybinds domains to their DKIM public-key hashes. A claim verifies the proof, checks an OZStandardMerkleTreeleaf (discriminated domain vs email) against the active root, and mints the bound hat(s). Real BN254 pairing (~225k gas).setActiveAllowlistis 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
OrgDeployer/ModulesFactory/ModuleTypes; activation is conditional on per-chain protocol infra (two verifiers + DKIM registry) being set viasetZkEmailInfrastructure.ZkEmailInvitesbeacon degrade gracefully instead of revertingTypeUnknownand bricking org deploys. The beacon is registered in the canonical deploy helpers (DeployHelper,DeployInfrastructure,DeploySatelliteInfrastructure).ModulesFactory.deployModulesdeploys the module uninitialized, registers it, then initializes — so the per-org subgraph template (created onContractRegistered) exists beforeinitialize()emits its config/rule events, letting the subgraph index the full deploy-time snapshot with noeth_calls.initialize()emits the same events as the setters for the same reason.OrgDeployer/Executorbeacons and wiresModulesFactory+ infra on both chains over Hyperlane. Scripts live underscript/zkemail/; each mutating script ships a fork-sim sibling.Security review addressed
_verifyProofCommonrejects proofs withisCodeExist == false(AccountCodeMissing), asserted afterverifyEmailProof— without an embedded account code theaccountSaltis not a realPoseidon(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.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-chainInvalidProof; Test6 rewired via governance proposal #28.Follow-ups / caveats (not blocking)
setDomainVerifier/setVerifierredeploy before relying on this beyond Test6.v-zkemail-1domain verifier for future org deploys; needs a protocol-admin update.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