diff --git a/script/deploy/DeployInfrastructure.s.sol b/script/deploy/DeployInfrastructure.s.sol index 0fc9624..d926f88 100644 --- a/script/deploy/DeployInfrastructure.s.sol +++ b/script/deploy/DeployInfrastructure.s.sol @@ -19,6 +19,7 @@ import {EligibilityModule} from "../../src/EligibilityModule.sol"; import {ToggleModule} from "../../src/ToggleModule.sol"; import {PasskeyAccount} from "../../src/PasskeyAccount.sol"; import {PasskeyAccountFactory} from "../../src/PasskeyAccountFactory.sol"; +import {ZkEmailInvites} from "../../src/ZkEmailInvites.sol"; // Infrastructure import {ImplementationRegistry} from "../../src/ImplementationRegistry.sol"; @@ -115,6 +116,7 @@ contract DeployInfrastructure is Script { address toggleImpl = address(new ToggleModule()); address passkeyAccountImpl = address(new PasskeyAccount()); address passkeyAccountFactoryImpl = address(new PasskeyAccountFactory()); + address zkEmailInvitesImpl = address(new ZkEmailInvites()); address implRegImpl = address(new ImplementationRegistry()); address orgRegImpl = address(new OrgRegistry()); address deployerImpl = address(new OrgDeployer()); @@ -215,6 +217,9 @@ contract DeployInfrastructure is Script { pm.addContractType("ToggleModule", toggleImpl); pm.addContractType("PasskeyAccount", passkeyAccountImpl); pm.addContractType("PasskeyAccountFactory", passkeyAccountFactoryImpl); + // ZkEmailInvites beacon registered unconditionally; the per-org module stays dormant + // until OrgDeployer.setZkEmailInfrastructure wires the verifier + DKIM registry. + pm.addContractType("ZkEmailInvites", zkEmailInvitesImpl); console.log("\n--- Contract Types Registered ---"); diff --git a/script/deploy/DeploySatelliteInfrastructure.s.sol b/script/deploy/DeploySatelliteInfrastructure.s.sol index 63546b2..89e5336 100644 --- a/script/deploy/DeploySatelliteInfrastructure.s.sol +++ b/script/deploy/DeploySatelliteInfrastructure.s.sol @@ -25,6 +25,7 @@ import {EligibilityModule} from "../../src/EligibilityModule.sol"; import {ToggleModule} from "../../src/ToggleModule.sol"; import {PasskeyAccount} from "../../src/PasskeyAccount.sol"; import {PasskeyAccountFactory} from "../../src/PasskeyAccountFactory.sol"; +import {ZkEmailInvites} from "../../src/ZkEmailInvites.sol"; /** * @title DeploySatelliteInfrastructure @@ -107,6 +108,7 @@ contract DeploySatelliteInfrastructure is Script { _deployIfNeeded(dd, "ToggleModule", "v1", type(ToggleModule).creationCode); _deployIfNeeded(dd, "PasskeyAccount", "v1", type(PasskeyAccount).creationCode); _deployIfNeeded(dd, "PasskeyAccountFactory", "v1", type(PasskeyAccountFactory).creationCode); + _deployIfNeeded(dd, "ZkEmailInvites", "v1", type(ZkEmailInvites).creationCode); } function _deployIfNeeded(DeterministicDeployer dd, string memory typeName, string memory version, bytes memory code) @@ -138,6 +140,9 @@ contract DeploySatelliteInfrastructure is Script { _registerType(pm, dd, "ToggleModule", "v1"); _registerType(pm, dd, "PasskeyAccount", "v1"); _registerType(pm, dd, "PasskeyAccountFactory", "v1"); + // ZkEmailInvites beacon registered unconditionally; the per-org module stays dormant + // until OrgDeployer.setZkEmailInfrastructure wires the verifier + DKIM registry. + _registerType(pm, dd, "ZkEmailInvites", "v1"); } function _registerType(PoaManager pm, DeterministicDeployer dd, string memory typeName, string memory version) diff --git a/script/helpers/DeployHelper.s.sol b/script/helpers/DeployHelper.s.sol index 29f7eeb..98acc1f 100644 --- a/script/helpers/DeployHelper.s.sol +++ b/script/helpers/DeployHelper.s.sol @@ -18,6 +18,7 @@ import {EligibilityModule} from "../../src/EligibilityModule.sol"; import {ToggleModule} from "../../src/ToggleModule.sol"; import {PasskeyAccount} from "../../src/PasskeyAccount.sol"; import {PasskeyAccountFactory} from "../../src/PasskeyAccountFactory.sol"; +import {ZkEmailInvites} from "../../src/ZkEmailInvites.sol"; import {OrgRegistry} from "../../src/OrgRegistry.sol"; import {OrgDeployer} from "../../src/OrgDeployer.sol"; import {PaymasterHub} from "../../src/PaymasterHub.sol"; @@ -45,12 +46,17 @@ abstract contract DeployHelper is Script { address public constant POA_GUARDIAN = address(0); uint256 public constant INITIAL_SOLIDARITY_FUND = 0.005 ether; - /// @notice Canonical list of the 13 application contract types. + /// @notice Canonical list of the 14 application contract types. /// Infrastructure types (ImplementationRegistry, OrgRegistry, /// OrgDeployer, PaymasterHub) are handled separately because they /// require special initialization (beacon proxies, ownership, etc.). + /// @dev ZkEmailInvites is registered here so its beacon exists on every chain. + /// The module only *activates* once OrgDeployer.setZkEmailInfrastructure wires the + /// verifier + DKIM registry; until then the per-org gate in ModulesFactory skips it. + /// Registering the beacon unconditionally costs one extra impl deploy but removes the + /// ordering hazard where enabling infra before the beacon would brick org deploys. function _contractTypes() internal pure returns (ContractType[] memory types) { - types = new ContractType[](13); + types = new ContractType[](14); types[0] = ContractType("HybridVoting", type(HybridVoting).creationCode); types[1] = ContractType("DirectDemocracyVoting", type(DirectDemocracyVoting).creationCode); types[2] = ContractType("Executor", type(Executor).creationCode); @@ -64,6 +70,7 @@ abstract contract DeployHelper is Script { types[10] = ContractType("ToggleModule", type(ToggleModule).creationCode); types[11] = ContractType("PasskeyAccount", type(PasskeyAccount).creationCode); types[12] = ContractType("PasskeyAccountFactory", type(PasskeyAccountFactory).creationCode); + types[13] = ContractType("ZkEmailInvites", type(ZkEmailInvites).creationCode); } /// @notice Infrastructure contract types that need beacon registration for cross-chain upgrades. diff --git a/script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol b/script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol index 297a326..14521f6 100644 --- a/script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol +++ b/script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol @@ -316,9 +316,7 @@ contract DryRun_GnosisUpgrade is Script { vm.prank(KUBI_EXECUTOR); (bool okExecOn,) = KUBI_ELIG_MODULE.call( - abi.encodeWithSignature( - "setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, true, true - ) + abi.encodeWithSignature("setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, true, true) ); require(okExecOn, "DryRun: superAdmin setWearerEligibility(true,true) reverted"); { @@ -328,9 +326,7 @@ contract DryRun_GnosisUpgrade is Script { vm.prank(KUBI_EXECUTOR); (bool okExecOff,) = KUBI_ELIG_MODULE.call( - abi.encodeWithSignature( - "setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, false, false - ) + abi.encodeWithSignature("setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, false, false) ); require(okExecOff, "DryRun: superAdmin setWearerEligibility(false,false) reverted"); { diff --git a/src/OrgDeployer.sol b/src/OrgDeployer.sol index 232f96e..d0a093c 100644 --- a/src/OrgDeployer.sol +++ b/src/OrgDeployer.sol @@ -147,6 +147,10 @@ contract OrgDeployer is Initializable { address universalPasskeyFactory; // Universal PasskeyAccountFactory for all orgs uint256 _status; // manual reentrancy guard IHats hatsV2; // upgrade-safe hats reference (inside ERC-7201 namespace) + // ZK Email protocol infra (set once per chain via PoaManager). If unset, ZkEmailInvites + // module deployment is skipped per-org and the feature is gracefully unavailable. + address zkEmailVerifier; + address zkEmailDkimRegistry; } /// @dev Legacy slot-0 hats variable. Kept for ABI compatibility with existing proxies. @@ -219,6 +223,17 @@ contract OrgDeployer is Initializable { l.universalPasskeyFactory = _universalFactory; } + /// @notice Wire the per-chain ZK Email protocol infra. Once both are non-zero, every new + /// org gets a ZkEmailInvites proxy alongside its other modules. + /// @dev Only callable by PoaManager. Passing address(0) for either field is a no-op for + /// that field; pass both non-zero to enable the feature. + function setZkEmailInfrastructure(address verifier, address dkimRegistry) external { + Layout storage l = _layout(); + if (msg.sender != l.poaManager) revert InvalidAddress(); + if (verifier != address(0)) l.zkEmailVerifier = verifier; + if (dkimRegistry != address(0)) l.zkEmailDkimRegistry = dkimRegistry; + } + /*════════════════ DEPLOYMENT STRUCTS ════════════════*/ struct DeploymentResult { @@ -231,6 +246,9 @@ contract OrgDeployer is Initializable { address educationHub; address paymentManager; address eligibilityModule; + // address(0) on chains where ZK Email protocol infra (verifier + DKIM registry) + // has not been wired into the OrgDeployer yet — the module is skipped. + address zkEmailInvites; } struct RoleAssignments { @@ -450,13 +468,18 @@ contract OrgDeployer is Initializable { roleHatIds: gov.roleHatIds, autoUpgrade: params.autoUpgrade, roleAssignments: moduleRoles, - educationHubConfig: params.educationHubConfig + educationHubConfig: params.educationHubConfig, + zkEmailVerifier: l.zkEmailVerifier, + zkEmailDkimRegistry: l.zkEmailDkimRegistry, + accountRegistry: params.registryAddr, + universalFactory: l.universalPasskeyFactory }); modules = l.modulesFactory.deployModules(moduleParams); result.taskManager = modules.taskManager; result.educationHub = modules.educationHub; result.paymentManager = modules.paymentManager; + result.zkEmailInvites = modules.zkEmailInvites; } /* 7. Deploy Voting Mechanisms (HybridVoting, DirectDemocracyVoting) */ @@ -487,6 +510,11 @@ contract OrgDeployer is Initializable { /* 9. Authorize QuickJoin to mint hats */ IExecutorAdmin(result.executor).setHatMinterAuthorization(result.quickJoin, true); + /* 9b. Authorize ZkEmailInvites to mint hats (only if the module was deployed) */ + if (result.zkEmailInvites != address(0)) { + IExecutorAdmin(result.executor).setHatMinterAuthorization(result.zkEmailInvites, true); + } + /* 10. Link executor to governor */ IExecutorAdmin(result.executor).setCaller(result.hybridVoting); @@ -867,9 +895,11 @@ contract OrgDeployer is Initializable { pure returns (address[] memory targets, bytes4[] memory selectors, bool[] memory allowed, uint32[] memory gasHints) { - // Count: QuickJoin(6) + TaskManager(14) + HybridVoting(3) + DDVoting(3) + PaymentManager(5) + EligibilityModule(5) + ParticipationToken(3) + Registry(2) + EducationHub(0 or 4) + // Count: QuickJoin(6) + TaskManager(14) + HybridVoting(3) + DDVoting(3) + PaymentManager(5) + EligibilityModule(5) + ParticipationToken(3) + Registry(2) + EducationHub(0 or 4) + ZkEmailInvites(0 or 4) uint256 count = 41; if (educationEnabled) count += 4; + bool zkEmailEnabled = result.zkEmailInvites != address(0); + if (zkEmailEnabled) count += 4; targets = new address[](count); selectors = new bytes4[](count); @@ -896,12 +926,57 @@ contract OrgDeployer is Initializable { i += 4; } - // Set all rules to allowed with 0 gas hint (use default) + if (zkEmailEnabled) { + i = _appendZkEmailInvitesRules(targets, selectors, gasHints, result.zkEmailInvites, i); + } + + // Set all rules to allowed (gas hints already populated where non-zero). for (uint256 j = 0; j < count; j++) { allowed[j] = true; } } + /// @dev EmailProof tuple: (string,bytes32,uint256,string,bytes32,bytes32,bool,bytes) + /// PasskeyEnrollment tuple: (bytes32,bytes32,bytes32,uint256) + /// WebAuthnAuth tuple: (bytes,bytes,uint256,uint256,bytes32,bytes32) + function _appendZkEmailInvitesRules( + address[] memory targets, + bytes4[] memory selectors, + uint32[] memory gasHints, + address zk, + uint256 i + ) private pure returns (uint256) { + // Bare claim: Groth16 verify (~250k) + DKIM lookup + hat mint. + targets[i] = zk; + selectors[i] = + bytes4(keccak256("claimRoleByDomain((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address)")); + gasHints[i] = 800_000; + i++; + targets[i] = zk; + selectors[i] = + bytes4(keccak256("claimRoleByEmail((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address)")); + gasHints[i] = 800_000; + i++; + // Combined register + claim: passkey registration + account create + proof verify + hat mint. + targets[i] = zk; + selectors[i] = bytes4( + keccak256( + "registerAndClaimByDomainWithPasskey((bytes32,bytes32,bytes32,uint256),string,uint256,uint256,(bytes,bytes,uint256,uint256,bytes32,bytes32),(string,bytes32,uint256,string,bytes32,bytes32,bool,bytes))" + ) + ); + gasHints[i] = 1_200_000; + i++; + targets[i] = zk; + selectors[i] = bytes4( + keccak256( + "registerAndClaimByEmailWithPasskey((bytes32,bytes32,bytes32,uint256),string,uint256,uint256,(bytes,bytes,uint256,uint256,bytes32,bytes32),(string,bytes32,uint256,string,bytes32,bytes32,bool,bytes))" + ) + ); + gasHints[i] = 1_200_000; + i++; + return i; + } + function _appendQuickJoinRules(address[] memory targets, bytes4[] memory selectors, address qj, uint256 i) private pure diff --git a/src/ZkEmailInvites.sol b/src/ZkEmailInvites.sol new file mode 100644 index 0000000..46a973d --- /dev/null +++ b/src/ZkEmailInvites.sol @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.21; + +/*────────────────────────── OpenZeppelin v5.3 Upgradeables ────────────────────*/ +import {Initializable} from "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import {ContextUpgradeable} from "@openzeppelin-contracts-upgradeable/contracts/utils/ContextUpgradeable.sol"; +import { + ReentrancyGuardUpgradeable +} from "@openzeppelin-contracts-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol"; + +/*───────────────────────── POP libs / interface stubs ───────────────────────*/ +import {ValidationLib} from "./libs/ValidationLib.sol"; +import {HatManager} from "./libs/HatManager.sol"; +import {WebAuthnLib} from "./libs/WebAuthnLib.sol"; + +/*───────────────────────── ZK Email vendored surface ────────────────────────*/ +import {IVerifier, EmailProof} from "./zkemail/IVerifier.sol"; +import {IDKIMRegistry} from "./zkemail/IDKIMRegistry.sol"; +import {CommandUtils} from "./zkemail/CommandUtils.sol"; + +interface IExecutorHatMinter { + function mintHatsForUser(address user, uint256[] calldata hatIds) external; +} + +interface IUniversalAccountRegistry { + function registerAccountByPasskeySig( + bytes32 credentialId, + bytes32 pubKeyX, + bytes32 pubKeyY, + uint256 salt, + string calldata username, + uint256 deadline, + uint256 nonce, + WebAuthnLib.WebAuthnAuth calldata auth + ) external; +} + +interface IUniversalPasskeyAccountFactory { + function createAccount(bytes32 credentialId, bytes32 pubKeyX, bytes32 pubKeyY, uint256 salt) + external + returns (address account); +} + +/** + * @title ZkEmailInvites + * @notice Per-org module that lets an executor pre-authorize email addresses or whole domains + * to claim role hats by submitting a DKIM-backed ZK Email proof on-chain. + * @dev - Verification happens at claim time via an `IVerifier` (Groth16) and an `IDKIMRegistry` + * (ERC-7969 minimal interface) attached at init. + * - Same UX as the existing passkey QuickJoin: claim selectors are auto-whitelisted in + * `PaymasterHub`, so a freshly-deployed `PasskeyAccount` can claim a role gaslessly via + * ERC-4337. + * - Two binding mechanisms: per-domain wildcard ("anthropic.com -> MEMBER") and per-email + * commitment ("alice at anthropic.com -> CONTRIBUTOR" keyed on the proof's `accountSalt`, + * which is `Poseidon(emailAddress, accountCode)` derived off-chain by the admin). + * - Replay protection: per-org `usedNullifiers` mapping on `proof.emailNullifier`. + * Per-rule idempotency: per-email one-shot; per-domain one-shot-per-domain. + * - Address binding: the proof's `maskedCommand` must end with the `claimer` address as + * a "0x..." hex string (e.g. body or subject: "Claim POP role for 0xABC..."). + * - Account code required: proofs MUST carry an embedded account code + * (`isCodeExist == true`). Otherwise `accountSalt` is not a real + * Poseidon(emailAddress, accountCode) commitment, which would break both per-email + * rule lookups and per-domain claim idempotency. Such proofs are rejected. + * + * Trust model + * ----------- + * - `accountSalt = Poseidon(emailAddress, accountCode)` is the proof's only deterministic + * binding to the email address. `accountCode` is user-chosen at proof-generation time. + * For **per-email rules** (`setEmailRule`) the admin computes `accountSalt` off-chain + * using a fixed, protocol-known `accountCode` — a user who rotates `accountCode` cannot + * produce a proof matching the stored salt, so the per-email allowlist is strict. + * For **per-domain rules** (`setDomainRule`) the same email + a different `accountCode` + * yields a different `accountSalt`, which means an adversarial user CAN re-claim the + * same domain rule with the same email under a new salt. Per-domain rules are therefore + * "good-faith one-per-(email, accountCode)" — strict one-claim-per-email would require + * a custom circuit exposing `hash(emailAddress)` directly. + * + * Re-set semantics + * ---------------- + * - `setEmailRule(salt, ...)` resets `EmailRule.claimed = false`, allowing the admin to + * explicitly re-issue an invitation for the same email (e.g. to upgrade the role). + * - `setDomainRule(domain, ...)` does NOT clear the per-`(accountSalt, domainHash)` + * entries in `claimedByDomain`. Re-setting a domain rule will not let prior claimers + * double-claim that domain. To intentionally allow a clean re-claim under a domain + * rule, the admin must update the domain *string* (different hash) or accept the + * sticky claim state. + */ +contract ZkEmailInvites is Initializable, ContextUpgradeable, ReentrancyGuardUpgradeable { + using ValidationLib for address; + + /* ───────── Errors ───────── */ + error Unauthorized(); + error InvalidProof(); + error InvalidDKIMKey(); + error NullifierAlreadyUsed(); + error DomainNotAllowed(); + error EmailNotAllowed(); + error RuleExpired(); + error AlreadyClaimed(); + error AddressMismatch(); + error AccountCodeMissing(); + error EmptyHats(); + error EmptyDomain(); + error ZeroClaimer(); + error PasskeyFactoryNotSet(); + + /* ───────── Constants ────── */ + bytes4 public constant MODULE_ID = bytes4(keccak256("ZkEmailInvites")); + + /* ───────── Passkey enrollment (mirrors QuickJoin shape) ───────── */ + struct PasskeyEnrollment { + bytes32 credentialId; + bytes32 publicKeyX; + bytes32 publicKeyY; + uint256 salt; + } + + /* ───────── Rule structs ───────── */ + struct DomainRule { + uint256[] hatIds; + uint64 expiry; // 0 = never expires + bool exists; + } + + struct EmailRule { + uint256[] hatIds; + uint64 expiry; + bool exists; + bool claimed; // one-shot + } + + /* ───────── ERC-7201 Storage ──────── */ + /// @custom:storage-location erc7201:poa.zkemailinvites.storage + struct Layout { + address executor; + IVerifier verifier; + IDKIMRegistry dkimRegistry; + IUniversalAccountRegistry accountRegistry; + IUniversalPasskeyAccountFactory universalFactory; + mapping(bytes32 domainHash => DomainRule) domainRules; + mapping(bytes32 accountSalt => EmailRule) emailRules; + mapping(bytes32 nullifier => bool) usedNullifiers; + // Per-email idempotency under a given domain rule: same email may not claim + // under the same domain twice, but may claim across different domains. + mapping(bytes32 accountSalt => mapping(bytes32 domainHash => bool)) claimedByDomain; + } + + bytes32 private constant _STORAGE_SLOT = keccak256("poa.zkemailinvites.storage"); + + function _layout() private pure returns (Layout storage s) { + bytes32 slot = _STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + /* ───────── Events ───────── */ + event DomainRuleSet(bytes32 indexed domainHash, uint256[] hatIds, uint64 expiry); + event DomainRuleRemoved(bytes32 indexed domainHash); + event EmailRuleSet(bytes32 indexed accountSalt, uint256[] hatIds, uint64 expiry); + event EmailRuleRemoved(bytes32 indexed accountSalt); + event RoleClaimedByDomain(address indexed claimer, bytes32 indexed domainHash, uint256[] hatIds, bytes32 nullifier); + event RoleClaimedByEmail(address indexed claimer, bytes32 indexed accountSalt, uint256[] hatIds, bytes32 nullifier); + event RegisteredAndClaimedByDomain( + address indexed account, + bytes32 indexed credentialId, + string username, + bytes32 indexed domainHash, + uint256[] hatIds + ); + event RegisteredAndClaimedByEmail( + address indexed account, + bytes32 indexed credentialId, + string username, + bytes32 indexed accountSalt, + uint256[] hatIds + ); + event VerifierUpdated(address indexed verifier); + event DKIMRegistryUpdated(address indexed registry); + event AccountRegistryUpdated(address indexed registry); + event UniversalFactoryUpdated(address indexed factory); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /* ───────── Initialiser ───── */ + function initialize( + address executor_, + address verifier_, + address dkimRegistry_, + address accountRegistry_, + address universalFactory_ + ) external initializer { + executor_.requireNonZeroAddress(); + verifier_.requireNonZeroAddress(); + dkimRegistry_.requireNonZeroAddress(); + accountRegistry_.requireNonZeroAddress(); + // universalFactory MAY be address(0) at init — matches QuickJoin's late-bind pattern. + + __Context_init(); + __ReentrancyGuard_init(); + + Layout storage l = _layout(); + l.executor = executor_; + l.verifier = IVerifier(verifier_); + l.dkimRegistry = IDKIMRegistry(dkimRegistry_); + l.accountRegistry = IUniversalAccountRegistry(accountRegistry_); + l.universalFactory = IUniversalPasskeyAccountFactory(universalFactory_); + } + + /* ───────── Modifiers ─────── */ + modifier onlyExecutor() { + if (_msgSender() != _layout().executor) revert Unauthorized(); + _; + } + + /* ───────── Admin: rule management (executor-gated) ─────── */ + function setDomainRule(string calldata domain, uint256[] calldata hatIds, uint64 expiry) external onlyExecutor { + if (bytes(domain).length == 0) revert EmptyDomain(); + if (hatIds.length == 0) revert EmptyHats(); + bytes32 dh = keccak256(bytes(_lower(domain))); + DomainRule storage r = _layout().domainRules[dh]; + HatManager.clearHatArray(r.hatIds); + for (uint256 i; i < hatIds.length; ++i) { + HatManager.setHatInArray(r.hatIds, hatIds[i], true); + } + r.expiry = expiry; + r.exists = true; + emit DomainRuleSet(dh, hatIds, expiry); + } + + function removeDomainRule(string calldata domain) external onlyExecutor { + bytes32 dh = keccak256(bytes(_lower(domain))); + delete _layout().domainRules[dh]; + emit DomainRuleRemoved(dh); + } + + /// @dev accountSalt is Poseidon(emailAddress, accountCode) — admin derives off-chain using a + /// known accountCode (recommend `keccak256(orgId)` truncated to BN254 field size). + function setEmailRule(bytes32 accountSalt, uint256[] calldata hatIds, uint64 expiry) external onlyExecutor { + if (hatIds.length == 0) revert EmptyHats(); + EmailRule storage r = _layout().emailRules[accountSalt]; + HatManager.clearHatArray(r.hatIds); + for (uint256 i; i < hatIds.length; ++i) { + HatManager.setHatInArray(r.hatIds, hatIds[i], true); + } + r.expiry = expiry; + r.exists = true; + r.claimed = false; + emit EmailRuleSet(accountSalt, hatIds, expiry); + } + + function removeEmailRule(bytes32 accountSalt) external onlyExecutor { + delete _layout().emailRules[accountSalt]; + emit EmailRuleRemoved(accountSalt); + } + + function setVerifier(address v) external onlyExecutor { + v.requireNonZeroAddress(); + _layout().verifier = IVerifier(v); + emit VerifierUpdated(v); + } + + function setDKIMRegistry(address d) external onlyExecutor { + d.requireNonZeroAddress(); + _layout().dkimRegistry = IDKIMRegistry(d); + emit DKIMRegistryUpdated(d); + } + + function setAccountRegistry(address r) external onlyExecutor { + r.requireNonZeroAddress(); + _layout().accountRegistry = IUniversalAccountRegistry(r); + emit AccountRegistryUpdated(r); + } + + function setUniversalFactory(address f) external onlyExecutor { + _layout().universalFactory = IUniversalPasskeyAccountFactory(f); + emit UniversalFactoryUpdated(f); + } + + /* ───────── User: bare claim paths (user already has an account) ─────── */ + + /// @notice Claim hats under a pre-registered domain rule. + /// @param proof Email proof. `proof.domainName` matches an admin-registered domain rule. + /// @param claimer Address that will receive the hats. Must equal the address encoded in the + /// trailing "0x…" hex of `proof.maskedCommand`. + function claimRoleByDomain(EmailProof calldata proof, address claimer) external nonReentrant { + _verifyProofCommon(proof, claimer); + bytes32 dh = keccak256(bytes(_lower(proof.domainName))); + uint256[] storage hatIds = _consumeDomainRule(dh, proof.accountSalt); + IExecutorHatMinter(_layout().executor).mintHatsForUser(claimer, hatIds); + emit RoleClaimedByDomain(claimer, dh, hatIds, proof.emailNullifier); + } + + /// @notice Claim hats under a pre-registered email rule. + function claimRoleByEmail(EmailProof calldata proof, address claimer) external nonReentrant { + _verifyProofCommon(proof, claimer); + uint256[] storage hatIds = _consumeEmailRule(proof.accountSalt); + IExecutorHatMinter(_layout().executor).mintHatsForUser(claimer, hatIds); + emit RoleClaimedByEmail(claimer, proof.accountSalt, hatIds, proof.emailNullifier); + } + + /* ───────── User: combined register + claim paths (first-time onboarding) ─────── */ + + /// @notice Atomic onboarding: register username via WebAuthn sig + deploy `PasskeyAccount` + /// (idempotent if already deployed) + verify email proof + mint domain-rule hats. + /// @dev The email proof's `maskedCommand` must encode the resulting `account` address. + function registerAndClaimByDomainWithPasskey( + PasskeyEnrollment calldata passkey, + string calldata username, + uint256 deadline, + uint256 nonce, + WebAuthnLib.WebAuthnAuth calldata auth, + EmailProof calldata proof + ) external nonReentrant returns (address account) { + account = _registerAndCreateAccount(passkey, username, deadline, nonce, auth); + _verifyProofCommon(proof, account); + + bytes32 dh = keccak256(bytes(_lower(proof.domainName))); + uint256[] storage hatIds = _consumeDomainRule(dh, proof.accountSalt); + IExecutorHatMinter(_layout().executor).mintHatsForUser(account, hatIds); + emit RegisteredAndClaimedByDomain(account, passkey.credentialId, username, dh, hatIds); + } + + /// @notice Atomic onboarding under a per-email rule. + function registerAndClaimByEmailWithPasskey( + PasskeyEnrollment calldata passkey, + string calldata username, + uint256 deadline, + uint256 nonce, + WebAuthnLib.WebAuthnAuth calldata auth, + EmailProof calldata proof + ) external nonReentrant returns (address account) { + account = _registerAndCreateAccount(passkey, username, deadline, nonce, auth); + _verifyProofCommon(proof, account); + + uint256[] storage hatIds = _consumeEmailRule(proof.accountSalt); + IExecutorHatMinter(_layout().executor).mintHatsForUser(account, hatIds); + emit RegisteredAndClaimedByEmail(account, passkey.credentialId, username, proof.accountSalt, hatIds); + } + + /* ───────── Internals ─────── */ + function _registerAndCreateAccount( + PasskeyEnrollment calldata passkey, + string calldata username, + uint256 deadline, + uint256 nonce, + WebAuthnLib.WebAuthnAuth calldata auth + ) private returns (address account) { + Layout storage l = _layout(); + if (address(l.universalFactory) == address(0)) revert PasskeyFactoryNotSet(); + + l.accountRegistry + .registerAccountByPasskeySig( + passkey.credentialId, + passkey.publicKeyX, + passkey.publicKeyY, + passkey.salt, + username, + deadline, + nonce, + auth + ); + // Idempotent: returns the existing address if the account is already deployed. + account = l.universalFactory + .createAccount(passkey.credentialId, passkey.publicKeyX, passkey.publicKeyY, passkey.salt); + } + + function _verifyProofCommon(EmailProof calldata proof, address claimer) private { + if (claimer == address(0)) revert ZeroClaimer(); + Layout storage l = _layout(); + if (l.usedNullifiers[proof.emailNullifier]) revert NullifierAlreadyUsed(); + + bytes32 dh = keccak256(bytes(_lower(proof.domainName))); + if (!l.dkimRegistry.isKeyHashValid(dh, proof.publicKeyHash)) revert InvalidDKIMKey(); + if (!l.verifier.verifyEmailProof(proof)) revert InvalidProof(); + + // The account code must be embedded: accountSalt = Poseidon(emailAddress, accountCode). + // When isCodeExist is false the code was absent, so accountSalt is not a trustworthy + // (email, accountCode) commitment — which both email-rule lookups and per-domain claim + // idempotency depend on. Checked after verifyEmailProof so isCodeExist is the proven value. + if (!proof.isCodeExist) revert AccountCodeMissing(); + + address bound = CommandUtils.extractTrailingEthAddr(proof.maskedCommand); + if (bound != claimer) revert AddressMismatch(); + + l.usedNullifiers[proof.emailNullifier] = true; + } + + function _consumeDomainRule(bytes32 dh, bytes32 accountSalt) private returns (uint256[] storage) { + Layout storage l = _layout(); + DomainRule storage rule = l.domainRules[dh]; + if (!rule.exists) revert DomainNotAllowed(); + if (rule.expiry != 0 && block.timestamp > rule.expiry) revert RuleExpired(); + if (l.claimedByDomain[accountSalt][dh]) revert AlreadyClaimed(); + l.claimedByDomain[accountSalt][dh] = true; + return rule.hatIds; + } + + function _consumeEmailRule(bytes32 accountSalt) private returns (uint256[] storage) { + EmailRule storage rule = _layout().emailRules[accountSalt]; + if (!rule.exists) revert EmailNotAllowed(); + if (rule.expiry != 0 && block.timestamp > rule.expiry) revert RuleExpired(); + if (rule.claimed) revert AlreadyClaimed(); + rule.claimed = true; + return rule.hatIds; + } + + /// @dev ASCII lowercase — domain names are ASCII per RFC 1035. + function _lower(string memory s) private pure returns (string memory) { + bytes memory b = bytes(s); + for (uint256 i; i < b.length; ++i) { + if (b[i] >= 0x41 && b[i] <= 0x5A) { + b[i] = bytes1(uint8(b[i]) + 32); + } + } + return string(b); + } + + /* ───────── Views ─────── */ + function executor() external view returns (address) { + return _layout().executor; + } + + function verifier() external view returns (IVerifier) { + return _layout().verifier; + } + + function dkimRegistry() external view returns (IDKIMRegistry) { + return _layout().dkimRegistry; + } + + function accountRegistry() external view returns (IUniversalAccountRegistry) { + return _layout().accountRegistry; + } + + function universalFactory() external view returns (IUniversalPasskeyAccountFactory) { + return _layout().universalFactory; + } + + function getDomainRule(bytes32 domainHash) + external + view + returns (uint256[] memory hatIds, uint64 expiry, bool exists) + { + DomainRule storage r = _layout().domainRules[domainHash]; + return (HatManager.getHatArray(r.hatIds), r.expiry, r.exists); + } + + function getEmailRule(bytes32 accountSalt) + external + view + returns (uint256[] memory hatIds, uint64 expiry, bool exists, bool claimed) + { + EmailRule storage r = _layout().emailRules[accountSalt]; + return (HatManager.getHatArray(r.hatIds), r.expiry, r.exists, r.claimed); + } + + function isNullifierUsed(bytes32 n) external view returns (bool) { + return _layout().usedNullifiers[n]; + } + + function hasEmailClaimedDomain(bytes32 accountSalt, bytes32 domainHash) external view returns (bool) { + return _layout().claimedByDomain[accountSalt][domainHash]; + } +} diff --git a/src/factories/ModulesFactory.sol b/src/factories/ModulesFactory.sol index c40c0e5..bf09d4e 100644 --- a/src/factories/ModulesFactory.sol +++ b/src/factories/ModulesFactory.sol @@ -55,6 +55,14 @@ contract ModulesFactory { bool autoUpgrade; RoleAssignments roleAssignments; EducationHubConfig educationHubConfig; // EducationHub deployment configuration + // ZK Email Invites infra (deployed once per chain). + // If `zkEmailVerifier` AND `zkEmailDkimRegistry` are both non-zero, a per-org + // ZkEmailInvites proxy is deployed; otherwise this module is skipped (backwards-compatible + // for chains where the protocol infra hasn't been wired yet via PoaManager). + address zkEmailVerifier; + address zkEmailDkimRegistry; + address accountRegistry; // UniversalAccountRegistry used by ZkEmailInvites combined-claim flow + address universalFactory; // UniversalPasskeyAccountFactory used by combined-claim flow (may be 0) } /*──────────────────── Modules Deployment Result ────────────────────*/ @@ -62,6 +70,8 @@ contract ModulesFactory { address taskManager; address educationHub; address paymentManager; + // address(0) when ZK Email infra is not wired on this chain — caller MUST handle this. + address zkEmailInvites; } /*══════════════ MAIN DEPLOYMENT FUNCTION ═════════════=*/ @@ -160,13 +170,56 @@ contract ModulesFactory { ); } - /* 4. Batch register contracts (2 or 3 depending on EducationHub) */ + /* 4. Deploy ZkEmailInvites if the protocol infra is wired (conditional, without registration) */ + // Three independent prerequisites must ALL hold for the module to be deployable: + // (a) verifier + DKIM registry infra addresses are set (via OrgDeployer.setZkEmailInfrastructure), + // (b) the UniversalAccountRegistry address is present, and + // (c) PoaManager has a ZkEmailInvites beacon registered on THIS chain. + // (c) is checked with the non-reverting `beaconRegistered` probe: if the protocol type was + // never registered here, the module is silently skipped instead of reverting the whole org + // deploy with `TypeUnknown`. This keeps an optional feature from bricking the core path when + // infra addresses are wired ahead of the beacon (e.g. on a fresh or satellite chain). + address zkEmailInvitesBeacon; + bool zkEmailEnabled = params.zkEmailVerifier != address(0) && params.zkEmailDkimRegistry != address(0) + && params.accountRegistry != address(0) + && BeaconDeploymentLib.beaconRegistered(ModuleTypes.ZKEMAIL_INVITES_ID, params.poaManager); + if (zkEmailEnabled) { + zkEmailInvitesBeacon = BeaconDeploymentLib.createBeacon( + ModuleTypes.ZKEMAIL_INVITES_ID, params.poaManager, params.executor, params.autoUpgrade, address(0) + ); + + ModuleDeploymentLib.DeployConfig memory config = ModuleDeploymentLib.DeployConfig({ + poaManager: IPoaManager(params.poaManager), + orgRegistry: OrgRegistry(params.orgRegistry), + hats: params.hats, + orgId: params.orgId, + moduleOwner: params.executor, + autoUpgrade: params.autoUpgrade, + customImpl: address(0) + }); + + result.zkEmailInvites = ModuleDeploymentLib.deployZkEmailInvites( + config, + params.executor, + params.zkEmailVerifier, + params.zkEmailDkimRegistry, + params.accountRegistry, + params.universalFactory, + zkEmailInvitesBeacon + ); + } + + /* 5. Batch register contracts (variable count: TaskManager + PaymentManager + opt. EducationHub + opt. ZkEmailInvites) */ { - uint256 registrationCount = params.educationHubConfig.enabled ? 3 : 2; + uint256 registrationCount = 2; // TaskManager + PaymentManager always + if (params.educationHubConfig.enabled) registrationCount++; + if (zkEmailEnabled) registrationCount++; + OrgRegistry.ContractRegistration[] memory registrations = new OrgRegistry.ContractRegistration[](registrationCount); - registrations[0] = OrgRegistry.ContractRegistration({ + uint256 idx = 0; + registrations[idx++] = OrgRegistry.ContractRegistration({ typeId: ModuleTypes.TASK_MANAGER_ID, proxy: result.taskManager, beacon: taskManagerBeacon, @@ -174,24 +227,26 @@ contract ModulesFactory { }); if (params.educationHubConfig.enabled) { - registrations[1] = OrgRegistry.ContractRegistration({ + registrations[idx++] = OrgRegistry.ContractRegistration({ typeId: ModuleTypes.EDUCATION_HUB_ID, proxy: result.educationHub, beacon: educationHubBeacon, owner: params.executor }); + } - registrations[2] = OrgRegistry.ContractRegistration({ - typeId: ModuleTypes.PAYMENT_MANAGER_ID, - proxy: result.paymentManager, - beacon: paymentManagerBeacon, - owner: params.executor - }); - } else { - registrations[1] = OrgRegistry.ContractRegistration({ - typeId: ModuleTypes.PAYMENT_MANAGER_ID, - proxy: result.paymentManager, - beacon: paymentManagerBeacon, + registrations[idx++] = OrgRegistry.ContractRegistration({ + typeId: ModuleTypes.PAYMENT_MANAGER_ID, + proxy: result.paymentManager, + beacon: paymentManagerBeacon, + owner: params.executor + }); + + if (zkEmailEnabled) { + registrations[idx++] = OrgRegistry.ContractRegistration({ + typeId: ModuleTypes.ZKEMAIL_INVITES_ID, + proxy: result.zkEmailInvites, + beacon: zkEmailInvitesBeacon, owner: params.executor }); } diff --git a/src/libs/BeaconDeploymentLib.sol b/src/libs/BeaconDeploymentLib.sol index 8059cd3..7f4317d 100644 --- a/src/libs/BeaconDeploymentLib.sol +++ b/src/libs/BeaconDeploymentLib.sol @@ -44,4 +44,18 @@ library BeaconDeploymentLib { // Create SwitchableBeacon with appropriate configuration beacon = address(new SwitchableBeacon(moduleOwner, poaBeacon, initImpl, beaconMode)); } + + /** + * @notice Returns true if PoaManager has a beacon registered for `typeId`. + * @dev Uses the public `beacons` mapping getter, which returns address(0) for an + * unregistered type — unlike `getBeaconById`, it does NOT revert. Callers use this + * to gate *optional* modules: if the protocol-level beacon was never registered on + * this chain, the module is treated as unavailable and skipped, rather than reverting + * the entire org deployment with `TypeUnknown`. + * @param typeId Module type identifier from ModuleTypes + * @param poaManager Address of the PoaManager contract + */ + function beaconRegistered(bytes32 typeId, address poaManager) internal view returns (bool) { + return IPoaManager(poaManager).beacons(typeId) != address(0); + } } diff --git a/src/libs/ModuleDeploymentLib.sol b/src/libs/ModuleDeploymentLib.sol index 99d933d..d58e4d3 100644 --- a/src/libs/ModuleDeploymentLib.sol +++ b/src/libs/ModuleDeploymentLib.sol @@ -9,6 +9,10 @@ import {ModuleTypes} from "./ModuleTypes.sol"; interface IPoaManager { function getBeaconById(bytes32 typeId) external view returns (address); function getCurrentImplementationById(bytes32 typeId) external view returns (address); + /// @dev Public `beacons` mapping getter. Unlike `getBeaconById`, returns address(0) + /// for an unregistered type instead of reverting `TypeUnknown` — lets callers + /// probe for optional module types without bricking on absence. + function beacons(bytes32 typeId) external view returns (address); } interface IHybridVotingInit { @@ -94,6 +98,16 @@ interface IPasskeyAccountFactoryInit { external; } +interface IZkEmailInvitesInit { + function initialize( + address executor, + address verifier, + address dkimRegistry, + address accountRegistry, + address universalFactory + ) external; +} + library ModuleDeploymentLib { error InvalidAddress(); error EmptyInit(); @@ -304,4 +318,24 @@ library ModuleDeploymentLib { ); factoryProxy = deployCore(config, ModuleTypes.PASSKEY_ACCOUNT_FACTORY_ID, init, factoryBeacon); } + + function deployZkEmailInvites( + DeployConfig memory config, + address executorAddr, + address verifier, + address dkimRegistry, + address accountRegistry, + address universalFactory, + address beacon + ) internal returns (address proxy) { + bytes memory init = abi.encodeWithSelector( + IZkEmailInvitesInit.initialize.selector, + executorAddr, + verifier, + dkimRegistry, + accountRegistry, + universalFactory + ); + proxy = deployCore(config, ModuleTypes.ZKEMAIL_INVITES_ID, init, beacon); + } } diff --git a/src/libs/ModuleTypes.sol b/src/libs/ModuleTypes.sol index 1e1560c..3fcf4f6 100644 --- a/src/libs/ModuleTypes.sol +++ b/src/libs/ModuleTypes.sol @@ -30,4 +30,5 @@ library ModuleTypes { bytes32 constant DIRECT_DEMOCRACY_VOTING_ID = keccak256("DirectDemocracyVoting"); bytes32 constant PASSKEY_ACCOUNT_ID = keccak256("PasskeyAccount"); bytes32 constant PASSKEY_ACCOUNT_FACTORY_ID = keccak256("PasskeyAccountFactory"); + bytes32 constant ZKEMAIL_INVITES_ID = keccak256("ZkEmailInvites"); } diff --git a/src/zkemail/CommandUtils.sol b/src/zkemail/CommandUtils.sol new file mode 100644 index 0000000..b59176f --- /dev/null +++ b/src/zkemail/CommandUtils.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +/// @title CommandUtils +/// @notice Minimal helpers for parsing the `maskedCommand` field of a ZK Email proof. +/// @dev Upstream zkemail/email-tx-builder ships a full template engine +/// (computeExpectedCommand + {string}/{uint}/{int}/{decimals}/{ethAddr} matchers). +/// This module only needs to bind the proof to a claimer address, so we parse +/// the trailing "0x…" hex address directly. Saves ~200 lines and ~1 OZ import. +library CommandUtils { + error InvalidCommand(); + + /// @notice Extract an Ethereum address from the trailing 42 chars of `cmd`. + /// @dev Expected template: any prefix followed by "0xHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH". + /// Case-insensitive on hex digits. Reverts `InvalidCommand` if the trailing 42 chars + /// are not a valid "0x"-prefixed hex address. + function extractTrailingEthAddr(string memory cmd) internal pure returns (address) { + bytes memory b = bytes(cmd); + if (b.length < 42) revert InvalidCommand(); + + uint256 start = b.length - 42; + if (b[start] != bytes1("0") || (b[start + 1] != bytes1("x") && b[start + 1] != bytes1("X"))) { + revert InvalidCommand(); + } + + uint256 acc; + for (uint256 i = start + 2; i < b.length; ++i) { + uint8 c = uint8(b[i]); + uint256 digit; + if (c >= 0x30 && c <= 0x39) { + digit = c - 0x30; // 0-9 + } else if (c >= 0x41 && c <= 0x46) { + digit = c - 0x41 + 10; // A-F + } else if (c >= 0x61 && c <= 0x66) { + digit = c - 0x61 + 10; // a-f + } else { + revert InvalidCommand(); + } + acc = (acc << 4) | digit; + } + return address(uint160(acc)); + } +} diff --git a/src/zkemail/IDKIMRegistry.sol b/src/zkemail/IDKIMRegistry.sol new file mode 100644 index 0000000..c870448 --- /dev/null +++ b/src/zkemail/IDKIMRegistry.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +// ERC-7969: DomainKeys Identified Mail (DKIM) Registry — minimal interface +// https://eips.ethereum.org/EIPS/eip-7969 +pragma solidity ^0.8.21; + +interface IDKIMRegistry { + /// @notice Returns true if `keyHash` is currently a valid DKIM public-key hash for `domainHash`. + /// @param domainHash keccak256 of the lowercase ASCII domain (e.g. keccak256("anthropic.com")) + /// @param keyHash Hash of the DKIM RSA public key as published in DNS TXT (algorithm per impl). + function isKeyHashValid(bytes32 domainHash, bytes32 keyHash) external view returns (bool); +} diff --git a/src/zkemail/IVerifier.sol b/src/zkemail/IVerifier.sol new file mode 100644 index 0000000..39093d6 --- /dev/null +++ b/src/zkemail/IVerifier.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Vendored verbatim from zkemail/email-tx-builder +// https://github.com/zkemail/email-tx-builder/blob/main/packages/contracts/src/interfaces/IVerifier.sol +pragma solidity ^0.8.21; + +struct EmailProof { + string domainName; // Domain name of the sender's email + bytes32 publicKeyHash; // Hash of the DKIM public key used in email/proof + uint256 timestamp; // Timestamp of the email + string maskedCommand; // Masked command of the email + bytes32 emailNullifier; // Nullifier of the email to prevent its reuse. + bytes32 accountSalt; // Create2 salt of the account + bool isCodeExist; // Check if the account code is exist + bytes proof; // ZK Proof of Email +} + +interface IVerifier { + function commandBytes() external view returns (uint256); + function verifyEmailProof(EmailProof memory proof) external view returns (bool); +} diff --git a/test/DeployerTest.t.sol b/test/DeployerTest.t.sol index 4de9f73..8f5191d 100644 --- a/test/DeployerTest.t.sol +++ b/test/DeployerTest.t.sol @@ -658,7 +658,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { } /*══════════════════════════════════════════ SET‑UP ══════════════════════════════════════════*/ - function setUp() public { + function setUp() public virtual { // Fork Sepolia using the RPC URL from foundry.toml vm.createSelectFork("hoodi"); diff --git a/test/ZkEmailInvites.t.sol b/test/ZkEmailInvites.t.sol new file mode 100644 index 0000000..9c9a52f --- /dev/null +++ b/test/ZkEmailInvites.t.sol @@ -0,0 +1,914 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.21; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; + +import { + ZkEmailInvites, + IUniversalAccountRegistry, + IUniversalPasskeyAccountFactory, + IExecutorHatMinter +} from "../src/ZkEmailInvites.sol"; +import {EmailProof, IVerifier} from "../src/zkemail/IVerifier.sol"; +import {IDKIMRegistry} from "../src/zkemail/IDKIMRegistry.sol"; +import {CommandUtils} from "../src/zkemail/CommandUtils.sol"; +import {WebAuthnLib} from "../src/libs/WebAuthnLib.sol"; + +/*────────────────────────────── Mocks ──────────────────────────────*/ + +contract MockVerifier is IVerifier { + bool public result = true; + + function setResult(bool v) external { + result = v; + } + + function commandBytes() external pure returns (uint256) { + return 605; + } + + function verifyEmailProof(EmailProof memory) external view returns (bool) { + return result; + } +} + +contract MockDKIMRegistry is IDKIMRegistry { + bool public result = true; + mapping(bytes32 => mapping(bytes32 => bool)) public overrides; + mapping(bytes32 => mapping(bytes32 => bool)) public overrideSet; + + function setResult(bool v) external { + result = v; + } + + function setKey(bytes32 domainHash, bytes32 keyHash, bool ok) external { + overrides[domainHash][keyHash] = ok; + overrideSet[domainHash][keyHash] = true; + } + + function isKeyHashValid(bytes32 domainHash, bytes32 keyHash) external view returns (bool) { + if (overrideSet[domainHash][keyHash]) return overrides[domainHash][keyHash]; + return result; + } +} + +contract MockAccountRegistry is IUniversalAccountRegistry { + bool public shouldRevert; + bytes32 public lastCredentialId; + string public lastUsername; + uint256 public callCount; + + function setShouldRevert(bool v) external { + shouldRevert = v; + } + + function registerAccountByPasskeySig( + bytes32 credentialId, + bytes32, + bytes32, + uint256, + string calldata username, + uint256 deadline, + uint256, + WebAuthnLib.WebAuthnAuth calldata + ) external { + if (shouldRevert) revert("MockRegistry: rejected"); + require(block.timestamp <= deadline, "expired"); + lastCredentialId = credentialId; + lastUsername = username; + callCount++; + } +} + +contract MockUniversalFactory is IUniversalPasskeyAccountFactory { + mapping(bytes32 => address) public deployed; + uint256 public callCount; + + function pin(bytes32 credentialId, address acct) external { + deployed[credentialId] = acct; + } + + function createAccount(bytes32 credentialId, bytes32, bytes32, uint256) external returns (address account) { + callCount++; + account = deployed[credentialId]; + if (account == address(0)) { + // Deterministically derive a fresh address from credentialId + account = address(uint160(uint256(keccak256(abi.encode("acct", credentialId))))); + deployed[credentialId] = account; + } + } +} + +contract MockExecutor is IExecutorHatMinter { + struct Mint { + address user; + uint256[] hatIds; + } + + Mint[] private _mints; + + function mintHatsForUser(address user, uint256[] calldata hatIds) external { + uint256[] memory copy = new uint256[](hatIds.length); + for (uint256 i; i < hatIds.length; ++i) { + copy[i] = hatIds[i]; + } + _mints.push(Mint(user, copy)); + } + + function mintCount() external view returns (uint256) { + return _mints.length; + } + + function mintAt(uint256 i) external view returns (address user, uint256[] memory hatIds) { + Mint storage m = _mints[i]; + return (m.user, m.hatIds); + } +} + +/// @notice External wrapper so vm.expectRevert can catch CommandUtils library reverts. +contract CommandUtilsTester { + function extract(string calldata cmd) external pure returns (address) { + return CommandUtils.extractTrailingEthAddr(cmd); + } +} + +/// @notice Reentrancy probe — wired as a malicious executor that calls back into ZkEmailInvites. +contract ReentrancyExecutor is IExecutorHatMinter { + ZkEmailInvites public zk; + EmailProof public proof; + address public claimer; + bool public attempted; + + function arm(ZkEmailInvites _zk, EmailProof memory _proof, address _claimer) external { + zk = _zk; + proof = _proof; + claimer = _claimer; + } + + function mintHatsForUser(address, uint256[] calldata) external { + if (attempted) return; + attempted = true; + // Recurse — should be blocked by nonReentrant + zk.claimRoleByDomain(proof, claimer); + } +} + +/*────────────────────────────── Tests ──────────────────────────────*/ + +contract ZkEmailInvitesTest is Test { + ZkEmailInvites zk; + MockVerifier verifier; + MockDKIMRegistry dkim; + MockAccountRegistry acctRegistry; + MockUniversalFactory factory; + MockExecutor executorMock; + + address executorAddr; + address user = address(0xC0FFEE); + + // Standard fixture template + string constant DOMAIN = "anthropic.com"; + bytes32 DOMAIN_HASH = keccak256(bytes("anthropic.com")); + bytes32 constant KEY_HASH = bytes32(uint256(0xAA)); + bytes32 constant SALT_ALICE = bytes32(uint256(0xA11CE)); + bytes32 constant SALT_BOB = bytes32(uint256(0xB0B)); + + event RoleClaimedByDomain(address indexed claimer, bytes32 indexed domainHash, uint256[] hatIds, bytes32 nullifier); + event RoleClaimedByEmail(address indexed claimer, bytes32 indexed accountSalt, uint256[] hatIds, bytes32 nullifier); + event DomainRuleSet(bytes32 indexed domainHash, uint256[] hatIds, uint64 expiry); + event DomainRuleRemoved(bytes32 indexed domainHash); + event EmailRuleSet(bytes32 indexed accountSalt, uint256[] hatIds, uint64 expiry); + event EmailRuleRemoved(bytes32 indexed accountSalt); + event VerifierUpdated(address indexed verifier); + event DKIMRegistryUpdated(address indexed registry); + event AccountRegistryUpdated(address indexed registry); + event UniversalFactoryUpdated(address indexed factory); + + function setUp() public { + verifier = new MockVerifier(); + dkim = new MockDKIMRegistry(); + acctRegistry = new MockAccountRegistry(); + factory = new MockUniversalFactory(); + executorMock = new MockExecutor(); + executorAddr = address(executorMock); + + ZkEmailInvites impl = new ZkEmailInvites(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + zk = ZkEmailInvites(address(new BeaconProxy(address(beacon), ""))); + + zk.initialize(executorAddr, address(verifier), address(dkim), address(acctRegistry), address(factory)); + } + + /*────────── Helpers ──────────*/ + + function _hatIds(uint256 a) internal pure returns (uint256[] memory ids) { + ids = new uint256[](1); + ids[0] = a; + } + + function _hatIds(uint256 a, uint256 b) internal pure returns (uint256[] memory ids) { + ids = new uint256[](2); + ids[0] = a; + ids[1] = b; + } + + function _addrToHexLower(address a) internal pure returns (string memory out) { + bytes16 alphabet = "0123456789abcdef"; + bytes memory s = new bytes(42); + s[0] = "0"; + s[1] = "x"; + uint256 v = uint256(uint160(a)); + for (uint256 i = 0; i < 40; ++i) { + s[41 - i] = alphabet[v & 0xf]; + v >>= 4; + } + out = string(s); + } + + function _makeProof(bytes32 accountSalt, bytes32 nullifier, address forClaimer) + internal + view + returns (EmailProof memory p) + { + p.domainName = DOMAIN; + p.publicKeyHash = KEY_HASH; + p.timestamp = block.timestamp; + p.maskedCommand = string.concat("Claim POP role for ", _addrToHexLower(forClaimer)); + p.emailNullifier = nullifier; + p.accountSalt = accountSalt; + p.isCodeExist = true; + p.proof = hex"deadbeef"; + } + + function _setDomain(uint256[] memory ids, uint64 expiry) internal { + vm.prank(executorAddr); + zk.setDomainRule(DOMAIN, ids, expiry); + } + + function _setEmail(bytes32 salt, uint256[] memory ids, uint64 expiry) internal { + vm.prank(executorAddr); + zk.setEmailRule(salt, ids, expiry); + } + + function _enroll() internal pure returns (ZkEmailInvites.PasskeyEnrollment memory e) { + e.credentialId = bytes32(uint256(0x11)); + e.publicKeyX = bytes32(uint256(0x22)); + e.publicKeyY = bytes32(uint256(0x33)); + e.salt = 0; + } + + function _emptyAuth() internal pure returns (WebAuthnLib.WebAuthnAuth memory a) { + // zero-valued struct; mock registry doesn't verify + } + + /*────────── Storage slot guard ──────────*/ + + /// @dev Confirms that the contract reads/writes at `keccak256("poa.zkemailinvites.storage")`. + /// `executor` is the first field of `Layout` — if anyone reorders the struct or renames + /// the slot string, this test catches it before it ships. + function testStorageSlot_isExpected() public view { + bytes32 slot = keccak256("poa.zkemailinvites.storage"); + bytes32 stored = vm.load(address(zk), slot); + assertEq(address(uint160(uint256(stored))), executorAddr); + } + + /*────────── Init guards ──────────*/ + + function testCannotInitializeTwice() public { + vm.expectRevert(); + zk.initialize(executorAddr, address(verifier), address(dkim), address(acctRegistry), address(factory)); + } + + function testCannotInitializeImpl() public { + ZkEmailInvites impl = new ZkEmailInvites(); + vm.expectRevert(); + impl.initialize(executorAddr, address(verifier), address(dkim), address(acctRegistry), address(factory)); + } + + /*────────── Admin gating ──────────*/ + + function testSetDomainRule_onlyExecutor() public { + vm.expectRevert(ZkEmailInvites.Unauthorized.selector); + zk.setDomainRule(DOMAIN, _hatIds(1), 0); + } + + function testSetEmailRule_onlyExecutor() public { + vm.expectRevert(ZkEmailInvites.Unauthorized.selector); + zk.setEmailRule(SALT_ALICE, _hatIds(1), 0); + } + + function testSetVerifier_onlyExecutor() public { + vm.expectRevert(ZkEmailInvites.Unauthorized.selector); + zk.setVerifier(address(0xBEEF)); + } + + function testSetDKIMRegistry_onlyExecutor() public { + vm.expectRevert(ZkEmailInvites.Unauthorized.selector); + zk.setDKIMRegistry(address(0xBEEF)); + } + + function testSetDomainRule_emptyHatsReverts() public { + uint256[] memory none = new uint256[](0); + vm.prank(executorAddr); + vm.expectRevert(ZkEmailInvites.EmptyHats.selector); + zk.setDomainRule(DOMAIN, none, 0); + } + + function testSetDomainRule_emptyDomainReverts() public { + vm.prank(executorAddr); + vm.expectRevert(ZkEmailInvites.EmptyDomain.selector); + zk.setDomainRule("", _hatIds(1), 0); + } + + function testSetEmailRule_emptyHatsReverts() public { + uint256[] memory none = new uint256[](0); + vm.prank(executorAddr); + vm.expectRevert(ZkEmailInvites.EmptyHats.selector); + zk.setEmailRule(SALT_ALICE, none, 0); + } + + function testRemoveDomainRule() public { + _setDomain(_hatIds(1), 0); + vm.expectEmit(true, false, false, false); + emit DomainRuleRemoved(DOMAIN_HASH); + vm.prank(executorAddr); + zk.removeDomainRule(DOMAIN); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.DomainNotAllowed.selector); + zk.claimRoleByDomain(p, user); + } + + function testRemoveEmailRule() public { + _setEmail(SALT_ALICE, _hatIds(1), 0); + vm.expectEmit(true, false, false, false); + emit EmailRuleRemoved(SALT_ALICE); + vm.prank(executorAddr); + zk.removeEmailRule(SALT_ALICE); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.EmailNotAllowed.selector); + zk.claimRoleByEmail(p, user); + } + + function testSetters_updateAddressesAndEmit() public { + address newAddr = address(0xABCD); + + vm.expectEmit(true, false, false, false); + emit VerifierUpdated(newAddr); + vm.prank(executorAddr); + zk.setVerifier(newAddr); + + vm.expectEmit(true, false, false, false); + emit DKIMRegistryUpdated(newAddr); + vm.prank(executorAddr); + zk.setDKIMRegistry(newAddr); + + vm.expectEmit(true, false, false, false); + emit AccountRegistryUpdated(newAddr); + vm.prank(executorAddr); + zk.setAccountRegistry(newAddr); + + vm.expectEmit(true, false, false, false); + emit UniversalFactoryUpdated(newAddr); + vm.prank(executorAddr); + zk.setUniversalFactory(newAddr); + } + + /*────────── Bare claim — domain rule ──────────*/ + + function testClaimRoleByDomain_success() public { + _setDomain(_hatIds(42), 0); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(0x1111)), user); + + uint256[] memory expectedHats = _hatIds(42); + vm.expectEmit(true, true, false, true); + emit RoleClaimedByDomain(user, DOMAIN_HASH, expectedHats, p.emailNullifier); + + vm.prank(user); + zk.claimRoleByDomain(p, user); + + assertTrue(zk.isNullifierUsed(p.emailNullifier)); + assertTrue(zk.hasEmailClaimedDomain(SALT_ALICE, DOMAIN_HASH)); + assertEq(executorMock.mintCount(), 1); + (address mintedTo, uint256[] memory mintedHats) = executorMock.mintAt(0); + assertEq(mintedTo, user); + assertEq(mintedHats.length, 1); + assertEq(mintedHats[0], 42); + } + + function testClaimRoleByDomain_revertOnUnknownDomain() public { + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.DomainNotAllowed.selector); + zk.claimRoleByDomain(p, user); + } + + function testClaimRoleByDomain_revertOnExpiredRule() public { + _setDomain(_hatIds(1), uint64(block.timestamp + 1 hours)); + vm.warp(block.timestamp + 1 hours + 1); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.RuleExpired.selector); + zk.claimRoleByDomain(p, user); + } + + function testClaimRoleByDomain_revertOnNullifierReuse() public { + _setDomain(_hatIds(1), 0); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByDomain(p, user); + + // Same nullifier in a fresh proof — second attempt blocks at the nullifier check. + EmailProof memory p2 = _makeProof(SALT_BOB, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.NullifierAlreadyUsed.selector); + zk.claimRoleByDomain(p2, user); + } + + function testClaimRoleByDomain_revertOnAddressMismatch() public { + _setDomain(_hatIds(1), 0); + // maskedCommand encodes `user`, but the claim is for a different address. + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + address other = address(0xDEAD); + vm.prank(other); + vm.expectRevert(ZkEmailInvites.AddressMismatch.selector); + zk.claimRoleByDomain(p, other); + } + + function testClaimRoleByDomain_revertOnInvalidDKIM() public { + _setDomain(_hatIds(1), 0); + dkim.setResult(false); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.InvalidDKIMKey.selector); + zk.claimRoleByDomain(p, user); + } + + function testClaimRoleByDomain_revertOnInvalidProof() public { + _setDomain(_hatIds(1), 0); + verifier.setResult(false); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.InvalidProof.selector); + zk.claimRoleByDomain(p, user); + } + + function testClaimRoleByDomain_revertOnDoubleClaim() public { + _setDomain(_hatIds(1), 0); + EmailProof memory p1 = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByDomain(p1, user); + + // Same accountSalt, same domain, different nullifier — should hit AlreadyClaimed. + EmailProof memory p2 = _makeProof(SALT_ALICE, bytes32(uint256(2)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.AlreadyClaimed.selector); + zk.claimRoleByDomain(p2, user); + } + + function testClaimRoleByDomain_sameSaltDifferentDomain() public { + _setDomain(_hatIds(1), 0); + + // Register a second domain for the same accountSalt + string memory altDomain = "example.org"; + bytes32 altHash = keccak256(bytes(altDomain)); + vm.prank(executorAddr); + zk.setDomainRule(altDomain, _hatIds(2), 0); + + EmailProof memory p1 = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByDomain(p1, user); + + EmailProof memory p2 = _makeProof(SALT_ALICE, bytes32(uint256(2)), user); + p2.domainName = altDomain; + vm.prank(user); + zk.claimRoleByDomain(p2, user); + + assertTrue(zk.hasEmailClaimedDomain(SALT_ALICE, DOMAIN_HASH)); + assertTrue(zk.hasEmailClaimedDomain(SALT_ALICE, altHash)); + assertEq(executorMock.mintCount(), 2); + } + + function testClaimRoleByDomain_normalizesCase() public { + // Domain rule registered with mixed case; proof reports lowercase — should match. + vm.prank(executorAddr); + zk.setDomainRule("ANTHROPIC.com", _hatIds(7), 0); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByDomain(p, user); + assertEq(executorMock.mintCount(), 1); + } + + function testClaimRoleByDomain_revertOnZeroClaimer() public { + _setDomain(_hatIds(1), 0); + // Even if maskedCommand encodes address(0), explicit ZeroClaimer guard fires first. + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), address(0)); + vm.expectRevert(ZkEmailInvites.ZeroClaimer.selector); + zk.claimRoleByDomain(p, address(0)); + } + + function testClaimRoleByDomain_permissionless() public { + // Anyone may submit a proof on behalf of the address it's bound to. + // The relayer pays gas; the bound `claimer` receives the hat. + _setDomain(_hatIds(42), 0); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + + address relayer = address(0xBEEF); + vm.prank(relayer); + zk.claimRoleByDomain(p, user); // relayer submits, user receives + + (address mintedTo,) = executorMock.mintAt(0); + assertEq(mintedTo, user, "hat minted to bound claimer, not relayer"); + } + + function testClaimRoleByDomain_multiHatRule() public { + _setDomain(_hatIds(100, 200), 0); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByDomain(p, user); + + (address mintedTo, uint256[] memory hats) = executorMock.mintAt(0); + assertEq(mintedTo, user); + assertEq(hats.length, 2, "both hats minted in one call"); + assertEq(hats[0], 100); + assertEq(hats[1], 200); + } + + /// @dev Documents the per-domain rule limitation: same email + different accountCode + /// yields a different accountSalt, bypassing the `claimedByDomain[salt][domain]` guard. + /// This is intrinsic to using the standard zk-email circuit's `accountSalt` (which + /// depends on user-chosen accountCode). Mitigation = per-email rules or a custom circuit. + function testClaimRoleByDomain_accountSaltRotation_documentedLimit() public { + _setDomain(_hatIds(1), 0); + + // First proof: accountSalt = SALT_ALICE (Poseidon(alice@x, code1)) + EmailProof memory p1 = _makeProof(SALT_ALICE, bytes32(uint256(0xAAA)), user); + vm.prank(user); + zk.claimRoleByDomain(p1, user); + + // Same email, different accountCode → different accountSalt. Contract has no way + // to tell it's the same email, so it allows the second claim. + bytes32 rotatedSalt = bytes32(uint256(0xA11CE2)); + EmailProof memory p2 = _makeProof(rotatedSalt, bytes32(uint256(0xBBB)), user); + vm.prank(user); + zk.claimRoleByDomain(p2, user); + + assertEq(executorMock.mintCount(), 2, "limitation: same email under rotated salt re-claims"); + } + + function testReSetDomainRule_preservesClaimedByDomain() public { + _setDomain(_hatIds(1), 0); + EmailProof memory p1 = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByDomain(p1, user); + assertEq(executorMock.mintCount(), 1); + + // Admin re-issues the rule (same domain, new hat list). + vm.prank(executorAddr); + zk.setDomainRule(DOMAIN, _hatIds(99), 0); + + // Same email, fresh proof under that domain → still blocked by claimedByDomain. + EmailProof memory p2 = _makeProof(SALT_ALICE, bytes32(uint256(2)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.AlreadyClaimed.selector); + zk.claimRoleByDomain(p2, user); + } + + function testOverwriteDomainRule_replacesHats_forFreshSalt() public { + _setDomain(_hatIds(1), 0); + vm.prank(executorAddr); + zk.setDomainRule(DOMAIN, _hatIds(99), 0); + + // A different email under the same domain gets the NEW hat list. + EmailProof memory p = _makeProof(SALT_BOB, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByDomain(p, user); + + (, uint256[] memory hats) = executorMock.mintAt(0); + assertEq(hats.length, 1); + assertEq(hats[0], 99, "new rule hat applied"); + } + + /*────────── Bare claim — email rule ──────────*/ + + function testClaimRoleByEmail_success() public { + _setEmail(SALT_ALICE, _hatIds(99), 0); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(0xABCD)), user); + + uint256[] memory expectedHats = _hatIds(99); + vm.expectEmit(true, true, false, true); + emit RoleClaimedByEmail(user, SALT_ALICE, expectedHats, p.emailNullifier); + + vm.prank(user); + zk.claimRoleByEmail(p, user); + + (,,, bool claimed) = zk.getEmailRule(SALT_ALICE); + assertTrue(claimed); + assertEq(executorMock.mintCount(), 1); + } + + function testClaimRoleByEmail_revertOnUnknownEmail() public { + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.EmailNotAllowed.selector); + zk.claimRoleByEmail(p, user); + } + + function testClaimRoleByEmail_oneShot() public { + _setEmail(SALT_ALICE, _hatIds(1), 0); + EmailProof memory p1 = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByEmail(p1, user); + + EmailProof memory p2 = _makeProof(SALT_ALICE, bytes32(uint256(2)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.AlreadyClaimed.selector); + zk.claimRoleByEmail(p2, user); + } + + function testClaimRoleByEmail_revertOnAddressMismatch() public { + _setEmail(SALT_ALICE, _hatIds(1), 0); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + address other = address(0xBEEF); + vm.prank(other); + vm.expectRevert(ZkEmailInvites.AddressMismatch.selector); + zk.claimRoleByEmail(p, other); + } + + function testClaimRoleByEmail_revertOnExpiredRule() public { + _setEmail(SALT_ALICE, _hatIds(1), uint64(block.timestamp + 1 hours)); + vm.warp(block.timestamp + 1 hours + 1); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + vm.expectRevert(ZkEmailInvites.RuleExpired.selector); + zk.claimRoleByEmail(p, user); + } + + function testClaimRoleByEmail_multiHatRule() public { + _setEmail(SALT_ALICE, _hatIds(10, 20), 0); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByEmail(p, user); + + (, uint256[] memory hats) = executorMock.mintAt(0); + assertEq(hats.length, 2, "both hats minted"); + } + + function testReSetEmailRule_resetsClaimedFlag() public { + _setEmail(SALT_ALICE, _hatIds(1), 0); + EmailProof memory p1 = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + vm.prank(user); + zk.claimRoleByEmail(p1, user); + + (,,, bool claimed) = zk.getEmailRule(SALT_ALICE); + assertTrue(claimed, "first claim sets flag"); + + // Admin re-issues the invitation with a different hat — the documented re-claim semantic. + vm.prank(executorAddr); + zk.setEmailRule(SALT_ALICE, _hatIds(99), 0); + + (,,, claimed) = zk.getEmailRule(SALT_ALICE); + assertFalse(claimed, "re-set clears claimed flag"); + + EmailProof memory p2 = _makeProof(SALT_ALICE, bytes32(uint256(2)), user); + vm.prank(user); + zk.claimRoleByEmail(p2, user); + + assertEq(executorMock.mintCount(), 2, "re-issued rule is claimable"); + (, uint256[] memory hats) = executorMock.mintAt(1); + assertEq(hats[0], 99, "second claim mints the new hat"); + } + + function testClaimRoleByEmail_revertOnZeroClaimer() public { + _setEmail(SALT_ALICE, _hatIds(1), 0); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), address(0)); + vm.expectRevert(ZkEmailInvites.ZeroClaimer.selector); + zk.claimRoleByEmail(p, address(0)); + } + + /*────────── Combined register + claim — domain ──────────*/ + + function testRegisterAndClaimByDomainWithPasskey_success() public { + _setDomain(_hatIds(11, 12), 0); + + ZkEmailInvites.PasskeyEnrollment memory passkey = _enroll(); + address expectedAccount = address(uint160(uint256(keccak256(abi.encode("acct", passkey.credentialId))))); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), expectedAccount); + + address result = + zk.registerAndClaimByDomainWithPasskey(passkey, "alice", block.timestamp + 1 hours, 0, _emptyAuth(), p); + + assertEq(result, expectedAccount); + assertEq(acctRegistry.callCount(), 1); + assertEq(acctRegistry.lastUsername(), "alice"); + assertEq(factory.callCount(), 1); + assertTrue(zk.isNullifierUsed(p.emailNullifier)); + assertEq(executorMock.mintCount(), 1); + (address mintedTo, uint256[] memory hats) = executorMock.mintAt(0); + assertEq(mintedTo, expectedAccount); + assertEq(hats.length, 2); + } + + function testRegisterAndClaimByDomainWithPasskey_revertWhenFactoryUnset() public { + // Reinitialize the contract without the factory bound. + vm.prank(executorAddr); + zk.setUniversalFactory(address(0)); + + _setDomain(_hatIds(1), 0); + ZkEmailInvites.PasskeyEnrollment memory passkey = _enroll(); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + + vm.expectRevert(ZkEmailInvites.PasskeyFactoryNotSet.selector); + zk.registerAndClaimByDomainWithPasskey(passkey, "alice", block.timestamp + 1 hours, 0, _emptyAuth(), p); + } + + function testRegisterAndClaimByDomainWithPasskey_revertWhenRegistryRejectsSig() public { + _setDomain(_hatIds(1), 0); + acctRegistry.setShouldRevert(true); + + ZkEmailInvites.PasskeyEnrollment memory passkey = _enroll(); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + + vm.expectRevert(); // bubbles "MockRegistry: rejected" + zk.registerAndClaimByDomainWithPasskey(passkey, "alice", block.timestamp + 1 hours, 0, _emptyAuth(), p); + + // Nothing should have happened post-revert. + assertEq(factory.callCount(), 0); + assertEq(executorMock.mintCount(), 0); + assertFalse(zk.isNullifierUsed(p.emailNullifier)); + } + + function testRegisterAndClaimByDomainWithPasskey_revertOnAddressMismatch() public { + _setDomain(_hatIds(1), 0); + + ZkEmailInvites.PasskeyEnrollment memory passkey = _enroll(); + // proof encodes a different address than the one the factory will derive + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), address(0xDEAD)); + + vm.expectRevert(ZkEmailInvites.AddressMismatch.selector); + zk.registerAndClaimByDomainWithPasskey(passkey, "alice", block.timestamp + 1 hours, 0, _emptyAuth(), p); + + // The username got registered + the account got created, but the email proof bound to + // the wrong address so the whole tx reverts — atomic. + // (callCount values pre-revert are not observable post-revert; just assert no mint.) + assertEq(executorMock.mintCount(), 0); + } + + /*────────── Combined register + claim — email ──────────*/ + + function testRegisterAndClaimByEmailWithPasskey_success() public { + _setEmail(SALT_ALICE, _hatIds(7), 0); + + ZkEmailInvites.PasskeyEnrollment memory passkey = _enroll(); + address expectedAccount = address(uint160(uint256(keccak256(abi.encode("acct", passkey.credentialId))))); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), expectedAccount); + + address result = + zk.registerAndClaimByEmailWithPasskey(passkey, "alice", block.timestamp + 1 hours, 0, _emptyAuth(), p); + + assertEq(result, expectedAccount); + (,,, bool claimed) = zk.getEmailRule(SALT_ALICE); + assertTrue(claimed); + assertEq(executorMock.mintCount(), 1); + } + + function testRegisterAndClaimByEmailWithPasskey_oneShot() public { + _setEmail(SALT_ALICE, _hatIds(7), 0); + + ZkEmailInvites.PasskeyEnrollment memory passkey = _enroll(); + address expectedAccount = address(uint160(uint256(keccak256(abi.encode("acct", passkey.credentialId))))); + EmailProof memory p1 = _makeProof(SALT_ALICE, bytes32(uint256(1)), expectedAccount); + zk.registerAndClaimByEmailWithPasskey(passkey, "alice", block.timestamp + 1 hours, 0, _emptyAuth(), p1); + + EmailProof memory p2 = _makeProof(SALT_ALICE, bytes32(uint256(2)), expectedAccount); + vm.expectRevert(ZkEmailInvites.AlreadyClaimed.selector); + zk.registerAndClaimByEmailWithPasskey(passkey, "alice", block.timestamp + 1 hours, 0, _emptyAuth(), p2); + } + + /*────────── Account-code requirement (isCodeExist) ──────────*/ + + function testClaimRoleByDomain_revertWhenCodeNotEmbedded() public { + _setDomain(_hatIds(1), 0); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + p.isCodeExist = false; + vm.prank(user); + vm.expectRevert(ZkEmailInvites.AccountCodeMissing.selector); + zk.claimRoleByDomain(p, user); + // Nullifier must NOT have been consumed (whole tx reverted). + assertFalse(zk.isNullifierUsed(p.emailNullifier), "nullifier untouched on revert"); + } + + function testClaimRoleByEmail_revertWhenCodeNotEmbedded() public { + _setEmail(SALT_ALICE, _hatIds(1), 0); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + p.isCodeExist = false; + vm.prank(user); + vm.expectRevert(ZkEmailInvites.AccountCodeMissing.selector); + zk.claimRoleByEmail(p, user); + + (,,, bool claimed) = zk.getEmailRule(SALT_ALICE); + assertFalse(claimed, "email rule not consumed on revert"); + } + + function testRegisterAndClaimByDomainWithPasskey_revertWhenCodeNotEmbedded() public { + _setDomain(_hatIds(1), 0); + ZkEmailInvites.PasskeyEnrollment memory passkey = _enroll(); + address expectedAccount = address(uint160(uint256(keccak256(abi.encode("acct", passkey.credentialId))))); + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), expectedAccount); + p.isCodeExist = false; + vm.expectRevert(ZkEmailInvites.AccountCodeMissing.selector); + zk.registerAndClaimByDomainWithPasskey(passkey, "alice", block.timestamp + 1 hours, 0, _emptyAuth(), p); + assertEq(executorMock.mintCount(), 0, "no hats minted on revert"); + } + + /*────────── Reentrancy ──────────*/ + + function testReentrancy_isBlocked() public { + // Re-init zk with a malicious executor that calls back in. + ZkEmailInvites impl = new ZkEmailInvites(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + ZkEmailInvites attacker = ZkEmailInvites(address(new BeaconProxy(address(beacon), ""))); + ReentrancyExecutor rx = new ReentrancyExecutor(); + attacker.initialize(address(rx), address(verifier), address(dkim), address(acctRegistry), address(factory)); + + uint256[] memory ids = _hatIds(1); + vm.prank(address(rx)); + attacker.setDomainRule(DOMAIN, ids, 0); + + EmailProof memory p = _makeProof(SALT_ALICE, bytes32(uint256(1)), user); + rx.arm(attacker, p, user); + + vm.prank(user); + vm.expectRevert(); // ReentrancyGuardUpgradeable: ReentrancyGuardReentrantCall + attacker.claimRoleByDomain(p, user); + } + + /*────────── CommandUtils sanity ──────────*/ + + function testCommandUtils_extractTrailingEthAddr_lowercase() public pure { + address a = address(0x1234567890AbcdEF1234567890aBcdef12345678); + string memory cmd = string.concat("hello ", _addrToHexLower2(a)); + assertEq(CommandUtils.extractTrailingEthAddr(cmd), a); + } + + function testCommandUtils_extractTrailingEthAddr_short_reverts() public { + CommandUtilsTester tester = new CommandUtilsTester(); + vm.expectRevert(CommandUtils.InvalidCommand.selector); + tester.extract("0xtooshort"); + } + + function testCommandUtils_extractTrailingEthAddr_badPrefix_reverts() public { + CommandUtilsTester tester = new CommandUtilsTester(); + // 42-char string but doesn't start with "0x" + vm.expectRevert(CommandUtils.InvalidCommand.selector); + tester.extract("xx1234567890abcdef1234567890abcdef12345678"); + } + + function testCommandUtils_extractTrailingEthAddr_badHexInMiddle_reverts() public { + CommandUtilsTester tester = new CommandUtilsTester(); + // 42-char "0x..." string with a 'g' (invalid hex digit) in the middle + vm.expectRevert(CommandUtils.InvalidCommand.selector); + tester.extract("0x1234567890abcdef1234567890gbcdef12345678"); + } + + function testCommandUtils_extractTrailingEthAddr_acceptsUppercaseAndUppercasePrefix() public { + // The address itself is upper-case hex and the prefix is "0X" — both legal. + // (Literal prepended with 00 so solc doesn't try to checksum-validate it.) + address expected = address(uint160(0x001234567890ABCDEF1234567890ABCDEF12345678)); + CommandUtilsTester tester = new CommandUtilsTester(); + // 0X + 40 upper-case hex chars + assertEq(tester.extract("hello 0X1234567890ABCDEF1234567890ABCDEF12345678"), expected); + } + + function testCommandUtils_extractTrailingEthAddr_allF() public { + CommandUtilsTester tester = new CommandUtilsTester(); + assertEq( + tester.extract("prefix 0xffffffffffffffffffffffffffffffffffffffff"), address(uint160(type(uint160).max)) + ); + } + + // duplicate helper as a `pure` variant for use inside pure tests + function _addrToHexLower2(address a) internal pure returns (string memory out) { + bytes16 alphabet = "0123456789abcdef"; + bytes memory s = new bytes(42); + s[0] = "0"; + s[1] = "x"; + uint256 v = uint256(uint160(a)); + for (uint256 i = 0; i < 40; ++i) { + s[41 - i] = alphabet[v & 0xf]; + v >>= 4; + } + out = string(s); + } +} diff --git a/test/ZkEmailOrgFlow.t.sol b/test/ZkEmailOrgFlow.t.sol new file mode 100644 index 0000000..9fda590 --- /dev/null +++ b/test/ZkEmailOrgFlow.t.sol @@ -0,0 +1,466 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.21; + +/// @title ZkEmailOrgFlow.t.sol +/// @notice End-to-end integration test for ZkEmailInvites wiring through OrgDeployer. +/// @dev Inherits DeployerTest's setUp so we get the full infra (PoaManager, factories, +/// PaymasterHub, PasskeyFactory, OrgRegistry, real Hats Protocol fork). On top of +/// that we register the ZkEmailInvites contract type, wire mock Verifier + DKIMRegistry +/// via setZkEmailInfrastructure, deploy a new org, and assert every wiring point. + +import "forge-std/Test.sol"; +import {IHats} from "@hats-protocol/src/Interfaces/IHats.sol"; + +import {DeployerTest} from "./DeployerTest.t.sol"; + +import {ZkEmailInvites} from "../src/ZkEmailInvites.sol"; +import {EmailProof, IVerifier} from "../src/zkemail/IVerifier.sol"; +import {IDKIMRegistry} from "../src/zkemail/IDKIMRegistry.sol"; + +import {OrgDeployer, ITaskManagerBootstrap} from "../src/OrgDeployer.sol"; +import {ModulesFactory} from "../src/factories/ModulesFactory.sol"; +import {ModuleTypes} from "../src/libs/ModuleTypes.sol"; +import {RoleConfigStructs} from "../src/libs/RoleConfigStructs.sol"; +import {IHybridVotingInit} from "../src/libs/ModuleDeploymentLib.sol"; +import {PaymasterHub} from "../src/PaymasterHub.sol"; + +/*────────────────────────────── Mocks ──────────────────────────────*/ + +contract MockEmailVerifier is IVerifier { + bool public result = true; + + function setResult(bool v) external { + result = v; + } + + function commandBytes() external pure returns (uint256) { + return 605; + } + + function verifyEmailProof(EmailProof memory) external view returns (bool) { + return result; + } +} + +contract MockEmailDKIMRegistry is IDKIMRegistry { + bool public result = true; + + function setResult(bool v) external { + result = v; + } + + function isKeyHashValid(bytes32, bytes32) external view returns (bool) { + return result; + } +} + +/*────────────────────────── Tests ──────────────────────────*/ + +contract ZkEmailOrgFlowTest is DeployerTest { + MockEmailVerifier mockVerifier; + MockEmailDKIMRegistry mockDkim; + + bytes32 constant ZK_ORG_ID = keccak256("ZKEMAIL-TEST-ORG"); + + function setUp() public override { + super.setUp(); + + // Mock infra so the verifier always returns true and the DKIM registry accepts any key. + mockVerifier = new MockEmailVerifier(); + mockDkim = new MockEmailDKIMRegistry(); + // NOTE: the ZkEmailInvites beacon is intentionally NOT registered here. Tests that + // expect the module to deploy call `_registerZkBeacon()` explicitly (mirroring the + // canonical infra-deploy step). The beacon-missing path leaves it unregistered. + } + + /// @dev Registers the ZkEmailInvites beacon on PoaManager — the protocol-level prerequisite + /// that DeployHelper._contractTypes()/DeployInfrastructure perform on a real chain. + function _registerZkBeacon() internal { + ZkEmailInvites zkImpl = new ZkEmailInvites(); + vm.prank(poaAdmin); + poaManager.addContractType("ZkEmailInvites", address(zkImpl)); + } + + /*──────────── Infra setter ────────────*/ + + function testSetZkEmailInfrastructure_onlyPoaManager() public { + vm.expectRevert(); // InvalidAddress (not from poaManager) + deployer.setZkEmailInfrastructure(address(mockVerifier), address(mockDkim)); + } + + function testSetZkEmailInfrastructure_setsBothFields() public { + // Wire infra via PoaManager.adminCall + vm.prank(poaAdmin); + poaManager.adminCall( + address(deployer), + abi.encodeWithSignature( + "setZkEmailInfrastructure(address,address)", address(mockVerifier), address(mockDkim) + ) + ); + + // Read via raw storage probe — OrgDeployer Layout slot. + bytes32 layoutSlot = keccak256("poa.orgdeployer.storage"); + // Layout fields used by ZkEmail are after the original 10 fields: + // [0] GovernanceFactory governanceFactory + // [1] AccessFactory accessFactory + // [2] ModulesFactory modulesFactory + // [3] OrgRegistry orgRegistry + // [4] address poaManager + // [5] address hatsTreeSetup + // [6] address paymasterHub + // [7] address universalPasskeyFactory + // [8] uint256 _status + // [9] IHats hatsV2 + // [10] address zkEmailVerifier ← target + // [11] address zkEmailDkimRegistry ← target + bytes32 verifierSlot = bytes32(uint256(layoutSlot) + 10); + bytes32 dkimSlot = bytes32(uint256(layoutSlot) + 11); + address storedVerifier = address(uint160(uint256(vm.load(address(deployer), verifierSlot)))); + address storedDkim = address(uint160(uint256(vm.load(address(deployer), dkimSlot)))); + assertEq(storedVerifier, address(mockVerifier), "verifier slot"); + assertEq(storedDkim, address(mockDkim), "dkim slot"); + } + + /*──────────── Conditional deployment ────────────*/ + + function testOrgDeploy_withoutInfra_skipsZkEmailModule() public { + // Verify infra is NOT wired (we don't call setZkEmailInfrastructure) + OrgDeployer.DeploymentResult memory result = _deployZkOrg(ZK_ORG_ID); + assertEq(result.zkEmailInvites, address(0), "ZkEmailInvites should be skipped"); + + // OrgRegistry should revert with ContractUnknown when querying for an unregistered module. + vm.expectRevert(); // ContractUnknown() + orgRegistry.getOrgContract(ZK_ORG_ID, ModuleTypes.ZKEMAIL_INVITES_ID); + } + + function testOrgDeploy_withInfra_deploysAndWiresZkEmailModule() public { + _registerZkBeacon(); + _wireZkInfra(); + + OrgDeployer.DeploymentResult memory result = _deployZkOrg(ZK_ORG_ID); + + // 1. ZkEmailInvites was deployed + assertTrue(result.zkEmailInvites != address(0), "ZkEmailInvites deployed"); + + // 2. ZkEmailInvites is registered in OrgRegistry + address registered = orgRegistry.getOrgContract(ZK_ORG_ID, ModuleTypes.ZKEMAIL_INVITES_ID); + assertEq(registered, result.zkEmailInvites, "registered in OrgRegistry"); + + // 3. ZkEmailInvites was initialized with the right dependencies + ZkEmailInvites zk = ZkEmailInvites(result.zkEmailInvites); + assertEq(zk.executor(), result.executor, "executor wired"); + assertEq(address(zk.verifier()), address(mockVerifier), "verifier wired"); + assertEq(address(zk.dkimRegistry()), address(mockDkim), "dkim wired"); + assertEq(address(zk.accountRegistry()), accountRegProxy, "account registry wired"); + assertEq(address(zk.universalFactory()), address(universalPasskeyFactory), "factory wired"); + } + + function testOrgDeploy_withInfra_butEducationDisabled_stillDeploysZkEmail() public { + // Covers the registration-count combination not exercised elsewhere: + // {edu = false, zk = true} — TaskManager + PaymentManager + ZkEmailInvites = 3 entries + _registerZkBeacon(); + _wireZkInfra(); + OrgDeployer.DeploymentResult memory result = _deployZkOrgInner(ZK_ORG_ID, false, false); + assertTrue(result.zkEmailInvites != address(0), "ZkEmailInvites deployed without edu hub"); + assertEq(result.educationHub, address(0), "EducationHub not deployed"); + + // Both module types are queryable from OrgRegistry + assertEq(orgRegistry.getOrgContract(ZK_ORG_ID, ModuleTypes.TASK_MANAGER_ID), result.taskManager); + assertEq(orgRegistry.getOrgContract(ZK_ORG_ID, ModuleTypes.PAYMENT_MANAGER_ID), result.paymentManager); + assertEq(orgRegistry.getOrgContract(ZK_ORG_ID, ModuleTypes.ZKEMAIL_INVITES_ID), result.zkEmailInvites); + } + + function testSetZkEmailInfrastructure_partialUpdatePreservesOtherField() public { + address oldDkim = address(0xD1); + address oldVerifier = address(0xCAFE); + + // Wire both initially + vm.prank(poaAdmin); + poaManager.adminCall( + address(deployer), + abi.encodeWithSignature("setZkEmailInfrastructure(address,address)", oldVerifier, oldDkim) + ); + + // Update only the verifier (pass address(0) for dkim → no-op for that field) + address newVerifier = address(mockVerifier); + vm.prank(poaAdmin); + poaManager.adminCall( + address(deployer), + abi.encodeWithSignature("setZkEmailInfrastructure(address,address)", newVerifier, address(0)) + ); + + bytes32 layoutSlot = keccak256("poa.orgdeployer.storage"); + address storedVerifier = + address(uint160(uint256(vm.load(address(deployer), bytes32(uint256(layoutSlot) + 10))))); + address storedDkim = address(uint160(uint256(vm.load(address(deployer), bytes32(uint256(layoutSlot) + 11))))); + assertEq(storedVerifier, newVerifier, "verifier was updated"); + assertEq(storedDkim, oldDkim, "dkim preserved (passed address(0) is a no-op)"); + } + + function testSetZkEmailInfrastructure_onlyOneAddressSet_doesNotEnableModule() public { + // Beacon present so the ONLY missing prerequisite under test is the second infra address. + _registerZkBeacon(); + // Set verifier only; dkim stays unset. Module should still be skipped because the + // ModulesFactory gate requires BOTH (+ accountRegistry). + vm.prank(poaAdmin); + poaManager.adminCall( + address(deployer), + abi.encodeWithSignature("setZkEmailInfrastructure(address,address)", address(mockVerifier), address(0)) + ); + + OrgDeployer.DeploymentResult memory result = _deployZkOrg(ZK_ORG_ID); + assertEq(result.zkEmailInvites, address(0), "Module should remain disabled with only one address"); + } + + /// @notice Regression test for the reported coupling bug: enabling ZK Email infra WITHOUT a + /// registered ZkEmailInvites beacon must NOT brick org deployment. Before the fix, + /// ModulesFactory called BeaconDeploymentLib.createBeacon -> getBeaconById, which + /// reverts TypeUnknown, failing the entire deployFullOrg. Now the gate probes + /// beaconRegistered() and degrades gracefully. + function testOrgDeploy_infraWiredButBeaconMissing_skipsGracefully() public { + // Wire infra but DELIBERATELY do not register the beacon. + _wireZkInfra(); + + // Must NOT revert — core deploy proceeds, ZK module is skipped. + OrgDeployer.DeploymentResult memory result = _deployZkOrg(ZK_ORG_ID); + + assertEq(result.zkEmailInvites, address(0), "module skipped when beacon missing"); + // Core modules unaffected. + assertTrue(result.executor != address(0), "executor deployed"); + assertTrue(result.taskManager != address(0), "task manager deployed"); + assertTrue(result.quickJoin != address(0), "quick join deployed"); + // OrgRegistry has no ZkEmailInvites entry. + vm.expectRevert(); // ContractUnknown() + orgRegistry.getOrgContract(ZK_ORG_ID, ModuleTypes.ZKEMAIL_INVITES_ID); + } + + /// @notice Once the beacon is registered (the missing step from the bug report), the SAME + /// infra wiring now activates the module — proving the gate self-heals. + function testOrgDeploy_beaconRegisteredAfterInfra_activatesModule() public { + _wireZkInfra(); + _registerZkBeacon(); // beacon registered AFTER infra wiring — order-independent + + OrgDeployer.DeploymentResult memory result = _deployZkOrg(ZK_ORG_ID); + assertTrue(result.zkEmailInvites != address(0), "module activates once beacon present"); + } + + /*──────────── Hat-minter authorization ────────────*/ + + function testZkEmailInvites_isAuthorizedHatMinterOnExecutor() public { + _registerZkBeacon(); + _wireZkInfra(); + OrgDeployer.DeploymentResult memory result = _deployZkOrg(ZK_ORG_ID); + + // Probe Executor's authorizedHatMinters mapping via storage. + // Executor layout: { allowedCaller, hats, authorizedHatMinters, pendingCaller, callerChangeTimestamp } + // authorizedHatMinters is the 3rd field (mapping). Mapping slot = baseSlot + 2. + bytes32 execLayoutSlot = keccak256("poa.executor.storage"); + bytes32 mappingBase = bytes32(uint256(execLayoutSlot) + 2); + bytes32 entrySlot = keccak256(abi.encode(result.zkEmailInvites, mappingBase)); + bool authorized = uint256(vm.load(result.executor, entrySlot)) != 0; + assertTrue(authorized, "ZkEmailInvites must be authorized hat minter"); + } + + /*──────────── End-to-end claim ────────────*/ + + function testEndToEndClaimByDomain_mintsHatViaExecutor() public { + _registerZkBeacon(); + _wireZkInfra(); + OrgDeployer.DeploymentResult memory result = _deployZkOrg(ZK_ORG_ID); + + ZkEmailInvites zk = ZkEmailInvites(result.zkEmailInvites); + + // Pre-register a domain rule via the executor (orgOwner holds the toggle hat by virtue of + // owning the top hat; in our tests setHatMinterAuthorization is gated on the executor's + // allowed caller, but setDomainRule is gated on _msgSender() == executor address). + // We prank as the executor contract address itself. + uint256 targetHat = orgRegistry.getRoleHat(ZK_ORG_ID, 0); // DEFAULT role hat + assertTrue(targetHat != 0, "default role hat exists"); + + uint256[] memory hats = new uint256[](1); + hats[0] = targetHat; + + vm.prank(result.executor); + zk.setDomainRule("anthropic.com", hats, 0); + + // Build a proof addressed to a fresh claimer + address claimer = address(0xC0FFEE); + EmailProof memory p = _buildProof(claimer, "anthropic.com", bytes32(uint256(0xBEEF))); + + // Claim + vm.prank(claimer); + zk.claimRoleByDomain(p, claimer); + + // Confirm the claimer wears the hat now + bool isWearer = IHats(SEPOLIA_HATS).isWearerOfHat(claimer, targetHat); + assertTrue(isWearer, "claimer wears the role hat"); + + // Nullifier was consumed + assertTrue(zk.isNullifierUsed(p.emailNullifier), "nullifier consumed"); + } + + /*──────────── Paymaster auto-whitelist ────────────*/ + + function testPaymasterRules_includeZkEmailSelectors_whenInfraWired() public { + _registerZkBeacon(); + _wireZkInfra(); + + // Deploy with autoWhitelistContracts = true and assert that all 4 ZkEmailInvites selectors + // are now allowed rules on PaymasterHub for our org. + OrgDeployer.DeploymentResult memory result = _deployZkOrgWithPaymaster(ZK_ORG_ID); + + bytes4 sel1 = + bytes4(keccak256("claimRoleByDomain((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address)")); + bytes4 sel2 = + bytes4(keccak256("claimRoleByEmail((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address)")); + bytes4 sel3 = bytes4( + keccak256( + "registerAndClaimByDomainWithPasskey((bytes32,bytes32,bytes32,uint256),string,uint256,uint256,(bytes,bytes,uint256,uint256,bytes32,bytes32),(string,bytes32,uint256,string,bytes32,bytes32,bool,bytes))" + ) + ); + bytes4 sel4 = bytes4( + keccak256( + "registerAndClaimByEmailWithPasskey((bytes32,bytes32,bytes32,uint256),string,uint256,uint256,(bytes,bytes,uint256,uint256,bytes32,bytes32),(string,bytes32,uint256,string,bytes32,bytes32,bool,bytes))" + ) + ); + + PaymasterHub.Rule memory r1 = paymasterHub.getRule(ZK_ORG_ID, result.zkEmailInvites, sel1); + PaymasterHub.Rule memory r2 = paymasterHub.getRule(ZK_ORG_ID, result.zkEmailInvites, sel2); + PaymasterHub.Rule memory r3 = paymasterHub.getRule(ZK_ORG_ID, result.zkEmailInvites, sel3); + PaymasterHub.Rule memory r4 = paymasterHub.getRule(ZK_ORG_ID, result.zkEmailInvites, sel4); + + assertTrue(r1.allowed, "claimRoleByDomain allowed"); + assertTrue(r2.allowed, "claimRoleByEmail allowed"); + assertTrue(r3.allowed, "registerAndClaimByDomainWithPasskey allowed"); + assertTrue(r4.allowed, "registerAndClaimByEmailWithPasskey allowed"); + + assertEq(uint256(r1.maxCallGasHint), 800_000, "bare claim gas hint"); + assertEq(uint256(r2.maxCallGasHint), 800_000, "bare claim gas hint"); + assertEq(uint256(r3.maxCallGasHint), 1_200_000, "combined claim gas hint"); + assertEq(uint256(r4.maxCallGasHint), 1_200_000, "combined claim gas hint"); + } + + function testPaymasterRules_unaffected_whenInfraNotWired() public { + // Without infra wiring: paymaster rules should still work; ZkEmailInvites selectors absent. + OrgDeployer.DeploymentResult memory result = _deployZkOrgWithPaymaster(ZK_ORG_ID); + assertEq(result.zkEmailInvites, address(0), "ZkEmailInvites not deployed"); + + // Even if we query for the selectors, they should be unset (allowed=false, cap=0). + bytes4 sel1 = + bytes4(keccak256("claimRoleByDomain((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address)")); + PaymasterHub.Rule memory r1 = paymasterHub.getRule(ZK_ORG_ID, address(0), sel1); + assertFalse(r1.allowed, "no rule for address(0)"); + assertEq(uint256(r1.maxCallGasHint), 0, "no gas hint"); + } + + /*──────────── Helpers ────────────*/ + + function _wireZkInfra() internal { + vm.prank(poaAdmin); + poaManager.adminCall( + address(deployer), + abi.encodeWithSignature( + "setZkEmailInfrastructure(address,address)", address(mockVerifier), address(mockDkim) + ) + ); + } + + function _deployZkOrg(bytes32 orgId) internal returns (OrgDeployer.DeploymentResult memory) { + return _deployZkOrgInner(orgId, false, true); + } + + function _deployZkOrgWithPaymaster(bytes32 orgId) internal returns (OrgDeployer.DeploymentResult memory) { + return _deployZkOrgInner(orgId, true, true); + } + + function _deployZkOrgInner(bytes32 orgId, bool autoWhitelist, bool enableEducation) + internal + returns (OrgDeployer.DeploymentResult memory) + { + vm.startPrank(orgOwner); + string[] memory names = new string[](2); + names[0] = "DEFAULT"; + names[1] = "EXECUTIVE"; + string[] memory images = new string[](2); + images[0] = "ipfs://default-role-image"; + images[1] = "ipfs://executive-role-image"; + bool[] memory voting = new bool[](2); + voting[0] = true; + voting[1] = true; + + IHybridVotingInit.ClassConfig[] memory classes = _buildLegacyClasses(50, 50, false, 4 ether); + + OrgDeployer.PaymasterConfig memory pmCfg; + if (autoWhitelist) { + pmCfg = OrgDeployer.PaymasterConfig({ + operatorRoleIndex: type(uint256).max, + autoWhitelistContracts: true, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + maxCallGas: 0, + maxVerificationGas: 0, + maxPreVerificationGas: 0, + defaultBudgetCapPerEpoch: 0, + defaultBudgetEpochLen: 0 + }); + } else { + pmCfg = _defaultPaymasterConfig(); + } + + OrgDeployer.DeploymentParams memory params = OrgDeployer.DeploymentParams({ + orgId: orgId, + orgName: "ZkEmail DAO", + metadataHash: bytes32(0), + registryAddr: accountRegProxy, + deployerAddress: orgOwner, + deployerUsername: "", + regDeadline: 0, + regNonce: 0, + regSignature: "", + autoUpgrade: true, + hybridThresholdPct: 50, + ddThresholdPct: 50, + hybridClasses: classes, + ddInitialTargets: new address[](0), + roles: _buildSimpleRoleConfigs(names, images, voting), + roleAssignments: _buildDefaultRoleAssignments(), + metadataAdminRoleIndex: type(uint256).max, + passkeyEnabled: false, + educationHubConfig: ModulesFactory.EducationHubConfig({enabled: enableEducation}), + bootstrap: _emptyBootstrap(), + paymasterConfig: pmCfg + }); + + OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); + vm.stopPrank(); + return result; + } + + function _buildProof(address claimer, string memory domain, bytes32 nullifier) + internal + view + returns (EmailProof memory p) + { + p.domainName = domain; + p.publicKeyHash = bytes32(uint256(0xAA)); + p.timestamp = block.timestamp; + p.maskedCommand = string.concat("Claim POP role for ", _addrToHex(claimer)); + p.emailNullifier = nullifier; + p.accountSalt = bytes32(uint256(uint160(claimer))); + p.isCodeExist = true; + p.proof = hex"deadbeef"; + } + + function _addrToHex(address a) internal pure returns (string memory out) { + bytes16 alphabet = "0123456789abcdef"; + bytes memory s = new bytes(42); + s[0] = "0"; + s[1] = "x"; + uint256 v = uint256(uint160(a)); + for (uint256 i = 0; i < 40; ++i) { + s[41 - i] = alphabet[v & 0xf]; + v >>= 4; + } + out = string(s); + } +}