Skip to content
Open
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
12 changes: 12 additions & 0 deletions docs/HYBRID_VOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,18 @@ Hats enables dynamic, attestation-based roles without redeploying contracts.
| Whale dominance | Quadratic voting dampens large holders |
| Empty proposal spam | Creator hat requirement |
| Mid-vote manipulation | Class configuration snapshots |
| Zero-weight Sybils | A voter with no power in any class is rejected (`Unauthorized`) — cannot pad turnout/quorum |

### ERC20_BAL weight is read **live** — safe-configuration requirements

⚠️ **Important nuance.** "Snapshot Isolation" above freezes the *class configuration* (strategy, slice, asset, hat gating) at proposal creation — it does **not** snapshot token *balances*. An `ERC20_BAL` class reads `IERC20(asset).balanceOf(voter)` at the moment `vote()` is called, not a `getPastVotes` checkpoint. This is intentional and safe **only when the org is configured as follows**:

1. **Use the soulbound ParticipationToken as the `ERC20_BAL` asset.** PT's `transfer`/`transferFrom`/`approve`/`delegate` all revert, so the classic *transfer-and-revote* / flash-loan inflation is structurally impossible — the same tokens cannot be moved between addresses to be counted twice within one proposal.
2. **Keep PT mint authority narrow.** Because weight is live, anyone who can mint PT mid-proposal can raise their own vote weight. Mint is gated to the TaskManager / EducationHub / approvers, and an approver **cannot self-approve** their own token request. Do **not** grant broad project `SELF_REVIEW` (which lets a contributor self-complete tasks and mint themselves PT) to members you would not trust to inflate a vote. Use designated project leads and fixed project budgets.

🚫 **Do not** configure an `ERC20_BAL` class over a freely **transferable** ERC20 unless that token is checkpoint-based and you have added an explicit `getPastVotes` snapshot — with a plain transferable token, live `balanceOf` allows transfer-and-revote across colluding hat-wearers.

These boundaries are proven by `test/HybridVotingSafeConfig.t.sol`: soulbound transfers revert, unprivileged actors cannot self-mint, a decided outcome cannot be flipped without mint authority, and a 128k-call fuzzing invariant shows unprivileged voting/transfer/mint activity never changes PT supply or voter balances. The same file's `test_Boundary_MintAuthorityIsTheLever` demonstrates, executably, the inflation that an *unsafe* config (broad mint authority) would expose.

---

Expand Down
3 changes: 2 additions & 1 deletion script/deploy/DeployInfrastructure.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,10 @@ contract DeployInfrastructure is Script {
.adminCall(
paymasterHub,
abi.encodeWithSignature(
"setOnboardingConfig(uint128,uint128,bool,address)",
"setOnboardingConfig(uint128,uint128,uint8,bool,address)",
uint128(0.01 ether),
uint128(1000),
uint8(3),
true,
globalAccountRegistry
)
Expand Down
6 changes: 4 additions & 2 deletions script/deploy/MainDeploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,10 @@ contract DeployHomeChain is DeployHelper {
.adminCall(
infra.paymasterHub,
abi.encodeWithSignature(
"setOnboardingConfig(uint128,uint128,bool,address)",
"setOnboardingConfig(uint128,uint128,uint8,bool,address)",
uint128(0.01 ether),
uint128(1000),
uint8(3),
true,
infra.globalAccountRegistry
)
Expand Down Expand Up @@ -667,9 +668,10 @@ contract DeploySatellite is DeployHelper {
pm.adminCall(
infra.paymasterHub,
abi.encodeWithSignature(
"setOnboardingConfig(uint128,uint128,bool,address)",
"setOnboardingConfig(uint128,uint128,uint8,bool,address)",
uint128(0.01 ether),
uint128(1000),
uint8(3),
true,
infra.globalAccountRegistry
)
Expand Down
8 changes: 2 additions & 6 deletions script/upgrades/UpgradeEligibilitySuperAdminLockdown.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,7 @@ contract DryRun_GnosisUpgrade is Script {

vm.prank(KUBI_EXECUTOR);
(bool okExecOn,) = KUBI_ELIG_MODULE.call(
abi.encodeWithSignature(
"setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, true, true
)
abi.encodeWithSignature("setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, true, true)
);
require(okExecOn, "DryRun: superAdmin setWearerEligibility(true,true) reverted");
{
Expand All @@ -328,9 +326,7 @@ contract DryRun_GnosisUpgrade is Script {

vm.prank(KUBI_EXECUTOR);
(bool okExecOff,) = KUBI_ELIG_MODULE.call(
abi.encodeWithSignature(
"setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, false, false
)
abi.encodeWithSignature("setWearerEligibility(address,uint256,bool,bool)", probe, MEMBER_HAT, false, false)
);
require(okExecOff, "DryRun: superAdmin setWearerEligibility(false,false) reverted");
{
Expand Down
223 changes: 223 additions & 0 deletions script/upgrades/UpgradePaymasterOnboardingCap.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import "forge-std/console.sol";
import {PaymasterHub} from "../../src/PaymasterHub.sol";
import {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol";
import {PoaManager} from "../../src/PoaManager.sol";
import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol";

// ─────────────────────────────────────────────────────────────────────────────
// UpgradePaymasterOnboardingCap (Audit H4)
//
// Adds a per-account lifetime cap on solidarity-funded onboarding sponsorship to
// PaymasterHub (new `OnboardingConfig.maxOnboardingsPerAccount`, a new
// `poa.paymasterhub.onboarding.counts` storage mapping, enforcement in
// `_validateOnboardingEligibility`, and the new `setOnboardingConfig` arg).
//
// Storage safety: the new struct field is appended (packs into the slot holding
// `accountRegistry`) and the counts mapping lives at a fresh ERC-7201 slot, so the
// upgrade preserves all existing onboarding state. `maxOnboardingsPerAccount == 0`
// means UNLIMITED, so an already-deployed hub (where the appended field reads 0
// post-upgrade) keeps sponsoring onboarding until an admin sets a cap — Step3/Step4
// set the cap to activate the protection.
//
// Validated: Sim_GnosisUpgrade PASSES under FOUNDRY_PROFILE=production via `forge script`
// (scoped compile of this script + its src deps) against a live Gnosis fork — i.e. a real
// production-profile sim, broadcast-representative bytecode. Run it with:
// FOUNDRY_PROFILE=production forge script \
// script/upgrades/UpgradePaymasterOnboardingCap.s.sol:Sim_GnosisUpgrade --fork-url gnosis
// Note: a full-project `FOUNDRY_PROFILE=production forge build` currently fails with a
// Stack-too-deep in an unrelated NON-deployable test/script file (all of src/ compiles
// clean under production, and CI gates on the default profile), so it does not affect this
// scoped broadcast — but if you want a clean full production build, that file needs a fix.
//
// Version: v18 (probed free on Gnosis + Arbitrum, both registry and CREATE2, 2026-06-09).
// ─────────────────────────────────────────────────────────────────────────────

// Shared constants (same addresses as UpgradePaymasterGraceFix)
address constant DD = 0x4aC8B5ebEb9D8C3dE3180ddF381D552d59e8835a;
address constant HUB = 0xB72840B343654eAfb2CFf7acC4Fc6b59E6c3CC71; // PoaManagerHub (Arbitrum)
address constant ARB_PAYMASTER = 0xD6659bCaFAdCB9CC2F57B7aE923c7F1Ca4438a11;
address constant GNOSIS_PAYMASTER = 0xdEf1038C297493c0b5f82F0CDB49e929B53B4108;
address constant GNOSIS_POA_MANAGER = 0x794fD39e75140ee1545B1B022E5486B7c863789b;
uint256 constant HYPERLANE_FEE = 0.005 ether;
string constant VERSION = "v18";
uint8 constant MAX_ONBOARDINGS_PER_ACCOUNT = 3; // register + profile + 1 retry

/// @title Step1_DeployImplOnGnosis — deploy PaymasterHub v18 impl on Gnosis via DD.
/// Usage: FOUNDRY_PROFILE=production forge script .../UpgradePaymasterOnboardingCap.s.sol:Step1_DeployImplOnGnosis --rpc-url gnosis --broadcast --slow --optimizer-runs 200
contract Step1_DeployImplOnGnosis is Script {
function run() public {
uint256 deployerKey = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY"));
DeterministicDeployer dd = DeterministicDeployer(DD);

bytes32 salt = dd.computeSalt("PaymasterHub", VERSION);
address predicted = dd.computeAddress(salt);
console.log("Predicted PaymasterHub v18 impl:", predicted);
if (predicted.code.length > 0) {
console.log("Already deployed. Skipping.");
return;
}
vm.startBroadcast(deployerKey);
address deployed = dd.deploy(salt, type(PaymasterHub).creationCode);
vm.stopBroadcast();
require(deployed == predicted, "Address mismatch");
console.log("Deployed:", deployed);
console.log("Next: Step2_UpgradeFromArbitrum on Arbitrum");
}
}

/// @title Step2_UpgradeFromArbitrum — deploy on Arbitrum via DD + upgrade beacon cross-chain.
/// Usage: FOUNDRY_PROFILE=production forge script .../:Step2_UpgradeFromArbitrum --rpc-url arbitrum --broadcast --slow --optimizer-runs 200
contract Step2_UpgradeFromArbitrum is Script {
function run() public {
uint256 deployerKey = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY"));
address deployer = vm.addr(deployerKey);
PoaManagerHub hub = PoaManagerHub(payable(HUB));
DeterministicDeployer dd = DeterministicDeployer(DD);

require(hub.owner() == deployer, "Deployer must own Hub");
require(!hub.paused(), "Hub is paused");

bytes32 salt = dd.computeSalt("PaymasterHub", VERSION);
address predicted = dd.computeAddress(salt);

vm.startBroadcast(deployerKey);
if (predicted.code.length == 0) {
dd.deploy(salt, type(PaymasterHub).creationCode);
console.log("Deployed v18 on Arbitrum");
}
hub.upgradeBeaconCrossChain{value: HYPERLANE_FEE}("PaymasterHub", predicted, VERSION);
console.log("Beacon upgraded cross-chain to v18");
vm.stopBroadcast();
console.log("Wait ~5 min for Hyperlane relay, then run Step3 (Gnosis) and Step4 (Arbitrum) to set the cap.");
}
}

/// @title Step3_SetCapGnosis — activate the per-account cap on the Gnosis paymaster (preserves other config).
/// Usage: FOUNDRY_PROFILE=production forge script .../:Step3_SetCapGnosis --rpc-url gnosis --broadcast --slow --optimizer-runs 200
contract Step3_SetCapGnosis is Script {
function run() public {
uint256 deployerKey = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY"));
PaymasterHub pm = PaymasterHub(payable(GNOSIS_PAYMASTER));
PaymasterHub.OnboardingConfig memory c = pm.getOnboardingConfig();
console.log("Gnosis onboarding pre-set: maxOnboardingsPerAccount =", c.maxOnboardingsPerAccount);

vm.startBroadcast(deployerKey);
// Re-set onboarding config, preserving all existing values and only adding the cap.
PoaManager(GNOSIS_POA_MANAGER)
.adminCall(
GNOSIS_PAYMASTER,
abi.encodeWithSignature(
"setOnboardingConfig(uint128,uint128,uint8,bool,address)",
c.maxGasPerCreation,
c.dailyCreationLimit,
MAX_ONBOARDINGS_PER_ACCOUNT,
c.enabled,
c.accountRegistry
)
);
vm.stopBroadcast();
console.log("Gnosis onboarding cap set to", MAX_ONBOARDINGS_PER_ACCOUNT);
}
}

/// @title Step4_SetCapArbitrum — activate the per-account cap on the Arbitrum paymaster (preserves other config).
/// Usage: FOUNDRY_PROFILE=production forge script .../:Step4_SetCapArbitrum --rpc-url arbitrum --broadcast --slow --optimizer-runs 200
contract Step4_SetCapArbitrum is Script {
function run() public {
uint256 deployerKey = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY"));
PaymasterHub pm = PaymasterHub(payable(ARB_PAYMASTER));
PaymasterHub.OnboardingConfig memory c = pm.getOnboardingConfig();

vm.startBroadcast(deployerKey);
PoaManagerHub(payable(HUB))
.adminCall(
ARB_PAYMASTER,
abi.encodeWithSignature(
"setOnboardingConfig(uint128,uint128,uint8,bool,address)",
c.maxGasPerCreation,
c.dailyCreationLimit,
MAX_ONBOARDINGS_PER_ACCOUNT,
c.enabled,
c.accountRegistry
)
);
vm.stopBroadcast();
console.log("Arbitrum onboarding cap set to", MAX_ONBOARDINGS_PER_ACCOUNT);
}
}

/**
* @title Sim_GnosisUpgrade
* @notice Fork-simulates the v18 upgrade + cap activation against LIVE Gnosis state and asserts:
* 1. The beacon upgrade preserves existing onboarding storage (maxGasPerCreation / dailyCreationLimit
* / accountRegistry unchanged), and the appended field reads 0 (= unlimited) so onboarding is NOT
* bricked by the upgrade alone.
* 2. After the admin sets the cap, getOnboardingConfig().maxOnboardingsPerAccount == 3.
* Validated PASS under FOUNDRY_PROFILE=production (broadcast-representative bytecode); also runs under
* the default profile. This is the real production-profile sim required before broadcast.
*
* Usage: forge script script/upgrades/UpgradePaymasterOnboardingCap.s.sol:Sim_GnosisUpgrade --fork-url gnosis -vvv
*/
contract Sim_GnosisUpgrade is Script {
function run() public {
PaymasterHub pm = PaymasterHub(payable(GNOSIS_PAYMASTER));
PoaManager poa = PoaManager(GNOSIS_POA_MANAGER);
address owner = poa.owner();
console.log("Gnosis PoaManager owner:", owner);

// Capture live pre-upgrade onboarding config via low-level call decoding the OLD 6-field struct
// (the live impl predates the appended field, so the new ABI getter cannot decode its return).
(bool ok, bytes memory raw) = GNOSIS_PAYMASTER.staticcall(abi.encodeWithSignature("getOnboardingConfig()"));
require(ok, "Sim: pre-upgrade getOnboardingConfig() failed");
(
uint128 preMaxGas,
uint128 preDailyLimit,, // attemptsToday
, // currentDay
bool preEnabled,
address preRegistry
) = abi.decode(raw, (uint128, uint128, uint128, uint32, bool, address));
console.log("PRE maxGasPerCreation:", preMaxGas);
console.log("PRE dailyCreationLimit:", preDailyLimit);
console.log("PRE enabled:", preEnabled);

// 1. Deploy the new impl and upgrade the Gnosis beacon (as the PoaManager owner would).
address newImpl = address(new PaymasterHub());
vm.prank(owner);
poa.upgradeBeacon("PaymasterHub", newImpl, VERSION);
require(poa.getCurrentImplementationById(keccak256("PaymasterHub")) == newImpl, "Sim: beacon not upgraded");

// 2. Storage preserved + appended field reads 0 (= unlimited; onboarding not bricked by the upgrade).
PaymasterHub.OnboardingConfig memory mid = pm.getOnboardingConfig();
require(mid.maxGasPerCreation == preMaxGas, "Sim: maxGasPerCreation drifted");
require(mid.dailyCreationLimit == preDailyLimit, "Sim: dailyCreationLimit drifted");
require(mid.accountRegistry == preRegistry, "Sim: accountRegistry drifted");
require(mid.enabled == preEnabled, "Sim: enabled drifted");
require(mid.maxOnboardingsPerAccount == 0, "Sim: appended field should read 0 (unlimited) pre-config");
console.log("OK: upgrade preserved onboarding storage; cap defaults to 0 (unlimited).");

// 3. Activate the cap via the PoaManager admin path and assert it took effect.
vm.prank(owner);
poa.adminCall(
GNOSIS_PAYMASTER,
abi.encodeWithSignature(
"setOnboardingConfig(uint128,uint128,uint8,bool,address)",
mid.maxGasPerCreation,
mid.dailyCreationLimit,
MAX_ONBOARDINGS_PER_ACCOUNT,
mid.enabled,
mid.accountRegistry
)
);
PaymasterHub.OnboardingConfig memory post = pm.getOnboardingConfig();
require(post.maxOnboardingsPerAccount == MAX_ONBOARDINGS_PER_ACCOUNT, "Sim: cap not applied");
require(post.maxGasPerCreation == preMaxGas, "Sim: config clobbered while setting cap");
require(post.dailyCreationLimit == preDailyLimit, "Sim: config clobbered while setting cap");

console.log("PASS: v18 upgrade + cap activation validated against live Gnosis state.");
console.log("POST maxOnboardingsPerAccount:", post.maxOnboardingsPerAccount);
}
}
8 changes: 0 additions & 8 deletions src/EligibilityModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -1125,14 +1125,6 @@ contract EligibilityModule is Initializable, IHatsEligibility {
}
}

function _isEligible(uint8 flags) internal pure returns (bool) {
return (flags & ELIGIBLE_FLAG) != 0;
}

function _hasGoodStanding(uint8 flags) internal pure returns (bool) {
return (flags & STANDING_FLAG) != 0;
}

function _isVouchingEnabled(uint8 flags) internal pure returns (bool) {
return (flags & ENABLED_FLAG) != 0;
}
Expand Down
1 change: 0 additions & 1 deletion src/HybridVoting.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ pragma solidity ^0.8.30;

/* OpenZeppelin v5.3 Upgradeables */
import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IHats} from "lib/hats-protocol/src/Interfaces/IHats.sol";
import {IExecutor} from "./Executor.sol";
import {HatManager} from "./libs/HatManager.sol";
Expand Down
1 change: 0 additions & 1 deletion src/OrgDeployer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {IHats} from "@hats-protocol/src/Interfaces/IHats.sol";

import "./OrgRegistry.sol";
import {IHybridVotingInit} from "./libs/ModuleDeploymentLib.sol";
import {RoleResolver} from "./libs/RoleResolver.sol";
import {GovernanceFactory, IHatsTreeSetup} from "./factories/GovernanceFactory.sol";
import {AccessFactory} from "./factories/AccessFactory.sol";
import {ModulesFactory} from "./factories/ModulesFactory.sol";
Expand Down
1 change: 0 additions & 1 deletion src/ParticipationToken.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ pragma solidity ^0.8.20;

/*──────────────────── OpenZeppelin v5.3 Upgradeables ─────────────*/
import "@openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol";
import "@openzeppelin-contracts-upgradeable/contracts/utils/ContextUpgradeable.sol";
import "@openzeppelin-contracts-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol";

/*────────────── External Hats interface ─────────────*/
Expand Down
Loading