From 510f2c27016922649dff4bd4c1e96030c7477ae3 Mon Sep 17 00:00:00 2001 From: Rajath Alex Date: Fri, 9 Oct 2026 22:49:55 -0400 Subject: [PATCH] feat: governedAsDeclared by name reads the book's adminRole --- docs/testing.md | 4 +- src/LatticeTest.sol | 20 ++++++++ test/Governed.t.sol | 109 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 test/Governed.t.sol diff --git a/docs/testing.md b/docs/testing.md index 7b7d18f..4a0d99f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -43,7 +43,7 @@ rehearsal and `PostUpgrade`: | The proxy runs what the book records | ✓ | | `liveImplementation(name) == Env.implementation(name)` | | Nothing is staged yet | ✓ | | `!Env.hasStaged(name)` | | The operation is not already in the timelock | ✓ | | `operationState(operationId(calls, predecessor, salt)) == OPERATION_UNSET` | -| Governance is as the book declares: owner is the timelock, the authority holds the admin role | ✓ | ✓ | `governedAsDeclared(target)`; `IOwnableRolesView(target).hasAllRoles(who, soladyRole(n))` for other roles; `holdsNoRoles(target, timelock())` | +| Governance is as the book declares: owner is the timelock, and the authority holds the admin role where the book declares one (`contracts..adminRole`) | ✓ | ✓ | `governedAsDeclared("exchange")` by name, reading the book's declaration (a contract declared owner-governed only, the Router, passes on its owner); `governedAsDeclared(target)` by address demands the role; `IOwnableRolesView(target).hasAllRoles(who, soladyRole(n))` for other roles; `holdsNoRoles(target, timelock())` | | The new implementation has code, is UUPS, and nobody can initialize it | ✓ | | `implementationSane(impl) == ""` (or the pieces: `isUUPS`, `initializersDisabled`, `soladyInitializersDisabled`, `ozInitializersDisabled`) | | Its constructor immutables and constants match the live ones | ✓ | | `sameAnswers(proxy, impl, getters) == getters.length`, one `abi.encodeWithSignature` per getter; assert the one that is meant to differ separately | | Storage the upgrade must not touch | snapshot | compare | `snapshot(target, firstSlots(n))`, `firstChangedSlot(target, slots, before) == slots.length` — in one test when the test applies the plan itself; across runs, assert the values the book implies | @@ -113,7 +113,7 @@ applyAsAuthority(calls, bytes32(0), salt); assertEq(operationState(id), OPERATION_DONE); assertEq(liveImplementation("exchange"), Env.staged("exchange")); assertEq(firstChangedSlot(proxy, slots, before), slots.length, "raw storage changed"); -assertTrue(governedAsDeclared(proxy)); +assertTrue(governedAsDeclared("exchange")); assertTrue(reverts(proxy, abi.encodeWithSignature("initialize(address,address)", address(1), address(2)))); ``` diff --git a/src/LatticeTest.sol b/src/LatticeTest.sol index cb4d73b..0222372 100644 --- a/src/LatticeTest.sol +++ b/src/LatticeTest.sol @@ -11,6 +11,9 @@ interface LatticeTestVm { function exists(string calldata path) external view returns (bool); function readFile(string calldata path) external view returns (string memory); function parseJsonUint(string calldata json, string calldata key) external pure returns (uint256); + function parseJsonAddress(string calldata json, string calldata key) external pure returns (address); + function parseJsonBool(string calldata json, string calldata key) external pure returns (bool); + function keyExistsJson(string calldata json, string calldata key) external view returns (bool); function toString(uint256 value) external pure returns (string memory); function skip(bool skipTest, string calldata reason) external; function prank(address msgSender) external; @@ -138,6 +141,23 @@ abstract contract LatticeTest { && IOwnableRolesView(target).hasAllRoles(Env.authority(), SOLADY_ROLE_0); } + /// Whether a contract the book names is governed as the book declares: owned by the + /// timelock and, where the book declares the authority holds its admin role + /// (`contracts..adminRole`), holding `_ROLE_0` there. A contract the book declares + /// owner-governed only — the Router grants no roles — passes on its owner alone, where the + /// address form above demands a role it was never meant to hold. Read off bookPath(), the + /// book this base checks, so a harness with a book of its own is judged by it. + function governedAsDeclared(string memory name) internal view returns (bool) { + string memory book = cheats.readFile(bookPath()); + address target = cheats.parseJsonAddress(book, string.concat(".contracts.", name, ".address")); + if (IOwnableRolesView(target).owner() != cheats.parseJsonAddress(book, ".contracts.timelock.address")) { + return false; + } + string memory key = string.concat(".contracts.", name, ".adminRole"); + if (!cheats.keyExistsJson(book, key) || !cheats.parseJsonBool(book, key)) return true; + return IOwnableRolesView(target).hasAllRoles(cheats.parseJsonAddress(book, ".authority.address"), SOLADY_ROLE_0); + } + /// Solady OwnableRoles' bit for `_ROLE_n`. function soladyRole(uint8 n) internal pure returns (uint256) { return 1 << n; diff --git a/test/Governed.t.sol b/test/Governed.t.sol new file mode 100644 index 0000000..091e9bc --- /dev/null +++ b/test/Governed.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.12; + +import {LatticeTest} from "../src/LatticeTest.sol"; + +interface GovernedVm { + function createDir(string calldata path, bool recursive) external; + function writeFile(string calldata path, string calldata data) external; + function toString(uint256 value) external pure returns (string memory); + function toString(address value) external pure returns (string memory); +} + +/// A target with an owner and one admin, the shape Solady's OwnableRoles presents. +contract GovernedTarget { + address public owner; + address public admin; + + constructor(address owner_, address admin_) { + owner = owner_; + admin = admin_; + } + + function hasAllRoles(address user, uint256 roles) external view returns (bool) { + return user == admin && roles == 1; + } + + function rolesOf(address user) external view returns (uint256) { + return user == admin ? 1 : 0; + } +} + +/// Exposes the base's name-based governance check over a book of its own. Forge runs tests in +/// parallel, so every test below writes a file nothing else writes. (The address form reads the +/// timelock off Env's fixed path and is exercised with the default book elsewhere.) +abstract contract GovernedHarness is LatticeTest { + function byName(string memory name) external view returns (bool) { + return governedAsDeclared(name); + } +} + +contract GovernedHarnessA is GovernedHarness { + function bookPath() internal pure override returns (string memory) { + return ".lattice/governed-a.json"; + } +} + +contract GovernedHarnessB is GovernedHarness { + function bookPath() internal pure override returns (string memory) { + return ".lattice/governed-b.json"; + } +} + +/// `governedAsDeclared(name)` reads the book: owned by the timelock always, the admin role only +/// where the book declares it. +contract GovernedAsDeclaredTest { + GovernedVm constant vm = GovernedVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant AUTHORITY = 0x00447A08bf275b7FB3D5d832387a54Be1090d281; + address constant TIMELOCK = 0x47EbFAC3353314C788B96CDCbf41daadfE03629C; + + function setUp() public { + vm.createDir(".lattice", true); + } + + /// Writes a book at path: the timelock, and one contract `x` at target with the admin role + /// declared as given ("" writes no adminRole field at all). + function writeBook(string memory path, address target, string memory adminRole) internal { + string memory x = string.concat('"x":{"address":"', vm.toString(target), '"'); + if (bytes(adminRole).length != 0) x = string.concat(x, ',"adminRole":', adminRole); + x = string.concat(x, "}"); + vm.writeFile( + path, + string.concat( + '{"id":"t","chainId":', + vm.toString(block.chainid), + ',"authority":{"kind":"safe","address":"', + vm.toString(AUTHORITY), + '"},"contracts":{"timelock":{"address":"', + vm.toString(TIMELOCK), + '"},', + x, + '},"params":{}}' + ) + ); + } + + function test_ownerGovernedOnlyPassesOnItsOwner() public { + GovernedHarnessA h = new GovernedHarnessA(); + // The Router's shape: the timelock owns it, nobody holds its roles. + GovernedTarget t = new GovernedTarget(TIMELOCK, address(0)); + writeBook(".lattice/governed-a.json", address(t), ""); + require(h.byName("x"), "owner-governed only, as declared"); + writeBook(".lattice/governed-a.json", address(t), "false"); + require(h.byName("x"), "adminRole false is owner-governed only"); + writeBook(".lattice/governed-a.json", address(t), "true"); + require(!h.byName("x"), "the book declares a role the authority does not hold"); + } + + function test_adminGovernedNeedsTheRoleAndTheOwner() public { + GovernedHarnessB h = new GovernedHarnessB(); + GovernedTarget t = new GovernedTarget(TIMELOCK, AUTHORITY); + writeBook(".lattice/governed-b.json", address(t), "true"); + require(h.byName("x"), "owner and role, as declared"); + GovernedTarget stray = new GovernedTarget(address(0xBEEF), AUTHORITY); + writeBook(".lattice/governed-b.json", address(stray), "true"); + require(!h.byName("x"), "owned by somebody else"); + writeBook(".lattice/governed-b.json", address(stray), ""); + require(!h.byName("x"), "owned by somebody else, role or not"); + } +}