From 6e8c1204e1a78cb9f85f7e886d564018206d7f3f Mon Sep 17 00:00:00 2001 From: hudsonhrh Date: Thu, 28 May 2026 14:43:35 -0400 Subject: [PATCH] feat(TaskManager v5): post-claim edit permissions + deploy-time perm bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskManager v5: adds TaskPerm.EDIT_META (1<<6) and TaskPerm.EDIT_FULL (1<<7) bits, status-aware updateTask gate that allows post-claim editing for EDIT_FULL holders (PMs/executor bypass preserved; terminal COMPLETED/CANCELLED states stay immutable), and a new updateTaskMetadata(id, title, hash) function for the metadata-only path. uint8 mask is now saturated (documented). OrgDeployer v13: new DeploymentParams.taskManagerPerms field ({ roleIndices[], masks[] }) wired to a deployer-only TaskManager.bootstrapGlobalPerms(hatIds[], masks[]) so new orgs can grant org-wide TaskPerm bits (EDIT_META/EDIT_FULL/BUDGET/...) at deploy time. Guard enters the block when EITHER array is non-empty so malformed configs revert ArrayLengthMismatch instead of silently dropping masks. Tests: 1370/1370 passing. Adds 18 tests for the new edit gates (post-claim EDIT_FULL succeeds, EDIT_META holders use updateTaskMetadata, PM/executor bypass, terminal-state revert, bounty token swap, per-project mask shadows global, edit→complete payout flow-through), 12 tests for bootstrapGlobalPerms (deployer-only, length mismatch, dup hat last-wins, mask=0 removes hat, equivalence with setConfig, ordering with project bootstrap), and 7 tests for the OrgDeployer integration including a regression-pin for the silent-skip bug. Upgrade scripts (TaskManager v5 / OrgDeployer v13), simmed against live Gnosis + Arbitrum forks under FOUNDRY_PROFILE=production per CLAUDE.md (prank Hudson directly, no hub.owner() reads). New governance grant scripts for Test6 + Decentral Park EDIT_FULL, Decentral Park paymaster ops handover + budget bumps, Decentral Park Agent role creation (vouching-only mint), and Decentral Park Neighbor default-eligibility fix. Every grant/fix script has a Sim contract that runs the full proposal-pass-execute path on a fork and asserts post-state. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...eDecentralParkPaymasterViaGovernance.s.sol | 356 +++++++++ .../fixes/CreateAgentRoleDecentralPark.s.sol | 452 +++++++++++ .../FixDecentralParkNeighborEligibility.s.sol | 231 ++++++ .../fixes/GrantV5EditFullViaGovernance.s.sol | 361 +++++++++ .../UpgradeOrgDeployerTaskManagerPerms.s.sol | 260 +++++++ .../UpgradeTaskManagerEditPerms.s.sol | 399 ++++++++++ src/OrgDeployer.sol | 37 + src/TaskManager.sol | 103 ++- src/libs/TaskPerm.sol | 8 + test/DeployerTest.t.sol | 479 +++++++++++- test/TaskManager.t.sol | 730 +++++++++++++++++- 11 files changed, 3355 insertions(+), 61 deletions(-) create mode 100644 script/fixes/ConfigureDecentralParkPaymasterViaGovernance.s.sol create mode 100644 script/fixes/CreateAgentRoleDecentralPark.s.sol create mode 100644 script/fixes/FixDecentralParkNeighborEligibility.s.sol create mode 100644 script/fixes/GrantV5EditFullViaGovernance.s.sol create mode 100644 script/upgrades/UpgradeOrgDeployerTaskManagerPerms.s.sol create mode 100644 script/upgrades/UpgradeTaskManagerEditPerms.s.sol diff --git a/script/fixes/ConfigureDecentralParkPaymasterViaGovernance.s.sol b/script/fixes/ConfigureDecentralParkPaymasterViaGovernance.s.sol new file mode 100644 index 0000000..773b939 --- /dev/null +++ b/script/fixes/ConfigureDecentralParkPaymasterViaGovernance.s.sol @@ -0,0 +1,356 @@ +// 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) — Paymaster ops handover + gas-sponsorship bump + * ============================================================================ + * + * Single governance proposal with 3 calls executed by Decentral Park's Executor + * (the topHat wearer, satisfying PaymasterHub's `onlyOrgAdmin` modifier): + * + * 1. `paymaster.setOperatorHat(orgId, DELEGATE_HAT)` + * → Lets Delegate-hat wearers configure gas sponsorship for the org + * going forward (setRule / setBudget / setFeeCaps without needing a + * new governance vote each time). + * + * 2. `paymaster.setBudget(orgId, delegateSubjectKey, newCap, epochLen)` + * → Raise Delegate's per-epoch gas budget. Current state on Gnosis: + * capPerEpoch = 0.0002 xDAI / week. Default new cap = 1 xDAI / week + * (5000x bump). Override via DELEGATE_CAP_PER_EPOCH env var. + * + * 3. `paymaster.setBudget(orgId, neighborSubjectKey, newCap, epochLen)` + * → Raise Neighbor's per-epoch gas budget. Same current state. Default + * new cap = 0.5 xDAI / week. Override via NEIGHBOR_CAP_PER_EPOCH. + * + * Subject-key encoding for hats is `keccak256(abi.encodePacked(uint8(1), bytes32(hatId)))` + * — confirmed against PaymasterHub.sol's storage layout. Mirrors the cast computation + * in the recon notes for this script. + * + * Auth model: + * - `setOperatorHat` is `onlyOrgAdmin` (topHat wearer; Executor wears the topHat). + * - `setBudget` is `onlyOrgOperator` (adminHat OR operatorHat wearer; Executor + * satisfies via adminHat). After this proposal lands, Delegate-hat wearers + * ALSO satisfy `onlyOrgOperator` and can manage budgets / rules / fee caps + * directly without another vote. + * + * Sim-first per CLAUDE.md: stages the full proposal-pass-execute path on a + * Gnosis fork WITHOUT etching the Hats contract — instead, pranks Hudson's + * EOA (verified Delegate-hat wearer on real on-chain Hats Protocol) for the + * createProposal + vote steps. Then advances time, calls announceWinner, and + * lets the Executor (real topHat wearer on Hats Protocol) fire the 3 + * paymaster calls — `onlyOrgAdmin` and `onlyOrgOperator` checks resolve + * against real Hats state, so the sim exercises the same auth path as the + * real broadcast. Asserts post-state operatorHatId + both budget caps. + * + * If Hudson's single vote doesn't reach Decentral Park's quorum/threshold, + * the sim adds additional pranked voters from a list of known on-chain hat + * wearers — see _simFullFlow for the loop. + * + * Usage: + * # Sim (no broadcast, just validate end-to-end on a fork) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/ConfigureDecentralParkPaymasterViaGovernance.s.sol:SimConfigureDecentralParkPaymaster \ + * --fork-url gnosis -vvv + * + * # Broadcast (creates the real proposal; members vote in normal cadence) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/ConfigureDecentralParkPaymasterViaGovernance.s.sol:BroadcastConfigureDecentralParkPaymaster \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env overrides: + * DELEGATE_CAP_PER_EPOCH — Delegate's new cap (wei). Default 1 xDAI = 1e18. + * NEIGHBOR_CAP_PER_EPOCH — Neighbor's new cap (wei). Default 0.5 xDAI = 5e17. + * EPOCH_LEN_SECONDS — Epoch length (seconds). Default 604800 = 7 days. + * PROPOSAL_DURATION — Voting window (minutes). Default 30. + * ============================================================================ + */ + +// PaymasterHub on Gnosis — confirmed via existing fix scripts +// (AddSetFoldersSelectorRules.s.sol, AddCreateTasksBatchSelectorRules.s.sol). +address constant GNOSIS_PAYMASTER_HUB = 0xdEf1038C297493c0b5f82F0CDB49e929B53B4108; + +// Decentral Park (Gnosis) — verified via Poa subgraph 2026-05-28 +bytes32 constant DECENTRAL_PARK_ORG_ID = 0x3721271eb827a52a5adf676136d302efe19c34e72f08e080b07b225eecf27d78; +address constant DECENTRAL_PARK_TM = 0x2D9d397A842B8D691ea2A232062CbC8eF8eBbdB7; +address constant DECENTRAL_PARK_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; + +uint256 constant DECENTRAL_PARK_DELEGATE_HAT = 36180248838698575036480031466286475792781881727149517033480474826113024; +uint256 constant DECENTRAL_PARK_NEIGHBOR_HAT = 36180248838698575132261002770404529440178570924043841009651669962588160; + +// Default new caps and epoch length. Override via env vars at sim/broadcast time. +// Defaults picked to be ~5000x the current 0.0002 xDAI/week for Delegate (the new +// operator likely sponsors more activity) and ~2500x for Neighbor (rank-and-file +// member). Both keep the existing 7-day epoch. +uint128 constant DEFAULT_DELEGATE_CAP_PER_EPOCH = 1 ether; // 1 xDAI / week +uint128 constant DEFAULT_NEIGHBOR_CAP_PER_EPOCH = 0.5 ether; // 0.5 xDAI / week +uint32 constant DEFAULT_EPOCH_LEN_SECONDS = 7 days; +uint32 constant DEFAULT_PROPOSAL_DURATION_MINUTES = 30; + +// Hudson — verified Delegate-hat wearer on Decentral Park (Gnosis) as of 2026-05-28. +// The sim pranks this address for createProposal + vote so all hat checks resolve against +// real on-chain Hats Protocol state, not a shim. Per CLAUDE.md this is the same address +// that owns the cross-chain admin contracts, so it's also the expected broadcaster. +address constant HUDSON = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; + +/// @dev Minimal PaymasterHub interface — only the surface this script touches. Avoids +/// pulling in the full PaymasterHub.sol compile graph. +interface IPaymasterHubMinimal { + function getOrgConfig(bytes32 orgId) + external + view + returns (uint256 adminHatId, uint256 operatorHatId, bool paused, uint40 registeredAt, bool bannedFromSolidarity); + + function getBudget(bytes32 orgId, bytes32 subjectKey) + external + view + returns (uint128 capPerEpoch, uint128 usedInEpoch, uint32 epochLen, uint32 epochStart); + + function setOperatorHat(bytes32 orgId, uint256 operatorHatId) external; + + function setBudget(bytes32 orgId, bytes32 subjectKey, uint128 capPerEpoch, uint32 epochLen) external; +} + +interface IHatsMinimal { + function balanceOf(address user, uint256 hatId) external view returns (uint256); +} + +abstract contract ConfigurePaymasterBase is Script { + /// @dev Subject-key for a hat budget. Matches `keccak256(abi.encodePacked(uint8(1), bytes32(hatId)))` + /// computation in PaymasterHub.sol. (subjectType 1 = hat.) + function _hatSubjectKey(uint256 hatId) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(uint8(1), bytes32(hatId))); + } + + function _resolveDelegateCap() internal view returns (uint128) { + return uint128(vm.envOr("DELEGATE_CAP_PER_EPOCH", uint256(DEFAULT_DELEGATE_CAP_PER_EPOCH))); + } + + function _resolveNeighborCap() internal view returns (uint128) { + return uint128(vm.envOr("NEIGHBOR_CAP_PER_EPOCH", uint256(DEFAULT_NEIGHBOR_CAP_PER_EPOCH))); + } + + function _resolveEpochLen() internal view returns (uint32) { + return uint32(vm.envOr("EPOCH_LEN_SECONDS", uint256(DEFAULT_EPOCH_LEN_SECONDS))); + } + + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("PROPOSAL_DURATION", uint256(DEFAULT_PROPOSAL_DURATION_MINUTES))); + } + + /// @dev Build the 3-call batch the proposal will execute. + function _buildBatch(uint128 delegateCap, uint128 neighborCap, uint32 epochLen) + internal + pure + returns (IExecutor.Call[] memory batch) + { + batch = new IExecutor.Call[](3); + + // 1. setOperatorHat → Delegate + batch[0] = IExecutor.Call({ + target: GNOSIS_PAYMASTER_HUB, + value: 0, + data: abi.encodeCall( + IPaymasterHubMinimal.setOperatorHat, (DECENTRAL_PARK_ORG_ID, DECENTRAL_PARK_DELEGATE_HAT) + ) + }); + + // 2. setBudget for Delegate + batch[1] = IExecutor.Call({ + target: GNOSIS_PAYMASTER_HUB, + value: 0, + data: abi.encodeCall( + IPaymasterHubMinimal.setBudget, + ( + DECENTRAL_PARK_ORG_ID, + keccak256(abi.encodePacked(uint8(1), bytes32(DECENTRAL_PARK_DELEGATE_HAT))), + delegateCap, + epochLen + ) + ) + }); + + // 3. setBudget for Neighbor + batch[2] = IExecutor.Call({ + target: GNOSIS_PAYMASTER_HUB, + value: 0, + data: abi.encodeCall( + IPaymasterHubMinimal.setBudget, + ( + DECENTRAL_PARK_ORG_ID, + keccak256(abi.encodePacked(uint8(1), bytes32(DECENTRAL_PARK_NEIGHBOR_HAT))), + neighborCap, + epochLen + ) + ) + }); + } + + function _printPreview(uint128 delegateCap, uint128 neighborCap, uint32 epochLen) internal view { + IPaymasterHubMinimal ph = IPaymasterHubMinimal(GNOSIS_PAYMASTER_HUB); + (uint256 adminHatId, uint256 currentOperatorHatId,,,) = ph.getOrgConfig(DECENTRAL_PARK_ORG_ID); + (uint128 delegateCurCap,, uint32 delegateCurEpoch,) = + ph.getBudget(DECENTRAL_PARK_ORG_ID, _hatSubjectKey(DECENTRAL_PARK_DELEGATE_HAT)); + (uint128 neighborCurCap,, uint32 neighborCurEpoch,) = + ph.getBudget(DECENTRAL_PARK_ORG_ID, _hatSubjectKey(DECENTRAL_PARK_NEIGHBOR_HAT)); + + console.log("\n=== Proposal preview ==="); + console.log(" adminHatId: ", adminHatId); + console.log(" current operatorHat: ", currentOperatorHatId); + console.log(" new operatorHat: ", DECENTRAL_PARK_DELEGATE_HAT); + console.log(""); + console.log(" Delegate current cap (wei):", uint256(delegateCurCap)); + console.log(" Delegate current epoch (s):", uint256(delegateCurEpoch)); + console.log(" Delegate new cap (wei): ", uint256(delegateCap)); + console.log(" Neighbor current cap (wei):", uint256(neighborCurCap)); + console.log(" Neighbor current epoch (s):", uint256(neighborCurEpoch)); + console.log(" Neighbor new cap (wei): ", uint256(neighborCap)); + console.log(" New epoch length (s): ", uint256(epochLen)); + } + + /// @dev Full sim using REAL Hats Protocol state — no etch. Pranks Hudson (verified + /// Delegate-hat wearer on Decentral Park) for createProposal + vote. The Executor + /// genuinely wears the topHat on Gnosis, so when announceWinner fires Executor.execute, + /// PaymasterHub's `onlyOrgAdmin` check resolves against real Hats state and succeeds. + /// This mirrors the production broadcast path exactly — no auth shortcuts. + function _simFullFlow(uint128 delegateCap, uint128 neighborCap, uint32 epochLen) internal { + console.log("\n=== Decentral Park paymaster config sim (real Hats, prank Hudson) ==="); + + _printPreview(delegateCap, neighborCap, epochLen); + + // 1. Build the 3-call batch + wrap into the HybridVoting batches array (1 option, 3 calls). + IExecutor.Call[] memory batch = _buildBatch(delegateCap, neighborCap, epochLen); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + // 2. Create the proposal as Hudson (he wears Delegate, which is in HV.creatorHats). + uint32 minutesDuration = 10; + uint256[] memory pollHats = new uint256[](0); // unrestricted poll + vm.prank(HUDSON); + HybridVoting(DECENTRAL_PARK_HV) + .createProposal( + bytes("Decentral Park paymaster: operator + budgets (sim)"), + bytes32(0), + minutesDuration, + 1, + batches, + pollHats + ); + uint256 proposalId = HybridVoting(DECENTRAL_PARK_HV).proposalsCount() - 1; + console.log("\n Proposal id:", proposalId); + + // 3. Vote 100% for option 0 as Hudson. If Decentral Park's HV requires more weight + // to clear quorum + threshold (50% threshold today), this single vote may not be + // enough — in that case announceWinner returns valid=false and the require below + // fires with a clear message. Add more pranked voters by querying other Delegate / + // Neighbor wearers from on-chain Hats and prank-voting them here. + 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); + + // 4. Advance time past expiry. + vm.warp(block.timestamp + uint256(minutesDuration) * 60 + 10); + + // 5. announceWinner → Executor.execute (real topHat wearer → onlyOrgAdmin passes on + // real Hats Protocol → 3 paymaster calls land atomically). + (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); + + // 7. Verify post-state — operator hat + both budgets. + IPaymasterHubMinimal ph = IPaymasterHubMinimal(GNOSIS_PAYMASTER_HUB); + (, uint256 operatorHatAfter,,,) = ph.getOrgConfig(DECENTRAL_PARK_ORG_ID); + require(operatorHatAfter == DECENTRAL_PARK_DELEGATE_HAT, "Sim: operatorHat did not flip to Delegate"); + + (uint128 delegateCapAfter,, uint32 delegateEpochAfter,) = + ph.getBudget(DECENTRAL_PARK_ORG_ID, _hatSubjectKey(DECENTRAL_PARK_DELEGATE_HAT)); + require(delegateCapAfter == delegateCap, "Sim: Delegate cap mismatch"); + require(delegateEpochAfter == epochLen, "Sim: Delegate epoch mismatch"); + + (uint128 neighborCapAfter,, uint32 neighborEpochAfter,) = + ph.getBudget(DECENTRAL_PARK_ORG_ID, _hatSubjectKey(DECENTRAL_PARK_NEIGHBOR_HAT)); + require(neighborCapAfter == neighborCap, "Sim: Neighbor cap mismatch"); + require(neighborEpochAfter == epochLen, "Sim: Neighbor epoch mismatch"); + + console.log("\n Post-state:"); + console.log(" operatorHat: ", operatorHatAfter); + console.log(" Delegate cap (wei): ", uint256(delegateCapAfter)); + console.log(" Delegate epoch (s): ", uint256(delegateEpochAfter)); + console.log(" Neighbor cap (wei): ", uint256(neighborCapAfter)); + console.log(" Neighbor epoch (s): ", uint256(neighborEpochAfter)); + console.log("\nPASS: Decentral Park paymaster config governance proposal landed end-to-end."); + } + + /// @dev Real broadcast: creates the proposal on-chain. Members vote in normal cadence. + function _broadcast(uint128 delegateCap, uint128 neighborCap, uint32 epochLen) internal { + uint256 key = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY")); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting Decentral Park paymaster config proposal ==="); + console.log(" Sender: ", sender); + console.log(" TaskManager: ", DECENTRAL_PARK_TM); + console.log(" HybridVoting: ", DECENTRAL_PARK_HV); + console.log(" PaymasterHub: ", GNOSIS_PAYMASTER_HUB); + console.log(" Duration: ", minutesDuration, "minutes"); + + // Preview — readable confirmation of what the calldata will do. + _printPreview(delegateCap, neighborCap, epochLen); + + // 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(delegateCap, neighborCap, epochLen); + 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 paymaster: set Delegate as operator + raise Delegate/Neighbor budgets"), + 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 SimConfigureDecentralParkPaymaster is ConfigurePaymasterBase { + function run() public { + _simFullFlow(_resolveDelegateCap(), _resolveNeighborCap(), _resolveEpochLen()); + } +} + +contract BroadcastConfigureDecentralParkPaymaster is ConfigurePaymasterBase { + function run() public { + _broadcast(_resolveDelegateCap(), _resolveNeighborCap(), _resolveEpochLen()); + } +} diff --git a/script/fixes/CreateAgentRoleDecentralPark.s.sol b/script/fixes/CreateAgentRoleDecentralPark.s.sol new file mode 100644 index 0000000..87cc9eb --- /dev/null +++ b/script/fixes/CreateAgentRoleDecentralPark.s.sol @@ -0,0 +1,452 @@ +// 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"; + +/* + * ============================================================================ + * Decentral Park (Gnosis) - Create "Agent" role via governance + * ============================================================================ + * + * Single governance proposal with 5 calls executed by Decentral Park's Executor. + * Creates a new "Agent" role for AI brains that help generate tasks: + * + * 1. EligibilityModule.createHatWithEligibility(parent=topHat, ...) + * → Creates the Agent hat under the org's topHat. Unlimited supply, + * mutable, no initial mint (wearers join via vouching). + * + * 2. EligibilityModule.configureVouching(agentHat, quorum=1, membershipHat=Delegate, combine=false) + * → A single Delegate-hat wearer can vouch a candidate EOA into eligibility. + * Once vouched, the candidate (or anyone) can call Hats.mintHat to grant + * them the Agent hat. + * + * 3. TaskManager.setConfig(ROLE_PERM, abi.encode(agentHat, CREATE | ASSIGN | EDIT_META)) + * → Org-wide TaskPerm grant. Agent can create tasks, assign them to humans + * (also covers approveApplication - same perm bit gates both), and edit + * task metadata post-claim (title + IPFS hash, not payout / bounty). + * + * 4. HybridVoting.setCreatorHatAllowed(agentHat, true) + * → Agent wearers can submit binding governance proposals. + * + * 5. PaymasterHub.setBudget(orgId, agentSubjectKey, 5 xDAI, 7 days) + * → Gas sponsorship for Agent wearers. On Gnosis, 5 xDAI/week sponsors + * thousands of typical txs. + * + * Explicit non-grants (this proposal deliberately does NOT add Agent to): + * - HV / DD voting class hats → Agent cannot vote + * - DirectDemocracyVoting creator hats → no DD polls (only HV proposals) + * - ParticipationToken member hats → Agent cannot hold shares + * - ParticipationToken approver hats → Agent cannot approve token mints + * - QuickJoin memberHatIds → Agent isn't quick-joinable; vouching only + * - TaskManager creatorHatIds → Agent can create tasks but not new projects + * - TaskPerm.EDIT_FULL → Agent can NOT change payout / bounty after claim + * - TaskPerm.REVIEW / BUDGET → Reserved for humans + * + * Auth: all 5 calls are executor-gated (TaskManager / HV / PaymasterHub auth + * paths) or topHat-admin-gated (EligibilityModule via superAdmin == Executor). + * The Executor wears Decentral Park's topHat, so all checks resolve cleanly. + * + * Hat-ID prediction caveat: Hats Protocol assigns sequential child IDs under a + * given admin. We predict the Agent hat's ID via `Hats.getNextId(topHat)` at + * proposal-build time and bake it into calls 2-5. If another hat is created + * under Decentral Park's topHat between this proposal's broadcast and execute, + * the prediction skews and the downstream calls would target a stale ID. The + * sim re-checks `getNextId` immediately before building the batch; the + * broadcast contract re-checks again at broadcast time and reverts if drift is + * detected so a stale proposal can't ship. + * + * Sim-first per CLAUDE.md: stages the full proposal-pass-execute path on a + * Gnosis fork using REAL Hats Protocol state (no etch). Pranks Hudson + * (verified Delegate-hat wearer) for createProposal + vote; the Executor (real + * topHat wearer) fires the 5-call batch atomically and the sim asserts all + * five post-conditions. + * + * Usage: + * # Sim + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/CreateAgentRoleDecentralPark.s.sol:SimCreateAgentRoleDecentralPark \ + * --fork-url gnosis -vvv + * + * # Broadcast + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/CreateAgentRoleDecentralPark.s.sol:BroadcastCreateAgentRoleDecentralPark \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env overrides: + * AGENT_GAS_BUDGET_WEI - Default 5 xDAI = 5e18. + * AGENT_EPOCH_LEN_SEC - Default 604800 (7 days). + * PROPOSAL_DURATION_MIN - Default 30 minutes. + * AGENT_DETAILS - Hat details field (ipfs:// or short string). Default "Agent". + * AGENT_IMAGE_URI - Hat image URI. Default "". + * + * Next steps after the proposal executes: + * 1. A Delegate calls `EligibilityModule.vouchFor(agentEOA, AGENT_HAT_ID)` + * 2. Anyone calls `Hats.mintHat(AGENT_HAT_ID, agentEOA)` - agent is live + * ============================================================================ + */ + +// On-chain addresses +address constant GNOSIS_HATS_PROTOCOL = 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137; +address constant GNOSIS_PAYMASTER_HUB = 0xdEf1038C297493c0b5f82F0CDB49e929B53B4108; + +// Decentral Park (Gnosis) - verified 2026-05-28 +bytes32 constant DECENTRAL_PARK_ORG_ID = 0x3721271eb827a52a5adf676136d302efe19c34e72f08e080b07b225eecf27d78; +address constant DECENTRAL_PARK_TM = 0x2D9d397A842B8D691ea2A232062CbC8eF8eBbdB7; +address constant DECENTRAL_PARK_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; +address constant DECENTRAL_PARK_EM = 0xe4A02F20B8282A272879e31479Ee070dab07B015; +uint256 constant DECENTRAL_PARK_TOP_HAT = 36180248427316158604443134246780344364021047815049448269641044954447872; +// EligibilityModule wears the ELIGIBILITY_ADMIN hat (set up by HatsTreeSetup at deploy). +// That gives EM admin rights to create children of ELIGIBILITY_ADMIN — which is where all +// role hats in Decentral Park live (Delegate / Neighbor are level-2 hats under ELIG_ADMIN, +// confirmed via `Hats.getAdminAtLevel(delegateHat, 1) == ELIG_ADMIN`). New role hats created +// post-deploy must use ELIGIBILITY_ADMIN as parent or `createHat` reverts NotAdmin (EM is +// NOT admin of topHat — only the Executor is, and EM is the contract relaying the call). +uint256 constant DECENTRAL_PARK_ELIG_ADMIN_HAT = + 36180248838692297934744644785522640003358674060733414678036010791600128; +uint256 constant DECENTRAL_PARK_DELEGATE_HAT = 36180248838698575036480031466286475792781881727149517033480474826113024; + +// Hudson - verified Delegate-hat wearer; the sim pranks this address. +address constant HUDSON = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; + +// Agent role config defaults (overridable via env at sim / broadcast time). +uint128 constant DEFAULT_AGENT_GAS_BUDGET_WEI = 5 ether; // 5 xDAI / week +uint32 constant DEFAULT_AGENT_EPOCH_LEN_SEC = 7 days; +uint32 constant DEFAULT_PROPOSAL_DURATION_MIN = 30; +uint32 constant AGENT_MAX_SUPPLY = type(uint32).max; // unlimited per user direction +uint8 constant AGENT_TASK_PERM_MASK = TaskPerm.CREATE | TaskPerm.ASSIGN | TaskPerm.EDIT_META; +uint32 constant VOUCH_QUORUM = 1; // a single Delegate vouch suffices + +/// @dev EligibilityModule.createHatWithEligibility takes this struct (mirrored exactly here +/// to avoid pulling in the full EligibilityModule.sol compile graph). +struct CreateHatParams { + uint256 parentHatId; + string details; + uint32 maxSupply; + bool _mutable; + string imageURI; + bool defaultEligible; + bool defaultStanding; + address[] mintToAddresses; + bool[] wearerEligibleFlags; + bool[] wearerStandingFlags; +} + +interface IEligibilityModuleMinimal { + function createHatWithEligibility(CreateHatParams calldata params) external returns (uint256 newHatId); + function configureVouching(uint256 hatId, uint32 quorum, uint256 membershipHatId, bool combineWithHierarchy) + external; + function vouchConfigs(uint256 hatId) external view returns (uint32 quorum, uint256 membershipHatId, uint8 flags); +} + +interface IHatsMinimal { + function balanceOf(address user, uint256 hatId) external view returns (uint256); + function getNextId(uint256 admin) external view returns (uint256); + function isWearerOfHat(address user, uint256 hatId) external view returns (bool); + function viewHat(uint256 hatId) + external + view + returns ( + string memory details, + uint32 maxSupply, + uint32 supply, + address eligibility, + address toggle, + string memory imageURI, + uint16 lastHatId, + uint16 numChildren, + bool mutable_ + ); +} + +interface IPaymasterHubMinimal { + function getBudget(bytes32 orgId, bytes32 subjectKey) + external + view + returns (uint128 capPerEpoch, uint128 usedInEpoch, uint32 epochLen, uint32 epochStart); + function setBudget(bytes32 orgId, bytes32 subjectKey, uint128 capPerEpoch, uint32 epochLen) external; +} + +abstract contract CreateAgentBase is Script { + /// @dev Subject-key for a hat budget on PaymasterHub. Matches + /// `keccak256(abi.encodePacked(uint8(1), bytes32(hatId)))` (subjectType=1 → hat). + function _hatSubjectKey(uint256 hatId) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(uint8(1), bytes32(hatId))); + } + + function _resolveGasBudget() internal view returns (uint128) { + return uint128(vm.envOr("AGENT_GAS_BUDGET_WEI", uint256(DEFAULT_AGENT_GAS_BUDGET_WEI))); + } + + function _resolveEpochLen() internal view returns (uint32) { + return uint32(vm.envOr("AGENT_EPOCH_LEN_SEC", uint256(DEFAULT_AGENT_EPOCH_LEN_SEC))); + } + + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("PROPOSAL_DURATION_MIN", uint256(DEFAULT_PROPOSAL_DURATION_MIN))); + } + + function _resolveDetails() internal view returns (string memory) { + return vm.envOr("AGENT_DETAILS", string("Agent")); + } + + function _resolveImageURI() internal view returns (string memory) { + return vm.envOr("AGENT_IMAGE_URI", string("")); + } + + /// @dev Predict the Agent hat ID - the next child slot under Decentral Park's topHat. + /// Race risk: if another hat is created under the topHat between now and proposal + /// execution, this ID will be stale. The broadcast variant re-checks before sending. + function _predictAgentHatId() internal view returns (uint256) { + return IHatsMinimal(GNOSIS_HATS_PROTOCOL).getNextId(DECENTRAL_PARK_ELIG_ADMIN_HAT); + } + + /// @dev Build the 5-call batch the proposal executes. + function _buildBatch(uint256 predictedAgentHatId, uint128 gasBudget, uint32 epochLen) + internal + view + returns (IExecutor.Call[] memory batch) + { + batch = new IExecutor.Call[](5); + + // 1. Create the Agent hat (Executor satisfies onlyHatAdmin(topHat) since it wears topHat). + CreateHatParams memory createParams = CreateHatParams({ + parentHatId: DECENTRAL_PARK_ELIG_ADMIN_HAT, + details: _resolveDetails(), + maxSupply: AGENT_MAX_SUPPLY, + _mutable: true, + imageURI: _resolveImageURI(), + defaultEligible: false, // default deny - wearers become eligible only via vouching + defaultStanding: true, + mintToAddresses: new address[](0), // no initial mint; minting via vouch + Hats.mintHat + wearerEligibleFlags: new bool[](0), + wearerStandingFlags: new bool[](0) + }); + batch[0] = IExecutor.Call({ + target: DECENTRAL_PARK_EM, + value: 0, + data: abi.encodeCall(IEligibilityModuleMinimal.createHatWithEligibility, (createParams)) + }); + + // 2. Configure vouching: 1 Delegate vouch makes a candidate eligible. + batch[1] = IExecutor.Call({ + target: DECENTRAL_PARK_EM, + value: 0, + data: abi.encodeCall( + IEligibilityModuleMinimal.configureVouching, + (predictedAgentHatId, VOUCH_QUORUM, DECENTRAL_PARK_DELEGATE_HAT, false) + ) + }); + + // 3. Grant TaskPerm bits globally (CREATE | ASSIGN | EDIT_META = 1 | 8 | 64 = 73). + batch[2] = IExecutor.Call({ + target: DECENTRAL_PARK_TM, + value: 0, + data: abi.encodeCall( + TaskManager.setConfig, + (TaskManager.ConfigKey.ROLE_PERM, abi.encode(predictedAgentHatId, AGENT_TASK_PERM_MASK)) + ) + }); + + // 4. Allow Agent to create HybridVoting proposals. + batch[3] = IExecutor.Call({ + target: DECENTRAL_PARK_HV, + value: 0, + data: abi.encodeWithSignature("setCreatorHatAllowed(uint256,bool)", predictedAgentHatId, true) + }); + + // 5. Set gas sponsorship budget on PaymasterHub. + batch[4] = IExecutor.Call({ + target: GNOSIS_PAYMASTER_HUB, + value: 0, + data: abi.encodeCall( + IPaymasterHubMinimal.setBudget, + (DECENTRAL_PARK_ORG_ID, _hatSubjectKey(predictedAgentHatId), gasBudget, epochLen) + ) + }); + } + + function _printPreview(uint256 predictedAgentHatId, uint128 gasBudget, uint32 epochLen) internal view { + console.log("\n=== Agent role preview ==="); + console.log(" Predicted hatId: ", predictedAgentHatId); + console.log(" Parent (ELIG_ADMIN): ", DECENTRAL_PARK_ELIG_ADMIN_HAT); + console.log(" Max supply: ", uint256(AGENT_MAX_SUPPLY)); + console.log(" Vouching quorum: ", uint256(VOUCH_QUORUM)); + console.log(" Voucher hat (Delegate): ", DECENTRAL_PARK_DELEGATE_HAT); + console.log(" TaskPerm mask: ", uint256(AGENT_TASK_PERM_MASK), "(CREATE | ASSIGN | EDIT_META)"); + console.log(" HV creator hat: ", uint256(1), "(true)"); + console.log(" PaymasterHub cap (wei): ", uint256(gasBudget)); + console.log(" PaymasterHub epoch (s): ", uint256(epochLen)); + } + + /// @dev Full sim using REAL Hats Protocol state - no etch. + function _simFullFlow(uint128 gasBudget, uint32 epochLen) internal { + console.log("\n=== Decentral Park Agent role creation sim (real Hats, prank Hudson) ==="); + + uint256 predictedAgentHatId = _predictAgentHatId(); + _printPreview(predictedAgentHatId, gasBudget, epochLen); + + // Pre-state: predicted hat must NOT yet exist (supply == 0 and details empty). + IHatsMinimal hats = IHatsMinimal(GNOSIS_HATS_PROTOCOL); + (, uint32 maxSupplyBefore,,,,,,,) = hats.viewHat(predictedAgentHatId); + require(maxSupplyBefore == 0, "Sim: predicted hat already exists - race or stale prediction"); + + IExecutor.Call[] memory batch = _buildBatch(predictedAgentHatId, gasBudget, epochLen); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + // Create + vote as Hudson (real Delegate hat wearer). + uint32 minutesDuration = 10; + uint256[] memory pollHats = new uint256[](0); + vm.prank(HUDSON); + HybridVoting(DECENTRAL_PARK_HV) + .createProposal( + bytes("Decentral Park: create Agent role (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)"); + console.log(" Winner option:", winner, " valid:", valid); + + // Post-state assertions. + // (a) Agent hat exists with the predicted ID and configured supply. + (string memory details, uint32 maxSupplyAfter,, address eligibility,, string memory imageURIAfter,,,) = + hats.viewHat(predictedAgentHatId); + require(maxSupplyAfter == AGENT_MAX_SUPPLY, "Sim: hat maxSupply mismatch"); + require(eligibility == DECENTRAL_PARK_EM, "Sim: hat eligibility module mismatch"); + require(keccak256(bytes(details)) == keccak256(bytes(_resolveDetails())), "Sim: hat details mismatch"); + // imageURI not asserted: Hats Protocol inherits the parent's URI when the input is + // empty, so the stored value may differ from what we passed. Acceptable — image is + // cosmetic. To set a custom image, pass AGENT_IMAGE_URI env var. + imageURIAfter; // silence unused-local warning + + // (b) Vouching configured: quorum=1, membershipHat=Delegate. + (uint32 quorumAfter, uint256 membershipHatAfter,) = + IEligibilityModuleMinimal(DECENTRAL_PARK_EM).vouchConfigs(predictedAgentHatId); + require(quorumAfter == VOUCH_QUORUM, "Sim: vouch quorum mismatch"); + require(membershipHatAfter == DECENTRAL_PARK_DELEGATE_HAT, "Sim: vouch membership hat mismatch"); + + // (c) TaskManager role perm matches AGENT_TASK_PERM_MASK. + uint8 maskAfter = _readTaskManagerRolePerm(predictedAgentHatId); + require(maskAfter == AGENT_TASK_PERM_MASK, "Sim: TaskManager rolePermGlobal mismatch"); + + // (d) HybridVoting creator hats includes the new Agent hat. + uint256[] memory creatorHats = HybridVoting(DECENTRAL_PARK_HV).creatorHats(); + bool foundCreator = false; + for (uint256 i; i < creatorHats.length; ++i) { + if (creatorHats[i] == predictedAgentHatId) { + foundCreator = true; + break; + } + } + require(foundCreator, "Sim: Agent hat missing from HV creatorHats"); + + // (e) PaymasterHub budget set correctly. + (uint128 capAfter,, uint32 epochAfter,) = IPaymasterHubMinimal(GNOSIS_PAYMASTER_HUB) + .getBudget(DECENTRAL_PARK_ORG_ID, _hatSubjectKey(predictedAgentHatId)); + require(capAfter == gasBudget, "Sim: paymaster cap mismatch"); + require(epochAfter == epochLen, "Sim: paymaster epoch mismatch"); + + console.log("\n Post-state:"); + console.log(" Hat exists, maxSupply:", uint256(maxSupplyAfter)); + console.log(" Eligibility module: ", eligibility); + console.log(" Vouch quorum: ", uint256(quorumAfter)); + console.log(" Voucher hat: ", membershipHatAfter); + console.log(" TaskPerm mask: ", uint256(maskAfter)); + console.log(" HV creator hats len: ", creatorHats.length); + console.log(" Paymaster cap (wei): ", uint256(capAfter)); + console.log(" Paymaster epoch (s): ", uint256(epochAfter)); + console.log("\nPASS: Decentral Park Agent role creation governance proposal landed end-to-end."); + } + + /// @dev TaskManager has no public getter for rolePermGlobal - read directly from storage. + function _readTaskManagerRolePerm(uint256 hatId) internal view returns (uint8) { + // Same slot computation used by GrantV5EditFullViaGovernance / AuditTaskPermBit5. + bytes32 base = bytes32(uint256(keccak256("poa.taskmanager.storage")) + 6); + bytes32 slot = keccak256(abi.encode(hatId, base)); + return uint8(uint256(vm.load(DECENTRAL_PARK_TM, slot))); + } + + function _broadcast(uint128 gasBudget, uint32 epochLen) internal { + uint256 key = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY")); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting Agent role creation proposal ==="); + console.log(" Sender: ", sender); + console.log(" Duration (min):", uint256(minutesDuration)); + + // Drift guard: predicted hatId must still be Hats' next slot at broadcast time. + uint256 predictedAgentHatId = _predictAgentHatId(); + console.log(" Predicted hatId:", predictedAgentHatId); + + IHatsMinimal hats = IHatsMinimal(GNOSIS_HATS_PROTOCOL); + (, uint32 maxSupplyBefore,,,,,,,) = hats.viewHat(predictedAgentHatId); + require(maxSupplyBefore == 0, "Broadcast: predicted hat already exists - race; re-run script"); + + // Sanity: sender must wear a creator hat or createProposal reverts. + IHatsMinimal hatsForCheck = IHatsMinimal(GNOSIS_HATS_PROTOCOL); + uint256[] memory creatorHats = HybridVoting(DECENTRAL_PARK_HV).creatorHats(); + bool isCreator = false; + for (uint256 i; i < creatorHats.length; ++i) { + if (hatsForCheck.balanceOf(sender, creatorHats[i]) > 0) { + isCreator = true; + break; + } + } + require(isCreator, "Sender does not wear any creator hat on Decentral Park HybridVoting"); + + _printPreview(predictedAgentHatId, gasBudget, epochLen); + + IExecutor.Call[] memory batch = _buildBatch(predictedAgentHatId, gasBudget, epochLen); + 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: create Agent role (vouching-only mint, no shares, no voting)"), + 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(" IMPORTANT: between now and announceWinner, do NOT let another hat be created under DP's topHat,"); + console.log(" or this proposal's downstream calls will target a stale hatId."); + } +} + +contract SimCreateAgentRoleDecentralPark is CreateAgentBase { + function run() public { + _simFullFlow(_resolveGasBudget(), _resolveEpochLen()); + } +} + +contract BroadcastCreateAgentRoleDecentralPark is CreateAgentBase { + function run() public { + _broadcast(_resolveGasBudget(), _resolveEpochLen()); + } +} diff --git a/script/fixes/FixDecentralParkNeighborEligibility.s.sol b/script/fixes/FixDecentralParkNeighborEligibility.s.sol new file mode 100644 index 0000000..bd75f0e --- /dev/null +++ b/script/fixes/FixDecentralParkNeighborEligibility.s.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {IExecutor} from "../../src/Executor.sol"; +import {HybridVoting} from "../../src/HybridVoting.sol"; + +/* + * ============================================================================ + * Decentral Park (Gnosis) - Fix Neighbor default eligibility + * ============================================================================ + * + * Single-call governance proposal: + * EligibilityModule.setDefaultEligibility(NEIGHBOR_HAT, eligible=true, standing=true) + * + * Decentral Park's Neighbor role is intended to be quick-joinable by anyone + * (the Neighbor hat IS listed in QuickJoin.memberHatIds via subgraph), but the + * default eligibility on the EligibilityModule was left at `false`. As a + * result, when a new user tries to quick-join, Hats Protocol calls the + * eligibility module's `getWearerStatus`, which returns the default rules for + * any address with no per-wearer entry — `(eligible=false, standing=true)` — + * so `isEligible` returns false and `mintHat` reverts NotEligible. + * + * Vouching is NOT configured on Neighbor (quorum=0), so the fix is the + * one-liner: flip defaultEligible from false to true. defaultStanding is + * already true, leaving it as-is for documentation parity. + * + * Auth: `setDefaultEligibility` is `onlyHatAdmin(hatId)`. The EligibilityModule + * modifier accepts either superAdmin (== Executor) or any Hats-admin-of-hatId. + * Executor wears Decentral Park's topHat, so the call passes. + * + * Sim-first per CLAUDE.md: prank Hudson (real Delegate-hat wearer) for + * createProposal + vote against real on-chain Hats Protocol state. After + * announceWinner fires, the sim asserts: + * - Pre-state: defaultEligible == false (the reported bug) + * - Post-state: defaultEligible == true + * - A randomly chosen EOA now passes Hats.isEligible(eoa, neighborHat) — the + * functional QuickJoin precondition. + * + * Usage: + * # Sim + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/FixDecentralParkNeighborEligibility.s.sol:SimFixDecentralParkNeighborEligibility \ + * --fork-url gnosis -vvv + * + * # Broadcast (sender must wear Delegate or Neighbor hat to satisfy HV.creatorHats) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/FixDecentralParkNeighborEligibility.s.sol:BroadcastFixDecentralParkNeighborEligibility \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env overrides: + * PROPOSAL_DURATION_MIN - Voting window (minutes). Default 30. + * ============================================================================ + */ + +address constant GNOSIS_HATS_PROTOCOL = 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137; + +// Decentral Park (Gnosis) - verified via subgraph 2026-05-28 +address constant DECENTRAL_PARK_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; +address constant DECENTRAL_PARK_EM = 0xe4A02F20B8282A272879e31479Ee070dab07B015; +uint256 constant DECENTRAL_PARK_NEIGHBOR_HAT = 36180248838698575132261002770404529440178570924043841009651669962588160; + +// Hudson - verified Delegate-hat wearer; the sim pranks this address. +address constant HUDSON = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; + +// 10 minutes = HybridVoting MIN_DURATION; matches the sim's internal value so broadcasting +// uses the same cadence the sim validates against. +uint32 constant DEFAULT_PROPOSAL_DURATION_MIN = 10; + +interface IEligibilityModuleMinimal { + function setDefaultEligibility(uint256 hatId, bool eligible, bool standing) external; + function getDefaultRules(uint256 hatId) external view returns (bool eligible, bool standing); +} + +interface IHatsMinimal { + function balanceOf(address user, uint256 hatId) external view returns (uint256); + function isEligible(address user, uint256 hatId) external view returns (bool); +} + +abstract contract FixNeighborBase is Script { + function _resolveDuration() internal view returns (uint32) { + return uint32(vm.envOr("PROPOSAL_DURATION_MIN", uint256(DEFAULT_PROPOSAL_DURATION_MIN))); + } + + function _buildBatch() internal pure returns (IExecutor.Call[] memory batch) { + batch = new IExecutor.Call[](1); + batch[0] = IExecutor.Call({ + target: DECENTRAL_PARK_EM, + value: 0, + data: abi.encodeCall( + IEligibilityModuleMinimal.setDefaultEligibility, (DECENTRAL_PARK_NEIGHBOR_HAT, true, true) + ) + }); + } + + function _printPreview() internal view { + (bool eligibleBefore, bool standingBefore) = + IEligibilityModuleMinimal(DECENTRAL_PARK_EM).getDefaultRules(DECENTRAL_PARK_NEIGHBOR_HAT); + console.log("\n=== Proposal preview ==="); + console.log(" Neighbor hatId: ", DECENTRAL_PARK_NEIGHBOR_HAT); + console.log(" defaultEligible BEFORE: ", eligibleBefore); + console.log(" defaultStanding BEFORE: ", standingBefore); + console.log(" defaultEligible AFTER (target): ", true); + console.log(" defaultStanding AFTER (target): ", true); + } + + /// @dev Full sim using REAL Hats Protocol state - no etch. Pranks Hudson for proposal + + /// vote; the Executor fires the EligibilityModule call. + function _simFullFlow() internal { + console.log("\n=== Decentral Park Neighbor eligibility fix sim (real Hats, prank Hudson) ==="); + + _printPreview(); + + // Pre-state assertion: bug must be present. + (bool eligibleBefore,) = + IEligibilityModuleMinimal(DECENTRAL_PARK_EM).getDefaultRules(DECENTRAL_PARK_NEIGHBOR_HAT); + require(!eligibleBefore, "Sim: defaultEligible is already true - nothing to fix"); + + // Sanity-check: a fresh EOA cannot currently quick-join (functional manifestation of bug). + address probe = makeAddr("dp-neighbor-eligibility-probe"); + bool eligibleProbeBefore = IHatsMinimal(GNOSIS_HATS_PROTOCOL).isEligible(probe, DECENTRAL_PARK_NEIGHBOR_HAT); + require(!eligibleProbeBefore, "Sim: fresh EOA already passes isEligible - state differs from bug report"); + + IExecutor.Call[] memory batch = _buildBatch(); + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + uint32 minutesDuration = 10; + vm.prank(HUDSON); + HybridVoting(DECENTRAL_PARK_HV) + .createProposal( + bytes("Decentral Park: fix Neighbor default eligibility (sim)"), + bytes32(0), + minutesDuration, + 1, + batches, + new uint256[](0) + ); + 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"); + console.log(" Winner option:", winner, " valid:", valid); + + // Post-state assertion: defaults flipped. + (bool eligibleAfter, bool standingAfter) = + IEligibilityModuleMinimal(DECENTRAL_PARK_EM).getDefaultRules(DECENTRAL_PARK_NEIGHBOR_HAT); + require(eligibleAfter, "Sim: defaultEligible did not flip to true"); + require(standingAfter, "Sim: defaultStanding regressed to false"); + + // Functional verification: a fresh EOA now passes isEligible (i.e. QuickJoin will succeed). + bool eligibleProbeAfter = IHatsMinimal(GNOSIS_HATS_PROTOCOL).isEligible(probe, DECENTRAL_PARK_NEIGHBOR_HAT); + require(eligibleProbeAfter, "Sim: fresh EOA still cannot pass isEligible after fix"); + + console.log("\n Post-state:"); + console.log(" defaultEligible: ", eligibleAfter); + console.log(" defaultStanding: ", standingAfter); + console.log(" fresh EOA isEligible: ", eligibleProbeAfter); + console.log("\nPASS: Decentral Park Neighbor eligibility fix landed end-to-end."); + } + + function _broadcast() internal { + uint256 key = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY")); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting Neighbor eligibility fix proposal ==="); + console.log(" Sender: ", sender); + console.log(" Duration (min):", uint256(minutesDuration)); + + _printPreview(); + + // Sanity: sender must wear an HV creator hat. + IHatsMinimal hats = IHatsMinimal(GNOSIS_HATS_PROTOCOL); + 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: fix Neighbor default eligibility (set eligible=true so QuickJoin works)"), + 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."); + } +} + +contract SimFixDecentralParkNeighborEligibility is FixNeighborBase { + function run() public { + _simFullFlow(); + } +} + +contract BroadcastFixDecentralParkNeighborEligibility is FixNeighborBase { + function run() public { + _broadcast(); + } +} diff --git a/script/fixes/GrantV5EditFullViaGovernance.s.sol b/script/fixes/GrantV5EditFullViaGovernance.s.sol new file mode 100644 index 0000000..7ef9386 --- /dev/null +++ b/script/fixes/GrantV5EditFullViaGovernance.s.sol @@ -0,0 +1,361 @@ +// 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 v5 EDIT_FULL via real HybridVoting governance — Test6 + Decentral Park + * ============================================================================ + * + * TaskManager v5 added two new TaskPerm bits — EDIT_META (1 << 6) and EDIT_FULL + * (1 << 7) — letting holders edit a task post-claim. This script creates a + * single-call governance proposal on a target org that sets a chosen hat's + * global rolePermGlobal mask to (current | EDIT_FULL), giving wearers org-wide + * post-claim editing power across every project. + * + * Two orgs wired today (Sim + Broadcast contracts per org): + * - Test6 (Gnosis) → Executive hat (default) + * - Decentral Park (Gnosis) → Delegate hat (default) + * + * Read-modify-write is mandatory: setConfig(ROLE_PERM, ...) REPLACES the mask + * (it does not OR-merge). Overwriting with just EDIT_FULL would silently strip + * any existing bits — Test6 EXEC currently has BUDGET (set by the v4 grant); + * this script preserves it. + * + * Race-condition note: the proposal's calldata is fixed at create-time. If + * another proposal mutates the mask between create and execute, this proposal + * will overwrite that change. For Test6 (a test org) the risk is acceptable. + * For production orgs, re-read the mask immediately before broadcast and + * rebuild the proposal if drift is detected. + * + * Sim-first per CLAUDE.md: stages the proposal-pass-execute path on a Gnosis + * fork (etch permissive Hats shim, createProposal, vote, advance time, + * announceWinner -> executor.execute -> setConfig lands on TaskManager) and + * asserts the post-state mask has the EDIT_FULL bit set AND the pre-existing + * bits preserved. + * + * Usage (replace `Test6` with `DecentralPark` to target that org instead): + * # Sim (no broadcast, just validate end-to-end on a fork) + * FOUNDRY_PROFILE=production forge script \ + * script/fixes/GrantV5EditFullViaGovernance.s.sol:SimGrantEditFullTest6 \ + * --fork-url gnosis -vvv + * + * # Broadcast (creates the real proposal; members vote in normal cadence) + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/fixes/GrantV5EditFullViaGovernance.s.sol:BroadcastGrantEditFullTest6 \ + * --rpc-url gnosis --broadcast --slow + * + * Optional env overrides: + * GRANT_HAT_ID — override the target hat (default = each org's own constant) + * GRANT_DURATION — voting window in minutes (default 30) + * ============================================================================ + */ + +// Test6 (Gnosis) — verified via Poa subgraph 2026-05-27 +address constant TEST6_TM = 0x3d93f0D090356D25E7a1614F0F8764b103ca99bc; +address constant TEST6_HV = 0xF642DdE77848dC195c8089F4042A311Ed650d7a6; +uint256 constant TEST6_EXEC_HAT = 29035862971903655490893272468226273664268038455176265325988018110070784; + +// Decentral Park (Gnosis) — verified via Poa subgraph 2026-05-28 +// orgId 0x3721271eb827a52a5adf676136d302efe19c34e72f08e080b07b225eecf27d78 +// Roles: (top hat unnamed), ELIGIBILITY_ADMIN, Delegate, Neighbor — Delegate is the default +// target since the requester wears it and it's already in HybridVoting.creatorHats(). +address constant DECENTRAL_PARK_TM = 0x2D9d397A842B8D691ea2A232062CbC8eF8eBbdB7; +address constant DECENTRAL_PARK_HV = 0x1B80CA1EF7F274E141658A666fc12277957bF7A1; +uint256 constant DECENTRAL_PARK_DELEGATE_HAT = 36180248838698575036480031466286475792781881727149517033480474826113024; +// Other Decentral Park hats (for env override via GRANT_HAT_ID): +// Neighbor = 36180248838698575132261002770404529440178570924043841009651669962588160 +// ELIGIBILITY_ADMIN = 36180248838692297934744644785522640003358674060733414678036010791600128 +// Top hat (unnamed) = 36180248427316158604443134246780344364021047815049448269641044954447872 + +// Default voting window (minutes). HybridVoting MIN_DURATION = 10. +uint32 constant DEFAULT_DURATION_MINUTES = 30; + +// Storage slot for rolePermGlobal in TaskManager.Layout (offset 6 from base). +// See AuditTaskPermBit5.s.sol / GrantV4PermsViaGovernance.s.sol for the reference. +bytes32 constant TM_STORAGE_SLOT = keccak256("poa.taskmanager.storage"); +uint256 constant ROLE_PERM_GLOBAL_OFFSET = 6; + +/// @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 +/// GrantV4PermsViaGovernance.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 GrantEditFullBase is Script { + /// @dev Read the current rolePermGlobal mask for a hat directly from TaskManager.Layout + /// storage (no public getter). + function _readRolePermGlobal(address taskManager, uint256 hatId) internal view returns (uint8) { + bytes32 base = bytes32(uint256(TM_STORAGE_SLOT) + ROLE_PERM_GLOBAL_OFFSET); + 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 the proposal will execute: setConfig(ROLE_PERM, ...) with + /// the new combined mask. Reads current mask and OR's EDIT_FULL in to preserve existing bits. + function _buildBatch(address taskManager, uint256 hatId) + internal + view + returns (IExecutor.Call[] memory batch, uint8 currentMask, uint8 newMask) + { + currentMask = _readRolePermGlobal(taskManager, hatId); + newMask = currentMask | TaskPerm.EDIT_FULL; + + 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))) + }); + } + + /// @dev Per-org default hat; override via GRANT_HAT_ID env var (e.g. to target a + /// different role in the same org). + 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 _printGrantPreview(uint8 currentMask, uint8 newMask) internal pure { + console.log(" Current mask:", currentMask); + console.log(" New mask: ", newMask); + console.log(" Bits added: EDIT_FULL (bit 7)"); + if (currentMask != 0) { + console.log(" Preserved bits in current mask:"); + if (currentMask & TaskPerm.CREATE != 0) console.log(" - CREATE"); + if (currentMask & TaskPerm.CLAIM != 0) console.log(" - CLAIM"); + if (currentMask & TaskPerm.REVIEW != 0) console.log(" - REVIEW"); + if (currentMask & TaskPerm.ASSIGN != 0) console.log(" - ASSIGN"); + if (currentMask & TaskPerm.SELF_REVIEW != 0) console.log(" - SELF_REVIEW"); + if (currentMask & TaskPerm.BUDGET != 0) console.log(" - BUDGET"); + if (currentMask & TaskPerm.EDIT_META != 0) console.log(" - EDIT_META"); + if (currentMask & TaskPerm.EDIT_FULL != 0) console.log(" - EDIT_FULL (already set; proposal is no-op)"); + } + } + + /// @dev Full sim: etch Hats shim, create proposal, vote, advance time, announceWinner, + /// assert post-state. Mirrors GrantV4PermsViaGovernance.s.sol's `_runFullFlow`. + function _simFullFlow(string memory orgName, address taskManager, address hybridVoting, uint256 targetHat) + internal + { + console.log("\n=== EDIT_FULL grant sim:", orgName, "==="); + console.log(" TaskManager: ", taskManager); + console.log(" HybridVoting: ", hybridVoting); + console.log(" Target hat: ", targetHat); + + // 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("v5-grant-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 (EDIT_FULL already set)"); + _printGrantPreview(currentMask, newMask); + + IExecutor.Call[][] memory batches = new IExecutor.Call[][](1); + batches[0] = batch; + + // 3. Create the proposal (use 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 v5 EDIT_FULL - ", orgName)), + bytes32(0), + minutesDuration, + 1, // single option + 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: mask matches newMask exactly (BUDGET preserved, EDIT_FULL added). + uint8 maskAfter = _readRolePermGlobal(taskManager, targetHat); + require(maskAfter == newMask, "Sim: post-state mask mismatch"); + require(maskAfter & TaskPerm.EDIT_FULL != 0, "Sim: EDIT_FULL bit not set"); + if (currentMask & TaskPerm.BUDGET != 0) { + require(maskAfter & TaskPerm.BUDGET != 0, "Sim: BUDGET bit lost (read-modify-write failed)"); + } + console.log(" Post-state mask:", maskAfter, "(EDIT_FULL set, prior bits preserved)"); + + console.log("PASS:", orgName, "EDIT_FULL governance grant fully 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 + { + uint256 key = vm.envOr("PRIVATE_KEY", vm.envUint("DEPLOYER_PRIVATE_KEY")); + address sender = vm.addr(key); + uint32 minutesDuration = _resolveDuration(); + + console.log("\n=== Broadcasting EDIT_FULL grant proposal:", orgName, "==="); + console.log(" Sender: ", sender); + console.log(" TaskManager: ", taskManager); + console.log(" HybridVoting: ", hybridVoting); + console.log(" Target hat: ", targetHat); + console.log(" Duration: ", minutesDuration, "minutes"); + + // Sanity: sender must wear a creator hat or createProposal reverts NotCreator. + 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"); + + // Build the batch and preview the change so the broadcaster can sanity-check before the + // tx lands. + (IExecutor.Call[] memory batch, uint8 currentMask, uint8 newMask) = _buildBatch(taskManager, targetHat); + require(newMask != currentMask, "Broadcast: proposal is a no-op (EDIT_FULL already set on target hat)"); + _printGrantPreview(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 v5 EDIT_FULL - ", 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, ")"); + } +} + +/* ───────────────────────── Test6 on Gnosis ──────────────────────── */ + +contract SimGrantEditFullTest6 is GrantEditFullBase { + function run() public { + _simFullFlow("Test6", TEST6_TM, TEST6_HV, _resolveTargetHat(TEST6_EXEC_HAT)); + } +} + +contract BroadcastGrantEditFullTest6 is GrantEditFullBase { + function run() public { + _broadcastProposal("Test6", TEST6_TM, TEST6_HV, _resolveTargetHat(TEST6_EXEC_HAT)); + } +} + +/* ─────────────────── Decentral Park on Gnosis ────────────────────── */ +// Default target = Delegate hat. Decentral Park's HybridVoting.creatorHats() includes +// both Delegate and Neighbor, so any wearer of either can broadcast the proposal. + +contract SimGrantEditFullDecentralPark is GrantEditFullBase { + function run() public { + _simFullFlow( + "Decentral Park", DECENTRAL_PARK_TM, DECENTRAL_PARK_HV, _resolveTargetHat(DECENTRAL_PARK_DELEGATE_HAT) + ); + } +} + +contract BroadcastGrantEditFullDecentralPark is GrantEditFullBase { + function run() public { + _broadcastProposal( + "Decentral Park", DECENTRAL_PARK_TM, DECENTRAL_PARK_HV, _resolveTargetHat(DECENTRAL_PARK_DELEGATE_HAT) + ); + } +} diff --git a/script/upgrades/UpgradeOrgDeployerTaskManagerPerms.s.sol b/script/upgrades/UpgradeOrgDeployerTaskManagerPerms.s.sol new file mode 100644 index 0000000..7e90ced --- /dev/null +++ b/script/upgrades/UpgradeOrgDeployerTaskManagerPerms.s.sol @@ -0,0 +1,260 @@ +// 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 v13 — TaskManager Bootstrap Permissions + * ============================================================================ + * + * Companion to UpgradeTaskManagerEditPerms.s.sol (TaskManager v5). Adds the + * `taskManagerPerms` field to `OrgDeployer.DeploymentParams` and wires the + * deployer to call `TaskManager.bootstrapGlobalPerms(hatIds[], masks[])` + * after impl init and before per-project bootstrap. Lets new orgs deploy with + * org-wide TaskPerm bits (EDIT_META, EDIT_FULL, BUDGET, SELF_REVIEW, etc.) + * already granted to chosen role hats — no post-deploy governance vote needed. + * + * **MUST BROADCAST AFTER TaskManager v5.** OrgDeployer v13 calls a function + * that only exists on TaskManager v5+. Broadcasting v13 before TaskManager v5 + * would cause new org deploys with non-empty taskManagerPerms to revert. + * Empty taskManagerPerms is a no-op so existing deploys continue to work. + * + * No Layout struct change. ITaskManagerBootstrap interface extended (purely + * additive). No new events. No new ConfigKey. + * + * Three-step cross-chain upgrade pattern (mirrors UpgradeOrgDeployerEduRules): + * 1. Step1_DeployOnGnosis — deploy impl on Gnosis via DD + * 2. Step2_UpgradeFromArbitrum — deploy impl on Arb + cross-chain dispatch + * 3. Step3_Verify — verify Gnosis beacon updated + * + * VERSION = "v13" — verified FREE on both Gnosis and Arbitrum (probed + * 2026-05-27). Gnosis: registry count=8, CREATE2 slot empty. Arbitrum: registry + * count=9, CREATE2 slot empty. Predicted impl address (same on both via CREATE2): + * 0x02D16118AA9EB485e8cD5Ac51167B2934c462b80 + * ============================================================================ + */ + +address constant DD = 0x4aC8B5ebEb9D8C3dE3180ddF381D552d59e8835a; +address constant HUB = 0xB72840B343654eAfb2CFf7acC4Fc6b59E6c3CC71; +address constant GNOSIS_POA_MANAGER = 0x794fD39e75140ee1545B1B022E5486B7c863789b; +uint256 constant HYPERLANE_FEE = 0.005 ether; +// Hudson — owner of PoaManagerHub on Arbitrum and PoaManagerSatellite on Gnosis. +// Hardcoded per CLAUDE.md ("prank as it, don't read Hub.owner() and reuse the result"). +address constant HUDSON_ADMIN = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; +string constant VERSION = "v13"; + +/** + * @title Step1_DeployOnGnosis + * @notice Deploy OrgDeployer v13 impl on Gnosis via DD. + * + * Usage: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeOrgDeployerTaskManagerPerms.s.sol:Step1_DeployOnGnosis \ + * --rpc-url gnosis --broadcast --slow + */ +contract Step1_DeployOnGnosis 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 v13 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"); + } +} + +/** + * @title Step2_UpgradeFromArbitrum + * @notice Deploy impl on Arbitrum via DD, upgrade beacon locally + dispatch cross-chain. + * + * Usage: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeOrgDeployerTaskManagerPerms.s.sol:Step2_UpgradeFromArbitrum \ + * --rpc-url arbitrum --broadcast --slow + */ +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("OrgDeployer", VERSION); + address predicted = dd.computeAddress(salt); + console.log("\n=== Step 2: Upgrade OrgDeployer from Arbitrum ==="); + console.log("DD impl address:", predicted); + + vm.startBroadcast(deployerKey); + + if (predicted.code.length == 0) { + address deployed = dd.deploy(salt, type(OrgDeployer).creationCode); + require(deployed == predicted, "Address mismatch on Arbitrum"); + console.log("Deployed on Arbitrum"); + } else { + console.log("Already deployed on Arbitrum"); + } + + hub.upgradeBeaconCrossChain{value: HYPERLANE_FEE}("OrgDeployer", predicted, VERSION); + console.log("Beacon upgraded locally + dispatched cross-chain"); + + vm.stopBroadcast(); + + // Verify Arbitrum-side switch landed before declaring ready for Gnosis. + address pm = address(hub.poaManager()); + address current = PoaManager(pm).getCurrentImplementationById(keccak256("OrgDeployer")); + require(current == predicted, "Arbitrum impl not upgraded"); + console.log("Arbitrum upgrade: PASS"); + console.log("\nWait ~5 min for Hyperlane relay, then run Step3_Verify on Gnosis."); + } +} + +/** + * @title Step3_Verify + * @notice Verify the Gnosis beacon picked up the cross-chain upgrade. + * + * Usage: + * forge script script/upgrades/UpgradeOrgDeployerTaskManagerPerms.s.sol:Step3_Verify \ + * --rpc-url gnosis + */ +contract Step3_Verify is Script { + function run() public view { + DeterministicDeployer dd = DeterministicDeployer(DD); + bytes32 salt = dd.computeSalt("OrgDeployer", VERSION); + address expected = dd.computeAddress(salt); + + address current = PoaManager(GNOSIS_POA_MANAGER).getCurrentImplementationById(keccak256("OrgDeployer")); + + console.log("\n=== Step 3: Verify Gnosis OrgDeployer Upgrade ==="); + console.log("Expected impl:", expected); + console.log("Current impl: ", current); + + if (current == expected) { + console.log("PASS: OrgDeployer upgraded to v13 on Gnosis"); + console.log("\nNew capability: DeploymentParams.taskManagerPerms { roleIndices[], masks[] }"); + console.log(" - Wires bootstrapGlobalPerms() during _deployFullOrgInternal"); + console.log(" - New orgs can grant EDIT_META/EDIT_FULL/BUDGET to role hats at deploy"); + } else { + console.log("WAITING: Hyperlane message not yet relayed."); + } + } +} + +/** + * @title SimulateOrgDeployerUpgrade + * @notice Fork-simulates the v13 upgrade end-to-end on the Arbitrum side and asserts: + * 1. DD-predicted address matches deployed address. + * 2. PoaManager beacon for "OrgDeployer" advances to the new impl (before vs after). + * 3. The new impl has non-zero code in the runtime. + * 4. The new `bootstrapGlobalPerms` selector exists in the new impl's bytecode — + * this is the v13 feature; without it the upgrade is meaningless. + * + * Pranks Hudson directly (`HUDSON_ADMIN` constant) per CLAUDE.md, instead of trusting + * `Hub.owner()` mid-fork. + * + * Storage-layout drift is enforced separately by CI (`upgrades/baseline` storage-layout + * validator) — the sim deliberately does NOT try to read through a live proxy because + * locating the OrgDeployer proxy on a mainnet fork is fragile and the CI check is the + * authoritative guarantee. (The reference pattern, `UpgradeOrgDeployerEduRules.s.sol`, + * also skips the proxy read for the same reason.) + * + * Cross-chain dispatch is not simulated end-to-end (would require Hyperlane relay). + * The Hub-side call is exercised; Gnosis-side verification waits for the real relay. + * + * Usage: + * FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeOrgDeployerTaskManagerPerms.s.sol:SimulateOrgDeployerUpgrade \ + * --fork-url arbitrum -vvv + */ +contract SimulateOrgDeployerUpgrade is Script { + function run() public { + console.log("\n========================================"); + console.log(" OrgDeployer v13 Upgrade Simulation"); + console.log("========================================"); + + PoaManagerHub hub = PoaManagerHub(payable(HUB)); + DeterministicDeployer dd = DeterministicDeployer(DD); + address pm = address(hub.poaManager()); + + // 1. Pre-state snapshot. + bytes32 typeId = keccak256("OrgDeployer"); + address before = PoaManager(pm).getCurrentImplementationById(typeId); + require(before != address(0), "Sim: no existing OrgDeployer impl"); + console.log("Impl before:", before); + + // 2. Deploy v13 impl via DD (prank DD owner — DD.deploy is onlyOwner). + bytes32 salt = dd.computeSalt("OrgDeployer", VERSION); + address predicted = dd.computeAddress(salt); + console.log("Predicted impl:", predicted); + + address deployed; + if (predicted.code.length == 0) { + address ddOwner = DeterministicDeployer(DD).owner(); + vm.prank(ddOwner); + deployed = dd.deploy(salt, type(OrgDeployer).creationCode); + } else { + console.log("Already deployed at predicted (skipping deploy)"); + deployed = predicted; + } + require(deployed == predicted, "Sim: DD address mismatch"); + require(deployed.code.length > 0, "Sim: impl code missing"); + console.log("Deployed impl: ", deployed); + + // 3. Upgrade beacon as Hudson (CLAUDE.md: prank the hardcoded EOA, don't trust + // hub.owner() in case ownership changes mid-fork). + vm.deal(HUDSON_ADMIN, 1 ether); + vm.prank(HUDSON_ADMIN); + hub.upgradeBeaconCrossChain{value: HYPERLANE_FEE}("OrgDeployer", predicted, VERSION); + + // 4. Verify beacon advanced — read-before-and-after invariant per CLAUDE.md. + address afterImpl = PoaManager(pm).getCurrentImplementationById(typeId); + require(afterImpl == predicted, "Sim: beacon upgrade did not stick"); + require(afterImpl != before, "Sim: impl unchanged (before == after)"); + console.log("Impl after: ", afterImpl); + + // 5. Selector presence in impl bytecode — verifies the new `bootstrapGlobalPerms` call + // path will resolve on the upgraded proxy. Without this check the upgrade carries + // nothing of v13 substance. + bytes4 sel = bytes4(keccak256("bootstrapGlobalPerms(uint256[],uint8[])")); + require(_selectorIn(predicted, sel), "Sim: bootstrapGlobalPerms selector missing in v13 impl"); + console.log("Selector check: bootstrapGlobalPerms(uint256[],uint8[]) present in v13 impl"); + + console.log("\n=== ALL SIM CHECKS PASSED ==="); + console.log("Safe to broadcast Step1/Step2/Step3 against mainnet."); + } + + /// @dev Naive selector scan over a bytecode blob — same defensive check used in + /// UpgradeTaskManagerEditPerms.s.sol. Bounded by impl bytecode length (~40KB). + function _selectorIn(address impl, bytes4 sel) internal view returns (bool) { + bytes memory code = impl.code; + for (uint256 i; i + 4 <= code.length; ++i) { + if (code[i] == sel[0] && code[i + 1] == sel[1] && code[i + 2] == sel[2] && code[i + 3] == sel[3]) { + return true; + } + } + return false; + } +} diff --git a/script/upgrades/UpgradeTaskManagerEditPerms.s.sol b/script/upgrades/UpgradeTaskManagerEditPerms.s.sol new file mode 100644 index 0000000..6a5b971 --- /dev/null +++ b/script/upgrades/UpgradeTaskManagerEditPerms.s.sol @@ -0,0 +1,399 @@ +// 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 {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol"; +import {PoaManager} from "../../src/PoaManager.sol"; +import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; + +/* + * ============================================================================ + * TaskManager Upgrade — Post-Claim Edit Permissions (v5) + * ============================================================================ + * + * Adds two new TaskPerm bits — `EDIT_META` (1 << 6) and `EDIT_FULL` (1 << 7) — + * letting hats explicitly granted those bits edit a task after it has been + * claimed or submitted. Adds the sibling `updateTaskMetadata(id, title, hash)` + * for the metadata-only path. Terminal states (COMPLETED / CANCELLED) remain + * immutable. Project managers and the executor implicitly get EDIT_FULL on + * their projects. + * + * No Layout struct change. No new event signature. No new ConfigKey. Subgraph + * is unaffected — RolePermSet / ProjectRolePermSet already carry every bit + * the indexer needs. + * + * Three-step cross-chain upgrade pattern (mirrors UpgradeTaskManagerCreateTasksBatch): + * 1. Deploy impl on Gnosis via DeterministicDeployer + * 2. Deploy on Arbitrum + upgradeBeaconCrossChain + * 3. Verify on Gnosis + * + * Usage: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeTaskManagerEditPerms.s.sol: \ + * --rpc-url --broadcast --slow + * ============================================================================ + */ + +address constant DD = 0x4aC8B5ebEb9D8C3dE3180ddF381D552d59e8835a; +address constant HUB = 0xB72840B343654eAfb2CFf7acC4Fc6b59E6c3CC71; +address constant GNOSIS_POA_MANAGER = 0x794fD39e75140ee1545B1B022E5486B7c863789b; +uint256 constant HYPERLANE_FEE = 0.005 ether; +// Hudson — owner of PoaManagerHub (Arbitrum), PoaManagerSatellite (Gnosis), DeterministicDeployer. +// Hardcoded per CLAUDE.md: "prank as it, don't read Hub.owner() and reuse the result, in case +// ownership ever changes mid-fork." Used by the DryRun sim, not the broadcast steps. +address constant HUDSON_ADMIN = 0xA6F4D9f44Dd980b7168D829d5f74c2b00a46b2c9; +// PoaManagerSatellite on Gnosis — owner of Gnosis PoaManager (verified via on-chain `owner()` +// read 2026-05-27). In production, Satellite receives Hyperlane messages from the Hub and +// invokes `PoaManager.upgradeBeacon`; the sim shortcuts by pranking the Satellite directly +// since simulating the Hyperlane relay end-to-end is impractical on a single-chain fork. +address constant GNOSIS_SATELLITE = 0x4Ad70029a9247D369a5bEA92f90840B9ee58eD06; +// v4 is the latest registered impl on both Gnosis and Arbitrum (createTasksBatch +// shipped as v2, organizer/folders/budget as v4). v5 was probed FREE on both +// chains and the CREATE2 slot for ("TaskManager", "v5") is empty. +string constant VERSION = "v5"; + +/** + * @title Step1_DeployImplOnGnosis + * @notice Deploy TaskManager v5 implementation on Gnosis via DD. + * + * Usage: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeTaskManagerEditPerms.s.sol:Step1_DeployImplOnGnosis \ + * --rpc-url gnosis --broadcast --slow + */ +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("TaskManager", VERSION); + address predicted = dd.computeAddress(salt); + console.log("\n=== Step 1: Deploy TaskManager v5 impl 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(TaskManager).creationCode); + vm.stopBroadcast(); + + require(deployed == predicted, "Address mismatch"); + console.log("Deployed:", deployed); + console.log("\nNext: Run Step2_UpgradeFromArbitrum on Arbitrum"); + } +} + +/** + * @title Step2_UpgradeFromArbitrum + * @notice Deploy impl on Arbitrum via DD, upgrade beacon cross-chain. + * + * Usage: + * source .env && FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeTaskManagerEditPerms.s.sol:Step2_UpgradeFromArbitrum \ + * --rpc-url arbitrum --broadcast --slow + */ +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("TaskManager", VERSION); + address predicted = dd.computeAddress(salt); + console.log("\n=== Step 2: Upgrade TaskManager from Arbitrum ==="); + console.log("DD impl address:", predicted); + + vm.startBroadcast(deployerKey); + + if (predicted.code.length == 0) { + dd.deploy(salt, type(TaskManager).creationCode); + console.log("Deployed on Arbitrum"); + } else { + console.log("Already deployed on Arbitrum"); + } + + hub.upgradeBeaconCrossChain{value: HYPERLANE_FEE}("TaskManager", predicted, VERSION); + console.log("Beacon upgraded cross-chain"); + + vm.stopBroadcast(); + console.log("\nWait ~5 min for Hyperlane relay, then run Step3 on Gnosis."); + } +} + +/** + * @title Step3_VerifyGnosis + * @notice Verify the Gnosis beacon upgrade landed. + * + * Usage: + * forge script script/upgrades/UpgradeTaskManagerEditPerms.s.sol:Step3_VerifyGnosis \ + * --rpc-url gnosis + */ +contract Step3_VerifyGnosis is Script { + function run() public view { + DeterministicDeployer dd = DeterministicDeployer(DD); + bytes32 salt = dd.computeSalt("TaskManager", VERSION); + address expectedImpl = dd.computeAddress(salt); + + address currentImpl = PoaManager(GNOSIS_POA_MANAGER).getCurrentImplementationById(keccak256("TaskManager")); + + console.log("\n=== Step 3: Verify Gnosis TaskManager Upgrade ==="); + console.log("Expected impl:", expectedImpl); + console.log("Current impl: ", currentImpl); + + if (currentImpl == expectedImpl) { + console.log("PASS: TaskManager upgraded to v5 on Gnosis"); + console.log("\nNew capabilities:"); + console.log(" - updateTaskMetadata(id, title, hash): metadata-only edits post-claim"); + console.log(" - updateTask now allowed post-claim for EDIT_FULL hats / PMs / executor"); + console.log(" - TaskPerm.EDIT_META (1<<6) and TaskPerm.EDIT_FULL (1<<7) bits"); + } else { + console.log("WAITING: Hyperlane message not yet relayed."); + } + } +} + +interface IOrgRegistry { + function orgIds(uint256 index) external view returns (bytes32); + function proxyOf(bytes32 orgId, bytes32 typeId) external view returns (address); +} + +/** + * @title DryRun_GnosisUpgrade + * @notice Pre-flight test on a Gnosis fork. Deploys impl via DD, upgrades the + * beacon, and exercises the new edit-permission paths against a live, + * autoUpgrade-tracking TaskManager proxy. Does not broadcast. + * + * Asserts: + * 1. DD-predicted address matches deployed address. + * 2. PoaManager beacon updates to the new impl. + * 3. New `updateTaskMetadata` selector exists in impl runtime bytecode. + * 4. A live TaskManager proxy on Gnosis (org #0 from OrgRegistry): + * a. Pre-existing storage is preserved (executor address survives the impl swap). + * b. A fresh project + assigned task is editable by the executor + * post-claim via `updateTask` (payout swap nets correctly in `p.spent`). + * c. The executor can also call `updateTaskMetadata` post-claim. + * d. A non-PM, non-EDIT_FULL caller reverts `Unauthorized` on `updateTask`. + * e. A non-PM, non-EDIT_META caller reverts `Unauthorized` on `updateTaskMetadata`. + * f. After the task is COMPLETED, the executor still cannot edit (`BadStatus`). + * g. A CANCELLED task on a separate id also reverts `BadStatus`. + * + * Usage: + * FOUNDRY_PROFILE=production forge script \ + * script/upgrades/UpgradeTaskManagerEditPerms.s.sol:DryRun_GnosisUpgrade \ + * --rpc-url gnosis + */ +contract DryRun_GnosisUpgrade is Script { + // OrgRegistry is deployed at the same CREATE2 address on every chain. + address constant ORG_REGISTRY = 0x3744b372abc41589226313F2bB1dB3aCAa22A854; + + function run() public { + console.log("\n=== DRY RUN: TaskManager v5 upgrade on Gnosis fork ===\n"); + + DeterministicDeployer dd = DeterministicDeployer(DD); + PoaManager pm = PoaManager(GNOSIS_POA_MANAGER); + + // 1. Pre-state snapshot. + address implBefore = pm.getCurrentImplementationById(keccak256("TaskManager")); + console.log("Impl before:", implBefore); + + // 2. Step1 simulation: deploy v5 impl via DD. + bytes32 salt = dd.computeSalt("TaskManager", VERSION); + address predicted = dd.computeAddress(salt); + console.log("DD predicted impl:", predicted); + + address deployed; + if (predicted.code.length == 0) { + // DD's deploy is onlyOwner — prank Hudson directly (CLAUDE.md: don't trust on-chain + // owner() reads mid-fork). Hudson owns DD on both Gnosis and Arbitrum. + vm.prank(HUDSON_ADMIN); + deployed = dd.deploy(salt, type(TaskManager).creationCode); + } else { + console.log("Already deployed at predicted (skipping deploy)"); + deployed = predicted; + } + require(deployed == predicted, "DryRun: DD address mismatch"); + require(deployed.code.length > 0, "DryRun: impl code missing"); + console.log("Deployed impl:", deployed); + + // 3. Step2 simulation: upgrade beacon as the Gnosis PoaManager owner (the Satellite). + // Hardcoded per CLAUDE.md — don't trust the on-chain owner() read mid-fork. The + // Satellite is the real production caller (via Hyperlane relay from the Hub on Arbitrum). + vm.prank(GNOSIS_SATELLITE); + pm.upgradeBeacon("TaskManager", deployed, VERSION); + address implAfter = pm.getCurrentImplementationById(keccak256("TaskManager")); + require(implAfter == deployed, "DryRun: beacon upgrade did not stick"); + console.log("Impl after :", implAfter); + + // 4. Selector presence in impl bytecode (cheap source-vs-deployed check). + bytes4 sel = TaskManager.updateTaskMetadata.selector; + bytes memory code = deployed.code; + bool found = false; + for (uint256 i; i + 4 <= code.length; ++i) { + if (code[i] == sel[0] && code[i + 1] == sel[1] && code[i + 2] == sel[2] && code[i + 3] == sel[3]) { + found = true; + break; + } + } + require(found, "DryRun: updateTaskMetadata selector missing from impl bytecode"); + console.log("updateTaskMetadata selector present in impl bytecode"); + + // 5. Live-proxy exercise. + _exerciseLiveProxy(); + + console.log("\n=== ALL DRY-RUN CHECKS PASSED ==="); + console.log("Safe to broadcast Step1/Step2/Step3 against mainnet."); + } + + function _exerciseLiveProxy() internal { + IOrgRegistry reg = IOrgRegistry(ORG_REGISTRY); + bytes32 orgId = reg.orgIds(0); + address proxy = reg.proxyOf(orgId, keccak256("TaskManager")); + require(proxy != address(0), "DryRun: no TaskManager proxy for org 0"); + TaskManager tm = TaskManager(proxy); + + console.log("\n--- Live-proxy exercise ---"); + console.log("orgId:", vm.toString(orgId)); + console.log("TaskManager proxy:", proxy); + + // 5a. Storage preservation: read executor through the upgraded impl. + bytes memory execData = tm.getLensData(4, ""); + address executor = abi.decode(execData, (address)); + require(executor != address(0), "DryRun: executor unset post-upgrade (storage drift?)"); + console.log("Executor (preserved):", executor); + + // 5b. Create a fresh test project (executor bypasses _requireCreator). + TaskManager.BootstrapProjectConfig memory cfg = TaskManager.BootstrapProjectConfig({ + title: bytes("dryrun-edit-perms"), + metadataHash: bytes32(0), + cap: 0, // unlimited PT + managers: new address[](0), + createHats: new uint256[](0), + claimHats: new uint256[](0), + reviewHats: new uint256[](0), + assignHats: new uint256[](0), + bountyTokens: new address[](0), + bountyCaps: new uint256[](0) + }); + vm.prank(executor); + bytes32 pid = tm.createProject(cfg); + console.log("Test project pid:", vm.toString(pid)); + + // 5c. Create + assign a task so it's CLAIMED, then capture the id. + bytes memory probe = abi.encodeWithSelector(TaskManager.getLensData.selector, uint8(1), abi.encode(uint256(0))); + // Iterate to find the first unused id (cheap probe loop bounded by project age). + uint256 candidateId = _nextAvailableTaskId(proxy); + + address assignee = makeAddr("dryrun-claimer"); + vm.prank(executor); + tm.createTask(2 ether, bytes("orig-title"), bytes32(0), pid, address(0), 0, false); + vm.prank(executor); + tm.assignTask(candidateId, assignee); + + (uint96 payoutBefore,, address claimerBefore,,, TaskManager.Status statusBefore,) = _readTask(tm, candidateId); + require(payoutBefore == 2 ether, "DryRun: pre-edit payout wrong"); + require(claimerBefore == assignee, "DryRun: pre-edit claimer wrong"); + require(statusBefore == TaskManager.Status.CLAIMED, "DryRun: pre-edit status not CLAIMED"); + console.log("Task assigned at id:", candidateId); + + // 5d. Executor edits payout on the CLAIMED task. + vm.prank(executor); + tm.updateTask(candidateId, 5 ether, bytes("edited-title"), bytes32(0), address(0), 0); + (uint96 payoutAfter,,,,, TaskManager.Status statusAfter,) = _readTask(tm, candidateId); + require(payoutAfter == 5 ether, "DryRun: post-edit payout wrong"); + require(statusAfter == TaskManager.Status.CLAIMED, "DryRun: post-edit status changed"); + console.log("Executor updateTask post-claim OK (payout 2 -> 5)"); + + // 5e. Executor calls updateTaskMetadata — payout unchanged, no revert. + vm.prank(executor); + tm.updateTaskMetadata(candidateId, bytes("meta-only-title"), bytes32(uint256(0xfeed))); + (uint96 payoutAfterMeta,,,,,,) = _readTask(tm, candidateId); + require(payoutAfterMeta == 5 ether, "DryRun: updateTaskMetadata changed payout"); + console.log("Executor updateTaskMetadata post-claim OK (payout preserved)"); + + // 5f. Outsider cannot updateTask post-claim. + address outsider = makeAddr("dryrun-outsider"); + vm.prank(outsider); + (bool okOut,) = proxy.call( + abi.encodeCall(TaskManager.updateTask, (candidateId, 1 ether, bytes("nope"), bytes32(0), address(0), 0)) + ); + require(!okOut, "DryRun: outsider updateTask must revert"); + console.log("Outsider updateTask -> Unauthorized OK"); + + // 5g. Outsider cannot updateTaskMetadata either. + vm.prank(outsider); + (bool okOutMeta,) = + proxy.call(abi.encodeCall(TaskManager.updateTaskMetadata, (candidateId, bytes("nope"), bytes32(0)))); + require(!okOutMeta, "DryRun: outsider updateTaskMetadata must revert"); + console.log("Outsider updateTaskMetadata -> Unauthorized OK"); + + // 5h. Submit + complete the task, then prove edits are blocked on COMPLETED. + vm.prank(assignee); + tm.submitTask(candidateId, keccak256("done")); + vm.prank(executor); + tm.completeTask(candidateId); + + vm.prank(executor); + (bool okComplete,) = proxy.call( + abi.encodeCall(TaskManager.updateTask, (candidateId, 9 ether, bytes("x"), bytes32(0), address(0), 0)) + ); + require(!okComplete, "DryRun: COMPLETED updateTask must revert"); + vm.prank(executor); + (bool okCompleteMeta,) = + proxy.call(abi.encodeCall(TaskManager.updateTaskMetadata, (candidateId, bytes("x"), bytes32(0)))); + require(!okCompleteMeta, "DryRun: COMPLETED updateTaskMetadata must revert"); + console.log("COMPLETED edits blocked OK"); + + // 5i. CANCELLED edits also blocked. + uint256 cancelId = _nextAvailableTaskId(proxy); + vm.prank(executor); + tm.createTask(1 ether, bytes("to-cancel"), bytes32(0), pid, address(0), 0, false); + vm.prank(executor); + tm.cancelTask(cancelId); + + vm.prank(executor); + (bool okCancel,) = proxy.call( + abi.encodeCall(TaskManager.updateTask, (cancelId, 2 ether, bytes("x"), bytes32(0), address(0), 0)) + ); + require(!okCancel, "DryRun: CANCELLED updateTask must revert"); + console.log("CANCELLED edits blocked OK"); + } + + /// @dev Finds the next unused task id by probing the lens — bounded by org task count. + function _nextAvailableTaskId(address proxy) internal view returns (uint256) { + for (uint256 i; i < 1_000_000; ++i) { + (bool ok,) = + proxy.staticcall(abi.encodeWithSelector(TaskManager.getLensData.selector, uint8(1), abi.encode(i))); + if (!ok) return i; + } + revert("DryRun: nextAvailableTaskId search exhausted"); + } + + function _readTask(TaskManager tm, uint256 id) + internal + view + returns ( + uint96 payout, + bytes32 projectId, + address claimer, + uint96 bountyPayout, + bool requiresApplication, + TaskManager.Status status, + address bountyToken + ) + { + bytes memory data = tm.getLensData(1, abi.encode(id)); + (projectId, payout, claimer, bountyPayout, requiresApplication, status, bountyToken) = + abi.decode(data, (bytes32, uint96, address, uint96, bool, TaskManager.Status, address)); + } +} diff --git a/src/OrgDeployer.sol b/src/OrgDeployer.sol index 232f96e..b42a04e 100644 --- a/src/OrgDeployer.sol +++ b/src/OrgDeployer.sol @@ -90,6 +90,8 @@ interface ITaskManagerBootstrap { external returns (bytes32[] memory projectIds); + function bootstrapGlobalPerms(uint256[] calldata hatIds, uint8[] calldata masks) external; + function clearDeployer() external; } @@ -250,6 +252,28 @@ contract OrgDeployer is Initializable { ITaskManagerBootstrap.BootstrapTaskConfig[] tasks; } + /// @notice Optional org-wide TaskManager role-permission grants applied during bootstrap. + /// @dev Each `roleIndices[i]` is resolved against `params.roles[]` to a hat ID and assigned + /// the corresponding `masks[i]` (a bitwise OR of `TaskPerm` bits). Equivalent to the + /// executor calling `setConfig(ROLE_PERM, abi.encode(hatId, mask))` post-deployment, + /// but baked into the atomic deploy. Empty arrays = skip (no grants). `roleIndices.length` + /// must equal `masks.length`. Duplicate role indices: last write wins. + /// Use this for org-wide grants of `TaskPerm.EDIT_META`, `EDIT_FULL`, `BUDGET`, + /// `SELF_REVIEW`, or any future TaskPerm bit. Per-project grants still go through + /// `BootstrapProjectConfig.{create,claim,review,assign}Hats`. + /// + /// CAVEAT — per-project overrides shadow global grants. `TaskManager._permMask` returns + /// the per-project mask for a hat IF non-zero, otherwise the global mask. If a bootstrap + /// project sets per-project perms for the same hat (e.g. `createHats: [executiveRole]`), + /// the global mask granted here is silently ignored on that project. To grant EDIT_FULL + /// on a bootstrap project, either (a) call `setProjectRolePerm(pid, hat, existing | EDIT_FULL)` + /// post-deploy via governance, or (b) create the project post-deploy without per-project + /// perms for that hat. Fresh post-deploy projects inherit the global grant correctly. + struct TaskManagerPermConfig { + uint256[] roleIndices; + uint8[] masks; + } + struct PaymasterConfig { uint256 operatorRoleIndex; // Role index for paymaster operator hat; type(uint256).max = skip (topHat-only) bool autoWhitelistContracts; // If true, auto-whitelist deployed org contracts @@ -285,6 +309,7 @@ contract OrgDeployer is Initializable { ModulesFactory.EducationHubConfig educationHubConfig; // EducationHub deployment configuration BootstrapConfig bootstrap; // Optional: initial projects and tasks to create PaymasterConfig paymasterConfig; // Optional: paymaster configuration (funding via msg.value) + TaskManagerPermConfig taskManagerPerms; // Optional: org-wide TaskManager ROLE_PERM grants } /*════════════════ VALIDATION ════════════════*/ @@ -472,6 +497,18 @@ contract OrgDeployer is Initializable { IParticipationToken(result.participationToken).setEducationHub(result.educationHub); } + /* 8.5a. Bootstrap org-wide TaskManager ROLE_PERM grants (EDIT_META/EDIT_FULL/BUDGET/etc). + Runs BEFORE project bootstrap so per-project hat masks can override the global + mask (project mask != 0 takes precedence in _permMask). + Enter the block whenever EITHER array is non-empty so the length-mismatch check + inside `bootstrapGlobalPerms` fires for malformed configs (e.g. empty roleIndices + + non-empty masks would otherwise be silently dropped). */ + if (params.taskManagerPerms.roleIndices.length > 0 || params.taskManagerPerms.masks.length > 0) { + uint256[] memory permHatIds = + _resolveRoleIndicesToHatIds(params.taskManagerPerms.roleIndices, gov.roleHatIds); + ITaskManagerBootstrap(result.taskManager).bootstrapGlobalPerms(permHatIds, params.taskManagerPerms.masks); + } + /* 8.5. Bootstrap initial projects and tasks if configured */ if (params.bootstrap.projects.length > 0) { // Resolve role indices to hat IDs in bootstrap config diff --git a/src/TaskManager.sol b/src/TaskManager.sol index d406d33..d763fe6 100644 --- a/src/TaskManager.sol +++ b/src/TaskManager.sol @@ -514,6 +514,37 @@ contract TaskManager is Initializable, ContextUpgradeable { l.deployer = address(0); } + /** + * @notice Bulk-grant org-wide `rolePermGlobal` masks during the bootstrap window. + * @dev Deployer-only escape hatch, identical access pattern to {bootstrapProjectsAndTasks}. + * Reverts {NotDeployer} once {clearDeployer} has been called. Effects per pair: + * - Writes `rolePermGlobal[hatId] = mask` (last write wins for duplicate hat IDs). + * - Calls `_syncPermissionHat(hatId)` so the enumeration array stays consistent + * (a `mask == 0` write removes the hat unless it still has any project-specific mask). + * - Emits {RolePermSet} per hat — the same event `setConfig(ROLE_PERM, ...)` emits, so + * subgraph consumers index these grants exactly the same way as runtime grants. + * Empty `hatIds` is a no-op (does not revert) — lets the caller pass zero grants without + * branching at the call site. + * @param hatIds Hat IDs to grant masks to. + * @param masks TaskPerm bitmasks (bitwise-OR of {TaskPerm} constants). Length must match `hatIds`. + */ + function bootstrapGlobalPerms(uint256[] calldata hatIds, uint8[] calldata masks) external { + Layout storage l = _layout(); + if (_msgSender() != l.deployer) revert NotDeployer(); + if (hatIds.length != masks.length) revert ArrayLengthMismatch(); + + for (uint256 i; i < hatIds.length;) { + uint256 hatId = hatIds[i]; + uint8 mask = masks[i]; + l.rolePermGlobal[hatId] = mask; + _syncPermissionHat(hatId); + emit RolePermSet(hatId, mask); + unchecked { + ++i; + } + } + } + /*──────── Task Logic ───────*/ /** * @notice Create a task under `pid` with the given payout and optional bounty. @@ -606,8 +637,22 @@ contract TaskManager is Initializable, ContextUpgradeable { } /** - * @notice Update an UNCLAIMED task's payout, title, metadata, and bounty fields. - * @dev Permission: CREATE on the task's project. Reverts BadStatus once the task is claimed. + * @notice Update a task's payout, title, metadata, and bounty fields. + * @dev Status gate: COMPLETED / CANCELLED always revert `BadStatus` — terminal states are + * immutable to avoid accounting drift (payouts have already been minted or refunded). + * + * Permission gate (any non-terminal status): + * - Executor or project manager: always allowed. + * - Hat with `TaskPerm.EDIT_FULL`: allowed in any non-terminal status. + * - Hat with `TaskPerm.CREATE`: allowed only while the task is `UNCLAIMED` + * (preserves the original pre-claim editing path). + * + * Post-claim edits silently change the claimer's payout / bounty expectation — + * the subgraph picks up the new values via the existing `TaskUpdated` event. + * Swapping `newBountyToken` to a token whose `bountyBudgets[token].cap` is zero + * (DISABLED) reverts via `BudgetLib.addSpent`; enable the new token's budget first + * with `setConfig(BOUNTY_CAP, ...)`. + * * Re-runs validation on the new values and adjusts both PT and bounty budgets. * @param id Task ID. * @param newPayout New participation-token payout. @@ -624,16 +669,24 @@ contract TaskManager is Initializable, ContextUpgradeable { address newBountyToken, uint256 newBountyPayout ) external { - _requireCanCreate(_layout()._tasks[id].projectId); Layout storage l = _layout(); + Task storage t = _task(l, id); + if (t.status == Status.COMPLETED || t.status == Status.CANCELLED) revert BadStatus(); + + bytes32 pid = t.projectId; + address s = _msgSender(); + if (s != l.executor && !_isPM(pid, s)) { + uint8 mask = _permMask(s, pid); + bool canEditFull = TaskPerm.has(mask, TaskPerm.EDIT_FULL); + bool canEditUnclaimed = t.status == Status.UNCLAIMED && TaskPerm.has(mask, TaskPerm.CREATE); + if (!canEditFull && !canEditUnclaimed) revert Unauthorized(); + } + ValidationLib.requireValidTitle(newTitle); ValidationLib.requireValidPayout96(newPayout); ValidationLib.requireValidBountyConfig(newBountyToken, newBountyPayout); - Task storage t = _task(l, id); - if (t.status != Status.UNCLAIMED) revert BadStatus(); - - Project storage p = l._projects[t.projectId]; + Project storage p = l._projects[pid]; // Update participation token budget // PT cap: 0 = unlimited (minted tokens) @@ -660,6 +713,42 @@ contract TaskManager is Initializable, ContextUpgradeable { emit TaskUpdated(id, newPayout, newBountyToken, newBountyPayout, newTitle, newMetadataHash); } + /** + * @notice Update only a non-terminal task's title and metadata hash; payout and bounty fields + * are preserved verbatim. + * @dev Status gate matches {updateTask}: COMPLETED / CANCELLED revert `BadStatus`. + * + * Permission gate (any non-terminal status): + * - Executor or project manager: always allowed. + * - Hat with `TaskPerm.EDIT_META` or `TaskPerm.EDIT_FULL`: allowed in any non-terminal status. + * - Hat with `TaskPerm.CREATE`: allowed only while the task is `UNCLAIMED` + * (parity with the pre-claim editing path on {updateTask}). + * + * No budget side effects — the on-chain payout / bountyToken / bountyPayout fields are + * re-emitted unchanged so subgraph consumers can index the metadata update via the + * existing `TaskUpdated` event. + * @param id Task ID. + * @param newTitle New title. + * @param newMetadataHash New IPFS CID (emitted; not stored). + */ + function updateTaskMetadata(uint256 id, bytes calldata newTitle, bytes32 newMetadataHash) external { + Layout storage l = _layout(); + Task storage t = _task(l, id); + if (t.status == Status.COMPLETED || t.status == Status.CANCELLED) revert BadStatus(); + + bytes32 pid = t.projectId; + address s = _msgSender(); + if (s != l.executor && !_isPM(pid, s)) { + uint8 mask = _permMask(s, pid); + bool canEditMeta = TaskPerm.has(mask, TaskPerm.EDIT_META) || TaskPerm.has(mask, TaskPerm.EDIT_FULL); + bool canEditUnclaimed = t.status == Status.UNCLAIMED && TaskPerm.has(mask, TaskPerm.CREATE); + if (!canEditMeta && !canEditUnclaimed) revert Unauthorized(); + } + + ValidationLib.requireValidTitle(newTitle); + emit TaskUpdated(id, t.payout, t.bountyToken, t.bountyPayout, newTitle, newMetadataHash); + } + /** * @notice Claim an UNCLAIMED task that does not require an application. * @dev Permission: CLAIM on the task's project. Reverts RequiresApplication for diff --git a/src/libs/TaskPerm.sol b/src/libs/TaskPerm.sol index 665a8ee..da3fc23 100644 --- a/src/libs/TaskPerm.sol +++ b/src/libs/TaskPerm.sol @@ -12,6 +12,14 @@ library TaskPerm { uint8 internal constant ASSIGN = 1 << 3; uint8 internal constant SELF_REVIEW = 1 << 4; uint8 internal constant BUDGET = 1 << 5; + /// @dev Edit a task's title / metadataHash after it has been claimed or submitted. + uint8 internal constant EDIT_META = 1 << 6; + /// @dev Edit a task's payout / bounty fields (and metadata) after it has been claimed or submitted. + /// Strict superset of EDIT_META. + uint8 internal constant EDIT_FULL = 1 << 7; + // NOTE: uint8 is now saturated. Adding a 9th flag requires widening the value type of + // TaskManager.Layout.rolePermGlobal / rolePermProj and bumping the RolePermSet / + // ProjectRolePermSet event ABIs — a Layout-breaking change plus a subgraph v2. function has(uint8 mask, uint8 flag) internal pure returns (bool) { return mask & flag != 0; diff --git a/test/DeployerTest.t.sol b/test/DeployerTest.t.sol index dd1673e..793ccdc 100644 --- a/test/DeployerTest.t.sol +++ b/test/DeployerTest.t.sol @@ -17,6 +17,8 @@ import {Executor} from "../src/Executor.sol"; import {ParticipationToken} from "../src/ParticipationToken.sol"; import {QuickJoin} from "../src/QuickJoin.sol"; import {TaskManager} from "../src/TaskManager.sol"; +import {TaskManagerLens} from "../src/lens/TaskManagerLens.sol"; +import {TaskPerm} from "../src/libs/TaskPerm.sol"; import {EducationHub} from "../src/EducationHub.sol"; import {PaymentManager} from "../src/PaymentManager.sol"; import {IPaymentManager} from "../src/interfaces/IPaymentManager.sol"; @@ -184,7 +186,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -284,6 +287,10 @@ contract DeployerTest is Test, IEligibilityModuleEvents { return OrgDeployer.BootstrapConfig({projects: projects, tasks: tasks}); } + function _emptyTaskManagerPerms() internal pure returns (OrgDeployer.TaskManagerPermConfig memory) { + return OrgDeployer.TaskManagerPermConfig({roleIndices: new uint256[](0), masks: new uint8[](0)}); + } + /// @dev Helper to build bootstrap config with one project and two tasks function _buildBootstrapWithTasks() internal pure returns (OrgDeployer.BootstrapConfig memory) { ITaskManagerBootstrap.BootstrapProjectConfig[] memory projects = @@ -442,7 +449,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -503,7 +511,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -868,7 +877,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -1001,7 +1011,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _buildBootstrapWithTasks(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -1049,6 +1060,353 @@ contract DeployerTest is Test, IEligibilityModuleEvents { ITaskManagerBootstrap(result.taskManager).clearDeployer(); } + /*━━━━━━━━━━━━━━━━━━━━ taskManagerPerms bootstrap (v5) ━━━━━━━━━━━━━━━━━━━━*/ + + /// @dev Common builder for a deploy that grants `mask` to the role at `roleIndex` globally. + function _buildParamsWithTaskPerm(uint256 roleIndex, uint8 mask) + internal + view + returns (OrgDeployer.DeploymentParams memory params) + { + string[] memory names = new string[](2); + names[0] = "DEFAULT"; + names[1] = "EXECUTIVE"; + string[] memory images = new string[](2); + images[0] = "ipfs://default"; + images[1] = "ipfs://executive"; + bool[] memory voting = new bool[](2); + voting[0] = true; + voting[1] = true; + + uint256[] memory roleIndices = new uint256[](1); + roleIndices[0] = roleIndex; + uint8[] memory masks = new uint8[](1); + masks[0] = mask; + + params = OrgDeployer.DeploymentParams({ + orgId: ORG_ID, + orgName: "v5-bootstrap-perms", + metadataHash: bytes32(0), + registryAddr: accountRegProxy, + deployerAddress: orgOwner, + deployerUsername: "", + regDeadline: 0, + regNonce: 0, + regSignature: "", + autoUpgrade: true, + hybridThresholdPct: 50, + ddThresholdPct: 50, + hybridClasses: _buildLegacyClasses(50, 50, false, 4 ether), + ddInitialTargets: new address[](0), + roles: _buildSimpleRoleConfigs(names, images, voting), + roleAssignments: _buildDefaultRoleAssignments(), + metadataAdminRoleIndex: type(uint256).max, + passkeyEnabled: false, + educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), + bootstrap: _buildBootstrapWithTasks(), + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: OrgDeployer.TaskManagerPermConfig({roleIndices: roleIndices, masks: masks}) + }); + } + + /// @dev Create a fresh project + task post-deploy with no per-project mask for the editor hat + /// (so the global ROLE_PERM grant isn't shadowed). Uses the executor as creator/PM to + /// avoid leaking per-project perms to any role we care about, and to give the test a + /// caller that bypasses createTask's permission gate. The claimer is then a hat wearer + /// whose hat IS in claimHats. Returns the fresh pid and the claimed task id. + function _createFreshProjectAndClaim( + TaskManager tm, + address executorAddr, + uint256 claimerHatId, + address claimerAddr + ) internal returns (bytes32 pid, uint256 taskId) { + uint256[] memory clHats = new uint256[](1); + clHats[0] = claimerHatId; + + // Executor creates the project — bypasses createProject's creator-hat gate. We + // deliberately pass empty createHats so no role gets a per-project CREATE mask that + // could shadow a global grant we want to test. + vm.prank(executorAddr); + pid = tm.createProject( + TaskManager.BootstrapProjectConfig({ + title: bytes("fresh"), + metadataHash: bytes32(0), + cap: 100 ether, + managers: new address[](0), + createHats: new uint256[](0), + claimHats: clHats, + reviewHats: new uint256[](0), + assignHats: new uint256[](0), + bountyTokens: new address[](0), + bountyCaps: new uint256[](0) + }) + ); + + // Compute the next task id BEFORE pranking, otherwise the staticcall inside _nextTaskId + // consumes the vm.prank and createTask runs as the test contract. + taskId = _nextTaskId(tm); + vm.prank(executorAddr); + tm.createTask(2 ether, bytes("fresh-task"), bytes32(0), pid, address(0), 0, false); + + vm.prank(claimerAddr); + tm.claimTask(taskId); + } + + /// @dev Probe the next available task id without relying on internal storage. + function _nextTaskId(TaskManager tm) internal view returns (uint256 id) { + for (uint256 i; i < 1_000_000; ++i) { + (bool ok,) = address(tm) + .staticcall(abi.encodeWithSelector(TaskManager.getLensData.selector, uint8(1), abi.encode(i))); + if (!ok) return i; + } + revert("no available task id"); + } + + function testBootstrapTaskManagerPerms_GrantsEditFullAtDeployTime() public { + // Grant EDIT_FULL to EXECUTIVE (role 1) at deploy time. orgOwner wears EXECUTIVE. + vm.startPrank(orgOwner); + OrgDeployer.DeploymentParams memory params = _buildParamsWithTaskPerm(1, TaskPerm.EDIT_FULL); + OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); + vm.stopPrank(); + + TaskManager tm = TaskManager(result.taskManager); + + // Pre-flight: orgOwner must wear EXECUTIVE for the rest of this test to mean anything. + uint256 executiveHat = orgRegistry.getRoleHat(ORG_ID, 1); + require(IHats(SEPOLIA_HATS).isWearerOfHat(orgOwner, executiveHat), "orgOwner must wear EXECUTIVE"); + + // QuickJoin a member who'll claim the fresh task. + address member = makeAddr("v5-member"); + vm.prank(result.executor); + QuickJoin(result.quickJoin).quickJoinNoUserMasterDeploy(member); + + // Create a fresh project with no per-project perms for any role — global EDIT_FULL + // grant for EXECUTIVE applies via _permMask's fallback path. + uint256 defaultHat = orgRegistry.getRoleHat(ORG_ID, 0); + (, uint256 taskId) = _createFreshProjectAndClaim(tm, result.executor, defaultHat, member); + + // orgOwner wears EXECUTIVE → has global EDIT_FULL → can edit the CLAIMED task on the + // fresh project (no per-project mask shadowing the global grant). + vm.prank(orgOwner); + tm.updateTask(taskId, 25 ether, bytes("edited-at-deploy"), bytes32(0), address(0), 0); + + // And updateTaskMetadata is also available to EDIT_FULL holders. + vm.prank(orgOwner); + tm.updateTaskMetadata(taskId, bytes("meta-edit"), bytes32(uint256(0xfeed))); + } + + function testBootstrapTaskManagerPerms_GrantsEditMetaOnlyAtDeployTime() public { + // Grant EDIT_META (not EDIT_FULL) to EXECUTIVE. + vm.startPrank(orgOwner); + OrgDeployer.DeploymentParams memory params = _buildParamsWithTaskPerm(1, TaskPerm.EDIT_META); + OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); + vm.stopPrank(); + + TaskManager tm = TaskManager(result.taskManager); + + address member = makeAddr("v5-member-meta"); + vm.prank(result.executor); + QuickJoin(result.quickJoin).quickJoinNoUserMasterDeploy(member); + + uint256 defaultHat = orgRegistry.getRoleHat(ORG_ID, 0); + (, uint256 taskId) = _createFreshProjectAndClaim(tm, result.executor, defaultHat, member); + + // EDIT_META only — metadata edit succeeds, full edit reverts. + vm.prank(orgOwner); + tm.updateTaskMetadata(taskId, bytes("meta-only"), bytes32(uint256(0xab))); + + vm.prank(orgOwner); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTask(taskId, 50 ether, bytes("nope"), bytes32(0), address(0), 0); + } + + function testBootstrapTaskManagerPerms_BootstrapProjectOverrideShadowsGlobalGrant() public { + // CRITICAL CAVEAT: if a bootstrap project ALSO sets per-project perms for the same hat, + // the project mask replaces the global mask in _permMask. The global EDIT_FULL grant is + // silently shadowed on that project. To get EDIT_FULL on a bootstrap project, the operator + // must call setProjectRolePerm(pid, hat, existingMask | EDIT_FULL) post-deploy. This test + // pins that behavior so the caveat doesn't regress. + vm.startPrank(orgOwner); + OrgDeployer.DeploymentParams memory params = _buildParamsWithTaskPerm(1, TaskPerm.EDIT_FULL); + OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); + vm.stopPrank(); + + TaskManager tm = TaskManager(result.taskManager); + + // Bootstrap task 0 is in the bootstrap project, which set per-project perms for EXECUTIVE + // (createHats/claimHats/etc.). The global EDIT_FULL grant is shadowed there. + address member = makeAddr("v5-shadow-member"); + vm.prank(result.executor); + QuickJoin(result.quickJoin).quickJoinNoUserMasterDeploy(member); + vm.prank(member); + tm.claimTask(0); + + vm.prank(orgOwner); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTask(0, 25 ether, bytes("shadowed"), bytes32(0), address(0), 0); + } + + function testBootstrapTaskManagerPerms_EmptyRoleIndicesWithMasksRevertsAtomic() public { + // Regression: an earlier draft only entered the bootstrapGlobalPerms branch when + // roleIndices.length > 0, which silently dropped a malformed config that had empty + // roleIndices + non-empty masks. The deploy must revert atomically so the misconfig + // surfaces immediately. + vm.startPrank(orgOwner); + + string[] memory names = new string[](2); + names[0] = "DEFAULT"; + names[1] = "EXECUTIVE"; + string[] memory images = new string[](2); + images[0] = "ipfs://default"; + images[1] = "ipfs://executive"; + bool[] memory voting = new bool[](2); + voting[0] = true; + voting[1] = true; + + // Empty roleIndices + non-empty masks — the malformed-config case. + uint256[] memory roleIndices = new uint256[](0); + uint8[] memory masks = new uint8[](1); + masks[0] = TaskPerm.EDIT_FULL; + + OrgDeployer.DeploymentParams memory params = OrgDeployer.DeploymentParams({ + orgId: ORG_ID, + orgName: "v5-empty-roles-masks", + metadataHash: bytes32(0), + registryAddr: accountRegProxy, + deployerAddress: orgOwner, + deployerUsername: "", + regDeadline: 0, + regNonce: 0, + regSignature: "", + autoUpgrade: true, + hybridThresholdPct: 50, + ddThresholdPct: 50, + hybridClasses: _buildLegacyClasses(50, 50, false, 4 ether), + ddInitialTargets: new address[](0), + roles: _buildSimpleRoleConfigs(names, images, voting), + roleAssignments: _buildDefaultRoleAssignments(), + metadataAdminRoleIndex: type(uint256).max, + passkeyEnabled: false, + educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), + bootstrap: _emptyBootstrap(), + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: OrgDeployer.TaskManagerPermConfig({roleIndices: roleIndices, masks: masks}) + }); + + vm.expectRevert(TaskManager.ArrayLengthMismatch.selector); + deployer.deployFullOrg(params); + vm.stopPrank(); + } + + function testBootstrapTaskManagerPerms_LengthMismatchRevertsAtomicDeploy() public { + vm.startPrank(orgOwner); + + string[] memory names = new string[](2); + names[0] = "DEFAULT"; + names[1] = "EXECUTIVE"; + string[] memory images = new string[](2); + images[0] = "ipfs://default"; + images[1] = "ipfs://executive"; + bool[] memory voting = new bool[](2); + voting[0] = true; + voting[1] = true; + + uint256[] memory roleIndices = new uint256[](2); + roleIndices[0] = 0; + roleIndices[1] = 1; + uint8[] memory masks = new uint8[](1); // mismatched length + masks[0] = TaskPerm.EDIT_FULL; + + OrgDeployer.DeploymentParams memory params = OrgDeployer.DeploymentParams({ + orgId: ORG_ID, + orgName: "v5-bad-perms", + metadataHash: bytes32(0), + registryAddr: accountRegProxy, + deployerAddress: orgOwner, + deployerUsername: "", + regDeadline: 0, + regNonce: 0, + regSignature: "", + autoUpgrade: true, + hybridThresholdPct: 50, + ddThresholdPct: 50, + hybridClasses: _buildLegacyClasses(50, 50, false, 4 ether), + ddInitialTargets: new address[](0), + roles: _buildSimpleRoleConfigs(names, images, voting), + roleAssignments: _buildDefaultRoleAssignments(), + metadataAdminRoleIndex: type(uint256).max, + passkeyEnabled: false, + educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), + bootstrap: _emptyBootstrap(), + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: OrgDeployer.TaskManagerPermConfig({roleIndices: roleIndices, masks: masks}) + }); + + // The whole deploy reverts atomically — no partial state. + vm.expectRevert(TaskManager.ArrayLengthMismatch.selector); + deployer.deployFullOrg(params); + vm.stopPrank(); + } + + function testBootstrapTaskManagerPerms_InvalidRoleIndexReverts() public { + vm.startPrank(orgOwner); + + // Role index 99 is out of bounds (only 2 roles defined). + OrgDeployer.DeploymentParams memory params = _buildParamsWithTaskPerm(99, TaskPerm.EDIT_FULL); + + vm.expectRevert(bytes("Invalid role index in bootstrap config")); + deployer.deployFullOrg(params); + vm.stopPrank(); + } + + function testBootstrapTaskManagerPerms_GrantedHatIsEnumeratedPostDeploy() public { + // After deploy, the granted hat should be in permissionHatIds (so setProjectRolePerm + // and other downstream consumers can iterate it correctly). + vm.startPrank(orgOwner); + OrgDeployer.DeploymentParams memory params = _buildParamsWithTaskPerm(1, TaskPerm.EDIT_FULL); + OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); + vm.stopPrank(); + + TaskManager tm = TaskManager(result.taskManager); + TaskManagerLens permsLens = new TaskManagerLens(); + uint256[] memory permHats = + abi.decode(permsLens.getStorage(address(tm), TaskManagerLens.StorageKey.PERMISSION_HATS, ""), (uint256[])); + + // Bootstrap also added per-project perms for createHats/claimHats/etc, so we just assert + // that the granted EXECUTIVE hat is present. + uint256 executiveHatId = orgRegistry.getRoleHat(ORG_ID, 1); + bool found; + for (uint256 i; i < permHats.length; ++i) { + if (permHats[i] == executiveHatId) { + found = true; + break; + } + } + assertTrue(found, "executive hat enumerated after bootstrap"); + } + + function testBootstrapTaskManagerPerms_EmptyArraysSkipsTheCall() public { + // Backwards-compat: empty taskManagerPerms = no grants, no revert (existing deploys work). + vm.startPrank(orgOwner); + OrgDeployer.DeploymentParams memory params = _buildParamsWithTaskPerm(0, 0); // overwritten below + params.taskManagerPerms = _emptyTaskManagerPerms(); + OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); + vm.stopPrank(); + + // Deploy succeeded. Without any global EDIT_FULL grant, post-claim edits revert. + TaskManager tm = TaskManager(result.taskManager); + + address member = makeAddr("v5-empty-member"); + vm.prank(result.executor); + QuickJoin(result.quickJoin).quickJoinNoUserMasterDeploy(member); + vm.prank(member); + tm.claimTask(0); + + vm.prank(orgOwner); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTask(0, 50 ether, bytes("nope"), bytes32(0), address(0), 0); + } + function testDeployFullOrgMismatchExecutorReverts() public { _deployFullOrg(); address other = address(99); @@ -1090,7 +1448,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); deployer.deployFullOrg(params); @@ -1134,7 +1493,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -1221,7 +1581,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -1495,7 +1856,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -1713,7 +2075,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -1957,7 +2320,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -2097,7 +2461,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); vm.expectRevert(OrgDeployer.InvalidRoleConfiguration.selector); @@ -2376,7 +2741,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -2636,7 +3002,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -2722,7 +3089,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); deployer.deployFullOrg(params); @@ -2777,7 +3145,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -2947,7 +3316,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -3934,7 +4304,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); // Record logs to verify HatCreatedWithEligibility events were emitted @@ -4038,7 +4409,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: false}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -4103,7 +4475,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: false}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -4181,7 +4554,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: false}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -4617,7 +4991,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); vm.prank(deployerSigner); @@ -4663,7 +5038,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); vm.prank(orgOwner); @@ -4720,7 +5096,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); vm.prank(deployerSigner); @@ -4773,7 +5150,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); deployer.deployFullOrg(params); @@ -4824,7 +5202,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); deployer.deployFullOrg(params); @@ -4879,7 +5258,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); deployer.deployFullOrg(params); @@ -4928,7 +5308,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); deployer.deployFullOrg(params); // Should NOT revert @@ -4979,7 +5360,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); vm.deal(orgOwner, 1 ether); @@ -5045,7 +5427,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); vm.deal(orgOwner, 1 ether); @@ -5143,7 +5526,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); vm.prank(orgOwner); @@ -5197,7 +5581,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); vm.prank(orgOwner); @@ -5267,7 +5652,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); vm.prank(orgOwner); @@ -5334,7 +5720,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: false}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); vm.prank(orgOwner); @@ -5410,7 +5797,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); vm.deal(orgOwner, 1 ether); @@ -5616,7 +6004,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: false}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); vm.deal(orgOwner, 1 ether); @@ -5691,7 +6080,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); vm.deal(orgOwner, 1 ether); @@ -5760,7 +6150,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: false}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); vm.prank(orgOwner); @@ -5828,7 +6219,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: false}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); // No ETH sent — budgets are the only config @@ -6067,7 +6459,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: true, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: pmConfig + paymasterConfig: pmConfig, + taskManagerPerms: _emptyTaskManagerPerms() }); vm.deal(orgOwner, 1 ether); @@ -6325,7 +6718,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); @@ -6549,7 +6943,8 @@ contract DeployerTest is Test, IEligibilityModuleEvents { passkeyEnabled: false, educationHubConfig: ModulesFactory.EducationHubConfig({enabled: true}), bootstrap: _emptyBootstrap(), - paymasterConfig: _defaultPaymasterConfig() + paymasterConfig: _defaultPaymasterConfig(), + taskManagerPerms: _emptyTaskManagerPerms() }); OrgDeployer.DeploymentResult memory result = deployer.deployFullOrg(params); diff --git a/test/TaskManager.t.sol b/test/TaskManager.t.sol index 6ddcaf2..1c6f9a5 100644 --- a/test/TaskManager.t.sol +++ b/test/TaskManager.t.sol @@ -463,9 +463,12 @@ contract MockToken is Test, IERC20 { vm.prank(member1); tm.claimTask(id); - // attempt to update claimed task should revert - vm.prank(pm1); - vm.expectRevert(TaskManager.BadStatus.selector); + // outsider holds no hats and is not a project manager — post-claim updates from a + // non-PM, non-EDIT_FULL caller revert. (_prepareFlow promotes pm1 to PM, so pm1 now + // implicitly has EDIT_FULL on this project; PM bypass and EDIT_FULL hat positive + // paths are covered in TaskManagerEditPermsTest.) + vm.prank(outsider); + vm.expectRevert(TaskManager.Unauthorized.selector); tm.updateTask(id, 5 ether, bytes("newhash"), bytes32(0), address(0), 0); } @@ -4143,9 +4146,11 @@ contract MockToken is Test, IERC20 { vm.prank(creator1); tm.assignTask(0, member1); - // Update bounty after claim should revert - vm.prank(creator1); - vm.expectRevert(TaskManager.BadStatus.selector); + // outsider has no EDIT_FULL grant and is not a PM — post-claim updates revert Unauthorized. + // (creator1 is a project manager and would now succeed; PM bypass is covered by + // dedicated positive-path tests.) + vm.prank(outsider); + vm.expectRevert(TaskManager.Unauthorized.selector); tm.updateTask(0, 1 ether, bytes("updated_metadata"), bytes32(0), address(bountyToken2), 0.6 ether); } @@ -5387,9 +5392,9 @@ contract MockToken is Test, IERC20 { vm.prank(creator1); tm.assignTask(0, member1); - // Update bounty after claim should revert - vm.prank(creator1); - vm.expectRevert(TaskManager.BadStatus.selector); + // outsider has no EDIT_FULL grant and is not a PM — post-claim updates revert Unauthorized. + vm.prank(outsider); + vm.expectRevert(TaskManager.Unauthorized.selector); tm.updateTask(0, 1 ether, bytes("updated"), bytes32(0), address(bountyToken2), 3 ether); } @@ -5591,12 +5596,12 @@ contract MockToken is Test, IERC20 { (cap, spent) = abi.decode(result, (uint256, uint256)); assertEq(spent, 1.5 ether, "Spent should be updated correctly"); - // Update after claiming should revert + // Update after claiming reverts for non-PM, non-EDIT_FULL callers. vm.prank(creator1); tm.assignTask(0, member1); - vm.prank(creator1); - vm.expectRevert(TaskManager.BadStatus.selector); + vm.prank(outsider); + vm.expectRevert(TaskManager.Unauthorized.selector); tm.updateTask(0, 1 ether, bytes("updated2"), bytes32(0), address(bountyToken1), 1 ether); } @@ -7528,3 +7533,704 @@ contract MockToken is Test, IERC20 { tm.setConfig(TaskManager.ConfigKey.PROJECT_CAP, abi.encode(PID, uint256(100 ether))); } } + + /*──────────────────── Edit Permission Tests ────────────────────*/ + contract TaskManagerEditPermsTest is TaskManagerTestBase { + uint256 constant EDIT_META_HAT = 10; + uint256 constant EDIT_FULL_HAT = 11; + + address editorMeta = makeAddr("editorMeta"); + address editorFull = makeAddr("editorFull"); + + bytes32 PID; + uint256 _nextId; + + function setUp() public { + setUpBase(); + + // Mint the new editor hats and grant the corresponding TaskPerm bits globally. + setHat(editorMeta, EDIT_META_HAT); + setHat(editorFull, EDIT_FULL_HAT); + + vm.startPrank(executor); + tm.setConfig(TaskManager.ConfigKey.ROLE_PERM, abi.encode(EDIT_META_HAT, TaskPerm.EDIT_META)); + tm.setConfig(TaskManager.ConfigKey.ROLE_PERM, abi.encode(EDIT_FULL_HAT, TaskPerm.EDIT_FULL)); + vm.stopPrank(); + + PID = _createDefaultProject("EDIT_PERMS", 100 ether); + } + + /// @dev Returns the on-chain task's payout, bountyToken, bountyPayout, status, claimer. + function _taskFields(uint256 id) + internal + view + returns ( + uint256 payout, + address bountyToken, + uint256 bountyPayout, + TaskManager.Status status, + address claimer + ) + { + bytes memory result = lens.getStorage( + address(tm), TaskManagerLens.StorageKey.TASK_FULL_INFO, abi.encode(id) + ); + bytes32 projectId; + bool requiresApplication; + (payout, bountyPayout, bountyToken, status, claimer, projectId, requiresApplication) = + abi.decode(result, (uint256, uint256, address, TaskManager.Status, address, bytes32, bool)); + } + + function _projectSpent(bytes32 pid) internal view returns (uint256) { + bytes memory result = + lens.getStorage(address(tm), TaskManagerLens.StorageKey.PROJECT_INFO, abi.encode(pid)); + (, uint256 spent,) = abi.decode(result, (uint256, uint256, bool)); + return spent; + } + + function _createAndClaim(uint256 payout) internal returns (uint256 id) { + id = _nextId++; + vm.prank(creator1); + tm.createTask(payout, bytes("orig"), bytes32(0), PID, address(0), 0, false); + vm.prank(member1); + tm.claimTask(id); + } + + function test_EditFull_UpdatesPayoutPostClaim() public { + uint256 id = _createAndClaim(2 ether); + assertEq(_projectSpent(PID), 2 ether); + + vm.prank(editorFull); + tm.updateTask(id, 5 ether, bytes("new-title"), bytes32(0), address(0), 0); + + (uint256 payout,,, TaskManager.Status status,) = _taskFields(id); + assertEq(payout, 5 ether, "payout should be updated"); + assertEq(uint256(status), uint256(TaskManager.Status.CLAIMED), "status unchanged"); + assertEq(_projectSpent(PID), 5 ether, "p.spent must net the delta"); + } + + function test_EditMeta_UpdatesMetadataPostClaim() public { + uint256 id = _createAndClaim(2 ether); + + vm.prank(editorMeta); + tm.updateTaskMetadata(id, bytes("renamed"), bytes32(uint256(0xdeadbeef))); + + // Payout / bounty unchanged. + (uint256 payout, address bToken, uint256 bPay,,) = _taskFields(id); + assertEq(payout, 2 ether); + assertEq(bToken, address(0)); + assertEq(bPay, 0); + assertEq(_projectSpent(PID), 2 ether, "metadata-only edit must not touch budget"); + } + + function test_EditMeta_CannotChangePayoutViaUpdateTask() public { + uint256 id = _createAndClaim(2 ether); + + // EDIT_META alone is insufficient for the full updateTask post-claim. + vm.prank(editorMeta); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTask(id, 5 ether, bytes("new"), bytes32(0), address(0), 0); + } + + function test_EditFull_CanAlsoCallUpdateTaskMetadata() public { + uint256 id = _createAndClaim(2 ether); + + // EDIT_FULL is a strict superset of EDIT_META. + vm.prank(editorFull); + tm.updateTaskMetadata(id, bytes("renamed"), bytes32(uint256(0xfeed))); + } + + function test_EditFull_RevertsOnCompletedTask() public { + uint256 id = _createAndClaim(2 ether); + + vm.prank(member1); + tm.submitTask(id, keccak256("done")); + vm.prank(pm1); // pm1 wears PM_HAT (REVIEW perm) + tm.completeTask(id); + + vm.prank(editorFull); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.updateTask(id, 3 ether, bytes("x"), bytes32(0), address(0), 0); + + vm.prank(editorFull); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.updateTaskMetadata(id, bytes("x"), bytes32(0)); + } + + function test_EditFull_RevertsOnCancelledTask() public { + uint256 id = _nextId++; + vm.prank(creator1); + tm.createTask(2 ether, bytes("c"), bytes32(0), PID, address(0), 0, false); + vm.prank(creator1); + tm.cancelTask(id); + + vm.prank(editorFull); + vm.expectRevert(TaskManager.BadStatus.selector); + tm.updateTask(id, 3 ether, bytes("x"), bytes32(0), address(0), 0); + } + + function test_Pm_ImplicitEditFullPostClaim() public { + uint256 id = _createAndClaim(2 ether); + + // creator1 is the project manager (auto-added on createProject). No EDIT_FULL grant. + vm.prank(creator1); + tm.updateTask(id, 7 ether, bytes("pm-edit"), bytes32(0), address(0), 0); + + (uint256 payout,,,,) = _taskFields(id); + assertEq(payout, 7 ether); + assertEq(_projectSpent(PID), 7 ether); + } + + function test_Executor_ImplicitEditFullPostClaim() public { + uint256 id = _createAndClaim(2 ether); + + vm.prank(executor); + tm.updateTask(id, 9 ether, bytes("exec-edit"), bytes32(0), address(0), 0); + + (uint256 payout,,,,) = _taskFields(id); + assertEq(payout, 9 ether); + } + + function test_EditFull_SubmittedTaskEditable() public { + uint256 id = _createAndClaim(2 ether); + vm.prank(member1); + tm.submitTask(id, keccak256("done")); + + vm.prank(editorFull); + tm.updateTask(id, 3 ether, bytes("late-bump"), bytes32(0), address(0), 0); + + (uint256 payout,,, TaskManager.Status status,) = _taskFields(id); + assertEq(payout, 3 ether); + assertEq(uint256(status), uint256(TaskManager.Status.SUBMITTED)); + } + + function test_EditMeta_OutsiderReverts() public { + uint256 id = _createAndClaim(2 ether); + + vm.prank(outsider); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTaskMetadata(id, bytes("x"), bytes32(0)); + } + + function test_EditMeta_CreatorHatWorksPreClaimOnly() public { + uint256 id = _nextId++; + vm.prank(creator1); + tm.createTask(2 ether, bytes("c"), bytes32(0), PID, address(0), 0, false); + + // creator2 has CREATE perm via CREATOR_HAT but is NOT the PM of this project. + vm.prank(creator2); + tm.updateTaskMetadata(id, bytes("pre-claim-edit"), bytes32(uint256(0xab))); + + // Claim and try again — pre-claim CREATE path no longer applies. + vm.prank(member1); + tm.claimTask(id); + + vm.prank(creator2); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTaskMetadata(id, bytes("post-claim-edit"), bytes32(0)); + } + + function test_BountyTokenSwapPostClaim() public { + MockERC20 tokenA = new MockERC20(); + MockERC20 tokenB = new MockERC20(); + tokenA.mint(address(tm), 10 ether); + tokenB.mint(address(tm), 10 ether); + + address[] memory bountyTokens = new address[](2); + bountyTokens[0] = address(tokenA); + bountyTokens[1] = address(tokenB); + uint256[] memory bountyCaps = new uint256[](2); + bountyCaps[0] = 10 ether; + bountyCaps[1] = 10 ether; + bytes32 bountyPid = _createProjectWithBountyBudget("BPROJ", 100 ether, bountyTokens, bountyCaps); + + uint256 id = _nextId++; + vm.prank(creator1); + tm.createTask(1 ether, bytes("b"), bytes32(0), bountyPid, address(tokenA), 2 ether, false); + vm.prank(creator1); + tm.assignTask(id, member1); + + _assertBountySpent(bountyPid, address(tokenA), 2 ether); + _assertBountySpent(bountyPid, address(tokenB), 0); + + vm.prank(editorFull); + tm.updateTask(id, 1 ether, bytes("b-swap"), bytes32(0), address(tokenB), 3 ether); + + _assertBountySpent(bountyPid, address(tokenA), 0); + _assertBountySpent(bountyPid, address(tokenB), 3 ether); + } + + function test_BountyTokenSwapToDisabledTokenReverts() public { + MockERC20 tokenA = new MockERC20(); + MockERC20 tokenB = new MockERC20(); + tokenA.mint(address(tm), 10 ether); + + address[] memory bountyTokens = new address[](1); + bountyTokens[0] = address(tokenA); + uint256[] memory bountyCaps = new uint256[](1); + bountyCaps[0] = 10 ether; + bytes32 bountyPid = _createProjectWithBountyBudget("BPROJ2", 100 ether, bountyTokens, bountyCaps); + + uint256 id = _nextId++; + vm.prank(creator1); + tm.createTask(1 ether, bytes("b"), bytes32(0), bountyPid, address(tokenA), 2 ether, false); + vm.prank(creator1); + tm.assignTask(id, member1); + + // tokenB has no budget on this project (cap = 0 = DISABLED) — swap reverts via BudgetLib. + vm.prank(editorFull); + vm.expectRevert(BudgetLib.BudgetExceeded.selector); + tm.updateTask(id, 1 ether, bytes("b-bad"), bytes32(0), address(tokenB), 1 ether); + } + + function test_ProjectOverrideRemovesEditFull() public { + // editorFull has TaskPerm.EDIT_FULL globally. Override the per-project mask to + // something non-zero that does NOT include EDIT_FULL (project mask replaces global + // when set to any non-zero value; setting to 0 would fall back to global). + // Calls on PID should revert, calls on a second project still succeed via global. + bytes32 otherPid = _createDefaultProject("OTHER", 100 ether); + + vm.prank(creator1); + tm.setProjectRolePerm(PID, EDIT_FULL_HAT, TaskPerm.CLAIM); + + uint256 id = _createAndClaim(2 ether); + vm.prank(editorFull); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTask(id, 3 ether, bytes("x"), bytes32(0), address(0), 0); + + // On the other project (no override), the global EDIT_FULL still grants access. + uint256 otherId = _nextId++; + vm.prank(creator1); + tm.createTask(2 ether, bytes("o"), bytes32(0), otherPid, address(0), 0, false); + vm.prank(member1); + tm.claimTask(otherId); + + vm.prank(editorFull); + tm.updateTask(otherId, 3 ether, bytes("ok"), bytes32(0), address(0), 0); + } + + function test_EditFull_OnCreateAndAssignTask() public { + // createAndAssignTask lands tasks directly in CLAIMED — prove they are still + // editable by EDIT_FULL holders (would have been uneditable forever pre-v5). + uint256 id = _nextId++; + vm.prank(creator1); // creator1 is the PM (and has both CREATE and ASSIGN via creator hat? no — needs explicit) + // creator1 is a PM via auto-add on createProject. PMs satisfy the createAndAssignTask + // permission gate (hasCreateAndAssign || isPM). + tm.createAndAssignTask(2 ether, bytes("ca"), bytes32(0), PID, member1, address(0), 0, false); + + (uint256 payoutBefore,,, TaskManager.Status statusBefore,) = _taskFields(id); + assertEq(uint256(statusBefore), uint256(TaskManager.Status.CLAIMED)); + assertEq(payoutBefore, 2 ether); + + // EDIT_FULL holder can edit the directly-claimed task. + vm.prank(editorFull); + tm.updateTask(id, 4 ether, bytes("ca-edited"), bytes32(0), address(0), 0); + + (uint256 payoutAfter,,,,) = _taskFields(id); + assertEq(payoutAfter, 4 ether); + assertEq(_projectSpent(PID), 4 ether); + } + + function test_EditedPayoutFlowsThroughCompleteTask() public { + // Critical invariant: edits to t.payout post-claim must flow through to the PT + // minted at completion. Otherwise edits would be silently lost. + uint256 id = _createAndClaim(2 ether); + + vm.prank(editorFull); + tm.updateTask(id, 7 ether, bytes("bumped"), bytes32(0), address(0), 0); + + vm.prank(member1); + tm.submitTask(id, keccak256("done")); + + uint256 balBefore = token.balanceOf(member1); + vm.prank(pm1); // pm1 has PM_HAT → REVIEW perm + tm.completeTask(id); + uint256 balAfter = token.balanceOf(member1); + + assertEq(balAfter - balBefore, 7 ether, "completeTask should mint the edited payout, not the original"); + } + + function test_Pm_CanCallUpdateTaskMetadata() public { + uint256 id = _createAndClaim(2 ether); + + vm.prank(creator1); // PM via auto-add + tm.updateTaskMetadata(id, bytes("pm-meta-edit"), bytes32(uint256(0x42))); + + // Payout untouched. + (uint256 payout,,,,) = _taskFields(id); + assertEq(payout, 2 ether); + } + + function test_TaskUpdatedEventEmittedOnMetadataOnlyEdit() public { + uint256 id = _createAndClaim(2 ether); + + // Subgraph consumers depend on TaskUpdated to know about metadata changes. Re-emit + // includes the existing payout/bountyToken/bountyPayout verbatim so a naive + // indexer that overwrites all fields stays correct. + vm.expectEmit(true, false, false, true); + emit TaskManager.TaskUpdated(id, 2 ether, address(0), 0, bytes("meta-renamed"), bytes32(uint256(0x99))); + + vm.prank(editorMeta); + tm.updateTaskMetadata(id, bytes("meta-renamed"), bytes32(uint256(0x99))); + } + + function _assertBountySpent(bytes32 pid, address token, uint256 expected) internal { + bytes memory result = + lens.getStorage(address(tm), TaskManagerLens.StorageKey.BOUNTY_BUDGET, abi.encode(pid, token)); + (, uint256 spent) = abi.decode(result, (uint256, uint256)); + assertEq(spent, expected, "bounty spent mismatch"); + } + } + + /*──────────────────── bootstrapGlobalPerms tests ────────────────────*/ + contract TaskManagerBootstrapGlobalPermsTest is Test { + /* test actors */ + address deployer = makeAddr("deployer"); + address executor = makeAddr("executor"); + address creator1 = makeAddr("creator1"); + address member1 = makeAddr("member1"); + address outsider = makeAddr("outsider"); + address editorFull = makeAddr("editorFull"); + + uint256 constant CREATOR_HAT = 1; + uint256 constant MEMBER_HAT = 3; + uint256 constant EDIT_FULL_HAT = 11; + uint256 constant EDIT_META_HAT = 10; + uint256 constant BUDGET_HAT = 12; + + TaskManager tm; + TaskManagerLens lens; + MockToken token; + MockHats hats; + + function setUp() public { + token = new MockToken(); + hats = new MockHats(); + + hats.mintHat(CREATOR_HAT, creator1); + hats.mintHat(MEMBER_HAT, member1); + hats.mintHat(EDIT_FULL_HAT, editorFull); + + TaskManager _tmImpl = new TaskManager(); + UpgradeableBeacon _tmBeacon = new UpgradeableBeacon(address(_tmImpl), address(this)); + tm = TaskManager(address(new BeaconProxy(address(_tmBeacon), ""))); + lens = new TaskManagerLens(); + + uint256[] memory creatorHats = new uint256[](1); + creatorHats[0] = CREATOR_HAT; + + // Initialize with `deployer` as the bootstrap-window admin. No global CLAIM grant + // is needed because _setupProjectAndTask seeds CLAIM via the project's claimHats + // array — keeping the global permissionHatIds enumeration empty at baseline so the + // tests can assert exact counts. + tm.initialize(address(token), address(hats), creatorHats, executor, deployer); + } + + /* ---------- Edge case 1: happy path ---------- */ + function test_BootstrapGlobalPerms_GrantsSetMaskAndAddToPermissionHats() public { + uint256[] memory hatIds = new uint256[](2); + uint8[] memory masks = new uint8[](2); + hatIds[0] = EDIT_FULL_HAT; + hatIds[1] = EDIT_META_HAT; + masks[0] = TaskPerm.EDIT_FULL; + masks[1] = TaskPerm.EDIT_META; + + vm.expectEmit(true, false, false, true); + emit TaskManager.RolePermSet(EDIT_FULL_HAT, TaskPerm.EDIT_FULL); + vm.expectEmit(true, false, false, true); + emit TaskManager.RolePermSet(EDIT_META_HAT, TaskPerm.EDIT_META); + + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + uint256[] memory permHats = + abi.decode( + lens.getStorage(address(tm), TaskManagerLens.StorageKey.PERMISSION_HATS, ""), (uint256[]) + ); + assertEq(permHats.length, 2, "two hats now enumerated"); + // Order is not guaranteed; check both are present. + bool foundFull; + bool foundMeta; + for (uint256 i; i < permHats.length; ++i) { + if (permHats[i] == EDIT_FULL_HAT) foundFull = true; + if (permHats[i] == EDIT_META_HAT) foundMeta = true; + } + assertTrue(foundFull && foundMeta, "both hats enumerated"); + } + + /* ---------- Edge case 2: non-deployer reverts ---------- */ + function test_BootstrapGlobalPerms_NotDeployerReverts() public { + uint256[] memory hatIds = new uint256[](1); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL; + + // Try every wrong sender: executor, creator, outsider — all should revert. + vm.prank(executor); + vm.expectRevert(TaskManager.NotDeployer.selector); + tm.bootstrapGlobalPerms(hatIds, masks); + + vm.prank(creator1); + vm.expectRevert(TaskManager.NotDeployer.selector); + tm.bootstrapGlobalPerms(hatIds, masks); + + vm.prank(outsider); + vm.expectRevert(TaskManager.NotDeployer.selector); + tm.bootstrapGlobalPerms(hatIds, masks); + } + + /* ---------- Edge case 3: after clearDeployer ---------- */ + function test_BootstrapGlobalPerms_AfterClearDeployerReverts() public { + vm.prank(deployer); + tm.clearDeployer(); + + uint256[] memory hatIds = new uint256[](1); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL; + + // Even the original deployer is now blocked — l.deployer is address(0). + vm.prank(deployer); + vm.expectRevert(TaskManager.NotDeployer.selector); + tm.bootstrapGlobalPerms(hatIds, masks); + } + + /* ---------- Edge case 4: empty arrays = no-op (must not revert) ---------- */ + function test_BootstrapGlobalPerms_EmptyArraysIsNoOp() public { + vm.prank(deployer); + tm.bootstrapGlobalPerms(new uint256[](0), new uint8[](0)); + + uint256[] memory permHats = + abi.decode( + lens.getStorage(address(tm), TaskManagerLens.StorageKey.PERMISSION_HATS, ""), (uint256[]) + ); + assertEq(permHats.length, 0, "no hats enumerated"); + } + + /* ---------- Edge case 5: length mismatch reverts ArrayLengthMismatch ---------- */ + function test_BootstrapGlobalPerms_LengthMismatchReverts() public { + uint256[] memory hatIds = new uint256[](2); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + hatIds[1] = EDIT_META_HAT; + masks[0] = TaskPerm.EDIT_FULL; + + vm.prank(deployer); + vm.expectRevert(TaskManager.ArrayLengthMismatch.selector); + tm.bootstrapGlobalPerms(hatIds, masks); + } + + /* ---------- Edge case 6: duplicate hat IDs — last write wins ---------- */ + function test_BootstrapGlobalPerms_DuplicateHatLastWins() public { + uint256[] memory hatIds = new uint256[](2); + uint8[] memory masks = new uint8[](2); + hatIds[0] = EDIT_FULL_HAT; + hatIds[1] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL; + masks[1] = TaskPerm.EDIT_META; // last write wins + + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + // Grant a project, claim a task, then verify EDIT_FULL_HAT cannot edit payout (only meta). + bytes32 pid = _setupProjectAndTask(); + vm.prank(member1); + tm.claimTask(0); + + vm.prank(editorFull); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTask(0, 5 ether, bytes("nope"), bytes32(0), address(0), 0); + + // But metadata-only succeeds because EDIT_META was the last-written mask. + vm.prank(editorFull); + tm.updateTaskMetadata(0, bytes("ok"), bytes32(uint256(0xaa))); + pid; // silence unused + } + + /* ---------- Edge case 7: mask = 0 removes hat from permissionHatIds ---------- */ + function test_BootstrapGlobalPerms_ZeroMaskRemovesHat() public { + // First grant EDIT_FULL. + uint256[] memory hatIds = new uint256[](1); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL; + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + uint256[] memory permHatsBefore = + abi.decode( + lens.getStorage(address(tm), TaskManagerLens.StorageKey.PERMISSION_HATS, ""), (uint256[]) + ); + assertEq(permHatsBefore.length, 1, "hat enumerated after grant"); + + // Now zero it out — should be removed from permissionHatIds. + masks[0] = 0; + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + uint256[] memory permHatsAfter = + abi.decode( + lens.getStorage(address(tm), TaskManagerLens.StorageKey.PERMISSION_HATS, ""), (uint256[]) + ); + assertEq(permHatsAfter.length, 0, "hat de-enumerated after mask=0"); + } + + /* ---------- Edge case 8: equivalence with setConfig(ROLE_PERM) ---------- */ + function test_BootstrapGlobalPerms_EquivalentToSetConfig() public { + uint256[] memory hatIds = new uint256[](1); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL; + + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + // Re-applying via setConfig must be a no-op (idempotent) and leave the same enumerated state. + vm.prank(executor); + tm.setConfig(TaskManager.ConfigKey.ROLE_PERM, abi.encode(EDIT_FULL_HAT, TaskPerm.EDIT_FULL)); + + uint256[] memory permHats = + abi.decode( + lens.getStorage(address(tm), TaskManagerLens.StorageKey.PERMISSION_HATS, ""), (uint256[]) + ); + assertEq(permHats.length, 1, "no duplicate enumeration"); + assertEq(permHats[0], EDIT_FULL_HAT); + } + + /* ---------- Edge case 9: end-to-end — bootstrapped EDIT_FULL hat can edit a CLAIMED task ---------- */ + function test_BootstrapGlobalPerms_GrantedHatCanEditPostClaim() public { + uint256[] memory hatIds = new uint256[](1); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL; + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + _setupProjectAndTask(); + vm.prank(member1); + tm.claimTask(0); + + // The bootstrap-granted hat wearer can immediately edit. + vm.prank(editorFull); + tm.updateTask(0, 5 ether, bytes("bumped"), bytes32(0), address(0), 0); + } + + /* ---------- Edge case 10: multiple bits OR'd into one mask ---------- */ + function test_BootstrapGlobalPerms_MultipleBitsInOneMask() public { + uint256[] memory hatIds = new uint256[](1); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL | TaskPerm.BUDGET; // combined grant + + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + // Editor hat can now also resize budgets via setConfig(PROJECT_CAP, ...). + _setupProjectAndTask(); + vm.prank(editorFull); + tm.setConfig(TaskManager.ConfigKey.PROJECT_CAP, abi.encode(bytes32(uint256(0)), uint256(20 ether))); + } + + /* ---------- Edge case 11: ordering — bootstrap perms before bootstrap projects works ---------- */ + function test_BootstrapGlobalPerms_OrderingWithProjectBootstrap() public { + // Simulate the OrgDeployer ordering: bootstrapGlobalPerms first, then bootstrapProjectsAndTasks. + uint256[] memory hatIds = new uint256[](1); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL; + + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + // Now bootstrap a project + task atomically via the deployer path. + TaskManager.BootstrapProjectConfig[] memory projects = new TaskManager.BootstrapProjectConfig[](1); + uint256[] memory cHats = new uint256[](1); + cHats[0] = CREATOR_HAT; + uint256[] memory clHats = new uint256[](1); + clHats[0] = MEMBER_HAT; + projects[0] = TaskManager.BootstrapProjectConfig({ + title: bytes("BP"), + metadataHash: bytes32(0), + cap: 100 ether, + managers: new address[](0), + createHats: cHats, + claimHats: clHats, + reviewHats: new uint256[](0), + assignHats: new uint256[](0), + bountyTokens: new address[](0), + bountyCaps: new uint256[](0) + }); + + TaskManager.BootstrapTaskConfig[] memory btasks = new TaskManager.BootstrapTaskConfig[](1); + btasks[0] = TaskManager.BootstrapTaskConfig({ + projectIndex: 0, + payout: 1 ether, + title: bytes("bt"), + metadataHash: bytes32(0), + bountyToken: address(0), + bountyPayout: 0, + requiresApplication: false + }); + + vm.prank(deployer); + tm.bootstrapProjectsAndTasks(projects, btasks); + + // Task 0 is unclaimed; claim it via the member. + vm.prank(member1); + tm.claimTask(0); + + // Editor hat (bootstrapped) can edit the now-CLAIMED task. + vm.prank(editorFull); + tm.updateTask(0, 2 ether, bytes("post"), bytes32(0), address(0), 0); + } + + /* ---------- Edge case 12: per-project override still beats bootstrap-granted global ---------- */ + function test_BootstrapGlobalPerms_ProjectOverrideStillWins() public { + uint256[] memory hatIds = new uint256[](1); + uint8[] memory masks = new uint8[](1); + hatIds[0] = EDIT_FULL_HAT; + masks[0] = TaskPerm.EDIT_FULL; + vm.prank(deployer); + tm.bootstrapGlobalPerms(hatIds, masks); + + _setupProjectAndTask(); + + // Set a per-project override that does NOT include EDIT_FULL. + vm.prank(creator1); + tm.setProjectRolePerm(bytes32(uint256(0)), EDIT_FULL_HAT, TaskPerm.CLAIM); + + vm.prank(member1); + tm.claimTask(0); + + // Editor hat is denied on this project despite the global EDIT_FULL grant. + vm.prank(editorFull); + vm.expectRevert(TaskManager.Unauthorized.selector); + tm.updateTask(0, 5 ether, bytes("nope"), bytes32(0), address(0), 0); + } + + /* ---------- Helpers ---------- */ + function _setupProjectAndTask() internal returns (bytes32 pid) { + uint256[] memory cHats = new uint256[](1); + cHats[0] = CREATOR_HAT; + uint256[] memory clHats = new uint256[](1); + clHats[0] = MEMBER_HAT; + + vm.prank(creator1); + pid = tm.createProject( + TaskManager.BootstrapProjectConfig({ + title: bytes("P"), + metadataHash: bytes32(0), + cap: 100 ether, + managers: new address[](0), + createHats: cHats, + claimHats: clHats, + reviewHats: new uint256[](0), + assignHats: new uint256[](0), + bountyTokens: new address[](0), + bountyCaps: new uint256[](0) + }) + ); + vm.prank(creator1); + tm.createTask(2 ether, bytes("t"), bytes32(0), pid, address(0), 0, false); + } + }