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
34 changes: 34 additions & 0 deletions .github/workflows/oz-latest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: OpenZeppelin latest

# slotdiff imports a few OpenZeppelin upgrades-core files that are not its
# public API, and accepts any 1.x. A 1.x release that moves one would break
# every install; this finds out before users do.
on:
schedule:
# Mondays 16:30 UTC, after Scorecard.
- cron: "30 16 * * 1"
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
cache: pnpm
- run: pnpm install --frozen-lockfile
# The newest version package.json allows, as a fresh install gets it.
- run: pnpm update @openzeppelin/upgrades-core
- run: node -p "'upgrades-core ' + require('@openzeppelin/upgrades-core/package.json').version"
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
- run: node -e "require('./dist/index.js')"
6 changes: 4 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Publish

# Run by hand: `gh workflow run publish.yml -f dist-tag=alpha`. npm trusts
# Run by hand: `gh workflow run publish.yml -f dist-tag=latest`. npm trusts
# this workflow through trusted publishing (OIDC), so no npm token is stored,
# and the package gets a provenance attestation linking it to this repository
# and commit. It only stages the release: nothing is public until a maintainer
Expand Down Expand Up @@ -36,7 +36,9 @@ jobs:
# publishing alone needs 11.5.1+). No registry-url: trusted publishing needs no .npmrc.
node-version: "24"
check-latest: true
- run: pnpm install --frozen-lockfile
# No dependency install scripts in the job that can mint the publish token
# (keccak and secp256k1 only build optional native addons).
- run: pnpm install --frozen-lockfile --ignore-scripts
- name: Check npm supports staged publishing
run: |
npm --version
Expand Down
41 changes: 31 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
# Changelog

## Unreleased
## 0.3.0

### Fixed

These were in 0.2.0 (the solc one since 0.1.0); upgrade if you rely on the CLI or check verified source.

- CLI: an `<old>` address that is not an ERC-1967 or beacon proxy was taken as an implementation, so a proxy of another kind (a custom slot, a diamond) was compared as its own, nearly empty, code and could pass. It is now refused (exit 2) unless `--old-implementation` says it is an implementation.
- Storage placed at a fixed slot through a struct that is not an ERC-7201 namespace of the contract or its bases (a namespace declared in a library, as in the diamond and Solidstate pattern, or a struct with no annotation) is left out of OpenZeppelin's layout with only a warning, so a change to it passed. It now stops the analysis with `LayoutUnavailableError` (exit 2).
- solc ran in the caller's directory, so verified source could `import` a file under it (an `.env`, say) and solc would quote its first line in the compile error. It now runs in a new empty directory, and verified source given by `urls` instead of inline `content` is refused.
- Text from an explorer or from verified source could move the cursor, recolor the terminal or draw lines of its own (a fake "Safe to upgrade." under the error). Control characters are stripped from solc's errors, explorer messages are kept on one line, and verified source with control characters in its name, paths or text (tabs and line breaks in text aside), or any whitespace but a space in its name and paths, is refused (`LayoutUnavailableError`).

### Breaking

- CLI: an `<old>` implementation address needs `--old-implementation` (above).
- `layoutFromSource` is removed: use `(await analyzeSource(input, solc, contract)).layout`, at the same cost.
- `NamespaceCheck.result` says `"unchecked"` (was `"unread"`), as `UpgradeCheck.result` does. New union members: `NamespaceSlot.status` `"unused"` (such namespaces were `unread`), `NamespacePair.problem` `"dropped"`.
- `checkNamespaces`: `next` needs its `layout`.
- Plain `Error` (was `LayoutIntegrityError`), as wrong arguments: a contract missing from the compiler input, an input source without `content`. `safety()` throws on an unknown `unsafeAllow` kind (OpenZeppelin ignored it).

### Added

- `analyzeBuild(input, output, compiler)`: `analyzeSource` for a build already compiled (a build tool's), with no second full compile; one namespaced pass serves every contract. Its `metadata` must prove the output was compiled from those sources by that compiler, so output from another build is refused (`LayoutIntegrityError`); incomplete output throws the new `IncompleteBuildError`.
- `checkUpgrade(old, next, options?)`: layout, ERC-7201 slots and safety rules in one verdict (`pass`, `fail` or `unchecked`), as the CLI runs them. The proxy kind comes from `old`.
- Layout records keep the proxy `kind` and each namespace's slot (`namespaces`), so a saved record can stand in for `old`. Optional: older records still load.
- `checkNamespaces`: a namespace missing from `namespaces` (an older record) is unread, never matched by annotation alone; slots that do not fit the layout are refused. **Breaking:** `next` needs its `layout`.
- ERC-7201: a namespace with no `$.slot :=` assignment (kept reserved) is `unused` and compared by its annotation (unless the code places another struct at a known slot, or a mapping or an array pointer), instead of making every upgrade of such a contract exit 2. Dropping the last assignment of a namespace whose deployed annotation is wrong fails (`dropped`), while the real slot is still known. **Breaking:** `NamespaceSlot.status` adds `"unused"` (such namespaces were `unread`).
- `SafetyReport.explain(color?)` and `NamespaceCheck.explain(color?)` (the CLI's namespace grid); `safety()` refuses an unknown `unsafeAllow` kind; `resolveProxyLayout` returns the proxy's `kind`; `proveLocalBuild` takes `kind` and `namespaces` for the record. New types: `SolcOutput`, `UpgradeSide`, `UpgradeOptions`, `UpgradeCheck`, `BuildAnalysis`, `BuildCompiler`.
- `analyzeSource` checks its own compile as `analyzeBuild` does: it refuses an input source without `content` (such as `urls`), and a compiler whose version differs from the one the metadata names (`LayoutIntegrityError`).
- `parseRecord` refuses a namespace key not of the form `<formula>:<id>`, and a malformed `kind` or `namespaces`.
- CLI: namespaces with no problem that the code does not assign are left out of the grid, and the summary counts them ("N not assigned in code, compared by annotation"); an unread slot says which side could not be read.
- **Breaking:** a contract missing from the compiler input throws a plain `Error` (was `LayoutIntegrityError`), as a wrong argument; `NamespacePair.problem` adds `"dropped"`.
- `analyzeBuild(input, output, compiler)`: `analyzeSource` for a build already compiled (a build tool's), with no second full compile; one namespaced pass serves every contract. Its `metadata` must prove the output was compiled from those sources by that compiler (`LayoutIntegrityError` otherwise); incomplete output throws the new `IncompleteBuildError`.
- Layout records keep the proxy `kind` and each namespace's slot (`namespaces`), so a saved record can stand in for `old`; `proveLocalBuild` takes both. Optional: older records still load, their namespaces unchecked.
- `resolveProxyLayout` returns the proxy's `kind`; `SafetyReport.explain(color?)` and `NamespaceCheck.explain(color?)` (the CLI's namespace grid). New types: `SolcOutput`, `UpgradeSide`, `UpgradeOptions`, `UpgradeCheck`, `BuildAnalysis`, `BuildCompiler`.

### Changed

- ERC-7201: a namespace with no `$.slot :=` assignment (kept reserved) is `unused` and compared by its annotation (unless the code places a mapping or an array pointer), so such upgrades exit 0 where 0.2.0 exited 2. Dropping the last assignment of a namespace whose deployed annotation is wrong fails (`dropped`), while the real slot is still known.
- CLI: unassigned namespaces with no problem are left out of the grid and counted in the summary ("N not assigned in code, compared by annotation"); an unread slot says which side could not be read.
- Stricter input: `analyzeSource` checks its own compile as `analyzeBuild` does (a compiler whose version differs from the one the metadata names is a `LayoutIntegrityError`); `parseRecord` refuses a namespace key not of the form `<formula>:<id>` and a malformed `kind` or `namespaces`; `checkNamespaces` refuses slots that do not fit the layout (`LayoutIntegrityError`); `getSolc` refuses a malformed entry in solc's build list.
- `SAFETY_ERROR_KINDS` and `SafetyErrorKind` are slotdiff's own list (a test compares it with OpenZeppelin's), so the published types no longer import an OpenZeppelin internal file.
- Releases: the publish workflow installs dependencies without their install scripts; a weekly workflow tests against the newest OpenZeppelin upgrades-core 1.x.

## 0.2.0-alpha.1

Expand Down
42 changes: 18 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ Node 22.13+. Works from ESM and CommonJS.
slotdiff <old> <new> [--rpc <url>]
```

Each side is a contract in your build info (`src/Vault.sol:Vault`, or just `Vault` when only one contract has that name) or an address: a proxy, read as the implementation it runs now, or an implementation, such as one waiting in a multisig queue. Any mix works. An address needs `--rpc` and `ETHERSCAN_API_KEY` (or `--explorer-url` for another explorer), since its source comes from the explorer. The RPC is a flag because it picks the chain for each run; the key lives in the environment because one Etherscan key covers every chain. It is sent only to Etherscan: with `--explorer-url`, put that explorer's own key in the URL's query if it needs one.
Each side is a contract in your build info (`src/Vault.sol:Vault`, or just `Vault` when only one contract has that name) or an address: a proxy (ERC-1967 or beacon), read as the implementation it runs now, or for `<new>` an implementation, such as one waiting in a multisig queue. Any mix works. An `<old>` address that is not such a proxy is refused, since a proxy of another kind (a diamond, a custom slot) would be compared as its own code: pass `--old-implementation` when `<old>` is an implementation, or give such a proxy by its implementation's address. An address needs `--rpc` and `ETHERSCAN_API_KEY` (or `--explorer-url` for another explorer), since its source comes from the explorer. The RPC is a flag because it picks the chain for each run; the key lives in the environment because one Etherscan key covers every chain. It is sent only to Etherscan: with `--explorer-url`, put that explorer's own key in the URL's query if it needs one.

A local side is read from the build info your tool already wrote; slotdiff does not compile your project. Build first, from the project root or with `--root`. Foundry writes build info only with `build_info = true` in `foundry.toml` or `forge build --build-info`. A build older than the sources on disk is refused, never used. `--build-info <file>` names one build-info file to use as is, such as a CI artifact from another checkout; the output then says it was not compared with the sources on disk.
A local side is read from the build info your tool already wrote; slotdiff does not compile your project. Build first, from the project root or with `--root`. Foundry writes build info only with `build_info = true` in `foundry.toml` or `forge build --build-info`. A build older than the sources on disk is refused, never used (each file the contract imports is compared, except a dependency slotdiff cannot find from the root, such as one hoisted to a monorepo's top `node_modules`). `--build-info <file>` names one build-info file to use as is, such as a CI artifact from another checkout; the output then says it was not compared with the sources on disk.

Three checks run:

Expand Down Expand Up @@ -122,30 +122,24 @@ Colors and the banner appear only on an interactive terminal, never in a pipe, a

```ts
import { JsonRpcProvider } from "ethers";
import {
diffLayouts,
findBuildInfo,
getSolc,
layoutFromSource,
resolveProxyLayout,
} from "slotdiff";
import { analyzeSource, checkUpgrade, findBuildInfo, getSolc, resolveProxyLayout } from "slotdiff";

// Any object with send(method, params): an ethers JsonRpcProvider or Hardhat's network provider.
const provider = new JsonRpcProvider(process.env.RPC_URL);
const apiKey = process.env.ETHERSCAN_API_KEY;

// 1. The layout of whatever the proxy runs now, proven against its deployed code.
// 1. Whatever the proxy runs now: its layout and ERC-7201 slots, proven against its deployed code.
const live = await resolveProxyLayout(provider, "0xProxy", { explorer: { apiKey } });

// 2. The layout of the new version, from your build info (Foundry, Hardhat 2 or 3).
const build = await findBuildInfo(process.cwd(), "src/Vault.sol:Vault");
const solc = await getSolc(build.solcVersion);
const next = await layoutFromSource(build.input, solc, build.contract);
// 2. The new version, from your build info (Foundry, Hardhat 2 or 3).
const build = await findBuildInfo(process.cwd(), "src/VaultV2.sol:VaultV2");
const next = await analyzeSource(build.input, await getSolc(build.solcVersion), build.contract);

// 3. Compare.
const report = diffLayouts(live.record.layout, next);
if (!report.ok) {
console.error(report.explain());
// 3. The layout, the ERC-7201 slots and the safety rules, with the proxy's own kind.
const check = checkUpgrade({ ...live.record, kind: live.kind }, next);
if (check.result !== "pass") {
// "fail", or "unchecked" when an ERC-7201 slot could not be read: neither is a pass.
console.error(check.layout.explain(), check.namespaces.explain(), check.safety?.explain());
process.exit(1);
}
```
Expand All @@ -160,7 +154,7 @@ if (!report.ok) {

With `findBuildInfo`, Hardhat 3's `project/` prefix is optional.

`explain()` gives OpenZeppelin's report, for example:
`check.layout.explain()` gives OpenZeppelin's report, for example:

```
src/Vault.sol:7: Inserted `inserted`
Expand All @@ -169,9 +163,9 @@ src/Vault.sol:7: Inserted `inserted`

The explorer defaults to Etherscan's v2 API, where one key covers every chain it indexes. For another Etherscan-compatible explorer, such as Blockscout, pass `{ apiUrl }` (a key is optional there).

### Safety rules and ERC-7201 slots too
### Two local builds

`analyzeSource` is `layoutFromSource` plus OpenZeppelin's safety check and the slot each ERC-7201 namespace really uses, from one compile. `checkUpgrade` runs all three checks on two analyses and gives one verdict, as the command line does.
`analyzeSource` gives a contract's layout, the slot each ERC-7201 namespace really uses and OpenZeppelin's safety check, from one compile. `checkUpgrade` runs all three checks on two analyses, or a record and an analysis, and gives one verdict, as the command line does.

```ts
import { analyzeSource, checkUpgrade, findBuildInfo, getSolc } from "slotdiff";
Expand All @@ -189,7 +183,7 @@ if (check.result !== "pass") {
}
```

The safety rules take the proxy kind from `old`, the proxy being upgraded, never from `next`: a UUPS implementation that lost its upgrade function would otherwise look transparent and pass. For a beacon proxy, pass `{ ...old, kind: "beacon" }`. `checkUpgrade` also takes `unsafeAllowRenames`, `unsafeAllow` (error kinds to accept, listed in `SAFETY_ERROR_KINDS`, on top of `@custom:oz-upgrades-unsafe-allow` in the source) and `skipSafety`. `unchecked` means nothing failed but an ERC-7201 slot could not be read (`check.namespaces.result` is `unread`). With `skipSafety`, `pass` covers the layout and slots only, and `check.safety` is absent. The pieces are there too: `checkNamespaces`, `diffLayouts` and the analysis's own `safety({ kind })`.
The safety rules take the proxy kind from `old`, the proxy being upgraded, never from `next`: a UUPS implementation that lost its upgrade function would otherwise look transparent and pass. For a beacon proxy, pass `{ ...old, kind: "beacon" }`. `checkUpgrade` also takes `unsafeAllowRenames`, `unsafeAllow` (error kinds to accept, listed in `SAFETY_ERROR_KINDS`, on top of `@custom:oz-upgrades-unsafe-allow` in the source) and `skipSafety`. `unchecked` means nothing failed but an ERC-7201 slot could not be read (`check.namespaces.result` is `unchecked` too, and that pair's `problem` is `unread`). With `skipSafety`, `pass` covers the layout and slots only, and `check.safety` is absent. The pieces are there too: `checkNamespaces`, `diffLayouts` and the analysis's own `safety({ kind })`.

### Reuse a build you already compiled

Expand Down Expand Up @@ -295,7 +289,6 @@ Problems that are yours to fix throw a plain `Error`, or `IncompleteBuildError`
| `proveLocalBuild(provider, address, build)` | A record from your own build, only when it is the deployed build |
| `verifyRecord(provider, address, value)` | Load a saved record, checked against the chain |
| `parseRecord(value, address?)` | Load a saved record, checked offline |
| `layoutFromSource(input, solc, "file.sol:Name")` | Layout from standard-json input, including ERC-7201 namespaces. No proof |
| `analyzeSource(input, solc, "file.sol:Name")` | The layout, proxy `kind`, ERC-7201 `namespaces` and `safety({ kind })` |
| `analyzeBuild(input, output, compiler)` | The same for every contract of a build already compiled, with no second full compile |
| `checkUpgrade(old, next, options?)` | Layout, ERC-7201 slots and safety rules in one verdict: `pass`, `fail` or `unchecked` |
Expand All @@ -314,8 +307,9 @@ Renames and retypes follow OpenZeppelin's NatSpec tags, `@custom:oz-renamed-from

- Explorer source must be verified as standard-json. Flattened and multi-file verifications do not record every compiler setting, so they are refused.
- Solidity only.
- Storage placed at a fixed slot through a struct that is not an ERC-7201 namespace of the contract or its bases (a namespace declared in a library, as in the diamond pattern, or a struct with no annotation) is not in OpenZeppelin's layout, so slotdiff cannot compare it and stops (exit 2) rather than pass it.
- Struct members cannot be renamed or retyped: Solidity has no NatSpec on struct members, so OpenZeppelin's tags cannot reach them.
- An ERC-7201 slot is read only from a `$.slot := ...` assignment whose value is a constant (a hex literal, the ERC-7201 formula, or `erc7201("id")`), directly or through a local variable set once or a function with no arguments that returns one. Any other slot (computed at run time, a library's constant) is not read, and the upgrade is reported as not checked. A namespace with no `$.slot := ...` assignment is compared by its annotation alone, as with OpenZeppelin, unless the code places a struct without an annotation (a library's `Layout`, say) at a slot it can read, or a mapping or an array pointer anywhere: its data may live there, so the upgrade is reported as not checked. Data an even older version left at another slot, or data reached another way (raw `sload` and `sstore`, or a pointer whose slot is passed in, as OpenZeppelin's `StorageSlot` takes it), goes unnoticed.
- An ERC-7201 slot is read only from a `$.slot := ...` assignment whose value is a constant (a hex literal, the ERC-7201 formula, or `erc7201("id")`), directly or through a local variable set once or a function with no arguments that returns one. Any other slot (computed at run time, a library's constant) is not read, and the upgrade is reported as not checked. A namespace with no `$.slot := ...` assignment is compared by its annotation alone, as with OpenZeppelin, unless the code places a mapping or an array pointer anywhere: its data may live there, so the upgrade is reported as not checked. Data an even older version left at another slot, or data reached another way (raw `sload` and `sstore`, or a pointer whose slot is passed in, as OpenZeppelin's `StorageSlot` takes it), goes unnoticed.

## No guarantee

Expand Down
Loading
Loading