diff --git a/script/fixes/AddEducationHubDecentralPark.s.sol b/script/fixes/AddEducationHubDecentralPark.s.sol new file mode 100644 index 0000000..4eacb4d --- /dev/null +++ b/script/fixes/AddEducationHubDecentralPark.s.sol @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {EducationHub} from "../../src/EducationHub.sol"; +import {SwitchableBeacon} from "../../src/SwitchableBeacon.sol"; +import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {IExecutor} from "../../src/Executor.sol"; +import {HybridVoting} from "../../src/HybridVoting.sol"; + +/* + * ============================================================================ + * Add an EducationHub module to the Decentral Park org (Gnosis) + * ============================================================================ + * + * Decentral Park was deployed WITHOUT an EducationHub (its registry entry is + * 0x0). Adding one is NOT a pure governance config change — there is no + * post-hoc "deploy one module" factory the Executor can call — so it is a + * TWO-PART operation, both performed in a single broadcast here: + * + * PART 1 (plain deploy tx, done by the broadcasting EOA): + * a. new SwitchableBeacon(owner=Executor, mirror=, Mirror) + * b. new BeaconProxy(beacon, initData) where initData calls + * EducationHub.initialize(token, hats, executor, creatorHats, memberHats) + * + * PART 2 (governance proposal created in the same broadcast; executes on pass): + * a. ParticipationToken.setEducationHub(proxy) -> authorizes EduHub to mint + * b. OrgRegistry.registerOrgContract(orgId, EDUCATION_HUB_ID, proxy, beacon, + * true, executor, false) -> makes it a discoverable, + * upgrade-tracked org module + * + * Why PART 2 must be governance: TaskManager-era bootstrap is closed for this + * org, so OrgRegistry.registerOrgContract is Executor-only; and the Executor is + * only reachable through a passed HybridVoting proposal. setEducationHub's first + * call is open, but routing it through the same proposal keeps all mutations of + * existing org contracts under governance and atomic with registration. + * + * Prerequisites verified on Gnosis 2026-05-31: + * - PoaManager 0x794fD3...789b has a global EducationHub beacon + * (0xB48B2d...e2e5, impl 0x00a514...5D40) -> a per-org Mirror beacon can be built. + * - ParticipationToken.educationHub == 0x0 -> first-call setEducationHub is open. + * - OrgRegistry 0x3744b3...A854 has the org; EducationHub slot is unregistered. + * + * ROLE WIRING (defaults — ADJUST IF DESIRED; both are governance-adjustable later + * via EducationHub.setCreatorHatAllowed / setMemberHatAllowed, Executor-gated): + * - creatorHats = [Delegate] -> who can create/edit/remove modules + * - memberHats = [Neighbor, Delegate] -> who can complete modules & earn tokens + * + * Sim-first per CLAUDE.md: stages deploy -> create proposal -> vote -> execute on a + * Gnosis fork (permissive Hats shim for one voter), then asserts the post-state: + * token.educationHub == proxy, registry resolves the proxy, AND a module can be + * created and completed so a learner is minted participation tokens (proves the + * mint authorization wiring). + * + * Usage: + * # Sim (no broadcast — full end-to-end validation on a fork) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/AddEducationHubDecentralPark.s.sol:SimAddEducationHubDecentralPark \ + * --fork-url gnosis -vvv + * + * # Broadcast (deploys the module + creates the wiring/registration proposal) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/AddEducationHubDecentralPark.s.sol:BroadcastAddEducationHubDecentralPark \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env override: + * GRANT_DURATION — voting window in minutes (default 10 = HybridVoting min) + * ============================================================================ + */ + +interface IPoaManagerBeacon { + function getBeaconById(bytes32 typeId) external view returns (address); +} + +interface IParticipationTokenEdu { + function setEducationHub(address eh) external; + function educationHub() external view returns (address); + function balanceOf(address account) external view returns (uint256); +} + +interface IOrgRegistryEdu { + function registerOrgContract( + bytes32 orgId, + bytes32 typeId, + address proxy, + address beacon, + bool autoUp, + address moduleOwner, + bool lastRegister + ) external; + function getOrgContract(bytes32 orgId, bytes32 typeId) external view returns (address); +} + +interface IHatsBalance { + function balanceOf(address user, uint256 hatId) external view returns (uint256); +} + +// Decentral Park (Gnosis) — verified via Poa subgraph + on-chain reads 2026-05-31 +address constant DP_TM = 0x2D9d397A842B8D691ea2A232062CbC8eF8eBbdB7; +address constant DP_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; +address constant DP_EXECUTOR = 0x2A01133997abE2a001862cf0B03B22fe958FA4bC; +address constant DP_HATS = 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137; +address constant DP_TOKEN = 0x1A8b31903C98e514332991a70C00566ec2DeE14e; // ParticipationToken +bytes32 constant DP_ORGID = 0x3721271eb827a52a5adf676136d302efe19c34e72f08e080b07b225eecf27d78; + +address constant POA_MANAGER = 0x794fD39e75140ee1545B1B022E5486B7c863789b; +address constant ORG_REGISTRY = 0x3744b372abc41589226313F2bB1dB3aCAa22A854; + +uint256 constant DELEGATE_HAT = 36180248838698575036480031466286475792781881727149517033480474826113024; +uint256 constant NEIGHBOR_HAT = 36180248838698575132261002770404529440178570924043841009651669962588160; + +bytes32 constant EDUCATION_HUB_ID = keccak256("EducationHub"); +uint32 constant DEFAULT_DURATION_MINUTES = 10; + +/// @dev Permissive Hats stand-in for the sim only: returns "wears it" for a single godmode EOA on +/// every hat. Etched over the org's real Hats contract so one test voter satisfies createProposal, +/// vote, and EducationHub creator/member checks. +contract PermissiveHatsShim { + function balanceOf( + address user, + uint256 /*hatId*/ + ) + external + view + returns (uint256) + { + address g; + assembly { + g := sload(0) + } + return user == g ? 1 : 0; + } + + function balanceOfBatch(address[] calldata users, uint256[] calldata hatIds) + external + view + returns (uint256[] memory bal) + { + require(users.length == hatIds.length, "len mismatch"); + address g; + assembly { + g := sload(0) + } + bal = new uint256[](users.length); + for (uint256 i; i < users.length; ++i) { + if (users[i] == g) bal[i] = 1; + } + } + + function isWearerOfHat( + address user, + uint256 /*hatId*/ + ) + external + view + returns (bool) + { + address g; + assembly { + g := sload(0) + } + return user == g; + } +} + +abstract contract AddEducationHubBase is Script { + /// @dev Default creator hats: who can author/edit/remove learning modules. + function _creatorHats() internal pure returns (uint256[] memory hats) { + hats = new uint256[](1); + hats[0] = DELEGATE_HAT; + } + + /// @dev Default member hats: who can complete modules and earn participation tokens. + function _memberHats() internal pure returns (uint256[] memory hats) { + hats = new uint256[](2); + hats[0] = NEIGHBOR_HAT; + hats[1] = DELEGATE_HAT; + } + + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("GRANT_DURATION", uint256(DEFAULT_DURATION_MINUTES))); + } + + /// @dev PART 1: deploy a per-org Mirror SwitchableBeacon + BeaconProxy and initialize the + /// EducationHub. Returns (proxy, beacon). Owner of the beacon is the org Executor. + function _deployEducationHub() internal returns (address proxy, address beacon) { + address globalBeacon = IPoaManagerBeacon(POA_MANAGER).getBeaconById(EDUCATION_HUB_ID); + require(globalBeacon != address(0), "no global EducationHub beacon on PoaManager"); + + // Mirror mode (autoUpgrade): follows the protocol's EducationHub beacon. staticImpl = 0. + beacon = address(new SwitchableBeacon(DP_EXECUTOR, globalBeacon, address(0), SwitchableBeacon.Mode.Mirror)); + + bytes memory initData = + abi.encodeCall(EducationHub.initialize, (DP_TOKEN, DP_HATS, DP_EXECUTOR, _creatorHats(), _memberHats())); + proxy = address(new BeaconProxy(beacon, initData)); + } + + /// @dev PART 2: the governance batch that wires + registers the freshly-deployed EduHub. + function _buildGovBatch(address proxy, address beacon) internal pure returns (IExecutor.Call[] memory batch) { + batch = new IExecutor.Call[](2); + batch[0] = IExecutor.Call({ + target: DP_TOKEN, value: 0, data: abi.encodeCall(IParticipationTokenEdu.setEducationHub, (proxy)) + }); + batch[1] = IExecutor.Call({ + target: ORG_REGISTRY, + value: 0, + data: abi.encodeCall( + IOrgRegistryEdu.registerOrgContract, + (DP_ORGID, EDUCATION_HUB_ID, proxy, beacon, true, DP_EXECUTOR, false) + ) + }); + } +} + +/* ─────────────────────────── SIM ─────────────────────────── */ + +contract SimAddEducationHubDecentralPark is AddEducationHubBase { + function run() public { + console.log("\n=== Add EducationHub sim: Decentral Park ==="); + + // Pre-state. + require(IParticipationTokenEdu(DP_TOKEN).educationHub() == address(0), "Sim: educationHub already set"); + address globalBeacon = IPoaManagerBeacon(POA_MANAGER).getBeaconById(EDUCATION_HUB_ID); + console.log(" Global EduHub beacon:", globalBeacon); + + // PART 1: deploy + initialize. + (address proxy, address beacon) = _deployEducationHub(); + console.log(" Deployed EduHub proxy: ", proxy); + console.log(" Per-org beacon: ", beacon); + require(proxy.code.length > 0, "Sim: proxy has no code"); + + // Etch a permissive Hats shim so one voter can create the proposal, vote, and later act as + // an EduHub creator/member. + address voter = makeAddr("add-eduhub-sim-voter"); + PermissiveHatsShim shim = new PermissiveHatsShim(); + vm.etch(DP_HATS, address(shim).code); + vm.store(DP_HATS, bytes32(uint256(0)), bytes32(uint256(uint160(voter)))); + + // PART 2: build + create the governance proposal. + IExecutor.Call[] memory batch = _buildGovBatch(proxy, beacon); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + uint32 minutesDuration = 10; + vm.prank(voter); + HybridVoting(DP_HV) + .createProposal( + bytes("Add EducationHub - Decentral Park"), bytes32(0), minutesDuration, 1, batches, new uint256[](0) + ); + uint256 proposalId = HybridVoting(DP_HV).proposalsCount() - 1; + console.log(" Proposal id:", proposalId); + + // Vote 100% for option 0. + uint8[] memory idxs = new uint8[](1); + uint8[] memory weights = new uint8[](1); + idxs[0] = 0; + weights[0] = 100; + vm.prank(voter); + HybridVoting(DP_HV).vote(proposalId, idxs, weights); + + vm.warp(block.timestamp + uint256(minutesDuration) * 60 + 10); + (uint256 winner, bool valid) = HybridVoting(DP_HV).announceWinner(proposalId); + require(valid, "Sim: proposal did not pass"); + console.log(" Winner option:", winner, " valid:", valid); + + // Post-state: wired + registered. + require(IParticipationTokenEdu(DP_TOKEN).educationHub() == proxy, "Sim: token.educationHub != proxy"); + require( + IOrgRegistryEdu(ORG_REGISTRY).getOrgContract(DP_ORGID, EDUCATION_HUB_ID) == proxy, + "Sim: registry did not resolve proxy" + ); + console.log(" token.educationHub wired and registry resolves proxy."); + + // Prove the mint wiring end-to-end: create a module and complete it as the godmode voter. + uint256 payout = 100e18; + vm.prank(voter); + EducationHub(proxy).createModule(bytes("Intro to Decentral Park"), bytes32("content"), payout, 1); + uint256 balBefore = IParticipationTokenEdu(DP_TOKEN).balanceOf(voter); + vm.prank(voter); + EducationHub(proxy).completeModule(0, 1); + uint256 balAfter = IParticipationTokenEdu(DP_TOKEN).balanceOf(voter); + require(balAfter == balBefore + payout, "Sim: module completion did not mint participation tokens"); + console.log(" Module completed; minted participation tokens:", balAfter - balBefore); + + console.log("PASS: Decentral Park EducationHub deployed, wired, registered, and minting end-to-end."); + } +} + +/* ─────────────────────────── BROADCAST ─────────────────────────── */ + +contract BroadcastAddEducationHubDecentralPark is AddEducationHubBase { + function run() public { + uint256 key = vm.envOr("PRIVATE_KEY", uint256(0)); + if (key == 0) key = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Add EducationHub broadcast: Decentral Park ==="); + console.log(" Sender: ", sender); + console.log(" Duration: ", minutesDuration, "minutes"); + + // Sanity: sender must wear a HybridVoting creator hat or createProposal reverts. + uint256[] memory creatorHats = HybridVoting(DP_HV).creatorHats(); + bool isCreator = false; + for (uint256 i; i < creatorHats.length; ++i) { + if (IHatsBalance(DP_HATS).balanceOf(sender, creatorHats[i]) > 0) { + isCreator = true; + break; + } + } + require(isCreator, "Sender does not wear any creator hat on this voting contract"); + require(IParticipationTokenEdu(DP_TOKEN).educationHub() == address(0), "educationHub already set on token"); + + vm.startBroadcast(key); + + // PART 1: deploy the module (beacon + proxy + init). + (address proxy, address beacon) = _deployEducationHub(); + + // PART 2: create the wiring/registration proposal referencing the just-deployed addresses. + IExecutor.Call[] memory batch = _buildGovBatch(proxy, beacon); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + HybridVoting(DP_HV) + .createProposal( + bytes("Add EducationHub - Decentral Park"), bytes32(0), minutesDuration, 1, batches, new uint256[](0) + ); + + vm.stopBroadcast(); + + uint256 proposalId = HybridVoting(DP_HV).proposalsCount() - 1; + console.log("\n Deployed EduHub proxy:", proxy); + console.log(" Per-org beacon: ", beacon); + console.log(" Proposal ID: ", proposalId); + console.log(" Next: members vote; after expiry anyone calls announceWinner(", proposalId, ")"); + console.log(" On execution: token.setEducationHub(proxy) + OrgRegistry registration land."); + } +} diff --git a/script/fixes/GrantDelegateFullPermsViaGovernance.s.sol b/script/fixes/GrantDelegateFullPermsViaGovernance.s.sol new file mode 100644 index 0000000..0255ed5 --- /dev/null +++ b/script/fixes/GrantDelegateFullPermsViaGovernance.s.sol @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {TaskManager} from "../../src/TaskManager.sol"; +import {IExecutor} from "../../src/Executor.sol"; +import {HybridVoting} from "../../src/HybridVoting.sol"; + +/* + * ============================================================================ + * Grant the Delegate hat FULL org permissions via HybridVoting governance + * ============================================================================ + * + * Target org: Decentral Park (Gnosis). + * + * Goal: make the "Delegate" role hat fully empowered to run the TaskManager — + * create tasks, claim, review, assign, self-review, edit budgets, edit + * metadata/payouts post-claim — plus organize the folder tree. Today the + * Delegate hat's global TaskManager mask is only EDIT_FULL (0x80); it cannot + * create or assign tasks at the org-wide level. This proposal sets the mask to + * 0xFF (all 8 TaskPerm bits) and adds the Delegate hat as an organizer hat. + * + * The Delegate hat is ALREADY: + * - a TaskManager creator hat -> can create projects + * - a HybridVoting creator hat -> can create proposals + * so those powers need no change. PaymentManager is Ownable (Executor-only) and + * exposes no hat-grantable role, so treasury/distribution actions intentionally + * remain governance-only and are out of scope for a hat grant. + * + * The batch (single proposal option, executed by the Executor on pass): + * 1. setConfig(ROLE_PERM, abi.encode(DELEGATE_HAT, 0xFF)) + * 2. setConfig(ORGANIZER_HAT_ALLOWED, abi.encode(DELEGATE_HAT, true)) + * + * setConfig(ROLE_PERM, ...) REPLACES the mask (it does not OR-merge), but 0xFF + * is a superset of every bit so nothing is lost. + * + * Sim-first per CLAUDE.md: stages the full create -> vote -> execute path on a + * Gnosis fork (etch a permissive Hats shim so one test voter satisfies the + * onlyCreator + class-hat checks) and asserts the post-state: Delegate mask is + * exactly 0xFF and the Delegate hat is now present in the organizer-hat array. + * + * Usage: + * # Sim (no broadcast — validate end-to-end on a fork) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/GrantDelegateFullPermsViaGovernance.s.sol:SimGrantDelegateFullDecentralPark \ + * --fork-url gnosis -vvv + * + * # Broadcast (creates the real proposal; members vote in normal cadence) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/GrantDelegateFullPermsViaGovernance.s.sol:BroadcastGrantDelegateFullDecentralPark \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env overrides: + * GRANT_HAT_ID — override the target hat (default = Decentral Park Delegate) + * GRANT_DURATION — voting window in minutes (default 10 = HybridVoting min) + * ============================================================================ + */ + +// Decentral Park (Gnosis) — verified via Poa subgraph + on-chain reads 2026-05-31 +// orgId 0x3721271eb827a52a5adf676136d302efe19c34e72f08e080b07b225eecf27d78 +// Roles: (top hat unnamed), ELIGIBILITY_ADMIN, Delegate, Neighbor, Agent. +// Delegate is the target: the requester wears it and it's already in +// HybridVoting.creatorHats(), so any Delegate/Neighbor wearer can broadcast. +address constant DECENTRAL_PARK_TM = 0x2D9d397A842B8D691ea2A232062CbC8eF8eBbdB7; +address constant DECENTRAL_PARK_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; +uint256 constant DECENTRAL_PARK_DELEGATE_HAT = 36180248838698575036480031466286475792781881727149517033480474826113024; + +// Full TaskManager permission mask: all 8 TaskPerm bits set. +// CREATE|CLAIM|REVIEW|ASSIGN|SELF_REVIEW|BUDGET|EDIT_META|EDIT_FULL == 0xFF. +uint8 constant FULL_PERM_MASK = 0xFF; + +// Default voting window (minutes). HybridVoting MIN_DURATION = 10 (this is the minimum). +uint32 constant DEFAULT_DURATION_MINUTES = 10; + +/// @dev Permissive Hats stand-in: returns 1 for a single configured "godmode" EOA on every +/// hat queried. Used during the sim only — etched over the org's real Hats Protocol address. +/// Configure via vm.store(addr, slot=0, godmode). Mirrors the helper in +/// GrantV5EditFullViaGovernance.s.sol. +contract PermissiveHatsShim { + function balanceOf( + address user, + uint256 /*hatId*/ + ) + external + view + returns (uint256) + { + address g; + assembly { + g := sload(0) + } + return user == g ? 1 : 0; + } + + function balanceOfBatch(address[] calldata users, uint256[] calldata hatIds) + external + view + returns (uint256[] memory bal) + { + require(users.length == hatIds.length, "len mismatch"); + address g; + assembly { + g := sload(0) + } + bal = new uint256[](users.length); + for (uint256 i; i < users.length; ++i) { + if (users[i] == g) bal[i] = 1; + } + } + + function isWearerOfHat( + address user, + uint256 /*hatId*/ + ) + external + view + returns (bool) + { + address g; + assembly { + g := sload(0) + } + return user == g; + } +} + +interface IHatsMinimal { + function balanceOf(address user, uint256 hatId) external view returns (uint256); +} + +abstract contract GrantDelegateFullBase is Script { + /// @dev Read the current rolePermGlobal mask for a hat directly from TaskManager.Layout + /// storage (no public getter). rolePermGlobal is at offset 6 from the namespaced base slot. + function _readRolePermGlobal(address taskManager, uint256 hatId) internal view returns (uint8) { + bytes32 base = bytes32(uint256(keccak256("poa.taskmanager.storage")) + 6); + bytes32 slot = keccak256(abi.encode(hatId, base)); + return uint8(uint256(vm.load(taskManager, slot))); + } + + /// @dev True if `hatId` is present in TaskManager's organizer-hat enumeration (lens 11). + function _isOrganizerHat(address taskManager, uint256 hatId) internal view returns (bool) { + uint256[] memory organizers = abi.decode(TaskManager(taskManager).getLensData(11, ""), (uint256[])); + for (uint256 i; i < organizers.length; ++i) { + if (organizers[i] == hatId) return true; + } + return false; + } + + /// @dev Etch the permissive shim over the org's Hats contract and configure `voter` as godmode. + function _etchHats(address taskManager, address voter) internal returns (address hatsAddr) { + hatsAddr = abi.decode(TaskManager(taskManager).getLensData(3, ""), (address)); + PermissiveHatsShim impl = new PermissiveHatsShim(); + vm.etch(hatsAddr, address(impl).code); + vm.store(hatsAddr, bytes32(uint256(0)), bytes32(uint256(uint160(voter)))); + console.log(" HatsShim etched at:", hatsAddr); + console.log(" Godmode voter: ", voter); + } + + /// @dev Build the 2-call batch: set the hat's global mask to FULL_PERM_MASK and register it + /// as an organizer hat. + function _buildBatch(address taskManager, uint256 hatId) internal pure returns (IExecutor.Call[] memory batch) { + batch = new IExecutor.Call[](2); + batch[0] = IExecutor.Call({ + target: taskManager, + value: 0, + data: abi.encodeCall( + TaskManager.setConfig, (TaskManager.ConfigKey.ROLE_PERM, abi.encode(hatId, FULL_PERM_MASK)) + ) + }); + batch[1] = IExecutor.Call({ + target: taskManager, + value: 0, + data: abi.encodeCall( + TaskManager.setConfig, (TaskManager.ConfigKey.ORGANIZER_HAT_ALLOWED, abi.encode(hatId, true)) + ) + }); + } + + function _resolveTargetHat(uint256 defaultHat) internal view returns (uint256) { + return vm.envOr("GRANT_HAT_ID", defaultHat); + } + + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("GRANT_DURATION", uint256(DEFAULT_DURATION_MINUTES))); + } + + function _printPreview(address taskManager, uint256 hatId, uint8 currentMask) internal view { + console.log(" Target hat: ", hatId); + console.log(" Current ROLE_PERM: ", currentMask); + console.log( + " New ROLE_PERM: ", + FULL_PERM_MASK, + "(CREATE|CLAIM|REVIEW|ASSIGN|SELF_REVIEW|BUDGET|EDIT_META|EDIT_FULL)" + ); + console.log(" Currently organizer: ", _isOrganizerHat(taskManager, hatId)); + console.log(" Becomes organizer: true"); + } + + /// @dev Full sim: etch Hats shim, create proposal, vote, advance time, announceWinner, assert. + function _simFullFlow(string memory orgName, address taskManager, address hybridVoting, uint256 targetHat) + internal + { + console.log("\n=== Delegate FULL-perm grant sim:", orgName, "==="); + console.log(" TaskManager: ", taskManager); + console.log(" HybridVoting: ", hybridVoting); + + // 1. Snapshot pre-state. + uint8 currentMask = _readRolePermGlobal(taskManager, targetHat); + _printPreview(taskManager, targetHat, currentMask); + require(currentMask != FULL_PERM_MASK || !_isOrganizerHat(taskManager, targetHat), "Sim: proposal is a no-op"); + + // 2. Etch permissive Hats over the org's Hats Protocol address so a single test voter + // can satisfy onlyCreator (createProposal) and class-hat (vote) checks. + address voter = makeAddr("delegate-full-sim-voter"); + _etchHats(taskManager, voter); + + // 3. Build the batch. + IExecutor.Call[] memory batch = _buildBatch(taskManager, targetHat); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + // 4. Create the proposal (MIN_DURATION = 10 minutes for sim speed). + uint32 minutesDuration = 10; + uint256[] memory pollHats = new uint256[](0); // unrestricted poll + vm.prank(voter); + HybridVoting(hybridVoting) + .createProposal( + bytes(string.concat("Grant Delegate full org perms - ", orgName)), + bytes32(0), + minutesDuration, + 1, // single option + batches, + pollHats + ); + uint256 proposalId = HybridVoting(hybridVoting).proposalsCount() - 1; + console.log(" Proposal id:", proposalId); + + // 5. Vote 100% for option 0. + uint8[] memory idxs = new uint8[](1); + uint8[] memory weights = new uint8[](1); + idxs[0] = 0; + weights[0] = 100; + vm.prank(voter); + HybridVoting(hybridVoting).vote(proposalId, idxs, weights); + + // 6. Advance time past the proposal's end + small buffer. + vm.warp(block.timestamp + uint256(minutesDuration) * 60 + 10); + + // 7. Anyone can call announceWinner. Executor.execute fires inside if the option won. + (uint256 winner, bool valid) = HybridVoting(hybridVoting).announceWinner(proposalId); + require(valid, "Sim: proposal did not pass"); + console.log(" Winner option:", winner, " valid:", valid); + + // 8. Verify post-state. + uint8 maskAfter = _readRolePermGlobal(taskManager, targetHat); + require(maskAfter == FULL_PERM_MASK, "Sim: post-state mask is not 0xFF"); + require(_isOrganizerHat(taskManager, targetHat), "Sim: Delegate hat not added as organizer"); + console.log(" Post-state mask:", maskAfter, "(all 8 TaskPerm bits set)"); + console.log(" Post-state organizer:", _isOrganizerHat(taskManager, targetHat)); + console.log("PASS:", orgName, "Delegate full-perm governance grant executed end-to-end."); + } + + /// @dev Real broadcast: creates the proposal on-chain. Members vote in normal cadence. + function _broadcastProposal(string memory orgName, address taskManager, address hybridVoting, uint256 targetHat) + internal + { + // Prefer PRIVATE_KEY; fall back to DEPLOYER_PRIVATE_KEY. Resolved lazily so setting only + // PRIVATE_KEY does not trip an eager vm.envUint revert on a missing DEPLOYER_PRIVATE_KEY. + uint256 key = vm.envOr("PRIVATE_KEY", uint256(0)); + if (key == 0) key = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting Delegate full-perm grant proposal:", orgName, "==="); + console.log(" Sender: ", sender); + console.log(" TaskManager: ", taskManager); + console.log(" HybridVoting: ", hybridVoting); + console.log(" Duration: ", minutesDuration, "minutes"); + + // Sanity: sender must wear a creator hat or createProposal reverts. + IHatsMinimal hats = IHatsMinimal(abi.decode(TaskManager(taskManager).getLensData(3, ""), (address))); + uint256[] memory creatorHats = HybridVoting(hybridVoting).creatorHats(); + bool isCreator = false; + for (uint256 i; i < creatorHats.length; ++i) { + if (hats.balanceOf(sender, creatorHats[i]) > 0) { + isCreator = true; + break; + } + } + require(isCreator, "Sender does not wear any creator hat on this voting contract"); + + uint8 currentMask = _readRolePermGlobal(taskManager, targetHat); + _printPreview(taskManager, targetHat, currentMask); + require( + currentMask != FULL_PERM_MASK || !_isOrganizerHat(taskManager, targetHat), "Broadcast: proposal is a no-op" + ); + + IExecutor.Call[] memory batch = _buildBatch(taskManager, targetHat); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + uint256 idBefore = HybridVoting(hybridVoting).proposalsCount(); + + vm.startBroadcast(key); + HybridVoting(hybridVoting) + .createProposal( + bytes(string.concat("Grant Delegate full org perms - ", orgName)), + bytes32(0), + minutesDuration, + 1, // single option + batches, + new uint256[](0) // unrestricted — any class-hat wearer can vote + ); + vm.stopBroadcast(); + + uint256 newId = HybridVoting(hybridVoting).proposalsCount() - 1; + require(newId == idBefore, "Proposal not created"); + console.log("\n Proposal ID:", newId); + console.log(" Next: members vote; after expiry, anyone calls announceWinner(", newId, ")"); + } +} + +/* ─────────────────── Decentral Park on Gnosis ────────────────────── */ + +contract SimGrantDelegateFullDecentralPark is GrantDelegateFullBase { + function run() public { + _simFullFlow( + "Decentral Park", DECENTRAL_PARK_TM, DECENTRAL_PARK_HV, _resolveTargetHat(DECENTRAL_PARK_DELEGATE_HAT) + ); + } +} + +contract BroadcastGrantDelegateFullDecentralPark is GrantDelegateFullBase { + function run() public { + _broadcastProposal( + "Decentral Park", DECENTRAL_PARK_TM, DECENTRAL_PARK_HV, _resolveTargetHat(DECENTRAL_PARK_DELEGATE_HAT) + ); + } +} diff --git a/script/fixes/GrantNeighborClaimViaGovernance.s.sol b/script/fixes/GrantNeighborClaimViaGovernance.s.sol new file mode 100644 index 0000000..160087a --- /dev/null +++ b/script/fixes/GrantNeighborClaimViaGovernance.s.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {TaskManager} from "../../src/TaskManager.sol"; +import {TaskPerm} from "../../src/libs/TaskPerm.sol"; +import {IExecutor} from "../../src/Executor.sol"; +import {HybridVoting} from "../../src/HybridVoting.sol"; + +/* + * ============================================================================ + * Grant the Neighbor hat CLAIM permission via HybridVoting governance + * ============================================================================ + * + * Target org: Decentral Park (Gnosis). + * + * Today the "Neighbor" (member) hat has a global TaskManager mask of 0x00 — it + * holds no task permissions, so members can only be *assigned* tasks top-down; + * they cannot self-claim an open task or apply to an application-gated one + * (both claimTask and applyForTask require the CLAIM bit). This proposal adds + * the CLAIM bit (1 << 1 = 0x02) to the Neighbor hat so members can self-serve. + * + * Read-modify-write: setConfig(ROLE_PERM, ...) REPLACES the mask (it does not + * OR-merge). We read the current Neighbor mask and OR in CLAIM so any bits set + * between now and execution are preserved rather than clobbered. + * + * Single-call batch (executed by the Executor on pass): + * 1. setConfig(ROLE_PERM, abi.encode(NEIGHBOR_HAT, currentMask | CLAIM)) + * + * Sim-first per CLAUDE.md: stages the full create -> vote -> execute path on a + * Gnosis fork (etch a permissive Hats shim so one test voter satisfies the + * onlyCreator + class-hat checks) and asserts the post-state mask has CLAIM set + * and equals (currentMask | CLAIM). + * + * Usage: + * # Sim (no broadcast — validate end-to-end on a fork) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/GrantNeighborClaimViaGovernance.s.sol:SimGrantNeighborClaimDecentralPark \ + * --fork-url gnosis -vvv + * + * # Broadcast (creates the real proposal; members vote in normal cadence) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/GrantNeighborClaimViaGovernance.s.sol:BroadcastGrantNeighborClaimDecentralPark \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env overrides: + * GRANT_HAT_ID — override the target hat (default = Decentral Park Neighbor) + * GRANT_DURATION — voting window in minutes (default 10 = HybridVoting min) + * ============================================================================ + */ + +// Decentral Park (Gnosis) — verified via Poa subgraph + on-chain reads 2026-05-31 +// orgId 0x3721271eb827a52a5adf676136d302efe19c34e72f08e080b07b225eecf27d78 +// Neighbor is the member hat; it is already a HybridVoting creator hat, so any +// Neighbor/Delegate wearer can broadcast this proposal. +address constant DECENTRAL_PARK_TM = 0x2D9d397A842B8D691ea2A232062CbC8eF8eBbdB7; +address constant DECENTRAL_PARK_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; +uint256 constant DECENTRAL_PARK_NEIGHBOR_HAT = 36180248838698575132261002770404529440178570924043841009651669962588160; + +// Default voting window (minutes). HybridVoting MIN_DURATION = 10 (this is the minimum). +uint32 constant DEFAULT_DURATION_MINUTES = 10; + +/// @dev Permissive Hats stand-in: returns 1 for a single configured "godmode" EOA on every +/// hat queried. Used during the sim only — etched over the org's real Hats Protocol address. +/// Configure via vm.store(addr, slot=0, godmode). +contract PermissiveHatsShim { + function balanceOf( + address user, + uint256 /*hatId*/ + ) + external + view + returns (uint256) + { + address g; + assembly { + g := sload(0) + } + return user == g ? 1 : 0; + } + + function balanceOfBatch(address[] calldata users, uint256[] calldata hatIds) + external + view + returns (uint256[] memory bal) + { + require(users.length == hatIds.length, "len mismatch"); + address g; + assembly { + g := sload(0) + } + bal = new uint256[](users.length); + for (uint256 i; i < users.length; ++i) { + if (users[i] == g) bal[i] = 1; + } + } + + function isWearerOfHat( + address user, + uint256 /*hatId*/ + ) + external + view + returns (bool) + { + address g; + assembly { + g := sload(0) + } + return user == g; + } +} + +interface IHatsMinimal { + function balanceOf(address user, uint256 hatId) external view returns (uint256); +} + +abstract contract GrantNeighborClaimBase is Script { + /// @dev Read the current rolePermGlobal mask for a hat directly from TaskManager.Layout + /// storage (no public getter). rolePermGlobal is at offset 6 from the namespaced base slot. + function _readRolePermGlobal(address taskManager, uint256 hatId) internal view returns (uint8) { + bytes32 base = bytes32(uint256(keccak256("poa.taskmanager.storage")) + 6); + bytes32 slot = keccak256(abi.encode(hatId, base)); + return uint8(uint256(vm.load(taskManager, slot))); + } + + /// @dev Etch the permissive shim over the org's Hats contract and configure `voter` as godmode. + function _etchHats(address taskManager, address voter) internal returns (address hatsAddr) { + hatsAddr = abi.decode(TaskManager(taskManager).getLensData(3, ""), (address)); + PermissiveHatsShim impl = new PermissiveHatsShim(); + vm.etch(hatsAddr, address(impl).code); + vm.store(hatsAddr, bytes32(uint256(0)), bytes32(uint256(uint160(voter)))); + console.log(" HatsShim etched at:", hatsAddr); + console.log(" Godmode voter: ", voter); + } + + /// @dev Build the single-call batch: set the hat's global mask to (current | CLAIM). + function _buildBatch(address taskManager, uint256 hatId) + internal + view + returns (IExecutor.Call[] memory batch, uint8 currentMask, uint8 newMask) + { + currentMask = _readRolePermGlobal(taskManager, hatId); + newMask = currentMask | TaskPerm.CLAIM; + + batch = new IExecutor.Call[](1); + batch[0] = IExecutor.Call({ + target: taskManager, + value: 0, + data: abi.encodeCall(TaskManager.setConfig, (TaskManager.ConfigKey.ROLE_PERM, abi.encode(hatId, newMask))) + }); + } + + function _resolveTargetHat(uint256 defaultHat) internal view returns (uint256) { + return vm.envOr("GRANT_HAT_ID", defaultHat); + } + + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("GRANT_DURATION", uint256(DEFAULT_DURATION_MINUTES))); + } + + function _printPreview(uint256 hatId, uint8 currentMask, uint8 newMask) internal pure { + console.log(" Target hat: ", hatId); + console.log(" Current ROLE_PERM: ", currentMask); + console.log(" New ROLE_PERM: ", newMask, "(adds CLAIM = 0x02)"); + } + + /// @dev Full sim: etch Hats shim, create proposal, vote, advance time, announceWinner, assert. + function _simFullFlow(string memory orgName, address taskManager, address hybridVoting, uint256 targetHat) + internal + { + console.log("\n=== Neighbor CLAIM grant sim:", orgName, "==="); + console.log(" TaskManager: ", taskManager); + console.log(" HybridVoting: ", hybridVoting); + + // 1. Etch permissive Hats over the org's Hats Protocol address so a single test voter + // can satisfy onlyCreator (createProposal) and class-hat (vote) checks. + address voter = makeAddr("neighbor-claim-sim-voter"); + _etchHats(taskManager, voter); + + // 2. Snapshot pre-state and build the proposal batch. + (IExecutor.Call[] memory batch, uint8 currentMask, uint8 newMask) = _buildBatch(taskManager, targetHat); + require(newMask != currentMask, "Sim: proposal is a no-op (CLAIM already set)"); + _printPreview(targetHat, currentMask, newMask); + + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + // 3. Create the proposal (MIN_DURATION = 10 minutes for sim speed). + uint32 minutesDuration = 10; + uint256[] memory pollHats = new uint256[](0); // unrestricted poll + vm.prank(voter); + HybridVoting(hybridVoting) + .createProposal( + bytes(string.concat("Grant Neighbor CLAIM - ", orgName)), + bytes32(0), + minutesDuration, + 1, + batches, + pollHats + ); + uint256 proposalId = HybridVoting(hybridVoting).proposalsCount() - 1; + console.log(" Proposal id:", proposalId); + + // 4. Vote 100% for option 0. + uint8[] memory idxs = new uint8[](1); + uint8[] memory weights = new uint8[](1); + idxs[0] = 0; + weights[0] = 100; + vm.prank(voter); + HybridVoting(hybridVoting).vote(proposalId, idxs, weights); + + // 5. Advance time past the proposal's end + small buffer. + vm.warp(block.timestamp + uint256(minutesDuration) * 60 + 10); + + // 6. Anyone can call announceWinner. Executor.execute fires inside if the option won. + (uint256 winner, bool valid) = HybridVoting(hybridVoting).announceWinner(proposalId); + require(valid, "Sim: proposal did not pass"); + console.log(" Winner option:", winner, " valid:", valid); + + // 7. Verify post-state: CLAIM bit set, and mask equals (current | CLAIM). + uint8 maskAfter = _readRolePermGlobal(taskManager, targetHat); + require(maskAfter == newMask, "Sim: post-state mask mismatch"); + require(maskAfter & TaskPerm.CLAIM != 0, "Sim: CLAIM bit not set"); + console.log(" Post-state mask:", maskAfter, "(CLAIM set, prior bits preserved)"); + console.log("PASS:", orgName, "Neighbor CLAIM governance grant executed end-to-end."); + } + + /// @dev Real broadcast: creates the proposal on-chain. Members vote in normal cadence. + function _broadcastProposal(string memory orgName, address taskManager, address hybridVoting, uint256 targetHat) + internal + { + // Prefer PRIVATE_KEY; fall back to DEPLOYER_PRIVATE_KEY. Resolved lazily so setting only + // PRIVATE_KEY does not trip an eager vm.envUint revert on a missing DEPLOYER_PRIVATE_KEY. + uint256 key = vm.envOr("PRIVATE_KEY", uint256(0)); + if (key == 0) key = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting Neighbor CLAIM grant proposal:", orgName, "==="); + console.log(" Sender: ", sender); + console.log(" TaskManager: ", taskManager); + console.log(" HybridVoting: ", hybridVoting); + console.log(" Duration: ", minutesDuration, "minutes"); + + // Sanity: sender must wear a creator hat or createProposal reverts. + IHatsMinimal hats = IHatsMinimal(abi.decode(TaskManager(taskManager).getLensData(3, ""), (address))); + uint256[] memory creatorHats = HybridVoting(hybridVoting).creatorHats(); + bool isCreator = false; + for (uint256 i; i < creatorHats.length; ++i) { + if (hats.balanceOf(sender, creatorHats[i]) > 0) { + isCreator = true; + break; + } + } + require(isCreator, "Sender does not wear any creator hat on this voting contract"); + + (IExecutor.Call[] memory batch, uint8 currentMask, uint8 newMask) = _buildBatch(taskManager, targetHat); + require(newMask != currentMask, "Broadcast: proposal is a no-op (CLAIM already set on target hat)"); + _printPreview(targetHat, currentMask, newMask); + + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + uint256 idBefore = HybridVoting(hybridVoting).proposalsCount(); + + vm.startBroadcast(key); + HybridVoting(hybridVoting) + .createProposal( + bytes(string.concat("Grant Neighbor CLAIM - ", orgName)), + bytes32(0), + minutesDuration, + 1, // single option + batches, + new uint256[](0) // unrestricted — any class-hat wearer can vote + ); + vm.stopBroadcast(); + + uint256 newId = HybridVoting(hybridVoting).proposalsCount() - 1; + require(newId == idBefore, "Proposal not created"); + console.log("\n Proposal ID:", newId); + console.log(" Next: members vote; after expiry, anyone calls announceWinner(", newId, ")"); + } +} + +/* ─────────────────── Decentral Park on Gnosis ────────────────────── */ + +contract SimGrantNeighborClaimDecentralPark is GrantNeighborClaimBase { + function run() public { + _simFullFlow( + "Decentral Park", DECENTRAL_PARK_TM, DECENTRAL_PARK_HV, _resolveTargetHat(DECENTRAL_PARK_NEIGHBOR_HAT) + ); + } +} + +contract BroadcastGrantNeighborClaimDecentralPark is GrantNeighborClaimBase { + function run() public { + _broadcastProposal( + "Decentral Park", DECENTRAL_PARK_TM, DECENTRAL_PARK_HV, _resolveTargetHat(DECENTRAL_PARK_NEIGHBOR_HAT) + ); + } +} diff --git a/script/fixes/VoteDecentralParkProposal.s.sol b/script/fixes/VoteDecentralParkProposal.s.sol new file mode 100644 index 0000000..9c7db2e --- /dev/null +++ b/script/fixes/VoteDecentralParkProposal.s.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {HybridVoting} from "../../src/HybridVoting.sol"; + +/* + * ============================================================================ + * Decentral Park (Gnosis) — vote on a HybridVoting proposal directly + * ============================================================================ + * + * For when the subgraph/frontend is down: reads the proposal id straight from + * the HybridVoting contract (latest = proposalsCount - 1) and casts a 100% + * vote for one option. No subgraph dependency. + * + * Defaults to the LATEST proposal and option 0 (the single "approve" option + * these governance proposals use). Override either via env. + * + * Usage: + * # Sim (no broadcast — confirm the vote lands on a fork; pranks Hudson) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/VoteDecentralParkProposal.s.sol:SimVoteDecentralPark \ + * --fork-url gnosis -vvv + * + * # Broadcast (casts your real vote) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/VoteDecentralParkProposal.s.sol:BroadcastVoteDecentralPark \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env overrides: + * PROPOSAL_ID — which proposal to vote on (default = latest = proposalsCount-1) + * VOTE_OPTION — option index to back 100% (default 0) + * ============================================================================ + */ + +address constant DECENTRAL_PARK_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; + +// Verified Delegate-hat wearer — pranked in the sim so the vote resolves against real Hats state. +address constant HUDSON = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; + +abstract contract VoteBase is Script { + function _resolveProposalId() internal view returns (uint256) { + uint256 count = HybridVoting(DECENTRAL_PARK_HV).proposalsCount(); + require(count > 0, "no proposals on this HybridVoting"); + return vm.envOr("PROPOSAL_ID", count - 1); + } + + function _resolveOption() internal view returns (uint8) { + return uint8(vm.envOr("VOTE_OPTION", uint256(0))); + } + + /// @dev Single-choice ballot: 100% weight to `option`. + function _ballot(uint8 option) internal pure returns (uint8[] memory idxs, uint8[] memory weights) { + idxs = new uint8[](1); + weights = new uint8[](1); + idxs[0] = option; + weights[0] = 100; + } +} + +contract SimVoteDecentralPark is VoteBase { + function run() public { + uint256 count = HybridVoting(DECENTRAL_PARK_HV).proposalsCount(); + uint256 id = _resolveProposalId(); + uint8 option = _resolveOption(); + console.log("=== Vote sim (Decentral Park) ==="); + console.log(" HybridVoting: ", DECENTRAL_PARK_HV); + console.log(" proposalsCount:", count); + console.log(" voting proposal id:", id, " option:", option); + + (uint8[] memory idxs, uint8[] memory weights) = _ballot(option); + + // Prank Hudson (real Delegate-hat wearer) so class-hat checks resolve against real Hats. + vm.prank(HUDSON); + HybridVoting(DECENTRAL_PARK_HV).vote(id, idxs, weights); + console.log(" vote cast (no revert)."); + + // Informational: warp past the window and check whether this vote passes it. + vm.warp(block.timestamp + 31 days); + (uint256 winner, bool valid) = HybridVoting(DECENTRAL_PARK_HV).announceWinner(id); + console.log(" announceWinner -> winner option:", winner, " valid:", valid); + require(valid, "Sim: proposal does NOT pass with just this vote (needs more voters/quorum)"); + console.log("PASS: vote lands and the proposal passes."); + } +} + +contract BroadcastVoteDecentralPark is VoteBase { + function run() public { + uint256 key = vm.envOr("PRIVATE_KEY", uint256(0)); + if (key == 0) key = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address voter = vm.addr(key); + + uint256 count = HybridVoting(DECENTRAL_PARK_HV).proposalsCount(); + uint256 id = _resolveProposalId(); + uint8 option = _resolveOption(); + + console.log("=== Casting vote (Decentral Park) ==="); + console.log(" Voter: ", voter); + console.log(" HybridVoting: ", DECENTRAL_PARK_HV); + console.log(" proposalsCount:", count); + console.log(" voting proposal id:", id, " option:", option); + + (uint8[] memory idxs, uint8[] memory weights) = _ballot(option); + + vm.startBroadcast(key); + HybridVoting(DECENTRAL_PARK_HV).vote(id, idxs, weights); + vm.stopBroadcast(); + + console.log(" Vote submitted for proposal", id); + console.log(" After the window expires, anyone calls announceWinner(", id, ") to execute."); + } +} diff --git a/script/fixes/WhitelistTaskEditRulesDecentralParkViaGovernance.s.sol b/script/fixes/WhitelistTaskEditRulesDecentralParkViaGovernance.s.sol new file mode 100644 index 0000000..ffdc829 --- /dev/null +++ b/script/fixes/WhitelistTaskEditRulesDecentralParkViaGovernance.s.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {TaskManager} from "../../src/TaskManager.sol"; +import {IExecutor} from "../../src/Executor.sol"; +import {HybridVoting} from "../../src/HybridVoting.sol"; + +/* + * ============================================================================ + * Decentral Park (Gnosis) — whitelist the TaskManager EDIT-task functions in + * the PaymasterHub so passkey / smart accounts can edit tasks gas-sponsored + * ============================================================================ + * + * PaymasterHub only sponsors (orgId, target, selector) calls whose Rule is + * `allowed = true`. Decentral Park's creation selectors are already whitelisted + * (createTask 0x22fa79bc, createAndAssignTask 0xaf425951, createTasksBatch + * 0xc18aa1c9 — the last via PR #155), but the two EDIT selectors are NOT, so a + * sponsored passkey account reverts when trying to edit a task: + * + * updateTask(uint256,uint256,bytes,bytes32,address,uint256) 0x48db6f65 allowed=false + * updateTaskMetadata(uint256,bytes,bytes32) 0x26fa4e70 allowed=false + * + * Verified on Gnosis 2026-06-02 via getRule(orgId, taskManager, selector). + * + * This is a single governance proposal whose one call is executed by Decentral + * Park's Executor: + * + * paymaster.setRulesBatch(orgId, [TM, TM], [updateTask, updateTaskMetadata], + * [true, true], [0, 0]) + * + * Auth: setRulesBatch is `onlyOrgOperator` — satisfied by the org's adminHat + * wearer (the Executor) OR the operatorHat wearer. The Executor wears the + * adminHat, so when announceWinner fires Executor.execute the call passes + * against real Hats Protocol state. (maxCallGasHint = 0 = no hint, matching the + * existing creation-selector rules; allowed=true is the only thing that gates + * sponsorship.) + * + * Sim-first per CLAUDE.md: stages the full create -> vote -> execute path on a + * Gnosis fork using REAL Hats (no etch). Pranks Hudson (verified Delegate-hat + * wearer, a HybridVoting creator hat) for createProposal + vote; the real + * Executor (real adminHat wearer) satisfies onlyOrgOperator when it executes, + * so the sim exercises the exact production auth path. Asserts both selectors + * flip allowed=false -> true. + * + * Usage: + * # Sim (no broadcast — validate end-to-end on a fork) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/WhitelistTaskEditRulesDecentralParkViaGovernance.s.sol:SimWhitelistTaskEditDecentralPark \ + * --fork-url gnosis -vvv + * + * # Broadcast (creates the real proposal; members vote in normal cadence) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/WhitelistTaskEditRulesDecentralParkViaGovernance.s.sol:BroadcastWhitelistTaskEditDecentralPark \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env override: + * PROPOSAL_DURATION — voting window in minutes (default 30; HybridVoting min 10) + * ============================================================================ + */ + +// PaymasterHub on Gnosis — confirmed via ConfigureDecentralParkPaymasterViaGovernance.s.sol +// and AddCreateTasksBatchSelectorRules.s.sol. +address constant GNOSIS_PAYMASTER_HUB = 0xdEf1038C297493c0b5f82F0CDB49e929B53B4108; + +// Decentral Park (Gnosis) — verified via Poa subgraph + on-chain reads. +bytes32 constant DECENTRAL_PARK_ORG_ID = 0x3721271eb827a52a5adf676136d302efe19c34e72f08e080b07b225eecf27d78; +address constant DECENTRAL_PARK_TM = 0x2D9d397A842B8D691ea2A232062CbC8eF8eBbdB7; +address constant DECENTRAL_PARK_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; + +// TaskManager edit-task selectors (verify with: cast sig ''). +// cast sig 'updateTask(uint256,uint256,bytes,bytes32,address,uint256)' = 0x48db6f65 +// cast sig 'updateTaskMetadata(uint256,bytes,bytes32)' = 0x26fa4e70 +bytes4 constant SEL_UPDATE_TASK = 0x48db6f65; +bytes4 constant SEL_UPDATE_TASK_METADATA = 0x26fa4e70; + +// Hudson — verified Delegate-hat wearer on Decentral Park (Gnosis). The sim pranks this address +// for createProposal + vote so all hat checks resolve against real on-chain Hats state, not a +// shim. He's also the expected broadcaster (wears a HybridVoting creator hat). +address constant HUDSON = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; + +uint32 constant DEFAULT_PROPOSAL_DURATION_MINUTES = 30; + +/// @dev Minimal PaymasterHub surface this script touches. +interface IPaymasterHubMinimal { + // Field order matches PaymasterHub.sol's Rule struct exactly: (uint32 maxCallGasHint, bool allowed). + // Decoding as (bool, uint32) would silently swap the values — see CLAUDE.md. + struct Rule { + uint32 maxCallGasHint; + bool allowed; + } + + function setRulesBatch( + bytes32 orgId, + address[] calldata targets, + bytes4[] calldata selectors, + bool[] calldata allowed, + uint32[] calldata maxCallGasHints + ) external; + + function getRule(bytes32 orgId, address target, bytes4 selector) external view returns (Rule memory); +} + +interface IHatsMinimal { + function balanceOf(address user, uint256 hatId) external view returns (uint256); +} + +abstract contract WhitelistTaskEditBase is Script { + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("PROPOSAL_DURATION", uint256(DEFAULT_PROPOSAL_DURATION_MINUTES))); + } + + /// @dev Build the single-call batch: setRulesBatch enabling the two edit selectors on the + /// org's TaskManager. maxCallGasHint = 0 (no hint), matching the existing creation rules. + function _buildBatch() internal pure returns (IExecutor.Call[] memory batch) { + address[] memory targets = new address[](2); + bytes4[] memory selectors = new bytes4[](2); + bool[] memory allowed = new bool[](2); + uint32[] memory hints = new uint32[](2); + + targets[0] = DECENTRAL_PARK_TM; + selectors[0] = SEL_UPDATE_TASK; + allowed[0] = true; + hints[0] = 0; + + targets[1] = DECENTRAL_PARK_TM; + selectors[1] = SEL_UPDATE_TASK_METADATA; + allowed[1] = true; + hints[1] = 0; + + batch = new IExecutor.Call[](1); + batch[0] = IExecutor.Call({ + target: GNOSIS_PAYMASTER_HUB, + value: 0, + data: abi.encodeCall( + IPaymasterHubMinimal.setRulesBatch, (DECENTRAL_PARK_ORG_ID, targets, selectors, allowed, hints) + ) + }); + } + + function _ruleAllowed(bytes4 selector) internal view returns (bool) { + return + IPaymasterHubMinimal(GNOSIS_PAYMASTER_HUB) + .getRule(DECENTRAL_PARK_ORG_ID, DECENTRAL_PARK_TM, selector) + .allowed; + } + + function _printPreview() internal view { + console.log("\n=== Edit-task paymaster whitelist preview (Decentral Park) ==="); + console.log(" PaymasterHub: ", GNOSIS_PAYMASTER_HUB); + console.log(" TaskManager (target):", DECENTRAL_PARK_TM); + console.log(" updateTask allowed now: ", _ruleAllowed(SEL_UPDATE_TASK)); + console.log(" updateTaskMetadata allowed now:", _ruleAllowed(SEL_UPDATE_TASK_METADATA)); + console.log(" -> both will be set allowed = true"); + } + + /// @dev Full sim using REAL Hats (no etch): prank Hudson for createProposal + vote; the real + /// Executor (adminHat wearer) satisfies onlyOrgOperator when it executes setRulesBatch. + function _simFullFlow() internal { + _printPreview(); + + IExecutor.Call[] memory batch = _buildBatch(); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + uint32 minutesDuration = 10; + uint256[] memory pollHats = new uint256[](0); // unrestricted poll + vm.prank(HUDSON); + HybridVoting(DECENTRAL_PARK_HV) + .createProposal( + bytes("Decentral Park: whitelist updateTask + updateTaskMetadata for sponsorship (sim)"), + bytes32(0), + minutesDuration, + 1, + batches, + pollHats + ); + uint256 proposalId = HybridVoting(DECENTRAL_PARK_HV).proposalsCount() - 1; + console.log("\n Proposal id:", proposalId); + + uint8[] memory idxs = new uint8[](1); + uint8[] memory weights = new uint8[](1); + idxs[0] = 0; + weights[0] = 100; + vm.prank(HUDSON); + HybridVoting(DECENTRAL_PARK_HV).vote(proposalId, idxs, weights); + + vm.warp(block.timestamp + uint256(minutesDuration) * 60 + 10); + + (uint256 winner, bool valid) = HybridVoting(DECENTRAL_PARK_HV).announceWinner(proposalId); + require(valid, "Sim: proposal did not pass (likely quorum - add more pranked voters)"); + console.log(" Winner option:", winner, " valid:", valid); + + // Post-state: both edit selectors are now sponsored. + require(_ruleAllowed(SEL_UPDATE_TASK), "Sim: updateTask still not allowed"); + require(_ruleAllowed(SEL_UPDATE_TASK_METADATA), "Sim: updateTaskMetadata still not allowed"); + console.log("\n Post-state: updateTask allowed =", _ruleAllowed(SEL_UPDATE_TASK)); + console.log(" Post-state: updateTaskMetadata allowed =", _ruleAllowed(SEL_UPDATE_TASK_METADATA)); + console.log("PASS: Decentral Park edit-task sponsorship whitelisted end-to-end."); + } + + /// @dev Real broadcast: creates the proposal on-chain. Members vote in normal cadence. + function _broadcast() internal { + uint256 key = vm.envOr("PRIVATE_KEY", uint256(0)); + if (key == 0) key = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting Decentral Park edit-task whitelist proposal ==="); + console.log(" Sender: ", sender); + console.log(" HybridVoting: ", DECENTRAL_PARK_HV); + console.log(" PaymasterHub: ", GNOSIS_PAYMASTER_HUB); + console.log(" Duration: ", minutesDuration, "minutes"); + _printPreview(); + + // Sanity: sender must wear a creator hat or createProposal reverts NotCreator. + IHatsMinimal hats = IHatsMinimal(abi.decode(TaskManager(DECENTRAL_PARK_TM).getLensData(3, ""), (address))); + uint256[] memory creatorHats = HybridVoting(DECENTRAL_PARK_HV).creatorHats(); + bool isCreator = false; + for (uint256 i; i < creatorHats.length; ++i) { + if (hats.balanceOf(sender, creatorHats[i]) > 0) { + isCreator = true; + break; + } + } + require(isCreator, "Sender does not wear any creator hat on Decentral Park HybridVoting"); + + IExecutor.Call[] memory batch = _buildBatch(); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + uint256 idBefore = HybridVoting(DECENTRAL_PARK_HV).proposalsCount(); + + vm.startBroadcast(key); + HybridVoting(DECENTRAL_PARK_HV) + .createProposal( + bytes("Decentral Park: whitelist updateTask + updateTaskMetadata for gas sponsorship"), + bytes32(0), + minutesDuration, + 1, + batches, + new uint256[](0) + ); + vm.stopBroadcast(); + + uint256 newId = HybridVoting(DECENTRAL_PARK_HV).proposalsCount() - 1; + require(newId == idBefore, "Proposal not created"); + console.log("\n Proposal ID:", newId); + console.log(" Next: members vote; after expiry, anyone calls announceWinner(", newId, ")"); + } +} + +contract SimWhitelistTaskEditDecentralPark is WhitelistTaskEditBase { + function run() public { + _simFullFlow(); + } +} + +contract BroadcastWhitelistTaskEditDecentralPark is WhitelistTaskEditBase { + function run() public { + _broadcast(); + } +} diff --git a/script/fixes/WhitelistTaskEditRulesPoaViaGovernance.s.sol b/script/fixes/WhitelistTaskEditRulesPoaViaGovernance.s.sol new file mode 100644 index 0000000..2c28225 --- /dev/null +++ b/script/fixes/WhitelistTaskEditRulesPoaViaGovernance.s.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {TaskManager} from "../../src/TaskManager.sol"; +import {IExecutor} from "../../src/Executor.sol"; +import {HybridVoting} from "../../src/HybridVoting.sol"; + +/* + * ============================================================================ + * Poa (Arbitrum) — whitelist the TaskManager EDIT-task functions in the + * PaymasterHub via governance, and cast the creator's vote in the same script + * ============================================================================ + * + * Arbitrum twin of WhitelistTaskEditRulesTest6KubiViaGovernance (Gnosis). Poa is + * the home-chain governance org and lives on Arbitrum, so this targets the + * Arbitrum PaymasterHub. The two edit selectors are not yet sponsored: + * + * updateTask(uint256,uint256,bytes,bytes32,address,uint256) 0x48db6f65 + * updateTaskMetadata(uint256,bytes,bytes32) 0x26fa4e70 + * + * Verified allowed=false on Arbitrum 2026-06-02 via getRule (createTasksBatch is + * already allowed=true, so the rule set otherwise works). + * + * Single governance proposal whose one call the Poa Executor runs on execution: + * paymaster.setRulesBatch(orgId, [TM, TM], [updateTask, updateTaskMetadata], + * [true, true], [0, 0]) + * Auth: setRulesBatch is `onlyOrgOperator`, satisfied by the org's adminHat + * wearer (the Executor) when announceWinner fires Executor.execute. + * + * The BROADCAST creates the proposal AND casts the creator's vote (option 0, + * 100%). Poa HV is threshold 50% / quorum 0 and the broadcaster wears its sole + * creator hat (verified), so the single vote passes — proven by the sim's + * announceWinner assertion. + * + * Sim-first per CLAUDE.md: stages create -> vote -> warp -> execute on an + * ARBITRUM fork with REAL Hats (prank Hudson, verified creator-hat wearer); the + * real Executor satisfies onlyOrgOperator on execution. Asserts both selectors + * flip allowed=false -> true. + * + * Usage: + * # Sim (run first — note --fork-url arbitrum) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/WhitelistTaskEditRulesPoaViaGovernance.s.sol:SimWhitelistTaskEditPoa \ + * --fork-url arbitrum -vvv + * + * # Broadcast (creates the proposal AND votes) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/WhitelistTaskEditRulesPoaViaGovernance.s.sol:BroadcastWhitelistTaskEditPoa \ + * --rpc-url arbitrum --broadcast --slow + * + * Optional env override: + * PROPOSAL_DURATION — voting window in minutes (default 10 = HybridVoting min) + * ============================================================================ + */ + +// PaymasterHub on Arbitrum. +address constant ARB_PAYMASTER_HUB = 0xD6659bCaFAdCB9CC2F57B7aE923c7F1Ca4438a11; + +// Poa (Arbitrum) — verified via Poa subgraph + on-chain reads 2026-06-02. +bytes32 constant POA_ORG = 0xa71879ef0e38b15fe7080196c0102f859e0ca8e7b8c0703ec8df03c66befd069; +address constant POA_TM = 0x681f29751724D2bED331d3EB35e0C9B1C57aF9F0; +address constant POA_HV = 0x34aa1bD79a3A5eb5d2B208eb4f091ccF6B1081d5; + +// TaskManager edit-task selectors. +bytes4 constant SEL_UPDATE_TASK = 0x48db6f65; // updateTask(uint256,uint256,bytes,bytes32,address,uint256) +bytes4 constant SEL_UPDATE_TASK_METADATA = 0x26fa4e70; // updateTaskMetadata(uint256,bytes,bytes32) + +// Hudson — verified wearer of Poa HybridVoting's sole creator hat. Pranked in the sim so create + +// vote resolve against real on-chain Hats state; also the expected broadcaster. +address constant HUDSON = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; + +uint32 constant DEFAULT_PROPOSAL_DURATION_MINUTES = 10; + +interface IPaymasterHubMinimal { + // Field order matches PaymasterHub.sol's Rule struct exactly: (uint32 maxCallGasHint, bool allowed). + struct Rule { + uint32 maxCallGasHint; + bool allowed; + } + + function setRulesBatch( + bytes32 orgId, + address[] calldata targets, + bytes4[] calldata selectors, + bool[] calldata allowed, + uint32[] calldata maxCallGasHints + ) external; + + function getRule(bytes32 orgId, address target, bytes4 selector) external view returns (Rule memory); +} + +interface IHatsMinimal { + function balanceOf(address user, uint256 hatId) external view returns (uint256); +} + +abstract contract WhitelistTaskEditPoaBase is Script { + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("PROPOSAL_DURATION", uint256(DEFAULT_PROPOSAL_DURATION_MINUTES))); + } + + /// @dev Single-call batch: setRulesBatch enabling both edit selectors on Poa's TaskManager. + function _buildBatch() internal pure returns (IExecutor.Call[] memory batch) { + address[] memory targets = new address[](2); + bytes4[] memory selectors = new bytes4[](2); + bool[] memory allowed = new bool[](2); + uint32[] memory hints = new uint32[](2); + + targets[0] = POA_TM; + selectors[0] = SEL_UPDATE_TASK; + allowed[0] = true; + targets[1] = POA_TM; + selectors[1] = SEL_UPDATE_TASK_METADATA; + allowed[1] = true; + + batch = new IExecutor.Call[](1); + batch[0] = IExecutor.Call({ + target: ARB_PAYMASTER_HUB, + value: 0, + data: abi.encodeCall(IPaymasterHubMinimal.setRulesBatch, (POA_ORG, targets, selectors, allowed, hints)) + }); + } + + function _ruleAllowed(bytes4 selector) internal view returns (bool) { + return IPaymasterHubMinimal(ARB_PAYMASTER_HUB).getRule(POA_ORG, POA_TM, selector).allowed; + } + + function _ballot() internal pure returns (uint8[] memory idxs, uint8[] memory weights) { + idxs = new uint8[](1); + weights = new uint8[](1); + idxs[0] = 0; + weights[0] = 100; + } + + function _printPreview() internal view { + console.log("\n=== Edit-task paymaster whitelist preview: Poa (Arbitrum) ==="); + console.log(" PaymasterHub: ", ARB_PAYMASTER_HUB); + console.log(" TaskManager (target):", POA_TM); + console.log(" updateTask allowed now: ", _ruleAllowed(SEL_UPDATE_TASK)); + console.log(" updateTaskMetadata allowed now:", _ruleAllowed(SEL_UPDATE_TASK_METADATA)); + console.log(" -> both will be set allowed = true"); + } + + /// @dev Full sim (real Hats, prank Hudson): create -> vote -> warp -> announceWinner -> assert. + function _simFlow() internal { + _printPreview(); + + IExecutor.Call[] memory batch = _buildBatch(); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + uint32 minutesDuration = 10; + vm.prank(HUDSON); + HybridVoting(POA_HV) + .createProposal( + bytes("Poa: whitelist updateTask + updateTaskMetadata for sponsorship (sim)"), + bytes32(0), + minutesDuration, + 1, + batches, + new uint256[](0) + ); + uint256 proposalId = HybridVoting(POA_HV).proposalsCount() - 1; + console.log(" Proposal id:", proposalId); + + (uint8[] memory idxs, uint8[] memory weights) = _ballot(); + vm.prank(HUDSON); + HybridVoting(POA_HV).vote(proposalId, idxs, weights); + + vm.warp(block.timestamp + uint256(minutesDuration) * 60 + 10); + (uint256 winner, bool valid) = HybridVoting(POA_HV).announceWinner(proposalId); + require(valid, "Sim Poa: proposal did not pass with the creator's single vote"); + console.log(" Winner option:", winner, " valid:", valid); + + require(_ruleAllowed(SEL_UPDATE_TASK), "Sim: updateTask still not allowed"); + require(_ruleAllowed(SEL_UPDATE_TASK_METADATA), "Sim: updateTaskMetadata still not allowed"); + console.log("PASS: Poa edit-task sponsorship whitelisted end-to-end."); + } + + /// @dev Broadcast: create the proposal AND cast the creator's vote, in one signed session. + function _broadcast() internal { + uint256 key = vm.envOr("PRIVATE_KEY", uint256(0)); + if (key == 0) key = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting Poa edit-task whitelist proposal + vote ==="); + console.log(" Sender: ", sender); + console.log(" HybridVoting: ", POA_HV); + console.log(" PaymasterHub: ", ARB_PAYMASTER_HUB); + console.log(" Duration: ", minutesDuration, "minutes"); + _printPreview(); + + // Sanity: sender must wear a creator hat or createProposal reverts. + IHatsMinimal hats = IHatsMinimal(abi.decode(TaskManager(POA_TM).getLensData(3, ""), (address))); + uint256[] memory creatorHats = HybridVoting(POA_HV).creatorHats(); + bool isCreator = false; + for (uint256 i; i < creatorHats.length; ++i) { + if (hats.balanceOf(sender, creatorHats[i]) > 0) { + isCreator = true; + break; + } + } + require(isCreator, "Sender wears no creator hat on Poa HybridVoting"); + + IExecutor.Call[] memory batch = _buildBatch(); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + (uint8[] memory idxs, uint8[] memory weights) = _ballot(); + + vm.startBroadcast(key); + HybridVoting(POA_HV) + .createProposal( + bytes("Poa: whitelist updateTask + updateTaskMetadata for gas sponsorship"), + bytes32(0), + minutesDuration, + 1, + batches, + new uint256[](0) + ); + uint256 proposalId = HybridVoting(POA_HV).proposalsCount() - 1; + HybridVoting(POA_HV).vote(proposalId, idxs, weights); + vm.stopBroadcast(); + + console.log(" Proposal ID:", proposalId, "(vote cast)"); + console.log(" After the window expires, anyone calls announceWinner(", proposalId, ") to execute."); + } +} + +contract SimWhitelistTaskEditPoa is WhitelistTaskEditPoaBase { + function run() public { + _simFlow(); + } +} + +contract BroadcastWhitelistTaskEditPoa is WhitelistTaskEditPoaBase { + function run() public { + _broadcast(); + } +} diff --git a/script/fixes/WhitelistTaskEditRulesTest6KubiViaGovernance.s.sol b/script/fixes/WhitelistTaskEditRulesTest6KubiViaGovernance.s.sol new file mode 100644 index 0000000..a0d2c5b --- /dev/null +++ b/script/fixes/WhitelistTaskEditRulesTest6KubiViaGovernance.s.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {TaskManager} from "../../src/TaskManager.sol"; +import {IExecutor} from "../../src/Executor.sol"; +import {HybridVoting} from "../../src/HybridVoting.sol"; + +/* + * ============================================================================ + * Test6 + KUBI (Gnosis) — whitelist the TaskManager EDIT-task functions in the + * PaymasterHub via governance, and cast the creator's vote in the same script + * ============================================================================ + * + * Same fix as WhitelistTaskEditRulesDecentralParkViaGovernance, for Test6 and + * KUBI. PaymasterHub only sponsors (orgId, target, selector) calls whose Rule + * is allowed=true. Both orgs already have the creation selectors (createTask / + * createTasksBatch, the latter via PR #155) but NOT the two edit selectors: + * + * updateTask(uint256,uint256,bytes,bytes32,address,uint256) 0x48db6f65 + * updateTaskMetadata(uint256,bytes,bytes32) 0x26fa4e70 + * + * Verified allowed=false for both orgs on Gnosis 2026-06-02 via getRule. + * + * Each org gets a single governance proposal whose one call the org's Executor + * runs on execution: + * paymaster.setRulesBatch(orgId, [TM, TM], [updateTask, updateTaskMetadata], + * [true, true], [0, 0]) + * Auth: setRulesBatch is `onlyOrgOperator`, satisfied by the org's adminHat + * wearer (the Executor) when announceWinner fires Executor.execute. + * + * Unlike the other governance scripts, the BROADCAST here also CASTS THE VOTE + * (option 0, 100%) right after creating the proposal — the broadcaster wears a + * creator hat on both HVs (verified). Whether that single vote is enough to + * pass is org-specific (Test6: 25% threshold / quorum 1; KUBI: 35% / quorum 0) + * and is proven by the sim's announceWinner assertion below. If a sim reports + * "did not pass", the org needs more voters than just the broadcaster. + * + * Sim-first per CLAUDE.md: stages create -> vote -> warp -> execute on a Gnosis + * fork with REAL Hats (prank Hudson, a verified creator-hat wearer on both HVs); + * the real Executor satisfies onlyOrgOperator on execution. Asserts both + * selectors flip allowed=false -> true. + * + * Usage: + * # Sims (run first) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/WhitelistTaskEditRulesTest6KubiViaGovernance.s.sol:SimWhitelistTaskEditTest6 \ + * --fork-url gnosis -vvv + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/WhitelistTaskEditRulesTest6KubiViaGovernance.s.sol:SimWhitelistTaskEditKubi \ + * --fork-url gnosis -vvv + * + * # Broadcast (creates the proposal AND votes, per org or both at once) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/WhitelistTaskEditRulesTest6KubiViaGovernance.s.sol:BroadcastWhitelistTaskEditBothGnosis \ + * --rpc-url gnosis --broadcast --slow + * # (or :BroadcastWhitelistTaskEditTest6 / :BroadcastWhitelistTaskEditKubi for one org) + * + * Optional env override: + * PROPOSAL_DURATION — voting window in minutes (default 10 = HybridVoting min) + * ============================================================================ + */ + +// PaymasterHub on Gnosis. +address constant GNOSIS_PAYMASTER_HUB = 0xdEf1038C297493c0b5f82F0CDB49e929B53B4108; + +// Test6 (Gnosis) — verified via Poa subgraph 2026-06-02. +bytes32 constant TEST6_ORG = 0x263b2b29f392647f0fb8ddbb26f099e812ab4ba2777e5e07b906277164181f6b; +address constant TEST6_TM = 0x3d93f0D090356D25E7a1614F0F8764b103ca99bc; +address constant TEST6_HV = 0xF642DdE77848dC195c8089F4042A311Ed650d7a6; + +// KUBI (Gnosis) — verified via Poa subgraph 2026-06-02. +bytes32 constant KUBI_ORG = 0xc0f2765d555e21bfad5c6b05accef86a5758e0dee3e9a5b4ee3c3f3069c2102e; +address constant KUBI_TM = 0xF57024fC77915Fce8f2608afdd027941bCEE3336; +address constant KUBI_HV = 0x13CBd5eD47bF177968B24D84516a75879c23971E; + +// TaskManager edit-task selectors. +bytes4 constant SEL_UPDATE_TASK = 0x48db6f65; // updateTask(uint256,uint256,bytes,bytes32,address,uint256) +bytes4 constant SEL_UPDATE_TASK_METADATA = 0x26fa4e70; // updateTaskMetadata(uint256,bytes,bytes32) + +// Hudson — verified creator-hat wearer on BOTH Test6 and KUBI HybridVoting. Pranked in the sim so +// create + vote resolve against real on-chain Hats state; also the expected broadcaster. +address constant HUDSON = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; + +uint32 constant DEFAULT_PROPOSAL_DURATION_MINUTES = 10; + +interface IPaymasterHubMinimal { + // Field order matches PaymasterHub.sol's Rule struct exactly: (uint32 maxCallGasHint, bool allowed). + struct Rule { + uint32 maxCallGasHint; + bool allowed; + } + + function setRulesBatch( + bytes32 orgId, + address[] calldata targets, + bytes4[] calldata selectors, + bool[] calldata allowed, + uint32[] calldata maxCallGasHints + ) external; + + function getRule(bytes32 orgId, address target, bytes4 selector) external view returns (Rule memory); +} + +interface IHatsMinimal { + function balanceOf(address user, uint256 hatId) external view returns (uint256); +} + +abstract contract WhitelistTaskEditGovBase is Script { + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("PROPOSAL_DURATION", uint256(DEFAULT_PROPOSAL_DURATION_MINUTES))); + } + + /// @dev Single-call batch: setRulesBatch enabling both edit selectors on the org's TaskManager. + function _buildBatch(bytes32 orgId, address tm) internal pure returns (IExecutor.Call[] memory batch) { + address[] memory targets = new address[](2); + bytes4[] memory selectors = new bytes4[](2); + bool[] memory allowed = new bool[](2); + uint32[] memory hints = new uint32[](2); + + targets[0] = tm; + selectors[0] = SEL_UPDATE_TASK; + allowed[0] = true; + targets[1] = tm; + selectors[1] = SEL_UPDATE_TASK_METADATA; + allowed[1] = true; + + batch = new IExecutor.Call[](1); + batch[0] = IExecutor.Call({ + target: GNOSIS_PAYMASTER_HUB, + value: 0, + data: abi.encodeCall(IPaymasterHubMinimal.setRulesBatch, (orgId, targets, selectors, allowed, hints)) + }); + } + + function _ruleAllowed(bytes32 orgId, address tm, bytes4 selector) internal view returns (bool) { + return IPaymasterHubMinimal(GNOSIS_PAYMASTER_HUB).getRule(orgId, tm, selector).allowed; + } + + function _ballot() internal pure returns (uint8[] memory idxs, uint8[] memory weights) { + idxs = new uint8[](1); + weights = new uint8[](1); + idxs[0] = 0; + weights[0] = 100; + } + + function _printPreview(string memory name, bytes32 orgId, address tm) internal view { + console.log("\n=== Edit-task paymaster whitelist preview:", name, "==="); + console.log(" TaskManager (target):", tm); + console.log(" updateTask allowed now: ", _ruleAllowed(orgId, tm, SEL_UPDATE_TASK)); + console.log(" updateTaskMetadata allowed now:", _ruleAllowed(orgId, tm, SEL_UPDATE_TASK_METADATA)); + console.log(" -> both will be set allowed = true"); + } + + /// @dev Full sim (real Hats, prank Hudson): create -> vote -> warp -> announceWinner -> assert. + function _simFlow(string memory name, bytes32 orgId, address tm, address hv) internal { + _printPreview(name, orgId, tm); + + IExecutor.Call[] memory batch = _buildBatch(orgId, tm); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + uint32 minutesDuration = 10; + vm.prank(HUDSON); + HybridVoting(hv) + .createProposal( + bytes(string.concat(name, ": whitelist updateTask + updateTaskMetadata (sim)")), + bytes32(0), + minutesDuration, + 1, + batches, + new uint256[](0) + ); + uint256 proposalId = HybridVoting(hv).proposalsCount() - 1; + console.log(" Proposal id:", proposalId); + + (uint8[] memory idxs, uint8[] memory weights) = _ballot(); + vm.prank(HUDSON); + HybridVoting(hv).vote(proposalId, idxs, weights); + + vm.warp(block.timestamp + uint256(minutesDuration) * 60 + 10); + (uint256 winner, bool valid) = HybridVoting(hv).announceWinner(proposalId); + require(valid, string.concat("Sim ", name, ": proposal did not pass with the creator's single vote")); + console.log(" Winner option:", winner, " valid:", valid); + + require(_ruleAllowed(orgId, tm, SEL_UPDATE_TASK), "Sim: updateTask still not allowed"); + require(_ruleAllowed(orgId, tm, SEL_UPDATE_TASK_METADATA), "Sim: updateTaskMetadata still not allowed"); + console.log("PASS:", name, "edit-task sponsorship whitelisted end-to-end."); + } + + /// @dev Broadcast: create the proposal AND cast the creator's vote, in one signed session. + function _broadcastCreateAndVote(uint256 key, string memory name, bytes32 orgId, address tm, address hv) internal { + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting edit-task whitelist proposal + vote:", name, "==="); + console.log(" Sender: ", sender); + console.log(" HybridVoting: ", hv); + console.log(" Duration: ", minutesDuration, "minutes"); + _printPreview(name, orgId, tm); + + // Sanity: sender must wear a creator hat or createProposal reverts. + IHatsMinimal hats = IHatsMinimal(abi.decode(TaskManager(tm).getLensData(3, ""), (address))); + uint256[] memory creatorHats = HybridVoting(hv).creatorHats(); + bool isCreator = false; + for (uint256 i; i < creatorHats.length; ++i) { + if (hats.balanceOf(sender, creatorHats[i]) > 0) { + isCreator = true; + break; + } + } + require(isCreator, string.concat("Sender wears no creator hat on ", name, " HybridVoting")); + + IExecutor.Call[] memory batch = _buildBatch(orgId, tm); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + (uint8[] memory idxs, uint8[] memory weights) = _ballot(); + + vm.startBroadcast(key); + HybridVoting(hv) + .createProposal( + bytes(string.concat(name, ": whitelist updateTask + updateTaskMetadata for gas sponsorship")), + bytes32(0), + minutesDuration, + 1, + batches, + new uint256[](0) + ); + uint256 proposalId = HybridVoting(hv).proposalsCount() - 1; + HybridVoting(hv).vote(proposalId, idxs, weights); + vm.stopBroadcast(); + + console.log(" Proposal ID:", proposalId, "(vote cast)"); + console.log(" After the window expires, anyone calls announceWinner(", proposalId, ") to execute."); + } + + function _resolveKey() internal view returns (uint256 key) { + key = vm.envOr("PRIVATE_KEY", uint256(0)); + if (key == 0) key = vm.envUint("DEPLOYER_PRIVATE_KEY"); + } +} + +/* ───────────────────────────── Test6 ───────────────────────────── */ + +contract SimWhitelistTaskEditTest6 is WhitelistTaskEditGovBase { + function run() public { + _simFlow("Test6", TEST6_ORG, TEST6_TM, TEST6_HV); + } +} + +contract BroadcastWhitelistTaskEditTest6 is WhitelistTaskEditGovBase { + function run() public { + _broadcastCreateAndVote(_resolveKey(), "Test6", TEST6_ORG, TEST6_TM, TEST6_HV); + } +} + +/* ───────────────────────────── KUBI ───────────────────────────── */ + +contract SimWhitelistTaskEditKubi is WhitelistTaskEditGovBase { + function run() public { + _simFlow("KUBI", KUBI_ORG, KUBI_TM, KUBI_HV); + } +} + +contract BroadcastWhitelistTaskEditKubi is WhitelistTaskEditGovBase { + function run() public { + _broadcastCreateAndVote(_resolveKey(), "KUBI", KUBI_ORG, KUBI_TM, KUBI_HV); + } +} + +/* ─────────────────────── Both orgs in one run ─────────────────────── */ + +contract BroadcastWhitelistTaskEditBothGnosis is WhitelistTaskEditGovBase { + function run() public { + uint256 key = _resolveKey(); + _broadcastCreateAndVote(key, "Test6", TEST6_ORG, TEST6_TM, TEST6_HV); + _broadcastCreateAndVote(key, "KUBI", KUBI_ORG, KUBI_TM, KUBI_HV); + } +} diff --git a/script/upgrades/UpgradeOrgDeployerEditTaskRules.s.sol b/script/upgrades/UpgradeOrgDeployerEditTaskRules.s.sol new file mode 100644 index 0000000..f3253f8 --- /dev/null +++ b/script/upgrades/UpgradeOrgDeployerEditTaskRules.s.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {OrgDeployer} from "../../src/OrgDeployer.sol"; +import {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol"; +import {PoaManager} from "../../src/PoaManager.sol"; +import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; + +/* + * ============================================================================ + * OrgDeployer v14 — TaskManager v5 edit-task selectors in default whitelist + * ============================================================================ + * + * TaskManager v5 added two post-claim edit functions: + * updateTask(uint256,uint256,bytes,bytes32,address,uint256) -> 0x48db6f65 + * updateTaskMetadata(uint256,bytes,bytes32) -> 0x26fa4e70 + * gated on the EDIT_META / EDIT_FULL TaskPerm bits. They were NOT in the + * default paymaster ruleset emitted by deployFullOrg, so every newly deployed + * org's passkey/smart accounts could not call them with gas sponsorship + * (exactly the gap Decentral Park hit; see AddTaskEditSelectorRules retroactive + * fix for live orgs). + * + * This upgrade adds both selectors to OrgDeployer._appendTaskManagerRules so + * NEW orgs auto-whitelist them at deploy. The TaskManager rule count bumped + * 14 -> 16 and the _buildDefaultPaymasterRules base count 41 -> 43. + * + * Live impl: v13 + * This PR: v14 (edit-task selectors, in addition to setFolders + createTasksBatch) + * + * Version selection (CLAUDE.md probing recipe, both surfaces both chains, this branch): + * v11/v12/v13 are TAKEN (registry + CREATE2) on Gnosis AND Arbitrum; + * v14 is FREE on both surfaces on both chains (predicted 0xd0224cD4F2C3a22F02626bd346895bf28f929A03). + * (Live impl is v13 at 0x02D16118AA9EB485e8cD5Ac51167B2934c462b80; no committed v13 script — probed on-chain.) + * + * Forward-only: existing orgs are unaffected by an OrgDeployer upgrade — they + * already have their paymaster rules. This only changes what NEW orgs get + * bootstrapped with. Live orgs missing the edit-task rules need the separate + * retroactive fix (Hub/Satellite adminCall -> setRulesBatch per org). + * + * Three-step cross-chain upgrade pattern (mirrors UpgradeOrgDeployerFolders): + * 1. Deploy impl on Gnosis via DeterministicDeployer + * 2. Deploy on Arbitrum + upgradeBeaconCrossChain + * 3. Verify on Gnosis after Hyperlane relay (~5 min) + * + * Usage (sim first — CLAUDE.md requires PASS before broadcast): + * FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeOrgDeployerEditTaskRules.s.sol:SimulateOrgDeployerEditTaskUpgrade \ + * --fork-url arbitrum -vvv + * + * Broadcast: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeOrgDeployerEditTaskRules.s.sol:Step1_DeployImplOnGnosis \ + * --rpc-url gnosis --broadcast --slow + * + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeOrgDeployerEditTaskRules.s.sol:Step2_UpgradeFromArbitrum \ + * --rpc-url arbitrum --broadcast --slow + * + * forge script script/upgrades/UpgradeOrgDeployerEditTaskRules.s.sol:Step3_Verify \ + * --rpc-url gnosis + * ============================================================================ + */ + +address constant DD = 0x4aC8B5ebEb9D8C3dE3180ddF381D552d59e8835a; +address constant HUB = 0xB72840B343654eAfb2CFf7acC4Fc6b59E6c3CC71; +address constant GNOSIS_POA_MANAGER = 0x794fD39e75140ee1545B1B022E5486B7c863789b; +uint256 constant HYPERLANE_FEE = 0.005 ether; +string constant VERSION = "v14"; + +// keccak256(...)[:4] of the two TaskManager v5 edit functions added to the whitelist. +bytes4 constant SEL_UPDATE_TASK = 0x48db6f65; // updateTask(uint256,uint256,bytes,bytes32,address,uint256) +bytes4 constant SEL_UPDATE_TASK_META = 0x26fa4e70; // updateTaskMetadata(uint256,bytes,bytes32) + +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("OrgDeployer", VERSION); + address predicted = dd.computeAddress(salt); + console.log("\n=== Step 1: Deploy OrgDeployer v14 on Gnosis ==="); + console.log("Predicted:", predicted); + + if (predicted.code.length > 0) { + console.log("Already deployed. Skipping."); + return; + } + + vm.startBroadcast(deployerKey); + address deployed = dd.deploy(salt, type(OrgDeployer).creationCode); + vm.stopBroadcast(); + + require(deployed == predicted, "Address mismatch"); + console.log("Deployed:", deployed); + console.log("\nNext: Run Step2_UpgradeFromArbitrum on Arbitrum"); + } +} + +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); + + console.log("\n=== Step 2: Upgrade OrgDeployer from Arbitrum ==="); + require(hub.owner() == deployer, "Deployer must own Hub"); + require(!hub.paused(), "Hub is paused"); + + bytes32 salt = dd.computeSalt("OrgDeployer", VERSION); + address impl = dd.computeAddress(salt); + console.log("OrgDeployer v14 impl:", impl); + + vm.startBroadcast(deployerKey); + + if (impl.code.length == 0) { + address deployed = dd.deploy(salt, type(OrgDeployer).creationCode); + require(deployed == impl, "Address mismatch on Arbitrum"); + console.log("Deployed on Arbitrum"); + } else { + console.log("Already deployed on Arbitrum"); + } + + hub.upgradeBeaconCrossChain{value: HYPERLANE_FEE}("OrgDeployer", impl, VERSION); + console.log("Beacon upgrade dispatched (Arbitrum local + Gnosis cross-chain)"); + + vm.stopBroadcast(); + + address pm = address(hub.poaManager()); + address current = PoaManager(pm).getCurrentImplementationById(keccak256("OrgDeployer")); + require(current == impl, "Arbitrum impl not upgraded"); + console.log("Arbitrum upgrade: PASS"); + console.log("\nWait ~5 min for Hyperlane relay, then run Step3_Verify on Gnosis"); + } +} + +contract Step3_Verify is Script { + function run() public view { + DeterministicDeployer dd = DeterministicDeployer(DD); + address expected = dd.computeAddress(dd.computeSalt("OrgDeployer", VERSION)); + address current = PoaManager(GNOSIS_POA_MANAGER).getCurrentImplementationById(keccak256("OrgDeployer")); + + console.log("\n=== Verify Gnosis OrgDeployer Upgrade ==="); + console.log("Expected:", expected); + console.log("Current: ", current); + if (current == expected) { + console.log("PASS: OrgDeployer v14 live on Gnosis"); + console.log("New orgs now auto-whitelist updateTask (0x48db6f65) + updateTaskMetadata (0x26fa4e70)"); + } else { + console.log("WAITING: Hyperlane message not yet relayed."); + } + } +} + +/** + * @title SimulateOrgDeployerEditTaskUpgrade + * @notice Fork-simulates the upgrade end-to-end on Arbitrum: deploys v14 via DD, + * calls upgradeBeaconCrossChain, and verifies the Arbitrum beacon impl + * switched to the new bytecode. Does NOT verify Gnosis (cross-chain + * relay isn't fork-simulatable). + * + * Behavior verification (the new selectors actually land in the default + * rule batch) is covered by test/DeployerTest.t.sol: + * testDeployFullOrgWithPaymasterAutoWhitelist, which asserts + * getRule(orgId, taskManager, updateTask/updateTaskMetadata).allowed. + * + * Usage: + * FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeOrgDeployerEditTaskRules.s.sol:SimulateOrgDeployerEditTaskUpgrade \ + * --fork-url arbitrum -vvv + */ +contract SimulateOrgDeployerEditTaskUpgrade 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); + address pm = address(hub.poaManager()); + + console.log("\n=== SIM: OrgDeployer v14 upgrade (Arbitrum fork) ==="); + console.log("Deployer:", deployer); + console.log("Selectors added: updateTask 0x48db6f65, updateTaskMetadata 0x26fa4e70"); + + address before = PoaManager(pm).getCurrentImplementationById(keccak256("OrgDeployer")); + console.log("Current impl:", before); + + bytes32 salt = dd.computeSalt("OrgDeployer", VERSION); + address predicted = dd.computeAddress(salt); + console.log("Predicted v14:", predicted); + require(before != predicted, "Sim: v14 already live (nothing to upgrade)"); + + vm.deal(deployer, 1 ether); + vm.startPrank(deployer); + + if (predicted.code.length == 0) { + address deployed = dd.deploy(salt, type(OrgDeployer).creationCode); + require(deployed == predicted, "Address mismatch"); + console.log("v14 deployed at:", deployed); + } + + hub.upgradeBeaconCrossChain{value: HYPERLANE_FEE}("OrgDeployer", predicted, VERSION); + + vm.stopPrank(); + + address after_ = PoaManager(pm).getCurrentImplementationById(keccak256("OrgDeployer")); + console.log("New impl:", after_); + require(after_ == predicted, "Upgrade failed"); + require(after_.code.length > 0, "Impl has no code"); + console.log("New impl codesize:", after_.code.length, "bytes"); + + console.log("\nArbitrum upgrade simulation: PASS"); + console.log( + "Behavior verification: DeployerTest.testDeployFullOrgWithPaymasterAutoWhitelist asserts the edit-task rules auto-set on deploy." + ); + } +} diff --git a/src/OrgDeployer.sol b/src/OrgDeployer.sol index b42a04e..4bade7e 100644 --- a/src/OrgDeployer.sol +++ b/src/OrgDeployer.sol @@ -904,8 +904,8 @@ contract OrgDeployer is Initializable { pure returns (address[] memory targets, bytes4[] memory selectors, bool[] memory allowed, uint32[] memory gasHints) { - // Count: QuickJoin(6) + TaskManager(14) + HybridVoting(3) + DDVoting(3) + PaymentManager(5) + EligibilityModule(5) + ParticipationToken(3) + Registry(2) + EducationHub(0 or 4) - uint256 count = 41; + // Count: QuickJoin(6) + TaskManager(16) + HybridVoting(3) + DDVoting(3) + PaymentManager(5) + EligibilityModule(5) + ParticipationToken(3) + Registry(2) + EducationHub(0 or 4) + uint256 count = 43; if (educationEnabled) count += 4; targets = new address[](count); @@ -1027,6 +1027,14 @@ contract OrgDeployer is Initializable { targets[i] = tm; selectors[i] = bytes4(keccak256("setFolders(bytes32,bytes32)")); i++; + // TaskManager v5: post-claim edit functions, gated on EDIT_META / EDIT_FULL perms. + // Whitelist for gasless 4337/passkey calls by edit-permitted hat holders. + targets[i] = tm; + selectors[i] = bytes4(keccak256("updateTask(uint256,uint256,bytes,bytes32,address,uint256)")); + i++; + targets[i] = tm; + selectors[i] = bytes4(keccak256("updateTaskMetadata(uint256,bytes,bytes32)")); + i++; return i; } diff --git a/test/DeployerTest.t.sol b/test/DeployerTest.t.sol index 4cf81f2..949ee27 100644 --- a/test/DeployerTest.t.sol +++ b/test/DeployerTest.t.sol @@ -5700,6 +5700,16 @@ contract DeployerTest is Test, IEligibilityModuleEvents { rule = paymasterHub.getRule(orgId, result.taskManager, bytes4(keccak256("setFolders(bytes32,bytes32)"))); assertTrue(rule.allowed, "TaskManager setFolders should be whitelisted (v4 bootstrap)"); + // Check TaskManager v5 post-claim edit functions are whitelisted + rule = paymasterHub.getRule( + orgId, result.taskManager, bytes4(keccak256("updateTask(uint256,uint256,bytes,bytes32,address,uint256)")) + ); + assertTrue(rule.allowed, "TaskManager updateTask should be whitelisted (v5 edit)"); + rule = paymasterHub.getRule( + orgId, result.taskManager, bytes4(keccak256("updateTaskMetadata(uint256,bytes,bytes32)")) + ); + assertTrue(rule.allowed, "TaskManager updateTaskMetadata should be whitelisted (v5 edit)"); + // Check HybridVoting vote is whitelisted bytes4 voteSel = bytes4(keccak256("vote(uint256,uint8[],uint8[])")); rule = paymasterHub.getRule(orgId, result.hybridVoting, voteSel); @@ -6099,7 +6109,7 @@ contract DeployerTest is Test, IEligibilityModuleEvents { "registerAndQuickJoinWithPasskey selector mismatch" ); - // ── TaskManager (10) ── + // ── TaskManager (12) ── assertEq( bytes4(keccak256("createTask(uint256,bytes,bytes32,bytes32,address,uint256,bool)")), TaskManager.createTask.selector, @@ -6144,6 +6154,16 @@ contract DeployerTest is Test, IEligibilityModuleEvents { assertEq( bytes4(keccak256("cancelTask(uint256)")), TaskManager.cancelTask.selector, "cancelTask selector mismatch" ); + assertEq( + bytes4(keccak256("updateTask(uint256,uint256,bytes,bytes32,address,uint256)")), + TaskManager.updateTask.selector, + "updateTask selector mismatch" + ); + assertEq( + bytes4(keccak256("updateTaskMetadata(uint256,bytes,bytes32)")), + TaskManager.updateTaskMetadata.selector, + "updateTaskMetadata selector mismatch" + ); // ── HybridVoting (3) ── assertEq(