diff --git a/.coderabbitai.yaml b/.coderabbitai.yaml index 63c3ccb..c6e9c57 100644 --- a/.coderabbitai.yaml +++ b/.coderabbitai.yaml @@ -1,6 +1,5 @@ # SPDX-License-Identifier: LicenseRef-DCL-1.0 # SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd - reviews: path_filters: - "!audit/**" diff --git a/.github/workflows/manual-sol-artifacts.yaml b/.github/workflows/manual-sol-artifacts.yaml new file mode 100644 index 0000000..5e05316 --- /dev/null +++ b/.github/workflows/manual-sol-artifacts.yaml @@ -0,0 +1,23 @@ +name: Manual sol artifacts +# The on-chain deploy, run by hand. This repo carries a deployed concrete +# (`AddressRegistry`) whose address + codehash consumers pin, and +# `package-release.yaml` cuts a release for a deployment that ALREADY exists — +# rainix-tag-release verifies the live chains against freshly generated pins and +# never broadcasts. So the deploy has to happen first, and separately, which is +# this. +# +# Order is: dispatch this, confirm `AddressRegistryDeployChainTest` passes +# on every supported network, then push the `sol-v*` tag. +# +# Deliberately `workflow_dispatch` only. Broadcasting is key custody and real +# money; nothing about a merge or a tag should trigger it. +on: + workflow_dispatch: +jobs: + deploy: + uses: rainlanguage/rainix/.github/workflows/rainix-manual-sol-artifacts.yaml@main + with: + # Passed through as DEPLOYMENT_SUITE; script/Deploy.sol dispatches on it + # and reverts on anything else. + suite: address-registry + secrets: inherit diff --git a/.github/workflows/package-release.yaml b/.github/workflows/package-release.yaml index d0a1ba8..6acb357 100644 --- a/.github/workflows/package-release.yaml +++ b/.github/workflows/package-release.yaml @@ -1,11 +1,34 @@ name: Package Release +# Deploy repo: a manual `sol-v*` tag is the sole release trigger. This repo now +# carries a deployed concrete (`AddressRegistry`) whose address + codehash +# consumers pin, which is exactly the shape rainix-tag-release exists for and +# exactly the shape rainix-autopublish's merge-driven, next-version lifecycle is +# wrong for: autopublish bumps [package].version on every merge while the frozen +# deploy tag only advances at deploy time. +# +# The tag names the version; rainix-tag-release runs `cutRelease()`, which +# freezes src/generated// AND regenerates the released-suites lib that +# declares it in one call, then verifies the live chains match the fresh pins, +# publishes rain-deploy to Soldeer, and commits both back to main. One call, +# because a frozen record no declaration names is a release every check silently +# stops asking about. +# +# The on-chain deploy is separate and manual, run BEFORE tagging; this never +# broadcasts. The chain verification inside rainix-tag-release is what fails if a +# newly declared release is not on chain, and that is the intended gate: a +# release is declared here only once it is a deployment that already happened. +# +# Switching lifecycles retracts nothing: every version already published stays +# published, and consumers pin exact versions, so this changes who cuts a +# release and nothing about how anyone consumes one. on: push: - branches: - - main + tags: + - sol-v* jobs: release: - uses: rainlanguage/rainix/.github/workflows/rainix-autopublish.yaml@main + uses: rainlanguage/rainix/.github/workflows/rainix-tag-release.yaml@main with: soldeer-package: rain-deploy + snapshot-generate-cmd: forge script ./script/Build.sol --sig "cutRelease()" && forge fmt secrets: inherit diff --git a/CLAUDE.md b/CLAUDE.md index 9fbab8a..b67a857 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,15 +3,22 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. ## Project Overview -rain.deploy is a Solidity library for deploying Rain Protocol contracts via the Zoltu deterministic deployment proxy to multiple EVM networks. It ensures identical contract addresses across all supported chains (Arbitrum, Base, Flare, Polygon) because the Zoltu proxy is deployed at the same address on every chain and uses CREATE with a predictable nonce. +rain.deploy is a Solidity library for deploying Rain Protocol contracts via the +Zoltu deterministic deployment proxy to multiple EVM networks. It ensures +identical contract addresses across all supported chains (Arbitrum, Base, Base +Sepolia, Flare, Polygon) because the Zoltu proxy is deployed at the same address +on every chain and deploys with `CREATE2` over its calldata under a zero salt, +so a contract's address is a pure function of its creation code. ## Build & Development -This project uses **Foundry** (forge) for Solidity development and **Nix** for environment management. +This project uses **Foundry** (forge) for Solidity development and **Nix** for +environment management. ```bash # Enter the nix dev shell (provides forge and all tooling) @@ -21,52 +28,300 @@ nix develop nix develop -c forge build # Run tests -nix develop -c rainix-sol-test +nix develop -c forge test -vvv # Run a single test nix develop -c forge test --match-test "testName" # Static analysis / linting -nix develop -c rainix-sol-static +nix develop -c slither . +nix develop -c forge fmt --check +nix develop -c rainix-sol-single-contract # License/legal checks (REUSE compliance) -nix develop -c rainix-sol-legal +nix develop -c reuse lint ``` -CI runs three matrix tasks: `rainix-sol-legal`, `rainix-sol-test`, `rainix-sol-static`. +CI runs three matrix tasks: `rainix-sol-legal`, `rainix-sol-test`, +`rainix-sol-static`. Those are rainix reusable workflow names, not commands — +the block above is what they run. + +A fourth workflow, `Manual sol artifacts`, is `workflow_dispatch` only and is +the on-chain deploy — nothing automatic ever broadcasts. ## RPC Configuration Fork tests require RPC endpoints defined in `.env` (gitignored): + ```bash ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc BASE_RPC_URL=https://mainnet.base.org +BASE_SEPOLIA_RPC_URL=https://sepolia.base.org FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc -POLYGON_RPC_URL=https://polygon-rpc.com +POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com ``` + +All five are needed: `RainDeployVerifyChain` forks every network in +`supportedNetworks()`, so a missing or rate-limited endpoint fails it. Those +failures are `vm.createSelectFork` errors, distinct from the +`NotDeployedOnNetwork` a reachable network raises, and the snapshot contracts +run regardless: `forge test --no-match-contract Chain`. + These are referenced in `foundry.toml` under `[rpc_endpoints]`. ## Architecture -The entire library is a single file: `src/lib/LibRainDeploy.sol`. +**`src/lib/LibRainDeploy.sol`** — the deploy library: + +- `etchZoltuFactory(Vm)` — etches the Zoltu factory bytecode at the factory + address (for networks where it isn't deployed) +- `zoltuAddress(bytes creationCode)` — derives the address the factory deploys + creation code to, without deploying +- `deployZoltu(bytes creationCode)` — deploys creation code via the Zoltu + factory (`0x7A0D94F55792C434d74a40883C6ed8545E406D12`) using low-level `call`, + returns the deployed address +- `supportedNetworks()` — returns the list of Rain-supported network names (used + as foundry RPC config aliases) +- `isStartBlock(...)` / `findDeployBlock(...)` — binary search a fork's history + for the block a contract first appears at +- `checkResolvedAddresses(...)` — asserts an already-deployed contract holds the + addresses the deployment expected, on the currently selected fork, via + consumer-supplied static reads +- `checkResolvedAddressesOnNetworks(...)` — runs that check on every network. It + runs AFTER the deploy, against state the deployment has already settled, which + is the only point at which such a check means anything: registry bindings are + mutable, so a pre-deploy check would read a source that can change before the + constructor that consumes it +- `deployToNetworks(...)` — forks each network, verifies the factory and + dependencies, deploys via Zoltu, verifies address and code hash +- `deployAndBroadcast(...)` — the main entry point: derives the deployer from a + private key, then `deployToNetworks` + +**`src/interface/IAddressRegistryV1.sol`** — the address registry interface: an +immutable root binds a `bytes32` name to an address (`register`), anyone reads a +bound name (`get`), and reading an unbound name reverts. Bindings are mutable so +an owning multisig can rotate without moving any consumer's deterministic +address; a consumer resolves once in its constructor and stores the answer, so a +re-binding never moves anything already deployed. + +**`src/concrete/AddressRegistry.sol`** — the implementation. Two functions and +nothing else. `ADDRESS_REGISTRY_ROOT` is a compile-time constant and therefore +part of the creation code, so changing it moves the deterministic address and +code hash. + +`ADDRESS_REGISTRY_ROOT` is `address(0)` during rollout. Nothing calls from the +zero address, so no name can be bound, and `get` reverts on every name that is +not bound — a registry compiled under this root answers every read with a revert +and can never answer one with an address. Setting a real root later is an +ordinary source change that moves the creation code, the address, the code hash +and the snapshot together. + +**`src/lib/LibAddressRegistryDeploy.sol`** — those pins, derived from the +creation code this repo compiles under this repo's own settings and checked +against it by `AddressRegistryDeploySnapshotTest`. GENERATED by +`script/Build.sol`, aliasing the rolling `src/generated/candidate/` snapshot. + +### Generated snapshots, and the assertions that specify their shape + +Every deploy snapshot in this repo is GENERATED and committed. There is no +hand-maintained hex anywhere: `src/generated/candidate/AddressRegistry.sol` is +the deploy record, from `forge script script/Build.sol`. + +A compiler or optimiser change is therefore "run the script, commit". Never +hand-edit a generated file. + +`src/generated//` directories are the FROZEN record: what each release +deployed, written once by `cutRelease()` and never again. That tree is the only +description of what this repo has released that cannot fall behind, which is why +`RainDeployVerifySnapshot` checks the generated `releasedSuites()` against it. +`LibRainDeploySnapshot.frozenSnapshotPaths` is the walk: every file inside a +release-tag directory, where a release tag is exactly what `tagForVersion` +produces — so `candidate/`, a scratch directory and a `0_1_7-rc1` nobody could +have frozen all fall out under the same rule, and there is no name to remember +to exclude. + +**`GeneratedSnapshotShapeTest` is the specification of the shape.** It asserts +named properties against the compiler's AST — not against a second reference +file, so there is no question of that file's provenance, and not against source +text, so formatting cannot affect it: + +1. exactly four constants, in order: `bytes32 BYTECODE_HASH`, + `address DEPLOYED_ADDRESS`, `bytes CREATION_CODE`, `bytes RUNTIME_CODE` +2. every declaration is `constant` +3. no `ImportDirective` — a snapshot is read by repos that do not have the + contract it describes, which is the whole reason a frozen release stays + verifiable after its source has changed or gone +4. no `ContractDefinition` — it is a record, not code +5. the generated-file header is present + +Values are deliberately not asserted: a solc change moves every literal without +changing anything the shape test is about, and a wrong literal is caught +immediately by the group 1 derivation checks in `RainDeployVerifySnapshot`. + +`ast = true` in `foundry.toml` is what puts the AST in the artifacts a plain +`forge test` produces. + +### `src/` holds the deploy machinery here. That is a SCOPED EXCEPTION. + +`src/abstract/RainDeploy*.sol` are test and script infrastructure, and they live +in `src/` rather than `test/`. Two reasons, and the second is the one that +matters: -**LibRainDeploy** provides: -- `etchZoltuFactory(Vm)` — etches the Zoltu factory bytecode at the factory address (for networks where it isn't deployed) -- `deployZoltu(bytes creationCode)` — deploys creation code via the Zoltu factory (`0x7A0D94F55792C434d74a40883C6ed8545E406D12`) using low-level `call`, returns the deployed address -- `supportedNetworks()` — returns the list of Rain-supported network names (used as foundry RPC config aliases) -- `checkDependencies(...)` — forks each network, verifies dependencies and Zoltu factory exist with expected codehashes -- `deployToNetworks(...)` — re-verifies dependencies, deploys via Zoltu, verifies address and code hash -- `deployAndBroadcast(...)` — the main entry point: derives deployer from private key, calls `checkDependencies` then `deployToNetworks` +1. `.soldeerignore` excludes `test/` from the published package, and a + downstream repo has to import all of this — its `script/Deploy.sol` inherits + `RainDeployBroadcast`, its test contracts inherit `RainDeployVerify*`. An + abstract in a path the package excludes is unusable by every consumer. +2. **This repo's PRODUCT is the deployment process.** Machinery for deploying + and for verifying deployments is not scaffolding that happens to live here — + it is the thing the package exists to publish. So `src/` is where it belongs. -The library is designed to be called from Foundry scripts (`forge script`) in consuming repos, not directly. Consuming repos provide their own creation code, expected addresses, expected code hashes, and dependency lists. +**Do not copy this into a consumer repo.** There, `src/` is the product — +tokens, vaults, a factory — and deploy verification is scaffolding around it, so +the usual convention stands unchanged: `test/src/**` mirrors `src/**`, and test +abstracts live under `test/`. The exception is earned by what this repo IS, and +a repo that merely USES this machinery has not earned it. + +The exception is scoped to the deploy/verify abstracts and the suite +declaration. `src/concrete/AddressRegistry.sol` is an ordinary deployed +contract, tested from `test/src/concrete/` exactly as the convention requires. + +**`src/abstract/RainDeploySuitesBase.sol`** — the ONE declaration of what a repo +deploys: per suite, a key, the creation code, the recorded address/code +hash/runtime code, an artifact path, and dependencies. + +Both sides read it. `RainDeployBroadcast` deploys from it and +`RainDeployVerify*` verify against it, so "the deploy script broadcasts one +contract while the tests verify another" is not a statement that can be true — +not because something checks for it, but because there is one array and all +three contracts read it. + +Suites are a REGISTRY the abstract iterates, not a chain of `else if`. A repo +adds a suite by adding an array entry; the keys reported by a mistyped +`DEPLOYMENT_SUITE` are built from that same array, so the failure message cannot +fall behind the suites it describes. Keys are checked unique, because the key is +what selects what gets broadcast. + +**`src/abstract/RainDeployBroadcast.sol`** — the broadcast. Selects one suite by +`DEPLOYMENT_SUITE` and deploys it, before reading `DEPLOYMENT_KEY` so a mistyped +suite fails naming the valid ones rather than on a missing key. +`deployNetworks()` defaults to `supportedNetworks()` and is overridable for +repos that bootstrap one chain per dispatch. + +**`script/Deploy.sol`** — +`contract Deploy is AddressRegistryDeploySuites, +RainDeployBroadcast {}`. Empty +on purpose: the suites and the broadcast are both inherited. Run only via the +`Manual sol artifacts` workflow. + +**`src/abstract/AddressRegistryDeploySuites.sol`** — this repo's own +declaration, inherited by `script/Deploy.sol` and by both pins test contracts. + +**`src/abstract/RainDeployVerify*.sol`** — the deploy-pin verification every +deploy repo inherits instead of hand-writing. + +Nothing is per suite beyond an array entry, and nothing anywhere is per network. + +The creation code is the only parameter. The Zoltu factory is `CREATE2` over its +calldata under a zero salt, so the address is a pure function of it, and running +it once locally gives the runtime code and its hash. The address, code hash and +runtime code a generated file records are checked OUTPUTS. + +Four groups, sorted by what they are anchored to: + +1. **Internal to the recorded set** (`RainDeployVerifySnapshot`) — what a + version records is what its own creation code derives. Catches a set + generated inconsistently. CANNOT catch a snapshot of the wrong contract: a + consistent snapshot of the wrong thing satisfies all of it, which + `testWrongContractSnapshotPassesInternalConsistency` pins. +2. **Anchored to source** (`RainDeployVerifySnapshot`) — the candidate's + recorded creation code is `type(X).creationCode`. The only check that catches + a wrong-contract snapshot. Candidate only, because a released tag is MEANT to + diverge from current source; there is no field on a released version to spell + it, so it cannot be opted into or out of. +3. **Anchored to the record** (`RainDeployVerifySnapshot`) — every file in the + append-only `src/generated//` tree is declared by a released suite, + matched by the address that file's creation code derives. `releasedSuites()` + is generated from that same record and everything anchored to a chain reads + it, so a frozen tag it does not name is a release that quietly drops out of + every check there is — which is what this catches when the generated file is + hand edited, a record directory arrives out of band, or nobody re-ran the + generator after the record moved. Matched against RELEASED suites only: a + release and the candidate are byte-identical from the moment the release is + cut, so matching the whole declaration would let the candidate declare a + release. +4. **Anchored to chain** (`RainDeployVerifyChain`) — across + `supportedNetworks()`, every RELEASED version's derived address carries code + with its derived code hash. The only check that catches "never deployed" or + "not there any more", neither of which the repo can hold: both go false with + nobody touching it. + +Group 4 is released-only for the mirror image of group 2's reason. A release IS +a deployment that happened; the candidate is what the next release will be, and +between releases it is ordinarily ahead of anything on chain, so demanding it be +live asserts something false by design. Neither exemption is a field a caller +can set. Group 3 is what makes group 4's scope complete — a release group 4 is +never handed is a release it cannot fail on. + +Group 4 lives in its own contract so an unreachable RPC endpoint fails only it, +never the snapshot assertions — `forge test --no-match-contract Chain` is the +whole snapshot gate, and nothing reachable from those contracts forks anything. + +A single recorded code hash per version can only be true if the runtime code is +the same on every network, so a constructor reading `block.chainid` or similar +is a DEFECT: it fails hard, naming the chain and both hashes. There is +deliberately no per-chain code hash to record. + +The `src/abstract/` files are the only `src/` files `slither.config.json` +filters out, by name. They are inherited by test contracts and never deployed, +so slither's detectors — all of which are about deployed-code risk — have +nothing to say about them except that an abstract does not implement its own +virtuals and that a cheatcode is called in a loop, and — because slither skips +`test/` and `script/` — that an abstract's virtuals have no caller. The filter +matches those filenames exactly, not the `src/abstract/` prefix, so a file added +there later — including a deployable one — is analyzed rather than silently +exempted. + +The libraries are designed to be called from Foundry scripts (`forge script`) in +consuming repos, not directly. Consuming repos provide their own creation code, +expected addresses, expected code hashes, and dependency lists. ## Key Design Patterns -- **Deterministic addresses**: Zoltu proxy ensures same address on every chain. Deployments fail if the resulting address doesn't match `expectedAddress`. -- **Code hash verification**: Post-deploy bytecode integrity is verified against `expectedCodeHash`. -- **Dependency checking**: Before deploying to any network, all dependencies (contract addresses) are verified to have code on-chain. -- **Idempotent deploys**: If code already exists at the expected address, deployment is skipped for that network. +- **Deterministic addresses**: Zoltu proxy ensures same address on every chain. + Deployments fail if the resulting address doesn't match `expectedAddress`. +- **Code hash verification**: Post-deploy bytecode integrity is verified against + `expectedCodeHash`. The address registry is verified the same way before it is + read. +- **Dependency checking**: Before deploying to any network, all dependencies + (contract addresses) are verified to have code on-chain. +- **Idempotent deploys**: If code already exists at the expected address, + deployment is skipped for that network. +- **Resolve once, verify after**: registry bindings are mutable, so the + meaningful check is not "does the registry say what I expect" before a deploy + but "does the deployed contract hold what I expect" after one. A consumer + resolves in its constructor; the deployment is then verified across every + network before anything migrates onto it. +- **Deploy-repo lifecycle**: a manual `sol-v*` tag is the sole release trigger + (`rainix-tag-release`), because this repo carries a deployed concrete whose + pins consumers rely on. `[package].version` is the LAST released version and + moves only in lockstep with its snapshot. +- **Deploy, then verify, then tag** — in that order, and they are three separate + things. `script/Deploy.sol` broadcasts `AddressRegistry` to every network in + `supportedNetworks()`, dispatched by hand through + `.github/workflows/manual-sol-artifacts.yaml`. Only then is there a deployment + for `rainix-tag-release` to verify pins against — it verifies and publishes, + it never broadcasts. Broadcasting is key custody and real money, so it is + `workflow_dispatch` and nothing else. Deploying is idempotent: a network that + already has the code is skipped, so a partial run is fixed by running it + again. + + `AddressRegistryDeployChainTest` is what verifies it, and it checks the + RELEASED suites. Nothing is released yet, so it has nothing to check and forks + nothing. It gets a subject the moment a release is frozen and declared — from + then on it is red until that release is live on every supported network, which + is why the deploy comes first. ## License -DecentraLicense 1.0 (LicenseRef-DCL-1.0). All source files must have SPDX headers. REUSE compliance is enforced in CI. +DecentraLicense 1.0 (LicenseRef-DCL-1.0). All source files must have SPDX +headers. REUSE compliance is enforced in CI. diff --git a/README.md b/README.md index 9c2eb72..7f03e52 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,11 @@ It answers: - Have I deployed successfully to all expected networks? - How do I track deployments over time and share addresses with other people? - How do I ensure deployed code is bytecode-equivalent to local compilations? +- How does a deployment get a configured address — an owner, say — without + baking one into its creation code, where changing it would move every future + deployment? +- Is every version I have ever released still live, with the code I compiled, on + every network I support? Approach: @@ -25,6 +30,170 @@ Approach: and against the chain after: silent failures fail loudly. - Bytecode integrity checks (e.g. via the Rain Extrospection lib) supported post-deploy. +- An address registry, read at run time rather than compiled into creation code, + and a post-deploy check that every target network's deployment took the + address it was supposed to. +- One inherited deploy-pin verification, parameterized over versions, rather + than assertions hand-enumerated per version and per chain in every deploy + repo. + +## One declaration, deployed and verified + +A repo declares its suites ONCE. A suite is a named snapshot: a key, the +creation code, the recorded address/code hash/runtime code, the artifact path +and the addresses that must already be on chain before it can be deployed. + +```solidity +// src/abstract/MyDeploySuites.sol +abstract contract MyDeploySuites is RainDeploySuitesBase { + function releasedSuites() internal pure override returns (DeploySuite[] memory); + function candidateSuite() internal pure override returns (DeployCandidate memory); +} + +// script/Deploy.sol +contract Deploy is MyDeploySuites, RainDeployBroadcast {} + +// test/src/concrete/MyDeploySnapshot.t.sol +contract MyDeploySnapshotTest is MyDeploySuites, RainDeployVerifySnapshot {} + +// test/src/concrete/MyDeployChain.t.sol +contract MyDeployChainTest is MyDeploySuites, RainDeployVerifyChain {} +``` + +The broadcast and the verification read the SAME array. "The deploy script ships +one contract while the tests verify another" is therefore not a statement that +can be true — not because something checks for it, but because there is nothing +for it to disagree with. A repo that wrote its suites out twice would have that +bug available to it; this one does not. + +Suites are a **registry the abstract iterates**, not a chain of `else if`. +Adding a suite is adding an array entry. A mistyped `DEPLOYMENT_SUITE` reports +the valid keys built from that same array, so the error cannot fall behind the +suites it describes, and keys are checked unique because the key is what selects +what gets broadcast. + +Every suite is individually selectable, including a frozen release — which is +how a snapshot from before a network existed reaches that network. + +## Deploy verification + +**The creation code is the only input.** The Zoltu factory is `CREATE2` over its +calldata under a zero salt, so the address is a pure function of the creation +code and identical on every network, and running that creation code once locally +yields the runtime code and its hash. Everything else a suite records is a +checked output. + +Recorded rather than derived, deliberately. `LibRainDeploy` compares the +recorded address against the creation code **before it forks anything**, so a +stale pin fails instead of deploying to wherever the code happens to land. +Deriving the pins at broadcast time would make that comparison +derived-against-derived, and a guard that compares a value to itself is not a +guard. + +Four groups, sorted by what each is anchored to and therefore by what each can +catch: + +| Group | Anchored to | Catches | Cannot catch | +| -------- | ---------------------- | ------------------------------------- | -------------------------------- | +| Internal | the recorded set | an inconsistently generated set | a snapshot of the wrong contract | +| Source | `type(X).creationCode` | a snapshot of the wrong contract | anything about any chain | +| Record | the frozen record | a release the declaration missed | what a declared suite records | +| Chain | the networks | never deployed, or not there any more | anything about the candidate | + +The internal group's blind spot is not a gap to close there: every check in it +asks the recorded bytes to agree with each other, and the wrong contract's bytes +agree with each other perfectly. The source anchor is the only thing that +catches it, and it applies to the **candidate only** — a released tag is meant +to have diverged from current source, so anchoring one to source asserts +something false by design. That is a property of the assertion, and there is no +field on a released version with which to opt in or out. + +The chain group carries the mirror image of that exemption: it applies to +**released versions only**. A release IS a deployment that happened, so "it is +live on every supported network" is either true of it or a defect. The candidate +is what the next release will be, ordinarily ahead of anything on chain, so +demanding it be live asserts something false by design in the other direction. +Neither exemption is a field a caller can set. + +Scoping to releases puts the whole weight on `releasedSuites()` naming every +release, which is what the record group is for. A frozen tag the declaration +misses is not an entry that turns up missing somewhere — it is a release the +chain group is never handed, and a check with no subject cannot fail on it, so +that release drops out of everything while the suite stays green. The +declaration is generated from the append-only `src/generated//` record and +checked back against it, matched by address, since matching by name would assert +only that a convention was followed. + +**Chain-independent runtime code is a requirement, not a caveat.** One recorded +code hash per version can only be true if the runtime code is the same +everywhere. A constructor that reads `block.chainid` deploys different code per +chain: deploying through Zoltu buys address predictability, and such a +constructor spends it. So a per-chain difference fails hard, naming the chain +and both hashes, and there is deliberately no per-chain code hash to record. + +## Address registry + +`AddressRegistry` binds an opaque `bytes32` name to an address. An immutable +root authority binds a name, anyone reads a bound name, and reading an unbound +name reverts rather than answering with the zero address. There is no removal, +no upgrade and no authority besides root. + +Bindings are **mutable**, because the addresses they name are. Rotating an +owning multisig is ordinary business and has to be expressible without moving +anybody's deterministic address — which a binding welded to one address forever +would make impossible, because the name is in the consumer's creation code, so a +new name means new creation code and a new address. That is the problem the +registry exists to remove, not a property worth keeping. + +Mutability costs nothing already deployed. A consumer resolves a name **once**, +in its constructor, and stores the answer; it never reads the registry again. So +re-binding a name changes what the _next_ deployment resolves and nothing else, +which makes a rotation a deliberate migration rather than a silent change to +live contracts. + +`LibAddressRegistry.resolve` is the read, verifying the registry's code hash +first, the same way `LibRainDeploy` verifies the Zoltu factory's. It resolves a +name to an address and stops there — what a consumer does with the address, and +when, is the consumer's business. + +`LibRainDeploy.checkResolvedAddressesOnNetworks` is the **post-deploy** +verification: on every target network, the deployed contract must hold the +address the deployment expected. It runs after the deploy and before anything +depends on it, against state the deployment has already settled, so nothing it +reads can move underneath it. The same check run beforehand would be worth +nothing against a mutable source. A network where the deployment took something +else is a burned deterministic address, found while nothing points at it yet. + +Only the consumer knows where it stored what it resolved, so the consumer +supplies the reads (`abi.encodeCall(IOwnable.owner, ())` and the like) and this +library supplies the fork loop and the comparison. + +## Deploying, and then releasing + +Three separate steps, in this order. Nothing automatic ever broadcasts. + +1. **Deploy.** Dispatch the + [`Manual sol artifacts`](.github/workflows/manual-sol-artifacts.yaml) + workflow, which runs `script/Deploy.sol` and broadcasts `AddressRegistry` to + every network in `supportedNetworks()`. `workflow_dispatch` only: this is key + custody and real money, and no merge or tag should be able to trigger it. It + is idempotent — a network that already has the code is skipped — so a partial + run is fixed by running it again rather than by unpicking anything. +2. **Verify.** `AddressRegistryDeployChainTest` passes only once every supported + network has the registry, with the code this repo compiles. It is red today + because step 1 has never been run. +3. **Tag.** Push a `sol-v*` tag. `rainix-tag-release` regenerates the snapshot + for the version the tag names, verifies the live chains against those fresh + pins, publishes to Soldeer and commits the frozen snapshot back to `main`. It + verifies and publishes; it never broadcasts, which is exactly why step 1 + cannot be folded into it. + +This is a deploy repo: it carries a deployed concrete whose address and codehash +consumers pin, so releases are **manual `sol-v*` tags**, not merges. +`[package].version` is the LAST released version, naming the current +`src/generated//` snapshot, and only a release moves it. Every version +published under the previous merge-driven lifecycle stays published; consumers +pin exact versions and are unaffected. ## Install @@ -34,6 +203,24 @@ Via [soldeer](https://soldeer.xyz): forge soldeer install rain-deploy~ ``` +**You also need `forge-std` 1.16.1**, remapped as `forge-std-1.16.1/`. The +published package deliberately ships only `src/` and `script/` — no +`remappings.txt`, no `soldeer.lock`, no `dependencies/` — so a consumer resolves +`forge-std` itself. The requirement is transitive rather than incidental: the +deployed contract imports nothing outside this package, but every abstract a +consumer inherits pulls forge-std in — `Script` via `RainDeployBroadcast`, +`Test` via `RainDeployVerifyBase`, and `Vm` via `LibRainDeploy` beneath both: + +```toml +[dependencies] +forge-std = "1.16.1" +rain-deploy = "" +``` + +The version has to match: the import paths are version-qualified, which is +deliberate — it is what stops a consumer's incompatible `forge-std` from +silently satisfying these imports. + ## Develop This repo uses [nix](https://nixos.org/download.html). The default shell is the @@ -45,21 +232,16 @@ forge soldeer install # install deps declared in foundry.toml forge test ``` -Tasks: +The three CI jobs are rainix reusable workflows, not commands in the shell. What +each of them runs, which is what reproduces it locally: -- `rainix-sol-test` — `forge test` -- `rainix-sol-static` — slither +- `rainix-sol-test` — `forge test -vvv` - `rainix-sol-legal` — `reuse lint` +- `rainix-sol-static` — `slither .`, `forge fmt --check`, then + `rainix-sol-single-contract` Use the nix-pinned `forge` for all development. -## Publish - -Tag `v` on `main`. The -[`Publish to Soldeer`](.github/workflows/publish-soldeer.yaml) wrapper delegates -to rainix's reusable workflow, which derives the package name from the repo name -(`rain.deploy` → `rain-deploy`). - ## License DecentraLicense 1.0 (DCL-1.0) — full text in @@ -71,7 +253,7 @@ This repo is [REUSE 3.2](https://reuse.software/spec-3.2/) compliant. Verify locally: ```sh -nix develop -c rainix-sol-legal +nix develop -c reuse lint ``` ## Contributions diff --git a/foundry.toml b/foundry.toml index 3152e5c..d2ba1a6 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,9 @@ [package] name = "rain-deploy" -version = "0.1.6" +# Deploy repo: this is the LAST released version, naming the current +# src/generated// snapshot, not a next-version slot. A normal PR does not +# bump it; only a `sol-v*` tag release moves it, in lockstep with the snapshot. +version = "0.1.5" # SPDX-License-Identifier: LicenseRef-DCL-1.0 # SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd @@ -9,18 +12,65 @@ version = "0.1.6" src = "src" out = "out" libs = ["dependencies"] + +# This repo compiles a contract whose deterministic deploy address and code hash +# are pinned in LibAddressRegistryDeploy, and both are a pure function of the +# creation code, which is a function of these settings. They are pinned exactly +# rather than floated so the pins cannot move under a compiler or default-target +# change, and they match the settings the org's other deploy repos use. +solc = "0.8.25" +optimizer = true +optimizer_runs = 100000 +evm_version = "cancun" cbor_metadata = false bytecode_hash = "none" +# GeneratedSnapshotShapeTest asserts the SHAPE of a generated deploy snapshot +# from the compiler's own AST, so the AST has to be in the artifacts that a +# plain `forge test` produces — not only under an explicit `--ast`. +ast = true + +# Build reads the version from foundry.toml and writes the generated +# per-tag snapshots + the current-pin lib under src/. Nothing else in this repo +# touches the filesystem. +fs_permissions = [ + { access = "read", path = "./foundry.toml" }, + { access = "read-write", path = "./src" }, + # LibRainDeploySnapshotTest builds a record tree of its own under + # test/generated to drive the frozen-record walk. NOT src/generated: the + # inherited record check reads that root from contracts forge runs in + # parallel, so a fixture release there would be one they have to fail on. + { access = "read-write", path = "./test" }, + # GeneratedSnapshotShapeTest reads the compiler's AST out of the artifact. + { access = "read", path = "./out" }, +] + [dependencies] forge-std = "1.16.1" +rain-sol-codegen = "0.1.6" [soldeer] recursive_deps = false +# Every alias here is a network script/Deploy.sol broadcasts to and +# RainDeployVerifyChain forks. rainix's rpc-preflight action binds +# _RPC_URL to a candidate that is reachable at the time of the run, +# rather than to one URL that may be dead, so these names are the contract with +# it. [rpc_endpoints] arbitrum = "${ARBITRUM_RPC_URL}" base = "${BASE_RPC_URL}" base_sepolia = "${BASE_SEPOLIA_RPC_URL}" flare = "${FLARE_RPC_URL}" polygon = "${POLYGON_RPC_URL}" + +# `rainix-manual-sol-artifacts` passes `--verify` by default and exports exactly +# these variable names, so a deploy without this section broadcasts and then +# fails with no API key configured for the chain — after spending the gas. One +# entry per `[rpc_endpoints]` alias, because the deploy goes to all of them. +[etherscan] +arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}" } +base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}" } +base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}" } +flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}" } +polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}" } diff --git a/remappings.txt b/remappings.txt index f4f4742..46ed0ce 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1 +1,2 @@ forge-std-1.16.1/=dependencies/forge-std-1.16.1/ +rain-sol-codegen-0.1.6/=dependencies/rain-sol-codegen-0.1.6/ diff --git a/script/Build.sol b/script/Build.sol new file mode 100644 index 0000000..947b655 --- /dev/null +++ b/script/Build.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {AddressRegistryDeploySuites} from "../src/abstract/AddressRegistryDeploySuites.sol"; +import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; +import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; + +/// @title Build +/// @notice Generates the deterministic-deploy pins for `AddressRegistry`. +/// +/// Two entry points, because there are two different things to do and only one +/// of them happens on an ordinary build: +/// +/// - `run()` — every build. Regenerates the ROLLING snapshot +/// `src/generated/candidate/AddressRegistry.sol` from current source, +/// and the alias lib that points at it. Nothing here is frozen, so a source +/// change simply moves it. +/// - `cutRelease()` — a release. Regenerates the rolling snapshot and freezes +/// it as `src/generated//`, in ONE call, in that order. +/// +/// The alias lib always points at `candidate`, so consumers' import path never +/// moves and `LibAddressRegistry` always resolves against what this repo +/// currently compiles. The frozen `/` directories are the historical +/// record — what each release actually deployed — which is what +/// `AddressRegistryDeploySuites.releasedSuites()` enumerates. +/// +/// BOTH entry points also regenerate that released-suites lib from the record. +/// `run()` must: the lib is imported by ordinary source, so a repo before its +/// first release still has to have one, and with nothing frozen it declares an +/// empty set. +/// +/// The metadata each released entry carries beyond its frozen snapshot comes +/// from `candidateSuite()`, which is why this inherits the declaration rather +/// than restating it. There is one suite key, one artifact path and one +/// dependency list in this repo, and a second copy of them here is a second +/// copy that drifts. +/// +/// The tag, both snapshot paths, the freeze, the snapshot writer and both +/// generated-lib writers all come from `LibRainDeploySnapshot`, which in turn +/// emits every constant through `LibCodeGen` and writes snapshots through +/// `LibFs`. This script is the declaration and the sequencing, nothing else. +contract Build is Script, AddressRegistryDeploySuites { + /// @notice The prefix for the constants the alias lib exports. Passed + /// rather than derived from the contract name; see `writeAliasLib`. + string constant CONSTANT_PREFIX = "ADDRESS_REGISTRY"; + + /// @notice The contract this repo deploys. Named once, because the + /// snapshot, the alias lib, the released-suites lib and the freeze all + /// describe the same contract, and a second spelling of it is a second + /// spelling that drifts. + string constant CONTRACT_NAME = "AddressRegistry"; + + /// @notice Every build: regenerate the rolling snapshot, its alias lib and + /// the released-suites lib. + function run() external { + regenerateCandidate(); + regenerateLibs(); + } + + /// @notice A release: regenerate the rolling snapshot, freeze it as this + /// release's immutable record, then regenerate the declaration of that + /// record. + /// + /// One invocation, so the ordering is a property of the tool rather than of + /// whoever wrote the release command. `LibRainDeploySnapshot.freeze` takes + /// the regeneration and runs it FIRST; there is no entry point that freezes + /// without regenerating, so a stale freeze has nowhere to come from. + /// + /// The released-suites lib is written from the record AFTER the freeze, so + /// the release being cut is in it. A frozen tag no released suite declares + /// is a release that drops out of every check there is, which is exactly + /// what generating the two from one call removes. + function cutRelease() external { + string[] memory contractNames = new string[](1); + contractNames[0] = CONTRACT_NAME; + LibRainDeploySnapshot.freeze(vm, regenerateCandidate, contractNames); + regenerateLibs(); + } + + /// @notice Rewrite the alias lib and the released-suites lib. Both entry + /// points end here, so there is no entry point that regenerates one and not + /// the other. + function regenerateLibs() internal { + LibRainDeploySnapshot.writeAliasLib(vm, CONTRACT_NAME, CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); + LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, LibRainDeploySnapshot.LIB_FS_ROOT, CONTRACT_NAME, candidateSuite().snapshot + ); + } + + /// @notice Rewrite `src/generated/candidate/AddressRegistry.sol` from what + /// this repo currently compiles. + function regenerateCandidate() internal { + LibRainDeploySnapshot.writeSnapshot( + vm, LibRainDeploySnapshot.CANDIDATE, CONTRACT_NAME, type(AddressRegistry).creationCode + ); + } +} diff --git a/script/Deploy.sol b/script/Deploy.sol new file mode 100644 index 0000000..dfceb7f --- /dev/null +++ b/script/Deploy.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {RainDeployBroadcast} from "../src/abstract/RainDeployBroadcast.sol"; +import {AddressRegistryDeploySuites} from "../src/abstract/AddressRegistryDeploySuites.sol"; + +/// @title Deploy +/// @notice The on-chain deploy. Broadcasts whichever suite `DEPLOYMENT_SUITE` +/// names, through the Zoltu factory, to every supported network. +/// +/// Empty on purpose. The suites come from `AddressRegistryDeploySuites`, which +/// is the same declaration the verification tests inherit, and the dispatch, +/// the key handling and the broadcast come from `RainDeployBroadcast`. A deploy +/// repo writes its declaration and this pair of base contracts, and nothing +/// else — no per-suite branch, no per-network list. +/// +/// This is the missing half of the deploy lifecycle `package-release.yaml` +/// assumes: `rainix-tag-release` verifies and publishes pins for a deployment +/// that already exists, so something has to put it on chain first, and a +/// `sol-v*` tag is not it. Run manually via the `Manual sol artifacts` +/// workflow, before tagging. +/// +/// Deploying is idempotent by construction. `deployToNetworks` checks the +/// recorded address against the creation code before it forks anything, then +/// skips any network that already has code there, so a partial run — three +/// chains of five, one RPC down — is fixed by running it again rather than by +/// unpicking anything. +/// +/// `AddressRegistryDeployChainTest` is what says whether this has been run +/// and worked. It fails until every supported network has the registry, which +/// is the state this repo is in right now. +contract Deploy is AddressRegistryDeploySuites, RainDeployBroadcast {} diff --git a/slither.config.json b/slither.config.json index 70f3181..acb8796 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { - "filter_paths": "dependencies/forge-std-", + "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifySnapshot)|AddressRegistryDeploySuites)\\.sol", "detectors_to_exclude": "assembly" } diff --git a/soldeer.lock b/soldeer.lock index e9184e4..7cfb9a7 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -4,3 +4,10 @@ version = "1.16.1" url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_16_1_08-05-2026_08:51:16_forge-std-1.16.zip" checksum = "839b61832925c7152c7b6dffbfa4998d9e606211179bd8f604733124e8a7cb57" integrity = "60e55d10150354ca4a1e2985c5456c834b92b82ef85ab0e1d92a7786cddbd219" + +[[dependencies]] +name = "rain-sol-codegen" +version = "0.1.6" +url = "https://soldeer-revisions.s3.amazonaws.com/rain-sol-codegen/0_1_6_13-08-2026_19:05:33_rain.sol.zip" +checksum = "906ebec1dff49612802ce04db626827aba348f4efd174fc7cb483f7d9ce8a06d" +integrity = "2e911fdf161eed28e1d94c50a1cc2ee0d353aa4c9d1a4dc2bdbfa0ac4f3b1de7" diff --git a/src/abstract/AddressRegistryDeploySuites.sol b/src/abstract/AddressRegistryDeploySuites.sol new file mode 100644 index 0000000..bbe3e41 --- /dev/null +++ b/src/abstract/AddressRegistryDeploySuites.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol"; +import {AddressRegistry} from "../concrete/AddressRegistry.sol"; +import { + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE_CANDIDATE, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE_CANDIDATE +} from "../generated/candidate/AddressRegistry.sol"; +import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; +import {LibAddressRegistryReleased} from "../lib/LibAddressRegistryReleased.sol"; + +/// @title AddressRegistryDeploySuites +/// @notice Everything this repo deploys, declared ONCE. +/// +/// Three contracts inherit this and nothing else declares a suite: +/// +/// - `script/Deploy.sol` broadcasts from it +/// - `AddressRegistryDeploySnapshotTest` checks its records against its +/// creation code +/// - `AddressRegistryDeployChainTest` checks it against every chain +/// +/// So "the deploy script broadcasts one contract while the tests verify +/// another" is not a thing that can be true here. Not because something checks +/// for it — because there is only one list, and all three read it. +/// +/// It lives in `src/` rather than `test/` for two reasons. `.soldeerignore` +/// excludes `test/`, and a downstream `script/` has to import its own +/// equivalent. And in THIS repo the deployment process is the product: see the +/// scoped exception recorded in `CLAUDE.md`. +/// +/// This declaration is the whole of what a deploy repo writes. The derivation, +/// the comparisons, the source anchor, the networks matrix and the broadcast +/// are all inherited. There is deliberately nothing per suite beyond an array +/// entry and nothing per network at all. +/// +/// Half of it is written by hand and half is generated: the candidate below is +/// the declaration, and `releasedSuites()` comes from `script/Build.sol`, which +/// emits it from the frozen record. +abstract contract AddressRegistryDeploySuites is RainDeploySuitesBase { + /// @inheritdoc RainDeploySuitesBase + /// @dev Generated by `script/Build.sol` from the frozen + /// `src/generated//` record, in the same call that writes it. The + /// record and the declaration of it therefore cannot disagree — which + /// matters because a release missing from the declaration is a release + /// every check quietly stops asking about, while the whole suite stays + /// green. + /// + /// Empty until the first release is cut. The rolling `candidate/` snapshot + /// is not a release and does exist. + function releasedSuites() internal pure override returns (DeploySuite[] memory) { + return LibAddressRegistryReleased.releasedSuites(); + } + + /// @inheritdoc RainDeploySuitesBase + /// @dev The pins in `LibAddressRegistryDeploy` are hand-written literals, + /// and they are what the internal group checks the derivation against and + /// what the broadcast asserts against before it forks anything. + /// + /// The creation code and runtime code are RECORDED, read from the rolling + /// `src/generated/candidate/` snapshot. That is what makes the source + /// anchor mean something: it compares the recorded creation code against + /// `type(AddressRegistry).creationCode`, so editing the contract without + /// re-running `script/Build.sol` fails. While nothing was recorded, that + /// check compared source against itself and could only pass. + /// + /// `AddressRegistry` reads nothing and calls nothing at construction, so it + /// has no dependency that must already be on chain. + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "address-registry", + creationCode: ADDRESS_REGISTRY_CREATION_CODE_CANDIDATE, + storedDeployedAddress: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE_CANDIDATE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }), + sourceCreationCode: type(AddressRegistry).creationCode + }); + } +} diff --git a/src/abstract/RainDeployBroadcast.sol b/src/abstract/RainDeployBroadcast.sol new file mode 100644 index 0000000..723d74b --- /dev/null +++ b/src/abstract/RainDeployBroadcast.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; + +import {DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; + +/// @title RainDeployBroadcast +/// @notice The broadcast, inherited rather than rewritten per repo. Selects one +/// suite by `DEPLOYMENT_SUITE` and deploys it through the Zoltu factory. +/// +/// A deploy repo's whole script becomes the declaration plus this: +/// +/// ```solidity +/// contract Deploy is MyDeploySuites, RainDeployBroadcast {} +/// ``` +/// +/// The declaration it inherits is the SAME one its verification contracts +/// inherit. That is the point of putting it here: a repo that wrote its suites +/// out twice could broadcast one contract while its tests verified another and +/// stay green, because nothing would connect the two lists. There is one list. +/// +/// ## A registry, not a branch +/// +/// One suite is a degenerate case. `st0x.deploy` broadcasts ten, and its script +/// is ten `else if` arms of identical shape differing only in their arguments, +/// with the valid keys restated in a revert string that nothing keeps in step +/// with the arms. Here the keys and the arms are the same array, so adding a +/// suite is adding an entry and the failure message follows from the registry +/// rather than from a string somebody remembered to update. +/// +/// ## What is NOT derived, and why +/// +/// The recorded address and code hash are passed to `deployAndBroadcast`, not +/// derived from the creation code here. They are derivable — that is exactly +/// what `RainDeployVerifySnapshot` derives them for — but deriving them at +/// broadcast time would defeat the check that matters most at broadcast time. +/// `LibRainDeploy.deployToNetworks` compares the recorded address against the +/// address the creation code derives BEFORE it forks anything, precisely so a +/// stale pin fails instead of silently deploying somewhere the repo's constants +/// do not describe. Feeding it a derived value would make that comparison +/// derived-against-derived, and a guard that compares a value to itself is not +/// a guard. +/// +/// The artifact path is declared too. `src/concrete/.sol:` holds +/// only for the flattest repos; `st0x.deploy` groups concretes under +/// `deploy/` and `authorize/`, so a convention would be wrong for most of its +/// suites. +abstract contract RainDeployBroadcast is RainDeploySuitesBase, Script { + /// The networks to broadcast to. Every supported network by default, which + /// is what a deterministic deployment usually wants: one address, every + /// chain, in one dispatch. + /// + /// Overridable because that is not universal. A repo bootstrapping onto one + /// chain at a time — `st0x.deploy` selects between Ethereum and HyperEVM + /// per dispatch — returns a single-element list from its own env var + /// instead. The reusable workflow already carries a `network:` input for + /// exactly this. + /// @return The network names to deploy to, as `[rpc_endpoints]` aliases. + function deployNetworks() internal view virtual returns (string[] memory) { + return LibRainDeploy.supportedNetworks(); + } + + /// Broadcasts the suite `DEPLOYMENT_SUITE` names. + /// + /// The suite is resolved before the key is read, so a mistyped suite fails + /// in seconds listing the valid ones rather than failing on a missing + /// `DEPLOYMENT_KEY` and sending the reader after the wrong thing. + /// + /// One suite per run, deliberately. Deploying through a shared factory in a + /// single run couples every deployment to the others' success, and a suite + /// that depends on another needs that other to be on chain first — which + /// `dependencies` enforces per network, and which a caller satisfies by + /// dispatching in order. + function run() external { + DeploySuite memory suite = suiteByName(vm.envOr("DEPLOYMENT_SUITE", string(""))); + + uint256 deployerPrivateKey = vm.envUint("DEPLOYMENT_KEY"); + + LibRainDeploy.deployAndBroadcast( + vm, + deployNetworks(), + deployerPrivateKey, + suite.creationCode, + suite.artifactPath, + suite.storedDeployedAddress, + suite.storedBytecodeHash, + suite.dependencies + ); + } +} diff --git a/src/abstract/RainDeploySuitesBase.sol b/src/abstract/RainDeploySuitesBase.sol new file mode 100644 index 0000000..44bc2fe --- /dev/null +++ b/src/abstract/RainDeploySuitesBase.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// Thrown when two suites share a key. The key selects what gets broadcast, so +/// a duplicate makes the selection ambiguous and one of the two unreachable. +/// @param suite The key declared more than once. +error DuplicateDeploySuite(string suite); + +/// Thrown when `DEPLOYMENT_SUITE` names no declared suite. Carries the valid +/// keys, because the whole point of a registry is that the answer is not one +/// hardcoded string the caller has to already know. +/// @param requested The key that was asked for. +/// @param validSuites The declared keys, comma separated. +error UnknownDeploymentSuite(string requested, string validSuites); + +/// One deployable unit: a named snapshot of one contract. +/// +/// `creationCode` is the ONLY input. The Zoltu factory is `CREATE2` over its +/// calldata under a zero salt, so the deploy address is a pure function of it +/// and identical on every network, and running it once locally yields the +/// runtime code and its hash. The address, code hash and runtime code recorded +/// beside it are checked OUTPUTS, which is what the verification abstracts +/// check them as. +/// +/// They are recorded rather than derived on purpose. `LibRainDeploy` compares +/// the recorded address against the creation code before it forks anything, so +/// a snapshot whose pins have gone stale fails BEFORE broadcasting rather than +/// deploying to wherever the code happens to land. Deriving them here would +/// make that comparison derived-against-derived, and a guard that compares a +/// value to itself is not a guard. +struct DeploySuite { + /// The key. Unique across every suite a repo declares: it is what + /// `DEPLOYMENT_SUITE` selects for broadcasting, and the label every + /// verification error names. + /// + /// A repo with one contract and several frozen releases gives each release + /// its own key, because each is separately deployable — a chain added after + /// a release is exactly the case where an OLD snapshot has to be broadcast + /// on its own. + string suite; + /// The creation code this suite is a snapshot of. The only parameter. + /// + /// A frozen `CREATION_CODE` constant for a released snapshot, or + /// `type(X).creationCode` where nothing is frozen yet. Frozen matters: a + /// released suite broadcasts the exact bytes its audit covered, whatever + /// the current source now compiles to. + bytes creationCode; + /// The deploy address recorded for this suite. + address storedDeployedAddress; + /// The deployed code hash recorded for this suite. + bytes32 storedBytecodeHash; + /// The runtime code recorded for this suite. A frozen `RUNTIME_CODE` + /// constant, or `type(X).runtimeCode` where nothing is frozen yet. + bytes storedRuntimeCode; + /// `:`, for the explorer verification command. + /// + /// Declared rather than derived from the contract name. `src/concrete/` + /// holds only the flattest repos; a repo that groups concretes into + /// subdirectories has paths no naming convention recovers. + string artifactPath; + /// Addresses that MUST already have code on a network before this suite is + /// broadcast there. Ordinarily other suites' recorded addresses: a + /// constructor that bakes in a beacon, or a fallback that delegatecalls a + /// facet, silently produces a broken deployment if its target is absent. + address[] dependencies; +} + +/// The rolling candidate: the snapshot that tracks current source rather than a +/// frozen release, paired with the current source's creation code it MUST +/// equal. +/// +/// This pairing is the ONLY thing that catches a snapshot of the wrong +/// contract. Every check internal to a snapshot is satisfied by a consistent +/// snapshot of the wrong thing, so without an anchor to source there is nothing +/// that says the recorded bytes belong to the contract this repo compiles. +/// +/// It is deliberately absent from `DeploySuite` and therefore from released +/// suites: a released tag is MEANT to diverge from current source, so anchoring +/// one to source would fail on every release that is not the newest. That is a +/// property of the assertion, not an opt-out — there is no way for a caller to +/// spell "released, and also skip the checks that do apply". +struct DeployCandidate { + /// The candidate's own recorded snapshot, checked exactly as any other. + DeploySuite snapshot; + /// `type(X).creationCode` for the contract the candidate claims to be. + bytes sourceCreationCode; +} + +/// @title RainDeploySuitesBase +/// @notice The ONE declaration of what a repo deploys, consumed by both the +/// broadcasting abstract and the verification abstracts. +/// +/// That sharing is the point. A repo that declared its suites twice — once for +/// the deploy script, once for the verification tests — could broadcast one +/// contract while verifying another and have every test pass, because nothing +/// would connect the two lists. Here there is one list, so the deployment and +/// the thing checked against the chain cannot disagree: not because it is +/// checked, but because there is nothing to disagree with. +/// +/// A repo overrides `releasedSuites` and `candidateSuite` on one abstract +/// contract and inherits that into its deploy script and its test contracts. +/// Nothing else is per suite, and nothing anywhere is per network. +abstract contract RainDeploySuitesBase { + /// Every FROZEN released suite, in any order. A released snapshot is + /// immutable: its recorded bytes describe a deployment that already + /// happened, so it is never regenerated and never anchored to current + /// source. + /// @return The released suites. + function releasedSuites() internal pure virtual returns (DeploySuite[] memory); + + /// The rolling candidate — the snapshot describing what this repo compiles + /// right now. Required rather than optional: a deploy repo always compiles + /// a current source, so there is always something for the source-anchored + /// check to anchor to, and making it optional would let the only check that + /// catches a wrong-contract snapshot be silently skipped. + /// @return The candidate. + function candidateSuite() internal pure virtual returns (DeployCandidate memory); + + /// Every suite this repo declares: the released ones followed by the + /// candidate. This is the verification set and the deploy registry, which + /// are the same set because they are the same declaration. + /// + /// Keys are checked unique here rather than anywhere more specific, so both + /// sides pay for the check and neither can be handed an ambiguous registry. + /// @return suites Every declared suite. + function allSuites() internal pure returns (DeploySuite[] memory suites) { + DeploySuite[] memory released = releasedSuites(); + suites = new DeploySuite[](released.length + 1); + for (uint256 i = 0; i < released.length; i++) { + suites[i] = released[i]; + } + suites[released.length] = candidateSuite().snapshot; + + for (uint256 i = 0; i < suites.length; i++) { + for (uint256 j = i + 1; j < suites.length; j++) { + if (keccak256(bytes(suites[i].suite)) == keccak256(bytes(suites[j].suite))) { + revert DuplicateDeploySuite(suites[i].suite); + } + } + } + } + + /// Every declared key, comma separated, for the unknown-suite error. + /// @return names The declared keys. + function suiteNames() internal pure returns (string memory names) { + DeploySuite[] memory suites = allSuites(); + for (uint256 i = 0; i < suites.length; i++) { + names = i == 0 ? suites[i].suite : string.concat(names, ", ", suites[i].suite); + } + } + + /// The suite a key selects. + /// + /// Iterating the registry rather than branching on a hash: a repo adds a + /// suite by adding an array entry, and the set of valid keys the failure + /// reports follows from the same array rather than from a string somebody + /// remembered to update. + /// @param requested The key to select, from `DEPLOYMENT_SUITE`. + /// @return The selected suite. + function suiteByName(string memory requested) internal pure returns (DeploySuite memory) { + DeploySuite[] memory suites = allSuites(); + bytes32 requestedHash = keccak256(bytes(requested)); + for (uint256 i = 0; i < suites.length; i++) { + if (keccak256(bytes(suites[i].suite)) == requestedHash) { + return suites[i]; + } + } + revert UnknownDeploymentSuite(requested, suiteNames()); + } +} diff --git a/src/abstract/RainDeployVerifyBase.sol b/src/abstract/RainDeployVerifyBase.sol new file mode 100644 index 0000000..dc62f0d --- /dev/null +++ b/src/abstract/RainDeployVerifyBase.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; + +import {DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; + +/// Thrown when the pure `LibRainDeploy.zoltuAddress` formula and an actual +/// deploy through the etched Zoltu factory bytecode disagree about where a +/// creation code lands. Both are `LibRainDeploy`'s, so this is a defect in the +/// library rather than in any snapshot, and it invalidates every derivation +/// made from it. +/// @param suite The suite whose creation code was being derived. +/// @param formulaAddress The address `zoltuAddress` computed. +/// @param factoryAddress The address the factory bytecode actually deployed to. +error ZoltuDerivationMismatch(string suite, address formulaAddress, address factoryAddress); + +/// Thrown when the state snapshot taken around a derivation could not be +/// reverted. The derivation plants code at the derived address and clears its +/// nonce; if that cannot be undone, every later derivation reads state this one +/// created, and the chain-anchored checks compare a locally planted deployment +/// against itself. There is no safe way to continue. +/// @param suite The suite being derived when the revert failed. +/// @param snapshotId The snapshot that could not be reverted. +error DerivationSnapshotRevertFailed(string suite, uint256 snapshotId); + +/// What a suite's creation code derives, by itself. Computed +/// once and then compared against whatever claims to hold it, whether that is a +/// recorded constant or a live chain. +struct DerivedDeploy { + /// The suite the derivation came from. + string suite; + /// The address the creation code deploys to, on every network. + address deployedAddress; + /// The code hash the creation code leaves behind at that address. + bytes32 bytecodeHash; +} + +/// @title RainDeployVerifyBase +/// @notice The derivation every deploy-verification group shares: from a +/// suite's creation code to the (address, code hash) it produces, in one place +/// rather than restated per suite and per chain. +/// +/// The suites themselves come from `RainDeploySuitesBase`, which is the SAME +/// declaration `RainDeployBroadcast` deploys from. Verification and deployment +/// therefore cannot describe different things. +/// +/// This is not inherited directly. `RainDeployVerifySnapshot` and +/// `RainDeployVerifyChain` each inherit it and contribute the checks that need +/// no network and the checks that do, respectively. A repo inherits its +/// declaration into one of each, so running the snapshot checks never touches +/// an RPC endpoint — an outage is then a failure of one contract that plainly is +/// about the chain, and can never be confused with, or take down, the +/// snapshot assertions. +/// +/// ## Chain-independent runtime code is a requirement, not a caveat +/// +/// A single `storedBytecodeHash` per suite can only be true if the runtime +/// code is the same on every network. A constructor that reads `block.chainid`, +/// or anything else that varies per chain, produces a different code hash per +/// chain and cannot be described by these snapshots at all. Deploying through +/// Zoltu buys address predictability; a constructor that reads chain state +/// spends it. So a per-chain code hash difference is a DEFECT in the contract, +/// reported as a hard failure naming the chain and both hashes, and there is +/// deliberately no per-chain code hash to record. +abstract contract RainDeployVerifyBase is RainDeploySuitesBase, Test { + /// Derives what a suite's creation code deploys to, from the creation + /// code alone. + /// + /// The address comes from the pure `LibRainDeploy.zoltuAddress` formula and + /// the code hash from actually running the creation code through the Zoltu + /// factory bytecode locally, because the code hash cannot be known without + /// executing the constructor. The two are cross-checked against each other, + /// so a formula that drifted from the factory bytecode is caught here + /// rather than silently poisoning every downstream comparison. + /// + /// The whole derivation runs inside a state snapshot that is reverted, and + /// clears the derived address first, so that it reads ONLY what the + /// creation code produces. Both matter: + /// + /// - Two suites can legitimately share creation code (a release that + /// changed nothing that compiles), and `CREATE2` to an occupied address + /// fails. Clearing makes the second derivation work, and reverting means + /// the first never occupied it in the first place. + /// - The local deploy must not survive into the chain-anchored checks. A + /// locally deployed contract that leaked into a fork would be compared + /// against itself, and every chain would pass whether or not anything is + /// deployed there. + /// @param suite The suite to derive from. + /// @return derived The address and code hash the creation code produces. + function deriveDeployment(DeploySuite memory suite) internal returns (DerivedDeploy memory derived) { + address formulaAddress = LibRainDeploy.zoltuAddress(suite.creationCode); + + uint256 snapshotId = vm.snapshotState(); + + // Whatever is at the derived address is not part of the derivation. + // The nonce goes too: `CREATE2` collides on a non-zero nonce as well as + // on non-empty code. + vm.etch(formulaAddress, hex""); + vm.resetNonce(formulaAddress); + + LibRainDeploy.etchZoltuFactory(vm); + address factoryAddress = LibRainDeploy.deployZoltu(suite.creationCode); + if (factoryAddress != formulaAddress) { + revert ZoltuDerivationMismatch(suite.suite, formulaAddress, factoryAddress); + } + + derived = + DerivedDeploy({suite: suite.suite, deployedAddress: formulaAddress, bytecodeHash: factoryAddress.codehash}); + + // A failed revert is unrecoverable, not a warning to silence. The etch + // and the nonce reset would survive into every later derivation, whose + // results would then look entirely plausible while describing state + // this call planted. + if (!vm.revertToState(snapshotId)) { + revert DerivationSnapshotRevertFailed(suite.suite, snapshotId); + } + } + + /// Derives every suite once, before anything forks. Callers that compare + /// against chains need the derivation to have already happened on a local + /// EVM, because on a fork the derived address is exactly the address the + /// deployment under test occupies. + /// @param suites The suites to derive. + /// @return derived The derivation of each, positionally paired. + function deriveDeployments(DeploySuite[] memory suites) internal returns (DerivedDeploy[] memory derived) { + derived = new DerivedDeploy[](suites.length); + for (uint256 i = 0; i < suites.length; i++) { + derived[i] = deriveDeployment(suites[i]); + } + } +} diff --git a/src/abstract/RainDeployVerifyChain.sol b/src/abstract/RainDeployVerifyChain.sol new file mode 100644 index 0000000..ce84877 --- /dev/null +++ b/src/abstract/RainDeployVerifyChain.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; + +/// Thrown when a version's derived address has no code on a network. Either it +/// never deployed there, or it is not there any more. +/// @param network The network name, as configured in `[rpc_endpoints]`. +/// @param suite The suite that is missing. +/// @param deployedAddress The address that should hold it. +error NotDeployedOnNetwork(string network, string suite, address deployedAddress); + +/// Thrown when a version's derived address holds code that is not the code its +/// creation code produces. +/// +/// This is also what a chain-dependent runtime code looks like: a constructor +/// that reads `block.chainid` or similar deploys different code per network, +/// so one network disagrees while others pass. That is a defect in the +/// contract, not a shortcoming of a single recorded hash — hence a hard failure +/// naming the chain and both hashes, rather than a per-chain hash to record. +/// @param network The network name, as configured in `[rpc_endpoints]`. +/// @param suite The suite that failed. +/// @param deployedAddress The address checked. +/// @param expectedCodeHash The code hash the version's creation code produces. +/// @param actualCodeHash The code hash actually found on this network. +error CodeHashMismatchOnNetwork( + string network, string suite, address deployedAddress, bytes32 expectedCodeHash, bytes32 actualCodeHash +); + +/// @title RainDeployVerifyChain +/// @notice The only deploy-pin assertions anchored to something outside the +/// repo: across every network in `LibRainDeploy.supportedNetworks()`, every +/// RELEASED suite's derived address carries code with its derived code hash. +/// +/// This is the only group that can catch a suite that never deployed to a +/// network, or that is not there any more. Neither is a fact the repo can hold: +/// both can go false with nobody touching it — a release that reached four +/// chains of five, a chain added to `supportedNetworks()` after a release that +/// therefore never got it, a deploy that silently failed. +/// +/// ## Released only, for the same reason source anchors the candidate only +/// +/// A release IS a deployment that happened. That is what its recorded bytes +/// describe and why they are frozen, so "it is on every network" is a claim +/// about it that is either true or a defect. The candidate is what the NEXT +/// release will be: between releases it is ordinarily ahead of anything on +/// chain, and a repo whose source has moved since its last deploy is the +/// normal state of a repo, not a fault in it. Demanding the candidate be live +/// asserts something false by design. +/// +/// The two exemptions are the same shape from opposite ends — +/// `RainDeployVerifySnapshot` anchors the candidate to source and never a +/// release, because a release is meant to have diverged from source; this +/// anchors releases to the chain and never the candidate, because the +/// candidate is meant to be ahead of the chain. Neither is a field a caller +/// can set: there is nothing to opt a suite into or out of. +/// +/// That puts the whole weight on `releasedSuites()` naming every release. A +/// frozen tag missing from it would be a release nothing here is ever handed, +/// and a check that is never handed a subject cannot fail on it — so +/// `RainDeployVerifySnapshot` checks that declaration against the append-only +/// `src/generated//` record. Without that, scoping to releases would be a +/// way to make this contract quiet rather than correct. +/// +/// The matrix is suites by networks and is generated from both, so a new +/// network leaves no suite unchecked and a new release is checked on every +/// network from the moment it is declared. There are deliberately no per-chain +/// or per-suite functions to add. +/// +/// It compares against the DERIVED code hash rather than the recorded one, so +/// the creation code stays the only parameter. `RainDeployVerifySnapshot` is +/// what ties the derivation back to the recorded constants; the two together +/// say the recorded set describes what is actually live. +/// +/// Kept in its own contract, away from every assertion about the snapshot, so +/// an unreachable RPC endpoint fails only this. It cannot take down the +/// snapshot checks with it, and its failures are legible: a fork that cannot be created +/// is an outage, while `NotDeployedOnNetwork` from a fork that was created is a +/// missing deployment. A contract boundary is what `forge test +/// --match-contract` and a CI job select at, and it is structural rather than +/// conventional — nothing reachable from the snapshot contract forks anything. +abstract contract RainDeployVerifyChain is RainDeployVerifyBase { + /// Checks one derived suite against whichever network is currently + /// selected. + /// @param network The network name, for the error only. + /// @param derived The derivation to check for. + function checkDeployedOnNetwork(string memory network, DerivedDeploy memory derived) internal view { + if (derived.deployedAddress.code.length == 0) { + revert NotDeployedOnNetwork(network, derived.suite, derived.deployedAddress); + } + bytes32 actualCodeHash = derived.deployedAddress.codehash; + if (actualCodeHash != derived.bytecodeHash) { + revert CodeHashMismatchOnNetwork( + network, derived.suite, derived.deployedAddress, derived.bytecodeHash, actualCodeHash + ); + } + } + + /// Checks every derived suite against every supported network, forking + /// each network once and checking every suite on it. + /// + /// The derivations are taken as an argument, already computed, because they + /// have to be computed before anything forks: on a fork the derived address + /// is the very address the deployment under test occupies, so deriving + /// there would either collide with it or read it back as its own + /// expectation. + /// @param derived The derivation of every suite to check. + function checkDeployedOnSupportedNetworks(DerivedDeploy[] memory derived) internal { + // Nothing to check is not a reason to touch five RPC endpoints. Forking + // to check nothing turns an outage into the failure of an assertion + // that has no subject, which is the one failure this contract is + // supposed to be legible against. + if (derived.length == 0) { + return; + } + + string[] memory networks = LibRainDeploy.supportedNetworks(); + for (uint256 i = 0; i < networks.length; i++) { + // createSelectFork returns a fork id that is not needed here; bind + // and reference it so the unused-return lint stays satisfied. + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + for (uint256 j = 0; j < derived.length; j++) { + checkDeployedOnNetwork(networks[i], derived[j]); + } + } + } + + /// Every RELEASED suite MUST be live, with the code its creation code + /// produces, on every supported network. + function testSuitesLiveOnEverySupportedNetwork() external { + checkDeployedOnSupportedNetworks(deriveDeployments(releasedSuites())); + } +} diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol new file mode 100644 index 0000000..8c3fe93 --- /dev/null +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; +import {DeployCandidate, DeploySuite} from "./RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; +import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; + +/// Thrown when the deploy address recorded for a version is not the address its +/// own creation code derives. +/// @param suite The suite that failed. +/// @param storedAddress The address the suite records. +/// @param derivedAddress The address its creation code derives. +error StoredAddressMismatch(string suite, address storedAddress, address derivedAddress); + +/// Thrown when the deployed code hash recorded for a version is not the hash +/// its own creation code produces. +/// @param suite The suite that failed. +/// @param storedCodeHash The code hash the suite records. +/// @param derivedCodeHash The code hash its creation code produces. +error StoredCodeHashMismatch(string suite, bytes32 storedCodeHash, bytes32 derivedCodeHash); + +/// Thrown when the runtime code recorded for a version does not hash to the +/// code hash recorded beside it. +/// @param suite The suite that failed. +/// @param storedBytecodeHash The code hash the suite records. +/// @param runtimeCodeHash The hash of the runtime code the suite records. +error StoredRuntimeCodeHashMismatch(string suite, bytes32 storedBytecodeHash, bytes32 runtimeCodeHash); + +/// Thrown when the candidate's recorded creation code is not the creation code +/// this repo currently compiles. Hashes rather than the bytes themselves, which +/// run to tens of kilobytes. +/// @param suite The candidate's key. +/// @param storedCreationCodeHash Hash of the creation code the candidate +/// records. +/// @param sourceCreationCodeHash Hash of `type(X).creationCode` for the +/// contract the candidate claims to be. +error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash); + +/// Thrown when a file in the frozen record is declared by no released suite. +/// The record is append-only, so this never goes away by itself: a release the +/// declaration missed is a release the chain group never asks about, and the +/// chain group passing means nothing for it. +/// @param path The frozen record file no released suite declares. +error FrozenSnapshotNotReleased(string path); + +/// Thrown when a file in the frozen record declares no deployed address. The +/// record holds generated snapshots and nothing else, and `DEPLOYED_ADDRESS` is +/// what makes one the record of a deployment rather than a file that happens to +/// be in a release directory. Distinct from `FrozenSnapshotNotReleased`, which +/// is a declaration that is missing something — this is a record that cannot be +/// read at all, and reporting it as undeclared would send the reader after the +/// wrong thing. +/// @param path The record file with no `DEPLOYED_ADDRESS` declaration. +error FrozenSnapshotUnreadable(string path); + +/// @title RainDeployVerifySnapshot +/// @notice Every deploy-pin assertion that needs no network, for every suite +/// a repo declares. Three groups, which catch different things and are +/// documented as such because it is easy to read the first as covering the +/// second. +/// +/// **Internal to the recorded set.** The address a suite's creation code +/// derives is the address it records, the code hash that creation code produces +/// is the code hash it records, and the runtime code it records hashes to that +/// same code hash. These are real derivations and they catch a set generated +/// inconsistently — a hand-edited constant, a snapshot regenerated for one +/// field and not the others, an address copied from the wrong tag. +/// +/// They CANNOT catch a snapshot of the wrong contract. A consistent snapshot of +/// the wrong thing satisfies all three, because all three only ask the recorded +/// bytes to agree with each other, and the wrong contract's bytes agree with +/// each other perfectly. +/// +/// **Anchored to source.** The candidate's recorded creation code is the +/// creation code this repo compiles. This is the only check in the whole suite +/// that catches a snapshot of the wrong contract, and it applies to the +/// candidate alone: a released tag is meant to have diverged from current +/// source, so anchoring one to source asserts something that is false by +/// design. +/// +/// **Anchored to the record.** Every file in the frozen record — the +/// append-only `src/generated//` directories — is declared by a released +/// suite. This is the one check that is about the DECLARATION rather than about +/// what a declared suite records, and it exists because everything anchored to +/// a chain reads `releasedSuites()`, which is a separate file from the record +/// it describes. A release missing from it is not caught anywhere else, by +/// anything: it simply stops being checked, and every check there is stays +/// green. +/// +/// None of the three can catch a suite that was never deployed, or that is no +/// longer deployed. Only `RainDeployVerifyChain` can, and nothing here is a +/// substitute for it — but the record check is what makes its scope complete, +/// because a release it is never handed is a release it cannot fail on. +abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { + /// Checks one suite against itself: derive from its creation code, then + /// require everything it records to agree with the derivation. + /// @param suite The suite to check. + function checkInternallyConsistent(DeploySuite memory suite) internal { + DerivedDeploy memory derived = deriveDeployment(suite); + + if (suite.storedDeployedAddress != derived.deployedAddress) { + revert StoredAddressMismatch(suite.suite, suite.storedDeployedAddress, derived.deployedAddress); + } + + if (suite.storedBytecodeHash != derived.bytecodeHash) { + revert StoredCodeHashMismatch(suite.suite, suite.storedBytecodeHash, derived.bytecodeHash); + } + + bytes32 runtimeCodeHash = keccak256(suite.storedRuntimeCode); + if (suite.storedBytecodeHash != runtimeCodeHash) { + revert StoredRuntimeCodeHashMismatch(suite.suite, suite.storedBytecodeHash, runtimeCodeHash); + } + } + + /// @dev The declaration a generated snapshot records its deploy address in. + /// Matched whole and from the START of its line, so what is being looked + /// for is the DECLARATION: it cannot be satisfied by characters that happen + /// to occur inside a hex payload, nor by a line that merely CONTAINS the + /// declaration text — a commented-out copy carrying some other address is + /// exactly the hand edit this whole group exists to catch, and it is at + /// file scope in every generated snapshot, so there is no indentation to + /// allow for. + string constant DEPLOYED_ADDRESS_DECLARATION = "address constant DEPLOYED_ADDRESS ="; + + /// The address a frozen record declares as its deploy address. + /// + /// `LibCodeGen` emits an address constant on ONE line — wrapping needs 120 + /// characters and this declaration occupies 88 — so the declaration is a + /// line, and its value is that line's last token with the type wrapper and + /// the terminator stripped. `address(0x...);` and a bare `0x...;` read the + /// same, so which wrapper the generator chose is not something this has to + /// know. + /// + /// That every generated snapshot HAS this declaration, second, of type + /// `address`, is pinned by `GeneratedSnapshotShapeTest` against the + /// compiler's own AST. Read from the text here rather than from that AST + /// because a record is reached by its PATH, which is what the walk returns, + /// while its artifact path is not something a caller can name — foundry + /// disambiguates those by whatever else happens to share the basename. + /// @param path The record file, for the error only. + /// @param record The record file's contents. + /// @return The address the record declares. + function recordedDeployedAddress(string memory path, string memory record) internal pure returns (address) { + string[] memory lines = vm.split(record, "\n"); + for (uint256 i = 0; i < lines.length; i++) { + if (vm.indexOf(lines[i], DEPLOYED_ADDRESS_DECLARATION) != 0) { + continue; + } + string[] memory tokens = vm.split(lines[i], " "); + string memory literal = tokens[tokens.length - 1]; + return vm.parseAddress(vm.replace(vm.replace(vm.replace(literal, "address(", ""), ")", ""), ";", "")); + } + revert FrozenSnapshotUnreadable(path); + } + + /// Checks the frozen record against the released declaration: every file in + /// the record is declared by a released suite. + /// + /// `releasedSuites()` is a generated file, and everything anchored to a + /// chain reads it. A frozen tag it does not name is therefore not a missing + /// entry that shows up as a failure somewhere — it is a release that drops + /// out of every check there is, silently and permanently, while the whole + /// suite stays green. The record is the only thing that can say it + /// happened, so the declaration is checked against the record. + /// + /// Emitting the declaration from the record is what makes the two agree in + /// the first place. This is what catches the ways they still come apart: a + /// hand edit to the generated file, a record directory that arrived out of + /// band, and a generated file nobody regenerated after the record moved. + /// Nothing in CI regenerates anything, so a stale generated file is caught + /// here or not at all. + /// + /// Matched against the RELEASED suites alone, deliberately. A release and + /// the rolling candidate are byte-identical from the moment the release is + /// cut until source next moves, so a match against every declared suite + /// would let the candidate declare a frozen release — and the candidate is + /// exactly what the chain group does not check. + /// + /// The match is by address: the address a file DECLARES against the address + /// a suite's creation code DERIVES. The derived side is a pure function of + /// the creation code, so a suite whose creation code derives the address a + /// file records IS that file's release. + /// + /// Nothing is matched by name, which would assert only that a convention + /// was followed. Nothing is matched by searching the file's text either: a + /// record is mostly two hex payloads thousands of digits long, and an + /// address that merely OCCURS somewhere in one of them says nothing about + /// what the file records. + /// @param paths The frozen record's files. + /// @param released The declared released suites. + function checkFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) internal view { + for (uint256 i = 0; i < paths.length; i++) { + address recorded = recordedDeployedAddress(paths[i], vm.readFile(paths[i])); + + bool declared = false; + for (uint256 j = 0; j < released.length; j++) { + if (recorded == LibRainDeploy.zoltuAddress(released[j].creationCode)) { + declared = true; + break; + } + } + + if (!declared) { + revert FrozenSnapshotNotReleased(paths[i]); + } + } + } + + /// Checks the candidate against the source this repo compiles. + /// @param candidate The candidate to check. + function checkAnchoredToSource(DeployCandidate memory candidate) internal pure { + if (keccak256(candidate.snapshot.creationCode) != keccak256(candidate.sourceCreationCode)) { + revert CandidateSourceMismatch( + candidate.snapshot.suite, + keccak256(candidate.snapshot.creationCode), + keccak256(candidate.sourceCreationCode) + ); + } + } + + /// Every declared suite MUST be internally consistent: what it records is + /// what its own creation code derives. + function testSnapshotInternallyConsistent() external { + DeploySuite[] memory suites = allSuites(); + for (uint256 i = 0; i < suites.length; i++) { + checkInternallyConsistent(suites[i]); + } + } + + /// The candidate MUST be a snapshot of the contract this repo compiles, not + /// of some other contract that happens to be internally consistent. + function testSnapshotMatchesSource() external pure { + checkAnchoredToSource(candidateSuite()); + } + + /// Every release in the frozen record MUST be declared, so that the set the + /// chain group checks is every release this repo has ever cut rather than + /// the ones somebody remembered to list. + function testEveryFrozenSnapshotIsReleased() external view { + checkFrozenSnapshotsReleased( + LibRainDeploySnapshot.frozenSnapshotPaths(vm, LibRainDeploySnapshot.LIB_FS_ROOT), releasedSuites() + ); + } +} diff --git a/src/concrete/AddressRegistry.sol b/src/concrete/AddressRegistry.sol new file mode 100644 index 0000000..33a44a7 --- /dev/null +++ b/src/concrete/AddressRegistry.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IAddressRegistryV1} from "../interface/IAddressRegistryV1.sol"; + +/// @dev The only account that may bind a name. A compile-time constant rather +/// than storage, so it can never be rotated, and part of the creation code, so +/// changing it changes the deterministic deploy address and code hash of +/// `AddressRegistry` on every network. +/// +/// Zero during rollout. Nothing calls from the zero address, so no name can be +/// bound while root is zero, and `get` reverts on every name that is not bound +/// — so a registry compiled under this root answers every read with a revert +/// and cannot answer one with an address. There is no state in which a consumer +/// silently resolves something wrong from it: a zero root makes the registry +/// inert, loudly, in every direction. +/// +/// Setting a real root is an ordinary source change. It moves the creation +/// code, and therefore the deploy address, the code hash, the snapshot +/// `script/Build.sol` generates and the release that carries them. +address constant ADDRESS_REGISTRY_ROOT = address(0); + +/// @title AddressRegistry +/// @notice The whole of `IAddressRegistryV1`: an immutable root authority binds +/// a `bytes32` name, anyone reads a bound name, and a read of an unbound name +/// reverts. +/// +/// There is deliberately nothing else. No removal, no upgrade, no pause, and no +/// authority besides root — root is a compile-time constant, so it cannot even +/// hand itself over. +/// +/// A binding is mutable because the address it names is. Rotating an owning +/// multisig is ordinary business and has to be expressible without moving +/// anybody's deterministic address, which a binding welded to one address +/// forever would make impossible: the name is in the consumer's creation code, +/// so a new name means new creation code and a new address. +/// +/// Mutability costs nothing already deployed. A consumer resolves a name once, +/// in its constructor, and never reads the registry again, so re-binding a name +/// changes what the next deployment resolves and nothing else — a rotation is a +/// deliberate migration, never a silent change to live contracts. +/// +/// The storage mapping is `internal` rather than `public`: a public mapping's +/// generated getter answers an unbound name with the zero address, which is +/// exactly the silent failure `get` reverts to prevent. +contract AddressRegistry is IAddressRegistryV1 { + /// The bindings. Not `public`: the only reader is `get`, which reverts on an + /// unbound name. A name maps to the zero address if and only if it is + /// unbound, which is why `register` rejects the zero address. + mapping(bytes32 name => address account) internal sAddresses; + + /// @inheritdoc IAddressRegistryV1 + function register(bytes32 name, address account) external { + if (msg.sender != ADDRESS_REGISTRY_ROOT) { + revert NotRoot(msg.sender); + } + // Rejected so that a name can never be both bound and unreadable. There + // is deliberately no way to unbind a name; the nearest thing is + // re-binding it to something inert. + if (account == address(0)) { + revert ZeroAccount(name); + } + sAddresses[name] = account; + emit Register(name, account); + } + + /// @inheritdoc IAddressRegistryV1 + /// @dev Returns whatever root has bound most recently. A caller that needs + /// an answer that cannot move reads once and stores it, which is what a + /// consumer resolving a name in its constructor does. + function get(bytes32 name) external view returns (address account) { + account = sAddresses[name]; + if (account == address(0)) { + revert NameNotRegistered(name); + } + } +} diff --git a/src/generated/candidate/AddressRegistry.sol b/src/generated/candidate/AddressRegistry.sol new file mode 100644 index 0000000..962c396 --- /dev/null +++ b/src/generated/candidate/AddressRegistry.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0xef835570415a69bdf98ea5cacd8c4d2caba4730d06c2218bf102cb4473f4ea73); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0x25aC2b82915f191dbE64e65BAeDDD68b97b68fe1); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052348015600e575f80fd5b506102558061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b6100463660046101f8565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b61008761008236600461020f565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b331561012b576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff811661017b576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff85169081179091559051909184917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a35050565b5f60208284031215610208575f80fd5b5035919050565b5f8060408385031215610220575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811461024a575f80fd5b80915050925092905056"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b6100463660046101f8565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b61008761008236600461020f565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b331561012b576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff811661017b576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff85169081179091559051909184917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a35050565b5f60208284031215610208575f80fd5b5035919050565b5f8060408385031215610220575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811461024a575f80fd5b80915050925092905056"; diff --git a/src/interface/IAddressRegistryV1.sol b/src/interface/IAddressRegistryV1.sol new file mode 100644 index 0000000..84c925a --- /dev/null +++ b/src/interface/IAddressRegistryV1.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @title IAddressRegistryV1 +/// @notice A registry of `bytes32` names to addresses with exactly two +/// operations: an immutable root authority binds a name (`register`), and +/// anyone reads a bound name (`get`). Root may re-bind a name it has already +/// bound. There is no removal, no upgrade and no authority beyond root, and an +/// implementation MUST NOT add any. +/// +/// Names are opaque 32-byte values. This interface says nothing about how a +/// name is derived — hashed from a string, a raw ASCII literal, a counter — and +/// an implementation MUST NOT constrain it. Two callers agreeing on a name is +/// entirely their business. +/// +/// Bindings are mutable because the addresses they name are. Rotating an owning +/// multisig is ordinary business, and a binding that could never change would +/// make it impossible: the name a consumer resolves is in that consumer's +/// creation code, so a name welded to one address forever would force a new +/// name — and therefore new creation code and a new deterministic address — for +/// a routine rotation. That is the problem the registry exists to remove, not a +/// property worth keeping. +/// +/// A binding moving never moves anything already deployed. A consumer resolves +/// a name once, at construction, and stores the answer; it never consults the +/// registry again. So re-binding a name changes what the *next* deployment +/// resolves and nothing else, which is exactly what makes a rotation a +/// deliberate migration rather than a silent, retroactive change to live +/// contracts. +/// +/// A compromised root therefore cannot touch anything deployed. It can point a +/// name at an address it controls, so that a deployment made after the +/// compromise snapshots that address. That is caught by verifying a deployment +/// after deploying it and before anything depends on it: the deployed contract +/// has already snapshotted the value, so checking it is checking settled state. +/// A poisoned deploy is a burned deterministic address, discovered before use. +interface IAddressRegistryV1 { + /// Thrown when an account that is not the root authority calls `register`. + /// @param sender The `msg.sender` that was not root. + error NotRoot(address sender); + + /// Thrown when `register` is called with the zero address. The zero address + /// is how an unbound name reads, so binding it would produce a name that is + /// both bound and unreadable. A name cannot be unbound once bound; the + /// closest thing is binding it somewhere deliberately inert. + /// @param name The name that was being bound to the zero address. + error ZeroAccount(bytes32 name); + + /// Thrown by `get` when a name has never been bound, so that a caller + /// cannot silently proceed on the zero address by forgetting to check. + /// @param name The name that is not bound. + error NameNotRegistered(bytes32 name); + + /// Emitted every time `name` is bound, including when it is re-bound. The + /// log is the complete history of the registry and the only way to discover + /// a binding without already knowing the name; the most recent `Register` + /// for a name is its current binding. + /// @param name The name that was bound. + /// @param account The address `name` was bound to. + event Register(bytes32 indexed name, address indexed account); + + /// Binds `name` to `account`, replacing any address it is already bound to. + /// + /// The implementation MUST revert `NotRoot` unless the caller is the root + /// authority, and MUST revert `ZeroAccount` if `account` is the zero + /// address. On success it MUST emit `Register`. + /// @param name The name to bind. + /// @param account The address to bind it to. + function register(bytes32 name, address account) external; + + /// The address `name` is currently bound to. + /// + /// The implementation MUST revert `NameNotRegistered` when `name` is + /// unbound, rather than returning the zero address, so that no caller has + /// to remember to check. It MUST NOT expose any other reader that returns + /// the zero address for an unbound name, as that reintroduces exactly the + /// mistake this reverting read exists to prevent. + /// + /// A caller that needs an answer that cannot move MUST read once and store + /// the result, which is what a consumer resolving a name in its constructor + /// does. Reading at the point of use instead means reading whatever root + /// has bound most recently. + /// @param name The name to read. + /// @return account The address bound to `name`. Never the zero address. + function get(bytes32 name) external view returns (address account); +} diff --git a/src/lib/LibAddressRegistry.sol b/src/lib/LibAddressRegistry.sol new file mode 100644 index 0000000..6d53509 --- /dev/null +++ b/src/lib/LibAddressRegistry.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {IAddressRegistryV1} from "../interface/IAddressRegistryV1.sol"; +import {LibAddressRegistryDeploy} from "./LibAddressRegistryDeploy.sol"; + +/// @title LibAddressRegistry +/// @notice Reads the `AddressRegistry` deployed at a single deterministic +/// address on every network, verifying the registry's code hash first, exactly +/// as `LibRainDeploy` verifies `ZOLTU_FACTORY_CODEHASH` before using the Zoltu +/// factory. An address alone says nothing on a chain the caller has not +/// audited; the address plus the code hash says the caller is talking to the +/// registry it compiled against. +/// +/// That is the whole library. It resolves a name to an address. What a consumer +/// resolves a name for, and when — an owner set in a constructor or an +/// initializer, under `Ownable` or RBAC or nothing at all — is entirely the +/// consumer's business and none of this library's. +/// +/// Bindings are mutable, so `resolve` answers with whatever root has bound most +/// recently. A caller that needs an answer that cannot move afterwards resolves +/// once, in its constructor, and stores the result; it must not re-read at the +/// point of use. That single read at construction is what makes a deployment +/// verifiable after the fact: the value is settled the moment the contract +/// exists, and no later re-binding can move it. +library LibAddressRegistry { + /// Thrown when the code at the registry address is not the registry this + /// library was compiled against. An address with no code hits this too: an + /// empty account's code hash is zero, never the expected value. + /// @param expectedCodeHash The code hash of the pinned registry. + /// @param actualCodeHash The code hash actually found at the address. + error UnexpectedAddressRegistryCodeHash(bytes32 expectedCodeHash, bytes32 actualCodeHash); + + /// The address `name` is currently bound to in the registry. + /// + /// Verifies the registry's code hash before reading, so a chain where the + /// registry is absent, or where something else occupies its address, is a + /// loud revert rather than a call into unknown code. The registry itself + /// reverts on an unbound name, so a returned address is always a real + /// binding and is never the zero address. + /// @param name The name to resolve. Opaque; the registry constrains nothing + /// about how it was derived. + /// @return The address bound to `name`. + function resolve(bytes32 name) internal view returns (address) { + bytes32 actualCodeHash = LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS.codehash; + if (actualCodeHash != LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH) { + revert UnexpectedAddressRegistryCodeHash( + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, actualCodeHash + ); + } + return IAddressRegistryV1(LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS).get(name); + } +} diff --git a/src/lib/LibAddressRegistryDeploy.sol b/src/lib/LibAddressRegistryDeploy.sol new file mode 100644 index 0000000..c85e214 --- /dev/null +++ b/src/lib/LibAddressRegistryDeploy.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +import { + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_ADDR, + BYTECODE_HASH as ADDRESS_REGISTRY_HASH +} from "../generated/candidate/AddressRegistry.sol"; + +/// @title LibAddressRegistryDeploy +/// @notice The deterministic Zoltu deploy address and code hash of +/// `AddressRegistry`, aliased from its generated snapshot so that snapshot stays the +/// single source of truth. The import path never moves, so consumers are +/// unaffected by which snapshot it names. +library LibAddressRegistryDeploy { + address constant ADDRESS_REGISTRY_DEPLOYED_ADDRESS = ADDRESS_REGISTRY_ADDR; + bytes32 constant ADDRESS_REGISTRY_DEPLOYED_CODEHASH = ADDRESS_REGISTRY_HASH; +} diff --git a/src/lib/LibAddressRegistryReleased.sol b/src/lib/LibAddressRegistryReleased.sol new file mode 100644 index 0000000..9670d70 --- /dev/null +++ b/src/lib/LibAddressRegistryReleased.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +import {DeploySuite} from "../abstract/RainDeploySuitesBase.sol"; + +/// @title LibAddressRegistryReleased +/// @notice Every frozen release of `AddressRegistry`: one entry per file in +/// the append-only `src/generated//` record, in tag order. +/// +/// The deploy address, code hash, creation code and runtime code of each +/// entry are aliased from that release's own frozen snapshot, so the +/// consensus record is read from the immutable file and from nowhere else. +/// +/// The key, the artifact path and the dependencies are explorer and ordering +/// metadata regenerated from the CURRENT declaration, and are not part of +/// that record. A moved source path retroactively updates every entry's +/// artifact path, which is intended: the alternative is parsing this +/// generated file back in to preserve what it last said. +library LibAddressRegistryReleased { + /// Every frozen release, in tag order. + /// @return suites The released suites. + function releasedSuites() internal pure returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](0); + } +} diff --git a/src/lib/LibRainDeploy.sol b/src/lib/LibRainDeploy.sol index 39c5175..f5769b5 100644 --- a/src/lib/LibRainDeploy.sol +++ b/src/lib/LibRainDeploy.sol @@ -40,6 +40,18 @@ library LibRainDeploy { /// the deploy may have happened before the search range. error DeployedBeforeStartBlock(address target, uint256 startBlock); + /// Thrown when a deployed contract holds an address other than the one the + /// deployment expects, on a network. + error UnexpectedResolvedAddress(string network, address target, uint256 index, address expected, address actual); + + /// Thrown when the read calls and expected addresses of a post-deploy check + /// do not pair up. + error ResolvedAddressesLengthMismatch(uint256 readCallsLength, uint256 expectedAddressesLength); + + /// Thrown when a post-deploy read reverts, or answers with something that is + /// not a single address-sized word. + error ResolvedAddressReadFailed(string network, address target, uint256 index, bytes returnData); + /// Zoltu factory is the same on every network. address constant ZOLTU_FACTORY = 0x7A0D94F55792C434d74a40883C6ed8545E406D12; @@ -198,6 +210,110 @@ library LibRainDeploy { return networks; } + /// Asserts that an already-deployed contract holds the addresses the + /// deployment expects, on whichever network is currently selected. + /// + /// This runs AFTER the deploy, deliberately. What it checks is state the + /// deployed contract has already settled — a value it resolved once, in its + /// constructor, and stored — so nothing it reads can move underneath it. The + /// same check run BEFORE a deploy would be worth nothing: it would read a + /// source that can change between the check and the constructor that + /// consumes it. + /// + /// It is deliberately source-agnostic. It says the deployed contract holds + /// the expected address, not where that address came from, because the + /// address registry is only one way a deployment acquires one, and because + /// re-reading the registry here would assert a value that can move rather + /// than the value this deployment actually took. + /// + /// Only the consumer knows where it stored what it resolved, so the consumer + /// supplies the reads. Each entry in `readCalls` is static-called against + /// `target` and MUST answer with exactly one address. + /// @param network The network name, for the error only. + /// @param target The deployed contract to read. + /// @param readCalls The calldata for each read, e.g. + /// `abi.encodeCall(IOwnable.owner, ())`. + /// @param expectedAddresses The address each read MUST answer with, + /// positionally paired with `readCalls`. + function checkResolvedAddresses( + string memory network, + address target, + bytes[] memory readCalls, + address[] memory expectedAddresses + ) internal view { + if (readCalls.length != expectedAddresses.length) { + revert ResolvedAddressesLengthMismatch(readCalls.length, expectedAddresses.length); + } + for (uint256 i = 0; i < readCalls.length; i++) { + // The consumer supplies the reads, so the call is low level by + // construction: there is no interface here to call through. Excluded + // at the site rather than repo-wide so a low-level call added + // anywhere else is still reported. + // slither-disable-next-line low-level-calls + (bool success, bytes memory returnData) = target.staticcall(readCalls[i]); + // A read that reverts, answers nothing (no code at `target`), or + // answers something that is not one word cannot be compared, and is + // never a pass. + if (!success || returnData.length != 0x20) { + revert ResolvedAddressReadFailed(network, target, i, returnData); + } + // Decoded as a word and range checked here rather than decoded as an + // address, because `abi.decode(_, (address))` reverts with no data + // of its own when the word's upper 96 bits are dirty. A read that + // answers with a word that is not an address is exactly the case + // `ResolvedAddressReadFailed` is for, so it is reported as that + // rather than as a bare revert nothing can diagnose. + uint256 word = abi.decode(returnData, (uint256)); + if (word > type(uint160).max) { + revert ResolvedAddressReadFailed(network, target, i, returnData); + } + address actual = address(uint160(word)); + if (actual != expectedAddresses[i]) { + revert UnexpectedResolvedAddress(network, target, i, expectedAddresses[i], actual); + } + } + } + + /// Runs `checkResolvedAddresses` on every network, so a deployment verifies + /// itself across the whole target set here rather than in every consumer's + /// deploy script. + /// + /// Run this after `deployAndBroadcast` and before anything depends on the + /// deployment. A network where the deployed contract holds something other + /// than expected is a burned deterministic address, found while nothing + /// points at it yet — which is the whole reason to verify before migrating + /// onto a deployment rather than trusting it. + /// @param vm The Vm instance to use for forking. + /// @param networks The list of network names to check. + /// @param target The deployed contract to read on each network. + /// @param readCalls The calldata for each read. + /// @param expectedAddresses The address each read MUST answer with, + /// positionally paired with `readCalls`. + function checkResolvedAddressesOnNetworks( + Vm vm, + string[] memory networks, + address target, + bytes[] memory readCalls, + address[] memory expectedAddresses + ) internal { + if (networks.length == 0) { + revert NoNetworks(); + } + // Checked before any fork so a mispaired call fails immediately rather + // than after an RPC round trip. + if (readCalls.length != expectedAddresses.length) { + revert ResolvedAddressesLengthMismatch(readCalls.length, expectedAddresses.length); + } + for (uint256 i = 0; i < networks.length; i++) { + // createSelectFork returns a fork id that is not needed here; bind + // and reference it so the unused-return lint stays satisfied. + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + console2.log("Checking resolved addresses on network:", networks[i]); + checkResolvedAddresses(networks[i], target, readCalls, expectedAddresses); + } + } + /// Deploys the given creation code to each network via the Zoltu factory. /// `expectedAddress` MUST be the address the Zoltu factory derives for /// `creationCode`, which is checked before any network is forked, so an diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol new file mode 100644 index 0000000..c8c684f --- /dev/null +++ b/src/lib/LibRainDeploySnapshot.sol @@ -0,0 +1,772 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Vm} from "forge-std-1.16.1/src/Vm.sol"; +import {LibCodeGen} from "rain-sol-codegen-0.1.6/src/lib/LibCodeGen.sol"; +import {LibFs} from "rain-sol-codegen-0.1.6/src/lib/LibFs.sol"; +import {DeploySuite} from "../abstract/RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "./LibRainDeploy.sol"; + +/// Thrown when `[package].version` is not strict `X.Y.Z`. A version like +/// `0.1.7-rc1` maps to the directory `0_1_7-rc1`, which the append-only gate's +/// tag predicate ignores forever — an orphan snapshot nothing protects. Refused +/// rather than frozen. +/// @param version The version read from `foundry.toml`. +error UnreleasableVersion(string version); + +/// Thrown when there is no rolling snapshot to freeze. The property is "there +/// is something to freeze", so this fires on a missing directory and on a +/// missing file within it alike. +/// @param path The rolling path that was expected to exist. +error NothingToFreeze(string path); + +/// Thrown when a release tag already has a frozen snapshot. Frozen snapshots +/// are append-only: a release is cut once, and re-cutting one would replace the +/// record consumers of that release pin against. +/// @param tag The release tag. +/// @param dir The frozen directory that already exists. +error SnapshotAlreadyFrozen(string tag, string dir); + +/// Thrown when a freeze names no contracts. There would be nothing to write, so +/// it would leave `/` empty and report success — and an empty `/` is +/// still a frozen tag, which `SnapshotAlreadyFrozen` then refuses the real cut +/// of forever. A release lost to a directory with nothing in it. +/// @param tag The release tag that was being cut. +error EmptyRelease(string tag); + +/// @title LibRainDeploySnapshot +/// @notice Which release is being built, where its record lives, and how it is +/// frozen. Release machinery, not code generation. +/// +/// It lives here rather than in `rain-sol-codegen` deliberately. Emitting a +/// Solidity constant is codegen; reading `[package].version`, deciding a +/// release directory and making that directory immutable is the deploy +/// lifecycle, which is this repo's subject. `LibCodeGen` still emits every +/// constant — that split is the point, not an oversight. +/// +/// ## Two facts, two homes +/// +/// - `src/generated/candidate/` is the ROLLING snapshot: what the current +/// source compiles to, regenerated on every build, always describing HEAD. +/// - `src/generated//` is a FROZEN snapshot: what a release deployed, +/// written once and never again. +/// +/// Conflating them is how a repo ends up unable to say what a published version +/// actually deployed. Consumers pin releases; the candidate is what the next +/// release will be. +/// +/// ## The ordering is structural +/// +/// The freeze reads whatever is on disk, so freezing a stale candidate is +/// silent — the immutability check only fires when a tag is re-cut, which is +/// too late and too rare to rely on. `freeze` therefore takes the regeneration +/// as an argument and runs it FIRST, in the same call. There is no entry point +/// that freezes without regenerating, so "freeze, then regenerate" has nowhere +/// to be written. +/// +/// @dev The consuming repo's `foundry.toml` must grant read access to itself so +/// `deployTag` can read the release version from it: +/// `fs_permissions = [{ access = "read", path = "./foundry.toml" }, ...]` +/// alongside read-write access to `./src`. +library LibRainDeploySnapshot { + /// The rolling snapshot's directory name. A sibling of the frozen tag + /// directories rather than a file beside them, so `src/generated/` reads as + /// `candidate/ 0_1_5/ 0_1_6/` and "which one is current" is answered by + /// looking. + string constant CANDIDATE = "candidate"; + + /// The canonical release tag: `foundry.toml` `[package].version` with dots + /// converted to underscores (`0.1.7` -> `0_1_7`) for the Solidity directory + /// form. The single definition of the tag form — the version in + /// `foundry.toml` is the one source of truth for which release is being + /// built, so every path derives from it rather than restating it. + /// @param vm The Vm instance for file operations. + /// @return The tag. + function deployTag(Vm vm) internal view returns (string memory) { + return tagForVersion(vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version")); + } + + /// Whether `subject` is three non-empty runs of digits joined by exactly + /// two `separator`s. + /// + /// The ONE definition of the release-version shape. It is asked with `.` + /// for a version out of `foundry.toml` and with `_` for the directory that + /// version freezes to, so what `tagForVersion` accepts and what + /// `frozenSnapshotPaths` recognises as a release cannot drift apart. Two + /// spellings of one rule is how a version becomes freezable to a directory + /// the record then ignores. + /// @param subject The string to test. + /// @param separator The component separator: `.` for a version, `_` for a + /// tag. + /// @return Whether it has the shape. + function isStrictTriple(string memory subject, bytes1 separator) internal pure returns (bool) { + bytes memory subjectBytes = bytes(subject); + + uint256 separators = 0; + uint256 digitsInComponent = 0; + for (uint256 i = 0; i < subjectBytes.length; i++) { + bytes1 char = subjectBytes[i]; + if (char == separator) { + // No leading separator, and no empty component. + if (digitsInComponent == 0) { + return false; + } + separators++; + digitsInComponent = 0; + } else if (char >= "0" && char <= "9") { + digitsInComponent++; + } else { + return false; + } + } + // Exactly two separators, and no trailing one. + return separators == 2 && digitsInComponent > 0; + } + + /// Whether a directory under a record root is a frozen release. + /// + /// A release directory is named by `tagForVersion`, so being tag shaped is + /// what makes a directory a release. The rolling `candidate/` is not a + /// release and is excluded by the same rule that admits every real one, + /// rather than by a name this would have to remember to exclude. + /// @param dir The directory name, e.g. `0_1_7`. + /// @return Whether it is a release tag. + function isTag(string memory dir) internal pure returns (bool) { + return isStrictTriple(dir, "_"); + } + + /// The directory form of a release version, refusing anything that is not + /// strict `X.Y.Z`. + /// + /// Split from `deployTag` so the refusal is reachable without writing a + /// `foundry.toml`: a guard that cannot be exercised is a guard nobody knows + /// works. `0.1.7-rc1` maps to `0_1_7-rc1`, a directory `isTag` does not + /// recognise and the record therefore ignores forever — an orphan snapshot + /// nothing protects — so it is refused rather than frozen. + /// @param version The version string, e.g. `0.1.7`. + /// @return The tag, e.g. `0_1_7`. + function tagForVersion(string memory version) internal pure returns (string memory) { + if (!isStrictTriple(version, ".")) { + revert UnreleasableVersion(version); + } + + bytes memory versionBytes = bytes(version); + bytes memory tagBytes = new bytes(versionBytes.length); + for (uint256 i = 0; i < versionBytes.length; i++) { + // forge-lint: disable-next-line(unsafe-typecast) + tagBytes[i] = versionBytes[i] == "." ? bytes1("_") : versionBytes[i]; + } + return string(tagBytes); + } + + /// The directory holding a snapshot, rolling or frozen. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @return The directory path. + function dirForSnapshot(string memory dir) internal pure returns (string memory) { + return string.concat("src/generated/", dir); + } + + /// The contract name that places a generated file inside a snapshot + /// directory. `LibFs` derives its own path from a contract name and takes + /// no path, so the snapshot directory is folded into the name it is given. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @param contractName The name of the contract. + /// @return The name to pass to `LibFs.buildFileForContract`. + function snapshotName(string memory dir, string memory contractName) internal pure returns (string memory) { + return string.concat(dir, "/", contractName); + } + + /// The path of a contract's generated file within a snapshot. + /// + /// Delegated to `LibFs` rather than concatenated here, so the path this + /// library freezes FROM is the same definition `LibFs` writes TO. Two + /// spellings of one path is how a freeze silently reads nothing. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @param contractName The name of the contract. + /// @return The file path. + function pathForSnapshot(string memory dir, string memory contractName) internal pure returns (string memory) { + return LibFs.pathForContract(snapshotName(dir, contractName)); + } + + /// The output root `LibFs` writes to, and the only one it can write to: + /// `LibFs.pathForContract` hardcodes it. + string constant LIB_FS_ROOT = "src/generated"; + + /// Every file in the FROZEN record: everything inside a release-tag + /// directory under `root`. + /// + /// The record is the directory tree, not a list. `freeze` writes one + /// directory per release and removes none, so the tree is the complete + /// history of what a repo has released and is the only description of it + /// that cannot fall behind. Anything that reads a repo's own declaration of + /// what it has released has to be checkable against this, or a release + /// nobody declared is a release nothing verifies. + /// + /// Two rules, and both are the shape `freeze` writes: + /// + /// - the directory is tag shaped (`isTag`), which is what a release + /// directory is named by. `candidate/` is not a release and falls out + /// here, as does a scratch directory a test or a human left behind. + /// - the entry is a file directly inside it. Everything in a release + /// directory belongs to that release's record — there is no extension to + /// filter on, because nothing else has any business being in there. + /// @param vm The Vm instance for file operations. + /// @param root The record root — `LIB_FS_ROOT` for a repo's real record. + /// @return paths Every frozen record file. + function frozenSnapshotPaths(Vm vm, string memory root) internal view returns (string[] memory paths) { + // A repo with no generated directory at all has released nothing. That + // is a real state — it is this repo's own, before its first release — + // rather than a missing file to fail on. + if (!vm.exists(root)) { + return new string[](0); + } + + Vm.DirEntry[] memory entries = vm.readDir(root, 2); + + string[] memory found = new string[](entries.length); + uint256 count = 0; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].isDir || entries[i].depth != 2) { + continue; + } + string[] memory components = vm.split(entries[i].path, "/"); + string memory tag = components[components.length - 2]; + if (!isTag(tag)) { + continue; + } + // Rebuilt from `root` rather than taken from the entry, which + // `readDir` spells absolutely. A record path is compared against a + // repo's own paths and named in a failure, so it has to be the + // repo-relative one every other path here is — and at depth 2 the + // last two components are the whole of what is below `root`. + found[count] = string.concat(root, "/", tag, "/", components[components.length - 1]); + count++; + } + + paths = new string[](count); + for (uint256 i = 0; i < count; i++) { + paths[i] = found[i]; + } + } + + /// Generate one snapshot for one contract. + /// + /// There is no output root to choose. `LibFs.pathForContract` hardcodes + /// `LIB_FS_ROOT` and takes a contract name rather than a path, and this is + /// the repo's real deploy record, which belongs under that root and nowhere + /// else. A test that wants a record tree of its own writes one with + /// `vm.writeFile` and reads it with `frozenSnapshotPaths`, which does take a + /// root, because reading somebody else's tree is a thing a walk genuinely + /// does and writing this repo's record somewhere else is not. + /// @param vm The Vm instance for file operations. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @param contractName The contract the snapshot describes. + /// @param creationCode That contract's creation code. + /// @return The path written. + function writeSnapshot(Vm vm, string memory dir, string memory contractName, bytes memory creationCode) + internal + returns (string memory) + { + LibRainDeploy.etchZoltuFactory(vm); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(dirForSnapshot(dir), true); + + address deployed = LibRainDeploy.deployZoltu(creationCode); + + LibFs.buildFileForContract( + vm, + deployed, + snapshotName(dir, contractName), + string.concat( + LibCodeGen.addressConstantString( + vm, + "/// @dev The deterministic deploy address of the contract when deployed via\n/// the Zoltu factory.", + "DEPLOYED_ADDRESS", + deployed + ), + LibCodeGen.bytesConstantString( + vm, "/// @dev The creation bytecode of the contract.", "CREATION_CODE", creationCode + ), + LibCodeGen.bytesConstantString( + vm, "/// @dev The runtime bytecode of the contract.", "RUNTIME_CODE", deployed.code + ) + ) + ); + + return pathForSnapshot(dir, contractName); + } + + /// The import block of a generated alias lib. + /// @param contractName The contract the snapshot describes. + /// @param constantPrefix The prefix for the emitted constants. + /// @param dir The snapshot directory to alias. + /// @return The import block. + function aliasImportBlock(string memory contractName, string memory constantPrefix, string memory dir) + internal + pure + returns (string memory) + { + return string.concat( + "import {\n DEPLOYED_ADDRESS as ", + constantPrefix, + "_ADDR,\n BYTECODE_HASH as ", + constantPrefix, + "_HASH\n} from \"../generated/", + dir, + "/", + contractName, + ".sol\";\n\n" + ); + } + + /// The library block of a generated alias lib. + /// @param contractName The contract the snapshot describes. + /// @param constantPrefix The prefix for the emitted constants. + /// @param libraryName The generated library's name. + /// @return The library block. + function aliasLibraryBlock(string memory contractName, string memory constantPrefix, string memory libraryName) + internal + pure + returns (string memory) + { + return string.concat( + "/// @title ", + libraryName, + "\n/// @notice The deterministic Zoltu deploy address and code hash of\n/// `", + contractName, + "`, aliased from its generated snapshot so that snapshot stays the\n", + "/// single source of truth. The import path never moves, so consumers are\n", + "/// unaffected by which snapshot it names.\nlibrary ", + libraryName, + " {\n address constant ", + constantPrefix, + "_DEPLOYED_ADDRESS = ", + constantPrefix, + "_ADDR;\n bytes32 constant ", + constantPrefix, + "_DEPLOYED_CODEHASH = ", + constantPrefix, + "_HASH;\n}\n" + ); + } + + /// Generate the alias lib for a snapshot: the stable, consumer-facing + /// import path that re-exports one snapshot's address and code hash. + /// + /// Every deploy repo needs exactly this file and only four things differ, + /// three of which follow from the first — `rain.factory.deploy`'s + /// `LibCloneFactoryDeploy` is this shape to the character. Emitted here so + /// that ~20 lines of `vm.writeLine` are not copied into every repo and then + /// drifted. + /// + /// The constant prefix is passed rather than derived. Deriving + /// `ADDRESS_REGISTRY` from `AddressRegistry` means camelCase to + /// SCREAMING_SNAKE in Solidity, which is a byte loop with an acronym + /// problem, to save a caller one short string. The library name and output + /// path ARE derived, because `LibDeploy` at `src/lib/` is + /// mechanical. + /// + /// The header comes from `LibCodeGen.filePrefix`, the same one `LibFs` + /// gives a snapshot, so an alias lib and a snapshot say they are generated + /// in identical words and neither restates the other. + /// @param vm The Vm instance for file operations. + /// @param contractName The contract the snapshot describes. + /// @param constantPrefix The prefix for the emitted constants, e.g. + /// `ADDRESS_REGISTRY`. + /// @param dir The snapshot directory to alias — ordinarily `CANDIDATE`. + /// @return The path written. + function writeAliasLib(Vm vm, string memory contractName, string memory constantPrefix, string memory dir) + internal + returns (string memory) + { + string memory libraryName = string.concat("Lib", contractName, "Deploy"); + string memory path = string.concat("src/lib/", libraryName, ".sol"); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile( + path, + string.concat( + LibCodeGen.filePrefix(), + "\n", + aliasImportBlock(contractName, constantPrefix, dir), + aliasLibraryBlock(contractName, constantPrefix, libraryName) + ) + ); + return path; + } + + /// The release tag a record path sits under. + /// @param vm The Vm instance for string operations. + /// @param path A record file, as `frozenSnapshotPaths` returns it. + /// @return The tag, e.g. `0_1_7`. + function tagForRecordPath(Vm vm, string memory path) internal pure returns (string memory) { + string[] memory components = vm.split(path, "/"); + return components[components.length - 2]; + } + + /// The contract a record path names. + /// @param vm The Vm instance for string operations. + /// @param path A record file, as `frozenSnapshotPaths` returns it. + /// @return The contract name, e.g. `AddressRegistry`. + function contractForRecordPath(Vm vm, string memory path) internal pure returns (string memory) { + string[] memory components = vm.split(path, "/"); + return vm.replace(components[components.length - 1], ".sol", ""); + } + + /// The alias prefix a record's four constants are imported under. + /// + /// Both the tag and the contract, because a release freezes every contract + /// it names into one directory: the tag alone collides the moment a repo + /// releases two contracts together, and a collision here is a generated + /// file that does not compile. + /// @param vm The Vm instance for string operations. + /// @param path A record file, as `frozenSnapshotPaths` returns it. + /// @return The prefix, e.g. `AddressRegistry_0_1_7`. + function releasedConstantPrefix(Vm vm, string memory path) internal pure returns (string memory) { + return string.concat(contractForRecordPath(vm, path), "_", tagForRecordPath(vm, path)); + } + + /// Whether record file `a` is emitted before record file `b`: by release + /// tag, then by path. + /// + /// Tags compare as VERSIONS rather than as text — `0_10_0` follows `0_9_0` + /// as a release and precedes it as a string, so a text comparison misorders + /// every record that outlives a single-digit minor. Both tags are `isTag`, + /// so every component is a run of digits `parseUint` reads. + /// + /// The tie break is the path, byte for byte, so two contracts frozen under + /// one tag have an order at all. The walk's own order is the filesystem's, + /// and a generated file that changes with it is a diff on every build. + /// @param vm The Vm instance for string operations. + /// @param a A record file. + /// @param b A record file. + /// @return Whether `a` precedes `b`. + function recordPrecedes(Vm vm, string memory a, string memory b) internal pure returns (bool) { + string[] memory left = vm.split(tagForRecordPath(vm, a), "_"); + string[] memory right = vm.split(tagForRecordPath(vm, b), "_"); + for (uint256 i = 0; i < left.length; i++) { + uint256 leftComponent = vm.parseUint(left[i]); + uint256 rightComponent = vm.parseUint(right[i]); + if (leftComponent != rightComponent) { + return leftComponent < rightComponent; + } + } + + bytes memory aBytes = bytes(a); + bytes memory bBytes = bytes(b); + uint256 shortest = aBytes.length < bBytes.length ? aBytes.length : bBytes.length; + for (uint256 i = 0; i < shortest; i++) { + if (aBytes[i] != bBytes[i]) { + return aBytes[i] < bBytes[i]; + } + } + return aBytes.length < bBytes.length; + } + + /// The record's files in the order they are emitted. + /// + /// An insertion sort, because a record is one directory per release and + /// nothing sorts a list that short faster than it takes to say so. + /// @param vm The Vm instance for string operations. + /// @param paths The record's files, in any order. + /// @return sorted The same files, in release order. + function sortedRecordPaths(Vm vm, string[] memory paths) internal pure returns (string[] memory sorted) { + sorted = new string[](paths.length); + for (uint256 i = 0; i < paths.length; i++) { + uint256 j = i; + while (j > 0 && recordPrecedes(vm, paths[i], sorted[j - 1])) { + sorted[j] = sorted[j - 1]; + j--; + } + sorted[j] = paths[i]; + } + } + + /// One contract's releases out of a record, in tag order. + /// + /// The record holds every contract a repo has ever frozen, and `freeze` + /// takes a LIST of contract names, so a release of two contracts writes two + /// files under one tag. A released-suites lib describes one contract, so it + /// has to select rather than take the record whole: emitting another + /// contract's snapshot into it would give that entry this contract's suite + /// key, which collides with this contract's own entry for the same tag and + /// reverts `allSuites()` with `DuplicateDeploySuite`. A repo with one + /// contract never sees it, which is exactly why it is selected here rather + /// than left to be discovered by the first repo that freezes two. + /// @param vm The Vm instance for file operations. + /// @param recordRoot The record root to read releases from. + /// @param contractName The contract to select. + /// @return This contract's record paths, in tag order. + function recordPathsForContract(Vm vm, string memory recordRoot, string memory contractName) + internal + view + returns (string[] memory) + { + string[] memory paths = frozenSnapshotPaths(vm, recordRoot); + + string[] memory found = new string[](paths.length); + uint256 count = 0; + for (uint256 i = 0; i < paths.length; i++) { + if (keccak256(bytes(contractForRecordPath(vm, paths[i]))) != keccak256(bytes(contractName))) { + continue; + } + found[count] = paths[i]; + count++; + } + + string[] memory selected = new string[](count); + for (uint256 i = 0; i < count; i++) { + selected[i] = found[i]; + } + return sortedRecordPaths(vm, selected); + } + + /// The import block of a generated released-suites lib. + /// + /// One aliased import per record file, carrying all four consensus fields. + /// A released entry can therefore only say what its own frozen snapshot + /// says — there is no path by which a released address, code hash, creation + /// code or runtime code is written anywhere but into the immutable record. + /// @param vm The Vm instance for string operations. + /// @param paths The record's files, in the order they are emitted. + /// @return imports The import block. + function releasedImportBlock(Vm vm, string[] memory paths) internal pure returns (string memory imports) { + imports = "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n"; + for (uint256 i = 0; i < paths.length; i++) { + string memory prefix = releasedConstantPrefix(vm, paths[i]); + imports = string.concat( + imports, + "import {\n DEPLOYED_ADDRESS as ", + prefix, + "_DEPLOYED_ADDRESS,\n BYTECODE_HASH as ", + prefix, + "_BYTECODE_HASH,\n CREATION_CODE as ", + prefix, + "_CREATION_CODE,\n RUNTIME_CODE as ", + prefix, + "_RUNTIME_CODE\n} from \"../generated/", + tagForRecordPath(vm, paths[i]), + "/", + contractForRecordPath(vm, paths[i]), + ".sol\";\n\n" + ); + } + } + + /// The library block of a generated released-suites lib. + /// + /// Four fields per entry alias the frozen snapshot. The other three come + /// from `template`, the candidate declaration, and are regenerated from it + /// on every build: they are explorer and ordering metadata rather than + /// consensus, and preserving what a previous generation wrote would mean + /// parsing generated Solidity back in. + /// + /// The key is the template's with the tag appended, so every entry is + /// unique and `allSuites`'s duplicate check is satisfied by construction + /// rather than by whoever writes the declaration. + /// @param vm The Vm instance for string operations. + /// @param libraryName The generated library's name. + /// @param contractName The contract the released record describes. + /// @param paths The record's files, in the order they are emitted. + /// @param template The candidate declaration the metadata comes from. + /// @return The library block. + function releasedLibraryBlock( + Vm vm, + string memory libraryName, + string memory contractName, + string[] memory paths, + DeploySuite memory template + ) internal pure returns (string memory) { + string memory entries = ""; + for (uint256 i = 0; i < paths.length; i++) { + string memory index = vm.toString(i); + string memory dependencies = string.concat("dependencies", index); + string memory prefix = releasedConstantPrefix(vm, paths[i]); + + entries = string.concat( + entries, + " address[] memory ", + dependencies, + " = new address[](", + vm.toString(template.dependencies.length), + ");\n" + ); + for (uint256 j = 0; j < template.dependencies.length; j++) { + entries = string.concat( + entries, + " ", + dependencies, + "[", + vm.toString(j), + "] = address(", + vm.toString(template.dependencies[j]), + ");\n" + ); + } + + entries = string.concat( + entries, + " suites[", + index, + "] = DeploySuite({\n suite: \"", + template.suite, + "@", + tagForRecordPath(vm, paths[i]), + "\",\n creationCode: ", + prefix, + "_CREATION_CODE,\n storedDeployedAddress: ", + prefix, + "_DEPLOYED_ADDRESS,\n storedBytecodeHash: ", + prefix, + "_BYTECODE_HASH,\n storedRuntimeCode: ", + prefix, + "_RUNTIME_CODE,\n artifactPath: \"", + template.artifactPath, + "\",\n dependencies: ", + dependencies, + "\n });\n" + ); + } + + return string.concat( + "/// @title ", + libraryName, + "\n/// @notice Every frozen release of `", + contractName, + "`: one entry per file in\n", + "/// the append-only `src/generated//` record, in tag order.\n///\n", + "/// The deploy address, code hash, creation code and runtime code of each\n", + "/// entry are aliased from that release's own frozen snapshot, so the\n", + "/// consensus record is read from the immutable file and from nowhere else.\n///\n", + "/// The key, the artifact path and the dependencies are explorer and ordering\n", + "/// metadata regenerated from the CURRENT declaration, and are not part of\n", + "/// that record. A moved source path retroactively updates every entry's\n", + "/// artifact path, which is intended: the alternative is parsing this\n", + "/// generated file back in to preserve what it last said.\nlibrary ", + libraryName, + " {\n /// Every frozen release, in tag order.\n", + " /// @return suites The released suites.\n", + " function releasedSuites() internal pure returns (DeploySuite[] memory suites) {\n", + " suites = new DeploySuite[](", + vm.toString(paths.length), + ");\n", + entries, + " }\n}\n" + ); + } + + /// Generate the released-suites lib for a repo's frozen record: the + /// declaration of what this repo has released, emitted from the record + /// itself. + /// + /// The record and the declaration of it are produced by ONE call, so a + /// release that `src/generated//` holds is a release `releasedSuites()` + /// names. A hand-written declaration is the one thing that can silently + /// drop a release out of every check there is — `RainDeployVerifySnapshot` + /// checks the declaration against the record precisely because nothing else + /// would notice, and generating both here is what makes that check pass by + /// construction rather than by remembering. + /// + /// Written beside the alias lib, under the same `Lib` naming, so + /// all generated non-snapshot Solidity is in one directory. + /// + /// Four fields per entry come from the frozen snapshot and three from + /// `template`. Those three — the key, the artifact path and the + /// dependencies — are explorer and ordering metadata regenerated from the + /// CURRENT declaration on every build, NOT part of the frozen consensus + /// record. Moving a source file retroactively updates the artifact path of + /// every entry, including releases cut years ago, which is intended: the + /// alternative is parsing the previously generated Solidity back in to + /// preserve what it said. + /// @param vm The Vm instance for file operations. + /// @param recordRoot The record root to read releases from — `LIB_FS_ROOT` + /// for a repo's real record. A parameter for the same reason + /// `frozenSnapshotPaths` takes one: a writer that can only be pointed at + /// the real record can only be tested against it, and a repo that has cut + /// no release has nothing there to test against. + /// @param contractName The contract the released record describes. + /// @param template The candidate declaration the metadata comes from. + /// @return The path written. + function writeReleasedSuitesLib( + Vm vm, + string memory recordRoot, + string memory contractName, + DeploySuite memory template + ) internal returns (string memory) { + string memory libraryName = string.concat("Lib", contractName, "Released"); + string memory path = string.concat("src/lib/", libraryName, ".sol"); + string[] memory paths = recordPathsForContract(vm, recordRoot, contractName); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile( + path, + string.concat( + LibCodeGen.filePrefix(), + "\n", + releasedImportBlock(vm, paths), + releasedLibraryBlock(vm, libraryName, contractName, paths, template) + ) + ); + return path; + } + + /// Regenerate the rolling snapshot and freeze it as this release's record, + /// in that order, in one call. + /// + /// Every guard runs, and every byte that will be written is in hand, BEFORE + /// `/` is created. That ordering is load bearing rather than tidy. + /// Filesystem cheatcodes are not undone by a revert, so a throw once the + /// directory exists leaves a partial record behind — and a partial record is + /// a frozen tag, which `SnapshotAlreadyFrozen` then refuses the retry of. + /// The only exit from that state is deleting a directory this design calls + /// append-only, so the release is wedged by the failure rather than merely + /// stopped by it. + /// + /// The guards, in order: + /// + /// - the version must be strict `X.Y.Z` (`deployTag`) + /// - this release must not already be frozen — a release is cut once + /// - the release must name at least one contract, because a release with no + /// record is not a release and freezing one wedges the tag exactly as a + /// partial write does + /// - every named contract must have a rolling snapshot, once regenerated + /// + /// The frozen copy is the bytes just regenerated, read back from disk, so + /// "the record matches the candidate" is true by construction rather than + /// by a comparison afterwards. + /// @param vm The Vm instance for file operations. + /// @param regenerate Rewrites the rolling snapshot. Run first, always. + /// @param contractNames The contracts whose generated files form this + /// release's record. + function freeze(Vm vm, function() internal regenerate, string[] memory contractNames) internal { + string memory tag = deployTag(vm); + string memory frozenDir = dirForSnapshot(tag); + if (vm.exists(frozenDir)) { + revert SnapshotAlreadyFrozen(tag, frozenDir); + } + if (contractNames.length == 0) { + revert EmptyRelease(tag); + } + + regenerate(); + + // Read the whole record before writing any of it. Every reason this + // call can fail is now behind it, so what follows is writes only. + string[] memory records = new string[](contractNames.length); + for (uint256 i = 0; i < contractNames.length; i++) { + string memory rollingPath = pathForSnapshot(CANDIDATE, contractNames[i]); + if (!vm.exists(rollingPath)) { + revert NothingToFreeze(rollingPath); + } + records[i] = vm.readFile(rollingPath); + } + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(frozenDir, true); + for (uint256 i = 0; i < contractNames.length; i++) { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(pathForSnapshot(tag, contractNames[i]), records[i]); + } + } +} diff --git a/test/abstract/ExampleDeploySuites.sol b/test/abstract/ExampleDeploySuites.sol new file mode 100644 index 0000000..759e50b --- /dev/null +++ b/test/abstract/ExampleDeploySuites.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {AddressRegistry} from "../../src/concrete/AddressRegistry.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../src/generated/candidate/AddressRegistry.sol"; +import {LibRainDeploy} from "../../src/lib/LibRainDeploy.sol"; +import {MockDeployableV2} from "../concrete/MockDeployableV2.sol"; + +/// @title ExampleDeploySuites +/// @notice A deploy repo's suite declaration, as the verification abstracts see +/// it: a released `AddressRegistry` and a candidate `AddressRegistry`, both +/// reading the real `src/generated/candidate/AddressRegistry.sol` snapshot this +/// repo generates. +/// +/// That pair is the configuration every consumer has, and it is what makes the +/// released and candidate paths, and two suites deriving one address, real +/// rather than simulated. +/// +/// The third suite exists for one reason: the chain matrix loops over suites, +/// and proving it does not stop at the first requires a suite at a DIFFERENT +/// address. `AddressRegistry` is the only concrete in this repo, so the second +/// address comes from `MockDeployableV2`, which is already on main for +/// `LibRainDeploy`'s own tests. Without it, "the matrix silently checks only +/// the first suite" is undetectable — the failure mode that matters most to the +/// repos this abstract exists for, where ten suites sit at ten addresses. +abstract contract ExampleDeploySuites is RainDeploySuitesBase { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](2); + suites[0] = DeploySuite({ + suite: "address-registry-0-0-1", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + suites[1] = DeploySuite({ + suite: "second-address", + creationCode: type(MockDeployableV2).creationCode, + storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode), + storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode), + storedRuntimeCode: type(MockDeployableV2).runtimeCode, + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) + }); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "address-registry-candidate", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }), + sourceCreationCode: type(AddressRegistry).creationCode + }); + } +} diff --git a/test/concrete/DuplicateDeploySuites.sol b/test/concrete/DuplicateDeploySuites.sol new file mode 100644 index 0000000..a4185a0 --- /dev/null +++ b/test/concrete/DuplicateDeploySuites.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {AddressRegistry} from "../../src/concrete/AddressRegistry.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../src/generated/candidate/AddressRegistry.sol"; + +/// @title DuplicateDeploySuites +/// A declaration whose released suite and candidate share a key — the one thing +/// a registry must refuse, because the key is what selects what gets broadcast. +contract DuplicateDeploySuites is RainDeploySuitesBase { + /// The suite both entries declare, identically. + /// @return The colliding suite. + function collidingSuite() internal pure returns (DeploySuite memory) { + return DeploySuite({ + suite: "collides", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + } + + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](1); + suites[0] = collidingSuite(); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({snapshot: collidingSuite(), sourceCreationCode: type(AddressRegistry).creationCode}); + } + + /// @return Every declared suite. + function externalAllSuites() external pure returns (DeploySuite[] memory) { + return allSuites(); + } + + /// @param requested The suite key to select. + /// @return The selected suite. + function externalSuiteByName(string memory requested) external pure returns (DeploySuite memory) { + return suiteByName(requested); + } +} diff --git a/test/concrete/ExampleDeploy.sol b/test/concrete/ExampleDeploy.sol new file mode 100644 index 0000000..0d2fc0b --- /dev/null +++ b/test/concrete/ExampleDeploy.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {DeploySuite} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {RainDeployBroadcast} from "../../src/abstract/RainDeployBroadcast.sol"; +import {ExampleDeploySuites} from "../abstract/ExampleDeploySuites.sol"; + +/// @title ExampleDeploy +/// A deploy repo's whole script — a suite declaration plus `RainDeployBroadcast` +/// and nothing else, which is exactly what `script/Deploy.sol` is. The external +/// wrappers let a plain `Test` contract drive the internals without inheriting +/// `Script`. +contract ExampleDeploy is ExampleDeploySuites, RainDeployBroadcast { + /// @param requested The suite key to select. + /// @return The selected suite. + function externalSuiteByName(string memory requested) external pure returns (DeploySuite memory) { + return suiteByName(requested); + } + + /// @return Every declared suite. + function externalAllSuites() external pure returns (DeploySuite[] memory) { + return allSuites(); + } + + /// @return The declared keys, comma separated. + function externalSuiteNames() external pure returns (string memory) { + return suiteNames(); + } + + /// @return The networks a broadcast would go to. + function externalDeployNetworks() external view returns (string[] memory) { + return deployNetworks(); + } +} diff --git a/test/concrete/MockDirtyWordOwner.sol b/test/concrete/MockDirtyWordOwner.sol new file mode 100644 index 0000000..68d6c54 --- /dev/null +++ b/test/concrete/MockDirtyWordOwner.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +/// @title MockDirtyWordOwner +/// @notice Answers the same read `MockResolvedOwner` answers, but with an +/// arbitrary 32-byte word rather than an address. Nothing on the wire +/// distinguishes the two until the upper 96 bits are looked at, and contracts +/// that answer a read with a word rather than an address are ordinary: a getter +/// whose declared return type is `bytes32` or `uint256`, or one written in +/// assembly, hands back whatever word it holds. +contract MockDirtyWordOwner { + /// The word this contract answers every read with. + bytes32 public immutable iWord; + + /// @param word The word to answer with. + constructor(bytes32 word) { + iWord = word; + } + + /// The selector `MockResolvedOwner` answers, returning a raw word. + /// @return The word. + function iOwner() external view returns (bytes32) { + return iWord; + } +} diff --git a/test/concrete/MockResolvedOwner.sol b/test/concrete/MockResolvedOwner.sol new file mode 100644 index 0000000..d4f5dec --- /dev/null +++ b/test/concrete/MockResolvedOwner.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibAddressRegistry} from "../../src/lib/LibAddressRegistry.sol"; + +/// @title MockResolvedOwner +/// @notice A consumer in the shape the address registry is designed for: it +/// resolves a name exactly once, in its constructor, and stores the answer in an +/// immutable. It never reads the registry again, so a later re-binding of that +/// name cannot move what this contract holds — which is the property that makes +/// verifying a deployment after deploying it meaningful. +contract MockResolvedOwner { + /// The address the registry answered with at construction, and forever. + address public immutable iOwner; + + /// @param name The name to resolve, once. + constructor(bytes32 name) { + iOwner = LibAddressRegistry.resolve(name); + } +} diff --git a/test/src/abstract/RainDeployBroadcast.t.sol b/test/src/abstract/RainDeployBroadcast.t.sol new file mode 100644 index 0000000..528b456 --- /dev/null +++ b/test/src/abstract/RainDeployBroadcast.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; + +import {UnknownDeploymentSuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {ExampleDeploy} from "../../concrete/ExampleDeploy.sol"; + +/// @title RainDeployBroadcastTest +/// @notice The broadcast entry point, driven exactly as the `Manual sol +/// artifacts` workflow drives it — through `DEPLOYMENT_SUITE` and +/// `DEPLOYMENT_KEY` env vars. +/// +/// Nothing here broadcasts. Every case is one that fails before +/// `deployAndBroadcast` is reached, which is the half of `run()` that can be +/// tested without a key, an RPC and real money — and, not coincidentally, the +/// half that decides WHAT would be deployed. +contract RainDeployBroadcastTest is Test { + ExampleDeploy internal sDeploy; + + /// A deploy repo's whole script: the fixture declaration plus + /// `RainDeployBroadcast`. + function setUp() external { + sDeploy = new ExampleDeploy(); + } + + /// A mistyped suite MUST fail naming every valid suite, and MUST do so + /// before `DEPLOYMENT_KEY` is read. `DEPLOYMENT_KEY` is deliberately unset + /// here: if the key were read first, this would fail on the missing key and + /// send the reader after the wrong thing entirely. + function testRunUnknownSuiteRevertsBeforeReadingTheKey() external { + vm.setEnv("DEPLOYMENT_SUITE", "address-registry"); + + vm.expectRevert( + abi.encodeWithSelector( + UnknownDeploymentSuite.selector, + "address-registry", + "address-registry-0-0-1, second-address, address-registry-candidate" + ) + ); + sDeploy.run(); + } + + /// An unset `DEPLOYMENT_SUITE` MUST be an unknown suite rather than a + /// default. A deploy that picks something when told nothing is how the + /// wrong contract reaches a chain. + function testRunUnsetSuiteReverts() external { + vm.setEnv("DEPLOYMENT_SUITE", ""); + + vm.expectRevert( + abi.encodeWithSelector( + UnknownDeploymentSuite.selector, + "", + "address-registry-0-0-1, second-address, address-registry-candidate" + ) + ); + sDeploy.run(); + } + + /// The default target set MUST be every supported network, so a + /// deterministic deployment reaches one address on every chain from one + /// dispatch and no repo restates the list. + function testDeployNetworksDefaultsToSupportedNetworks() external view { + string[] memory networks = sDeploy.externalDeployNetworks(); + string[] memory supported = LibRainDeploy.supportedNetworks(); + + assertEq(networks.length, supported.length); + for (uint256 i = 0; i < supported.length; i++) { + assertEq(networks[i], supported[i]); + } + } + + /// The suite a key selects MUST be the one that would be broadcast — the + /// creation code, the artifact path and the recorded pins all come from the + /// same registry entry the verification contracts check. + /// + /// The recorded address and code hash are passed to `deployAndBroadcast` + /// rather than derived at broadcast time on purpose: + /// `LibRainDeploy.deployToNetworks` compares the recorded address against + /// the creation code before it forks anything, and a derived value would + /// make that comparison derived-against-derived. + function testSelectedSuiteCarriesTheRecordedPins() external view { + assertEq( + sDeploy.externalSuiteByName("address-registry-0-0-1").storedDeployedAddress, + LibRainDeploy.zoltuAddress(sDeploy.externalSuiteByName("address-registry-0-0-1").creationCode) + ); + assertEq( + sDeploy.externalSuiteByName("address-registry-0-0-1").artifactPath, + "src/concrete/AddressRegistry.sol:AddressRegistry" + ); + } +} diff --git a/test/src/abstract/RainDeploySuitesBase.t.sol b/test/src/abstract/RainDeploySuitesBase.t.sol new file mode 100644 index 0000000..21cae8a --- /dev/null +++ b/test/src/abstract/RainDeploySuitesBase.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; + +import { + DeploySuite, + DuplicateDeploySuite, + UnknownDeploymentSuite +} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import {ExampleDeploy} from "../../concrete/ExampleDeploy.sol"; +import {DuplicateDeploySuites} from "../../concrete/DuplicateDeploySuites.sol"; + +/// @title RainDeploySuitesBaseTest +/// @notice The registry itself: one declaration, keyed lookup, and the two ways +/// a key can be wrong. +/// +/// The registry is what replaces a chain of `else if` arms. `st0x.deploy`'s +/// production script is ten such arms of identical shape, and it restates its +/// valid keys in a revert string that nothing keeps in step with the arms — +/// so the failure message and the actual set of suites are free to drift. Here +/// they are the same array, which is what these tests pin. +contract RainDeploySuitesBaseTest is Test { + ExampleDeploy internal sSuites; + + /// The fixture declaration, as a deploy script would inherit it. + function setUp() external { + sSuites = new ExampleDeploy(); + } + + /// The registry MUST be the released suites followed by the candidate, in + /// declaration order. Both sides of the repo read this one array, which is + /// what makes deploying one thing and verifying another unrepresentable + /// rather than merely unlikely. + function testAllSuitesIsReleasedThenCandidate() external view { + DeploySuite[] memory suites = sSuites.externalAllSuites(); + + assertEq(suites.length, 3); + assertEq(suites[0].suite, "address-registry-0-0-1"); + assertEq(suites[1].suite, "second-address"); + assertEq(suites[2].suite, "address-registry-candidate"); + } + + /// Every declared key MUST select its own suite. A deploy is dispatched per + /// suite, so each has to be individually selectable — including a frozen + /// release, which is how an old snapshot reaches a chain added after it. + function testEverySuiteIsSelectableByKey() external view { + DeploySuite[] memory suites = sSuites.externalAllSuites(); + + for (uint256 i = 0; i < suites.length; i++) { + DeploySuite memory selected = sSuites.externalSuiteByName(suites[i].suite); + assertEq(selected.suite, suites[i].suite); + assertEq(keccak256(selected.creationCode), keccak256(suites[i].creationCode)); + assertEq(selected.storedDeployedAddress, suites[i].storedDeployedAddress); + assertEq(selected.artifactPath, suites[i].artifactPath); + } + } + + /// Two suites that record the SAME creation code MUST still be selectable + /// apart. `0_0_2` and the candidate are the same bytes at the same address, + /// so the key is the only thing that distinguishes them — and it has to, + /// because they are separately deployable records. + function testSuitesSharingCreationCodeSelectApart() external view { + DeploySuite memory released = sSuites.externalSuiteByName("address-registry-0-0-1"); + DeploySuite memory candidate = sSuites.externalSuiteByName("address-registry-candidate"); + + assertEq(keccak256(released.creationCode), keccak256(candidate.creationCode)); + assertEq(released.storedDeployedAddress, candidate.storedDeployedAddress); + assertNotEq(keccak256(bytes(released.suite)), keccak256(bytes(candidate.suite))); + } + + /// An unknown key MUST fail naming EVERY valid key. Not one hardcoded + /// string: the list is built from the registry, so it cannot fall behind + /// the suites it describes. + function testUnknownSuiteNamesEveryValidSuite() external { + vm.expectRevert( + abi.encodeWithSelector( + UnknownDeploymentSuite.selector, + "mock-deployable", + "address-registry-0-0-1, second-address, address-registry-candidate" + ) + ); + sSuites.externalSuiteByName("mock-deployable"); + } + + /// An empty key is just another unknown key, so an unset `DEPLOYMENT_SUITE` + /// reports the valid set rather than silently taking a default. + function testEmptySuiteIsUnknown() external { + vm.expectRevert( + abi.encodeWithSelector( + UnknownDeploymentSuite.selector, + "", + "address-registry-0-0-1, second-address, address-registry-candidate" + ) + ); + sSuites.externalSuiteByName(""); + } + + /// The reported key list MUST be exactly the registry, in order. + function testSuiteNamesIsTheRegistry() external view { + assertEq(sSuites.externalSuiteNames(), "address-registry-0-0-1, second-address, address-registry-candidate"); + } + + /// Two suites under one key MUST fail, on BOTH paths that read the + /// registry. A duplicate makes selection ambiguous and leaves one record + /// unreachable, and it is checked where both the deploy side and the verify + /// side pay for it rather than in either one of them. + function testDuplicateSuiteKeyReverts() external { + DuplicateDeploySuites duplicates = new DuplicateDeploySuites(); + + vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); + duplicates.externalAllSuites(); + + vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); + duplicates.externalSuiteByName("collides"); + } +} diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol new file mode 100644 index 0000000..b6204f3 --- /dev/null +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DerivedDeploy} from "../../../src/abstract/RainDeployVerifyBase.sol"; +import { + CodeHashMismatchOnNetwork, + NotDeployedOnNetwork, + RainDeployVerifyChain +} from "../../../src/abstract/RainDeployVerifyChain.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; +import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../../src/generated/candidate/AddressRegistry.sol"; + +/// @title RainDeployVerifyChainTest +/// @notice `RainDeployVerifyChain` inherited by a exemplar repo whose versions +/// are made live on every network by `setUp`, so the inherited +/// `testSuitesLiveOnEverySupportedNetwork` is the passing case: it forks +/// every network `supportedNetworks()` returns and finds both released suites. +/// The candidate is etched too, so nothing here depends on whether the matrix +/// happens to reach it — `RainDeployVerifyChainCandidateTest`, in +/// `RainDeployVerifyChainCandidate.t.sol`, is what says it does not. +/// +/// `setUp` places the code with a persistent `vm.etch` rather than pointing the +/// exemplar at some real deployment in another repo. A real one would make this +/// suite fail whenever that unrelated deployment moved — which is precisely the +/// signal this group exists to raise for its own repo, and precisely the wrong +/// thing to import into this one. +/// +/// The etch does not make the passing case circular. It writes the runtime code +/// the compiler emits, while the expectation is derived independently by running +/// the recorded CREATION code through the Zoltu factory. That the two agree is +/// the assertion. `testChainCodeHashMismatchReverts` is what proves it: it +/// leaves the etch in place and changes only the code, and the check still +/// fails, which it could not do if the expectation were read from the etch. +contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain { + /// The second suite's address, derived from the only other creation code in + /// this repo. + /// @return The address. + function secondDeployedAddress() internal pure returns (address) { + return LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode); + } + + /// The second suite's runtime code. + /// @return The runtime code. + function secondRuntimeCode() internal pure returns (bytes memory) { + return type(MockDeployableV2).runtimeCode; + } + + /// Makes every exemplar version live on every fork, which is what the + /// inherited test then verifies. Persistent so it survives each + /// `createSelectFork` inside the loop. + function setUp() external { + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_RUNTIME_CODE); + vm.makePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + vm.etch(secondDeployedAddress(), secondRuntimeCode()); + vm.makePersistent(secondDeployedAddress()); + } + + /// External wrapper for `checkDeployedOnNetwork` so `vm.expectRevert` works + /// at the correct call depth. + /// @param network The network name, for the error only. + /// @param derived The derivation to check for. + function externalCheckDeployedOnNetwork(string memory network, DerivedDeploy memory derived) external view { + checkDeployedOnNetwork(network, derived); + } + + /// A version that is not on a network MUST fail, naming the network, the + /// version and the address. This is the whole reason the group exists: a + /// release that reached four chains of five, or a chain added after a + /// release that therefore never got it, is invisible to every other check. + function testChainNotDeployedReverts() external { + // Present locally, but no longer carried onto forks. + vm.revokePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + vm.expectRevert( + abi.encodeWithSelector( + NotDeployedOnNetwork.selector, + LibRainDeploy.ARBITRUM_ONE, + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS + ) + ); + this.testSuitesLiveOnEverySupportedNetwork(); + } + + /// EVERY suite MUST be checked, not just the first one the matrix + /// reaches. The version missing here is the LAST one, so a matrix that + /// stopped after the first version would pass. + function testChainNotDeployedRevertsForALaterSuite() external { + vm.revokePersistent(secondDeployedAddress()); + + vm.expectRevert( + abi.encodeWithSelector( + NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, "second-address", secondDeployedAddress() + ) + ); + this.testSuitesLiveOnEverySupportedNetwork(); + } + + /// EVERY network MUST be forked, not just the first. A completed matrix + /// leaves the LAST supported network selected, which a matrix that stopped + /// early cannot do — and unlike a missing deployment, nothing about the + /// passing case itself distinguishes the two. + function testChainMatrixReachesTheLastSupportedNetwork() external { + string[] memory networks = LibRainDeploy.supportedNetworks(); + + uint256 lastForkId = vm.createSelectFork(networks[networks.length - 1]); + (lastForkId); + uint256 lastChainId = block.chainid; + + // Start somewhere the matrix does not end, so arriving at `lastChainId` + // means the matrix moved rather than that it never forked at all. + uint256 firstForkId = vm.createSelectFork(networks[0]); + (firstForkId); + assertNotEq(block.chainid, lastChainId); + + this.testSuitesLiveOnEverySupportedNetwork(); + + assertEq(block.chainid, lastChainId); + } + + /// Code on a network that is not the code the version's creation code + /// produces MUST fail hard, naming the network and BOTH hashes. + /// + /// This is also the shape of a chain-dependent runtime — a constructor that + /// reads `block.chainid` deploys different code per network — which is a + /// defect in the contract rather than something to record a hash per chain + /// for. + /// + /// It doubles as the proof that the expectation is derived rather than + /// observed: the wrong code is etched at the address the check reads, so if + /// the derivation took its expectation from there this would pass. + function testChainCodeHashMismatchReverts() external { + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, hex"6001"); + + vm.expectRevert( + abi.encodeWithSelector( + CodeHashMismatchOnNetwork.selector, + LibRainDeploy.ARBITRUM_ONE, + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + ADDRESS_REGISTRY_BYTECODE_HASH, + keccak256(hex"6001") + ) + ); + this.testSuitesLiveOnEverySupportedNetwork(); + } + + /// The network in the failure MUST be the network that failed, not a fixed + /// string. Checked on a different network from the one the matrix reaches + /// first, against a derivation that is deliberately expecting the wrong + /// hash. + function testChainFailureNamesTheNetworkChecked() external { + vm.createSelectFork(LibRainDeploy.BASE); + + DerivedDeploy memory derived = DerivedDeploy({ + suite: "address-registry-0-0-1", + deployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + bytecodeHash: bytes32(uint256(1)) + }); + + vm.expectRevert( + abi.encodeWithSelector( + CodeHashMismatchOnNetwork.selector, + LibRainDeploy.BASE, + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + bytes32(uint256(1)), + ADDRESS_REGISTRY_BYTECODE_HASH + ) + ); + this.externalCheckDeployedOnNetwork(LibRainDeploy.BASE, derived); + } + + /// Deriving MUST leave the derived address exactly as it found it. The + /// derivation clears that address to run the creation code there, so if it + /// did not put things back, a persistent deployment would be destroyed + /// before the networks were ever read — and the matrix would report every + /// suite missing everywhere. + /// + /// The nonce is checked as well as the code, and it is the part that + /// discriminates. A local deploy that survived would leave the SAME runtime + /// code sitting there, so comparing code alone cannot tell "put back" from + /// "deployed over the top" — but a `CREATE2` deploy leaves the account at + /// nonce 1, while a restored etch is at nonce 0. + function testDerivationRestoresCodeAtDerivedAddress() external { + assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); + assertEq(secondDeployedAddress().code, secondRuntimeCode()); + assertEq(vm.getNonce(ADDRESS_REGISTRY_DEPLOYED_ADDRESS), 0); + assertEq(vm.getNonce(secondDeployedAddress()), 0); + + DerivedDeploy[] memory derived = deriveDeployments(allSuites()); + assertEq(derived.length, 3); + + assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); + assertEq(secondDeployedAddress().code, secondRuntimeCode()); + assertEq(vm.getNonce(ADDRESS_REGISTRY_DEPLOYED_ADDRESS), 0); + assertEq(vm.getNonce(secondDeployedAddress()), 0); + } + + /// The matrix MUST cover every supported network, not a subset one repo + /// happened to list. A network added to `LibRainDeploy.supportedNetworks()` + /// is checked for every declared suite from the moment it is added, which + /// is the case no per-chain test function can cover. + function testChainMatrixCoversEverySupportedNetwork() external { + string[] memory networks = LibRainDeploy.supportedNetworks(); + for (uint256 i = 0; i < networks.length; i++) { + // Live on every network except this one. + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_RUNTIME_CODE); + vm.makePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, hex""); + + DerivedDeploy memory derived = DerivedDeploy({ + suite: "address-registry-0-0-1", + deployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + bytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH + }); + + vm.expectRevert( + abi.encodeWithSelector( + NotDeployedOnNetwork.selector, + networks[i], + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS + ) + ); + this.externalCheckDeployedOnNetwork(networks[i], derived); + } + } +} diff --git a/test/src/abstract/RainDeployVerifyChainCandidate.t.sol b/test/src/abstract/RainDeployVerifyChainCandidate.t.sol new file mode 100644 index 0000000..8fa85ac --- /dev/null +++ b/test/src/abstract/RainDeployVerifyChainCandidate.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../../src/generated/candidate/AddressRegistry.sol"; + +/// @title RainDeployVerifyChainCandidateTest +/// @notice A repo between releases: source has moved on, so the candidate is a +/// different contract from the last release and is deployed nowhere. The +/// inherited matrix MUST pass anyway. +/// +/// This is the ordinary state of a deploy repo, not a fault in one. A candidate +/// is what the NEXT release will be; requiring it to already be on chain asks +/// the repo to have deployed something it has not released, and would make +/// every repo permanently red for as long as its source was ahead of its last +/// deploy — which is most of the time. +/// +/// It needs its own declaration because `ExampleDeploySuites` cannot say it: +/// its candidate shares creation code with a release, so every address it names +/// is live whichever scope the matrix uses, and the two are indistinguishable +/// there. Here the released suite is made live and the candidate deliberately +/// is not, at a DIFFERENT address, which is the only configuration that can +/// tell them apart. +/// +/// It is its own contract in its own file rather than a second declaration +/// beside `RainDeployVerifyChainTest`, because the suites a contract inherits +/// are the whole of what the matrix runs over: a contract has exactly one +/// declaration, so a second scope is a second contract. +contract RainDeployVerifyChainCandidateTest is RainDeployVerifyChain { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](1); + suites[0] = DeploySuite({ + suite: "address-registry-0-0-1", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "second-address-candidate", + creationCode: type(MockDeployableV2).creationCode, + storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode), + storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode), + storedRuntimeCode: type(MockDeployableV2).runtimeCode, + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) + }), + sourceCreationCode: type(MockDeployableV2).creationCode + }); + } + + /// The RELEASE is live everywhere. The candidate is not touched. + function setUp() external { + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_RUNTIME_CODE); + vm.makePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + } + + /// The matrix MUST pass with the candidate on no network at all, and the + /// candidate MUST really be absent — otherwise this passes for the wrong + /// reason and says nothing about the scope. + function testChainIgnoresAnUndeployedCandidate() external { + address candidateAddress = LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode); + assertNotEq(candidateAddress, ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + string[] memory networks = LibRainDeploy.supportedNetworks(); + for (uint256 i = 0; i < networks.length; i++) { + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + assertEq(candidateAddress.code.length, 0); + assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); + } + + this.testSuitesLiveOnEverySupportedNetwork(); + } +} diff --git a/test/src/abstract/RainDeployVerifySnapshot.t.sol b/test/src/abstract/RainDeployVerifySnapshot.t.sol new file mode 100644 index 0000000..8a077b2 --- /dev/null +++ b/test/src/abstract/RainDeployVerifySnapshot.t.sol @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {ZoltuDerivationMismatch} from "../../../src/abstract/RainDeployVerifyBase.sol"; +import {DeployCandidate, DeploySuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import { + CandidateSourceMismatch, + FrozenSnapshotNotReleased, + FrozenSnapshotUnreadable, + RainDeployVerifySnapshot, + StoredAddressMismatch, + StoredCodeHashMismatch, + StoredRuntimeCodeHashMismatch +} from "../../../src/abstract/RainDeployVerifySnapshot.sol"; +import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {AddressRegistry} from "../../../src/concrete/AddressRegistry.sol"; +import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; +import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../../src/generated/candidate/AddressRegistry.sol"; + +/// @title RainDeployVerifySnapshotTest +/// @notice `RainDeployVerifySnapshot` inherited by a exemplar repo, so the +/// inherited tests themselves are the passing case: `ExampleDeploySuites` +/// declares two frozen releases and a candidate, and +/// `testSnapshotInternallyConsistent` / +/// `testSnapshotMatchesSource` run over them here exactly as they +/// would in a consumer. +/// +/// The rest is what each group CATCHES, and — for the internal group — what it +/// provably does not. Every case drives the same internal functions the +/// inherited tests do, through external wrappers so `vm.expectRevert` lands at +/// the right call depth, with the exemplar data deliberately broken one field at +/// a time. +contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySnapshot { + /// External wrapper for `checkInternallyConsistent` so `vm.expectRevert` + /// works at the correct call depth. + /// @param suite The suite to check. + function externalCheckInternallyConsistent(DeploySuite memory suite) external { + checkInternallyConsistent(suite); + } + + /// External wrapper for `checkAnchoredToSource` so `vm.expectRevert` works + /// at the correct call depth. + /// @param candidate The candidate to check. + function externalCheckAnchoredToSource(DeployCandidate memory candidate) external pure { + checkAnchoredToSource(candidate); + } + + /// External wrapper for `checkFrozenSnapshotsReleased` so `vm.expectRevert` + /// works at the correct call depth. + /// @param paths The frozen record's files. + /// @param released The declared released suites. + function externalCheckFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) external view { + checkFrozenSnapshotsReleased(paths, released); + } + + /// External wrapper for `recordedDeployedAddress` so `vm.expectRevert` + /// works at the correct call depth. + /// @param path The record file, for the error only. + /// @param record The record file's contents. + /// @return The address the record declares. + function externalRecordedDeployedAddress(string memory path, string memory record) external pure returns (address) { + return recordedDeployedAddress(path, record); + } + + /// The real generated snapshot, standing in for a frozen record. It is the + /// same file a freeze copies into `src/generated//`, and the exemplar's + /// first released suite is declared from it, so the pair below is a real + /// record checked against a real declaration. + /// @return paths The one-file record. + function recordOfTheGeneratedSnapshot() internal pure returns (string[] memory paths) { + paths = new string[](1); + paths[0] = LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, "AddressRegistry"); + } + + /// A record declared by a released suite MUST pass, so the failing cases + /// below are discriminating rather than a check that cannot succeed. + function testFrozenSnapshotDeclaredPasses() external view { + this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), releasedSuites()); + } + + /// A release in the record that the declaration does not name MUST fail, + /// naming the file. This is the hole the check exists for: nothing else + /// mentions that release, so without this it is simply never checked again + /// and every other assertion stays green. + function testFrozenSnapshotUndeclaredReverts() external { + vm.expectRevert(abi.encodeWithSelector(FrozenSnapshotNotReleased.selector, recordOfTheGeneratedSnapshot()[0])); + this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), new DeploySuite[](0)); + } + + /// The match MUST be against what each suite's creation code derives, not + /// merely against a declaration existing. A repo that declares SOME + /// releases and misses one is the case that actually happens, and it is + /// indistinguishable from a full declaration to anything that only counts. + function testFrozenSnapshotDeclaredByAnotherSuiteReverts() external { + DeploySuite[] memory wrongRelease = new DeploySuite[](1); + wrongRelease[0] = releasedSuites()[1]; + assertEq(wrongRelease[0].suite, "second-address"); + + vm.expectRevert(abi.encodeWithSelector(FrozenSnapshotNotReleased.selector, recordOfTheGeneratedSnapshot()[0])); + this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), wrongRelease); + } + + /// A record in the generated shape that DECLARES one address and merely + /// mentions another — in a comment, and in a second address constant. + /// @param declared The address the record declares as its deploy address. + /// @param mentioned The address the record only mentions. + /// @return The record's contents. + function recordDeclaring(address declared, address mentioned) internal pure returns (string memory) { + return string.concat( + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + "/// @dev The deterministic deploy address of the contract, which is not\n/// ", + vm.toString(mentioned), + ".\n", + "address constant DEPLOYED_ADDRESS = address(", + vm.toString(declared), + ");\n\n", + "/// @dev Some other address this record carries.\n", + "address constant OTHER_ADDRESS = address(", + vm.toString(mentioned), + ");\n" + ); + } + + /// The address a record is matched on MUST be the one it DECLARES, never + /// one that merely appears in its text. A record is two hex payloads + /// thousands of digits long plus a comment or two, so "this address occurs + /// somewhere in the file" is a question about characters rather than about + /// what was deployed — and it answers yes for a release the declaration + /// never named. + /// + /// Driven at the read rather than through a record on disk: a record whose + /// text carries a released address it does not declare cannot be built out + /// of this repo's real snapshots without grinding creation code for one, + /// and a `.sol` fixture left behind by a failed run is a file the next + /// build has to compile. + function testFrozenSnapshotMatchesTheDeclarationNotTheText() external view { + address released = LibRainDeploy.zoltuAddress(releasedSuites()[0].creationCode); + assertEq(released, ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + string memory record = recordDeclaring(address(0xdead), released); + // The released address really is in there. + assertTrue(vm.contains(record, vm.toString(released))); + + assertEq(this.externalRecordedDeployedAddress("record.sol", record), address(0xdead)); + } + + /// A record in the generated shape whose real declaration is preceded by a + /// COMMENTED OUT one. The commented line is the declaration text verbatim, + /// which is what separates this from a prose mention of an address. + /// @param declared The address the record's real declaration carries. + /// @param commented The address the commented-out declaration carries. + /// @return The record's contents. + function recordCommentingOutADeclaration(address declared, address commented) + internal + pure + returns (string memory) + { + return string.concat( + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + "/// @dev The deterministic deploy address of the contract.\n", + "// address constant DEPLOYED_ADDRESS = address(", + vm.toString(commented), + ");\n", + "address constant DEPLOYED_ADDRESS = address(", + vm.toString(declared), + ");\n" + ); + } + + /// The declaration a record is read from MUST be the one the COMPILER + /// would read. A commented-out declaration is text, and taking it would let + /// a hand edit park a released address above a real declaration carrying + /// something else — the file would then match a released suite while + /// declaring an address no suite derives, which is precisely the hand edit + /// this group exists to catch. The parser cannot be the thing that swallows + /// it. + function testFrozenSnapshotIgnoresACommentedOutDeclaration() external view { + address released = LibRainDeploy.zoltuAddress(releasedSuites()[0].creationCode); + assertEq(released, ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + string memory record = recordCommentingOutADeclaration(address(0xdead), released); + // The commented-out line really is the declaration text, carrying the + // released address: only its position makes it a comment. + assertTrue(vm.contains(record, string.concat("// ", DEPLOYED_ADDRESS_DECLARATION))); + assertTrue(vm.contains(record, vm.toString(released))); + + assertEq(this.externalRecordedDeployedAddress("record.sol", record), address(0xdead)); + } + + /// A file in the record that declares no deploy address at all MUST fail as + /// unreadable, naming itself. Reporting it as undeclared would send the + /// reader to `releasedSuites()` to add an entry for something that is not a + /// snapshot. + function testFrozenSnapshotWithoutADeployedAddressReverts() external { + string[] memory paths = new string[](1); + paths[0] = "src/concrete/AddressRegistry.sol"; + + vm.expectRevert(abi.encodeWithSelector(FrozenSnapshotUnreadable.selector, paths[0])); + this.externalCheckFrozenSnapshotsReleased(paths, releasedSuites()); + } + + /// A consistent snapshot of the WRONG contract: every recorded field is + /// `MockDeployable`'s and they all agree with each other, but it is + /// presented as the candidate for a repo whose source is + /// `MockDeployableV2`. This is the shape of a snapshot generated from a + /// stale build, or from the wrong contract in a repo with several. + /// @return The wrong-contract candidate. + function wrongContractCandidate() internal pure returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "address-registry-candidate", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }), + sourceCreationCode: type(MockDeployableV2).creationCode + }); + } + + /// The frozen `0_0_1` release, which every negative case below breaks one + /// field of. + /// @return The consistent `0_0_1` suite. + function consistentSuite() internal pure returns (DeploySuite memory) { + return DeploySuite({ + suite: "address-registry-0-0-1", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + } + + /// A recorded address that is not the one the recorded creation code + /// derives MUST fail, naming the version and both addresses. This is the + /// hand-edited constant, and the address copied from the wrong tag. + function testStoredAddressMismatchReverts() external { + DeploySuite memory suite = consistentSuite(); + suite.storedDeployedAddress = address(0xdead); + + vm.expectRevert( + abi.encodeWithSelector( + StoredAddressMismatch.selector, + "address-registry-0-0-1", + address(0xdead), + ADDRESS_REGISTRY_DEPLOYED_ADDRESS + ) + ); + this.externalCheckInternallyConsistent(suite); + } + + /// A recorded code hash that is not the one the recorded creation code + /// produces MUST fail, naming the version and both hashes. This is a + /// snapshot regenerated for one field and not the others. + function testStoredCodeHashMismatchReverts() external { + DeploySuite memory suite = consistentSuite(); + suite.storedBytecodeHash = bytes32(uint256(1)); + + vm.expectRevert( + abi.encodeWithSelector( + StoredCodeHashMismatch.selector, + "address-registry-0-0-1", + bytes32(uint256(1)), + ADDRESS_REGISTRY_BYTECODE_HASH + ) + ); + this.externalCheckInternallyConsistent(suite); + } + + /// Recorded runtime code that does not hash to the code hash recorded + /// beside it MUST fail, naming the version and both hashes. The address and + /// the code hash still agree with the creation code here, so this is the + /// only check standing between a corrupted `RUNTIME_CODE` and a green + /// suite. + function testStoredRuntimeCodeHashMismatchReverts() external { + DeploySuite memory suite = consistentSuite(); + suite.storedRuntimeCode = hex"00"; + + vm.expectRevert( + abi.encodeWithSelector( + StoredRuntimeCodeHashMismatch.selector, + "address-registry-0-0-1", + ADDRESS_REGISTRY_BYTECODE_HASH, + keccak256(hex"00") + ) + ); + this.externalCheckInternallyConsistent(suite); + } + + /// The internal group MUST NOT catch a consistent snapshot of the wrong + /// contract, and this pins that it does not. It is not a gap to be closed + /// there: every internal check asks the recorded bytes to agree with each + /// other, and the wrong contract's bytes agree with each other perfectly. + /// + /// Pinning the miss is what stops the internal group from being read as + /// covering the source-anchored one, and what makes the next test the only + /// thing standing between a stale snapshot and a green suite. + function testWrongContractSnapshotPassesInternalConsistency() external { + DeployCandidate memory candidate = wrongContractCandidate(); + + // It really is the wrong contract: the recorded creation code is not + // the creation code this repo compiles for the candidate. + assertNotEq(keccak256(candidate.snapshot.creationCode), keccak256(type(MockDeployableV2).creationCode)); + assertEq(keccak256(candidate.snapshot.creationCode), keccak256(type(AddressRegistry).creationCode)); + + // Every internal check passes anyway. + this.externalCheckInternallyConsistent(candidate.snapshot); + } + + /// The source-anchored group MUST catch exactly the snapshot the internal + /// group just let through, naming the candidate and both creation code + /// hashes. This is the only check in the whole suite that can. + function testWrongContractSnapshotCaughtBySource() external { + DeployCandidate memory candidate = wrongContractCandidate(); + + vm.expectRevert( + abi.encodeWithSelector( + CandidateSourceMismatch.selector, + "address-registry-candidate", + keccak256(ADDRESS_REGISTRY_CREATION_CODE), + keccak256(type(MockDeployableV2).creationCode) + ) + ); + this.externalCheckAnchoredToSource(candidate); + } + + /// A candidate whose recorded creation code IS the source's MUST pass, so + /// the previous test is discriminating rather than a check that always + /// fails. + function testCandidateAnchoredToSourcePasses() external view { + this.externalCheckAnchoredToSource(candidateSuite()); + } + + /// Two suites that record the SAME creation code MUST both derive, which + /// is the ordinary state of a repo between a release and the next source + /// change. `0_0_2` and the candidate are the same bytes and therefore the + /// same address, and the whole set still passes. + function testSuitesSharingCreationCodeAllDerive() external { + DeploySuite[] memory suites = allSuites(); + assertEq(suites.length, 3); + assertEq(suites[0].storedDeployedAddress, suites[2].storedDeployedAddress); + assertEq(keccak256(suites[0].creationCode), keccak256(suites[2].creationCode)); + + // Neither derivation is disturbed by the other. + this.externalCheckInternallyConsistent(suites[0]); + this.externalCheckInternallyConsistent(suites[2]); + } + + /// The pure address formula and the factory bytecode the derivation etches + /// MUST agree, and a disagreement MUST be named rather than left to surface + /// as a code hash read from an address nothing was deployed to. Forced here + /// by making the factory report an address it did not deploy to, which is + /// otherwise unreachable — the derivation etches the factory bytecode + /// itself, so only a `LibRainDeploy` whose constant and formula had drifted + /// apart could produce it. + function testZoltuDerivationMismatchReverts() external { + DeploySuite memory suite = consistentSuite(); + + // The factory answers with an address that does have code, but is not + // the one the creation code derives. + vm.mockCall( + LibRainDeploy.ZOLTU_FACTORY, + ADDRESS_REGISTRY_CREATION_CODE, + abi.encodePacked(bytes20(LibRainDeploy.ZOLTU_FACTORY)) + ); + + vm.expectRevert( + abi.encodeWithSelector( + ZoltuDerivationMismatch.selector, + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + LibRainDeploy.ZOLTU_FACTORY + ) + ); + this.externalCheckInternallyConsistent(suite); + } + + /// The derivation MUST leave nothing behind. A local deploy that survived + /// would be compared against itself by the chain-anchored group, and every + /// network would pass whether or not anything is deployed there. + function testDerivationLeavesNoCodeBehind() external { + DeploySuite[] memory suites = allSuites(); + for (uint256 i = 0; i < suites.length; i++) { + assertEq(suites[i].storedDeployedAddress.code.length, 0); + } + + this.externalCheckInternallyConsistent(suites[0]); + + for (uint256 i = 0; i < suites.length; i++) { + assertEq(suites[i].storedDeployedAddress.code.length, 0); + } + } +} diff --git a/test/src/concrete/AddressRegistryDeployChain.t.sol b/test/src/concrete/AddressRegistryDeployChain.t.sol new file mode 100644 index 0000000..035f1ba --- /dev/null +++ b/test/src/concrete/AddressRegistryDeployChain.t.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; +import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistryDeploySuites.sol"; + +/// @title AddressRegistryDeployChainTest +/// @notice Whether `AddressRegistry` is actually live, with the code this repo +/// compiles, on every supported network. +/// +/// It is not. `AddressRegistry` has never been deployed and +/// `ADDRESS_REGISTRY_ROOT` is still a placeholder, so this fails on the first +/// network with `NotDeployedOnNetwork` and keeps failing until the contract is +/// deployed to every supported network. +/// +/// That failure is the check working. "Nothing is deployed at the address +/// `LibAddressRegistry` reads" is true, it is the single most important fact +/// about these pins, and no snapshot assertion can discover it — a perfectly +/// consistent set of pins for a contract that exists nowhere passes every one +/// of them. A green here would only mean nobody asked. +/// +/// It is a separate contract from `AddressRegistryDeploySnapshotTest` +/// precisely so that it says this and nothing more: `forge test +/// --no-match-contract Chain` still runs every snapshot assertion, +/// whether the deployment is missing or the RPC endpoints are merely +/// unreachable. +contract AddressRegistryDeployChainTest is AddressRegistryDeploySuites, RainDeployVerifyChain {} diff --git a/test/src/concrete/AddressRegistryDeploySnapshot.t.sol b/test/src/concrete/AddressRegistryDeploySnapshot.t.sol new file mode 100644 index 0000000..40cee2f --- /dev/null +++ b/test/src/concrete/AddressRegistryDeploySnapshot.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {RainDeployVerifySnapshot} from "../../../src/abstract/RainDeployVerifySnapshot.sol"; +import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistryDeploySuites.sol"; + +/// @title AddressRegistryDeploySnapshotTest +/// @notice The deploy-pin assertions for `AddressRegistry` that need no +/// network: what `LibAddressRegistryDeploy` records is what the creation code +/// this repo compiles derives, and the candidate is a snapshot of that source +/// rather than of some other contract. +/// +/// The pins are a pure function of the creation code, which is a pure function +/// of this repo's compiler settings — and the contract, the settings and the +/// pins are all in this repo, so this closes the loop rather than asserting +/// across a boundary. The root authority is a constant in that creation code, +/// so changing the root moves both pins and turns this red until they follow. +/// +/// Both assertions are inherited. There is nothing to write here, which is the +/// point: `AddressRegistryDeploySuites` says which versions exist and +/// `RainDeployVerifySnapshot` says what is true of them. +contract AddressRegistryDeploySnapshotTest is AddressRegistryDeploySuites, RainDeployVerifySnapshot {} diff --git a/test/src/concrete/AddressRegistryGet.t.sol b/test/src/concrete/AddressRegistryGet.t.sol new file mode 100644 index 0000000..7b6faed --- /dev/null +++ b/test/src/concrete/AddressRegistryGet.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; + +import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title AddressRegistryGetTest +/// @notice A test suite for `AddressRegistry.get`: it answers a bound name with +/// its address and an unbound name with a revert, and it is the only reader. +contract AddressRegistryGetTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + AddressRegistry internal sRegistry; + + function setUp() external { + sRegistry = new AddressRegistry(); + } + + /// A read of an unbound name reverts rather than returning the zero + /// address, so a caller cannot proceed on a name nobody bound by forgetting + /// to check. + function testGetUnsetReverts(bytes32 name) external { + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + sRegistry.get(name); + } + + /// A read of a bound name returns exactly what was bound, and reading does + /// not consume or alter the binding. + function testGetReturnsRegistered(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), account); + assertEq(sRegistry.get(name), account); + } + + /// Names are opaque: nothing about a name's bytes changes how it is stored + /// or read, including names a string-hashing convention would never + /// produce. + function testGetOpaqueNames(address account) external { + vm.assume(account != address(0)); + + bytes32[3] memory names = [bytes32(0), bytes32(uint256(1)), bytes32(type(uint256).max)]; + for (uint256 i = 0; i < names.length; i++) { + AddressRegistry registry = new AddressRegistry(); + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(names[i], account); + assertEq(registry.get(names[i]), account); + } + } + + /// `get` is the only reader. The bindings mapping is not `public`, so the + /// getter a `public` mapping would generate — which answers an unbound name + /// with the zero address, the exact silent failure `get` reverts to prevent + /// — does not exist. + function testGetNoGeneratedMappingGetter(bytes32 name) external { + (bool success,) = address(sRegistry).call(abi.encodeWithSignature("sAddresses(bytes32)", name)); + assertFalse(success); + } + + /// There is no other entry point at all: no fallback, no receive, and + /// nothing beyond the two `IAddressRegistryV1` functions, so an unknown + /// selector reverts instead of being silently absorbed. + function testGetNoOtherEntryPoint(bytes4 selector, bytes32 name) external { + vm.assume(selector != IAddressRegistryV1.get.selector); + vm.assume(selector != IAddressRegistryV1.register.selector); + + (bool success,) = address(sRegistry).call(abi.encodeWithSelector(selector, name, address(this))); + assertFalse(success); + } +} diff --git a/test/src/concrete/AddressRegistryRegister.t.sol b/test/src/concrete/AddressRegistryRegister.t.sol new file mode 100644 index 0000000..f4bdf58 --- /dev/null +++ b/test/src/concrete/AddressRegistryRegister.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; + +import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title AddressRegistryRegisterTest +/// @notice A test suite for `AddressRegistry.register`: who may bind a name, +/// that root may re-bind one, and what a binding may never become. +contract AddressRegistryRegisterTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + AddressRegistry internal sRegistry; + + function setUp() external { + sRegistry = new AddressRegistry(); + } + + /// Only root may bind a name. Checked before the zero-address check, so a + /// non-root caller is rejected as `NotRoot` whatever it passes. + function testRegisterOnlyRoot(address sender, bytes32 name, address account) external { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + } + + /// Re-binding is root's alone. A name being already bound gives nobody else + /// authority over it, and the failed attempt leaves the binding untouched. + function testRegisterRebindOnlyRoot(address sender, bytes32 name, address bound, address account) external { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + vm.assume(bound != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), bound); + } + + /// Root may re-bind a name to a different address, and the new binding is + /// what `get` answers with from then on. This is the rotation case: an + /// owning multisig changes without any consumer's name, creation code or + /// deterministic address moving. + function testRegisterRebind(bytes32 name, address bound, address account) external { + vm.assume(bound != address(0)); + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + assertEq(sRegistry.get(name), bound); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + assertEq(sRegistry.get(name), account); + } + + /// Re-binding a name to the address it already holds is allowed and is a + /// no-op on the binding. There is no special case for it in either + /// direction. + function testRegisterRebindSameAccount(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), account); + } + + /// Re-binding survives any number of rotations, and only the most recent one + /// counts. + function testRegisterRebindRepeatedly(bytes32 name, address[] memory accounts) external { + vm.assume(accounts.length > 0); + for (uint256 i = 0; i < accounts.length; i++) { + vm.assume(accounts[i] != address(0)); + } + + for (uint256 i = 0; i < accounts.length; i++) { + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, accounts[i]); + assertEq(sRegistry.get(name), accounts[i]); + } + assertEq(sRegistry.get(name), accounts[accounts.length - 1]); + } + + /// The zero address is rejected. An unbound name reads as the zero address + /// internally, so binding it would produce a name that is bound but + /// unreadable. + function testRegisterZeroAccount(bytes32 name) external { + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.ZeroAccount.selector, name)); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, address(0)); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + sRegistry.get(name); + } + + /// The zero address is rejected for a name that is already bound too, so + /// there is no way to unbind a name by re-binding it to zero — the existing + /// binding survives intact rather than the name reverting to "never bound". + function testRegisterZeroAccountCannotUnbind(bytes32 name, address bound) external { + vm.assume(bound != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.ZeroAccount.selector, name)); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, address(0)); + + assertEq(sRegistry.get(name), bound); + } + + /// Names are independent: binding one says nothing about any other, and + /// re-binding one does not disturb another. + function testRegisterDistinctNames(bytes32 nameA, bytes32 nameB, address accountA, address accountB) external { + vm.assume(nameA != nameB); + vm.assume(accountA != address(0)); + vm.assume(accountB != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(nameA, accountA); + + // Binding `nameA` did not bind `nameB`. + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, nameB)); + sRegistry.get(nameB); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(nameB, accountB); + + assertEq(sRegistry.get(nameA), accountA); + assertEq(sRegistry.get(nameB), accountB); + } + + /// `Register` is emitted with the name and account both indexed, so the log + /// can be filtered by either. The log is the only enumeration of the + /// registry, so a binding that does not emit is a binding nobody can find. + function testRegisterEvent(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.recordLogs(); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + Vm.Log[] memory entries = vm.getRecordedLogs(); + + assertEq(entries.length, 1); + assertEq(entries[0].emitter, address(sRegistry)); + assertEq(entries[0].topics.length, 3); + assertEq(entries[0].topics[0], keccak256("Register(bytes32,address)")); + assertEq(entries[0].topics[1], name); + assertEq(entries[0].topics[2], bytes32(uint256(uint160(account)))); + assertEq(entries[0].data.length, 0); + } + + /// A re-binding emits its own `Register`, so the log is the full history and + /// the most recent entry for a name is its current binding. Without this an + /// indexer would still be serving the original binding after a rotation. + function testRegisterRebindEvent(bytes32 name, address bound, address account) external { + vm.assume(bound != address(0)); + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + + vm.recordLogs(); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + Vm.Log[] memory entries = vm.getRecordedLogs(); + + assertEq(entries.length, 1); + assertEq(entries[0].topics[1], name); + assertEq(entries[0].topics[2], bytes32(uint256(uint160(account)))); + } + + /// A rejected `register` emits nothing, so a failed bind can never be + /// mistaken for a binding by anything reading the logs. + function testRegisterNoEventOnRevert(address sender, bytes32 name, address account) external { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + + vm.recordLogs(); + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + assertEq(vm.getRecordedLogs().length, 0); + } +} diff --git a/test/src/lib/GeneratedSnapshotShape.t.sol b/test/src/lib/GeneratedSnapshotShape.t.sol new file mode 100644 index 0000000..ca445ab --- /dev/null +++ b/test/src/lib/GeneratedSnapshotShape.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; + +/// @title GeneratedSnapshotShapeTest +/// @notice What a generated deploy snapshot must look like, asserted against +/// the real generator's committed output. +/// +/// THESE ASSERTIONS ARE THE SPECIFICATION. There is no second hand-written file +/// to compare against and therefore no question about that file's provenance: +/// "there is an `address` constant named `DEPLOYED_ADDRESS`" is stated here, +/// once, in test code, and checked against what `script/Build.sol` actually +/// emitted. +/// +/// The check reads the compiler's AST rather than the source text, so it is +/// about the file's STRUCTURE and not its formatting. `forge build --ast` +/// writes the AST into the artifact JSON, and — usefully, since a snapshot +/// declares only file-level constants and no contract at all — foundry still +/// emits an artifact for such a file. +/// +/// Values are deliberately not asserted. A solc or optimiser change moves every +/// literal without changing anything here, and a wrong literal is caught +/// immediately by the group 1 derivation checks in `RainDeployVerifySnapshot`. +contract GeneratedSnapshotShapeTest is Test { + /// The artifact for the generated candidate snapshot, which carries its AST. + string constant ARTIFACT = "out/candidate/AddressRegistry.sol/AddressRegistry.json"; + + /// The node types of the generated snapshot's source unit, in file order. + /// + /// Indexed one at a time rather than with a `$.ast.nodes[*].nodeType` + /// wildcard: foundry's JSON path support requires a path to resolve to + /// exactly one value, so the wildcard is rejected outright. + /// @return types One entry per top-level AST node. + function nodeTypes() internal view returns (string[] memory types) { + string memory json = vm.readFile(ARTIFACT); + string[] memory found = new string[](64); + uint256 count = 0; + while (vm.keyExistsJson(json, string.concat("$.ast.nodes[", vm.toString(count), "].nodeType"))) { + found[count] = vm.parseJsonString(json, string.concat("$.ast.nodes[", vm.toString(count), "].nodeType")); + count++; + } + types = new string[](count); + for (uint256 i = 0; i < count; i++) { + types[i] = found[i]; + } + } + + /// The declared constants of the generated snapshot, in file order, as + /// ` ` — read from the AST, so formatting cannot affect it. + /// @return declarations One entry per file-level constant. + function constantDeclarations() internal view returns (string[] memory declarations) { + string memory json = vm.readFile(ARTIFACT); + string[] memory types = nodeTypes(); + + string[] memory found = new string[](types.length); + uint256 count = 0; + for (uint256 i = 0; i < types.length; i++) { + if (keccak256(bytes(types[i])) != keccak256(bytes("VariableDeclaration"))) { + continue; + } + string memory base = string.concat("$.ast.nodes[", vm.toString(i), "]"); + assertTrue(vm.parseJsonBool(json, string.concat(base, ".constant")), "snapshot declared a non-constant"); + found[count] = string.concat( + vm.parseJsonString(json, string.concat(base, ".typeName.name")), + " ", + vm.parseJsonString(json, string.concat(base, ".name")) + ); + count++; + } + declarations = new string[](count); + for (uint256 i = 0; i < count; i++) { + declarations[i] = found[i]; + } + } + + /// PROPERTY: the snapshot declares exactly the four constants a deploy + /// record is for, of these types, in this order. A rename, a retype, a + /// reorder or a fifth constant each fail here and name what broke. + function testSnapshotDeclaresTheFourDeployConstantsInOrder() external view { + string[] memory declarations = constantDeclarations(); + + assertEq(declarations.length, 4, "snapshot declares an unexpected number of constants"); + assertEq(declarations[0], "bytes32 BYTECODE_HASH", "first constant is not bytes32 BYTECODE_HASH"); + assertEq(declarations[1], "address DEPLOYED_ADDRESS", "second constant is not address DEPLOYED_ADDRESS"); + assertEq(declarations[2], "bytes CREATION_CODE", "third constant is not bytes CREATION_CODE"); + assertEq(declarations[3], "bytes RUNTIME_CODE", "fourth constant is not bytes RUNTIME_CODE"); + } + + /// PROPERTY: the snapshot imports nothing. It is read by repos that do not + /// have the contract it describes — that is the whole reason a frozen + /// release stays verifiable after its source has changed or gone — so any + /// import would make it unusable to exactly the readers it exists for. + function testSnapshotImportsNothing() external view { + string[] memory types = nodeTypes(); + for (uint256 i = 0; i < types.length; i++) { + assertNotEq(types[i], "ImportDirective", "snapshot imports something"); + } + } + + /// PROPERTY: the snapshot declares no contract, library or interface. It is + /// a record, not code; anything deployable in it would be a second + /// definition of something the record is supposed to describe. + function testSnapshotDeclaresNoContract() external view { + string[] memory types = nodeTypes(); + for (uint256 i = 0; i < types.length; i++) { + assertNotEq(types[i], "ContractDefinition", "snapshot declares a contract"); + } + } + + /// PROPERTY: the snapshot says it is generated. Someone who opens it must + /// be told not to hand-edit it, because hand-editing is how a deploy record + /// stops describing the deployment. + function testSnapshotSaysItIsGenerated() external view { + assertTrue( + vm.contains( + vm.readFile("src/generated/candidate/AddressRegistry.sol"), + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND." + ), + "snapshot is missing the generated-file header" + ); + } +} diff --git a/test/src/lib/LibAddressRegistry.t.sol b/test/src/lib/LibAddressRegistry.t.sol new file mode 100644 index 0000000..6044495 --- /dev/null +++ b/test/src/lib/LibAddressRegistry.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibAddressRegistry} from "../../../src/lib/LibAddressRegistry.sol"; +import {LibAddressRegistryDeploy} from "../../../src/lib/LibAddressRegistryDeploy.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title LibAddressRegistryTest +/// Tests for `LibAddressRegistry`. The registry is not mocked: the real +/// `AddressRegistry` is deployed through the Zoltu factory, which is what puts +/// it at the pinned address with the pinned code hash, so every test runs +/// against the same bytecode a network would. +/// +/// External wrappers are used for the library function so `vm.expectRevert` +/// lands at the correct call depth. +contract LibAddressRegistryTest is Test { + /// Deploys `AddressRegistry` through the Zoltu factory, which lands it at + /// the pinned address. + /// @return The deployed registry. + function deployRegistry() internal returns (IAddressRegistryV1) { + LibRainDeploy.etchZoltuFactory(vm); + return IAddressRegistryV1(LibRainDeploy.deployZoltu(type(AddressRegistry).creationCode)); + } + + /// External wrapper for `resolve` so that `vm.expectRevert` works at the + /// correct call depth. + /// @param name The name to resolve. + /// @return The address bound to `name`. + function externalResolve(bytes32 name) external view returns (address) { + return LibAddressRegistry.resolve(name); + } + + /// A bound name resolves to the address it is bound to. + function testResolveRegistered(bytes32 name, address account) external { + vm.assume(account != address(0)); + IAddressRegistryV1 registry = deployRegistry(); + + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, account); + + assertEq(LibAddressRegistry.resolve(name), account); + } + + /// `resolve` answers with the current binding, not the first one. A caller + /// that wants an answer which cannot move has to read once and store it — + /// the library deliberately does not pretend to offer that itself. + function testResolveFollowsRebinding(bytes32 name, address bound, address account) external { + vm.assume(bound != address(0)); + vm.assume(account != address(0)); + vm.assume(bound != account); + IAddressRegistryV1 registry = deployRegistry(); + + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, bound); + assertEq(LibAddressRegistry.resolve(name), bound); + + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, account); + assertEq(LibAddressRegistry.resolve(name), account); + } + + /// An unbound name reverts. The registry, not this library, is what refuses + /// to answer with the zero address, so the revert arrives unmodified. + function testResolveUnregistered(bytes32 name) external { + deployRegistry(); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + this.externalResolve(name); + } + + /// A chain with no registry deployed reverts on the code hash rather than + /// calling into an empty account, which would otherwise succeed silently + /// and return nothing. + function testResolveNoRegistry(bytes32 name) external { + assertEq(LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code.length, 0); + + vm.expectRevert( + abi.encodeWithSelector( + LibAddressRegistry.UnexpectedAddressRegistryCodeHash.selector, + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, + bytes32(0) + ) + ); + this.externalResolve(name); + } + + /// A chain where something other than the pinned registry occupies the + /// address reverts on the code hash, so a name is never resolved by code + /// the caller did not compile against. + function testResolveWrongCode(bytes32 name, bytes memory code) external { + vm.assume(code.length > 0); + vm.assume(keccak256(code) != LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH); + vm.etch(LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, code); + + vm.expectRevert( + abi.encodeWithSelector( + LibAddressRegistry.UnexpectedAddressRegistryCodeHash.selector, + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, + keccak256(code) + ) + ); + this.externalResolve(name); + } +} diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index 4991305..ecb81ec 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -4,6 +4,10 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; +import {MockResolvedOwner} from "../../concrete/MockResolvedOwner.sol"; +import {MockDirtyWordOwner} from "../../concrete/MockDirtyWordOwner.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import {MockReverter} from "../../concrete/MockReverter.sol"; @@ -305,9 +309,13 @@ contract LibRainDeployTest is Test { // Pinned literal, deliberately not `mockDeployableAddress()`. The live // factory on the fork is the oracle here, so an expected value taken // from the derivation would only check `zoltuAddress` against itself. - // It is the address the factory returns for the creation code solc - // 0.8.25 emits for `MockDeployable`, which itself pins `=0.8.25`. - assertEq(deployed, 0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854); + // It is the address the factory returns for the creation code this + // repo's compiler settings emit for `MockDeployable` — solc 0.8.25, + // optimizer on at 100,000 runs, targeting cancun. Those settings are + // now pinned exactly in `foundry.toml`, because this repo's deploy pins + // depend on them; that is what makes a literal here stable at all, and + // moving any of them moves this address. + assertEq(deployed, 0x0c04367b381F8Ca252aD2516F1Eac2b9B2ca928F); } /// `deployZoltu` MUST revert with `DeployFailed` when the Zoltu factory @@ -627,4 +635,327 @@ contract LibRainDeployTest is Test { dependencies ); } + + /// Deploys `AddressRegistry` through the Zoltu factory (which lands it at + /// its pinned address), binds `name` to `account` as root, then deploys a + /// consumer that resolves `name` once in its constructor. + /// @param name The name to bind and resolve. + /// @param account The address to bind it to. + /// @return registry The deployed registry. + /// @return consumer The deployed consumer holding the resolved address. + function deployRegistryAndConsumer(bytes32 name, address account) + internal + returns (IAddressRegistryV1 registry, MockResolvedOwner consumer) + { + LibRainDeploy.etchZoltuFactory(vm); + registry = IAddressRegistryV1(LibRainDeploy.deployZoltu(type(AddressRegistry).creationCode)); + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, account); + consumer = new MockResolvedOwner(name); + } + + /// The calldata for reading `MockResolvedOwner`'s stored address. + /// @return The single-element read call list. + function ownerReadCalls() internal pure returns (bytes[] memory) { + bytes[] memory readCalls = new bytes[](1); + readCalls[0] = abi.encodeWithSignature("iOwner()"); + return readCalls; + } + + /// A single-element expected address list. + /// @param account The expected address. + /// @return The list. + function expected(address account) internal pure returns (address[] memory) { + address[] memory expectedAddresses = new address[](1); + expectedAddresses[0] = account; + return expectedAddresses; + } + + /// External wrapper for `checkResolvedAddresses` so that `vm.expectRevert` + /// works at the correct call depth. + /// @param network The network name, for the error only. + /// @param target The deployed contract to read. + /// @param readCalls The calldata for each read. + /// @param expectedAddresses The address each read MUST answer with. + function externalCheckResolvedAddresses( + string memory network, + address target, + bytes[] memory readCalls, + address[] memory expectedAddresses + ) external view { + LibRainDeploy.checkResolvedAddresses(network, target, readCalls, expectedAddresses); + } + + /// External wrapper for `checkResolvedAddressesOnNetworks` so that + /// `vm.expectRevert` works at the correct call depth. + /// @param networks The list of network names to check. + /// @param target The deployed contract to read on each network. + /// @param readCalls The calldata for each read. + /// @param expectedAddresses The address each read MUST answer with. + function externalCheckResolvedAddressesOnNetworks( + string[] memory networks, + address target, + bytes[] memory readCalls, + address[] memory expectedAddresses + ) external { + LibRainDeploy.checkResolvedAddressesOnNetworks(vm, networks, target, readCalls, expectedAddresses); + } + + /// `checkResolvedAddresses` MUST pass when the deployed contract holds the + /// address the deployment expects. + function testCheckResolvedAddressesMatch(bytes32 name, address account) external { + vm.assume(account != address(0)); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + + LibRainDeploy.checkResolvedAddresses("test_network", address(consumer), ownerReadCalls(), expected(account)); + } + + /// The check is against settled state, which is the entire point of running + /// it after the deploy rather than before. Re-binding the name afterwards + /// changes what the registry answers but cannot change what the deployed + /// contract holds, so the check still passes against the address the + /// deployment actually took. + function testCheckResolvedAddressesUnaffectedByRebinding(bytes32 name, address account, address rebound) external { + vm.assume(account != address(0)); + vm.assume(rebound != address(0)); + vm.assume(rebound != account); + (IAddressRegistryV1 registry, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, rebound); + assertEq(registry.get(name), rebound); + + assertEq(consumer.iOwner(), account); + LibRainDeploy.checkResolvedAddresses("test_network", address(consumer), ownerReadCalls(), expected(account)); + + // And the value the registry now answers with is NOT what this + // deployment holds, so a check against it fails. + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.UnexpectedResolvedAddress.selector, + "test_network", + address(consumer), + uint256(0), + rebound, + account + ) + ); + this.externalCheckResolvedAddresses("test_network", address(consumer), ownerReadCalls(), expected(rebound)); + } + + /// `checkResolvedAddresses` MUST revert with `UnexpectedResolvedAddress` + /// when the deployed contract holds something else, naming the network and + /// which read disagreed. + function testCheckResolvedAddressesMismatchReverts(bytes32 name, address account, address wrong) external { + vm.assume(account != address(0)); + vm.assume(wrong != account); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.UnexpectedResolvedAddress.selector, + "test_network", + address(consumer), + uint256(0), + wrong, + account + ) + ); + this.externalCheckResolvedAddresses("test_network", address(consumer), ownerReadCalls(), expected(wrong)); + } + + /// `checkResolvedAddresses` MUST check every read, not only the first, so a + /// later value that disagrees is still caught. + function testCheckResolvedAddressesChecksEveryRead(bytes32 name, address account, address wrong) external { + vm.assume(account != address(0)); + vm.assume(wrong != account); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + + bytes[] memory readCalls = new bytes[](2); + readCalls[0] = abi.encodeWithSignature("iOwner()"); + readCalls[1] = abi.encodeWithSignature("iOwner()"); + address[] memory expectedAddresses = new address[](2); + expectedAddresses[0] = account; + expectedAddresses[1] = wrong; + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.UnexpectedResolvedAddress.selector, + "test_network", + address(consumer), + uint256(1), + wrong, + account + ) + ); + this.externalCheckResolvedAddresses("test_network", address(consumer), readCalls, expectedAddresses); + } + + /// A read that cannot be answered is never a pass. An address with no code + /// static-calls successfully and returns nothing, which would compare equal + /// to nothing at all if the length were not checked. + function testCheckResolvedAddressesUnreadableTargetReverts(address target, address account) external { + vm.assume(target.code.length == 0); + // The low address space is reserved for precompiles, which have no code + // and yet DO answer — the identity precompile echoes its calldata back, + // so a read of one returns data rather than nothing. They are not + // instances of the case under test. Bounded by the reserved range rather + // than by listing today's precompiles, so a chain or fork that adds one + // does not reintroduce this. + vm.assume(uint160(target) > 0xffff); + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.ResolvedAddressReadFailed.selector, "test_network", target, uint256(0), bytes("") + ) + ); + this.externalCheckResolvedAddresses("test_network", target, ownerReadCalls(), expected(account)); + } + + /// A read that reverts is never a pass either. + function testCheckResolvedAddressesRevertingReadReverts(bytes32 name, address account) external { + vm.assume(account != address(0)); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + + bytes[] memory readCalls = new bytes[](1); + readCalls[0] = abi.encodeWithSignature("thisFunctionDoesNotExist()"); + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.ResolvedAddressReadFailed.selector, + "test_network", + address(consumer), + uint256(0), + bytes("") + ) + ); + this.externalCheckResolvedAddresses("test_network", address(consumer), readCalls, expected(account)); + } + + /// A read that answers with one word whose upper 96 bits are dirty has not + /// answered with an address, and MUST be reported as + /// `ResolvedAddressReadFailed` — the error whose stated subject is a read + /// that answers with something that is not a single address-sized word. + /// + /// The expected address here is the word's own low 160 bits, so the only + /// thing wrong with the answer is the dirty bits. That rules out both ways + /// of getting this wrong at once: truncating the word silently PASSES this + /// check against an address the read never gave, and decoding it as an + /// address reverts inside the decoder with no return data at all — a bare + /// revert naming neither the network, the target, nor which read produced + /// it, which is exactly what this error exists to avoid. + function testCheckResolvedAddressesDirtyWordReverts(bytes32 word) external { + // Only the words that are not an address. A clean one is an address and + // decodes, which is `testCheckResolvedAddressesMatch`'s case. + vm.assume(uint256(word) > type(uint160).max); + MockDirtyWordOwner target = new MockDirtyWordOwner(word); + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.ResolvedAddressReadFailed.selector, + "test_network", + address(target), + uint256(0), + abi.encode(word) + ) + ); + this.externalCheckResolvedAddresses( + "test_network", address(target), ownerReadCalls(), expected(address(uint160(uint256(word)))) + ); + } + + /// `checkResolvedAddresses` MUST revert when the reads and expected + /// addresses do not pair up, rather than checking the shorter of the two. + function testCheckResolvedAddressesLengthMismatchReverts(uint8 readCallsLength, uint8 expectedLength) external { + vm.assume(readCallsLength != expectedLength); + + bytes[] memory readCalls = new bytes[](readCallsLength); + address[] memory expectedAddresses = new address[](expectedLength); + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.ResolvedAddressesLengthMismatch.selector, + uint256(readCallsLength), + uint256(expectedLength) + ) + ); + this.externalCheckResolvedAddresses("test_network", address(this), readCalls, expectedAddresses); + } + + /// `checkResolvedAddressesOnNetworks` MUST revert with `NoNetworks` when + /// given none, so an empty target set can never be mistaken for every read + /// checking out. + function testCheckResolvedAddressesOnNetworksNoNetworksReverts(address account) external { + string[] memory networks = new string[](0); + + vm.expectRevert(abi.encodeWithSelector(LibRainDeploy.NoNetworks.selector)); + this.externalCheckResolvedAddressesOnNetworks(networks, address(this), ownerReadCalls(), expected(account)); + } + + /// `checkResolvedAddressesOnNetworks` MUST check the reads and expected + /// addresses pair up before it forks anything, so a mispaired call is + /// reported without any network being reachable at all. + function testCheckResolvedAddressesOnNetworksLengthMismatchRevertsBeforeForking() external { + string[] memory networks = new string[](1); + // Not a configured RPC alias, so forking it is itself an error. + networks[0] = "unconfigured_network"; + bytes[] memory readCalls = new bytes[](2); + address[] memory expectedAddresses = new address[](1); + + vm.expectRevert( + abi.encodeWithSelector(LibRainDeploy.ResolvedAddressesLengthMismatch.selector, uint256(2), uint256(1)) + ); + this.externalCheckResolvedAddressesOnNetworks(networks, address(this), readCalls, expectedAddresses); + } + + /// `checkResolvedAddressesOnNetworks` MUST fork each network in turn and + /// pass when the deployed contract holds the expected address on all of + /// them. The deployment is made persistent so the same contract is present + /// on every fork, which is the state a real multi-network deploy leaves + /// behind. + /// + /// Two networks rather than `supportedNetworks()`. What is under test is + /// that the loop visits every network it is given, which two prove as well + /// as five; the roster itself is `testSupportedNetworks`'s job. These are + /// the two networks the rest of this suite forks, so the test does not + /// depend on the reliability of RPC endpoints nothing else here touches. + function testCheckResolvedAddressesOnNetworksEachNetwork() external { + bytes32 name = keccak256("testCheckResolvedAddressesOnNetworksEachNetwork"); + address account = address(0xf00); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + vm.makePersistent(address(consumer)); + + string[] memory networks = new string[](2); + networks[0] = LibRainDeploy.ARBITRUM_ONE; + networks[1] = LibRainDeploy.BASE; + + LibRainDeploy.checkResolvedAddressesOnNetworks( + vm, networks, address(consumer), ownerReadCalls(), expected(account) + ); + } + + /// `checkResolvedAddressesOnNetworks` MUST fail on the network that + /// disagrees, and MUST name it. + function testCheckResolvedAddressesOnNetworksMismatchReverts() external { + bytes32 name = keccak256("testCheckResolvedAddressesOnNetworksMismatchReverts"); + address account = address(0xf00); + address wrong = address(0xba4); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + vm.makePersistent(address(consumer)); + + string[] memory networks = new string[](1); + networks[0] = LibRainDeploy.BASE; + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.UnexpectedResolvedAddress.selector, + LibRainDeploy.BASE, + address(consumer), + uint256(0), + wrong, + account + ) + ); + this.externalCheckResolvedAddressesOnNetworks(networks, address(consumer), ownerReadCalls(), expected(wrong)); + } } diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol new file mode 100644 index 0000000..ddf4d65 --- /dev/null +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -0,0 +1,675 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; + +import {DeploySuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import { + EmptyRelease, + LibRainDeploySnapshot, + NothingToFreeze, + UnreleasableVersion +} from "../../../src/lib/LibRainDeploySnapshot.sol"; +import {MockDeployable} from "../../concrete/MockDeployable.sol"; + +/// @title LibRainDeploySnapshotTest +/// @notice The guards on the release machinery every deploy repo inherits. +/// +/// These are the paths that only run when something has already gone wrong, so +/// nothing else exercises them — and a guard nobody has seen fire is a guard +/// nobody knows works. Each is driven here directly. +contract LibRainDeploySnapshotTest is Test { + /// External wrapper so `vm.expectRevert` lands at the right call depth. + /// @param version The version to convert. + /// @return The tag. + function externalTagForVersion(string memory version) external pure returns (string memory) { + return LibRainDeploySnapshot.tagForVersion(version); + } + + /// A regeneration that does nothing, for driving `freeze`'s guards. Every + /// one of them either fires before this runs or is about what it left + /// behind, so a no-op is what makes "the guard fired" and "the guard fired + /// FIRST" the same observation. + function noRegeneration() internal {} + + /// External wrapper so `vm.expectRevert` lands at the right call depth. + /// @param contractNames The contracts to freeze. + function externalFreeze(string[] memory contractNames) external { + LibRainDeploySnapshot.freeze(vm, noRegeneration, contractNames); + } + + /// Where the record fixture is built. NOT `src/generated`: the inherited + /// record check reads that root, in other contracts, which forge runs in + /// parallel with this one — a fixture release there would be a release + /// those contracts have to fail on, for as long as it exists. + string constant FIXTURE_ROOT = "test/generated"; + + /// Writes one file into the fixture record. + /// + /// Carries a licence header because a run that fails midway leaves it + /// behind, and an unlicensed file in the tree is a second failure on top of + /// the first. + /// @param path The file to write, under `FIXTURE_ROOT`. + function writeFixture(string memory path) internal { + string[] memory components = vm.split(path, "/"); + string memory dir = components[0]; + for (uint256 i = 1; i < components.length - 1; i++) { + dir = string.concat(dir, "/", components[i]); + } + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(dir, true); + // Split so `reuse lint` reads this as a fixture rather than as this + // file's own license declaration -- an SPDX identifier in a string + // literal is indistinguishable from a real one to a line scanner. + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, string.concat("// SPDX-License", "-Identifier: LicenseRef-DCL-1.0\n")); + } + + /// Whether `paths` holds `path`. The walk's order is the filesystem's, so + /// membership is the only thing worth asserting about it. + /// @param paths The paths returned by the walk. + /// @param path The path to look for. + /// @return Whether it is there. + function holdsPath(string[] memory paths, string memory path) internal pure returns (bool) { + for (uint256 i = 0; i < paths.length; i++) { + if (keccak256(bytes(paths[i])) == keccak256(bytes(path))) { + return true; + } + } + return false; + } + + /// A release tag is what `tagForVersion` produces and nothing else, so + /// exactly the directories a freeze can write are the ones the record + /// counts. The rolling `candidate/` is excluded by that same rule rather + /// than by name. + function testIsTagAcceptsWhatAFreezeCanWrite() external pure { + assertTrue(LibRainDeploySnapshot.isTag(LibRainDeploySnapshot.tagForVersion("0.1.7"))); + assertTrue(LibRainDeploySnapshot.isTag("0_0_0")); + assertTrue(LibRainDeploySnapshot.isTag("10_20_30")); + + assertFalse(LibRainDeploySnapshot.isTag(LibRainDeploySnapshot.CANDIDATE)); + assertFalse(LibRainDeploySnapshot.isTag("0_1")); + assertFalse(LibRainDeploySnapshot.isTag("0_1_7-rc1")); + assertFalse(LibRainDeploySnapshot.isTag("0.1.7")); + assertFalse(LibRainDeploySnapshot.isTag("_1_7")); + assertFalse(LibRainDeploySnapshot.isTag("0_1_")); + assertFalse(LibRainDeploySnapshot.isTag("")); + assertFalse(LibRainDeploySnapshot.isTag("collision-guard")); + } + + /// EVERY version a freeze accepts MUST produce a directory the record + /// recognises as a release. A version that could be frozen to a directory + /// the record then ignores is exactly the orphan snapshot + /// `UnreleasableVersion` exists to prevent, and it is what two spellings of + /// the version rule would eventually produce. + function testEveryFreezableVersionIsATagTheRecordFinds(uint8 major, uint8 minor, uint8 patch) external pure { + string memory version = string.concat( + vm.toString(uint256(major)), ".", vm.toString(uint256(minor)), ".", vm.toString(uint256(patch)) + ); + + assertTrue(LibRainDeploySnapshot.isStrictTriple(version, ".")); + assertTrue(LibRainDeploySnapshot.isTag(LibRainDeploySnapshot.tagForVersion(version))); + } + + /// The record is every file inside a release-tag directory, and only those. + /// + /// The whole point of reading the tree is that it is the one description of + /// what a repo has released that cannot fall behind, so a walk that quietly + /// found nothing would leave exactly the hole it is here to close. Driven + /// against a directory that really has releases in it. + function testFrozenSnapshotPathsFindsEveryReleaseAndNothingElse() external { + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_1/MockDeployable.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_2/MockDeployableV2.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_2/Second.sol")); + // Not releases: the rolling snapshot, a version no freeze could have + // written, a file loose in the root, and a file too deep to be a record. + writeFixture(string.concat(FIXTURE_ROOT, "/", LibRainDeploySnapshot.CANDIDATE, "/MockDeployable.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_3-rc1/MockDeployable.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/Loose.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_1/nested/TooDeep.sol")); + + string[] memory paths = LibRainDeploySnapshot.frozenSnapshotPaths(vm, FIXTURE_ROOT); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(FIXTURE_ROOT, true); + + assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_1/MockDeployable.sol"))); + assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_2/MockDeployableV2.sol"))); + assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_2/Second.sol"))); + assertEq(paths.length, 3); + } + + /// Where the missing-root case reads. Its own tree, for the same reason + /// `RELEASED_FIXTURE_ROOT` is not `FIXTURE_ROOT`: a root another test in + /// this contract builds and tears down is not a root this one can assert is + /// absent, and nothing writes here at all. + string constant MISSING_FIXTURE_ROOT = "test/generated-missing"; + + /// A root that is not there at all MUST read as a repo that has released + /// nothing, not as a failure. That is the state of every deploy repo before + /// its first release, including this one. + function testFrozenSnapshotPathsOnAMissingRoot() external view { + assertFalse(vm.exists(MISSING_FIXTURE_ROOT)); + assertEq(LibRainDeploySnapshot.frozenSnapshotPaths(vm, MISSING_FIXTURE_ROOT).length, 0); + } + + /// The rolling snapshot MUST NOT be in this repo's own record. It is the + /// only directory in `src/generated/` today, and a walk that returned it + /// would make the candidate a release that has to be declared and deployed. + function testFrozenSnapshotPathsExcludesTheRollingSnapshot() external view { + assertTrue(vm.exists(LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, "AddressRegistry"))); + assertFalse( + holdsPath( + LibRainDeploySnapshot.frozenSnapshotPaths(vm, LibRainDeploySnapshot.LIB_FS_ROOT), + LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, "AddressRegistry") + ) + ); + } + + /// A strict `X.Y.Z` version MUST become its directory form. + function testTagForVersionConvertsDots() external pure { + assertEq(LibRainDeploySnapshot.tagForVersion("0.1.7"), "0_1_7"); + assertEq(LibRainDeploySnapshot.tagForVersion("10.20.30"), "10_20_30"); + assertEq(LibRainDeploySnapshot.tagForVersion("0.0.0"), "0_0_0"); + } + + /// Anything that is not strict `X.Y.Z` MUST be refused rather than mapped + /// to a directory the append-only gate ignores forever. + function testTagForVersionRefusesNonStrict() external { + string[9] memory bad = ["0.1.7-rc1", "0.1", "0.1.7.1", "", "a.b.c", ".1.7", "0..7", "0.1.", "0.1.7 "]; + for (uint256 i = 0; i < bad.length; i++) { + vm.expectRevert(abi.encodeWithSelector(UnreleasableVersion.selector, bad[i])); + this.externalTagForVersion(bad[i]); + } + } + + /// The tag read from `foundry.toml` MUST go through the same guard, so a + /// repo cannot reach a release path with a version the guard would refuse. + function testDeployTagUsesTheGuardedConversion() external view { + assertEq( + LibRainDeploySnapshot.deployTag(vm), + LibRainDeploySnapshot.tagForVersion(vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version")) + ); + } + + /// Snapshot paths MUST agree with `LibFs`, which is what writes them. + function testSnapshotPathsAgreeWithTheWriter() external pure { + assertEq(LibRainDeploySnapshot.dirForSnapshot("0_1_7"), "src/generated/0_1_7"); + assertEq(LibRainDeploySnapshot.snapshotName("0_1_7", "Foo"), "0_1_7/Foo"); + assertEq(LibRainDeploySnapshot.pathForSnapshot("0_1_7", "Foo"), "src/generated/0_1_7/Foo.sol"); + } + + /// A snapshot MUST land at the path this library says it does, and writing + /// one over a directory that is already there is the ORDINARY case: the + /// rolling snapshot is regenerated into the same `candidate/` on every + /// build. + /// + /// Written into a directory that is not tag shaped on purpose. The record + /// root is the real `src/generated/`, which the inherited record check + /// walks from other contracts that forge runs in parallel with this one, so + /// a tag-shaped name here would be a release those contracts have to fail + /// on for as long as it exists. + function testWriteSnapshotWritesTheSnapshotAtItsPath() external { + string memory dir = "write-snapshot-not-a-tag"; + assertFalse(LibRainDeploySnapshot.isTag(dir)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + + string memory written = + LibRainDeploySnapshot.writeSnapshot(vm, dir, "MockDeployable", type(MockDeployable).creationCode); + // Read while the snapshot is still there, asserted once it is gone. + bool exists = vm.exists(written); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + + assertEq(written, LibRainDeploySnapshot.pathForSnapshot(dir, "MockDeployable")); + assertTrue(exists); + } + + /// Where the released-lib record fixture is built. Its own tree rather + /// than `FIXTURE_ROOT`: forge runs the tests in a contract concurrently, + /// and two of them writing one record root see each other's releases. + string constant RELEASED_FIXTURE_ROOT = "test/generated-released"; + + /// Where the selection fixture's record is built, for the same reason + /// `RELEASED_FIXTURE_ROOT` is not `FIXTURE_ROOT`. + string constant SELECTED_FIXTURE_ROOT = "test/generated-selected"; + + /// The contract the fixture record freezes, and the one the writer is + /// pointed at there. NOT this repo's own `AddressRegistry`: the writer + /// derives the file it writes from the contract name, and the committed + /// declaration is not a file a fixture gets to overwrite. + string constant FIXTURE_CONTRACT = "MockDeployable"; + + /// A second contract frozen under one of the fixture record's tags, as a + /// release naming two contracts writes it. Its name starts with + /// `FIXTURE_CONTRACT`, so a selection that matched on a prefix would take + /// it too. + string constant FIXTURE_CONTRACT_SECOND = "MockDeployableV2"; + + /// The contract every emitter test emits a released lib for. + string constant EMITTED_CONTRACT = "AddressRegistry"; + + /// The library every emitter test emits, derived from `EMITTED_CONTRACT` + /// exactly as `writeReleasedSuitesLib` derives it. + string constant EMITTED_LIBRARY = "LibAddressRegistryReleased"; + + /// The candidate declaration the emitted metadata comes from. + /// + /// Every CONSENSUS field is zero, so an emitter that took one of the four + /// frozen fields from the template rather than from the record would emit a + /// zero and be seen doing it. Only the key, the artifact path and the + /// dependencies are meant to come from here. + /// @return The template. + function emitterTemplate() internal pure returns (DeploySuite memory) { + return DeploySuite({ + suite: "address-registry", + creationCode: "", + storedDeployedAddress: address(0), + storedBytecodeHash: bytes32(0), + storedRuntimeCode: "", + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + } + + /// A record of `count` releases, `0_0_1` upwards, as + /// `frozenSnapshotPaths` spells them. + /// @param count How many releases. + /// @return paths The record's files. + function recordOf(uint256 count) internal pure returns (string[] memory paths) { + paths = new string[](count); + for (uint256 i = 0; i < count; i++) { + paths[i] = string.concat( + LibRainDeploySnapshot.LIB_FS_ROOT, "/0_0_", vm.toString(i + 1), "/", EMITTED_CONTRACT, ".sol" + ); + } + } + + /// The aliased import one release contributes. + /// @param tag The release tag. + /// @return The import statement, and the blank line after it. + function expectedImport(string memory tag) internal pure returns (string memory) { + return string.concat( + "import {\n DEPLOYED_ADDRESS as AddressRegistry_", + tag, + "_DEPLOYED_ADDRESS,\n BYTECODE_HASH as AddressRegistry_", + tag, + "_BYTECODE_HASH,\n CREATION_CODE as AddressRegistry_", + tag, + "_CREATION_CODE,\n RUNTIME_CODE as AddressRegistry_", + tag, + "_RUNTIME_CODE\n} from \"../generated/", + tag, + "/AddressRegistry.sol\";\n\n" + ); + } + + /// The generated library's text from its title down to the line that opens + /// `releasedSuites`. The same for every record, so the per-record + /// assertions below are about the entries. + string constant EXPECTED_LIBRARY_HEADER = "/// @title LibAddressRegistryReleased\n" + "/// @notice Every frozen release of `AddressRegistry`: one entry per file in\n" + "/// the append-only `src/generated//` record, in tag order.\n" "///\n" + "/// The deploy address, code hash, creation code and runtime code of each\n" + "/// entry are aliased from that release's own frozen snapshot, so the\n" + "/// consensus record is read from the immutable file and from nowhere else.\n" "///\n" + "/// The key, the artifact path and the dependencies are explorer and ordering\n" + "/// metadata regenerated from the CURRENT declaration, and are not part of\n" + "/// that record. A moved source path retroactively updates every entry's\n" + "/// artifact path, which is intended: the alternative is parsing this\n" + "/// generated file back in to preserve what it last said.\n" "library LibAddressRegistryReleased {\n" + " /// Every frozen release, in tag order.\n" " /// @return suites The released suites.\n" + " function releasedSuites() internal pure returns (DeploySuite[] memory suites) {\n"; + + /// The entry one release contributes, with no dependencies. + /// @param index The entry's index. + /// @param tag The release tag. + /// @return The entry's statements. + function expectedEntry(string memory index, string memory tag) internal pure returns (string memory) { + return string.concat( + " address[] memory dependencies", + index, + " = new address[](0);\n suites[", + index, + "] = DeploySuite({\n suite: \"address-registry@", + tag, + "\",\n creationCode: AddressRegistry_", + tag, + "_CREATION_CODE,\n storedDeployedAddress: AddressRegistry_", + tag, + "_DEPLOYED_ADDRESS,\n storedBytecodeHash: AddressRegistry_", + tag, + "_BYTECODE_HASH,\n storedRuntimeCode: AddressRegistry_", + tag, + "_RUNTIME_CODE,\n artifactPath: \"src/concrete/AddressRegistry.sol:AddressRegistry\",\n", + " dependencies: dependencies", + index, + "\n });\n" + ); + } + + /// The import block MUST carry all four consensus constants of every record + /// file and nothing else, aliased so that two releases of one contract, and + /// two contracts in one release, are all distinct names. + /// + /// The four aliased fields are the whole of what a released entry says + /// about consensus, so an import that goes missing is a field that silently + /// falls back to whatever else is in scope. + function testReleasedImportBlockAliasesEveryRecord() external pure { + assertEq( + LibRainDeploySnapshot.releasedImportBlock(vm, recordOf(0)), + "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n" + ); + + assertEq( + LibRainDeploySnapshot.releasedImportBlock(vm, recordOf(1)), + string.concat( + "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n", expectedImport("0_0_1") + ) + ); + + assertEq( + LibRainDeploySnapshot.releasedImportBlock(vm, recordOf(2)), + string.concat( + "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n", + expectedImport("0_0_1"), + expectedImport("0_0_2") + ) + ); + } + + /// The library block MUST declare one suite per record file, taking the + /// four consensus fields from that file's aliased constants and the other + /// three from the template. + /// + /// A record with nothing in it is the state of every deploy repo before its + /// first release, including this one, and it MUST still emit a compiling + /// library: the declaration is imported by ordinary source, so a repo that + /// could not emit one before its first release could not build. + function testReleasedLibraryBlockDeclaresEveryRecord() external pure { + assertEq( + LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(0), emitterTemplate() + ), + string.concat(EXPECTED_LIBRARY_HEADER, " suites = new DeploySuite[](0);\n", " }\n}\n") + ); + + assertEq( + LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(1), emitterTemplate() + ), + string.concat( + EXPECTED_LIBRARY_HEADER, + " suites = new DeploySuite[](1);\n", + expectedEntry("0", "0_0_1"), + " }\n}\n" + ) + ); + + assertEq( + LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(2), emitterTemplate() + ), + string.concat( + EXPECTED_LIBRARY_HEADER, + " suites = new DeploySuite[](2);\n", + expectedEntry("0", "0_0_1"), + expectedEntry("1", "0_0_2"), + " }\n}\n" + ) + ); + } + + /// The key MUST be the template's with the tag appended, so a repo with + /// several releases of one contract declares several DISTINCT suites. + /// `allSuites` refuses a duplicate key, so a key that did not carry the tag + /// would make the second release unreachable and the whole declaration + /// revert. + function testReleasedLibraryBlockKeysAreUniquePerRelease() external pure { + string memory emitted = LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(2), emitterTemplate() + ); + + assertTrue(vm.contains(emitted, "suite: \"address-registry@0_0_1\"")); + assertTrue(vm.contains(emitted, "suite: \"address-registry@0_0_2\"")); + } + + /// The dependencies MUST be the template's, element for element. They are + /// what a broadcast checks is already on chain before it deploys anything, + /// so an entry that dropped them would deploy a suite whose constructor + /// reads an address with no code. + function testReleasedLibraryBlockCarriesTheTemplateDependencies() external pure { + DeploySuite memory template = emitterTemplate(); + template.dependencies = new address[](2); + template.dependencies[0] = address(0xdead); + template.dependencies[1] = address(0xbeef); + + assertEq( + LibRainDeploySnapshot.releasedLibraryBlock(vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(1), template), + string.concat( + EXPECTED_LIBRARY_HEADER, + " suites = new DeploySuite[](1);\n", + " address[] memory dependencies0 = new address[](2);\n", + " dependencies0[0] = address(", + vm.toString(address(0xdead)), + ");\n dependencies0[1] = address(", + vm.toString(address(0xbeef)), + ");\n suites[0] = DeploySuite({\n suite: \"address-registry@0_0_1\",\n", + " creationCode: AddressRegistry_0_0_1_CREATION_CODE,\n", + " storedDeployedAddress: AddressRegistry_0_0_1_DEPLOYED_ADDRESS,\n", + " storedBytecodeHash: AddressRegistry_0_0_1_BYTECODE_HASH,\n", + " storedRuntimeCode: AddressRegistry_0_0_1_RUNTIME_CODE,\n", + " artifactPath: \"src/concrete/AddressRegistry.sol:AddressRegistry\",\n", + " dependencies: dependencies0\n });\n", + " }\n}\n" + ) + ); + } + + /// Releases MUST be emitted in the order they were cut, comparing tags as + /// VERSIONS. `0_10_0` follows `0_9_0` as a release and precedes it as text, + /// so a sort on the raw string misorders every record that outlives a + /// single-digit component. + /// + /// Files frozen under one tag are ordered by path, so the emitted file does + /// not change with whatever order the filesystem happened to hand the walk + /// — a generated file that moves on its own is a diff on every build. A + /// record directory holds every file in it and there is no extension to + /// filter on, so one name being the whole start of another is a state the + /// order has to settle too. + function testSortedRecordPathsOrdersTagsAsVersions() external pure { + string[] memory paths = new string[](5); + paths[0] = "src/generated/1_0_0/AddressRegistry.sol"; + paths[1] = "src/generated/0_9_0/AddressRegistry.sol.orig"; + paths[2] = "src/generated/0_9_0/Second.sol"; + paths[3] = "src/generated/0_10_0/AddressRegistry.sol"; + paths[4] = "src/generated/0_9_0/AddressRegistry.sol"; + + string[] memory sorted = LibRainDeploySnapshot.sortedRecordPaths(vm, paths); + + assertEq(sorted.length, 5); + assertEq(sorted[0], "src/generated/0_9_0/AddressRegistry.sol"); + assertEq(sorted[1], "src/generated/0_9_0/AddressRegistry.sol.orig"); + assertEq(sorted[2], "src/generated/0_9_0/Second.sol"); + assertEq(sorted[3], "src/generated/0_10_0/AddressRegistry.sol"); + assertEq(sorted[4], "src/generated/1_0_0/AddressRegistry.sol"); + } + + /// A record holds every contract a repo has ever frozen, and a released lib + /// describes ONE of them. The selection MUST be by whole contract name, + /// over releases only, in tag order. + /// + /// Two contracts under one tag is what a release naming two of them writes. + /// Emitting the other one's snapshot into this contract's lib would give it + /// this contract's suite key, colliding with this contract's own entry for + /// that tag and reverting `allSuites()` for everything downstream. + /// + /// The fixture record is written newest tag first, so the order returned is + /// the sort's and not whatever order the walk came back in. + function testRecordPathsForContractSelectsOneContractInTagOrder() external { + writeFixture(string.concat(SELECTED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); + writeFixture(string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); + writeFixture(string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT_SECOND, ".sol")); + // Not releases: the rolling snapshot, and a scratch directory a test or + // a human left behind. + writeFixture( + string.concat(SELECTED_FIXTURE_ROOT, "/", LibRainDeploySnapshot.CANDIDATE, "/", FIXTURE_CONTRACT, ".sol") + ); + writeFixture(string.concat(SELECTED_FIXTURE_ROOT, "/collision-guard/", FIXTURE_CONTRACT, ".sol")); + + string[] memory selected = + LibRainDeploySnapshot.recordPathsForContract(vm, SELECTED_FIXTURE_ROOT, FIXTURE_CONTRACT); + + // The contract asked for is the contract selected, and the one frozen + // beside it has its own single release rather than none. + string[] memory second = + LibRainDeploySnapshot.recordPathsForContract(vm, SELECTED_FIXTURE_ROOT, FIXTURE_CONTRACT_SECOND); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(SELECTED_FIXTURE_ROOT, true); + + assertEq(selected.length, 2); + assertEq(selected[0], string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); + assertEq(selected[1], string.concat(SELECTED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); + + assertEq(second.length, 1); + assertEq(second[0], string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT_SECOND, ".sol")); + } + + /// The writer MUST emit the record root it is HANDED, selected down to the + /// contract it names, and nothing else in that tree. + /// + /// The record root is a parameter, so a fixture record goes straight into + /// the writer: pointed at a root it was not given, or handed the record + /// whole, the file it writes says so. The fixture record is written newest + /// tag first, so the emitted order is the sort's and not the walk's. + /// + /// A fixture contract name, so the file written is the fixture's own and + /// not this repo's committed declaration. Removed BEFORE the assertions, + /// because an emitted lib importing a record that only a test wrote does + /// not compile, and forge-std assertions revert — undoing afterwards is + /// undoing in every case except the one this test exists to report. + function testWriteReleasedSuitesLibReadsTheRecordItIsHanded() external { + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT_SECOND, ".sol")); + // Not releases: the rolling snapshot, and a scratch directory a test or + // a human left behind. + writeFixture( + string.concat(RELEASED_FIXTURE_ROOT, "/", LibRainDeploySnapshot.CANDIDATE, "/", FIXTURE_CONTRACT, ".sol") + ); + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/collision-guard/", FIXTURE_CONTRACT, ".sol")); + + string[] memory paths = new string[](2); + paths[0] = string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol"); + paths[1] = string.concat(RELEASED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol"); + + string memory libraryName = string.concat("Lib", FIXTURE_CONTRACT, "Released"); + string memory path = string.concat("src/lib/", libraryName, ".sol"); + + string memory written = LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, RELEASED_FIXTURE_ROOT, FIXTURE_CONTRACT, emitterTemplate() + ); + + string memory emitted = vm.readFile(path); + // Built while the fixture record is still there: both emitters read it. + string memory expected = string.concat( + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", + "pragma solidity ^0.8.25;\n\n", + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + LibRainDeploySnapshot.releasedImportBlock(vm, paths), + LibRainDeploySnapshot.releasedLibraryBlock(vm, libraryName, FIXTURE_CONTRACT, paths, emitterTemplate()) + ); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeFile(path); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(RELEASED_FIXTURE_ROOT, true); + + assertEq(written, path); + assertEq(emitted, expected); + assertFalse(vm.contains(emitted, FIXTURE_CONTRACT_SECOND)); + assertFalse(vm.contains(emitted, LibRainDeploySnapshot.CANDIDATE)); + assertFalse(vm.contains(emitted, "collision-guard")); + } + + /// The released lib MUST land beside the alias lib, under the name derived + /// from the contract, holding exactly the prefix, imports and library the + /// emitters produce. + /// + /// Run against this repo's REAL record and its real contract, so what it + /// writes is the committed generated file — that is the whole of what makes + /// a stale generated file a test failure rather than a silent one. Restored + /// BEFORE the assertions run, because forge-std assertions revert: restoring + /// afterwards restores in every case except a failure, which is the only + /// case where the tree is dirty and the one this test exists to report. + function testWriteReleasedSuitesLibWritesTheLibAtItsPath() external { + string memory path = "src/lib/LibAddressRegistryReleased.sol"; + string memory before = vm.readFile(path); + + string memory written = LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, LibRainDeploySnapshot.LIB_FS_ROOT, EMITTED_CONTRACT, emitterTemplate() + ); + string memory emitted = vm.readFile(path); + + string[] memory paths = + LibRainDeploySnapshot.recordPathsForContract(vm, LibRainDeploySnapshot.LIB_FS_ROOT, EMITTED_CONTRACT); + string memory expected = string.concat( + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", + "pragma solidity ^0.8.25;\n\n", + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + LibRainDeploySnapshot.releasedImportBlock(vm, paths), + LibRainDeploySnapshot.releasedLibraryBlock(vm, EMITTED_LIBRARY, EMITTED_CONTRACT, paths, emitterTemplate()) + ); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, before); + + assertEq(written, path); + assertEq(emitted, expected); + } + + /// A freeze that names no contracts MUST be refused. It would write + /// nothing, report success, and leave `/` there — and an empty + /// `/` is a frozen tag, so the real cut of that release could never + /// happen afterwards. + function testFreezeRefusesAnEmptyRelease() external { + string memory tag = LibRainDeploySnapshot.deployTag(vm); + + vm.expectRevert(abi.encodeWithSelector(EmptyRelease.selector, tag)); + this.externalFreeze(new string[](0)); + + assertFalse(vm.exists(LibRainDeploySnapshot.dirForSnapshot(tag))); + } + + /// A freeze that throws MUST leave NOTHING behind. Filesystem cheatcodes + /// survive a revert, so a `/` created before the last thing that can + /// fail is a partial record that outlives the failure — and it is a frozen + /// tag, which the immutability check then refuses the retry of, forever. + /// The exit from that state is deleting a directory this design calls + /// append-only, so the ordering here is what keeps a failed release + /// retryable at all. + function testFreezeLeavesNothingBehindWhenThereIsNothingToFreeze() external { + string memory tag = LibRainDeploySnapshot.deployTag(vm); + string[] memory contractNames = new string[](1); + contractNames[0] = "NoSuchContract"; + + vm.expectRevert( + abi.encodeWithSelector( + NothingToFreeze.selector, + LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, contractNames[0]) + ) + ); + this.externalFreeze(contractNames); + + assertFalse(vm.exists(LibRainDeploySnapshot.dirForSnapshot(tag))); + } +}