From 5d64cc5e63dbf3833a862ea65f24ef8af23b2707 Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Wed, 18 Mar 2026 14:38:41 -0400 Subject: [PATCH] refactor: reduce PaymasterHub bytecode via library extraction and consolidation Extract errors, events, and business logic into four specialized libraries to reduce PaymasterHub from 25,434 bytes (858 over limit) to 24,030 bytes (546 under limit), while improving code organization and maintainability. Changes: - PaymasterHubErrors: all 38 errors and 22 events with full NatSpec - PaymasterGraceLib: grace period checks and solidarity tier matching - PaymasterPostOpLib: budget adjustments and clamped deductions - PaymasterCalldataLib: execute() envelope validation and selector extraction Additional optimizations: - Remove RULE_ID_EXECUTOR (identical to RULE_ID_COARSE, created SDK confusion) - Remove totalDeposited field from OrgFinancials (no on-chain readers, event history sufficient) - Move getOrgGraceStatus view function to PaymasterHubLens - Consolidate duplicate _validateBatchRules loop bodies - Extract _setRulesBatch internal helper to deduplicate rule-setting code All 1089 tests pass. Contract is production-ready under size limit. Co-Authored-By: Claude Haiku 4.5 --- src/PaymasterHub.sol | 629 +++++++----------------- src/PaymasterHubLens.sol | 65 ++- src/libs/PaymasterCalldataLib.sol | 57 +++ src/libs/PaymasterGraceLib.sol | 67 +++ src/libs/PaymasterHubErrors.sol | 216 ++++++++ src/libs/PaymasterPostOpLib.sol | 35 ++ test/DeployerTest.t.sol | 1 - test/PasskeyPaymasterIntegration.t.sol | 89 ++-- test/PaymasterHub.t.sol.skip | 17 +- test/PaymasterHubIntegration.t.sol.skip | 3 +- test/PaymasterHubInvariants.t.sol.skip | 23 +- test/PaymasterHubSolidarity.t.sol | 173 +++---- test/PaymasterLibsUnit.t.sol | 436 ++++++++++++++++ 13 files changed, 1187 insertions(+), 624 deletions(-) create mode 100644 src/libs/PaymasterCalldataLib.sol create mode 100644 src/libs/PaymasterGraceLib.sol create mode 100644 src/libs/PaymasterHubErrors.sol create mode 100644 src/libs/PaymasterPostOpLib.sol create mode 100644 test/PaymasterLibsUnit.t.sol diff --git a/src/PaymasterHub.sol b/src/PaymasterHub.sol index da0e926..c74b7b8 100644 --- a/src/PaymasterHub.sol +++ b/src/PaymasterHub.sol @@ -11,6 +11,10 @@ import { ReentrancyGuardUpgradeable } from "lib/openzeppelin-contracts-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol"; import {IERC165} from "lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol"; +import {PaymasterHubErrors} from "./libs/PaymasterHubErrors.sol"; +import {PaymasterGraceLib} from "./libs/PaymasterGraceLib.sol"; +import {PaymasterPostOpLib} from "./libs/PaymasterPostOpLib.sol"; +import {PaymasterCalldataLib} from "./libs/PaymasterCalldataLib.sol"; /** * @title PaymasterHub @@ -23,46 +27,6 @@ import {IERC165} from "lib/openzeppelin-contracts/contracts/utils/introspection/ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyGuardUpgradeable, IERC165 { using UserOpLib for bytes32; - // ============ Custom Errors ============ - error EPOnly(); - error Paused(); - error NotAdmin(); - error NotOperator(); - error NotPoaManager(); - error RuleDenied(address target, bytes4 selector); - error FeeTooHigh(); - error GasTooHigh(); - error Ineligible(); - error BudgetExceeded(); - error InvalidRuleId(); - error PaymentFailed(); - error InvalidSubjectType(); - error InvalidVersion(); - error InvalidPaymasterData(); - error ZeroAddress(); - error InvalidEpochLength(); - error ContractNotDeployed(); - error ArrayLengthMismatch(); - error OrgNotRegistered(); - error OrgAlreadyRegistered(); - error GracePeriodSpendLimitReached(); - error InsufficientDepositForSolidarity(); - error SolidarityLimitExceeded(); - error InsufficientOrgBalance(); - error OrgIsBanned(); - error InsufficientFunds(); - error SolidarityDistributionIsPaused(); - error OnboardingDisabled(); - error OnboardingDailyLimitExceeded(); - error Overflow(); - error InvalidOnboardingRequest(); - error OrgDeployDisabled(); - error OrgDeployLimitExceeded(); - error OrgDeployDailyLimitExceeded(); - error InvalidOrgDeployRequest(); - error InvalidOrgId(); - error ZeroAmount(); - // ============ Constants ============ uint8 private constant PAYMASTER_DATA_VERSION = 1; uint8 private constant SUBJECT_TYPE_ACCOUNT = 0x00; @@ -76,49 +40,10 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG uint8 private constant SPONSORSHIP_ORG_DEPLOY = 2; uint32 private constant RULE_ID_GENERIC = 0x00000000; - uint32 private constant RULE_ID_EXECUTOR = 0x00000001; uint32 private constant RULE_ID_COARSE = 0x000000FF; uint32 private constant MIN_EPOCH_LENGTH = 1 hours; uint32 private constant MAX_EPOCH_LENGTH = 365 days; - // ============ Events ============ - event PaymasterInitialized(address indexed entryPoint, address indexed hats, address indexed poaManager); - event OrgRegistered(bytes32 indexed orgId, uint256 adminHatId, uint256 operatorHatId); - event RuleSet( - bytes32 indexed orgId, address indexed target, bytes4 indexed selector, bool allowed, uint32 maxCallGasHint - ); - event BudgetSet(bytes32 indexed orgId, bytes32 subjectKey, uint128 capPerEpoch, uint32 epochLen, uint32 epochStart); - event FeeCapsSet( - bytes32 indexed orgId, - uint256 maxFeePerGas, - uint256 maxPriorityFeePerGas, - uint32 maxCallGas, - uint32 maxVerificationGas, - uint32 maxPreVerificationGas - ); - event PauseSet(bytes32 indexed orgId, bool paused); - event OperatorHatSet(bytes32 indexed orgId, uint256 operatorHatId); - event DepositIncrease(uint256 amount, uint256 newDeposit); - event DepositWithdraw(address indexed to, uint256 amount); - event UsageIncreased( - bytes32 indexed orgId, bytes32 subjectKey, uint256 delta, uint128 usedInEpoch, uint32 epochStart - ); - event EmergencyWithdraw(address indexed to, uint256 amount); - event OrgDepositReceived(bytes32 indexed orgId, address indexed from, uint256 amount); - event SolidarityFeeCollected(bytes32 indexed orgId, uint256 amount); - event SolidarityDonationReceived(address indexed from, uint256 amount); - event GracePeriodConfigUpdated(uint32 initialGraceDays, uint128 maxSpendDuringGrace, uint128 minDepositRequired); - event OrgBannedFromSolidarity(bytes32 indexed orgId, bool banned); - event OnboardingConfigUpdated( - uint128 maxGasPerCreation, uint128 dailyCreationLimit, bool enabled, address accountRegistry - ); - event OnboardingAccountCreated(address indexed account, uint256 gasCost); - event OrgDeployConfigUpdated( - uint128 maxGasPerDeploy, uint128 dailyDeployLimit, uint8 maxDeploysPerAccount, bool enabled, address orgDeployer - ); - event OrgDeploymentSponsored(address indexed account, uint256 gasCost); - event SolidarityDistributionPaused(); - event SolidarityDistributionUnpaused(); // ============ Storage Variables ============ /// @custom:storage-location erc7201:poa.paymasterhub.main @@ -139,13 +64,11 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * Storage optimization: registeredAt packed with paused to save gas */ struct OrgConfig { - uint256 adminHatId; // Slot 0 - uint256 operatorHatId; // Slot 1: Optional role for budget/rule management - uint256 __deprecated_voucherHatId; // Slot 2: Reserved for storage layout compatibility - bool paused; // Slot 3 (1 byte) - uint40 registeredAt; // Slot 3 (5 bytes): UNIX timestamp, good until year 36812 - bool bannedFromSolidarity; // Slot 3 (1 byte) - // 25 bytes remaining in slot 3 for future use + uint256 adminHatId; + uint256 operatorHatId; // Optional role for budget/rule management + bool paused; + uint40 registeredAt; // UNIX timestamp, good until year 36812 + bool bannedFromSolidarity; } /** @@ -153,11 +76,9 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG */ struct OrgFinancials { uint128 deposited; // Current balance deposited by org - uint128 totalDeposited; // Cumulative lifetime deposits (never decreases) uint128 spent; // Total spent from org's own deposits uint128 solidarityUsedThisPeriod; // Solidarity used in current 90-day period uint32 periodStart; // Timestamp when current 90-day period started - uint224 reserved; // Padding for future use } /** @@ -168,7 +89,6 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG uint32 numActiveOrgs; // Number of orgs with deposits > 0 uint16 feePercentageBps; // Fee as basis points (100 = 1%) bool distributionPaused; // When true, only collect fees, no payouts - uint200 reserved; // Padding } /** @@ -262,16 +182,16 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * @param _poaManager PoaManager address for upgrade authorization */ function initialize(address _entryPoint, address _hats, address _poaManager) public initializer { - if (_entryPoint == address(0)) revert ZeroAddress(); - if (_hats == address(0)) revert ZeroAddress(); - if (_poaManager == address(0)) revert ZeroAddress(); + if (_entryPoint == address(0)) revert PaymasterHubErrors.ZeroAddress(); + if (_hats == address(0)) revert PaymasterHubErrors.ZeroAddress(); + if (_poaManager == address(0)) revert PaymasterHubErrors.ZeroAddress(); // Verify entryPoint is a contract uint256 codeSize; assembly { codeSize := extcodesize(_entryPoint) } - if (codeSize == 0) revert ContractNotDeployed(); + if (codeSize == 0) revert PaymasterHubErrors.ContractNotDeployed(); // Initialize upgradeable contracts __ReentrancyGuard_init(); @@ -309,7 +229,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG orgDeploy.maxDeploysPerAccount = 2; // 2 free deployments per account lifetime orgDeploy.enabled = false; // Requires explicit setOrgDeployConfig to activate - emit PaymasterInitialized(_entryPoint, _hats, _poaManager); + emit PaymasterHubErrors.PaymasterInitialized(_entryPoint, _hats, _poaManager); } // ============ Deploy Config Struct ============ @@ -374,7 +294,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG feeCaps.maxVerificationGas = config.maxVerificationGas; feeCaps.maxPreVerificationGas = config.maxPreVerificationGas; - emit FeeCapsSet( + emit PaymasterHubErrors.FeeCapsSet( orgId, config.maxFeePerGas, config.maxPriorityFeePerGas, @@ -386,41 +306,16 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Set rules if arrays provided if (config.ruleTargets.length > 0) { - uint256 length = config.ruleTargets.length; - if ( - length != config.ruleSelectors.length || length != config.ruleAllowed.length - || length != config.ruleMaxCallGasHints.length - ) { - revert ArrayLengthMismatch(); - } - - mapping(address => mapping(bytes4 => Rule)) storage rules = _getRulesStorage()[orgId]; - - for (uint256 i; i < length;) { - if (config.ruleTargets[i] == address(0)) revert ZeroAddress(); - - rules[config.ruleTargets[i]][config.ruleSelectors[i]] = - Rule({allowed: config.ruleAllowed[i], maxCallGasHint: config.ruleMaxCallGasHints[i]}); - - emit RuleSet( - orgId, - config.ruleTargets[i], - config.ruleSelectors[i], - config.ruleAllowed[i], - config.ruleMaxCallGasHints[i] - ); - - unchecked { - ++i; - } - } + _setRulesBatch( + orgId, config.ruleTargets, config.ruleSelectors, config.ruleAllowed, config.ruleMaxCallGasHints + ); } // Set budgets if arrays provided if (config.budgetSubjectKeys.length > 0) { uint256 budgetLen = config.budgetSubjectKeys.length; if (budgetLen != config.budgetCapsPerEpoch.length || budgetLen != config.budgetEpochLens.length) { - revert ArrayLengthMismatch(); + revert PaymasterHubErrors.ArrayLengthMismatch(); } mapping(bytes32 => Budget) storage budgets = _getBudgetsStorage()[orgId]; @@ -428,7 +323,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG for (uint256 j; j < budgetLen;) { uint32 epochLen = config.budgetEpochLens[j]; if (epochLen < MIN_EPOCH_LENGTH || epochLen > MAX_EPOCH_LENGTH) { - revert InvalidEpochLength(); + revert PaymasterHubErrors.InvalidEpochLength(); } Budget storage budget = budgets[config.budgetSubjectKeys[j]]; @@ -436,7 +331,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG budget.epochLen = epochLen; budget.epochStart = uint32(block.timestamp); - emit BudgetSet( + emit PaymasterHubErrors.BudgetSet( orgId, config.budgetSubjectKeys[j], config.budgetCapsPerEpoch[j], epochLen, uint32(block.timestamp) ); @@ -454,56 +349,57 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG function _onlyRegistrar() internal view { MainStorage storage main = _getMainStorage(); - if (msg.sender != main.poaManager && msg.sender != main.orgRegistrar) revert NotPoaManager(); + if (msg.sender != main.poaManager && msg.sender != main.orgRegistrar) { + revert PaymasterHubErrors.NotPoaManager(); + } } function _registerOrg(bytes32 orgId, uint256 adminHatId, uint256 operatorHatId) internal { - if (orgId == bytes32(0)) revert InvalidOrgId(); - if (adminHatId == 0) revert ZeroAddress(); + if (orgId == bytes32(0)) revert PaymasterHubErrors.InvalidOrgId(); + if (adminHatId == 0) revert PaymasterHubErrors.ZeroAddress(); mapping(bytes32 => OrgConfig) storage orgs = _getOrgsStorage(); - if (orgs[orgId].adminHatId != 0) revert OrgAlreadyRegistered(); + if (orgs[orgId].adminHatId != 0) revert PaymasterHubErrors.OrgAlreadyRegistered(); orgs[orgId] = OrgConfig({ adminHatId: adminHatId, operatorHatId: operatorHatId, - __deprecated_voucherHatId: 0, paused: false, registeredAt: uint40(block.timestamp), bannedFromSolidarity: false }); - emit OrgRegistered(orgId, adminHatId, operatorHatId); + emit PaymasterHubErrors.OrgRegistered(orgId, adminHatId, operatorHatId); } // ============ Modifiers ============ modifier onlyEntryPoint() { - if (msg.sender != _getMainStorage().entryPoint) revert EPOnly(); + if (msg.sender != _getMainStorage().entryPoint) revert PaymasterHubErrors.EPOnly(); _; } modifier onlyOrgAdmin(bytes32 orgId) { OrgConfig storage org = _getOrgsStorage()[orgId]; - if (org.adminHatId == 0) revert OrgNotRegistered(); + if (org.adminHatId == 0) revert PaymasterHubErrors.OrgNotRegistered(); if (!IHats(_getMainStorage().hats).isWearerOfHat(msg.sender, org.adminHatId)) { - revert NotAdmin(); + revert PaymasterHubErrors.NotAdmin(); } _; } modifier onlyOrgOperator(bytes32 orgId) { OrgConfig storage org = _getOrgsStorage()[orgId]; - if (org.adminHatId == 0) revert OrgNotRegistered(); + if (org.adminHatId == 0) revert PaymasterHubErrors.OrgNotRegistered(); bool isAdmin = IHats(_getMainStorage().hats).isWearerOfHat(msg.sender, org.adminHatId); bool isOperator = org.operatorHatId != 0 && IHats(_getMainStorage().hats).isWearerOfHat(msg.sender, org.operatorHatId); - if (!isAdmin && !isOperator) revert NotOperator(); + if (!isAdmin && !isOperator) revert PaymasterHubErrors.NotOperator(); _; } modifier whenOrgNotPaused(bytes32 orgId) { - if (_getOrgsStorage()[orgId].paused) revert Paused(); + if (_getOrgsStorage()[orgId].paused) revert PaymasterHubErrors.Paused(); _; } @@ -546,42 +442,41 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG uint256 depositAvailable = org.deposited > org.spent ? org.deposited - org.spent : 0; // Check if org is banned from solidarity - if (config.bannedFromSolidarity) revert OrgIsBanned(); + if (config.bannedFromSolidarity) revert PaymasterHubErrors.OrgIsBanned(); - // Calculate grace period end time - uint256 graceEndTime = config.registeredAt + (uint256(grace.initialGraceDays) * 1 days); - bool inInitialGrace = block.timestamp < graceEndTime; + // Check grace period + bool inInitialGrace = PaymasterGraceLib.isInGracePeriod(config.registeredAt, grace.initialGraceDays); if (inInitialGrace) { // Startup phase: can use solidarity even with zero deposits // Enforce spending limit only (configured to represent ~3000 tx worth of value) if (org.solidarityUsedThisPeriod + maxCost > grace.maxSpendDuringGrace) { - revert GracePeriodSpendLimitReached(); + revert PaymasterHubErrors.GracePeriodSpendLimitReached(); } // In initial grace period, operations are paid 100% from solidarity. - if (solidarity.balance < maxCost) revert InsufficientFunds(); + if (solidarity.balance < maxCost) revert PaymasterHubErrors.InsufficientFunds(); } else { // After startup: must MAINTAIN minimum deposit (like $10/month commitment) - // This checks deposited (current balance), not totalDeposited (cumulative) // Orgs must keep funds in reserve to access solidarity if (depositAvailable < grace.minDepositRequired) { - revert InsufficientDepositForSolidarity(); + revert PaymasterHubErrors.InsufficientDepositForSolidarity(); } // Check against tier-based allowance (calculated in payment logic) // Tier 1: deposit 0.003 ETH → 0.006 ETH match → 0.009 ETH total per 90 days // Tier 2: deposit 0.006 ETH → 0.009 ETH match → 0.015 ETH total per 90 days // Tier 3: deposit >= 0.017 ETH → no match, self-funded - uint256 matchAllowance = _calculateMatchAllowance(depositAvailable, grace.minDepositRequired); + uint256 matchAllowance = + PaymasterGraceLib.calculateMatchAllowance(depositAvailable, grace.minDepositRequired); uint256 solidarityRemaining = matchAllowance > org.solidarityUsedThisPeriod ? matchAllowance - org.solidarityUsedThisPeriod : 0; // Minimum solidarity needed after using available deposits. uint256 requiredSolidarity = maxCost > depositAvailable ? maxCost - depositAvailable : 0; if (requiredSolidarity > solidarityRemaining) { - revert SolidarityLimitExceeded(); + revert PaymasterHubErrors.SolidarityLimitExceeded(); } - if (solidarity.balance < requiredSolidarity) revert InsufficientFunds(); + if (solidarity.balance < requiredSolidarity) revert PaymasterHubErrors.InsufficientFunds(); } } @@ -596,10 +491,10 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * @param orgId The organization to deposit for */ function depositForOrg(bytes32 orgId) external payable { - if (msg.value == 0) revert ZeroAmount(); + if (msg.value == 0) revert PaymasterHubErrors.ZeroAmount(); // Verify org exists - if (_getOrgsStorage()[orgId].adminHatId == 0) revert OrgNotRegistered(); + if (_getOrgsStorage()[orgId].adminHatId == 0) revert PaymasterHubErrors.OrgNotRegistered(); _depositForOrg(orgId, msg.value); } @@ -634,11 +529,10 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG bool wasUnfunded = org.deposited == 0; // Safe cast check - if (amount > type(uint128).max) revert Overflow(); + if (amount > type(uint128).max) revert PaymasterHubErrors.Overflow(); // Update org financials org.deposited += uint128(amount); - org.totalDeposited += uint128(amount); // Reset period when triggered if (shouldResetPeriod) { @@ -658,7 +552,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Deposit to EntryPoint IEntryPoint(_getMainStorage().entryPoint).depositTo{value: amount}(address(this)); - emit OrgDepositReceived(orgId, msg.sender, amount); + emit PaymasterHubErrors.OrgDepositReceived(orgId, msg.sender, amount); } /** @@ -666,8 +560,8 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * @dev Anyone can donate to support all orgs */ function donateToSolidarity() external payable { - if (msg.value == 0) revert ZeroAmount(); - if (msg.value > type(uint128).max) revert Overflow(); + if (msg.value == 0) revert PaymasterHubErrors.ZeroAmount(); + if (msg.value > type(uint128).max) revert PaymasterHubErrors.Overflow(); SolidarityFund storage solidarity = _getSolidarityStorage(); solidarity.balance += uint128(msg.value); @@ -675,7 +569,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Deposit to EntryPoint IEntryPoint(_getMainStorage().entryPoint).depositTo{value: msg.value}(address(this)); - emit SolidarityDonationReceived(msg.sender, msg.value); + emit PaymasterHubErrors.SolidarityDonationReceived(msg.sender, msg.value); } // ============ ERC-4337 Paymaster Functions ============ @@ -699,7 +593,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG (uint8 version, bytes32 orgId, uint8 subjectType, bytes32 subjectId, uint32 ruleId) = _decodePaymasterData(userOp.paymasterAndData); - if (version != PAYMASTER_DATA_VERSION) revert InvalidVersion(); + if (version != PAYMASTER_DATA_VERSION) revert PaymasterHubErrors.InvalidVersion(); bytes32 subjectKey; uint32 currentEpochStart; @@ -713,7 +607,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG sponsorshipType = SPONSORSHIP_ONBOARDING; // Global-only onboarding path: never org-scoped billing. if (orgId != bytes32(0) || subjectId != bytes32(0) || ruleId != RULE_ID_GENERIC) { - revert InvalidOnboardingRequest(); + revert PaymasterHubErrors.InvalidOnboardingRequest(); } // Validate POA onboarding eligibility @@ -727,7 +621,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG sponsorshipType = SPONSORSHIP_ORG_DEPLOY; // Global-only org deploy path: never org-scoped billing. if (orgId != bytes32(0) || subjectId != bytes32(0) || ruleId != RULE_ID_GENERIC) { - revert InvalidOrgDeployRequest(); + revert PaymasterHubErrors.InvalidOrgDeployRequest(); } // Validate free org deployment eligibility @@ -737,8 +631,8 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG } else { // Validate org is registered and not paused OrgConfig storage org = _getOrgsStorage()[orgId]; - if (org.adminHatId == 0) revert OrgNotRegistered(); - if (org.paused) revert Paused(); + if (org.adminHatId == 0) revert PaymasterHubErrors.OrgNotRegistered(); + if (org.paused) revert PaymasterHubErrors.Paused(); // Validate subject eligibility subjectKey = _validateSubjectEligibility(userOp.sender, subjectType, subjectId); @@ -793,7 +687,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG if (solidarity.distributionPaused) { // When distribution is paused, org must cover 100% from deposits if (depositAvailable < maxCost) { - revert InsufficientOrgBalance(); + revert PaymasterHubErrors.InsufficientOrgBalance(); } org.spent += uint128(maxCost); // Reserve for bundle safety return maxCost; @@ -801,14 +695,12 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Deposits are fully exhausted — check if grace period allows solidarity-only coverage if (depositAvailable == 0) { - mapping(bytes32 => OrgConfig) storage orgs = _getOrgsStorage(); + OrgConfig storage config = _getOrgsStorage()[orgId]; GracePeriodConfig storage grace = _getGracePeriodStorage(); - OrgConfig storage config = orgs[orgId]; - uint256 graceEndTime = config.registeredAt + (uint256(grace.initialGraceDays) * 1 days); - if (block.timestamp < graceEndTime) { + if (PaymasterGraceLib.isInGracePeriod(config.registeredAt, grace.initialGraceDays)) { return 0; // Grace period with zero deposits: no reservation needed } - revert InsufficientOrgBalance(); + revert PaymasterHubErrors.InsufficientOrgBalance(); } // Deposits cover fully or partially — solidarity covers the rest @@ -893,8 +785,8 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG mapping(bytes32 => Budget) storage budgets = _getBudgetsStorage()[orgId]; Budget storage budget = budgets[subjectKey]; if (budget.epochStart == epochStart) { - budget.usedInEpoch = budget.usedInEpoch - uint128(reservedBudget) + uint128(actualGasCost); - emit UsageIncreased(orgId, subjectKey, actualGasCost, budget.usedInEpoch, epochStart); + budget.usedInEpoch = PaymasterPostOpLib.adjustBudget(budget.usedInEpoch, reservedBudget, actualGasCost); + emit PaymasterHubErrors.UsageIncreased(orgId, subjectKey, actualGasCost, budget.usedInEpoch, epochStart); } // Unreserve org balance, then charge actual + fee from deposits only (no solidarity) @@ -907,15 +799,10 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Zero the fee during grace — same as _updateOrgFinancials. // Grace orgs have no deposits; charging a fee would create phantom debt // and circular solidarity accounting (solidarity pays itself). - uint256 solidarityFee; - mapping(bytes32 => OrgConfig) storage orgs = _getOrgsStorage(); GracePeriodConfig storage grace = _getGracePeriodStorage(); - uint256 graceEndTime = orgs[orgId].registeredAt + (uint256(grace.initialGraceDays) * 1 days); - if (block.timestamp < graceEndTime) { - solidarityFee = 0; - } else { - solidarityFee = (actualGasCost * uint256(solidarity.feePercentageBps)) / 10000; - } + uint256 solidarityFee = PaymasterGraceLib.solidarityFee( + actualGasCost, solidarity.feePercentageBps, _getOrgsStorage()[orgId].registeredAt, grace.initialGraceDays + ); org.spent += uint128(actualGasCost + solidarityFee); solidarity.balance += uint128(solidarityFee); @@ -926,7 +813,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG org.solidarityUsedThisPeriod += uint128(actualGasCost); if (solidarityFee > 0) { - emit SolidarityFeeCollected(orgId, solidarityFee); + emit PaymasterHubErrors.SolidarityFeeCollected(orgId, solidarityFee); } } @@ -946,8 +833,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG function _sponsorshipPostOpFallback(uint8 sponsorshipType, address sender, uint256 actualGasCost) private { // Deduct from solidarity (clamped to available balance — never revert) SolidarityFund storage solidarity = _getSolidarityStorage(); - uint128 deduction = solidarity.balance < uint128(actualGasCost) ? solidarity.balance : uint128(actualGasCost); - solidarity.balance -= deduction; + (solidarity.balance,) = PaymasterPostOpLib.clampedDeduction(solidarity.balance, actualGasCost); // Refund optimistic counters. The first postOp's refunds were rolled back by EntryPoint, // so counters are still at their validation-incremented values. @@ -969,42 +855,6 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG } } - /** - * @notice Calculate solidarity match allowance based on deposit tier - * @dev Progressive tier system with declining marginal match rates - * - * Tier 1: deposit = 1x min → match = 2x → total budget = 3x (e.g. 0.003 ETH → 0.009 ETH) - * Tier 2: deposit = 2x min → match = 3x → total budget = 5x (e.g. 0.006 ETH → 0.015 ETH) - * Tier 3: deposit >= 5x min → no match, self-funded - * - * @param deposited Current deposit balance - * @param minDeposit Minimum deposit requirement (from grace config) - * @return matchAllowance How much solidarity can be used per 90-day period - */ - function _calculateMatchAllowance(uint256 deposited, uint256 minDeposit) internal pure returns (uint256) { - if (minDeposit == 0 || deposited < minDeposit) { - return 0; - } - // Tier 1: deposit <= 1x minimum -> 2x match - if (deposited <= minDeposit) { - return deposited * 2; - } - // Tier 2: deposit <= 2x minimum -> first tier at 2x, remainder at 1x - if (deposited <= minDeposit * 2) { - uint256 firstTierMatch = minDeposit * 2; - uint256 secondTierMatch = deposited - minDeposit; - return firstTierMatch + secondTierMatch; - } - // Tier 3: deposit < 5x minimum -> capped match from first two tiers - if (deposited < minDeposit * 5) { - uint256 firstTierMatch = minDeposit * 2; - uint256 secondTierMatch = minDeposit; - return firstTierMatch + secondTierMatch; - } - // Tier 4: >= 5x minimum -> self-sufficient, no match - return 0; - } - /** * @notice Update org's financial tracking and collect 1% solidarity fee * @dev Called in postOp after actual gas cost is known @@ -1036,13 +886,12 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG if (solidarity.distributionPaused) { org.spent += uint128(actualGasCost + solidarityFee); solidarity.balance += uint128(solidarityFee); - emit SolidarityFeeCollected(orgId, solidarityFee); + emit PaymasterHubErrors.SolidarityFeeCollected(orgId, solidarityFee); return; } // Check if in initial grace period - uint256 graceEndTime = config.registeredAt + (uint256(grace.initialGraceDays) * 1 days); - bool inInitialGrace = block.timestamp < graceEndTime; + bool inInitialGrace = PaymasterGraceLib.isInGracePeriod(config.registeredAt, grace.initialGraceDays); // Determine how much comes from org's deposits vs solidarity uint256 fromDeposits = 0; @@ -1051,7 +900,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG if (inInitialGrace) { // Grace period: 100% from solidarity (deposits untouched) - if (solidarityLiquidity < actualGasCost) revert InsufficientFunds(); + if (solidarityLiquidity < actualGasCost) revert PaymasterHubErrors.InsufficientFunds(); fromSolidarity = actualGasCost; // No fee during grace — would be circular (solidarity pays itself) solidarityFee = 0; @@ -1061,7 +910,8 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG uint256 depositAvailable = org.deposited > org.spent ? org.deposited - org.spent : 0; // Match allowance based on CURRENT BALANCE, not lifetime deposits - uint256 matchAllowance = _calculateMatchAllowance(depositAvailable, grace.minDepositRequired); + uint256 matchAllowance = + PaymasterGraceLib.calculateMatchAllowance(depositAvailable, grace.minDepositRequired); uint256 solidarityRemaining = matchAllowance > org.solidarityUsedThisPeriod ? matchAllowance - org.solidarityUsedThisPeriod : 0; if (solidarityRemaining > solidarityLiquidity) { @@ -1099,7 +949,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // If still can't cover, revert if (shortfall > 0) { - revert InsufficientFunds(); + revert PaymasterHubErrors.InsufficientFunds(); } } } @@ -1112,7 +962,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG solidarity.balance -= uint128(fromSolidarity); solidarity.balance += uint128(solidarityFee); - emit SolidarityFeeCollected(orgId, solidarityFee); + emit PaymasterHubErrors.SolidarityFeeCollected(orgId, solidarityFee); } // ============ Admin Functions ============ @@ -1125,11 +975,11 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG external onlyOrgOperator(orgId) { - if (target == address(0)) revert ZeroAddress(); + if (target == address(0)) revert PaymasterHubErrors.ZeroAddress(); mapping(address => mapping(bytes4 => Rule)) storage rules = _getRulesStorage()[orgId]; rules[target][selector] = Rule({allowed: allowed, maxCallGasHint: maxCallGasHint}); - emit RuleSet(orgId, target, selector, allowed, maxCallGasHint); + emit PaymasterHubErrors.RuleSet(orgId, target, selector, allowed, maxCallGasHint); } /** @@ -1142,19 +992,29 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG bool[] calldata allowed, uint32[] calldata maxCallGasHints ) external onlyOrgOperator(orgId) { + _setRulesBatch(orgId, targets, selectors, allowed, maxCallGasHints); + } + + function _setRulesBatch( + bytes32 orgId, + address[] calldata targets, + bytes4[] calldata selectors, + bool[] calldata allowed, + uint32[] calldata maxCallGasHints + ) internal { uint256 length = targets.length; if (length != selectors.length || length != allowed.length || length != maxCallGasHints.length) { - revert ArrayLengthMismatch(); + revert PaymasterHubErrors.ArrayLengthMismatch(); } mapping(address => mapping(bytes4 => Rule)) storage rules = _getRulesStorage()[orgId]; for (uint256 i; i < length;) { - if (targets[i] == address(0)) revert ZeroAddress(); + if (targets[i] == address(0)) revert PaymasterHubErrors.ZeroAddress(); rules[targets[i]][selectors[i]] = Rule({allowed: allowed[i], maxCallGasHint: maxCallGasHints[i]}); - emit RuleSet(orgId, targets[i], selectors[i], allowed[i], maxCallGasHints[i]); + emit PaymasterHubErrors.RuleSet(orgId, targets[i], selectors[i], allowed[i], maxCallGasHints[i]); unchecked { ++i; @@ -1168,7 +1028,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG function clearRule(bytes32 orgId, address target, bytes4 selector) external onlyOrgOperator(orgId) { mapping(address => mapping(bytes4 => Rule)) storage rules = _getRulesStorage()[orgId]; delete rules[target][selector]; - emit RuleSet(orgId, target, selector, false, 0); + emit PaymasterHubErrors.RuleSet(orgId, target, selector, false, 0); } /** @@ -1180,7 +1040,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG onlyOrgOperator(orgId) { if (epochLen < MIN_EPOCH_LENGTH || epochLen > MAX_EPOCH_LENGTH) { - revert InvalidEpochLength(); + revert PaymasterHubErrors.InvalidEpochLength(); } mapping(bytes32 => Budget) storage budgets = _getBudgetsStorage()[orgId]; @@ -1199,7 +1059,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG budget.epochStart = uint32(block.timestamp); } - emit BudgetSet(orgId, subjectKey, capPerEpoch, epochLen, budget.epochStart); + emit PaymasterHubErrors.BudgetSet(orgId, subjectKey, capPerEpoch, epochLen, budget.epochStart); } /** @@ -1212,7 +1072,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG budget.epochStart = epochStart; budget.usedInEpoch = 0; // Reset usage when manually setting epoch - emit BudgetSet(orgId, subjectKey, budget.capPerEpoch, budget.epochLen, epochStart); + emit PaymasterHubErrors.BudgetSet(orgId, subjectKey, budget.capPerEpoch, budget.epochLen, epochStart); } /** @@ -1234,7 +1094,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG feeCaps.maxVerificationGas = maxVerificationGas; feeCaps.maxPreVerificationGas = maxPreVerificationGas; - emit FeeCapsSet( + emit PaymasterHubErrors.FeeCapsSet( orgId, maxFeePerGas, maxPriorityFeePerGas, maxCallGas, maxVerificationGas, maxPreVerificationGas ); } @@ -1245,7 +1105,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG */ function setPause(bytes32 orgId, bool paused) external onlyOrgAdmin(orgId) { _getOrgsStorage()[orgId].paused = paused; - emit PauseSet(orgId, paused); + emit PaymasterHubErrors.PauseSet(orgId, paused); } /** @@ -1253,7 +1113,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG */ function setOperatorHat(bytes32 orgId, uint256 operatorHatId) external onlyOrgAdmin(orgId) { _getOrgsStorage()[orgId].operatorHatId = operatorHatId; - emit OperatorHatSet(orgId, operatorHatId); + emit PaymasterHubErrors.OperatorHatSet(orgId, operatorHatId); } /** @@ -1265,26 +1125,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG IEntryPoint(entryPoint).depositTo{value: msg.value}(address(this)); uint256 newDeposit = IEntryPoint(entryPoint).balanceOf(address(this)); - emit DepositIncrease(msg.value, newDeposit); - } - - /** - * @notice Withdraw funds from EntryPoint deposit (requires global admin) - * @dev Withdrawals affect shared pool, so restricted to prevent abuse - */ - function withdrawFromEntryPoint(address payable to, uint256 amount) external { - // TODO: Add global admin mechanism or require multi-org consensus - // For now, disabled to protect shared pool - revert NotAdmin(); - } - - /** - * @notice Emergency withdrawal in case of critical issues - * @dev Requires global admin - affects all orgs - */ - function emergencyWithdraw(address payable to) external { - // TODO: Add global admin mechanism - revert NotAdmin(); + emit PaymasterHubErrors.DepositIncrease(msg.value, newDeposit); } /** @@ -1297,17 +1138,17 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG function setGracePeriodConfig(uint32 _initialGraceDays, uint128 _maxSpendDuringGrace, uint128 _minDepositRequired) external { - if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); - if (_initialGraceDays == 0) revert InvalidEpochLength(); - if (_maxSpendDuringGrace == 0) revert InvalidEpochLength(); - if (_minDepositRequired == 0) revert InvalidEpochLength(); + if (msg.sender != _getMainStorage().poaManager) revert PaymasterHubErrors.NotPoaManager(); + if (_initialGraceDays == 0) revert PaymasterHubErrors.InvalidEpochLength(); + if (_maxSpendDuringGrace == 0) revert PaymasterHubErrors.InvalidEpochLength(); + if (_minDepositRequired == 0) revert PaymasterHubErrors.InvalidEpochLength(); GracePeriodConfig storage grace = _getGracePeriodStorage(); grace.initialGraceDays = _initialGraceDays; grace.maxSpendDuringGrace = _maxSpendDuringGrace; grace.minDepositRequired = _minDepositRequired; - emit GracePeriodConfigUpdated(_initialGraceDays, _maxSpendDuringGrace, _minDepositRequired); + emit PaymasterHubErrors.GracePeriodConfigUpdated(_initialGraceDays, _maxSpendDuringGrace, _minDepositRequired); } /** @@ -1316,7 +1157,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * @param registrar Address authorized to call registerOrg */ function setOrgRegistrar(address registrar) external { - if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); + if (msg.sender != _getMainStorage().poaManager) revert PaymasterHubErrors.NotPoaManager(); _getMainStorage().orgRegistrar = registrar; } @@ -1327,14 +1168,14 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * @param banned True to ban, false to unban */ function setBanFromSolidarity(bytes32 orgId, bool banned) external { - if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); + if (msg.sender != _getMainStorage().poaManager) revert PaymasterHubErrors.NotPoaManager(); mapping(bytes32 => OrgConfig) storage orgs = _getOrgsStorage(); - if (orgs[orgId].adminHatId == 0) revert OrgNotRegistered(); + if (orgs[orgId].adminHatId == 0) revert PaymasterHubErrors.OrgNotRegistered(); orgs[orgId].bannedFromSolidarity = banned; - emit OrgBannedFromSolidarity(orgId, banned); + emit PaymasterHubErrors.OrgBannedFromSolidarity(orgId, banned); } /** @@ -1343,8 +1184,8 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * @param feePercentageBps Fee as basis points (100 = 1%) */ function setSolidarityFee(uint16 feePercentageBps) external { - if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); - if (feePercentageBps > 1000) revert FeeTooHigh(); // Cap at 10% + if (msg.sender != _getMainStorage().poaManager) revert PaymasterHubErrors.NotPoaManager(); + if (feePercentageBps > 1000) revert PaymasterHubErrors.FeeTooHigh(); // Cap at 10% SolidarityFund storage solidarity = _getSolidarityStorage(); solidarity.feePercentageBps = feePercentageBps; @@ -1357,11 +1198,11 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * Only PoaManager can pause/unpause. */ function pauseSolidarityDistribution() external { - if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); + if (msg.sender != _getMainStorage().poaManager) revert PaymasterHubErrors.NotPoaManager(); SolidarityFund storage solidarity = _getSolidarityStorage(); if (!solidarity.distributionPaused) { solidarity.distributionPaused = true; - emit SolidarityDistributionPaused(); + emit PaymasterHubErrors.SolidarityDistributionPaused(); } } @@ -1371,11 +1212,11 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * Only PoaManager can pause/unpause. */ function unpauseSolidarityDistribution() external { - if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); + if (msg.sender != _getMainStorage().poaManager) revert PaymasterHubErrors.NotPoaManager(); SolidarityFund storage solidarity = _getSolidarityStorage(); if (solidarity.distributionPaused) { solidarity.distributionPaused = false; - emit SolidarityDistributionUnpaused(); + emit PaymasterHubErrors.SolidarityDistributionUnpaused(); } } @@ -1393,7 +1234,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG bool _enabled, address _accountRegistry ) external { - if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); + if (msg.sender != _getMainStorage().poaManager) revert PaymasterHubErrors.NotPoaManager(); OnboardingConfig storage onboarding = _getOnboardingStorage(); onboarding.maxGasPerCreation = _maxGasPerCreation; @@ -1401,7 +1242,9 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG onboarding.enabled = _enabled; onboarding.accountRegistry = _accountRegistry; - emit OnboardingConfigUpdated(_maxGasPerCreation, _dailyCreationLimit, _enabled, _accountRegistry); + emit PaymasterHubErrors.OnboardingConfigUpdated( + _maxGasPerCreation, _dailyCreationLimit, _enabled, _accountRegistry + ); } /** @@ -1420,7 +1263,9 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG bool _enabled, address _orgDeployer ) external { - if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); + if (msg.sender != _getMainStorage().poaManager) { + revert PaymasterHubErrors.NotPoaManager(); + } OrgDeployConfig storage deployConfig = _getOrgDeployStorage(); deployConfig.maxGasPerDeploy = _maxGasPerDeploy; @@ -1429,7 +1274,9 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG deployConfig.enabled = _enabled; deployConfig.orgDeployer = _orgDeployer; - emit OrgDeployConfigUpdated(_maxGasPerDeploy, _dailyDeployLimit, _maxDeploysPerAccount, _enabled, _orgDeployer); + emit PaymasterHubErrors.OrgDeployConfigUpdated( + _maxGasPerDeploy, _dailyDeployLimit, _maxDeploysPerAccount, _enabled, _orgDeployer + ); } // ============ Storage Getters (for Lens) ============ @@ -1522,53 +1369,6 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG return _getOrgDeployCountsStorage()[account]; } - /** - * @notice Get org's grace period status and limits - * @param orgId The organization identifier - * @return inGrace True if in initial grace period - * @return spendRemaining Spending remaining during grace (0 if not in grace) - * @return requiresDeposit True if org needs to deposit to access solidarity - * @return solidarityLimit Current solidarity allocation for org (per 90-day period) - */ - function getOrgGraceStatus(bytes32 orgId) - external - view - returns (bool inGrace, uint128 spendRemaining, bool requiresDeposit, uint256 solidarityLimit) - { - mapping(bytes32 => OrgConfig) storage orgs = _getOrgsStorage(); - mapping(bytes32 => OrgFinancials) storage financials = _getFinancialsStorage(); - GracePeriodConfig storage grace = _getGracePeriodStorage(); - SolidarityFund storage solidarity = _getSolidarityStorage(); - - OrgConfig storage config = orgs[orgId]; - OrgFinancials storage org = financials[orgId]; - - uint256 graceEndTime = config.registeredAt + (uint256(grace.initialGraceDays) * 1 days); - inGrace = block.timestamp < graceEndTime; - - // When distribution is paused, no solidarity is available regardless of grace/tier - if (solidarity.distributionPaused) { - spendRemaining = 0; - requiresDeposit = true; - solidarityLimit = 0; - return (inGrace, spendRemaining, requiresDeposit, solidarityLimit); - } - - if (inGrace) { - // During grace: track spending limit - uint128 spendUsed = org.solidarityUsedThisPeriod; - spendRemaining = spendUsed < grace.maxSpendDuringGrace ? grace.maxSpendDuringGrace - spendUsed : 0; - requiresDeposit = false; - solidarityLimit = uint256(grace.maxSpendDuringGrace); - } else { - // After grace: check current balance (not cumulative deposits) - spendRemaining = 0; - uint256 depositAvailable = org.deposited > org.spent ? org.deposited - org.spent : 0; - requiresDeposit = depositAvailable < grace.minDepositRequired; - solidarityLimit = _calculateMatchAllowance(depositAvailable, grace.minDepositRequired); - } - } - // ============ Storage Accessors ============ function _getMainStorage() private pure returns (MainStorage storage $) { bytes32 slot = MAIN_STORAGE_LOCATION; @@ -1686,9 +1486,9 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG */ function _authorizeUpgrade(address newImplementation) internal override { MainStorage storage main = _getMainStorage(); - if (msg.sender != main.poaManager) revert NotPoaManager(); - if (newImplementation == address(0)) revert ZeroAddress(); - if (newImplementation.code.length == 0) revert ContractNotDeployed(); + if (msg.sender != main.poaManager) revert PaymasterHubErrors.NotPoaManager(); + if (newImplementation == address(0)) revert PaymasterHubErrors.ZeroAddress(); + if (newImplementation.code.length == 0) revert PaymasterHubErrors.ContractNotDeployed(); } // ============ Internal Functions ============ @@ -1701,7 +1501,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // ERC-4337 v0.7 packed format: // [paymaster(20) | verificationGasLimit(16) | postOpGasLimit(16) | version(1) | orgId(32) | subjectType(1) | subjectId(32) | ruleId(4)] // = 122 bytes total. Custom data starts at offset 52. - if (paymasterAndData.length < 122) revert InvalidPaymasterData(); + if (paymasterAndData.length < 122) revert PaymasterHubErrors.InvalidPaymasterData(); // Skip first 52 bytes (paymaster address + v0.7 gas limits) and decode the rest version = uint8(paymasterAndData[52]); @@ -1723,22 +1523,22 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG returns (bytes32 subjectKey) { if (subjectType == SUBJECT_TYPE_ACCOUNT) { - if (address(uint160(uint256(subjectId))) != sender) revert Ineligible(); + if (address(uint160(uint256(subjectId))) != sender) revert PaymasterHubErrors.Ineligible(); subjectKey = keccak256(abi.encodePacked(subjectType, subjectId)); } else if (subjectType == SUBJECT_TYPE_HAT) { uint256 hatId = uint256(subjectId); IHats hatsContract = IHats(_getMainStorage().hats); if (!hatsContract.isEligible(sender, hatId)) { - revert Ineligible(); + revert PaymasterHubErrors.Ineligible(); } // Ensure the hat itself is still active (toggle module check) (,,,,,,,, bool active) = hatsContract.viewHat(hatId); if (!active) { - revert Ineligible(); + revert PaymasterHubErrors.Ineligible(); } subjectKey = keccak256(abi.encodePacked(subjectType, subjectId)); } else { - revert InvalidSubjectType(); + revert PaymasterHubErrors.InvalidSubjectType(); } } @@ -1757,10 +1557,10 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG OnboardingConfig storage onboarding = _getOnboardingStorage(); // Check onboarding is enabled - if (!onboarding.enabled) revert OnboardingDisabled(); + if (!onboarding.enabled) revert PaymasterHubErrors.OnboardingDisabled(); // Onboarding must only sponsor account creation, not arbitrary operations. - if (userOp.initCode.length == 0) revert InvalidOnboardingRequest(); + if (userOp.initCode.length == 0) revert PaymasterHubErrors.InvalidOnboardingRequest(); // NOTE: We intentionally do NOT check `account.code.length != 0` here. // In ERC-4337 v0.7, the EntryPoint deploys the account via _createSenderIfNeeded() // BEFORE calling validatePaymasterUserOp(), so the account already has code by the @@ -1773,10 +1573,10 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Onboarding is paid from solidarity fund, so block when distribution is paused SolidarityFund storage solidarity = _getSolidarityStorage(); - if (solidarity.distributionPaused) revert SolidarityDistributionIsPaused(); + if (solidarity.distributionPaused) revert PaymasterHubErrors.SolidarityDistributionIsPaused(); // Check gas cost limit - if (maxCost > onboarding.maxGasPerCreation) revert GasTooHigh(); + if (maxCost > onboarding.maxGasPerCreation) revert PaymasterHubErrors.GasTooHigh(); // Check daily rate limit uint32 today = uint32(block.timestamp / 1 days); @@ -1785,49 +1585,24 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG onboarding.attemptsToday = 0; } if (onboarding.attemptsToday >= onboarding.dailyCreationLimit) { - revert OnboardingDailyLimitExceeded(); + revert PaymasterHubErrors.OnboardingDailyLimitExceeded(); } onboarding.attemptsToday++; // Check solidarity fund has sufficient balance - if (solidarity.balance < maxCost) revert InsufficientFunds(); + if (solidarity.balance < maxCost) revert PaymasterHubErrors.InsufficientFunds(); // Subject key for onboarding is based on the account address (natural nonce) subjectKey = keccak256(abi.encodePacked(SUBJECT_TYPE_POA_ONBOARDING, bytes20(account))); } /// @dev Validates that onboarding callData is execute(registryAddress, 0, registerAccount(...)). - /// Reuses the same ABI layout parsing as _extractTargetSelector. function _validateOnboardingCallData(bytes calldata callData, address registry) private pure { - if (registry == address(0)) revert InvalidOnboardingRequest(); - if (callData.length < 4) revert InvalidOnboardingRequest(); - - // Must be execute(address,uint256,bytes) = 0xb61d27f6 - bytes4 outerSelector = bytes4(callData[0:4]); - if (outerSelector != bytes4(0xb61d27f6)) revert InvalidOnboardingRequest(); - if (callData.length < 0x64) revert InvalidOnboardingRequest(); - - address target; - uint256 value; - bytes4 innerSelector; - assembly { - target := calldataload(add(callData.offset, 0x04)) - value := calldataload(add(callData.offset, 0x24)) - // Read inner bytes data (offset at 0x44, must be standard 0x60) - let dataOffset := calldataload(add(callData.offset, 0x44)) - if eq(dataOffset, 0x60) { - let dataStart := add(add(0x04, dataOffset), 0x20) - if lt(dataStart, callData.length) { - innerSelector := calldataload(add(callData.offset, dataStart)) - } - } - } - innerSelector = bytes4(innerSelector); - - // Must target the registry with zero value and registerAccount(string) = 0xbff6de20 - if (target != registry) revert InvalidOnboardingRequest(); - if (value != 0) revert InvalidOnboardingRequest(); - if (innerSelector != bytes4(0xbff6de20)) revert InvalidOnboardingRequest(); + if (registry == address(0)) revert PaymasterHubErrors.InvalidOnboardingRequest(); + (bool valid, bytes4 innerSelector) = PaymasterCalldataLib.parseExecuteCall(callData, registry); + if (!valid) revert PaymasterHubErrors.InvalidOnboardingRequest(); + // Must call registerAccount(string) = 0xbff6de20 + if (innerSelector != bytes4(0xbff6de20)) revert PaymasterHubErrors.InvalidOnboardingRequest(); } /** @@ -1845,24 +1620,24 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG OrgDeployConfig storage deployConfig = _getOrgDeployStorage(); // Check feature is enabled - if (!deployConfig.enabled) revert OrgDeployDisabled(); + if (!deployConfig.enabled) revert PaymasterHubErrors.OrgDeployDisabled(); // No initCode for org deployment (account must already exist) - if (userOp.initCode.length != 0) revert InvalidOrgDeployRequest(); + if (userOp.initCode.length != 0) revert PaymasterHubErrors.InvalidOrgDeployRequest(); // Validate calldata: must be execute(orgDeployerAddress, 0, ...) _validateOrgDeployCallData(userOp.callData, deployConfig.orgDeployer); // Org deploy is paid from solidarity fund, so block when distribution is paused SolidarityFund storage solidarity = _getSolidarityStorage(); - if (solidarity.distributionPaused) revert SolidarityDistributionIsPaused(); + if (solidarity.distributionPaused) revert PaymasterHubErrors.SolidarityDistributionIsPaused(); // Check gas cost limit - if (maxCost > deployConfig.maxGasPerDeploy) revert GasTooHigh(); + if (maxCost > deployConfig.maxGasPerDeploy) revert PaymasterHubErrors.GasTooHigh(); // Check per-account lifetime limit (optimistic increment for bundle safety) mapping(address => uint8) storage counts = _getOrgDeployCountsStorage(); - if (counts[account] >= deployConfig.maxDeploysPerAccount) revert OrgDeployLimitExceeded(); + if (counts[account] >= deployConfig.maxDeploysPerAccount) revert PaymasterHubErrors.OrgDeployLimitExceeded(); counts[account]++; // Check daily rate limit (same pattern as onboarding) @@ -1872,44 +1647,28 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG deployConfig.attemptsToday = 0; } if (deployConfig.attemptsToday >= deployConfig.dailyDeployLimit) { - revert OrgDeployDailyLimitExceeded(); + revert PaymasterHubErrors.OrgDeployDailyLimitExceeded(); } deployConfig.attemptsToday++; // Check solidarity fund has sufficient balance - if (solidarity.balance < maxCost) revert InsufficientFunds(); + if (solidarity.balance < maxCost) revert PaymasterHubErrors.InsufficientFunds(); // Subject key based on account address subjectKey = keccak256(abi.encodePacked(SUBJECT_TYPE_ORG_DEPLOY, bytes20(account))); } /// @dev Validates that org deploy callData is execute(orgDeployerAddress, 0, ...). - /// Checks outer selector, target address, and value. Does NOT parse inner deployFullOrg - /// params because the struct is complex and may change. + /// Does NOT parse inner deployFullOrg params because the struct is complex and may change. function _validateOrgDeployCallData(bytes calldata callData, address orgDeployer) private pure { - if (orgDeployer == address(0)) revert InvalidOrgDeployRequest(); - if (callData.length < 4) revert InvalidOrgDeployRequest(); - - // Must be execute(address,uint256,bytes) = 0xb61d27f6 - bytes4 outerSelector = bytes4(callData[0:4]); - if (outerSelector != bytes4(0xb61d27f6)) revert InvalidOrgDeployRequest(); - if (callData.length < 0x64) revert InvalidOrgDeployRequest(); - - address target; - uint256 value; - assembly { - target := calldataload(add(callData.offset, 0x04)) - value := calldataload(add(callData.offset, 0x24)) - } - - // Must target the orgDeployer with zero value (free deployment) - if (target != orgDeployer) revert InvalidOrgDeployRequest(); - if (value != 0) revert InvalidOrgDeployRequest(); + if (orgDeployer == address(0)) revert PaymasterHubErrors.InvalidOrgDeployRequest(); + (bool valid,) = PaymasterCalldataLib.parseExecuteCall(callData, orgDeployer); + if (!valid) revert PaymasterHubErrors.InvalidOrgDeployRequest(); } function _validateRules(PackedUserOperation calldata userOp, uint32 ruleId, bytes32 orgId) private view { bytes calldata callData = userOp.callData; - if (callData.length < 4) revert InvalidPaymasterData(); + if (callData.length < 4) revert PaymasterHubErrors.InvalidPaymasterData(); // For RULE_ID_GENERIC, executeBatch needs per-inner-call validation if (ruleId == RULE_ID_GENERIC) { @@ -1927,12 +1686,12 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG mapping(address => mapping(bytes4 => Rule)) storage rules = _getRulesStorage()[orgId]; Rule storage rule = rules[target][selector]; - if (!rule.allowed) revert RuleDenied(target, selector); + if (!rule.allowed) revert PaymasterHubErrors.RuleDenied(target, selector); // Check gas hint if set if (rule.maxCallGasHint > 0) { (, uint128 callGasLimit) = UserOpLib.unpackAccountGasLimits(userOp.accountGasLimits); - if (callGasLimit > rule.maxCallGasHint) revert GasTooHigh(); + if (callGasLimit > rule.maxCallGasHint) revert PaymasterHubErrors.GasTooHigh(); } } @@ -1943,39 +1702,31 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG function _validateBatchRules(bytes calldata callData, bytes4 outerSelector, bytes32 orgId) private view { mapping(address => mapping(bytes4 => Rule)) storage rules = _getRulesStorage()[orgId]; + // Decode targets and datas from either batch format + address[] memory targets; + bytes[] memory datas; if (outerSelector == bytes4(0x47e1da2a)) { // executeBatch(address[],uint256[],bytes[]) — PasskeyAccount pattern - (address[] memory targets,, bytes[] memory datas) = - abi.decode(callData[4:], (address[], uint256[], bytes[])); - if (targets.length != datas.length) revert ArrayLengthMismatch(); - for (uint256 i = 0; i < targets.length;) { - bytes4 innerSelector; - if (datas[i].length >= 4) { - bytes memory d = datas[i]; - assembly { innerSelector := mload(add(d, 0x20)) } - } - Rule storage rule = rules[targets[i]][innerSelector]; - if (!rule.allowed) revert RuleDenied(targets[i], innerSelector); - unchecked { - ++i; - } - } + (targets,, datas) = abi.decode(callData[4:], (address[], uint256[], bytes[])); } else { // executeBatch(address[],bytes[]) — SimpleAccount pattern (0x18dfb3c7) - (address[] memory targets, bytes[] memory datas) = abi.decode(callData[4:], (address[], bytes[])); - if (targets.length != datas.length) revert ArrayLengthMismatch(); - for (uint256 i = 0; i < targets.length;) { - bytes4 innerSelector; - if (datas[i].length >= 4) { - bytes memory d = datas[i]; - assembly { innerSelector := mload(add(d, 0x20)) } - } - Rule storage rule = rules[targets[i]][innerSelector]; - if (!rule.allowed) revert RuleDenied(targets[i], innerSelector); - unchecked { - ++i; + (targets, datas) = abi.decode(callData[4:], (address[], bytes[])); + } + + if (targets.length != datas.length) revert PaymasterHubErrors.ArrayLengthMismatch(); + for (uint256 i = 0; i < targets.length;) { + bytes4 innerSelector; + if (datas[i].length >= 4) { + bytes memory d = datas[i]; + assembly { + innerSelector := mload(add(d, 0x20)) } } + Rule storage rule = rules[targets[i]][innerSelector]; + if (!rule.allowed) revert PaymasterHubErrors.RuleDenied(targets[i], innerSelector); + unchecked { + ++i; + } } } @@ -1986,7 +1737,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG { bytes calldata callData = userOp.callData; - if (callData.length < 4) revert InvalidPaymasterData(); + if (callData.length < 4) revert PaymasterHubErrors.InvalidPaymasterData(); if (ruleId == RULE_ID_GENERIC) { // ERC-4337 account execute patterns (SimpleAccount, PasskeyAccount, etc.) @@ -2021,16 +1772,12 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG else { target = userOp.sender; } - } else if (ruleId == RULE_ID_EXECUTOR) { - // Custom Executor pattern - target = userOp.sender; - selector = bytes4(callData[0:4]); } else if (ruleId == RULE_ID_COARSE) { // Coarse mode: only check account's selector target = userOp.sender; selector = bytes4(callData[0:4]); } else { - revert InvalidRuleId(); + revert PaymasterHubErrors.InvalidRuleId(); } } @@ -2039,22 +1786,22 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG (uint128 maxPriorityFeePerGas, uint128 maxFeePerGas) = UserOpLib.unpackGasFees(userOp.gasFees); if (caps.maxFeePerGas > 0 && maxFeePerGas > caps.maxFeePerGas) { - revert FeeTooHigh(); + revert PaymasterHubErrors.FeeTooHigh(); } if (caps.maxPriorityFeePerGas > 0 && maxPriorityFeePerGas > caps.maxPriorityFeePerGas) { - revert FeeTooHigh(); + revert PaymasterHubErrors.FeeTooHigh(); } (uint128 verificationGasLimit, uint128 callGasLimit) = UserOpLib.unpackAccountGasLimits(userOp.accountGasLimits); if (caps.maxCallGas > 0 && callGasLimit > caps.maxCallGas) { - revert GasTooHigh(); + revert PaymasterHubErrors.GasTooHigh(); } if (caps.maxVerificationGas > 0 && verificationGasLimit > caps.maxVerificationGas) { - revert GasTooHigh(); + revert PaymasterHubErrors.GasTooHigh(); } if (caps.maxPreVerificationGas > 0 && userOp.preVerificationGas > caps.maxPreVerificationGas) { - revert GasTooHigh(); + revert PaymasterHubErrors.GasTooHigh(); } } @@ -2076,7 +1823,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Check budget capacity (safe conversion as maxCost is bounded by EntryPoint) if (budget.usedInEpoch + uint128(maxCost) > budget.capPerEpoch) { - revert BudgetExceeded(); + revert PaymasterHubErrors.BudgetExceeded(); } // Reserve maxCost for bundle safety — ensures a second UserOp for the same @@ -2100,8 +1847,8 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Only update if we're still in the same epoch if (budget.epochStart == epochStart) { // Replace reservation (maxCost) with actual cost (actualGasCost <= maxCost) - budget.usedInEpoch = budget.usedInEpoch - uint128(reservedMaxCost) + uint128(actualGasCost); - emit UsageIncreased(orgId, subjectKey, actualGasCost, budget.usedInEpoch, epochStart); + budget.usedInEpoch = PaymasterPostOpLib.adjustBudget(budget.usedInEpoch, reservedMaxCost, actualGasCost); + emit PaymasterHubErrors.UsageIncreased(orgId, subjectKey, actualGasCost, budget.usedInEpoch, epochStart); } // If epoch rolled since validation, reservation was already cleared by epoch reset } @@ -2116,7 +1863,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG SolidarityFund storage solidarity = _getSolidarityStorage(); if (countAsCreation) { - emit OnboardingAccountCreated(address(0), actualGasCost); + emit PaymasterHubErrors.OnboardingAccountCreated(address(0), actualGasCost); } else { // Refund the daily counter slot for failed operations (incremented during validation for bundle safety) OnboardingConfig storage onboarding = _getOnboardingStorage(); @@ -2126,7 +1873,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG } // Deduct from solidarity fund (validated during _validateOnboardingEligibility) - if (solidarity.balance < actualGasCost) revert InsufficientFunds(); + if (solidarity.balance < actualGasCost) revert PaymasterHubErrors.InsufficientFunds(); solidarity.balance -= uint128(actualGasCost); } @@ -2143,7 +1890,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG if (countAsDeployment) { // Per-account counter already incremented during validation (bundle safety). // Just emit the event. - emit OrgDeploymentSponsored(sender, actualGasCost); + emit PaymasterHubErrors.OrgDeploymentSponsored(sender, actualGasCost); } else { // Refund both counters for failed operations (incremented during validation for bundle safety) mapping(address => uint8) storage counts = _getOrgDeployCountsStorage(); @@ -2157,7 +1904,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG } // Deduct from solidarity fund (validated during _validateOrgDeployEligibility) - if (solidarity.balance < actualGasCost) revert InsufficientFunds(); + if (solidarity.balance < actualGasCost) revert PaymasterHubErrors.InsufficientFunds(); solidarity.balance -= uint128(actualGasCost); } } diff --git a/src/PaymasterHubLens.sol b/src/PaymasterHubLens.sol index a6d8ebc..dc04208 100644 --- a/src/PaymasterHubLens.sol +++ b/src/PaymasterHubLens.sol @@ -6,12 +6,13 @@ import {IEntryPoint} from "./interfaces/IEntryPoint.sol"; import {PackedUserOperation, UserOpLib} from "./interfaces/PackedUserOperation.sol"; import {IHats} from "lib/hats-protocol/src/Interfaces/IHats.sol"; import {IERC165} from "lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol"; +import {PaymasterHubErrors} from "./libs/PaymasterHubErrors.sol"; +import {PaymasterGraceLib} from "./libs/PaymasterGraceLib.sol"; // Storage structs matching PaymasterHub struct OrgConfig { uint256 adminHatId; uint256 operatorHatId; - uint256 __deprecated_voucherHatId; bool paused; uint40 registeredAt; bool bannedFromSolidarity; @@ -19,11 +20,9 @@ struct OrgConfig { struct OrgFinancials { uint128 deposited; - uint128 totalDeposited; uint128 spent; uint128 solidarityUsedThisPeriod; uint32 periodStart; - uint224 reserved; } struct SolidarityFund { @@ -31,7 +30,6 @@ struct SolidarityFund { uint32 numActiveOrgs; uint16 feePercentageBps; bool distributionPaused; - uint200 reserved; } struct GracePeriodConfig { @@ -79,10 +77,6 @@ interface IPaymasterHubStorage { function getOrgFinancials(bytes32 orgId) external view returns (OrgFinancials memory); function getSolidarityFund() external view returns (SolidarityFund memory); function getGracePeriodConfig() external view returns (GracePeriodConfig memory); - function getOrgGraceStatus(bytes32 orgId) - external - view - returns (bool inGrace, uint128 spendRemaining, bool requiresDeposit, uint256 solidarityLimit); function getOrgDeployConfig() external view returns (OrgDeployConfig memory); function getOrgDeployCount(address account) external view returns (uint8); function ENTRY_POINT() external view returns (address); @@ -99,11 +93,6 @@ interface IPaymasterHubStorage { contract PaymasterHubLens { using UserOpLib for bytes32; - // ============ Custom Errors ============ - error InvalidRuleId(); - error InvalidPaymasterData(); - error ZeroAddress(); - // ============ Constants ============ uint8 private constant PAYMASTER_DATA_VERSION = 1; uint8 private constant SUBJECT_TYPE_ACCOUNT = 0x00; @@ -112,7 +101,6 @@ contract PaymasterHubLens { uint8 private constant SUBJECT_TYPE_ORG_DEPLOY = 0x04; uint32 private constant RULE_ID_GENERIC = 0x00000000; - uint32 private constant RULE_ID_EXECUTOR = 0x00000001; uint32 private constant RULE_ID_COARSE = 0x000000FF; // ============ Immutable ============ @@ -120,7 +108,7 @@ contract PaymasterHubLens { // ============ Constructor ============ constructor(address _hub) { - if (_hub == address(0)) revert ZeroAddress(); + if (_hub == address(0)) revert PaymasterHubErrors.ZeroAddress(); hub = IPaymasterHubStorage(_hub); } @@ -176,6 +164,44 @@ contract PaymasterHubLens { return IEntryPoint(entryPoint).balanceOf(address(hub)); } + /** + * @notice Get org's grace period status and limits + * @dev Moved from PaymasterHub to reduce main contract bytecode size + * @param orgId The organization identifier + * @return inGrace True if in initial grace period + * @return spendRemaining Spending remaining during grace (0 if not in grace) + * @return requiresDeposit True if org needs to deposit to access solidarity + * @return solidarityLimit Current solidarity allocation for org (per 90-day period) + */ + function getOrgGraceStatus(bytes32 orgId) + external + view + returns (bool inGrace, uint128 spendRemaining, bool requiresDeposit, uint256 solidarityLimit) + { + OrgConfig memory config = hub.getOrgConfig(orgId); + OrgFinancials memory org = hub.getOrgFinancials(orgId); + GracePeriodConfig memory grace = hub.getGracePeriodConfig(); + SolidarityFund memory solidarity = hub.getSolidarityFund(); + + inGrace = PaymasterGraceLib.isInGracePeriod(config.registeredAt, grace.initialGraceDays); + + // When distribution is paused, no solidarity is available regardless of grace/tier + if (solidarity.distributionPaused) { + return (inGrace, 0, true, 0); + } + + if (inGrace) { + uint128 spendUsed = org.solidarityUsedThisPeriod; + spendRemaining = spendUsed < grace.maxSpendDuringGrace ? grace.maxSpendDuringGrace - spendUsed : 0; + requiresDeposit = false; + solidarityLimit = uint256(grace.maxSpendDuringGrace); + } else { + uint256 depositAvailable = org.deposited > org.spent ? org.deposited - org.spent : 0; + requiresDeposit = depositAvailable < grace.minDepositRequired; + solidarityLimit = PaymasterGraceLib.calculateMatchAllowance(depositAvailable, grace.minDepositRequired); + } + } + /** * @notice Check if a UserOperation would be valid without state changes * @param orgId The org to validate against @@ -273,7 +299,7 @@ contract PaymasterHubLens { // ERC-4337 v0.7 packed format (must match PaymasterHub._decodePaymasterData): // [paymaster(20) | verificationGasLimit(16) | postOpGasLimit(16) | version(1) | orgId(32) | subjectType(1) | subjectId(32) | ruleId(4)] // = 122 bytes total. Custom data starts at offset 52. - if (paymasterAndData.length < 122) revert InvalidPaymasterData(); + if (paymasterAndData.length < 122) revert PaymasterHubErrors.InvalidPaymasterData(); version = uint8(paymasterAndData[52]); @@ -300,7 +326,7 @@ contract PaymasterHubLens { { bytes calldata callData = userOp.callData; - if (callData.length < 4) revert InvalidPaymasterData(); + if (callData.length < 4) revert PaymasterHubErrors.InvalidPaymasterData(); if (ruleId == RULE_ID_GENERIC) { // ERC-4337 account execute patterns (SimpleAccount, PasskeyAccount, etc.) @@ -337,14 +363,11 @@ contract PaymasterHubLens { } else { target = userOp.sender; } - } else if (ruleId == RULE_ID_EXECUTOR) { - target = userOp.sender; - selector = bytes4(callData[0:4]); } else if (ruleId == RULE_ID_COARSE) { target = userOp.sender; selector = bytes4(callData[0:4]); } else { - revert InvalidRuleId(); + revert PaymasterHubErrors.InvalidRuleId(); } } } diff --git a/src/libs/PaymasterCalldataLib.sol b/src/libs/PaymasterCalldataLib.sol new file mode 100644 index 0000000..4faef44 --- /dev/null +++ b/src/libs/PaymasterCalldataLib.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.24; + +/// @title PaymasterCalldataLib +/// @author POA Engineering +/// @notice Calldata parsing for ERC-4337 execute(address,uint256,bytes) envelope validation +/// @dev Extracts and validates the target, value, and inner selector from a UserOp's callData +/// when it follows the standard SimpleAccount/PasskeyAccount execute pattern. +/// All functions are internal pure (inlined at compile time) for zero gas overhead. +library PaymasterCalldataLib { + /// @notice Selector for execute(address,uint256,bytes) + bytes4 internal constant EXECUTE_SELECTOR = 0xb61d27f6; + + /// @notice Parse and validate an execute(address,uint256,bytes) calldata envelope + /// @dev Checks: callData >= 4 bytes, outer selector = EXECUTE_SELECTOR, callData >= 0x64 bytes, + /// target matches expectedTarget, and value == 0. If all pass, extracts the inner selector + /// from the nested bytes parameter (if available). + /// @param callData The full callData from the UserOperation + /// @param expectedTarget The required target address in the execute call + /// @return valid True if all structural checks pass (selector, length, target, value) + /// @return innerSelector First 4 bytes of the inner data payload (bytes4(0) if unavailable) + function parseExecuteCall(bytes calldata callData, address expectedTarget) + internal + pure + returns (bool valid, bytes4 innerSelector) + { + // Must have at least a 4-byte selector + if (callData.length < 4) return (false, bytes4(0)); + + // Outer selector must be execute(address,uint256,bytes) + if (bytes4(callData[0:4]) != EXECUTE_SELECTOR) return (false, bytes4(0)); + + // Need at least 0x64 bytes for execute(address, uint256, bytes offset) + if (callData.length < 0x64) return (false, bytes4(0)); + + address target; + uint256 value; + assembly { + target := calldataload(add(callData.offset, 0x04)) + value := calldataload(add(callData.offset, 0x24)) + // Read inner bytes data (offset at 0x44, must be standard 0x60) + let dataOffset := calldataload(add(callData.offset, 0x44)) + if eq(dataOffset, 0x60) { + let dataStart := add(add(0x04, dataOffset), 0x20) + if lt(dataStart, callData.length) { + innerSelector := calldataload(add(callData.offset, dataStart)) + } + } + } + innerSelector = bytes4(innerSelector); + + // Target must match and value must be zero + if (target != expectedTarget || value != 0) return (false, bytes4(0)); + + valid = true; + } +} diff --git a/src/libs/PaymasterGraceLib.sol b/src/libs/PaymasterGraceLib.sol new file mode 100644 index 0000000..52bfb48 --- /dev/null +++ b/src/libs/PaymasterGraceLib.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.24; + +/// @title PaymasterGraceLib +/// @author POA Engineering +/// @notice Grace period calculations and solidarity tier matching for PaymasterHub +/// @dev All functions are internal (inlined at compile time) for zero gas overhead +library PaymasterGraceLib { + /// @notice Check if an organization is currently in its initial grace period + /// @param registeredAt Timestamp when the org was registered (uint40) + /// @param initialGraceDays Configured grace period duration in days (uint32) + /// @return True if the current block is before the grace period end + function isInGracePeriod(uint40 registeredAt, uint32 initialGraceDays) internal view returns (bool) { + return block.timestamp < uint256(registeredAt) + (uint256(initialGraceDays) * 1 days); + } + + /// @notice Calculate solidarity fee, returning zero during the grace period + /// @dev Replaces the repeated pattern of computing graceEndTime then branching on fee + /// @param actualGasCost The actual gas cost of the operation + /// @param feePercentageBps Solidarity fee in basis points (e.g. 100 = 1%) + /// @param registeredAt Timestamp when the org was registered + /// @param initialGraceDays Configured grace period duration in days + /// @return fee The calculated solidarity fee (0 during grace period) + function solidarityFee(uint256 actualGasCost, uint16 feePercentageBps, uint40 registeredAt, uint32 initialGraceDays) + internal + view + returns (uint256 fee) + { + if (isInGracePeriod(registeredAt, initialGraceDays)) { + return 0; + } + return (actualGasCost * uint256(feePercentageBps)) / 10000; + } + + /// @notice Calculate solidarity match allowance based on deposit tier + /// @dev Progressive tier system with declining marginal match rates: + /// - Tier 1: deposit <= 1x min -> 2x match (total 3x) + /// - Tier 2: deposit <= 2x min -> 3x match total (total 5x) + /// - Tier 3: deposit < 5x min -> capped at 3x min match + /// - Tier 4: deposit >= 5x min -> no match (self-funded) + /// @param deposited Current available deposit balance + /// @param minDeposit Minimum deposit requirement from grace period config + /// @return matchAllowance Maximum solidarity usage per 90-day period + function calculateMatchAllowance(uint256 deposited, uint256 minDeposit) internal pure returns (uint256) { + if (minDeposit == 0 || deposited < minDeposit) { + return 0; + } + // Tier 1: deposit <= 1x minimum -> 2x match + if (deposited <= minDeposit) { + return deposited * 2; + } + // Tier 2: deposit <= 2x minimum -> first tier at 2x, remainder at 1x + if (deposited <= minDeposit * 2) { + uint256 firstTierMatch = minDeposit * 2; + uint256 secondTierMatch = deposited - minDeposit; + return firstTierMatch + secondTierMatch; + } + // Tier 3: deposit < 5x minimum -> capped match from first two tiers + if (deposited < minDeposit * 5) { + uint256 firstTierMatch = minDeposit * 2; + uint256 secondTierMatch = minDeposit; + return firstTierMatch + secondTierMatch; + } + // Tier 4: >= 5x minimum -> self-sufficient, no match + return 0; + } +} diff --git a/src/libs/PaymasterHubErrors.sol b/src/libs/PaymasterHubErrors.sol new file mode 100644 index 0000000..5a99cf9 --- /dev/null +++ b/src/libs/PaymasterHubErrors.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.24; + +/// @title PaymasterHubErrors +/// @author POA Engineering +/// @notice Shared errors and events for PaymasterHub and PaymasterHubLens +library PaymasterHubErrors { + // ============ Access Control Errors ============ + + /// @notice Caller is not the ERC-4337 EntryPoint + error EPOnly(); + + /// @notice Organization is paused by its admin + error Paused(); + + /// @notice Caller does not wear the org admin hat + error NotAdmin(); + + /// @notice Caller does not wear the org operator hat + error NotOperator(); + + /// @notice Caller is not the PoaManager contract + error NotPoaManager(); + + // ============ Validation Errors ============ + + /// @notice Target + selector combination is not in the org's allowlist + error RuleDenied(address target, bytes4 selector); + + /// @notice Gas fee exceeds the org's configured fee cap + error FeeTooHigh(); + + /// @notice Gas limit exceeds the org's configured gas cap + error GasTooHigh(); + + /// @notice Sender is not eligible for the specified subject (hat or account) + error Ineligible(); + + /// @notice Subject's per-epoch budget would be exceeded + error BudgetExceeded(); + + /// @notice Rule ID is not a recognized rule type + error InvalidRuleId(); + + /// @notice ETH transfer to recipient failed + error PaymentFailed(); + + /// @notice Subject type byte is not a recognized type (0x00, 0x01, 0x03, 0x04) + error InvalidSubjectType(); + + /// @notice Paymaster data version does not match expected version + error InvalidVersion(); + + /// @notice Paymaster data is malformed or too short + error InvalidPaymasterData(); + + /// @notice Address parameter must not be zero + error ZeroAddress(); + + /// @notice Epoch length is outside the allowed range + error InvalidEpochLength(); + + /// @notice Expected contract at address has no code deployed + error ContractNotDeployed(); + + /// @notice Array parameters have mismatched lengths + error ArrayLengthMismatch(); + + // ============ Organization Errors ============ + + /// @notice Organization is not registered in the PaymasterHub + error OrgNotRegistered(); + + /// @notice Organization is already registered + error OrgAlreadyRegistered(); + + /// @notice Organization ID is invalid (zero) + error InvalidOrgId(); + + // ============ Solidarity & Grace Period Errors ============ + + /// @notice Org has exceeded its grace period spending limit + error GracePeriodSpendLimitReached(); + + /// @notice Org's deposit is below the minimum required to access solidarity + error InsufficientDepositForSolidarity(); + + /// @notice Org has exceeded its tier-based solidarity match allowance + error SolidarityLimitExceeded(); + + /// @notice Org's available deposit balance is insufficient + error InsufficientOrgBalance(); + + /// @notice Organization is banned from using the solidarity fund + error OrgIsBanned(); + + /// @notice Solidarity fund or org balance has insufficient funds + error InsufficientFunds(); + + /// @notice Solidarity fund distribution is currently paused + error SolidarityDistributionIsPaused(); + + /// @notice Arithmetic overflow detected + error Overflow(); + + /// @notice Amount parameter must not be zero + error ZeroAmount(); + + // ============ Onboarding Errors ============ + + /// @notice Onboarding sponsorship feature is disabled + error OnboardingDisabled(); + + /// @notice Global daily onboarding creation limit has been reached + error OnboardingDailyLimitExceeded(); + + /// @notice Onboarding request is malformed (bad calldata, missing initCode, etc.) + error InvalidOnboardingRequest(); + + // ============ Org Deploy Errors ============ + + /// @notice Org deployment sponsorship feature is disabled + error OrgDeployDisabled(); + + /// @notice Account has reached its lifetime org deployment limit + error OrgDeployLimitExceeded(); + + /// @notice Global daily org deployment limit has been reached + error OrgDeployDailyLimitExceeded(); + + /// @notice Org deploy request is malformed (bad calldata, has initCode, etc.) + error InvalidOrgDeployRequest(); + + // ============ Events ============ + + /// @notice Emitted when the PaymasterHub is initialized + event PaymasterInitialized(address indexed entryPoint, address indexed hats, address indexed poaManager); + + /// @notice Emitted when a new organization is registered + event OrgRegistered(bytes32 indexed orgId, uint256 adminHatId, uint256 operatorHatId); + + /// @notice Emitted when a rule is set or updated for an org + event RuleSet( + bytes32 indexed orgId, address indexed target, bytes4 indexed selector, bool allowed, uint32 maxCallGasHint + ); + + /// @notice Emitted when a per-subject budget is configured + event BudgetSet(bytes32 indexed orgId, bytes32 subjectKey, uint128 capPerEpoch, uint32 epochLen, uint32 epochStart); + + /// @notice Emitted when an org's fee caps are updated + event FeeCapsSet( + bytes32 indexed orgId, + uint256 maxFeePerGas, + uint256 maxPriorityFeePerGas, + uint32 maxCallGas, + uint32 maxVerificationGas, + uint32 maxPreVerificationGas + ); + + /// @notice Emitted when an org's pause state changes + event PauseSet(bytes32 indexed orgId, bool paused); + + /// @notice Emitted when an org's operator hat is updated + event OperatorHatSet(bytes32 indexed orgId, uint256 operatorHatId); + + /// @notice Emitted when ETH is deposited to the EntryPoint + event DepositIncrease(uint256 amount, uint256 newDeposit); + + /// @notice Emitted when ETH is withdrawn from the EntryPoint deposit + event DepositWithdraw(address indexed to, uint256 amount); + + /// @notice Emitted when a subject's budget usage increases + event UsageIncreased( + bytes32 indexed orgId, bytes32 subjectKey, uint256 delta, uint128 usedInEpoch, uint32 epochStart + ); + + /// @notice Emitted on emergency withdrawal by PoaManager + event EmergencyWithdraw(address indexed to, uint256 amount); + + /// @notice Emitted when ETH is deposited for a specific org + event OrgDepositReceived(bytes32 indexed orgId, address indexed from, uint256 amount); + + /// @notice Emitted when solidarity fee is collected from an org's operation + event SolidarityFeeCollected(bytes32 indexed orgId, uint256 amount); + + /// @notice Emitted when a direct donation is made to the solidarity fund + event SolidarityDonationReceived(address indexed from, uint256 amount); + + /// @notice Emitted when grace period configuration is updated + event GracePeriodConfigUpdated(uint32 initialGraceDays, uint128 maxSpendDuringGrace, uint128 minDepositRequired); + + /// @notice Emitted when an org's solidarity ban status changes + event OrgBannedFromSolidarity(bytes32 indexed orgId, bool banned); + + /// @notice Emitted when onboarding configuration is updated + event OnboardingConfigUpdated( + uint128 maxGasPerCreation, uint128 dailyCreationLimit, bool enabled, address accountRegistry + ); + + /// @notice Emitted when a new account is created via onboarding sponsorship + event OnboardingAccountCreated(address indexed account, uint256 gasCost); + + /// @notice Emitted when org deployment configuration is updated + event OrgDeployConfigUpdated( + uint128 maxGasPerDeploy, uint128 dailyDeployLimit, uint8 maxDeploysPerAccount, bool enabled, address orgDeployer + ); + + /// @notice Emitted when an org deployment is sponsored + event OrgDeploymentSponsored(address indexed account, uint256 gasCost); + + /// @notice Emitted when solidarity fund distribution is paused + event SolidarityDistributionPaused(); + + /// @notice Emitted when solidarity fund distribution is unpaused + event SolidarityDistributionUnpaused(); +} diff --git a/src/libs/PaymasterPostOpLib.sol b/src/libs/PaymasterPostOpLib.sol new file mode 100644 index 0000000..a07b99a --- /dev/null +++ b/src/libs/PaymasterPostOpLib.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.24; + +/// @title PaymasterPostOpLib +/// @author POA Engineering +/// @notice PostOp accounting helpers for PaymasterHub budget and solidarity adjustments +/// @dev All functions are internal pure (inlined at compile time) for zero gas overhead +library PaymasterPostOpLib { + /// @notice Replace a budget reservation with the actual gas cost + /// @dev Used in postOp to swap the maxCost reservation made during validation + /// with the actual (lower) gas cost: usedInEpoch - reserved + actual + /// @param usedInEpoch Current used amount in the epoch (includes reservation) + /// @param reserved Amount reserved during validation (maxCost) + /// @param actual Actual gas cost from EntryPoint (actual <= reserved) + /// @return Updated usedInEpoch value + function adjustBudget(uint128 usedInEpoch, uint256 reserved, uint256 actual) internal pure returns (uint128) { + return usedInEpoch - uint128(reserved) + uint128(actual); + } + + /// @notice Deduct from solidarity balance, clamped to prevent underflow + /// @dev Used in sponsorship fallback paths where the function must never revert. + /// Returns min(balance, cost) as the deduction amount. + /// @param balance Current solidarity fund balance + /// @param cost Amount to deduct + /// @return newBalance Updated solidarity balance after deduction + /// @return deducted Actual amount deducted (may be less than cost if balance is insufficient) + function clampedDeduction(uint128 balance, uint256 cost) + internal + pure + returns (uint128 newBalance, uint128 deducted) + { + deducted = balance < uint128(cost) ? balance : uint128(cost); + newBalance = balance - deducted; + } +} diff --git a/test/DeployerTest.t.sol b/test/DeployerTest.t.sol index 726b92b..8951030 100644 --- a/test/DeployerTest.t.sol +++ b/test/DeployerTest.t.sol @@ -4748,7 +4748,6 @@ contract DeployerTest is Test, IEligibilityModuleEvents { // Verify deposit was credited PaymasterHub.OrgFinancials memory financials = paymasterHub.getOrgFinancials(orgId); assertEq(financials.deposited, 0.1 ether, "Org should have 0.1 ETH deposited"); - assertEq(financials.totalDeposited, 0.1 ether, "Total deposited should be 0.1 ETH"); } function testDeployFullOrgWithPaymasterAutoWhitelist() public { diff --git a/test/PasskeyPaymasterIntegration.t.sol b/test/PasskeyPaymasterIntegration.t.sol index 3649209..5b1c12c 100644 --- a/test/PasskeyPaymasterIntegration.t.sol +++ b/test/PasskeyPaymasterIntegration.t.sol @@ -13,6 +13,7 @@ import {IPasskeyAccount} from "../src/interfaces/IPasskeyAccount.sol"; // PaymasterHub import {PaymasterHub} from "../src/PaymasterHub.sol"; +import {PaymasterHubErrors} from "../src/libs/PaymasterHubErrors.sol"; import {IPaymaster} from "../src/interfaces/IPaymaster.sol"; import {IEntryPoint} from "../src/interfaces/IEntryPoint.sol"; import {PackedUserOperation, UserOpLib} from "../src/interfaces/PackedUserOperation.sol"; @@ -84,7 +85,6 @@ contract PasskeyPaymasterIntegrationTest is Test { uint8 constant SUBJECT_TYPE_ACCOUNT = 0x00; uint8 constant SUBJECT_TYPE_HAT = 0x01; uint32 constant RULE_ID_GENERIC = 0x00000000; - uint32 constant RULE_ID_EXECUTOR = 0x00000001; uint32 constant RULE_ID_COARSE = 0x000000FF; // Function selectors @@ -290,7 +290,7 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( abi.encodeWithSelector( - PaymasterHub.RuleDenied.selector, address(mockTarget), MockTarget.doSomething.selector + PaymasterHubErrors.RuleDenied.selector, address(mockTarget), MockTarget.doSomething.selector ) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); @@ -330,7 +330,9 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( - abi.encodeWithSelector(PaymasterHub.RuleDenied.selector, address(target2), MockTarget.doSomething.selector) + abi.encodeWithSelector( + PaymasterHubErrors.RuleDenied.selector, address(target2), MockTarget.doSomething.selector + ) ); hub.validatePaymasterUserOp(userOp2, bytes32(0), 0.001 ether); } @@ -400,7 +402,7 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( abi.encodeWithSelector( - PaymasterHub.RuleDenied.selector, address(mockTarget), MockTarget.doSomething.selector + PaymasterHubErrors.RuleDenied.selector, address(mockTarget), MockTarget.doSomething.selector ) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); @@ -437,7 +439,7 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( abi.encodeWithSelector( - PaymasterHub.RuleDenied.selector, address(mockTarget), MockTarget.doSomethingElse.selector + PaymasterHubErrors.RuleDenied.selector, address(mockTarget), MockTarget.doSomethingElse.selector ) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); @@ -477,7 +479,9 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( - abi.encodeWithSelector(PaymasterHub.RuleDenied.selector, address(target2), MockTarget.doSomething.selector) + abi.encodeWithSelector( + PaymasterHubErrors.RuleDenied.selector, address(target2), MockTarget.doSomething.selector + ) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -619,7 +623,9 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( abi.encodeWithSelector( - PaymasterHub.RuleDenied.selector, address(mockEligibility), MockEligibility.claimVouchedHat.selector + PaymasterHubErrors.RuleDenied.selector, + address(mockEligibility), + MockEligibility.claimVouchedHat.selector ) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); @@ -693,7 +699,7 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( abi.encodeWithSelector( - PaymasterHub.RuleDenied.selector, address(target2), MockTarget.doSomethingElse.selector + PaymasterHubErrors.RuleDenied.selector, address(target2), MockTarget.doSomethingElse.selector ) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); @@ -749,7 +755,7 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( abi.encodeWithSelector( - PaymasterHub.RuleDenied.selector, address(mockTarget), MockTarget.doSomething.selector + PaymasterHubErrors.RuleDenied.selector, address(mockTarget), MockTarget.doSomething.selector ) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); @@ -776,7 +782,7 @@ contract PasskeyPaymasterIntegrationTest is Test { // Should be denied: (mockTarget, bytes4(0)) is not whitelisted vm.prank(address(entryPoint)); - vm.expectRevert(abi.encodeWithSelector(PaymasterHub.RuleDenied.selector, address(mockTarget), bytes4(0))); + vm.expectRevert(abi.encodeWithSelector(PaymasterHubErrors.RuleDenied.selector, address(mockTarget), bytes4(0))); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -800,7 +806,7 @@ contract PasskeyPaymasterIntegrationTest is Test { PackedUserOperation memory userOp = _createUserOp(address(account), callData, paymasterAndData, ""); vm.prank(address(entryPoint)); - vm.expectRevert(abi.encodeWithSelector(PaymasterHub.RuleDenied.selector, address(mockTarget), bytes4(0))); + vm.expectRevert(abi.encodeWithSelector(PaymasterHubErrors.RuleDenied.selector, address(mockTarget), bytes4(0))); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -867,7 +873,7 @@ contract PasskeyPaymasterIntegrationTest is Test { // Should be denied: (account, executeBatch_selector) is not whitelisted vm.prank(address(entryPoint)); vm.expectRevert( - abi.encodeWithSelector(PaymasterHub.RuleDenied.selector, address(account), EXECUTE_BATCH_SELECTOR) + abi.encodeWithSelector(PaymasterHubErrors.RuleDenied.selector, address(account), EXECUTE_BATCH_SELECTOR) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -900,7 +906,9 @@ contract PasskeyPaymasterIntegrationTest is Test { // Should be denied: (account, execute_selector) is not whitelisted vm.prank(address(entryPoint)); - vm.expectRevert(abi.encodeWithSelector(PaymasterHub.RuleDenied.selector, address(account), EXECUTE_SELECTOR)); + vm.expectRevert( + abi.encodeWithSelector(PaymasterHubErrors.RuleDenied.selector, address(account), EXECUTE_SELECTOR) + ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -929,7 +937,7 @@ contract PasskeyPaymasterIntegrationTest is Test { vm.prank(address(entryPoint)); vm.expectRevert( - abi.encodeWithSelector(PaymasterHub.RuleDenied.selector, address(account), EXECUTE_BATCH_SELECTOR) + abi.encodeWithSelector(PaymasterHubErrors.RuleDenied.selector, address(account), EXECUTE_BATCH_SELECTOR) ); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -995,35 +1003,6 @@ contract PasskeyPaymasterIntegrationTest is Test { assertTrue(context.length > 0); } - function testExecuteBatch_ExecutorMode_StillUsesAccountLevel() public { - PasskeyAccount account = _createPasskeyAccount(); - _setupDefaultBudget(address(account)); - - // With RULE_ID_EXECUTOR, executeBatch should still use (sender, executeBatch) - vm.prank(orgAdmin); - hub.setRule(ORG_ID, address(account), EXECUTE_BATCH_SELECTOR, true, 0); - - address[] memory targets = new address[](1); - targets[0] = address(mockTarget); - - uint256[] memory values = new uint256[](1); - bytes[] memory datas = new bytes[](1); - datas[0] = abi.encodeWithSelector(MockTarget.doSomething.selector); - - bytes memory callData = _buildExecuteBatchCalldata(targets, values, datas); - bytes memory paymasterAndData = _buildPaymasterData( - ORG_ID, SUBJECT_TYPE_ACCOUNT, bytes32(uint256(uint160(address(account)))), RULE_ID_EXECUTOR - ); - - PackedUserOperation memory userOp = _createUserOp(address(account), callData, paymasterAndData, ""); - - vm.prank(address(entryPoint)); - (bytes memory context, uint256 validationData) = hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); - - assertEq(validationData, 0, "EXECUTOR mode should still use account-level validation for batch"); - assertTrue(context.length > 0); - } - function testExecuteBatch_OrgOperationSelectors_VouchAndVote() public { PasskeyAccount account = _createPasskeyAccount(); _setupDefaultBudget(address(account)); @@ -1195,7 +1174,7 @@ contract PasskeyPaymasterIntegrationTest is Test { }); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.GasTooHigh.selector); + vm.expectRevert(PaymasterHubErrors.GasTooHigh.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -1251,7 +1230,7 @@ contract PasskeyPaymasterIntegrationTest is Test { // Try to use more than budget allows vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.BudgetExceeded.selector); + vm.expectRevert(PaymasterHubErrors.BudgetExceeded.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.01 ether); // maxCost > budget } @@ -1333,7 +1312,7 @@ contract PasskeyPaymasterIntegrationTest is Test { PackedUserOperation memory userOp = _createUserOp(address(account), callData, shortData, ""); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidPaymasterData.selector); + vm.expectRevert(PaymasterHubErrors.InvalidPaymasterData.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -1394,7 +1373,7 @@ contract PasskeyPaymasterIntegrationTest is Test { PackedUserOperation memory userOp = _createUserOp(ineligibleUser, callData, paymasterAndData, ""); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.Ineligible.selector); + vm.expectRevert(PaymasterHubErrors.Ineligible.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.01 ether); } @@ -1446,7 +1425,7 @@ contract PasskeyPaymasterIntegrationTest is Test { PackedUserOperation memory userOp = _createUserOp(eligibleUser, callData, paymasterAndData, ""); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.Ineligible.selector); + vm.expectRevert(PaymasterHubErrors.Ineligible.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.01 ether); } @@ -1470,7 +1449,7 @@ contract PasskeyPaymasterIntegrationTest is Test { PackedUserOperation memory userOp = _createUserOp(user, callData, paymasterAndData, ""); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.Ineligible.selector); + vm.expectRevert(PaymasterHubErrors.Ineligible.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.01 ether); } @@ -1526,7 +1505,7 @@ contract PasskeyPaymasterIntegrationTest is Test { PackedUserOperation memory userOp = _createUserOp(address(account), callData, tooShort, ""); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidPaymasterData.selector); + vm.expectRevert(PaymasterHubErrors.InvalidPaymasterData.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.001 ether); } @@ -1740,7 +1719,7 @@ contract PasskeyPaymasterIntegrationTest is Test { // Op 2 validation: maxCost=0.005 ETH, but budget already has 0.005 reserved // Total would be 0.01 > 0.009 cap → must revert vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.BudgetExceeded.selector); + vm.expectRevert(PaymasterHubErrors.BudgetExceeded.selector); hub.validatePaymasterUserOp(userOp, bytes32(uint256(2)), 0.005 ether); } @@ -1791,7 +1770,7 @@ contract PasskeyPaymasterIntegrationTest is Test { // Op 2: maxCost=0.005, but only 0.003 remaining → must revert vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InsufficientOrgBalance.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientOrgBalance.selector); hub.validatePaymasterUserOp(userOp, bytes32(uint256(2)), 0.005 ether); } @@ -1999,7 +1978,7 @@ contract PasskeyPaymasterIntegrationTest is Test { // Op 3: 0.005 would make total 0.015 > cap (0.015 - 1), must revert vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.BudgetExceeded.selector); + vm.expectRevert(PaymasterHubErrors.BudgetExceeded.selector); hub.validatePaymasterUserOp(userOp, bytes32(uint256(3)), 0.005 ether); } @@ -2179,7 +2158,7 @@ contract PasskeyPaymasterIntegrationTest is Test { // solidarityUsedThisPeriod is now 75_000. Next validate: 75_000 + 30_000 = 105_000 > 100_000 vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.GracePeriodSpendLimitReached.selector); + vm.expectRevert(PaymasterHubErrors.GracePeriodSpendLimitReached.selector); hub.validatePaymasterUserOp(userOp, bytes32(uint256(4)), 30_000); } @@ -2641,7 +2620,7 @@ contract PasskeyPaymasterIntegrationTest is Test { // depositAvailable for op2's solidarity check = 0.005 - 0.004 = 0.001 ether // 0.001 ether < minDepositRequired (0.003 ether), so op2 should fail InsufficientDepositForSolidarity vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InsufficientDepositForSolidarity.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientDepositForSolidarity.selector); hub.validatePaymasterUserOp(userOp, bytes32(uint256(2)), 0.004 ether); // But after op1's postOp settles, deposits are freed for the next validation diff --git a/test/PaymasterHub.t.sol.skip b/test/PaymasterHub.t.sol.skip index 1ed7ca5..03d94e1 100644 --- a/test/PaymasterHub.t.sol.skip +++ b/test/PaymasterHub.t.sol.skip @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import {Test, console2} from "forge-std/Test.sol"; import {PaymasterHub} from "../src/PaymasterHub.sol"; +import {PaymasterHubErrors} from "../src/libs/PaymasterHubErrors.sol"; import {PaymasterHubLens, Config, Budget, Rule, FeeCaps, Bounty} from "../src/PaymasterHubLens.sol"; import {IPaymaster} from "../src/interfaces/IPaymaster.sol"; import {IEntryPoint} from "../src/interfaces/IEntryPoint.sol"; @@ -351,7 +352,7 @@ contract PaymasterHubTest is Test { function testAdminAccessControl() public { // Non-admin should fail vm.prank(user); - vm.expectRevert(PaymasterHub.NotAdmin.selector); + vm.expectRevert(PaymasterHubErrors.NotAdmin.selector); hub.setPause(true); // Admin should succeed @@ -445,7 +446,7 @@ contract PaymasterHubTest is Test { // Should revert when paused vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.Paused.selector); + vm.expectRevert(PaymasterHubErrors.Paused.selector); hub.validatePaymasterUserOp(userOp, userOpHash, 0.1 ether); } @@ -466,7 +467,7 @@ contract PaymasterHubTest is Test { userOp.paymasterAndData = invalidPaymasterData; vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidVersion.selector); + vm.expectRevert(PaymasterHubErrors.InvalidVersion.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.1 ether); } @@ -478,7 +479,7 @@ contract PaymasterHubTest is Test { uint256 maxCost = 2 ether; // Exceeds budget vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.BudgetExceeded.selector); + vm.expectRevert(PaymasterHubErrors.BudgetExceeded.selector); hub.validatePaymasterUserOp(userOp, userOpHash, maxCost); } @@ -495,7 +496,7 @@ contract PaymasterHubTest is Test { // Contract extracts target from execute parameters (0x123) and uses execute selector vm.expectRevert( abi.encodeWithSelector( - PaymasterHub.RuleDenied.selector, address(0x123), bytes4(keccak256("execute(address,uint256,bytes)")) + PaymasterHubErrors.RuleDenied.selector, address(0x123), bytes4(keccak256("execute(address,uint256,bytes)")) ) ); hub.validatePaymasterUserOp(userOp, userOpHash, 0.1 ether); @@ -612,7 +613,7 @@ contract PaymasterHubTest is Test { bytes32 userOpHash = keccak256(abi.encode(userOp)); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.FeeTooHigh.selector); + vm.expectRevert(PaymasterHubErrors.FeeTooHigh.selector); hub.validatePaymasterUserOp(userOp, userOpHash, 0.1 ether); } @@ -639,7 +640,7 @@ contract PaymasterHubTest is Test { bytes32 expectedHash = keccak256(packedOp); vm.expectEmit(true, true, false, false); - emit PaymasterHub.UserOpPosted(expectedHash, user); + emit PaymasterHubErrors.UserOpPosted(expectedHash, user); vm.prank(user); bytes32 returnedHash = hub.postUserOp(packedOp); @@ -771,7 +772,7 @@ contract PaymasterHubTest is Test { vm.prank(address(entryPoint)); if (used + maxCost > cap) { - vm.expectRevert(PaymasterHub.BudgetExceeded.selector); + vm.expectRevert(PaymasterHubErrors.BudgetExceeded.selector); hub.validatePaymasterUserOp(userOp, userOpHash, maxCost); } else { (bytes memory context,) = hub.validatePaymasterUserOp(userOp, userOpHash, maxCost); diff --git a/test/PaymasterHubIntegration.t.sol.skip b/test/PaymasterHubIntegration.t.sol.skip index 0ab7f81..e61a52a 100644 --- a/test/PaymasterHubIntegration.t.sol.skip +++ b/test/PaymasterHubIntegration.t.sol.skip @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import {Test, console2} from "forge-std/Test.sol"; import {PaymasterHub} from "../src/PaymasterHub.sol"; +import {PaymasterHubErrors} from "../src/libs/PaymasterHubErrors.sol"; import {PaymasterHubLens, Config, Budget, Rule, FeeCaps, Bounty} from "../src/PaymasterHubLens.sol"; import {IPaymaster} from "../src/interfaces/IPaymaster.sol"; import {PackedUserOperation, UserOpLib} from "../src/interfaces/PackedUserOperation.sol"; @@ -267,7 +268,7 @@ contract PaymasterHubIntegrationTest is Test { _createUserOp(address(account1), targetContract, abi.encodeWithSignature("operation2()"), 0); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.BudgetExceeded.selector); + vm.expectRevert(PaymasterHubErrors.BudgetExceeded.selector); hub.validatePaymasterUserOp(op2, keccak256(abi.encode(op2)), 0.3 ether); // Move to next epoch diff --git a/test/PaymasterHubInvariants.t.sol.skip b/test/PaymasterHubInvariants.t.sol.skip index 87fefdd..340c4e6 100644 --- a/test/PaymasterHubInvariants.t.sol.skip +++ b/test/PaymasterHubInvariants.t.sol.skip @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import {Test, console2} from "forge-std/Test.sol"; import {PaymasterHub} from "../src/PaymasterHub.sol"; +import {PaymasterHubErrors} from "../src/libs/PaymasterHubErrors.sol"; import {PaymasterHubLens, Config, Budget, Rule, FeeCaps, Bounty} from "../src/PaymasterHubLens.sol"; import {PackedUserOperation, UserOpLib} from "../src/interfaces/PackedUserOperation.sol"; import {IPaymaster} from "../src/interfaces/IPaymaster.sol"; @@ -118,7 +119,7 @@ contract PaymasterHubInvariantsTest is Test { } else { // Should fail vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.BudgetExceeded.selector); + vm.expectRevert(PaymasterHubErrors.BudgetExceeded.selector); hub.validatePaymasterUserOp(userOp, userOpHash, maxCost); } } @@ -136,7 +137,7 @@ contract PaymasterHubInvariantsTest is Test { PackedUserOperation memory userOp = _createUserOp(address(account)); vm.prank(caller); - vm.expectRevert(PaymasterHub.EPOnly.selector); + vm.expectRevert(PaymasterHubErrors.EPOnly.selector); hub.validatePaymasterUserOp(userOp, bytes32(0), 0.1 ether); } @@ -148,7 +149,7 @@ contract PaymasterHubInvariantsTest is Test { vm.assume(caller != address(0)); vm.prank(caller); - vm.expectRevert(PaymasterHub.EPOnly.selector); + vm.expectRevert(PaymasterHubErrors.EPOnly.selector); hub.postOp(IPaymaster.PostOpMode.opSucceeded, context, 0.01 ether); } @@ -271,30 +272,30 @@ contract PaymasterHubInvariantsTest is Test { vm.startPrank(caller); // Test operator function (setRule can be called by operator OR admin) - vm.expectRevert(PaymasterHub.NotOperator.selector); + vm.expectRevert(PaymasterHubErrors.NotOperator.selector); hub.setRule(address(0x1), bytes4(0x12345678), true, 100000); - vm.expectRevert(PaymasterHub.NotOperator.selector); + vm.expectRevert(PaymasterHubErrors.NotOperator.selector); hub.setBudget(bytes32(0), 1 ether, 1 days); - vm.expectRevert(PaymasterHub.NotOperator.selector); + vm.expectRevert(PaymasterHubErrors.NotOperator.selector); hub.setFeeCaps(100 gwei, 10 gwei, 1000000, 500000, 200000); - vm.expectRevert(PaymasterHub.NotAdmin.selector); + vm.expectRevert(PaymasterHubErrors.NotAdmin.selector); hub.setPause(true); - vm.expectRevert(PaymasterHub.NotAdmin.selector); + vm.expectRevert(PaymasterHubErrors.NotAdmin.selector); hub.setBounty(true, 0.1 ether, 1000); // Test depositToEntryPoint with a small amount that won't cause OutOfFunds vm.deal(caller, 0.01 ether); - vm.expectRevert(PaymasterHub.NotOperator.selector); + vm.expectRevert(PaymasterHubErrors.NotOperator.selector); hub.depositToEntryPoint{value: 0.01 ether}(); - vm.expectRevert(PaymasterHub.NotAdmin.selector); + vm.expectRevert(PaymasterHubErrors.NotAdmin.selector); hub.withdrawFromEntryPoint(payable(caller), 1 ether); - vm.expectRevert(PaymasterHub.NotAdmin.selector); + vm.expectRevert(PaymasterHubErrors.NotAdmin.selector); hub.sweepBounty(payable(caller), 1 ether); vm.stopPrank(); diff --git a/test/PaymasterHubSolidarity.t.sol b/test/PaymasterHubSolidarity.t.sol index 3fb5298..bf5e1af 100644 --- a/test/PaymasterHubSolidarity.t.sol +++ b/test/PaymasterHubSolidarity.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.24; import {Test, Vm, console2} from "forge-std/Test.sol"; import {PaymasterHub} from "../src/PaymasterHub.sol"; +import {PaymasterHubErrors} from "../src/libs/PaymasterHubErrors.sol"; +import {PaymasterHubLens} from "../src/PaymasterHubLens.sol"; import {IPaymaster} from "../src/interfaces/IPaymaster.sol"; import {IEntryPoint} from "../src/interfaces/IEntryPoint.sol"; import {PackedUserOperation, UserOpLib} from "../src/interfaces/PackedUserOperation.sol"; @@ -265,6 +267,7 @@ contract MockHats is IHats { */ contract PaymasterHubSolidarityTest is Test { PaymasterHub public hub; + PaymasterHubLens public lens; MockEntryPoint public entryPoint; MockHats public hats; @@ -301,6 +304,7 @@ contract PaymasterHubSolidarityTest is Test { abi.encodeWithSelector(PaymasterHub.initialize.selector, address(entryPoint), address(hats), poaManager); ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData); hub = PaymasterHub(payable(address(proxy))); + lens = new PaymasterHubLens(address(hub)); // Setup hats hats.mintHat(ADMIN_HAT, orgAdmin); @@ -367,7 +371,7 @@ contract PaymasterHubSolidarityTest is Test { function testGracePeriodSpendingLimit() public view { // New org should be in grace period PaymasterHub.GracePeriodConfig memory grace = hub.getGracePeriodConfig(); - (bool inGrace, uint128 spendRemaining,,) = hub.getOrgGraceStatus(ORG_ALPHA); + (bool inGrace, uint128 spendRemaining,,) = lens.getOrgGraceStatus(ORG_ALPHA); assertTrue(inGrace); assertEq(spendRemaining, grace.maxSpendDuringGrace); @@ -377,7 +381,7 @@ contract PaymasterHubSolidarityTest is Test { // Fast forward past grace period vm.warp(block.timestamp + 91 days); - (bool inGrace,,,) = hub.getOrgGraceStatus(ORG_ALPHA); + (bool inGrace,,,) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(inGrace); } @@ -399,7 +403,7 @@ contract PaymasterHubSolidarityTest is Test { function testGracePeriodConfigOnlyPoaManager() public { vm.prank(orgAdmin); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.setGracePeriodConfig(90, 0.01 ether, 0.003 ether); } @@ -417,7 +421,6 @@ contract PaymasterHubSolidarityTest is Test { // Check org financials PaymasterHub.OrgFinancials memory fin = hub.getOrgFinancials(ORG_ALPHA); assertEq(fin.deposited, depositAmount); - assertEq(fin.totalDeposited, depositAmount); assertEq(fin.spent, 0); assertEq(fin.solidarityUsedThisPeriod, 0); @@ -435,14 +438,13 @@ contract PaymasterHubSolidarityTest is Test { PaymasterHub.OrgFinancials memory fin = hub.getOrgFinancials(ORG_ALPHA); assertEq(fin.deposited, 0.01 ether); - assertEq(fin.totalDeposited, 0.01 ether); } function testDepositForNonExistentOrg() public { bytes32 fakeOrg = keccak256("FAKE"); vm.prank(user1); - vm.expectRevert(PaymasterHub.OrgNotRegistered.selector); + vm.expectRevert(PaymasterHubErrors.OrgNotRegistered.selector); hub.depositForOrg{value: 0.01 ether}(fakeOrg); } @@ -464,14 +466,14 @@ contract PaymasterHubSolidarityTest is Test { function testDepositForOrgOverflowReverts() public { vm.deal(user1, type(uint256).max); vm.prank(user1); - vm.expectRevert(PaymasterHub.Overflow.selector); + vm.expectRevert(PaymasterHubErrors.Overflow.selector); hub.depositForOrg{value: uint256(type(uint128).max) + 1}(ORG_ALPHA); } function testDonateToSolidarityOverflowReverts() public { vm.deal(user1, type(uint256).max); vm.prank(user1); - vm.expectRevert(PaymasterHub.Overflow.selector); + vm.expectRevert(PaymasterHubErrors.Overflow.selector); hub.donateToSolidarity{value: uint256(type(uint128).max) + 1}(); } @@ -529,7 +531,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); - (,, bool requiresDeposit, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit); assertEq(solidarityLimit, 0.006 ether); // 2x match } @@ -541,7 +543,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.006 ether}(ORG_ALPHA); - (,,, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,,, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); // First 0.003 at 2x = 0.006, second 0.003 at 1x = 0.003, total = 0.009 assertEq(solidarityLimit, 0.009 ether); } @@ -553,7 +555,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.02 ether}(ORG_ALPHA); - (,,, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,,, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); assertEq(solidarityLimit, 0); // No match for large deposits } @@ -564,7 +566,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.002 ether}(ORG_ALPHA); - (,, bool requiresDeposit, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); assertTrue(requiresDeposit); // Below minimum assertEq(solidarityLimit, 0); // No match } @@ -598,7 +600,7 @@ contract PaymasterHubSolidarityTest is Test { function testBanOnlyPoaManager() public { vm.prank(orgAdmin); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.setBanFromSolidarity(ORG_ALPHA, true); } @@ -616,13 +618,13 @@ contract PaymasterHubSolidarityTest is Test { function testSolidarityFeeCapAt10Percent() public { vm.prank(poaManager); - vm.expectRevert(PaymasterHub.FeeTooHigh.selector); + vm.expectRevert(PaymasterHubErrors.FeeTooHigh.selector); hub.setSolidarityFee(1001); // >10% } function testSolidarityFeeOnlyPoaManager() public { vm.prank(orgAdmin); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.setSolidarityFee(200); } @@ -637,7 +639,6 @@ contract PaymasterHubSolidarityTest is Test { PaymasterHub.OrgFinancials memory fin = hub.getOrgFinancials(ORG_ALPHA); assertEq(fin.deposited, amount); - assertEq(fin.totalDeposited, amount); } function testFuzz_TierMatchCalculation(uint128 depositAmount) public { @@ -648,7 +649,7 @@ contract PaymasterHubSolidarityTest is Test { vm.deal(user1, depositAmount); hub.depositForOrg{value: depositAmount}(ORG_ALPHA); - (,,, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,,, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); // Verify tier logic if (depositAmount <= 0.003 ether) { @@ -688,7 +689,7 @@ contract PaymasterHubSolidarityTest is Test { // (Note: Actual spending happens in postOp during validatePaymasterUserOp, // which requires full EntryPoint integration. This tests the state.) - (bool inGrace, uint128 spendRemaining,,) = hub.getOrgGraceStatus(ORG_ALPHA); + (bool inGrace, uint128 spendRemaining,,) = lens.getOrgGraceStatus(ORG_ALPHA); assertTrue(inGrace); assertEq(spendRemaining, 0.01 ether); @@ -699,7 +700,7 @@ contract PaymasterHubSolidarityTest is Test { hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); uint256 solidarityLimit; - (inGrace,,, solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (inGrace,,, solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(inGrace); assertEq(solidarityLimit, 0.006 ether); // 2x match @@ -714,7 +715,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.006 ether}(ORG_ALPHA); - (,,, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,,, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); assertEq(solidarityLimit, 0.009 ether); // First 0.003 at 2x + second 0.003 at 1x PaymasterHub.OrgFinancials memory fin = hub.getOrgFinancials(ORG_ALPHA); @@ -728,7 +729,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.05 ether}(ORG_ALPHA); - (,,, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,,, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); assertEq(solidarityLimit, 0); // No match for large deposits PaymasterHub.OrgFinancials memory fin = hub.getOrgFinancials(ORG_ALPHA); @@ -743,7 +744,7 @@ contract PaymasterHubSolidarityTest is Test { // Can't deposit next month, access cut off vm.warp(block.timestamp + 91 days); - (,, bool requiresDeposit,) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit,) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit); // Still has initial deposit // But if deposit was spent and not replenished... @@ -780,13 +781,13 @@ contract PaymasterHubSolidarityTest is Test { function testCannotDepositZero() public { vm.prank(user1); - vm.expectRevert(PaymasterHub.ZeroAmount.selector); + vm.expectRevert(PaymasterHubErrors.ZeroAmount.selector); hub.depositForOrg{value: 0}(ORG_ALPHA); } function testCannotDonateZero() public { vm.prank(user1); - vm.expectRevert(PaymasterHub.ZeroAmount.selector); + vm.expectRevert(PaymasterHubErrors.ZeroAmount.selector); hub.donateToSolidarity{value: 0}(); } @@ -836,7 +837,7 @@ contract PaymasterHubSolidarityTest is Test { hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); // Check eligible for Tier 1 - (,, bool requiresDeposit1, uint256 solidarityLimit1) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit1, uint256 solidarityLimit1) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit1); assertEq(solidarityLimit1, 0.006 ether); // 2x match @@ -859,7 +860,7 @@ contract PaymasterHubSolidarityTest is Test { hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); // Check eligible - (,, bool requiresDeposit1, uint256 solidarityLimit1) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit1, uint256 solidarityLimit1) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit1); assertEq(solidarityLimit1, 0.006 ether); @@ -868,7 +869,7 @@ contract PaymasterHubSolidarityTest is Test { hub.depositForOrg{value: 0.0015 ether}(ORG_ALPHA); // Check still Tier 1 (balance = 0.0045 ETH, which is > minDeposit but < 2x minDeposit) - (,, bool requiresDeposit2, uint256 solidarityLimit2) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit2, uint256 solidarityLimit2) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit2); // Should get 2x match on 0.003 (first tier) + 1x match on 0.0015 (second tier) @@ -894,7 +895,7 @@ contract PaymasterHubSolidarityTest is Test { assertEq(fin1.spent, 0); // Check eligible for $20 match - (,, bool requiresDeposit1, uint256 solidarityLimit1) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit1, uint256 solidarityLimit1) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit1); assertEq(solidarityLimit1, 0.006 ether); @@ -913,7 +914,7 @@ contract PaymasterHubSolidarityTest is Test { vm.warp(block.timestamp + 91 days); // Without any deposits, should require deposit and have no match - (,, bool requiresDeposit1, uint256 match1) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit1, uint256 match1) = lens.getOrgGraceStatus(ORG_ALPHA); assertTrue(requiresDeposit1); assertEq(match1, 0); @@ -921,7 +922,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); - (,, bool requiresDeposit2, uint256 match2) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit2, uint256 match2) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit2); assertEq(match2, 0.006 ether); // 2x match on available balance } @@ -934,7 +935,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); - (,, bool requiresDeposit1, uint256 limit1) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit1, uint256 limit1) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit1); assertEq(limit1, 0.006 ether); // 2x match @@ -942,7 +943,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); - (,, bool requiresDeposit2, uint256 limit2) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit2, uint256 limit2) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit2); assertEq(limit2, 0.009 ether); // 0.006 (first tier 2x) + 0.003 (second tier 1x) @@ -950,7 +951,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.011 ether}(ORG_ALPHA); - (,, bool requiresDeposit3, uint256 limit3) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit3, uint256 limit3) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit3); assertEq(limit3, 0); // No match for self-sufficient orgs } @@ -964,7 +965,7 @@ contract PaymasterHubSolidarityTest is Test { hub.depositForOrg{value: 0.002 ether}(ORG_ALPHA); // Should require deposit and have no match - (,, bool requiresDeposit, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); assertTrue(requiresDeposit); assertEq(solidarityLimit, 0); } @@ -977,7 +978,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); - (,, bool requiresDeposit, uint256 solidarityLimit) = hub.getOrgGraceStatus(ORG_ALPHA); + (,, bool requiresDeposit, uint256 solidarityLimit) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(requiresDeposit); assertEq(solidarityLimit, 0.006 ether); // 2x match } @@ -1001,13 +1002,13 @@ contract PaymasterHubSolidarityTest is Test { function testPauseDistributionOnlyPoaManager() public { vm.prank(orgAdmin); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.pauseSolidarityDistribution(); } function testUnpauseDistributionOnlyPoaManager() public { vm.prank(orgAdmin); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.unpauseSolidarityDistribution(); } @@ -1154,7 +1155,7 @@ contract PaymasterHubSolidarityTest is Test { // ORG_ALPHA was just registered, so it's in grace period (bool inGrace, uint128 spendRemaining, bool requiresDeposit, uint256 solidarityLimit) = - hub.getOrgGraceStatus(ORG_ALPHA); + lens.getOrgGraceStatus(ORG_ALPHA); // inGrace still reflects the time-based status (useful info) assertTrue(inGrace); @@ -1175,7 +1176,7 @@ contract PaymasterHubSolidarityTest is Test { hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); (bool inGrace, uint128 spendRemaining, bool requiresDeposit, uint256 solidarityLimit) = - hub.getOrgGraceStatus(ORG_ALPHA); + lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(inGrace); assertEq(spendRemaining, 0); @@ -1186,7 +1187,7 @@ contract PaymasterHubSolidarityTest is Test { function testGetOrgGraceStatus_UnpausedShowsNormalValues() public view { // setUp already unpaused, so this should show normal grace values (bool inGrace, uint128 spendRemaining, bool requiresDeposit, uint256 solidarityLimit) = - hub.getOrgGraceStatus(ORG_ALPHA); + lens.getOrgGraceStatus(ORG_ALPHA); assertTrue(inGrace); assertEq(spendRemaining, 0.01 ether); // Full grace spending available @@ -1204,14 +1205,14 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(poaManager); hub.pauseSolidarityDistribution(); - (,,, uint256 limit1) = hub.getOrgGraceStatus(ORG_ALPHA); + (,,, uint256 limit1) = lens.getOrgGraceStatus(ORG_ALPHA); assertEq(limit1, 0); // Unpause — match restored vm.prank(poaManager); hub.unpauseSolidarityDistribution(); - (,,, uint256 limit2) = hub.getOrgGraceStatus(ORG_ALPHA); + (,,, uint256 limit2) = lens.getOrgGraceStatus(ORG_ALPHA); assertEq(limit2, 0.006 ether); // 2x match restored } @@ -1221,7 +1222,7 @@ contract PaymasterHubSolidarityTest is Test { bytes32 newOrgId = keccak256("UNAUTHORIZED_ORG"); vm.prank(orgAdmin); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.registerOrg(newOrgId, ADMIN_HAT, OPERATOR_HAT); } @@ -1229,7 +1230,7 @@ contract PaymasterHubSolidarityTest is Test { bytes32 newOrgId = keccak256("RANDOM_ORG"); vm.prank(user1); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.registerOrg(newOrgId, ADMIN_HAT, OPERATOR_HAT); } @@ -1250,7 +1251,7 @@ contract PaymasterHubSolidarityTest is Test { function testSetOrgRegistrarUnauthorizedReverts() public { vm.prank(orgAdmin); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.setOrgRegistrar(address(0x99)); } @@ -1260,7 +1261,7 @@ contract PaymasterHubSolidarityTest is Test { function testGracePeriodZeroDepositOrgGraceStatus() public { // A newly registered org with zero deposits should be in grace - (bool inGrace, uint128 spendRemaining,,) = hub.getOrgGraceStatus(ORG_ALPHA); + (bool inGrace, uint128 spendRemaining,,) = lens.getOrgGraceStatus(ORG_ALPHA); assertTrue(inGrace, "New org should be in grace period"); PaymasterHub.OrgFinancials memory fin = hub.getOrgFinancials(ORG_ALPHA); @@ -1272,7 +1273,7 @@ contract PaymasterHubSolidarityTest is Test { // Warp past grace period (default 90 days) vm.warp(block.timestamp + 91 days); - (bool inGrace,,,) = hub.getOrgGraceStatus(ORG_ALPHA); + (bool inGrace,,,) = lens.getOrgGraceStatus(ORG_ALPHA); assertFalse(inGrace, "Org should no longer be in grace after 91 days"); // Verify the org still has zero deposits @@ -1300,7 +1301,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(user1); hub.depositForOrg{value: smallDeposit}(ORG_ALPHA); - (bool inGrace,,,) = hub.getOrgGraceStatus(ORG_ALPHA); + (bool inGrace,,,) = lens.getOrgGraceStatus(ORG_ALPHA); assertTrue(inGrace, "Org should still be in grace"); PaymasterHub.OrgFinancials memory fin = hub.getOrgFinancials(ORG_ALPHA); @@ -1365,7 +1366,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(address(0xdead), "", pmData); userOp.initCode = hex"01"; vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOnboardingRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOnboardingRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1446,7 +1447,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp3 = _buildUserOp(account3, "", pmData3); userOp3.initCode = hex"01"; vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.OnboardingDailyLimitExceeded.selector); + vm.expectRevert(PaymasterHubErrors.OnboardingDailyLimitExceeded.selector); hub.validatePaymasterUserOp(userOp3, keccak256("hash3"), MAX_COST); } @@ -1486,7 +1487,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(deployed, execCallData, pmData); userOp.initCode = hex"01"; vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOnboardingRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOnboardingRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1506,7 +1507,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(deployed, execCallData, pmData); userOp.initCode = hex"01"; vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOnboardingRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOnboardingRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1525,7 +1526,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(deployed, badCallData, pmData); userOp.initCode = hex"01"; vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOnboardingRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOnboardingRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1597,7 +1598,7 @@ contract PaymasterHubSolidarityTest is Test { // Third deploy should revert PackedUserOperation memory userOp3 = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.OrgDeployLimitExceeded.selector); + vm.expectRevert(PaymasterHubErrors.OrgDeployLimitExceeded.selector); hub.validatePaymasterUserOp(userOp3, keccak256("h3"), MAX_COST); } @@ -1625,7 +1626,7 @@ contract PaymasterHubSolidarityTest is Test { address sender2 = address(new DummySender()); PackedUserOperation memory userOp2 = _buildUserOp(sender2, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.OrgDeployDailyLimitExceeded.selector); + vm.expectRevert(PaymasterHubErrors.OrgDeployDailyLimitExceeded.selector); hub.validatePaymasterUserOp(userOp2, keccak256("h2"), MAX_COST); } @@ -1640,7 +1641,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.GasTooHigh.selector); + vm.expectRevert(PaymasterHubErrors.GasTooHigh.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST + 1); } @@ -1659,7 +1660,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.OrgDeployDisabled.selector); + vm.expectRevert(PaymasterHubErrors.OrgDeployDisabled.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1673,7 +1674,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(address(0xdead), callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1688,7 +1689,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(address(0xdead), callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1704,7 +1705,7 @@ contract PaymasterHubSolidarityTest is Test { userOp.initCode = hex"01"; vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1721,7 +1722,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1738,7 +1739,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1758,7 +1759,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.SolidarityDistributionIsPaused.selector); + vm.expectRevert(PaymasterHubErrors.SolidarityDistributionIsPaused.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1778,7 +1779,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InsufficientFunds.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientFunds.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1820,7 +1821,7 @@ contract PaymasterHubSolidarityTest is Test { // Non-poaManager should revert vm.prank(user1); - vm.expectRevert(PaymasterHub.NotPoaManager.selector); + vm.expectRevert(PaymasterHubErrors.NotPoaManager.selector); hub.setOrgDeployConfig(0.1 ether, 50, 3, true, deployer); // PoaManager can set @@ -1859,7 +1860,7 @@ contract PaymasterHubSolidarityTest is Test { address sender2 = address(new DummySender()); PackedUserOperation memory userOp2 = _buildUserOp(sender2, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.OrgDeployDailyLimitExceeded.selector); + vm.expectRevert(PaymasterHubErrors.OrgDeployDailyLimitExceeded.selector); hub.validatePaymasterUserOp(userOp2, keccak256("h2"), MAX_COST); // Warp to next day @@ -1883,7 +1884,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -1913,7 +1914,7 @@ contract PaymasterHubSolidarityTest is Test { vm.prank(poaManager); vm.expectEmit(false, false, false, true); - emit PaymasterHub.OrgDeployConfigUpdated(0.1 ether, 50, 3, true, deployer); + emit PaymasterHubErrors.OrgDeployConfigUpdated(0.1 ether, 50, 3, true, deployer); hub.setOrgDeployConfig(0.1 ether, 50, 3, true, deployer); } @@ -2076,7 +2077,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -2095,7 +2096,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.OrgDeployLimitExceeded.selector); + vm.expectRevert(PaymasterHubErrors.OrgDeployLimitExceeded.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -2109,7 +2110,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(address(new DummySender()), callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -2123,7 +2124,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, "", pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -2139,7 +2140,7 @@ contract PaymasterHubSolidarityTest is Test { PackedUserOperation memory userOp = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InvalidOrgDeployRequest.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgDeployRequest.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), MAX_COST); } @@ -2165,7 +2166,7 @@ contract PaymasterHubSolidarityTest is Test { // sender1 is blocked PackedUserOperation memory blockedOp = _buildUserOp(sender1, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.OrgDeployLimitExceeded.selector); + vm.expectRevert(PaymasterHubErrors.OrgDeployLimitExceeded.selector); hub.validatePaymasterUserOp(blockedOp, keccak256("blocked"), MAX_COST); // sender2 should still succeed (independent counter) @@ -2202,7 +2203,7 @@ contract PaymasterHubSolidarityTest is Test { // Second validation from SAME sender should fail (counter is already 1 >= limit of 1) PackedUserOperation memory userOp2 = _buildUserOp(sender, callData, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.OrgDeployLimitExceeded.selector); + vm.expectRevert(PaymasterHubErrors.OrgDeployLimitExceeded.selector); hub.validatePaymasterUserOp(userOp2, keccak256("h2"), MAX_COST); } @@ -2251,7 +2252,7 @@ contract PaymasterHubSolidarityTest is Test { /// @notice registerOrg must reject bytes32(0) as orgId function testRegisterOrgRejectsZeroOrgId() public { vm.prank(poaManager); - vm.expectRevert(PaymasterHub.InvalidOrgId.selector); + vm.expectRevert(PaymasterHubErrors.InvalidOrgId.selector); hub.registerOrg(bytes32(0), ADMIN_HAT, OPERATOR_HAT); } @@ -2272,7 +2273,7 @@ contract PaymasterHubSolidarityTest is Test { _buildPaymasterData(ORG_ALPHA, SUBJECT_TYPE_ACCOUNT, bytes32(uint256(uint160(user1))), RULE_ID_GENERIC); PackedUserOperation memory userOp = _buildUserOp(user1, innerCall, pmData); vm.prank(address(entryPoint)); - vm.expectRevert(PaymasterHub.InsufficientFunds.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientFunds.selector); hub.validatePaymasterUserOp(userOp, keccak256("hash"), 2 ether); } } @@ -2340,7 +2341,7 @@ contract PaymasterHubBalanceCheckTest is Test { function testCheckOrgBalance_ZeroDeposit_PostGrace_Reverts() public { vm.warp(block.timestamp + 91 days); - vm.expectRevert(PaymasterHub.InsufficientOrgBalance.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientOrgBalance.selector); hub.exposed_checkOrgBalance(ORG_A, 0.001 ether); } @@ -2349,7 +2350,7 @@ contract PaymasterHubBalanceCheckTest is Test { hub.pauseSolidarityDistribution(); // Even in grace, paused distribution means org must cover 100% from deposits - vm.expectRevert(PaymasterHub.InsufficientOrgBalance.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientOrgBalance.selector); hub.exposed_checkOrgBalance(ORG_A, 0.001 ether); } @@ -2415,7 +2416,7 @@ contract PaymasterHubBalanceCheckTest is Test { if (depositCovers) { hub.exposed_checkOrgBalance(ORG_A, maxCost); } else { - vm.expectRevert(PaymasterHub.InsufficientOrgBalance.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientOrgBalance.selector); hub.exposed_checkOrgBalance(ORG_A, maxCost); } } else if (depositCovers) { @@ -2428,7 +2429,7 @@ contract PaymasterHubBalanceCheckTest is Test { hub.exposed_checkOrgBalance(ORG_A, maxCost); } else { // Zero deposit, post grace — should revert - vm.expectRevert(PaymasterHub.InsufficientOrgBalance.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientOrgBalance.selector); hub.exposed_checkOrgBalance(ORG_A, maxCost); } } @@ -2443,7 +2444,7 @@ contract PaymasterHubBalanceCheckTest is Test { function testCheckSolidarityAccess_InGrace_ExceedsMaxSpend_Reverts() public { PaymasterHub.GracePeriodConfig memory grace = hub.getGracePeriodConfig(); - vm.expectRevert(PaymasterHub.GracePeriodSpendLimitReached.selector); + vm.expectRevert(PaymasterHubErrors.GracePeriodSpendLimitReached.selector); hub.exposed_checkSolidarityAccess(ORG_A, grace.maxSpendDuringGrace + 1); } @@ -2451,7 +2452,7 @@ contract PaymasterHubBalanceCheckTest is Test { vm.warp(block.timestamp + 91 days); // No deposit — below min required - vm.expectRevert(PaymasterHub.InsufficientDepositForSolidarity.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientDepositForSolidarity.selector); hub.exposed_checkSolidarityAccess(ORG_A, 0.001 ether); } @@ -2468,7 +2469,7 @@ contract PaymasterHubBalanceCheckTest is Test { vm.prank(poaManager); hub.setBanFromSolidarity(ORG_A, true); - vm.expectRevert(PaymasterHub.OrgIsBanned.selector); + vm.expectRevert(PaymasterHubErrors.OrgIsBanned.selector); hub.exposed_checkSolidarityAccess(ORG_A, 0.001 ether); } @@ -2492,7 +2493,7 @@ contract PaymasterHubBalanceCheckTest is Test { vm.warp(block.timestamp + 91 days); // Balance check fails first (same order as validatePaymasterUserOp) - vm.expectRevert(PaymasterHub.InsufficientOrgBalance.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientOrgBalance.selector); hub.exposed_checkOrgBalance(ORG_A, 0.001 ether); } @@ -2520,7 +2521,7 @@ contract PaymasterHubBalanceCheckTest is Test { // At exact boundary: block.timestamp == graceEndTime, NOT < graceEndTime // So inInitialGrace is false — should revert - vm.expectRevert(PaymasterHub.InsufficientOrgBalance.selector); + vm.expectRevert(PaymasterHubErrors.InsufficientOrgBalance.selector); hub.exposed_checkOrgBalance(ORG_A, 0.001 ether); } diff --git a/test/PaymasterLibsUnit.t.sol b/test/PaymasterLibsUnit.t.sol new file mode 100644 index 0000000..51e9784 --- /dev/null +++ b/test/PaymasterLibsUnit.t.sol @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {PaymasterGraceLib} from "../src/libs/PaymasterGraceLib.sol"; +import {PaymasterPostOpLib} from "../src/libs/PaymasterPostOpLib.sol"; +import {PaymasterCalldataLib} from "../src/libs/PaymasterCalldataLib.sol"; + +// ============ Wrapper Contracts ============ +// Libraries with internal functions need wrappers so tests can call them externally. + +contract GraceLibWrapper { + function isInGracePeriod(uint40 registeredAt, uint32 initialGraceDays) external view returns (bool) { + return PaymasterGraceLib.isInGracePeriod(registeredAt, initialGraceDays); + } + + function solidarityFee(uint256 actualGasCost, uint16 feePercentageBps, uint40 registeredAt, uint32 initialGraceDays) + external + view + returns (uint256) + { + return PaymasterGraceLib.solidarityFee(actualGasCost, feePercentageBps, registeredAt, initialGraceDays); + } + + function calculateMatchAllowance(uint256 deposited, uint256 minDeposit) external pure returns (uint256) { + return PaymasterGraceLib.calculateMatchAllowance(deposited, minDeposit); + } +} + +contract PostOpLibWrapper { + function adjustBudget(uint128 usedInEpoch, uint256 reserved, uint256 actual) external pure returns (uint128) { + return PaymasterPostOpLib.adjustBudget(usedInEpoch, reserved, actual); + } + + function clampedDeduction(uint128 balance, uint256 cost) + external + pure + returns (uint128 newBalance, uint128 deducted) + { + return PaymasterPostOpLib.clampedDeduction(balance, cost); + } +} + +contract CalldataLibWrapper { + function parseExecuteCall(bytes calldata callData, address expectedTarget) + external + pure + returns (bool valid, bytes4 innerSelector) + { + return PaymasterCalldataLib.parseExecuteCall(callData, expectedTarget); + } +} + +// ============ PaymasterGraceLib Tests ============ + +contract PaymasterGraceLibTest is Test { + GraceLibWrapper lib; + + // Use a realistic timestamp so arithmetic doesn't underflow + uint256 constant START_TIME = 1_700_000_000; // Nov 2023 + + function setUp() public { + lib = new GraceLibWrapper(); + vm.warp(START_TIME); + } + + // -------- isInGracePeriod -------- + + function testIsInGracePeriod_DuringGrace() public view { + uint40 registeredAt = uint40(block.timestamp); + assertTrue(lib.isInGracePeriod(registeredAt, 90)); + } + + function testIsInGracePeriod_ExactlyAtExpiry() public view { + uint40 registeredAt = uint40(block.timestamp - 90 days); + // At exactly the boundary, grace has ended (not strictly less than) + assertFalse(lib.isInGracePeriod(registeredAt, 90)); + } + + function testIsInGracePeriod_OneSecondBeforeExpiry() public view { + uint40 registeredAt = uint40(block.timestamp - 90 days + 1); + assertTrue(lib.isInGracePeriod(registeredAt, 90)); + } + + function testIsInGracePeriod_AfterExpiry() public view { + uint40 registeredAt = uint40(block.timestamp - 91 days); + assertFalse(lib.isInGracePeriod(registeredAt, 90)); + } + + function testIsInGracePeriod_ZeroGraceDays() public view { + uint40 registeredAt = uint40(block.timestamp); + // 0 grace days means grace ends immediately at registeredAt + assertFalse(lib.isInGracePeriod(registeredAt, 0)); + } + + function testIsInGracePeriod_ZeroRegisteredAt() public view { + // registeredAt=0 with 90 grace days: graceEnd = 90 days = 7,776,000 + // At realistic timestamp this is long expired + assertFalse(lib.isInGracePeriod(0, 90)); + } + + function testIsInGracePeriod_ZeroBoth() public view { + // registeredAt=0 + 0 days = 0, block.timestamp > 0 + assertFalse(lib.isInGracePeriod(0, 0)); + } + + function testIsInGracePeriod_MaxGraceDays() public view { + uint40 registeredAt = uint40(block.timestamp); + // Max uint32 days = ~11.7 million years, should be in grace + assertTrue(lib.isInGracePeriod(registeredAt, type(uint32).max)); + } + + function testFuzz_IsInGracePeriod(uint40 registeredAt, uint32 graceDays) public view { + // Skip if registeredAt is in the future (unrealistic) + vm.assume(registeredAt <= block.timestamp); + bool result = lib.isInGracePeriod(registeredAt, graceDays); + uint256 graceEnd = uint256(registeredAt) + uint256(graceDays) * 1 days; + assertEq(result, block.timestamp < graceEnd); + } + + // -------- solidarityFee -------- + + function testSolidarityFee_DuringGraceIsZero() public view { + uint40 registeredAt = uint40(block.timestamp); + uint256 fee = lib.solidarityFee(1 ether, 100, registeredAt, 90); + assertEq(fee, 0); + } + + function testSolidarityFee_AfterGraceCalculatesFee() public view { + uint40 registeredAt = uint40(block.timestamp - 91 days); + // 1 ether * 100 bps / 10000 = 0.01 ether + uint256 fee = lib.solidarityFee(1 ether, 100, registeredAt, 90); + assertEq(fee, 0.01 ether); + } + + function testSolidarityFee_ZeroBpsIsZero() public view { + uint40 registeredAt = uint40(block.timestamp - 91 days); + uint256 fee = lib.solidarityFee(1 ether, 0, registeredAt, 90); + assertEq(fee, 0); + } + + function testSolidarityFee_ZeroCostIsZero() public view { + uint40 registeredAt = uint40(block.timestamp - 91 days); + uint256 fee = lib.solidarityFee(0, 100, registeredAt, 90); + assertEq(fee, 0); + } + + function testSolidarityFee_MaxBps() public view { + uint40 registeredAt = uint40(block.timestamp - 91 days); + // 10000 bps = 100% + uint256 fee = lib.solidarityFee(1 ether, 10000, registeredAt, 90); + assertEq(fee, 1 ether); + } + + function testSolidarityFee_PrecisionLoss() public view { + uint40 registeredAt = uint40(block.timestamp - 91 days); + // 1 wei * 1 bps / 10000 = 0 (rounds down) + uint256 fee = lib.solidarityFee(1, 1, registeredAt, 90); + assertEq(fee, 0); + } + + function testFuzz_SolidarityFee_AfterGrace(uint128 gasCost, uint16 bps) public view { + vm.assume(bps <= 10000); + uint40 registeredAt = uint40(block.timestamp - 91 days); + uint256 fee = lib.solidarityFee(gasCost, bps, registeredAt, 90); + assertEq(fee, (uint256(gasCost) * uint256(bps)) / 10000); + } + + // -------- calculateMatchAllowance -------- + + function testMatchAllowance_ZeroMinDeposit() public pure { + assertEq(PaymasterGraceLib.calculateMatchAllowance(1 ether, 0), 0); + } + + function testMatchAllowance_BelowMinDeposit() public pure { + assertEq(PaymasterGraceLib.calculateMatchAllowance(0.002 ether, 0.003 ether), 0); + } + + function testMatchAllowance_ExactlyMinDeposit() public pure { + // Tier 1: deposit == minDeposit → 2x match + assertEq(PaymasterGraceLib.calculateMatchAllowance(0.003 ether, 0.003 ether), 0.006 ether); + } + + function testMatchAllowance_BetweenMinAndTwoX() public pure { + // Tier 2: minDeposit < deposit <= 2x minDeposit + // First tier: 0.003 * 2 = 0.006; Second tier: (0.005 - 0.003) = 0.002 + // Total = 0.008 + assertEq(PaymasterGraceLib.calculateMatchAllowance(0.005 ether, 0.003 ether), 0.008 ether); + } + + function testMatchAllowance_ExactlyTwoXMin() public pure { + // Tier 2 boundary: deposit == 2x minDeposit + // First tier: 0.003 * 2 = 0.006; Second tier: 0.003 + // Total = 0.009 + assertEq(PaymasterGraceLib.calculateMatchAllowance(0.006 ether, 0.003 ether), 0.009 ether); + } + + function testMatchAllowance_BetweenTwoXAndFiveX() public pure { + // Tier 3: 2x < deposit < 5x → capped at firstTier + secondTier + // First tier: 0.003 * 2 = 0.006; Second tier: 0.003 + // Total = 0.009 (same cap) + assertEq(PaymasterGraceLib.calculateMatchAllowance(0.01 ether, 0.003 ether), 0.009 ether); + } + + function testMatchAllowance_ExactlyFiveXMin() public pure { + // Tier 4: deposit >= 5x → self-sufficient, no match + assertEq(PaymasterGraceLib.calculateMatchAllowance(0.015 ether, 0.003 ether), 0); + } + + function testMatchAllowance_AboveFiveXMin() public pure { + assertEq(PaymasterGraceLib.calculateMatchAllowance(1 ether, 0.003 ether), 0); + } + + function testMatchAllowance_ZeroDeposit() public pure { + assertEq(PaymasterGraceLib.calculateMatchAllowance(0, 0.003 ether), 0); + } + + function testFuzz_MatchAllowance_TierBoundaries(uint128 minDeposit) public pure { + vm.assume(minDeposit > 0 && minDeposit < type(uint128).max / 5); + + uint256 min = uint256(minDeposit); + + // Below min → 0 + assertEq(PaymasterGraceLib.calculateMatchAllowance(min - 1, min), 0); + + // Exactly min → 2x + assertEq(PaymasterGraceLib.calculateMatchAllowance(min, min), min * 2); + + // Exactly 2x min → 3x min (first tier 2x + second tier 1x) + assertEq(PaymasterGraceLib.calculateMatchAllowance(min * 2, min), min * 3); + + // Between 2x and 5x → capped at 3x min + assertEq(PaymasterGraceLib.calculateMatchAllowance(min * 3, min), min * 3); + + // Exactly 5x → no match + assertEq(PaymasterGraceLib.calculateMatchAllowance(min * 5, min), 0); + } + + function testFuzz_MatchAllowance_NeverExceedsCap(uint128 deposited, uint128 minDeposit) public pure { + vm.assume(minDeposit > 0); + uint256 result = PaymasterGraceLib.calculateMatchAllowance(deposited, minDeposit); + // Match can never exceed 3x the minimum deposit + assertTrue(result <= uint256(minDeposit) * 3); + } +} + +// ============ PaymasterPostOpLib Tests ============ + +contract PaymasterPostOpLibTest is Test { + PostOpLibWrapper lib; + + function setUp() public { + lib = new PostOpLibWrapper(); + } + + // -------- adjustBudget -------- + + function testAdjustBudget_BasicReplacement() public view { + // Reserved 1000, actual 700 → usage drops by 300 + uint128 result = lib.adjustBudget(5000, 1000, 700); + assertEq(result, 4700); // 5000 - 1000 + 700 + } + + function testAdjustBudget_ActualEqualsReserved() public view { + // No change when actual == reserved + uint128 result = lib.adjustBudget(5000, 1000, 1000); + assertEq(result, 5000); + } + + function testAdjustBudget_ActualIsZero() public view { + // UserOp used no gas (edge case) + uint128 result = lib.adjustBudget(5000, 1000, 0); + assertEq(result, 4000); // 5000 - 1000 + 0 + } + + function testAdjustBudget_ExactlyReservedEqualsUsed() public view { + // All usage was from this single reservation + uint128 result = lib.adjustBudget(1000, 1000, 500); + assertEq(result, 500); + } + + function testFuzz_AdjustBudget(uint128 usedInEpoch, uint128 reserved, uint128 actual) public view { + // reserved <= usedInEpoch (reservation was included in used) + // actual <= reserved (ERC-4337 guarantee) + vm.assume(reserved <= usedInEpoch); + vm.assume(actual <= reserved); + + uint128 result = lib.adjustBudget(usedInEpoch, reserved, actual); + assertEq(result, usedInEpoch - reserved + actual); + // Result should always be <= original + assertTrue(result <= usedInEpoch); + } + + // -------- clampedDeduction -------- + + function testClampedDeduction_SufficientBalance() public view { + (uint128 newBal, uint128 deducted) = lib.clampedDeduction(1 ether, 0.5 ether); + assertEq(newBal, 0.5 ether); + assertEq(deducted, 0.5 ether); + } + + function testClampedDeduction_ExactBalance() public view { + (uint128 newBal, uint128 deducted) = lib.clampedDeduction(1 ether, 1 ether); + assertEq(newBal, 0); + assertEq(deducted, 1 ether); + } + + function testClampedDeduction_InsufficientBalance() public view { + // Deduction clamped to available balance + (uint128 newBal, uint128 deducted) = lib.clampedDeduction(0.3 ether, 1 ether); + assertEq(newBal, 0); + assertEq(deducted, 0.3 ether); + } + + function testClampedDeduction_ZeroBalance() public view { + (uint128 newBal, uint128 deducted) = lib.clampedDeduction(0, 1 ether); + assertEq(newBal, 0); + assertEq(deducted, 0); + } + + function testClampedDeduction_ZeroCost() public view { + (uint128 newBal, uint128 deducted) = lib.clampedDeduction(1 ether, 0); + assertEq(newBal, 1 ether); + assertEq(deducted, 0); + } + + function testFuzz_ClampedDeduction_NeverUnderflows(uint128 balance, uint128 cost) public view { + (uint128 newBal, uint128 deducted) = lib.clampedDeduction(balance, cost); + // Balance never goes negative + assertTrue(newBal <= balance); + // Deducted never exceeds balance or cost + assertTrue(deducted <= balance); + assertTrue(deducted <= cost); + // Conservation: newBal + deducted == balance + assertEq(uint256(newBal) + uint256(deducted), uint256(balance)); + } +} + +// ============ PaymasterCalldataLib Tests ============ + +contract PaymasterCalldataLibTest is Test { + CalldataLibWrapper lib; + + address constant TARGET = address(0xBEEF); + bytes4 constant EXECUTE_SELECTOR = 0xb61d27f6; + + function setUp() public { + lib = new CalldataLibWrapper(); + } + + // -------- parseExecuteCall -------- + + function testParseExecute_ValidCallWithInnerSelector() public view { + // Build valid execute(address,uint256,bytes) calldata + bytes memory innerData = abi.encodeWithSelector(bytes4(0xdeadbeef), uint256(42)); + bytes memory callData = abi.encodeWithSelector(EXECUTE_SELECTOR, TARGET, uint256(0), innerData); + + (bool valid, bytes4 innerSelector) = lib.parseExecuteCall(callData, TARGET); + assertTrue(valid); + assertEq(innerSelector, bytes4(0xdeadbeef)); + } + + function testParseExecute_ValidCallEmptyInnerData() public view { + // execute(target, 0, "") — empty inner data + bytes memory callData = abi.encodeWithSelector(EXECUTE_SELECTOR, TARGET, uint256(0), bytes("")); + + (bool valid, bytes4 innerSelector) = lib.parseExecuteCall(callData, TARGET); + assertTrue(valid); + assertEq(innerSelector, bytes4(0)); // No inner selector + } + + function testParseExecute_WrongOuterSelector() public view { + bytes memory callData = abi.encodeWithSelector(bytes4(0x12345678), TARGET, uint256(0), bytes("")); + + (bool valid,) = lib.parseExecuteCall(callData, TARGET); + assertFalse(valid); + } + + function testParseExecute_TargetMismatch() public view { + bytes memory callData = abi.encodeWithSelector(EXECUTE_SELECTOR, address(0xDEAD), uint256(0), bytes("")); + + (bool valid,) = lib.parseExecuteCall(callData, TARGET); + assertFalse(valid); + } + + function testParseExecute_NonZeroValue() public view { + // value must be 0 for sponsored calls + bytes memory callData = abi.encodeWithSelector(EXECUTE_SELECTOR, TARGET, uint256(1), bytes("")); + + (bool valid,) = lib.parseExecuteCall(callData, TARGET); + assertFalse(valid); + } + + function testParseExecute_TooShortCalldata_LessThan4() public view { + (bool valid,) = lib.parseExecuteCall(hex"b61d27", TARGET); + assertFalse(valid); + } + + function testParseExecute_TooShortCalldata_LessThan0x64() public view { + // 4-byte selector + some data but not enough for full execute args + bytes memory callData = abi.encodePacked(EXECUTE_SELECTOR, bytes32(0)); + (bool valid,) = lib.parseExecuteCall(callData, TARGET); + assertFalse(valid); + } + + function testParseExecute_EmptyCalldata() public view { + (bool valid,) = lib.parseExecuteCall(hex"", TARGET); + assertFalse(valid); + } + + function testParseExecute_RegisterAccountSelector() public view { + // This is the real-world scenario: execute(registry, 0, registerAccount(bytes32,bytes)) + bytes4 registerAccount = bytes4(0xbff6de20); + bytes memory innerData = abi.encodeWithSelector(registerAccount, bytes32(uint256(1)), bytes("pubkey")); + bytes memory callData = abi.encodeWithSelector(EXECUTE_SELECTOR, TARGET, uint256(0), innerData); + + (bool valid, bytes4 innerSelector) = lib.parseExecuteCall(callData, TARGET); + assertTrue(valid); + assertEq(innerSelector, registerAccount); + } + + function testFuzz_ParseExecute_CorrectTarget(address target) public view { + vm.assume(target != address(0)); + bytes memory innerData = abi.encodeWithSelector(bytes4(0xaabbccdd)); + bytes memory callData = abi.encodeWithSelector(EXECUTE_SELECTOR, target, uint256(0), innerData); + + (bool valid,) = lib.parseExecuteCall(callData, target); + assertTrue(valid); + + // Wrong target should fail + if (target != address(1)) { + (bool valid2,) = lib.parseExecuteCall(callData, address(1)); + assertFalse(valid2); + } + } +}