From 5af7d8d244be543b3d92bc1339a4455cc043960e Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Sat, 7 Feb 2026 16:54:08 -0500 Subject: [PATCH] feat: Add solidarity fund distribution pause/unpause mechanism Enable fee collection without distribution: PaymasterHub now supports pausing solidarity fund distribution to allow fee accumulation before enabling the tier matching and grace period systems. When paused, organizations must fund 100% of transactions from their own deposits while 1% fees still accumulate in the solidarity fund. Key changes: - Add distributionPaused boolean flag to SolidarityFund struct - Implement pauseSolidarityDistribution() and unpauseSolidarityDistribution() (PoaManager only) - Update _checkSolidarityAccess, _updateOrgFinancials, and _checkOrgBalance to respect paused state - Block POA onboarding when distribution is paused - Fix getOrgGraceStatus to return accurate values (solidarity=0) when paused - Add idempotency guards to pause/unpause functions (no-op when already in that state) Tests: - 16 new tests covering initialization, access control, state transitions, idempotency, and view function behavior when paused - All 643 existing tests passing Co-Authored-By: Claude Haiku 4.5 --- src/PaymasterHub.sol | 89 +++++++++-- test/PaymasterHubSolidarity.t.sol | 240 +++++++++++++++++++++++++++++- 2 files changed, 318 insertions(+), 11 deletions(-) diff --git a/src/PaymasterHub.sol b/src/PaymasterHub.sol index bbb4648..3860055 100644 --- a/src/PaymasterHub.sol +++ b/src/PaymasterHub.sol @@ -54,6 +54,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG error InsufficientOrgBalance(); error OrgIsBanned(); error InsufficientFunds(); + error SolidarityDistributionIsPaused(); error VouchExpired(); error VouchAlreadyUsed(); error InvalidVouchSignature(); @@ -119,6 +120,8 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG event VouchUsed(bytes32 indexed orgId, address indexed account, address indexed voucher); event OnboardingConfigUpdated(uint128 maxGasPerCreation, uint128 dailyCreationLimit, bool enabled); event OnboardingAccountCreated(address indexed account, uint256 gasCost); + event SolidarityDistributionPaused(); + event SolidarityDistributionUnpaused(); // ============ Storage Variables ============ /// @custom:storage-location erc7201:poa.paymasterhub.main @@ -166,7 +169,8 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG uint128 balance; // Current solidarity fund balance uint32 numActiveOrgs; // Number of orgs with deposits > 0 uint16 feePercentageBps; // Fee as basis points (100 = 1%) - uint208 reserved; // Padding + bool distributionPaused; // When true, only collect fees, no payouts + uint200 reserved; // Padding } /** @@ -292,9 +296,10 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG main.hats = _hats; main.poaManager = _poaManager; - // Initialize solidarity fund with 1% fee + // Initialize solidarity fund with 1% fee, distribution paused (collection-only mode) SolidarityFund storage solidarity = _getSolidarityStorage(); solidarity.feePercentageBps = 100; // 1% + solidarity.distributionPaused = true; // Initialize grace period with defaults (90 days, 0.01 ETH ~$30 spend, 0.003 ETH ~$10 deposit) GracePeriodConfig storage grace = _getGracePeriodStorage(); @@ -410,6 +415,12 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG * @param maxCost Maximum cost of the operation (for solidarity limit check) */ function _checkSolidarityAccess(bytes32 orgId, uint256 maxCost) internal view { + SolidarityFund storage solidarity = _getSolidarityStorage(); + + // If distribution is paused, skip solidarity checks entirely + // Orgs pay 100% from deposits when distribution is paused + if (solidarity.distributionPaused) return; + mapping(bytes32 => OrgConfig) storage orgs = _getOrgsStorage(); mapping(bytes32 => OrgFinancials) storage financials = _getFinancialsStorage(); GracePeriodConfig storage grace = _getGracePeriodStorage(); @@ -614,14 +625,23 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Calculate total available funds uint256 totalAvailable = uint256(org.deposited) - uint256(org.spent); - // Check if org has enough in deposits to cover this - // Note: solidarity is checked separately in _checkSolidarityAccess - if (org.spent + maxCost > org.deposited) { - // Will need to use solidarity - that's checked elsewhere - // Here we just make sure they haven't overdrawn - if (totalAvailable == 0) { + SolidarityFund storage solidarity = _getSolidarityStorage(); + + if (solidarity.distributionPaused) { + // When distribution is paused, org must cover 100% from deposits + if (totalAvailable < maxCost) { revert InsufficientOrgBalance(); } + } else { + // Check if org has enough in deposits to cover this + // Note: solidarity is checked separately in _checkSolidarityAccess + if (org.spent + maxCost > org.deposited) { + // Will need to use solidarity - that's checked elsewhere + // Here we just make sure they haven't overdrawn + if (totalAvailable == 0) { + revert InsufficientOrgBalance(); + } + } } } @@ -723,9 +743,17 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG GracePeriodConfig storage grace = _getGracePeriodStorage(); SolidarityFund storage solidarity = _getSolidarityStorage(); - // Calculate 1% solidarity fee + // Calculate 1% solidarity fee (always collected, even when distribution is paused) uint256 solidarityFee = (actualGasCost * uint256(solidarity.feePercentageBps)) / 10000; + // If distribution is paused, pay 100% from org deposits, still collect fee + if (solidarity.distributionPaused) { + org.spent += uint128(actualGasCost); + solidarity.balance += uint128(solidarityFee); + emit SolidarityFeeCollected(orgId, solidarityFee); + return; + } + // Check if in initial grace period uint256 graceEndTime = config.registeredAt + (uint256(grace.initialGraceDays) * 1 days); bool inInitialGrace = block.timestamp < graceEndTime; @@ -1063,6 +1091,35 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG solidarity.feePercentageBps = feePercentageBps; } + /** + * @notice Pause solidarity fund distribution (collection-only mode) + * @dev When paused: 1% fees still collected, but no distribution to orgs. + * Orgs must fund 100% of gas costs from their own deposits. + * Only PoaManager can pause/unpause. + */ + function pauseSolidarityDistribution() external { + if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); + SolidarityFund storage solidarity = _getSolidarityStorage(); + if (!solidarity.distributionPaused) { + solidarity.distributionPaused = true; + emit SolidarityDistributionPaused(); + } + } + + /** + * @notice Unpause solidarity fund distribution + * @dev When unpaused: normal grace period + tier matching resumes. + * Only PoaManager can pause/unpause. + */ + function unpauseSolidarityDistribution() external { + if (msg.sender != _getMainStorage().poaManager) revert NotPoaManager(); + SolidarityFund storage solidarity = _getSolidarityStorage(); + if (solidarity.distributionPaused) { + solidarity.distributionPaused = false; + emit SolidarityDistributionUnpaused(); + } + } + /** * @notice Configure POA onboarding for account creation from solidarity fund * @dev Only PoaManager can modify onboarding parameters @@ -1201,6 +1258,7 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG 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]; @@ -1208,6 +1266,14 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG 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; @@ -1403,6 +1469,10 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG // Check onboarding is enabled if (!onboarding.enabled) revert OnboardingDisabled(); + // Onboarding is paid from solidarity fund, so block when distribution is paused + SolidarityFund storage solidarity = _getSolidarityStorage(); + if (solidarity.distributionPaused) revert SolidarityDistributionIsPaused(); + // Check gas cost limit if (maxCost > onboarding.maxGasPerCreation) revert GasTooHigh(); @@ -1413,7 +1483,6 @@ contract PaymasterHub is IPaymaster, Initializable, UUPSUpgradeable, ReentrancyG } // Check solidarity fund has sufficient balance - SolidarityFund storage solidarity = _getSolidarityStorage(); if (solidarity.balance < maxCost) revert InsufficientFunds(); // Subject key for onboarding is based on the account address (natural nonce) diff --git a/test/PaymasterHubSolidarity.t.sol b/test/PaymasterHubSolidarity.t.sol index 7506e92..1b7367e 100644 --- a/test/PaymasterHubSolidarity.t.sol +++ b/test/PaymasterHubSolidarity.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.24; -import {Test, console2} from "forge-std/Test.sol"; +import {Test, Vm, console2} from "forge-std/Test.sol"; import {PaymasterHub} from "../src/PaymasterHub.sol"; import {IPaymaster} from "../src/interfaces/IPaymaster.sol"; import {IEntryPoint} from "../src/interfaces/IEntryPoint.sol"; @@ -291,6 +291,11 @@ contract PaymasterHubSolidarityTest is Test { hub.registerOrg(ORG_ALPHA, ADMIN_HAT, OPERATOR_HAT); hub.registerOrg(ORG_BETA, ADMIN_HAT, OPERATOR_HAT); hub.registerOrg(ORG_GAMMA, ADMIN_HAT, OPERATOR_HAT); + + // Unpause distribution so existing tests work as before + // Pause-specific tests re-pause explicitly + vm.prank(poaManager); + hub.unpauseSolidarityDistribution(); } // ============ Initialization Tests ============ @@ -931,4 +936,237 @@ contract PaymasterHubSolidarityTest is Test { assertFalse(requiresDeposit); assertEq(solidarityLimit, 0.006 ether); // 2x match } + + // ============ Distribution Pause Tests ============ + + event SolidarityDistributionPaused(); + event SolidarityDistributionUnpaused(); + + function testInitializedWithDistributionPaused() public { + // Deploy a fresh hub to test initialization state (setUp unpauses) + PaymasterHub freshImpl = new PaymasterHub(); + bytes memory initData = + abi.encodeWithSelector(PaymasterHub.initialize.selector, address(entryPoint), address(hats), poaManager); + ERC1967Proxy freshProxy = new ERC1967Proxy(address(freshImpl), initData); + PaymasterHub freshHub = PaymasterHub(payable(address(freshProxy))); + + PaymasterHub.SolidarityFund memory solidarity = freshHub.getSolidarityFund(); + assertTrue(solidarity.distributionPaused); + } + + function testPauseDistributionOnlyPoaManager() public { + vm.prank(orgAdmin); + vm.expectRevert(PaymasterHub.NotPoaManager.selector); + hub.pauseSolidarityDistribution(); + } + + function testUnpauseDistributionOnlyPoaManager() public { + vm.prank(orgAdmin); + vm.expectRevert(PaymasterHub.NotPoaManager.selector); + hub.unpauseSolidarityDistribution(); + } + + function testPauseEmitsEvent() public { + // setUp already unpaused, so we can test pausing + vm.prank(poaManager); + vm.expectEmit(false, false, false, true); + emit SolidarityDistributionPaused(); + hub.pauseSolidarityDistribution(); + + PaymasterHub.SolidarityFund memory solidarity = hub.getSolidarityFund(); + assertTrue(solidarity.distributionPaused); + } + + function testUnpauseEmitsEvent() public { + // Pause first + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + vm.prank(poaManager); + vm.expectEmit(false, false, false, true); + emit SolidarityDistributionUnpaused(); + hub.unpauseSolidarityDistribution(); + + PaymasterHub.SolidarityFund memory solidarity = hub.getSolidarityFund(); + assertFalse(solidarity.distributionPaused); + } + + function testPausedSolidarityFundStillAcceptsDeposits() public { + // Pause distribution + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + // Org deposits should still work + vm.prank(user1); + hub.depositForOrg{value: 0.01 ether}(ORG_ALPHA); + + PaymasterHub.OrgFinancials memory fin = hub.getOrgFinancials(ORG_ALPHA); + assertEq(fin.deposited, 0.01 ether); + } + + function testPausedSolidarityFundStillAcceptsDonations() public { + // Pause distribution + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + // Direct donations should still work + vm.prank(user1); + hub.donateToSolidarity{value: 1 ether}(); + + PaymasterHub.SolidarityFund memory solidarity = hub.getSolidarityFund(); + assertEq(solidarity.balance, 1 ether); + } + + function testPauseUnpauseRoundtrip() public { + // Starts unpaused (setUp unpaused it) + PaymasterHub.SolidarityFund memory s1 = hub.getSolidarityFund(); + assertFalse(s1.distributionPaused); + + // Pause + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + PaymasterHub.SolidarityFund memory s2 = hub.getSolidarityFund(); + assertTrue(s2.distributionPaused); + + // Unpause + vm.prank(poaManager); + hub.unpauseSolidarityDistribution(); + PaymasterHub.SolidarityFund memory s3 = hub.getSolidarityFund(); + assertFalse(s3.distributionPaused); + } + + function testPausedFeeConfigStillAdjustable() public { + // Pause distribution + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + // Can adjust fee percentage even when distribution is paused + vm.prank(poaManager); + hub.setSolidarityFee(200); // 2% + + PaymasterHub.SolidarityFund memory solidarity = hub.getSolidarityFund(); + assertEq(solidarity.feePercentageBps, 200); + assertTrue(solidarity.distributionPaused); // Still paused + } + + function testPausedGraceConfigStillAdjustable() public { + // Pause distribution + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + // Can adjust grace period config even when distribution is paused + vm.prank(poaManager); + hub.setGracePeriodConfig(120, 0.02 ether, 0.005 ether); + + PaymasterHub.GracePeriodConfig memory grace = hub.getGracePeriodConfig(); + assertEq(grace.initialGraceDays, 120); + assertEq(grace.maxSpendDuringGrace, 0.02 ether); + assertEq(grace.minDepositRequired, 0.005 ether); + } + + // ============ Pause Idempotency Tests ============ + + function testDoublePauseIsNoOp() public { + // Pause first + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + // Calling pause again should succeed but not emit event + vm.recordLogs(); + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + // Should have emitted zero events (no state change) + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0); + + // State unchanged + PaymasterHub.SolidarityFund memory s = hub.getSolidarityFund(); + assertTrue(s.distributionPaused); + } + + function testDoubleUnpauseIsNoOp() public { + // Already unpaused from setUp + + // Calling unpause again should succeed but not emit event + vm.recordLogs(); + vm.prank(poaManager); + hub.unpauseSolidarityDistribution(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0); + + PaymasterHub.SolidarityFund memory s = hub.getSolidarityFund(); + assertFalse(s.distributionPaused); + } + + // ============ getOrgGraceStatus When Paused Tests ============ + + function testGetOrgGraceStatus_PausedDuringGrace() public { + // Pause distribution + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + // ORG_ALPHA was just registered, so it's in grace period + (bool inGrace, uint128 spendRemaining, bool requiresDeposit, uint256 solidarityLimit) = + hub.getOrgGraceStatus(ORG_ALPHA); + + // inGrace still reflects the time-based status (useful info) + assertTrue(inGrace); + // But solidarity is unavailable + assertEq(spendRemaining, 0); + assertTrue(requiresDeposit); + assertEq(solidarityLimit, 0); + } + + function testGetOrgGraceStatus_PausedPostGrace() public { + // Pause distribution + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + vm.warp(block.timestamp + 91 days); + + vm.prank(user1); + hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); + + (bool inGrace, uint128 spendRemaining, bool requiresDeposit, uint256 solidarityLimit) = + hub.getOrgGraceStatus(ORG_ALPHA); + + assertFalse(inGrace); + assertEq(spendRemaining, 0); + assertTrue(requiresDeposit); + assertEq(solidarityLimit, 0); // No match when paused + } + + 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); + + assertTrue(inGrace); + assertEq(spendRemaining, 0.01 ether); // Full grace spending available + assertFalse(requiresDeposit); // No deposit needed during grace + assertEq(solidarityLimit, 0.01 ether); // Grace limit + } + + function testGetOrgGraceStatus_PauseThenUnpauseRestoresMatch() public { + vm.warp(block.timestamp + 91 days); + + vm.prank(user1); + hub.depositForOrg{value: 0.003 ether}(ORG_ALPHA); + + // Pause — should show no match + vm.prank(poaManager); + hub.pauseSolidarityDistribution(); + + (,,, uint256 limit1) = hub.getOrgGraceStatus(ORG_ALPHA); + assertEq(limit1, 0); + + // Unpause — match restored + vm.prank(poaManager); + hub.unpauseSolidarityDistribution(); + + (,,, uint256 limit2) = hub.getOrgGraceStatus(ORG_ALPHA); + assertEq(limit2, 0.006 ether); // 2x match restored + } }