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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ 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

| | |
|---|---|
| `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`, `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";
Expand Down
17 changes: 17 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -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/<name>/` 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.
32 changes: 32 additions & 0 deletions docs/encoders.md
Original file line number Diff line number Diff line change
@@ -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`.
34 changes: 34 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
@@ -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.<name>.address` | You need the anchor — the proxy, beacon or singleton — a call targets. |
| `implementation(name)` | `contracts.<name>.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.<name>.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.<name>` exists | A check that must not revert on a missing entry. |
| `hasStaged(name)` | whether `contracts.<name>.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.<key>` 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 <key> 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.
46 changes: 46 additions & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Release scripts

A release is a folder in the contract repository — `script/releases/<name>/` — 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`).
97 changes: 97 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# `LatticeTest`: a release's checks, before and after

A release's tests are forge tests in its folder — `script/releases/<name>/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.
Loading
Loading