Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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 |
Expand Down Expand Up @@ -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))));
```

Expand Down
20 changes: 20 additions & 0 deletions src/LatticeTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.<name>.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;
Expand Down
109 changes: 109 additions & 0 deletions test/Governed.t.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading