From dc03f85f02753b1b3365fed9dcb6a9f5dc67570e Mon Sep 17 00:00:00 2001 From: Rajath Alex Date: Sat, 3 Oct 2026 22:49:01 -0400 Subject: [PATCH 1/3] feat: read the authority, existence and address params off the book --- README.md | 2 +- src/Lattice.sol | 47 ++++++++++++++++++++++++++++++++++++++++++++++ test/Lattice.t.sol | 24 ++++++++++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 723e914..7ae1130 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ fs_permissions = [{ access = "read", path = "./.lattice" }] | `Call` | `{to, value, data}`: the unit of a governance plan. A plan's `build()` returns `Call[]`. | | `Deployment` | `{name, addr}`: what a deploy script's entry point returns, naming each address for the address book. | | `EnvVar` | `{key, value}`: one scalar param, for a step that updates params. | -| `Env` | Reads `.lattice/env.json`: `addr(name)`, `implementation(name)`, `staged(name)` (an implementation deployed but not yet live, which is what an upgrade plan proposes), and `str` / `uint256_` / `bool_` for params. | +| `Env` | Reads `.lattice/env.json`: `addr(name)`, `implementation(name)`, `staged(name)` (an implementation deployed but not yet live, which is what an upgrade plan proposes), `has(name)` and `hasStaged(name)`; `str` / `uint256_` / `bool_` / `addr_` for params; `chainId()`; and the governing account, `authority()`, with the owner set and threshold the book expects of a Safe, `authorityOwners()` and `authorityThreshold()` (empty and zero when none is recorded). What a release's pre- and post-checks read. | | `Erc7821`, `Timelock`, `MultiSend` | The encoders for Solady Timelock batches and Safe `MultiSendCallOnly`. | ```solidity diff --git a/src/Lattice.sol b/src/Lattice.sol index ac1efa8..bf657f7 100644 --- a/src/Lattice.sol +++ b/src/Lattice.sol @@ -30,7 +30,11 @@ struct EnvVar { interface LatticeVm { function readFile(string calldata path) external view returns (string memory); function parseJsonAddress(string calldata json, string calldata key) external pure returns (address); + function parseJsonAddressArray(string calldata json, string calldata key) external pure returns (address[] memory); + function parseJsonUint(string calldata json, string calldata key) external pure returns (uint256); function parseJsonString(string calldata json, string calldata key) external pure returns (string memory); + function keyExistsJson(string calldata json, string calldata key) external view returns (bool); + function parseAddress(string calldata value) external pure returns (address); function parseUint(string calldata value) external pure returns (uint256); function parseBool(string calldata value) external pure returns (bool); } @@ -124,4 +128,47 @@ library Env { function bool_(string memory key) internal view returns (bool) { return vm.parseBool(str(key)); } + + /// A param that holds an address. + function addr_(string memory key) internal view returns (address) { + return vm.parseAddress(str(key)); + } + + /// The chain the book describes: `chainId`. + function chainId() internal view returns (uint256) { + return vm.parseJsonUint(vm.readFile(PATH), ".chainId"); + } + + /// The account that governs the environment: `authority.address` — the Protocol Safe, or the + /// EOA on a testnet. + function authority() internal view returns (address) { + return vm.parseJsonAddress(vm.readFile(PATH), ".authority.address"); + } + + /// The owner set the book expects of a Safe authority: `authority.owners`. Empty when the book + /// records none — an EOA, or a Safe nobody has recorded yet. + function authorityOwners() internal view returns (address[] memory) { + string memory book = vm.readFile(PATH); + if (!vm.keyExistsJson(book, ".authority.owners")) return new address[](0); + return vm.parseJsonAddressArray(book, ".authority.owners"); + } + + /// The threshold the book expects of a Safe authority: `authority.threshold`, zero when none + /// is recorded. + function authorityThreshold() internal view returns (uint256) { + string memory book = vm.readFile(PATH); + if (!vm.keyExistsJson(book, ".authority.threshold")) return 0; + return vm.parseJsonUint(book, ".authority.threshold"); + } + + /// Whether the book names a contract. + function has(string memory name) internal view returns (bool) { + return vm.keyExistsJson(vm.readFile(PATH), string.concat(".contracts.", name)); + } + + /// Whether an implementation is staged behind a proxy or beacon — deployed by a release and + /// not yet live. A test placed before the deploy step expects none. + function hasStaged(string memory name) internal view returns (bool) { + return vm.keyExistsJson(vm.readFile(PATH), string.concat(".contracts.", name, ".implementation.staged")); + } } diff --git a/test/Lattice.t.sol b/test/Lattice.t.sol index 929713b..44198bd 100644 --- a/test/Lattice.t.sol +++ b/test/Lattice.t.sol @@ -133,7 +133,7 @@ contract LatticeTest { vm.createDir(".lattice", true); vm.writeFile( ".lattice/env.json", - '{"id":"polygon-mainnet","chainId":137,"contracts":{"exchange":{"address":"0xe3333700cA9d93003F00f0F71f8515005F6c00Aa","kind":"proxy","route":"owner","implementation":{"address":"0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7","staged":{"address":"0x7345C6842b244926125ed4054905cAc49620B5dc"}}}},"params":{"feeRecipient":"0x115F48DC2A731aA16251c6d6e1BEfC42f92Accc9","minDelay":"43200","paused":"false"}}' + '{"id":"polygon-mainnet","chainId":137,"authority":{"kind":"safe","address":"0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952","threshold":3,"owners":["0x00447A08bf275b7FB3D5d832387a54Be1090d281","0x3B0dfCe5C3A1e2D5bB1c2D8E5f6A7B8C9d0E1f2A"]},"contracts":{"exchange":{"address":"0xe3333700cA9d93003F00f0F71f8515005F6c00Aa","kind":"proxy","route":"owner","implementation":{"address":"0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7","staged":{"address":"0x7345C6842b244926125ed4054905cAc49620B5dc"}}}},"params":{"feeRecipient":"0x115F48DC2A731aA16251c6d6e1BEfC42f92Accc9","minDelay":"43200","paused":"false"}}' ); require(Env.addr("exchange") == 0xe3333700cA9d93003F00f0F71f8515005F6c00Aa, "anchor"); require(Env.implementation("exchange") == 0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7, "implementation"); @@ -143,6 +143,28 @@ contract LatticeTest { ); require(Env.uint256_("minDelay") == 43200, "uint256"); require(!Env.bool_("paused"), "bool"); + require(Env.addr_("feeRecipient") == 0x115F48DC2A731aA16251c6d6e1BEfC42f92Accc9, "addr_"); + require(Env.chainId() == 137, "chainId"); + require(Env.authority() == 0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952, "authority"); + require(Env.authorityThreshold() == 3, "threshold"); + address[] memory owners = Env.authorityOwners(); + require(owners.length == 2 && owners[0] == 0x00447A08bf275b7FB3D5d832387a54Be1090d281, "owners"); + require(Env.has("exchange") && !Env.has("router"), "has"); + require(Env.hasStaged("exchange"), "hasStaged"); + + // A testnet's book, written over the first (forge runs test functions in parallel, and + // Env reads one fixed path, so the two books share one test): an EOA authority with no + // owner set recorded, and a proxy with nothing staged behind it — what a test placed + // before a deploy step sees. + vm.writeFile( + ".lattice/env.json", + '{"id":"polygon-amoy","chainId":80002,"authority":{"kind":"eoa","address":"0x00447A08bf275b7FB3D5d832387a54Be1090d281"},"contracts":{"exchange":{"address":"0xe3333700cA9d93003F00f0F71f8515005F6c00Aa","kind":"proxy","route":"owner","implementation":{"address":"0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7"}}},"params":{}}' + ); + require(Env.authority() == 0x00447A08bf275b7FB3D5d832387a54Be1090d281, "authority"); + require(Env.authorityOwners().length == 0, "owners"); + require(Env.authorityThreshold() == 0, "threshold"); + require(Env.has("exchange") && !Env.hasStaged("exchange"), "staged"); + require(Env.chainId() == 80002, "chainId"); } function test_structsHaveTheirShape() public pure { From 8c8a53850d0b985ef9d29df852e8b8e801c1e774 Mon Sep 17 00:00:00 2001 From: Rajath Alex Date: Sat, 3 Oct 2026 22:57:00 -0400 Subject: [PATCH 2/3] feat: a test base for release checks, and the remaining scalar param readers --- README.md | 3 +- src/Lattice.sol | 43 +++++++++ src/LatticeTest.sol | 145 ++++++++++++++++++++++++++++++ test/Lattice.t.sol | 86 +++++++++++++++++- test/LatticeTest.t.sol | 198 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 472 insertions(+), 3 deletions(-) create mode 100644 src/LatticeTest.sol create mode 100644 test/LatticeTest.t.sol diff --git a/README.md b/README.md index 7ae1130..56f11ce 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ fs_permissions = [{ access = "read", path = "./.lattice" }] | `Call` | `{to, value, data}`: the unit of a governance plan. A plan's `build()` returns `Call[]`. | | `Deployment` | `{name, addr}`: what a deploy script's entry point returns, naming each address for the address book. | | `EnvVar` | `{key, value}`: one scalar param, for a step that updates params. | -| `Env` | Reads `.lattice/env.json`: `addr(name)`, `implementation(name)`, `staged(name)` (an implementation deployed but not yet live, which is what an upgrade plan proposes), `has(name)` and `hasStaged(name)`; `str` / `uint256_` / `bool_` / `addr_` for params; `chainId()`; and the governing account, `authority()`, with the owner set and threshold the book expects of a Safe, `authorityOwners()` and `authorityThreshold()` (empty and zero when none is recorded). What a release's pre- and post-checks read. | +| `Env` | Reads `.lattice/env.json`: `addr(name)`, `implementation(name)`, `staged(name)` (an implementation deployed but not yet live, which is what an upgrade plan proposes), `has(name)` and `hasStaged(name)`; `str`, `addr_`, `bool_`, `uint256_`, `int256_`, `bytes32_`, `bytes_` and the range-checked `uint8_` … `uint128_` for params; `chainId()`; and the governing account, `authority()`, with the owner set and threshold the book expects of a Safe, `authorityOwners()` and `authorityThreshold()` (empty and zero when none is recorded). What a release's pre- and post-checks read. | | `Erc7821`, `Timelock`, `MultiSend` | The encoders for Solady Timelock batches and Safe `MultiSendCallOnly`. | +| `LatticeTest` (`src/LatticeTest.sol`) | A base for a release's pre- and post-checks and for safe-sim-style simulations: `materialized()` / `skipUnlessMaterialized()`, `liveImplementation(name)` and the ERC-1967 slots, `timelock()`, `governedAsDeclared(target)`, `operationId(calls, predecessor, salt)`, and `callAs` / `proposeAs` / `executeAs` / `applyAsAuthority` to apply a plan under `prank`. Readers and helpers only — assertions are the test's — so it still depends on nothing. | ```solidity import { Call, Env } from "lattice-std/Lattice.sol"; diff --git a/src/Lattice.sol b/src/Lattice.sol index bf657f7..588212f 100644 --- a/src/Lattice.sol +++ b/src/Lattice.sol @@ -36,6 +36,9 @@ interface LatticeVm { function keyExistsJson(string calldata json, string calldata key) external view returns (bool); function parseAddress(string calldata value) external pure returns (address); function parseUint(string calldata value) external pure returns (uint256); + function parseInt(string calldata value) external pure returns (int256); + function parseBytes32(string calldata value) external pure returns (bytes32); + function parseBytes(string calldata value) external pure returns (bytes memory); function parseBool(string calldata value) external pure returns (bool); } @@ -134,6 +137,46 @@ library Env { return vm.parseAddress(str(key)); } + function int256_(string memory key) internal view returns (int256) { + return vm.parseInt(str(key)); + } + + function bytes32_(string memory key) internal view returns (bytes32) { + return vm.parseBytes32(str(key)); + } + + function bytes_(string memory key) internal view returns (bytes memory) { + return vm.parseBytes(str(key)); + } + + /// Narrow unsigned params, range-checked: `uint32(Env.uint256_(key))` would truncate silently, + /// and a protocol struct's `uint16 resultLength` or `uint32 livenessWindow` deserves a revert. + function uint8_(string memory key) internal view returns (uint8) { + return uint8(narrow(key, type(uint8).max)); + } + + function uint16_(string memory key) internal view returns (uint16) { + return uint16(narrow(key, type(uint16).max)); + } + + function uint32_(string memory key) internal view returns (uint32) { + return uint32(narrow(key, type(uint32).max)); + } + + function uint64_(string memory key) internal view returns (uint64) { + return uint64(narrow(key, type(uint64).max)); + } + + function uint128_(string memory key) internal view returns (uint128) { + return uint128(narrow(key, type(uint128).max)); + } + + function narrow(string memory key, uint256 max) private view returns (uint256) { + uint256 v = uint256_(key); + require(v <= max, string.concat("lattice-std: param ", key, " does not fit its type")); + return v; + } + /// The chain the book describes: `chainId`. function chainId() internal view returns (uint256) { return vm.parseJsonUint(vm.readFile(PATH), ".chainId"); diff --git a/src/LatticeTest.sol b/src/LatticeTest.sol new file mode 100644 index 0000000..89ffb17 --- /dev/null +++ b/src/LatticeTest.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {Call, Env, Erc7821, Timelock} from "./Lattice.sol"; + +/// The forge cheatcodes the test base uses, declared here so nothing has to be installed beside +/// it. A test that also inherits forge-std's `Test` sees no clash: the handle is `cheats`, never +/// `vm`. +interface LatticeTestVm { + function load(address target, bytes32 slot) external view returns (bytes32); + function exists(string calldata path) external view returns (bool); + function skip(bool skipTest, string calldata reason) external; + function prank(address msgSender) external; + function warp(uint256 newTimestamp) external; +} + +/// A Solady Timelock, as far as a test reads it. +interface ITimelockView { + function minDelay() external view returns (uint256); + function operationState(bytes32 id) external view returns (uint8); +} + +/// A Solady OwnableRoles target, as far as its governance is checked. +interface IOwnableRolesView { + function owner() external view returns (address); + function hasAllRoles(address user, uint256 roles) external view returns (bool); +} + +/// Ground for a release's pre- and post-checks — the `verify` steps in `test` mode, which +/// `lattice test` runs on a fork and `lattice run` runs on the chain — and for a safe-sim-style +/// simulation that applies a plan itself. Readers and helpers only: the assertions are the test's, +/// from forge-std or anything else, so this file keeps lattice-std's rule of depending on nothing. +/// Every address comes from the book lattice materializes, never from a literal, so one test runs +/// on every environment. +abstract contract LatticeTest { + LatticeTestVm internal constant cheats = LatticeTestVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + /// ERC-1967's implementation and beacon slots. + bytes32 internal constant ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + bytes32 internal constant ERC1967_BEACON_SLOT = 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50; + /// Solady OwnableRoles' `_ROLE_0`: the admin role of Polymarket's modules. + uint256 internal constant SOLADY_ROLE_0 = 1; + /// Solady Timelock's operation states. + uint8 internal constant OPERATION_UNSET = 0; + uint8 internal constant OPERATION_WAITING = 1; + uint8 internal constant OPERATION_READY = 2; + uint8 internal constant OPERATION_DONE = 3; + + /// Where the book is. Overridable so a test of this contract can point at a missing one. + function bookPath() internal pure virtual returns (string memory) { + return Env.PATH; + } + + /// Whether lattice materialized a book for this run. A plain `forge test` over a repository + /// has none. + function materialized() internal view returns (bool) { + return cheats.exists(bookPath()); + } + + /// Skips the test unless a book is materialized: these are step hooks, not unit tests. + function skipUnlessMaterialized() internal { + cheats.skip(!materialized(), "no .lattice/env.json: run through lattice test or lattice run"); + } + + /// The timelock the book's governance goes through: its `timelock` entry. + function timelock() internal view returns (address) { + return Env.addr("timelock"); + } + + /// What a proxy runs right now, off its ERC-1967 implementation slot. + function liveImplementation(string memory name) internal view returns (address) { + return liveImplementationOf(Env.addr(name)); + } + + function liveImplementationOf(address proxy) internal view returns (address) { + return address(uint160(uint256(cheats.load(proxy, ERC1967_IMPLEMENTATION_SLOT)))); + } + + /// The beacon a beacon proxy points at, off its ERC-1967 beacon slot. + function liveBeaconOf(address proxy) internal view returns (address) { + return address(uint160(uint256(cheats.load(proxy, ERC1967_BEACON_SLOT)))); + } + + /// Whether a target is governed as the book declares: owned by the timelock, with the + /// authority holding the admin role. An upgrade touches neither, so a release checks it + /// before and after. + function governedAsDeclared(address target) internal view returns (bool) { + return IOwnableRolesView(target).owner() == timelock() + && IOwnableRolesView(target).hasAllRoles(Env.authority(), SOLADY_ROLE_0); + } + + /// The id the timelock gives an operation proposed with opData. + function operationId(Call[] memory calls, bytes32 predecessor, bytes32 salt) internal pure returns (bytes32) { + return Erc7821.opId(Erc7821.MODE_OPDATA, Erc7821.encode(calls, predecessor, salt)); + } + + // Applying a plan the way the chain will, without lattice: safe-sim's path. A prank, so the + // Safe's code does not run — these say what an operation does, not whether the Safe would + // send it; that half is `lattice test`'s. A revert is bubbled with its reason. + + function callAs(address sender, address to, bytes memory data) internal returns (bytes memory) { + cheats.prank(sender); + (bool ok, bytes memory ret) = to.call(data); + if (!ok) { + assembly { + revert(add(ret, 32), mload(ret)) + } + } + return ret; + } + + function callAsAuthority(address to, bytes memory data) internal returns (bytes memory) { + return callAs(Env.authority(), to, data); + } + + function proposeAs( + address sender, + address tl, + Call[] memory calls, + bytes32 predecessor, + bytes32 salt, + uint256 delay + ) internal returns (bytes32 id) { + callAs(sender, tl, Timelock.propose(Erc7821.MODE_OPDATA, Erc7821.encode(calls, predecessor, salt), delay)); + return operationId(calls, predecessor, salt); + } + + function executeAs(address sender, address tl, Call[] memory calls, bytes32 predecessor, bytes32 salt) + internal + returns (bytes32 id) + { + callAs(sender, tl, Timelock.execute(Erc7821.MODE_OPDATA, Erc7821.encode(calls, predecessor, salt))); + return operationId(calls, predecessor, salt); + } + + /// Propose, wait the timelock's own delay, execute — as the authority, on the book's timelock. + function applyAsAuthority(Call[] memory calls, bytes32 predecessor, bytes32 salt) internal returns (bytes32 id) { + address tl = timelock(); + uint256 delay = ITimelockView(tl).minDelay(); + id = proposeAs(Env.authority(), tl, calls, predecessor, salt, delay); + cheats.warp(block.timestamp + delay); + executeAs(Env.authority(), tl, calls, predecessor, salt); + } +} diff --git a/test/Lattice.t.sol b/test/Lattice.t.sol index 44198bd..e364946 100644 --- a/test/Lattice.t.sol +++ b/test/Lattice.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.0; import {Call, Deployment, EnvVar, Erc7821, Timelock, MultiSend, Env} from "../src/Lattice.sol"; +import {TestBaseHarness, FakeTimelock, FakeTarget, FakeGoverned} from "./LatticeTest.t.sol"; /// The two cheatcodes these tests need beyond the library's own, declared inline: lattice-std /// has no dependencies, and its tests keep it that way. @@ -9,6 +10,8 @@ interface Vm { function createDir(string calldata path, bool recursive) external; function writeFile(string calldata path, string calldata data) external; function expectRevert(bytes calldata revertData) external; + function store(address target, bytes32 slot, bytes32 value) external; + function etch(address target, bytes calldata code) external; } /// Exposes the libraries' internal functions on an external surface, so a revert can be @@ -25,10 +28,30 @@ contract Harness { function pack(Call[] memory calls) external pure returns (bytes memory) { return MultiSend.pack(calls); } + + function uint8_(string memory key) external view returns (uint8) { + return Env.uint8_(key); + } + + function uint16_(string memory key) external view returns (uint16) { + return Env.uint16_(key); + } + + function uint32_(string memory key) external view returns (uint32) { + return Env.uint32_(key); + } + + function uint64_(string memory key) external view returns (uint64) { + return Env.uint64_(key); + } + + function uint128_(string memory key) external view returns (uint128) { + return Env.uint128_(key); + } } /// Selectors measured with `cast sig`, so a typo in a signature string cannot agree with itself. -contract LatticeTest { +contract LatticeStdTest { Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); Harness harness = new Harness(); @@ -133,7 +156,7 @@ contract LatticeTest { vm.createDir(".lattice", true); vm.writeFile( ".lattice/env.json", - '{"id":"polygon-mainnet","chainId":137,"authority":{"kind":"safe","address":"0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952","threshold":3,"owners":["0x00447A08bf275b7FB3D5d832387a54Be1090d281","0x3B0dfCe5C3A1e2D5bB1c2D8E5f6A7B8C9d0E1f2A"]},"contracts":{"exchange":{"address":"0xe3333700cA9d93003F00f0F71f8515005F6c00Aa","kind":"proxy","route":"owner","implementation":{"address":"0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7","staged":{"address":"0x7345C6842b244926125ed4054905cAc49620B5dc"}}}},"params":{"feeRecipient":"0x115F48DC2A731aA16251c6d6e1BEfC42f92Accc9","minDelay":"43200","paused":"false"}}' + '{"id":"polygon-mainnet","chainId":137,"authority":{"kind":"safe","address":"0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952","threshold":3,"owners":["0x00447A08bf275b7FB3D5d832387a54Be1090d281","0x3B0dfCe5C3A1e2D5bB1c2D8E5f6A7B8C9d0E1f2A"]},"contracts":{"timelock":{"address":"0x47EbFAC3353314C788B96CDCbf41daadfE03629C","kind":"singleton","route":"none"},"exchange":{"address":"0xe3333700cA9d93003F00f0F71f8515005F6c00Aa","kind":"proxy","route":"owner","implementation":{"address":"0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7","staged":{"address":"0x7345C6842b244926125ed4054905cAc49620B5dc"}}}},"params":{"feeRecipient":"0x115F48DC2A731aA16251c6d6e1BEfC42f92Accc9","minDelay":"43200","paused":"false","skew":"-5","salt":"0x0000000000000000000000000000000000000000000000000000000000000007","blob":"0x1234","u8":"255","u8x":"256","u16":"65535","u16x":"65536","u32":"4294967295","u32x":"4294967296","u64":"18446744073709551615","u64x":"18446744073709551616","u128":"340282366920938463463374607431768211455","u128x":"340282366920938463463374607431768211456"}}' ); require(Env.addr("exchange") == 0xe3333700cA9d93003F00f0F71f8515005F6c00Aa, "anchor"); require(Env.implementation("exchange") == 0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7, "implementation"); @@ -144,6 +167,21 @@ contract LatticeTest { require(Env.uint256_("minDelay") == 43200, "uint256"); require(!Env.bool_("paused"), "bool"); require(Env.addr_("feeRecipient") == 0x115F48DC2A731aA16251c6d6e1BEfC42f92Accc9, "addr_"); + require(Env.int256_("skew") == -5, "int256_"); + require(Env.bytes32_("salt") == bytes32(uint256(7)), "bytes32_"); + require(keccak256(Env.bytes_("blob")) == keccak256(hex"1234"), "bytes_"); + require(Env.uint8_("u8") == 255 && Env.uint16_("u16") == 65535 && Env.uint32_("u32") == 4294967295, "narrow"); + require(Env.uint64_("u64") == type(uint64).max && Env.uint128_("u128") == type(uint128).max, "wide"); + vm.expectRevert(bytes("lattice-std: param u8x does not fit its type")); + harness.uint8_("u8x"); + vm.expectRevert(bytes("lattice-std: param u16x does not fit its type")); + harness.uint16_("u16x"); + vm.expectRevert(bytes("lattice-std: param u32x does not fit its type")); + harness.uint32_("u32x"); + vm.expectRevert(bytes("lattice-std: param u64x does not fit its type")); + harness.uint64_("u64x"); + vm.expectRevert(bytes("lattice-std: param u128x does not fit its type")); + harness.uint128_("u128x"); require(Env.chainId() == 137, "chainId"); require(Env.authority() == 0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952, "authority"); require(Env.authorityThreshold() == 3, "threshold"); @@ -152,6 +190,50 @@ contract LatticeTest { require(Env.has("exchange") && !Env.has("router"), "has"); require(Env.hasStaged("exchange"), "hasStaged"); + // The test base over the same book. + TestBaseHarness base = new TestBaseHarness(); + require(base.isMaterialized(), "materialized"); + base.skipUnless(); // a no-op with a book in place + require(base.theTimelock() == 0x47EbFAC3353314C788B96CDCbf41daadfE03629C, "timelock"); + vm.store( + 0xe3333700cA9d93003F00f0F71f8515005F6c00Aa, + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, + bytes32(uint256(uint160(0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7))) + ); + require(base.live("exchange") == 0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7, "liveImplementation"); + FakeGoverned asDeclared = + new FakeGoverned(0x47EbFAC3353314C788B96CDCbf41daadfE03629C, 0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952); + FakeGoverned otherOwner = + new FakeGoverned(0x00447A08bf275b7FB3D5d832387a54Be1090d281, 0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952); + FakeGoverned otherAdmin = + new FakeGoverned(0x47EbFAC3353314C788B96CDCbf41daadfE03629C, 0x00447A08bf275b7FB3D5d832387a54Be1090d281); + require(base.governed(address(asDeclared)), "governed as declared"); + require(!base.governed(address(otherOwner)) && !base.governed(address(otherAdmin)), "not governed as declared"); + FakeTarget target = new FakeTarget(); + base.callAuthority(address(target), abi.encodeWithSignature("poke()")); + require(target.lastSender() == 0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952, "callAsAuthority sender"); + // applyAsAuthority goes to the book's timelock: put a recording one there and watch the clock. + FakeTimelock tl = new FakeTimelock(); + vm.etch(0x47EbFAC3353314C788B96CDCbf41daadfE03629C, address(tl).code); + Call[] memory calls = new Call[](1); + calls[0] = Call(0xe3333700cA9d93003F00f0F71f8515005F6c00Aa, 0, hex"1234"); + uint256 before = block.timestamp; + bytes32 id = base.apply_(calls, bytes32(0), bytes32(uint256(7))); + require(id == base.id(calls, bytes32(0), bytes32(uint256(7))), "apply id"); + require(block.timestamp == before + 60, "the clock did not move past the delay"); + require( + keccak256(FakeTimelock(payable(0x47EbFAC3353314C788B96CDCbf41daadfE03629C)).lastData()) + == keccak256( + Timelock.execute(Erc7821.MODE_OPDATA, Erc7821.encode(calls, bytes32(0), bytes32(uint256(7)))) + ), + "apply executed last" + ); + require( + FakeTimelock(payable(0x47EbFAC3353314C788B96CDCbf41daadfE03629C)).lastSender() + == 0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952, + "apply sender" + ); + // A testnet's book, written over the first (forge runs test functions in parallel, and // Env reads one fixed path, so the two books share one test): an EOA authority with no // owner set recorded, and a proxy with nothing staged behind it — what a test placed diff --git a/test/LatticeTest.t.sol b/test/LatticeTest.t.sol new file mode 100644 index 0000000..6e557ae --- /dev/null +++ b/test/LatticeTest.t.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Call, Erc7821, Timelock} from "../src/Lattice.sol"; +import {LatticeTest} from "../src/LatticeTest.sol"; + +/// The cheatcodes these tests need beyond the base's own, declared inline: lattice-std has no +/// dependencies, and its tests keep it that way. +interface TestVm { + function store(address target, bytes32 slot, bytes32 value) external; + function expectRevert(bytes calldata revertData) external; +} + +/// Exposes the base's internal helpers over the default book path. +contract TestBaseHarness is LatticeTest { + function isMaterialized() external view returns (bool) { + return materialized(); + } + + function skipUnless() external { + skipUnlessMaterialized(); + } + + function live(string memory name) external view returns (address) { + return liveImplementation(name); + } + + function liveOf(address proxy) external view returns (address) { + return liveImplementationOf(proxy); + } + + function beaconOf(address proxy) external view returns (address) { + return liveBeaconOf(proxy); + } + + function theTimelock() external view returns (address) { + return timelock(); + } + + function governed(address target) external view returns (bool) { + return governedAsDeclared(target); + } + + function id(Call[] memory calls, bytes32 predecessor, bytes32 salt) external pure returns (bytes32) { + return operationId(calls, predecessor, salt); + } + + function call(address sender, address to, bytes memory data) external returns (bytes memory) { + return callAs(sender, to, data); + } + + function callAuthority(address to, bytes memory data) external returns (bytes memory) { + return callAsAuthority(to, data); + } + + function propose(address sender, address tl, Call[] memory calls, bytes32 predecessor, bytes32 salt, uint256 delay) + external + returns (bytes32) + { + return proposeAs(sender, tl, calls, predecessor, salt, delay); + } + + function execute(address sender, address tl, Call[] memory calls, bytes32 predecessor, bytes32 salt) + external + returns (bytes32) + { + return executeAs(sender, tl, calls, predecessor, salt); + } + + function apply_(Call[] memory calls, bytes32 predecessor, bytes32 salt) external returns (bytes32) { + return applyAsAuthority(calls, predecessor, salt); + } +} + +/// A harness whose book is somewhere nothing was written. +contract NoBookHarness is TestBaseHarness { + function bookPath() internal pure override returns (string memory) { + return ".lattice/nowhere.json"; + } +} + +/// A timelock that records what it was asked and answers a fixed delay. +contract FakeTimelock { + address public lastSender; + bytes public lastData; + + /// A constant, not storage: the book test etches this code at the book's timelock address, + /// and etch carries code, never storage. + function minDelay() external pure returns (uint256) { + return 60; + } + + function operationState(bytes32) external pure returns (uint8) { + return 1; + } + + fallback() external { + lastSender = msg.sender; + lastData = msg.data; + } +} + +/// A target that records its caller and can be made to revert. +contract FakeTarget { + address public lastSender; + + function poke() external returns (uint256) { + lastSender = msg.sender; + return 7; + } + + function boom() external pure { + revert("boom: no"); + } +} + +/// A Solady-shaped governed contract with a settable owner and role holder. +contract FakeGoverned { + 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; + } +} + +contract LatticeTestBaseTest { + TestVm constant vm = TestVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + TestBaseHarness harness = new TestBaseHarness(); + + address constant PROXY = 0xe3333700cA9d93003F00f0F71f8515005F6c00Aa; + address constant IMPL = 0x641b40ec414a076b9e79E703Fc7BF4EBEC248Bb7; + address constant BEACON = 0x7A18EDfe055488A3128f01F563e5B479D92ffc3a; + address constant SENDER = 0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952; + + function test_readsTheErc1967Slots() public { + vm.store( + PROXY, 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, bytes32(uint256(uint160(IMPL))) + ); + vm.store( + PROXY, 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50, bytes32(uint256(uint160(BEACON))) + ); + require(harness.liveOf(PROXY) == IMPL, "implementation slot"); + require(harness.beaconOf(PROXY) == BEACON, "beacon slot"); + require(harness.liveOf(address(0xdead)) == address(0), "an empty slot is the zero address"); + } + + function test_callAsPranksTheSenderAndBubblesAReason() public { + FakeTarget target = new FakeTarget(); + bytes memory ret = harness.call(SENDER, address(target), abi.encodeWithSignature("poke()")); + require(target.lastSender() == SENDER, "the sender was not pranked"); + require(abi.decode(ret, (uint256)) == 7, "the return data was lost"); + vm.expectRevert(bytes("boom: no")); + harness.call(SENDER, address(target), abi.encodeWithSignature("boom()")); + } + + function test_proposeAndExecuteEncodeTheOperationAndNameItsId() public { + FakeTimelock tl = new FakeTimelock(); + Call[] memory calls = new Call[](1); + calls[0] = Call(PROXY, 0, abi.encodeWithSignature("upgradeToAndCall(address,bytes)", IMPL, "")); + bytes32 salt = bytes32(uint256(42)); + bytes memory d = Erc7821.encode(calls, bytes32(0), salt); + bytes32 want = Erc7821.opId(Erc7821.MODE_OPDATA, d); + + bytes32 id = harness.propose(SENDER, address(tl), calls, bytes32(0), salt, 43200); + require(id == want && id == harness.id(calls, bytes32(0), salt), "propose id"); + require(tl.lastSender() == SENDER, "propose sender"); + require(keccak256(tl.lastData()) == keccak256(Timelock.propose(Erc7821.MODE_OPDATA, d, 43200)), "propose data"); + + id = harness.execute(SENDER, address(tl), calls, bytes32(0), salt); + require(id == want, "execute id"); + require(keccak256(tl.lastData()) == keccak256(Timelock.execute(Erc7821.MODE_OPDATA, d)), "execute data"); + } + + function test_aMissingBookIsNotMaterialized() public { + NoBookHarness none = new NoBookHarness(); + require(!none.isMaterialized(), "a missing book reads as materialized"); + } +} + +/// A test that is itself the base, over a missing book: forge allows `skip` only from the test's +/// own frame, which is how a release's checks call it. Skipping is the whole point — with no book +/// the hook does not run, and forge reports the test as skipped rather than failed. +contract LatticeTestSkipsWithoutABook is LatticeTest { + function bookPath() internal pure override returns (string memory) { + return ".lattice/nowhere.json"; + } + + function test_skipsWithoutABook() public { + skipUnlessMaterialized(); + revert("reached past the skip"); + } +} From 6ba1d2608e2af13b00a4db9e65a7a1814c11c30d Mon Sep 17 00:00:00 2001 From: Rajath Alex Date: Sat, 3 Oct 2026 23:08:19 -0400 Subject: [PATCH 3/3] feat: the governance checklist in LatticeTest, and docs for every piece --- README.md | 2 + docs/README.md | 17 ++++ docs/encoders.md | 32 +++++++ docs/env.md | 34 ++++++++ docs/scripts.md | 46 ++++++++++ docs/testing.md | 97 +++++++++++++++++++++ src/LatticeTest.sol | 149 ++++++++++++++++++++++++++++++- test/Lattice.t.sol | 1 + test/LatticeTest.t.sol | 194 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/encoders.md create mode 100644 docs/env.md create mode 100644 docs/scripts.md create mode 100644 docs/testing.md diff --git a/README.md b/README.md index 56f11ce..625c68a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Give forge read access to the environment written before every run: fs_permissions = [{ access = "read", path = "./.lattice" }] ``` +Documentation — how and when to use each piece — is under [`docs/`](./docs/README.md). + ## What it provides | | | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0bdb254 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# lattice-std documentation + +lattice-std is what a contract repository imports to work with [Lattice](https://github.com/Polymarket/lattice), +Polymarket's staged deployer and governance execution tool. Two Solidity files, no dependencies: + +| File | Holds | Read | +|---|---|---| +| `src/Lattice.sol` | The structs a release script returns (`Call`, `Deployment`, `EnvVar`), the encoders for Solady Timelock batches and Safe `MultiSendCallOnly` (`Erc7821`, `Timelock`, `MultiSend`), and `Env`, the reader of the address book Lattice materializes. | [scripts.md](./scripts.md), [encoders.md](./encoders.md), [env.md](./env.md) | +| `src/LatticeTest.sol` | `LatticeTest`, the base for a release's pre- and post-checks and for safe-sim-style simulations. | [testing.md](./testing.md) | + +The short version of when to reach for what: + +- **Writing a release** (a folder under `script/releases//` with a `release.json` and one script per step): the scripts return the structs in [scripts.md](./scripts.md) and read addresses through `Env` ([env.md](./env.md)). They do not encode Safe or timelock calldata — Lattice does that from the plan they return. +- **Writing the release's checks** (forge tests the `verify` steps run before and after each state change): inherit `LatticeTest` ([testing.md](./testing.md)) and forge-std's `Test`, read everything through `Env`, assert with forge-std. +- **Simulating a governance operation without Lattice** (what safe-sim does): `LatticeTest` again, with `applyAsAuthority` and the encoders ([encoders.md](./encoders.md)) to apply the plan yourself under `prank`. + +A consumer matches on ABI shape, never on a type's name or import path, so nothing here is required — a plan may declare the structs itself. The libraries are conveniences with fixed, tested encodings, and `test/differential` in the Lattice repository holds them byte for byte to Lattice's own Go encoders. diff --git a/docs/encoders.md b/docs/encoders.md new file mode 100644 index 0000000..32eef4d --- /dev/null +++ b/docs/encoders.md @@ -0,0 +1,32 @@ +# Encoders: `Erc7821`, `Timelock`, `MultiSend` + +Lattice encodes a plan's governance transactions itself, in Go, and these libraries produce the +same bytes in Solidity — proven byte for byte by the Lattice repository's `test/differential` +against the operation governance executed at Safe nonce 147. A release script does **not** need +them: `build()` returns inner calls and Lattice does the rest ([scripts.md](./scripts.md)). They +exist for the two cases where Solidity has to speak the chain's language itself: + +- a **simulation** that applies a plan under `prank` without Lattice (safe-sim's path; `LatticeTest`'s `proposeAs` / `executeAs` / `applyAsAuthority` use them, [testing.md](./testing.md)), +- a **check** that recomputes an operation id or a MultiSend payload to compare with what landed. + +| Library | Function | Produces | +|---|---|---| +| `Erc7821` | `MODE` | The ERC-7821 batch mode with no opData: `0x01000000…`. | +| | `MODE_OPDATA` | The batch mode with opData — `(predecessor, salt)` inside `executionData` — which is what production proposals use: `0x0100000000007821000100…`. | +| | `encode(calls)` | `executionData` without opData: `abi.encode(calls)`. Refuses an empty plan. | +| | `encode(calls, predecessor, salt)` | `executionData` with opData: `abi.encode(calls, abi.encode(predecessor, salt))`. Refuses an empty plan. | +| | `opId(mode, executionData)` | The Solady Timelock's operation id, `keccak256(abi.encode(mode, keccak256(executionData)))`. | +| `Timelock` | `propose(mode, executionData, delay)` | Calldata for `propose(bytes32,bytes,uint256)`: what the Safe sends the timelock. | +| | `execute(mode, executionData)` | Calldata for `execute(bytes32,bytes)`, after the delay. | +| | `cancel(id)` | Calldata for `cancel(bytes32)`. | +| `MultiSend` | `pack(calls)` | The argument of `MultiSendCallOnly.multiSend(bytes)`: per call `uint8 operation` (always 0), `address to`, `uint256 value`, `uint256 data.length`, `bytes data`, tightly packed. The Safe must send it with `operation = DelegateCall`, or every inner call runs with MultiSend's authority. Refuses an empty plan. | + +```solidity +bytes memory d = Erc7821.encode(calls, bytes32(0), salt); +bytes32 id = Erc7821.opId(Erc7821.MODE_OPDATA, d); +bytes memory proposeCalldata = Timelock.propose(Erc7821.MODE_OPDATA, d, 12 hours); +``` + +Both `encode` forms and `pack` refuse an empty plan: 32 zero bytes is valid ABI for an empty +`Call[]`, and proposing it would burn an authority nonce to do nothing. Lattice's Go side refuses +the same way; do not remove either `require`. diff --git a/docs/env.md b/docs/env.md new file mode 100644 index 0000000..cfbdf6a --- /dev/null +++ b/docs/env.md @@ -0,0 +1,34 @@ +# `Env`: reading the address book + +Before every forge run — a deploy script, a plan's `build()`, a test — Lattice materializes the +environment's book at `.lattice/env.json` in the checkout: the contracts by name, their +implementations (and any the current release has deployed but not yet made live), the scalar +params, the authority. `Env` reads it. Every function takes a name or key as written in the book. + +| Function | Reads | Use it when | +|---|---|---| +| `addr(name)` | `contracts..address` | You need the anchor — the proxy, beacon or singleton — a call targets. | +| `implementation(name)` | `contracts..implementation.address` | You need what the proxy runs as the book records it: a pre-upgrade check, or a plan that compares old and new. | +| `staged(name)` | `contracts..implementation.staged.address` | An upgrade plan: the implementation this release deployed and has not yet pointed the proxy at. Reverts when nothing is staged — see `hasStaged`. | +| `has(name)` | whether `contracts.` exists | A check that must not revert on a missing entry. | +| `hasStaged(name)` | whether `contracts..implementation.staged` exists | A pre-deploy check asserting nothing is staged yet; a plan deciding between a staged and a recorded implementation. | +| `authority()` | `authority.address` | The Protocol Safe (or the EOA on a testnet): the account that holds admin roles, the sender of direct calls. | +| `authorityOwners()`, `authorityThreshold()` | `authority.owners`, `authority.threshold` | Safe-administration releases and their checks. Empty and zero when the book records none. | +| `chainId()` | `chainId` | Guards in a script that must not run on the wrong chain. | +| `str(key)` | `params.` as recorded | Any param; everything else below parses this string. | +| `addr_(key)` | an address param | Most params are addresses: `Env.addr_("chainlinkDataStore")`. | +| `bool_(key)`, `uint256_(key)`, `int256_(key)`, `bytes32_(key)`, `bytes_(key)` | scalar params | Typed reads. | +| `uint8_` … `uint128_(key)` | narrow unsigned params | A struct field like `uint16 resultLength` or `uint32 livenessWindow`: range-checked, so a value that does not fit reverts with `lattice-std: param does not fit its type` instead of truncating as a cast would. | + +Params are always strings in the book; Lattice writes them that way and a release's `envUpdates()` +returns them that way ([scripts.md](./scripts.md)). + +## When the book is not there + +`Env` reverts when `.lattice/env.json` is missing — a script run outside Lattice, or a plain +`forge test` over the repository. Scripts should not run outside Lattice at all. Tests inherit +`LatticeTest` and call `skipUnlessMaterialized()` first ([testing.md](./testing.md)), so a +repository's own `forge test` skips them rather than failing. + +The repository's `foundry.toml` needs `fs_permissions = [{ access = "read", path = ".lattice/" }]` +for any of this to work. diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 0000000..206c911 --- /dev/null +++ b/docs/scripts.md @@ -0,0 +1,46 @@ +# Release scripts + +A release is a folder in the contract repository — `script/releases//` — holding a +`release.json` manifest and one forge script per step that touches the chain. Lattice runs the +scripts; this page is about what they return. + +## Entry points and their shapes + +| Step kind | Function Lattice calls | Returns | Use it for | +|---|---|---|---| +| `deploy` | `deploy()` | `Deployment[]` | Creating contracts. Each `Deployment{name, addr}` names an address for the book: `name` is the book's contract name (`"chainlinkReporterModuleImpl"` with a `bindings` entry in the manifest saying it is `chainlinkReporterModule`'s implementation) and `addr` what the broadcast created. Deploy inside `vm.startBroadcast()` / `vm.stopBroadcast()`; Lattice signs as `--deployer`. | +| `propose`, `execute` | `build()` | `Call[]` | A governance plan: the calls the authority's route will carry — through the timelock for `owner`-routed targets, directly for `admin` / `direct` ones. Return the inner calls only (`Call{to, value, data}` per target); Lattice routes and encodes them, computes the operation id and the Safe transaction, and preflights the result. An empty plan is refused. | +| `script` with `envUpdates` | `envUpdates()` | `EnvVar[]` | Scalar params the release changes in the book: `EnvVar{key, value}` with the value as a decimal, hex, bool or address string. | + +```solidity +import { Call, Deployment, Env } from "lattice-std/Lattice.sol"; + +contract DeployReporterImplementation { + function deploy() external returns (Deployment[] memory out) { + address dataStore = Env.addr_("chainlinkDataStore"); + vm.startBroadcast(); + ChainlinkReporterModule impl = new ChainlinkReporterModule{ salt: 0 }(dataStore); + vm.stopBroadcast(); + out = new Deployment[](1); + out[0] = Deployment({ name: "chainlinkReporterModuleImpl", addr: address(impl) }); + } +} + +contract UpgradeReporter { + function build() external view returns (Call[] memory calls) { + calls = new Call[](1); + calls[0] = Call({ + to: Env.addr("chainlinkReporterModule"), + value: 0, + data: abi.encodeCall(IUUPS.upgradeToAndCall, (Env.staged("chainlinkReporterModule"), "")) + }); + } +} +``` + +## Rules worth knowing + +- **Read the book, never a literal.** `Env.addr`, `Env.implementation`, `Env.staged` and the param readers ([env.md](./env.md)) make one script run on every environment, and let Lattice's materialized overlay show a later step what an earlier one produced: an upgrade's `build()` reads `Env.staged(name)`, the implementation the deploy step just landed. +- **A plan is the inner calls.** Do not wrap them in `Timelock.propose(...)` or `Safe.execTransaction(...)`: the route is the book's and the encoding is Lattice's, so the same `build()` serves `propose` and `execute`, and the second reviewer's `verify plan` can re-derive it. +- **Deterministic where it matters.** CREATE2 / CREATE3 deployments land at the same address on a fork and on the chain; a plain CREATE moves with the deployer's nonce, which Lattice records by position in the creation sequence. +- **No secrets, no RPC URLs.** Scripts read `.lattice/env.json` and the chain; signing is Lattice's (`--deployer`, `--sender`). diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..579ad85 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,97 @@ +# `LatticeTest`: a release's checks, before and after + +A release's tests are forge tests in its folder — `script/releases//test/*.t.sol` — run by +the manifest's `verify` steps in `test` mode. Lattice runs them when the machine reaches the step: +on a disposable fork under `lattice test`, on the chain itself under `lattice run`, each time with +the book materialized at `.lattice/env.json` so the test sees what the release has produced so far. +A suite placed before the deploy step checks the state the release starts from; one after the +deploy sees the staged implementation; one after the execute sees the proxy moved. The same files +run in both commands, unchanged. + +`LatticeTest` is the shared ground: readers and helpers, no assertions. Inherit it beside forge-std's +`Test` (or any assertion library — its cheatcodes are reached through `cheats`, never `vm`, so there +is no clash) and read every address through `Env`, never a literal. + +```solidity +import { Test } from "@forge-std/src/Test.sol"; +import { Env } from "lattice-std/Lattice.sol"; +import { LatticeTest } from "lattice-std/LatticeTest.sol"; + +abstract contract ReporterTestBase is Test, LatticeTest { + address internal proxy; + + function setUp() public virtual { + skipUnlessMaterialized(); // a plain `forge test` has no book: skip, do not fail + if (!materialized()) return; + proxy = Env.addr("chainlinkReporterModule"); + } +} +``` + +## The checklist, and the helper for each line + +What a governance simulation at Polymarket checks (safe-sim's convention), mapped to `LatticeTest`: + +| Check | Before | After | Helper | +|---|---|---|---| +| 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())` | +| 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 | +| The proxy's initialized version is unchanged | ✓ | ✓ | `soladyInitializedVersion(proxy)` / `ozInitializedVersion(proxy)` | +| The proxy moved | | ✓ | `liveImplementation(name) == Env.staged(name)` | +| The proxy cannot be re-initialized | | ✓ | `reverts(proxy, abi.encodeWithSignature("initialize(...)", ...))` | +| Selectors only the new build has resolve now, and did not before | ✓ | ✓ | `answer(proxy, data)` → `(ok, ret)` | +| A beacon or its proxies | | ✓ | `liveBeaconOf(proxy)` | + +Deploy checks use the same readers against the book's expectations: `Env.staged(name)` has code, +`implementationSane(Env.staged(name)) == ""`, each constructor argument read back equals the param +or address it was built from (`Env.addr_(...)`, `Env.addr(...)`). + +## Applying a plan yourself + +Under `lattice test` the machine applies the plan for real and the tests only assert. A test that +wants to apply a plan itself — a safe-sim-style simulation, or a pre-check asking "what will this +do" — has the helpers safe-sim's base had. They `prank` the authority, so the Safe's code does not +run: they say what the operation does, not whether the Safe would send it; that half is Lattice's. + +| Helper | Does | +|---|---| +| `callAs(sender, to, data)` / `callAsAuthority(to, data)` | One call under `prank`; a revert is bubbled with its reason. | +| `tryCallAs(sender, to, data)` | The same, answered as `(ok, returndata)` — for an execute before the delay, which must fail and leave the operation `OPERATION_WAITING`. | +| `proposeAs(sender, timelock, calls, predecessor, salt, delay)` | `Timelock.propose` with opData; returns the operation id. | +| `executeAs(sender, timelock, calls, predecessor, salt)` | `Timelock.execute`; returns the id. | +| `applyAsAuthority(calls, predecessor, salt)` | Propose on the book's timelock as the authority, warp past its live `minDelay()`, execute. | +| `operationId(calls, predecessor, salt)` | The id, for `operationState`. | + +```solidity +bytes32 id = operationId(calls, bytes32(0), salt); +assertEq(operationState(id), OPERATION_UNSET, "operation already exists"); +bytes32[] memory slots = firstSlots(8); +bytes32[] memory before = snapshot(proxy, slots); + +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(reverts(proxy, abi.encodeWithSignature("initialize(address,address)", address(1), address(2)))); +``` + +## Constants + +`ERC1967_IMPLEMENTATION_SLOT`, `ERC1967_BEACON_SLOT`; `SOLADY_INITIALIZABLE_SLOT` (bit 0 +initializing, bits 1..64 the version; disabled is `uint64.max << 1`) and `OZ_INITIALIZABLE_SLOT` +(`uint64 _initialized` low, `_initializing` at bit 64; disabled is `uint64.max`); `SOLADY_ROLE_0` +(the admin role of Polymarket's modules) with `soladyRole(n)` for the others; the timelock's +`OPERATION_UNSET` … `OPERATION_DONE`. + +## What stays with the test + +Which getters are immutables, which selectors are new, which storage slots carry what, which +`initialize` signature a contract has, and what a release is *for*: those are the release's to +state. `LatticeTest` reads and probes; the test asserts and explains. diff --git a/src/LatticeTest.sol b/src/LatticeTest.sol index 89ffb17..6446860 100644 --- a/src/LatticeTest.sol +++ b/src/LatticeTest.sol @@ -24,6 +24,12 @@ interface ITimelockView { interface IOwnableRolesView { function owner() external view returns (address); function hasAllRoles(address user, uint256 roles) external view returns (bool); + function rolesOf(address user) external view returns (uint256); +} + +/// A UUPS implementation, as far as its sanity is checked. +interface IUUPSView { + function proxiableUUID() external view returns (bytes32); } /// Ground for a release's pre- and post-checks — the `verify` steps in `test` mode, which @@ -46,6 +52,15 @@ abstract contract LatticeTest { uint8 internal constant OPERATION_WAITING = 1; uint8 internal constant OPERATION_READY = 2; uint8 internal constant OPERATION_DONE = 3; + /// Solady Initializable's slot, `~uint256(uint32(bytes4(keccak256("_INITIALIZABLE_SLOT"))))`: + /// bit 0 is `initializing`, bits 1..64 the initialized version. `_disableInitializers()` + /// stores `uint64.max << 1`. + bytes32 internal constant SOLADY_INITIALIZABLE_SLOT = + 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffbf601132; + /// OpenZeppelin v5 Initializable's namespaced slot: `uint64 _initialized` in the low 64 bits, + /// `bool _initializing` at bit 64. `_disableInitializers()` stores `uint64.max`. + bytes32 internal constant OZ_INITIALIZABLE_SLOT = + 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00; /// Where the book is. Overridable so a test of this contract can point at a missing one. function bookPath() internal pure virtual returns (string memory) { @@ -90,18 +105,148 @@ abstract contract LatticeTest { && IOwnableRolesView(target).hasAllRoles(Env.authority(), SOLADY_ROLE_0); } + /// Solady OwnableRoles' bit for `_ROLE_n`. + function soladyRole(uint8 n) internal pure returns (uint256) { + return 1 << n; + } + + /// Whether an account holds no role at all on a target — the timelock, for one, owns a + /// module without holding its roles, and an upgrade must not change that. + function holdsNoRoles(address target, address who) internal view returns (bool) { + return IOwnableRolesView(target).rolesOf(who) == 0; + } + /// The id the timelock gives an operation proposed with opData. function operationId(Call[] memory calls, bytes32 predecessor, bytes32 salt) internal pure returns (bytes32) { return Erc7821.opId(Erc7821.MODE_OPDATA, Erc7821.encode(calls, predecessor, salt)); } + /// What the book's timelock says about an operation: OPERATION_UNSET before it is proposed, + /// which a pre-check asserts, through OPERATION_DONE. + function operationState(bytes32 id) internal view returns (uint8) { + return ITimelockView(timelock()).operationState(id); + } + + // Initializers. A bare implementation must be inert — `_disableInitializers()` in its + // constructor — and a proxy keeps its initialized version across an upgrade. + + function soladyInitializedVersion(address target) internal view returns (uint64) { + return uint64((uint256(cheats.load(target, SOLADY_INITIALIZABLE_SLOT)) >> 1) & type(uint64).max); + } + + function soladyInitializing(address target) internal view returns (bool) { + return uint256(cheats.load(target, SOLADY_INITIALIZABLE_SLOT)) & 1 == 1; + } + + function soladyInitializersDisabled(address target) internal view returns (bool) { + return soladyInitializedVersion(target) == type(uint64).max; + } + + function ozInitializedVersion(address target) internal view returns (uint64) { + return uint64(uint256(cheats.load(target, OZ_INITIALIZABLE_SLOT))); + } + + function ozInitializing(address target) internal view returns (bool) { + return (uint256(cheats.load(target, OZ_INITIALIZABLE_SLOT)) >> 64) & 1 == 1; + } + + function ozInitializersDisabled(address target) internal view returns (bool) { + return ozInitializedVersion(target) == type(uint64).max; + } + + /// Whether the initializers are disabled under either framework. Both slots are namespaced, + /// so a contract using neither reads zero in both and is not disabled. + function initializersDisabled(address target) internal view returns (bool) { + return soladyInitializersDisabled(target) || ozInitializersDisabled(target); + } + + /// Whether a contract answers `proxiableUUID()` with the ERC-1967 implementation slot: what + /// `upgradeToAndCall` checks before it points a proxy at it. + function isUUPS(address implementation) internal view returns (bool) { + (bool ok, bytes memory ret) = + implementation.staticcall(abi.encodeWithSelector(IUUPSView.proxiableUUID.selector)); + return ok && ret.length == 32 && abi.decode(ret, (bytes32)) == ERC1967_IMPLEMENTATION_SLOT; + } + + /// The sanity of an implementation about to be put behind a proxy, as one answer: empty when + /// it has code, is UUPS and cannot be initialized by anyone; otherwise the first failing + /// reason. A pre-check asserts it is empty. + function implementationSane(address implementation) internal view returns (string memory) { + if (implementation.code.length == 0) return "no code"; + if (!isUUPS(implementation)) return "not UUPS: proxiableUUID is not the ERC-1967 implementation slot"; + if (!initializersDisabled(implementation)) return "initializers enabled"; + return ""; + } + + // Reading and probing targets. + + /// A static call's answer, without reverting on failure. + function answer(address to, bytes memory data) internal view returns (bool ok, bytes memory ret) { + return to.staticcall(data); + } + + /// Whether a call reverts. For a mutator — `initialize(...)` through an upgraded proxy, which + /// must. A call that does not revert has run, so a test asserting the revert fails anyway. + function reverts(address to, bytes memory data) internal returns (bool) { + (bool ok,) = to.call(data); + return !ok; + } + + /// Whether two contracts answer the same calls identically: an implementation's constructor + /// immutables and constants against the proxy's (or the live implementation's), one getter + /// per entry. Returns the index of the first call that differs or fails on either side, or + /// `calls.length` when all agree. + function sameAnswers(address a, address b, bytes[] memory calls) internal view returns (uint256) { + for (uint256 i = 0; i < calls.length; i++) { + (bool okA, bytes memory retA) = a.staticcall(calls[i]); + (bool okB, bytes memory retB) = b.staticcall(calls[i]); + if (!okA || !okB || keccak256(retA) != keccak256(retB)) return i; + } + return calls.length; + } + + /// The first n storage slots, for a raw snapshot. + function firstSlots(uint256 n) internal pure returns (bytes32[] memory slots) { + slots = new bytes32[](n); + for (uint256 i = 0; i < n; i++) { + slots[i] = bytes32(i); + } + } + + /// Raw storage, slot by slot: what an upgrade must leave byte-equal, proving the layout + /// is compatible. + function snapshot(address target, bytes32[] memory slots) internal view returns (bytes32[] memory words) { + words = new bytes32[](slots.length); + for (uint256 i = 0; i < slots.length; i++) { + words[i] = cheats.load(target, slots[i]); + } + } + + /// The index of the first slot whose word differs from the snapshot, or `slots.length`. + function firstChangedSlot(address target, bytes32[] memory slots, bytes32[] memory before) + internal + view + returns (uint256) + { + for (uint256 i = 0; i < slots.length; i++) { + if (cheats.load(target, slots[i]) != before[i]) return i; + } + return slots.length; + } + // Applying a plan the way the chain will, without lattice: safe-sim's path. A prank, so the // Safe's code does not run — these say what an operation does, not whether the Safe would // send it; that half is `lattice test`'s. A revert is bubbled with its reason. - function callAs(address sender, address to, bytes memory data) internal returns (bytes memory) { + /// A call as sender, answered rather than bubbled: for an execute before the delay, which + /// must fail and leave the operation Waiting. + function tryCallAs(address sender, address to, bytes memory data) internal returns (bool ok, bytes memory ret) { cheats.prank(sender); - (bool ok, bytes memory ret) = to.call(data); + return to.call(data); + } + + function callAs(address sender, address to, bytes memory data) internal returns (bytes memory) { + (bool ok, bytes memory ret) = tryCallAs(sender, to, data); if (!ok) { assembly { revert(add(ret, 32), mload(ret)) diff --git a/test/Lattice.t.sol b/test/Lattice.t.sol index e364946..6085c05 100644 --- a/test/Lattice.t.sol +++ b/test/Lattice.t.sol @@ -233,6 +233,7 @@ contract LatticeStdTest { == 0x3dcE0a29139A851Da1dFCa56Af8e8a6440b4D952, "apply sender" ); + require(base.state(bytes32(0)) == 1, "operationState reads the book's timelock"); // A testnet's book, written over the first (forge runs test functions in parallel, and // Env reads one fixed path, so the two books share one test): an EOA authority with no diff --git a/test/LatticeTest.t.sol b/test/LatticeTest.t.sol index 6e557ae..c207c63 100644 --- a/test/LatticeTest.t.sol +++ b/test/LatticeTest.t.sol @@ -70,6 +70,106 @@ contract TestBaseHarness is LatticeTest { function apply_(Call[] memory calls, bytes32 predecessor, bytes32 salt) external returns (bytes32) { return applyAsAuthority(calls, predecessor, salt); } + + function tryCall(address sender, address to, bytes memory data) external returns (bool, bytes memory) { + return tryCallAs(sender, to, data); + } + + function state(bytes32 opId) external view returns (uint8) { + return operationState(opId); + } + + function role(uint8 n) external pure returns (uint256) { + return soladyRole(n); + } + + function noRoles(address target, address who) external view returns (bool) { + return holdsNoRoles(target, who); + } + + function soladyVersion(address t) external view returns (uint64) { + return soladyInitializedVersion(t); + } + + function soladyInit(address t) external view returns (bool) { + return soladyInitializing(t); + } + + function soladyDisabled(address t) external view returns (bool) { + return soladyInitializersDisabled(t); + } + + function ozVersion(address t) external view returns (uint64) { + return ozInitializedVersion(t); + } + + function ozInit(address t) external view returns (bool) { + return ozInitializing(t); + } + + function ozDisabled(address t) external view returns (bool) { + return ozInitializersDisabled(t); + } + + function disabled(address t) external view returns (bool) { + return initializersDisabled(t); + } + + function uups(address t) external view returns (bool) { + return isUUPS(t); + } + + function sane(address t) external view returns (string memory) { + return implementationSane(t); + } + + function answerOf(address to, bytes memory data) external view returns (bool, bytes memory) { + return answer(to, data); + } + + function revertsOn(address to, bytes memory data) external returns (bool) { + return reverts(to, data); + } + + function same(address a, address b, bytes[] memory calls) external view returns (uint256) { + return sameAnswers(a, b, calls); + } + + function slots(uint256 n) external pure returns (bytes32[] memory) { + return firstSlots(n); + } + + function snap(address t, bytes32[] memory which) external view returns (bytes32[] memory) { + return snapshot(t, which); + } + + function changed(address t, bytes32[] memory which, bytes32[] memory before) external view returns (uint256) { + return firstChangedSlot(t, which, before); + } +} + +/// An implementation that answers proxiableUUID with the right slot — and one with the wrong one. +contract FakeUUPS { + function proxiableUUID() external pure returns (bytes32) { + return 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + } +} + +contract FakeNotUUPS { + function proxiableUUID() external pure returns (bytes32) { + return bytes32(uint256(1)); + } +} + +/// Two constructor-set values with getters, for parity checks. +contract FakeConstants { + uint256 public immutable A; + bytes32 public immutable B; + + constructor(uint256 a, bytes32 b) { + A = a; + B = b; + } } /// A harness whose book is somewhere nothing was written. @@ -127,6 +227,10 @@ contract FakeGoverned { 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; + } } contract LatticeTestBaseTest { @@ -177,6 +281,96 @@ contract LatticeTestBaseTest { require(keccak256(tl.lastData()) == keccak256(Timelock.execute(Erc7821.MODE_OPDATA, d)), "execute data"); } + function test_readsInitializerStateUnderBothFrameworks() public { + address t = address(0xbeef); + bytes32 solady = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffbf601132; + bytes32 oz = 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00; + require( + !harness.disabled(t) && harness.soladyVersion(t) == 0 && harness.ozVersion(t) == 0, "a blank is nothing" + ); + + vm.store(t, solady, bytes32(uint256(1) << 1)); + require(harness.soladyVersion(t) == 1 && !harness.soladyInit(t) && !harness.soladyDisabled(t), "solady v1"); + vm.store(t, solady, bytes32((uint256(1) << 1) | 1)); + require(harness.soladyInit(t), "solady initializing"); + vm.store(t, solady, bytes32(uint256(type(uint64).max) << 1)); + require(harness.soladyDisabled(t) && harness.disabled(t), "solady disabled"); + + vm.store(t, solady, bytes32(0)); + vm.store(t, oz, bytes32(uint256(1))); + require( + harness.ozVersion(t) == 1 && !harness.ozInit(t) && !harness.ozDisabled(t) && !harness.disabled(t), "oz v1" + ); + vm.store(t, oz, bytes32(uint256(1) | (uint256(1) << 64))); + require(harness.ozInit(t), "oz initializing"); + vm.store(t, oz, bytes32(uint256(type(uint64).max))); + require(harness.ozDisabled(t) && harness.disabled(t), "oz disabled"); + } + + function test_judgesAnImplementationsSanity() public { + require(harness.uups(address(new FakeUUPS())), "uups"); + require(!harness.uups(address(new FakeNotUUPS())), "wrong uuid"); + require(!harness.uups(address(new FakeTarget())), "no proxiableUUID"); + + require(keccak256(bytes(harness.sane(address(0xdead)))) == keccak256("no code"), "no code"); + require( + keccak256(bytes(harness.sane(address(new FakeTarget())))) + == keccak256("not UUPS: proxiableUUID is not the ERC-1967 implementation slot"), + "not uups" + ); + FakeUUPS live = new FakeUUPS(); + require(keccak256(bytes(harness.sane(address(live)))) == keccak256("initializers enabled"), "enabled"); + vm.store( + address(live), + 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffbf601132, + bytes32(uint256(type(uint64).max) << 1) + ); + require(bytes(harness.sane(address(live))).length == 0, "sane"); + } + + function test_probesAndComparesAnswers() public { + FakeTarget target = new FakeTarget(); + (bool ok, bytes memory ret) = harness.answerOf(address(target), abi.encodeWithSignature("lastSender()")); + require(ok && abi.decode(ret, (address)) == address(0), "answer"); + (ok,) = harness.answerOf(address(target), abi.encodeWithSignature("nothing()")); + require(!ok, "an unknown selector does not answer"); + require(harness.revertsOn(address(target), abi.encodeWithSignature("boom()")), "boom reverts"); + require(!harness.revertsOn(address(target), abi.encodeWithSignature("poke()")), "poke does not"); + + FakeConstants x = new FakeConstants(7, bytes32(uint256(9))); + FakeConstants y = new FakeConstants(7, bytes32(uint256(9))); + FakeConstants z = new FakeConstants(7, bytes32(uint256(8))); + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSignature("A()"); + calls[1] = abi.encodeWithSignature("B()"); + require(harness.same(address(x), address(y), calls) == 2, "all agree"); + require(harness.same(address(x), address(z), calls) == 1, "B differs"); + require(harness.same(address(x), address(target), calls) == 0, "a getter one side lacks differs"); + + (ok, ret) = harness.tryCall(SENDER, address(target), abi.encodeWithSignature("boom()")); + require(!ok && ret.length > 0, "tryCall answers the revert"); + (ok,) = harness.tryCall(SENDER, address(target), abi.encodeWithSignature("poke()")); + require(ok && target.lastSender() == SENDER, "tryCall pranks"); + } + + function test_snapshotsRawStorage() public { + address t = address(0xcafe); + bytes32[] memory which = harness.slots(3); + require(which.length == 3 && which[0] == bytes32(0) && which[2] == bytes32(uint256(2)), "first slots"); + vm.store(t, bytes32(uint256(1)), bytes32(uint256(11))); + bytes32[] memory before = harness.snap(t, which); + require(before[1] == bytes32(uint256(11)) && before[0] == bytes32(0), "snapshot"); + require(harness.changed(t, which, before) == 3, "unchanged"); + vm.store(t, bytes32(uint256(2)), bytes32(uint256(5))); + require(harness.changed(t, which, before) == 2, "slot 2 changed"); + } + + function test_rolesHelpers() public { + require(harness.role(0) == 1 && harness.role(4) == 16, "role bits"); + FakeGoverned g = new FakeGoverned(address(1), SENDER); + require(!harness.noRoles(address(g), SENDER) && harness.noRoles(address(g), address(1)), "holdsNoRoles"); + } + function test_aMissingBookIsNotMaterialized() public { NoBookHarness none = new NoBookHarness(); require(!none.isMaterialized(), "a missing book reads as materialized");