diff --git a/deploy/L2PASSpell.sol b/deploy/L2PASSpell.sol index 3d1cdbd..31323ff 100644 --- a/deploy/L2PASSpell.sol +++ b/deploy/L2PASSpell.sol @@ -38,7 +38,8 @@ contract L2PASSpell { uint256 minDelay, address coreCouncil, address[] memory cancellers, - address[] memory pausers + address[] memory pausers, + bool startPaused ) external { PASInstance memory pas = PASInstance({ beamState: beamState, @@ -47,5 +48,6 @@ contract L2PASSpell { }); PASInit.init(pas, minDelay, coreCouncil, cancellers, pausers); + if (startPaused) PASInit.pauseTimelock(timelock, address(this)); } } diff --git a/deploy/PASAuthorizeInPAU.sol b/deploy/PASAuthorizeInPAU.sol new file mode 100644 index 0000000..f9ca571 --- /dev/null +++ b/deploy/PASAuthorizeInPAU.sol @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: © 2026 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity >=0.8.0; + +interface AccessControlLike { + function grantRole(bytes32 role, address account) external; +} + +// Authorizes the PAS Configurator within a Star's PAU access-control system. +// +// Counterpart to PASInit: whereas PASInit configures the PAS-owned contracts (BeamState, +// Configurator, Timelock), this is run by a Star's governance (the current DEFAULT_ADMIN_ROLE +// holder on its PAU controller stack) to grant the PAS Configurator the admin access it needs +// on the Star's own contracts. +// +// The two grants cover everything the Configurator calls: +// - accessControls: gates every facet admin setter dispatched via `callControllerAction` +// (the controller has no roles of its own; it delegates to AccessControls). +// - rateLimits: gates `setRateLimit` (setRateLimitData / setUnlimitedRateLimitData). +// +// NOTE: DEFAULT_ADMIN_ROLE is the OZ role-admin, so each grant makes the Configurator a full +// role-superadmin of that contract (it can grant/revoke any role, including allocators). This +// is the intended trust model but has a wide blast radius; the security of these roles rests +// entirely on the timelock/cBeam/BeamState gating in front of the Configurator. +library PASAuthorizeInPAU { + + bytes32 internal constant DEFAULT_ADMIN_ROLE = 0x00; + + function authorize( + address configurator, + address accessControls, + address rateLimits + ) internal { + AccessControlLike(accessControls).grantRole(DEFAULT_ADMIN_ROLE, configurator); + AccessControlLike(rateLimits).grantRole(DEFAULT_ADMIN_ROLE, configurator); + } +} diff --git a/deploy/PASInit.sol b/deploy/PASInit.sol index 5892371..5980f7e 100644 --- a/deploy/PASInit.sol +++ b/deploy/PASInit.sol @@ -53,6 +53,8 @@ interface TimelockLike { function CANCELLER_ROLE() external view returns (bytes32); function PAUSER_ROLE() external view returns (bytes32); function grantRole(bytes32, address) external; + function revokeRole(bytes32, address) external; + function pause() external; } interface PASMomLike { @@ -145,6 +147,18 @@ library PASInit { } } + // Call after `init` for starting with spell-only configurations of `DELAYED` actions + function pauseTimelock( + address timelock_, + address admin + ) internal { + TimelockLike timelock = TimelockLike(timelock_); + + timelock.grantRole(timelock.PAUSER_ROLE(), admin); + timelock.pause(); + timelock.revokeRole(timelock.PAUSER_ROLE(), admin); + } + function initExtras( PASInstance memory pasInstance, uint256 hop, diff --git a/test/Integration.t.sol b/test/Integration.t.sol index 83c5256..c2d2cda 100644 --- a/test/Integration.t.sol +++ b/test/Integration.t.sol @@ -198,6 +198,43 @@ contract IntegrationTest is DssTest { assertEq(beamState.wards(address(mom)), 1, "mom should have wards on beamState"); } + + function testPauseThenUnpauseTimelock() public { + PASInstance memory freshPas = PASDeploy.deploy(address(this), pauseProxy, MIN_DELAY); + Timelock freshTimelock = Timelock(payable(freshPas.timelock)); + + vm.startPrank(pauseProxy); + PASInit.init(freshPas, MIN_DELAY, coreCouncil, new address[](0), new address[](0)); + // init alone does not pause the timelock + assertFalse(freshTimelock.paused(), "timelock should not be paused by init alone"); + PASInit.pauseTimelock(freshPas.timelock, pauseProxy); + vm.stopPrank(); + + assertTrue(freshTimelock.paused(), "timelock should be paused after pauseTimelock"); + assertFalse(freshTimelock.hasRole(freshTimelock.PAUSER_ROLE(), pauseProxy), "admin should not retain PAUSER_ROLE"); + assertTrue(freshTimelock.hasRole(freshTimelock.PROPOSER_ROLE(), coreCouncil), "coreCouncil should still be configured as proposer"); + + // Configured but frozen: scheduling blocked while paused + address[] memory targets = new address[](1); + targets[0] = freshPas.beamState; + uint256[] memory values = new uint256[](1); + bytes[] memory payloads = new bytes[](1); + payloads[0] = abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam); + vm.prank(coreCouncil); + vm.expectRevert(); + freshTimelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY); + + // unpause() resumes operations (needs only DEFAULT_ADMIN_ROLE, so the admin calls it directly) + vm.prank(pauseProxy); + freshTimelock.unpause(); + assertFalse(freshTimelock.paused(), "timelock should be unpaused"); + + vm.prank(coreCouncil); + freshTimelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY); + bytes32 opId = freshTimelock.hashOperationBatch(targets, values, payloads, bytes32(0), SALT); + assertTrue(freshTimelock.isOperationPending(opId), "operation should schedule after unpause"); + } + function testInitExtras() public { // Deploy a fresh PAS instance for this test PASInstance memory freshPas = PASDeploy.deploy(address(this), pauseProxy, MIN_DELAY); diff --git a/test/L2PASSpell.t.sol b/test/L2PASSpell.t.sol new file mode 100644 index 0000000..6b623e6 --- /dev/null +++ b/test/L2PASSpell.t.sol @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: © 2026 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.24; + +import "dss-test/DssTest.sol"; +import { PASInstance } from "deploy/PASInstance.sol"; +import { PASDeploy } from "deploy/PASDeploy.sol"; +import { L2PASSpell } from "deploy/L2PASSpell.sol"; +import { BeamState } from "src/BeamState.sol"; +import { Timelock } from "src/timelock/Timelock.sol"; + +// Minimal stand-in for the governance proxy that the L2GovernanceRelay executes spells through. +// It holds the admin/ward roles and delegatecalls into the spell, so inside the spell `address(this)` +// (and the msg.sender of its calls) is this proxy - mirroring real spell execution. +contract MockGovProxy { + function exec(address spell, bytes memory data) external { + (bool ok, bytes memory ret) = spell.delegatecall(data); + if (!ok) { + assembly { revert(add(ret, 0x20), mload(ret)) } + } + } +} + +contract L2PASSpellTest is DssTest { + + PASInstance pas; + MockGovProxy proxy; + L2PASSpell spell; + Timelock timelock; + + address coreCouncil = address(0x1); + + uint256 constant MIN_DELAY = 1 days; + + function setUp() public { + proxy = new MockGovProxy(); + + // Deploy PAS owned by the proxy: switchOwner relies the proxy on BeamState and the Timelock + // is constructed with the proxy as admin - exactly the roles the spell relies on at execution. + pas = PASDeploy.deploy(address(this), address(proxy), MIN_DELAY); + timelock = Timelock(payable(pas.timelock)); + + spell = new L2PASSpell(pas.beamState, pas.configurator, pas.timelock); + } + + // Runs the spell the way the relay would: the proxy delegatecalls into it. + function _init(bool startPaused) internal { + proxy.exec( + address(spell), + abi.encodeCall(L2PASSpell.init, (MIN_DELAY, coreCouncil, new address[](0), new address[](0), startPaused)) + ); + } + + function testInitOperational() public { + _init(false); + + assertFalse(timelock.paused(), "timelock should not be paused"); + assertTrue(timelock.hasRole(timelock.PROPOSER_ROLE(), coreCouncil), "coreCouncil should be proposer"); + assertTrue(BeamState(pas.beamState).hasUserRole(address(timelock), uint8(1)), "timelock should have DELAYED role"); + } + + function testInitStartPaused() public { + _init(true); + + assertTrue(timelock.paused(), "timelock should be paused"); + // The proxy temporarily held PAUSER_ROLE to pause; it must not retain it. + assertFalse(timelock.hasRole(timelock.PAUSER_ROLE(), address(proxy)), "proxy should not retain PAUSER_ROLE"); + // Still fully configured despite being paused. + assertTrue(timelock.hasRole(timelock.PROPOSER_ROLE(), coreCouncil), "coreCouncil should be proposer"); + } +} diff --git a/test/PASAuthorizeInPAU.t.sol b/test/PASAuthorizeInPAU.t.sol new file mode 100644 index 0000000..5750296 --- /dev/null +++ b/test/PASAuthorizeInPAU.t.sol @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: © 2026 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.24; + +import "dss-test/DssTest.sol"; + +import { PASAuthorizeInPAU } from "deploy/PASAuthorizeInPAU.sol"; + +import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; + +// Minimal stand-in for a PAU AccessControls / RateLimits contract: both are stock OZ +// AccessControl to this library, so one mock serves for both roles. +contract MockAccessControlled is AccessControl { + constructor(address admin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } +} + +// Stands in for the Star's governance spell: `authorize` is an internal library function, so it +// inlines here and `grantRole` is called with msg.sender == this harness. The harness must hold +// DEFAULT_ADMIN_ROLE on the targets, mirroring the Star proxy in production. +contract Authorizer { + function run(address configurator, address accessControls, address rateLimits) external { + PASAuthorizeInPAU.authorize(configurator, accessControls, rateLimits); + } +} + +contract PASAuthorizeInPAUTest is DssTest { + bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; + + Authorizer authorizer; + address configurator; + + function setUp() public { + authorizer = new Authorizer(); + configurator = makeAddr("configurator"); + } + + function testAuthorizeGrantsBothRoles() public { + MockAccessControlled accessControls = new MockAccessControlled(address(authorizer)); + MockAccessControlled rateLimits = new MockAccessControlled(address(authorizer)); + + // Pre-state: the configurator holds nothing. + assertFalse(accessControls.hasRole(DEFAULT_ADMIN_ROLE, configurator)); + assertFalse(rateLimits.hasRole(DEFAULT_ADMIN_ROLE, configurator)); + + authorizer.run(configurator, address(accessControls), address(rateLimits)); + + // Both targets are granted, not just one. + assertTrue(accessControls.hasRole(DEFAULT_ADMIN_ROLE, configurator)); + assertTrue(rateLimits.hasRole(DEFAULT_ADMIN_ROLE, configurator)); + } + + function testAuthorizeRevertsWhenGrantorNotAdminOnAccessControls() public { + // accessControls admin is someone else -> the first grant reverts. + MockAccessControlled accessControls = new MockAccessControlled(makeAddr("otherAdmin")); + MockAccessControlled rateLimits = new MockAccessControlled(address(authorizer)); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(authorizer), + DEFAULT_ADMIN_ROLE + ) + ); + authorizer.run(configurator, address(accessControls), address(rateLimits)); + } + + function testAuthorizeRevertsWhenGrantorNotAdminOnRateLimits() public { + // accessControls grant succeeds, then the rateLimits grant reverts (and rolls back). + MockAccessControlled accessControls = new MockAccessControlled(address(authorizer)); + MockAccessControlled rateLimits = new MockAccessControlled(makeAddr("otherAdmin")); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(authorizer), + DEFAULT_ADMIN_ROLE + ) + ); + authorizer.run(configurator, address(accessControls), address(rateLimits)); + + // The whole call reverted, so the earlier accessControls grant did not persist. + assertFalse(accessControls.hasRole(DEFAULT_ADMIN_ROLE, configurator)); + } +}