Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
629 changes: 188 additions & 441 deletions src/PaymasterHub.sol

Large diffs are not rendered by default.

65 changes: 44 additions & 21 deletions src/PaymasterHubLens.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,30 @@ 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;
}

struct OrgFinancials {
uint128 deposited;
uint128 totalDeposited;
uint128 spent;
uint128 solidarityUsedThisPeriod;
uint32 periodStart;
uint224 reserved;
}

struct SolidarityFund {
uint128 balance;
uint32 numActiveOrgs;
uint16 feePercentageBps;
bool distributionPaused;
uint200 reserved;
}

struct GracePeriodConfig {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -112,15 +101,14 @@ 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 ============
IPaymasterHubStorage public immutable hub;

// ============ Constructor ============
constructor(address _hub) {
if (_hub == address(0)) revert ZeroAddress();
if (_hub == address(0)) revert PaymasterHubErrors.ZeroAddress();
hub = IPaymasterHubStorage(_hub);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]);

Expand All @@ -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.)
Expand Down Expand Up @@ -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();
}
}
}
57 changes: 57 additions & 0 deletions src/libs/PaymasterCalldataLib.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
67 changes: 67 additions & 0 deletions src/libs/PaymasterGraceLib.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading