diff --git a/.env.example b/.env.example index f88a536..8a26393 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ # `foundry.toml` has to resolve — not just the one for the network under test. # # These are public endpoints and they rate-limit. CI holds its own. +# +# Generated from `LibRainDeploy.supportedNetworkConfigs()` by `script/Build.sol`, +# between the markers and nowhere else. +# rain-deploy:generated:env:begin ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc BASE_RPC_URL=https://mainnet.base.org BASE_SEPOLIA_RPC_URL=https://sepolia.base.org @@ -14,3 +18,4 @@ FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc HYPEREVM_RPC_URL=https://rpc.hyperliquid.xyz/evm POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com ROBINHOOD_RPC_URL=https://rpc.mainnet.chain.robinhood.com +# rain-deploy:generated:env:end diff --git a/.gitignore b/.gitignore index d194c7a..02f7a46 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ out .pre-commit-config.yaml fixture-lib target +.staged-config diff --git a/.soldeerignore b/.soldeerignore index 38c6da4..7a10590 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -23,3 +23,4 @@ CLAUDE.md /slither.config.json /soldeer.lock /test +/.staged-config diff --git a/CLAUDE.md b/CLAUDE.md index f0dfb02..269add6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,3 +37,10 @@ relocated. deployment. The deploy and the chain matrix create every fork before selecting any, so such a failure takes the whole run before any network is checked rather than stopping partway down the list. +- **Foundry REFUSES a cheatcode write to the project root's own + `foundry.toml`.** "access to `foundry.toml` is not allowed", from a guard on + the path — no `fs_permissions` grant and no spelling of the path gets past it, + and `writeFile`, `writeLine` and `copyFile` are all refused. Reads are + allowed. That is why `BuildScript.run()` stages the spliced config under + `.staged-config/` and `script/build.sh` installs it, and why regenerating by + hand is `forge script ./script/Build.sol` FOLLOWED BY `./script/build.sh`. diff --git a/README.md b/README.md index f0b0b54..0a449bc 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,12 @@ for it to disagree with. A repo that wrote its suites out twice would have that bug available to it; this one does not. `BuildScript` carries both build entry points concrete. `run()` regenerates the -generated sources and freezes nothing; `cutRelease()` regenerates, freezes the -release as `src/generated//`, then regenerates from the record that now -holds it. Neither is `virtual`, so the entry point CI runs on every push has no -way to cut a release. +generated sources and the network config, and freezes nothing; `cutRelease()` +regenerates, freezes the release as `src/generated//`, then regenerates +from the record that now holds it. The config is written by `run()` alone: it is +not part of a release record, and `cutRelease()` is the one operation that +cannot be repeated. Neither is `virtual`, so the entry point CI runs on every +push has no way to cut a release. 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 @@ -129,16 +131,15 @@ 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. -Five groups, sorted by what each is anchored to and therefore by what each can +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, gone, or a wrong chain id | anything about a candidate | -| Config | `foundry.toml` | a network it cannot fork or verify on | anything about a suite | +| 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 | a missing deployment, or an alias on another chain | anything about a 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 @@ -187,39 +188,42 @@ 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. -The config group is the only one whose subject is the CONSUMER's own -`foundry.toml` rather than its suites. `supportedNetworks()` is what the deploy -broadcasts to and what the chain group forks, `[rpc_endpoints]` is what makes an -alias forkable and `[etherscan]` is what makes `--verify` resolve, so the three -lists are one list and drift between them is a defect in either direction: a -supported network missing from a section broadcasts and then fails after the gas -is spent, and a section entry no supported network names is config nothing ever -reads. An `[etherscan]` entry carrying neither `chain` nor `url` under an alias -foundry cannot resolve is worse than missing — it takes verification down for -every entry in the section, not only its own — so membership is not the whole of -that half: every entry has to carry at least one of `chain` or `url` as well. -That is asked of every entry rather than only of the aliases foundry cannot -resolve, because which aliases those are is foundry's own table, and stating the -chain an alias already resolves to resolves it to the same chain. - -It reads the raw file rather than forge's resolved config, because the values -are `${VAR}` interpolations that only exist in CI, and nothing it asserts is a -value — the keys and the entry shapes are both in the text. So it needs no RPC -and fails on the pull request that drifts rather than at dispatch time. Reading -the file at all is what a consumer has to allow: see [Install](#install). - -Whether a stated `chain` IS the network its alias forks is the one thing about -that config the text cannot settle, so it belongs to the chain group instead: -`testSupportedNetworkChainIdsAreBound` forks every supported network that states -one and compares it against `block.chainid`. A wrong id resolves and satisfies -every check that reads the text, and `chain` is what `--verify` submits, so the -deployment is verified against another chain's explorer after the gas is spent. -The same comparison catches the mirror case — an `[rpc_endpoints]` alias bound -to a different network than it names — which is worse, because every -chain-anchored assertion ever made through that alias was made somewhere nobody -named. An entry resolving through a `url` alone states no id and is skipped; -every entry being that way is refused rather than passed as a check with no -subject. +**The network config is generated, not compared.** +`LibRainDeploy.supportedNetworkConfigs()` is the single statement of the set — +each network's name, chain id, explorer url and default endpoint — and +`BuildScript.run()` writes `foundry.toml`'s `[rpc_endpoints]` and `[etherscan]` +sections and `.env.example`'s endpoint variables from it. `Git is clean` is the +enforcement, the same mechanism already holding `src/generated/`: a tree whose +config has drifted from the roster it pins fails the job every push runs. + +It reaches `foundry.toml` in two steps, because foundry refuses a filesystem +cheatcode write to the project root's own config whatever `fs_permissions` says. +`run()` stages the spliced files under `.staged-config/` and `script/build.sh` +installs them — see [Install](#install). Reads are allowed, which is what makes +the splice possible at all, and staging removes the hazard the direct write +would have carried: nothing under `forge test` can race a rewrite of the config +every other test reads, because nothing rewrites it. + +That is what a config group used to be for, and comparing is the weaker half of +it. A comparison keeps both statements, so every assertion in it is one somebody +had to think of and the prose around them drifts silently; generation leaves one +statement and there is nothing left to compare. It also settles what a +comparison could only report: every generated `[etherscan]` entry states +`chain`, so an entry carrying neither `chain` nor `url` under an alias foundry +cannot resolve — which takes verification down for every entry in the section +and not only its own — is no longer a state a consumer can be in. + +The roster is deliberately not overridable. A repo able to narrow it would +deploy to and verify fewer chains with nothing red, so a network arrives in a +consumer's config by a version bump and by nothing else. + +The one thing generation cannot settle is whether a declared chain id is the one +the bound endpoint reports. That is a claim about the world rather than about +the text, and it is what `--verify` submits, so it sits in the chain group: +`testSupportedNetworkChainIdsAreBound` forks every supported network and +compares `block.chainid` against the roster. A wrong id there is config that +resolves, passes everything that reads the file, and verifies a deployment +against the wrong explorer. ## Address registry @@ -661,19 +665,62 @@ The versions have to match: the import paths are version-qualified, which is deliberate — it is what stops a consumer's incompatible copy from silently satisfying these imports. -The config and chain groups both read the CONSUMING repo's `foundry.toml`, so -that repo has to allow it and has to have the sections to be read: +`BuildScript.run()` regenerates the CONSUMING repo's own `foundry.toml` and +`.env.example`, so that repo has to allow it: ```toml -fs_permissions = [{ access = "read", path = "./foundry.toml" }] +fs_permissions = [ + { access = "read", path = "./foundry.toml" }, + { access = "read", path = "./.env.example" }, + { access = "read-write", path = "./.staged-config" }, + { access = "read", path = "./script/build.sh" }, +] ``` -`[rpc_endpoints]` and `[etherscan]` then have to name exactly the networks in -`supportedNetworks()`, every `[etherscan]` entry has to carry at least one of -`chain` or `url`, and a `chain` it states has to be the chain id the endpoint -bound to that alias reports. Missing permission fails the check rather than -skipping it, which is the intended direction: a repo that cannot read its own -config is a repo whose config nothing has checked. +Read, not read-write, on the two generated files: foundry refuses every +filesystem cheatcode write to the project root's own `foundry.toml` — the guard +is on the path, so no `fs_permissions` grant and no spelling of the path gets +past it. `run()` therefore reads each file, splices its blocks, and writes the +result to `.staged-config/` under the same name. + +What installs it is `script/build.sh`, the hook rainix's `rainix-copy-artifacts` +runs after `forge script ./script/Build.sol` and before the `git diff` that +fails a stale tree. Copy this repo's — it needs no forge, no nix and no `--ffi`, +which is the other way a script could reach a shell and is not taken: the +invocation that matters passes no `--ffi`, and granting it there would hand FFI +to every consumer's build rather than to one step. + +```sh +# .gitignore +.staged-config +``` + +A repo with no `script/build.sh` is REFUSED rather than staged for, because +nothing else moves a staged file into place: generating for it would write the +roster where nothing reads it while the config went on saying whatever it said, +and the build would report success. + +Both files then need the markers the generated blocks are spliced between, once +each and the begin before the end. A file carrying neither is refused, naming +the file and the block, rather than having the section appended to it — which is +a duplicate key at the next forge startup: + +```toml +# rain-deploy:generated:rpc_endpoints:begin +# rain-deploy:generated:rpc_endpoints:end + +# rain-deploy:generated:etherscan:begin +# rain-deploy:generated:etherscan:end +``` + +```sh +# .env.example +# rain-deploy:generated:env:begin +# rain-deploy:generated:env:end +``` + +Everything outside the markers is the consumer's, and the build neither reads +nor moves it. ## Develop @@ -686,6 +733,14 @@ forge soldeer install # install deps declared in foundry.toml forge test ``` +Regenerating what this repo generates is two commands, and the second is not +optional — the first only stages the network config: + +```sh +nix develop -c forge script ./script/Build.sol +./script/build.sh +``` + Three of the CI jobs are rainix reusable workflows, not commands in the shell, and the fourth is repo-local. What each of them runs, which is what reproduces it locally: diff --git a/foundry.toml b/foundry.toml index 9edcdc1..da5187c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -36,11 +36,23 @@ bytecode_hash = "none" # 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. +# Build reads the version and the network sections from foundry.toml, stages the +# regenerated sections under .staged-config/ for script/build.sh to install, and +# writes the generated per-tag snapshots + the current-pin lib under src/. +# Nothing else in this repo touches the filesystem. fs_permissions = [ + # READ, not read-write. `BuildScript.regenerateConfig` reads these two and + # writes the spliced result to .staged-config/: foundry refuses a cheatcode + # write to this file whatever is granted here, so the write it would need is + # not a permission this list can hand out. { access = "read", path = "./foundry.toml" }, + { access = "read", path = "./.env.example" }, + # Where the spliced files are staged, for script/build.sh to install over the + # two above. Gitignored; the hook removes it once it has installed them. + { access = "read-write", path = "./.staged-config" }, + # `writeStagedConfig` refuses a repo with no build hook, which is a repo where + # nothing would install what it staged. + { access = "read", path = "./script/build.sh" }, { access = "read-write", path = "./src" }, # LibRainDeploySnapshotTest builds a record tree of its own under # test/generated-snapshot to drive the frozen-record walk. NOT src/generated: @@ -74,6 +86,12 @@ recursive_deps = false # _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. +# +# Generated from `LibRainDeploy.supportedNetworkConfigs()` by `script/Build.sol`, +# between the markers and nowhere else. Adding a network is an edit to the +# roster followed by a build, and `Git is clean` fails a tree where the two have +# come apart. +# rain-deploy:generated:rpc_endpoints:begin [rpc_endpoints] arbitrum = "${ARBITRUM_RPC_URL}" base = "${BASE_RPC_URL}" @@ -84,33 +102,23 @@ flare = "${FLARE_RPC_URL}" hyperevm = "${HYPEREVM_RPC_URL}" polygon = "${POLYGON_RPC_URL}" robinhood = "${ROBINHOOD_RPC_URL}" +# rain-deploy:generated:rpc_endpoints:end # `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. # -# Both sections are checked against `LibRainDeploy.supportedNetworks()`, in both -# directions, by `testSupportedNetworksAreFullyConfigured`. Adding a network is -# an edit to all three or a red test, not a broadcast that discovers it. -# -# `chain` is stated on EVERY entry, and `testSupportedNetworksAreFullyConfigured` -# requires at least one of `chain` or `url` on each. An entry with neither, under -# an alias foundry does not itself resolve to a chain, is not a missing key, it -# is a config error — "At least one of `url` or `chain` must be present for -# Etherscan config with unknown alias" — raised while resolving the section, so -# it takes down verification for every network in it and not only its own. -# -# Stated on every entry rather than only on the aliases foundry cannot resolve, -# because which aliases those are is foundry's own table and that table moves -# under a toolchain bump. Stating the chain an alias already resolves to -# resolves it to the same chain, so neither these entries nor the check reading -# them has to know the table. +# `chain` is stated on every entry, from the roster's own chain id. An entry +# with neither `chain` nor `url` under an alias foundry does not itself resolve +# is not a missing key, it is a config error — "At least one of `url` or `chain` +# must be present for Etherscan config with unknown alias" — raised while +# resolving the section, so it takes down verification for every network in it +# and not only its own. # -# Each stated `chain` is compared against the chain id its `[rpc_endpoints]` -# alias actually reports, by `testSupportedNetworkChainIdsAreBound`. That is the -# one claim here the text cannot settle on its own: a wrong id resolves, reads -# as well-formed, and is what `--verify` submits. +# Generated with `[rpc_endpoints]`, from the same roster, so the two cannot name +# different networks. +# rain-deploy:generated:etherscan:begin [etherscan] arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}", chain = 42161 } base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}", chain = 8453 } @@ -120,11 +128,5 @@ ethereum = { key = "${CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY}", chain = 1 } flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}", chain = 14 } hyperevm = { key = "${CI_DEPLOY_HYPEREVM_ETHERSCAN_API_KEY}", chain = 999 } polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}", chain = 137 } -# Robinhood Chain (4663) is not indexed by Etherscan V2. Its Blockscout -# explorer speaks the Etherscan API, so the entry points there; the key is -# whatever `CI_DEPLOY_ROBINHOOD_ETHERSCAN_API_KEY` carries (Blockscout ignores -# it). Blockscout sits behind a browser challenge that has rejected non-browser -# clients, so if `--verify` fails on this network after a broadcast, verify -# afterwards through Sourcify (which supports 4663 and which Blockscout -# imports): `forge verify-contract --verifier sourcify --chain 4663 ...`. robinhood = { key = "${CI_DEPLOY_ROBINHOOD_ETHERSCAN_API_KEY}", chain = 4663, url = "https://robinhoodchain.blockscout.com/api" } +# rain-deploy:generated:etherscan:end diff --git a/script/build.sh b/script/build.sh new file mode 100755 index 0000000..4c7755c --- /dev/null +++ b/script/build.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-DCL-1.0 +# SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +# +# Installs what `forge script ./script/Build.sol` staged. +# +# `BuildScript.run()` generates `foundry.toml`'s network sections and +# `.env.example`'s endpoint variables from `LibRainDeploy.supportedNetworkConfigs()` +# but CANNOT write the first of them: foundry refuses every filesystem cheatcode +# write to the project root's own `foundry.toml`, whatever `fs_permissions` +# says. So the script splices and writes to `.staged-config/`, and this moves +# each staged file onto the file of the same name at the root. +# +# This is `rainix-copy-artifacts`' own consumer hook. rainix runs it outside any +# devshell, after `forge script ./script/Build.sol` and before the `git diff` +# that fails a stale tree — so a tree whose config has drifted from the roster +# it pins is red there. Nothing here needs forge, nix or `--ffi`; granting +# `--ffi` to the build is the alternative, and it would be granted to every +# consumer's build rather than to this one step. +set -euo pipefail + +cd "$(dirname "$0")/.." + +staged=.staged-config + +# Absent or empty means the staging never happened, which is exactly the state +# in which copying nothing would leave the committed config stale and report +# success. The regeneration step runs before this one and fails on its own, so +# reaching here with nothing staged is a build that generated nothing. +if [ ! -d "$staged" ]; then + echo "::error::$staged/ does not exist. forge script ./script/Build.sol stages the generated config there; it did not run, or it wrote nothing." >&2 + exit 1 +fi + +installed=0 +while IFS= read -r -d '' file; do + cp "$file" "./$(basename "$file")" + installed=$((installed + 1)) +done < <(find "$staged" -mindepth 1 -maxdepth 1 -type f -print0) + +if [ "$installed" -eq 0 ]; then + echo "::error::$staged/ holds no files. forge script ./script/Build.sol stages the generated config there; it wrote nothing." >&2 + exit 1 +fi + +# Removed once installed, so the staging directory is never a place a stale +# generated file can sit: the next run stages fresh, and a bare re-run of this +# hook fails above rather than re-installing what a previous run left. +rm -rf "$staged" + +echo "Installed $installed generated config file(s) from $staged/." diff --git a/src/abstract/BuildScript.sol b/src/abstract/BuildScript.sol index 92f5626..d653ec1 100644 --- a/src/abstract/BuildScript.sol +++ b/src/abstract/BuildScript.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.25; import {Script} from "forge-std-1.16.2/src/Script.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; +import {LibRainDeployConfig} from "../lib/LibRainDeployConfig.sol"; import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; /// @title BuildScript @@ -16,6 +18,17 @@ import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; /// /// Neither is `virtual`, so a repo inheriting this implements the hooks below /// and has no entry point to cut a release from other than `cutRelease()`. +/// +/// The network config is generated here rather than through a hook a repo +/// implements: it comes out of `LibRainDeploy.supportedNetworkConfigs()`, this +/// package's own constant, so a version bump is how a network arrives in a +/// consumer's `foundry.toml` and `.env.example`. A repo able to narrow that +/// list would deploy to and verify fewer chains with nothing red. +/// +/// `run()` stages those two files rather than writing them, because foundry +/// refuses a cheatcode write to the project root's own `foundry.toml`. A repo +/// inheriting this needs `script/build.sh` to install what was staged — see +/// `LibRainDeployConfig`. abstract contract BuildScript is Script { /// Rewrite the rolling `candidate/` snapshots from what this repo currently /// compiles. Run by `cutRelease()` inside `freeze`, after its guards and @@ -42,8 +55,38 @@ abstract contract BuildScript is Script { return LibRainDeploySnapshot.LIB_FS_ROOT; } + /// The root holding the `foundry.toml` and `.env.example` whose network + /// blocks are generated, and the parent of the staging directory they are + /// written to. + /// + /// Overridable for the same reason `recordRoot` is: a writer that can only + /// be pointed at the committed tree can only be exercised against it. The + /// hazard is the same too — a repo pointing this anywhere but its own root + /// generates config nothing reads, and `Git is clean` then sees a tree that + /// never drifts because nothing regenerates it. + /// @return The config root. + function configRoot() internal view virtual returns (string memory) { + return LibRainDeployConfig.CONFIG_ROOT; + } + + /// Rewrite the delimited network config blocks from this package's roster. + /// + /// Run by `run()` and not by `cutRelease()`: the config is not part of a + /// release record, and `run()` is what `Git is clean` calls on every push, + /// so a tree whose config has drifted from the roster it pins fails there. + /// + /// This STAGES the files; `script/build.sh` installs them. See + /// `LibRainDeployConfig` for why a script cannot write `foundry.toml` + /// itself. + function regenerateConfig() internal { + LibRainDeployConfig.writeStagedConfig( + vm, configRoot(), LibRainDeployConfig.BUILD_HOOK_PATH, LibRainDeploy.supportedNetworkConfigs() + ); + } + /// @notice Regenerate everything this repo generates. Freezes nothing. function run() external { + regenerateConfig(); regenerateSnapshots(); regenerateLibs(); } diff --git a/src/abstract/RainDeployVerifyChain.sol b/src/abstract/RainDeployVerifyChain.sol index cff6653..0ce7654 100644 --- a/src/abstract/RainDeployVerifyChain.sol +++ b/src/abstract/RainDeployVerifyChain.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; -import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; +import {LibRainDeploy, SupportedNetwork} 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. @@ -29,31 +29,17 @@ error CodeHashMismatchOnNetwork( string network, string suite, address deployedAddress, bytes32 expectedCodeHash, bytes32 actualCodeHash ); -/// Thrown when the chain id a network's `[etherscan]` entry declares is not the -/// chain id the endpoint bound to that network's `[rpc_endpoints]` alias -/// reports. Either the declaration is wrong — and `chain` is what `--verify` -/// submits, so the deployment is verified against another chain's explorer — or -/// the alias is bound to a different network than the one it names, and -/// everything ever checked through it was checked somewhere else. +/// Thrown when the chain id a network's roster entry declares is not the chain +/// id the endpoint bound to its `[rpc_endpoints]` alias reports. Either the +/// declaration is wrong — and `chain` is what `--verify` submits, so the +/// deployment is verified against another chain's explorer — or the alias is +/// bound to a different network than the one it names, and everything ever +/// checked through it was checked somewhere else. /// @param network The network name, as configured in `[rpc_endpoints]`. -/// @param declared The chain id the `[etherscan]` entry states. +/// @param declared The chain id the roster states. /// @param reported The chain id the endpoint answers with. error NetworkChainIdMismatch(string network, uint256 declared, uint256 reported); -/// Thrown when no supported network's `[etherscan]` entry declares a `chain` at -/// all. That is not nothing to check, it is a config in which every entry -/// resolves through a `url` alone, and a check with no subject passes having -/// forked nothing — indistinguishable from every declared id being right. -error NoDeclaredChainIds(); - -/// The chain id one network's `[etherscan]` entry states. -struct DeclaredChainId { - /// The network name, as configured in `[rpc_endpoints]` and `[etherscan]`. - string network; - /// The chain id the entry states. - uint256 chainId; -} - /// @title RainDeployVerifyChain /// @notice The only deploy-pin assertions anchored to something outside the /// repo: across every network in `LibRainDeploy.supportedNetworks()`, every @@ -177,50 +163,10 @@ abstract contract RainDeployVerifyChain is RainDeployVerifyBase { checkDeployedOnSupportedNetworks(deriveDeployments(releasedSuites())); } - /// The chain id each supported network's `[etherscan]` entry states, for - /// the networks that state one. - /// - /// An entry with no `chain` is not a gap here. The config group requires - /// only that an entry carry at least one of `chain` or `url`, so one that - /// resolves through a `url` alone makes no claim about which chain its - /// alias is, and there is nothing about it to compare. What WOULD be a gap - /// is every entry being that way, which is why `checkNetworkChainIds` - /// refuses an empty declaration set rather than passing on it. - /// - /// Takes the config text rather than reading it, so a test can hand it one - /// it built. Reading the binder's own file is - /// `testSupportedNetworkChainIdsAreBound`. - /// @param config The raw `foundry.toml` text. - /// @param networks The supported networks whose entries to read. - /// @return The declaration of every network that states a chain id, in - /// `networks` order. - function declaredChainIds(string memory config, string[] memory networks) - internal - view - returns (DeclaredChainId[] memory) - { - uint256 declaredCount = 0; - for (uint256 i = 0; i < networks.length; i++) { - if (vm.keyExistsToml(config, string.concat(".etherscan.", networks[i], ".chain"))) { - declaredCount++; - } - } - - DeclaredChainId[] memory declared = new DeclaredChainId[](declaredCount); - uint256 next = 0; - for (uint256 i = 0; i < networks.length; i++) { - string memory key = string.concat(".etherscan.", networks[i], ".chain"); - if (vm.keyExistsToml(config, key)) { - declared[next] = DeclaredChainId({network: networks[i], chainId: vm.parseTomlUint(config, key)}); - next++; - } - } - return declared; - } - - /// Checks one network's declared chain id against a reported one. + /// Checks one network's declared chain id against whichever chain id the + /// currently selected fork reports. /// @param network The network name, for the error only. - /// @param declared The chain id the `[etherscan]` entry states. + /// @param declared The chain id the roster states. /// @param reported The chain id the bound endpoint answers with. function checkNetworkChainId(string memory network, uint256 declared, uint256 reported) internal pure { if (declared != reported) { @@ -228,53 +174,43 @@ abstract contract RainDeployVerifyChain is RainDeployVerifyBase { } } - /// Checks every declaration against the endpoint bound to its network's - /// `[rpc_endpoints]` alias. - /// - /// Every fork is created before any is selected, for the reason - /// `LibRainDeploy.createForks` gives. - /// @param declared The declarations to check. - function checkNetworkChainIds(DeclaredChainId[] memory declared) internal { - if (declared.length == 0) { - revert NoDeclaredChainIds(); + /// Checks every network's declared chain id against the endpoint bound to + /// its `[rpc_endpoints]` alias. + /// @param networks The roster to check. + function checkNetworkChainIds(SupportedNetwork[] memory networks) internal { + // An empty roster is a repo that deploys nowhere, not a repo with + // nothing to check, and it would pass here having forked nothing. + if (networks.length == 0) { + revert LibRainDeploy.NoNetworks(); } - string[] memory names = new string[](declared.length); - for (uint256 i = 0; i < declared.length; i++) { - names[i] = declared[i].network; + string[] memory names = new string[](networks.length); + for (uint256 i = 0; i < networks.length; i++) { + names[i] = networks[i].name; } uint256[] memory forkIds = LibRainDeploy.createForks(vm, names); - for (uint256 i = 0; i < declared.length; i++) { + for (uint256 i = 0; i < networks.length; i++) { vm.selectFork(forkIds[i]); - checkNetworkChainId(declared[i].network, declared[i].chainId, block.chainid); + checkNetworkChainId(networks[i].name, networks[i].chainId, block.chainid); } } - /// Every chain id `[etherscan]` declares MUST be the one the endpoint bound - /// to that network's `[rpc_endpoints]` alias reports. - /// - /// The config group asserts that those entries exist and can resolve, and - /// can go no further: whether `chain = 42161` is the network `arbitrum` - /// forks is a claim about the world that only a fork settles. A wrong id - /// resolves, satisfies every check that reads the text, and is what - /// `--verify` submits — so the deployment is verified against another - /// chain's explorer, after the gas is spent. The mirror of it is an - /// `[rpc_endpoints]` alias bound to a different network than it names, - /// which the same comparison catches and which is worse: every - /// chain-anchored assertion ever made through that alias was made somewhere - /// nobody named. + /// Every supported network's declared chain id MUST be the one the endpoint + /// bound to its alias reports. /// - /// Here rather than in the config group because the subject is the - /// endpoint. This is the contract that forks, and keeping the comparison - /// out of the snapshot half is what leaves that half bindable by a job with - /// no RPC endpoint at all. + /// The one thing about the config that generating it cannot settle. The + /// sections are written from the roster, so an alias missing from one of + /// them is a tree `Git is clean` fails rather than an assertion anything + /// makes — but which chain an endpoint actually serves is a claim about the + /// world, and `chain` is what `--verify` submits to. A wrong id there is + /// config that resolves, passes every check that reads the text, and + /// verifies a deployment against the wrong explorer. /// - /// `vm.readFile` resolves against the project root of whatever runs it, so - /// the file read is the binder's own — which is why a binding repo needs - /// `{ access = "read", path = "./foundry.toml" }` in `fs_permissions` for - /// THIS half as well as the snapshot half. + /// Here rather than beside the roster because the subject is the endpoint: + /// this is the contract that already forks every supported network, and the + /// snapshot half is the one a credential-free job binds. function testSupportedNetworkChainIdsAreBound() external { - checkNetworkChainIds(declaredChainIds(vm.readFile("foundry.toml"), LibRainDeploy.supportedNetworks())); + checkNetworkChainIds(LibRainDeploy.supportedNetworkConfigs()); } } diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol index f10070a..c564f87 100644 --- a/src/abstract/RainDeployVerifySnapshot.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -3,21 +3,20 @@ pragma solidity ^0.8.25; import {RainDeployVerifySnapshotBase} from "./RainDeployVerifySnapshotBase.sol"; -import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; /// @title RainDeployVerifySnapshot /// @notice What a deploy repo inherits: every assertion that needs no network, /// bound to that repo. `RainDeployVerifySnapshotBase` is where the three -/// deploy-pin groups are defined and documented; this adds the tests whose -/// subject is the repo's own state on disk — its frozen record, and its -/// `foundry.toml` — rather than anything the inheriting contract declares. +/// deploy-pin groups are defined and documented; this adds the test whose +/// subject is the repo's own state on disk — its frozen record — rather than +/// anything the inheriting contract declares. /// -/// The split is which contract carries those tests, and nothing else. A -/// consumer inherits this and gets all three groups, exactly as it does when -/// they are one contract. The base is for a contract whose declaration is a -/// FIXTURE — the record is not its subject, and see the base for why asking it -/// about the record asserts something false. +/// The split is which contract carries that test, and nothing else. A consumer +/// inherits this and gets all three groups, exactly as it does when they are +/// one contract. The base is for a contract whose declaration is a FIXTURE — +/// the record is not its subject, and see the base for why asking it about the +/// record asserts something false. abstract contract RainDeployVerifySnapshot is RainDeployVerifySnapshotBase { /// 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 @@ -58,48 +57,4 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifySnapshotBase { function testEveryFrozenSnapshotIsReleased() external view { checkFrozenSnapshotsReleased(LibRainDeploySnapshot.frozenSnapshotPaths(vm), releasedSuites()); } - - /// `[rpc_endpoints]` and `[etherscan]` in the binding repo's `foundry.toml` - /// MUST be EXACTLY `supportedNetworks()`, which makes the three lists one - /// list. - /// - /// The deploy forks by the first and `--verify` resolves the second, so a - /// supported network missing from either broadcasts and then fails after - /// the gas is spent, and a section entry no supported network names is - /// config nothing ever reads. Both are the same defect — the lists having - /// drifted — so both directions are asserted, by membership: containment - /// one way alone passes for a section carrying an alias nothing deploys - /// to, and the other way alone passes for a network with no config at all. - /// Membership rather than position, because a config section is keyed - /// rather than ordered and there is no order in it to assert. - /// - /// This is what makes the `[etherscan]` half enforced at all. The RPC half - /// is enforced only incidentally, by the fork tests, and only forwards. - /// - /// Membership is necessary and not sufficient for that half, so the SHAPE - /// of each `[etherscan]` entry is asserted beside it by - /// `checkEtherscanEntriesResolvable`: foundry resolves the whole section, so - /// an entry it cannot resolve fails `--verify` for the other entries as - /// well, after the gas is spent, while satisfying every membership - /// assertion here. - /// - /// The raw file is read rather than forge's resolved config because the - /// values are `${VAR}` interpolations that exist only in CI. Nothing - /// asserted here is a value — the keys, and that each `[etherscan]` entry - /// carries enough to resolve at all, are both in the text — so this needs - /// no RPC and fails on the PR that drifts rather than at dispatch time. - /// - /// `vm.readFile` resolves against the project root of whatever runs it, so - /// the file read is the binder's own and the networks are this package's. - /// A binding repo therefore needs `{ access = "read", path = - /// "./foundry.toml" }` in `fs_permissions`, and one without it fails here - /// rather than passing on a file it never opened. - /// - /// The assertions themselves are `checkNetworksConfigured`, in the base, - /// because they take the config as an argument and so can be handed one a - /// test builds. Reading the binder's own file is the part that cannot be, - /// and it is all that is left here. - function testSupportedNetworksAreFullyConfigured() external view { - checkNetworksConfigured(vm.readFile("foundry.toml"), LibRainDeploy.supportedNetworks()); - } } diff --git a/src/abstract/RainDeployVerifySnapshotBase.sol b/src/abstract/RainDeployVerifySnapshotBase.sol index 8822c3f..23f19af 100644 --- a/src/abstract/RainDeployVerifySnapshotBase.sol +++ b/src/abstract/RainDeployVerifySnapshotBase.sol @@ -5,7 +5,6 @@ pragma solidity ^0.8.25; import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; import {DeploySuite} from "./RainDeploySuitesBase.sol"; import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; -import {LibMemoryKV, MemoryKV, MemoryKVKey, MemoryKVVal} from "rain-lib-memkv-0.1.5/src/lib/LibMemoryKV.sol"; /// Thrown when the deploy address recorded for a version is not the address its /// own creation code derives. @@ -54,15 +53,6 @@ error FrozenSnapshotUnreadable(string path); /// @param path The record file declaring `DEPLOYED_ADDRESS` more than once. error FrozenSnapshotAmbiguous(string path); -/// Thrown when an `[etherscan]` entry carries neither `chain` nor `url`. Under -/// an alias foundry does not itself resolve to a chain that entry is not a -/// missing key, it is "At least one of `url` or `chain` must be present for -/// Etherscan config with unknown alias" — raised while foundry resolves the -/// SECTION, so it takes verification down for the other entries too and not -/// only its own. -/// @param entry The `[etherscan]` entry that cannot resolve. -error EtherscanEntryUnresolvable(string entry); - /// @title RainDeployVerifySnapshotBase /// @notice Every deploy-pin assertion that needs no network, for every suite /// a repo declares. Three groups, which catch different things and are @@ -132,8 +122,6 @@ error EtherscanEntryUnresolvable(string entry); /// one spelling. Inheriting a narrower contract is a choice a reader sees in /// the inheritance list; overriding a test to nothing is one they do not. abstract contract RainDeployVerifySnapshotBase is RainDeployVerifyBase { - using LibMemoryKV for MemoryKV; - /// 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. @@ -298,86 +286,6 @@ abstract contract RainDeployVerifySnapshotBase is RainDeployVerifyBase { } } - /// Checks that every `[etherscan]` entry can resolve at all: each carries at - /// least one of `chain` or `url`. - /// - /// The entries EXISTING is not enough for the section to verify anything. - /// Foundry resolves the section rather than the single entry the network - /// being verified needs, so one entry it cannot resolve is an error raised - /// for whichever network `--verify` was pointed at — the failure mode the - /// key checks are there to keep off a broadcast, arriving from an entry - /// that satisfies them. - /// - /// Required of EVERY entry rather than only the aliases foundry cannot - /// resolve itself, because that set is foundry's table and moves under a - /// toolchain bump. Stating the chain an alias already resolves to resolves - /// it to the same chain, so the strict form is monotonic, needs to know - /// nothing of that table, and cannot red-line when foundry adds an alias. - /// @param config The raw `foundry.toml` text. - /// @param entries The `[etherscan]` entries to check. - function checkEtherscanEntriesResolvable(string memory config, string[] memory entries) internal view { - for (uint256 i = 0; i < entries.length; i++) { - if ( - !vm.keyExistsToml(config, string.concat(".etherscan.", entries[i], ".chain")) - && !vm.keyExistsToml(config, string.concat(".etherscan.", entries[i], ".url")) - ) { - revert EtherscanEntryUnresolvable(entries[i]); - } - } - } - - /// Checks a `foundry.toml`'s `[rpc_endpoints]` and `[etherscan]` sections - /// against a set of supported networks: the three lists are one list, and - /// every `[etherscan]` entry can resolve. - /// - /// Membership is asserted in BOTH directions. Containment one way alone - /// passes for a section carrying an alias nothing deploys to, and the other - /// way alone passes for a network with no config at all. Membership rather - /// than position, because a config section is keyed rather than ordered and - /// there is no order in it to assert. - /// - /// Takes the config text rather than reading it, so it can be handed one a - /// test builds. What reads the binder's own file is - /// `testSupportedNetworksAreFullyConfigured`, and see it for why the file's - /// text is the subject at all. - /// @param config The raw `foundry.toml` text. - /// @param networks The supported networks the sections must name. - function checkNetworksConfigured(string memory config, string[] memory networks) internal view { - MemoryKV networkSet = MemoryKV.wrap(0); - for (uint256 i = 0; i < networks.length; i++) { - networkSet = networkSet.set(MemoryKVKey.wrap(keccak256(bytes(networks[i]))), MemoryKVVal.wrap(0)); - } - - for (uint256 i = 0; i < networks.length; i++) { - assertTrue( - vm.keyExistsToml(config, string.concat(".rpc_endpoints.", networks[i])), - string.concat("supported network has no [rpc_endpoints] alias: ", networks[i]) - ); - assertTrue( - vm.keyExistsToml(config, string.concat(".etherscan.", networks[i])), - string.concat("supported network has no [etherscan] key: ", networks[i]) - ); - } - - string[] memory rpcAliases = vm.parseTomlKeys(config, ".rpc_endpoints"); - for (uint256 i = 0; i < rpcAliases.length; i++) { - assertTrue( - networkSet.has(MemoryKVKey.wrap(keccak256(bytes(rpcAliases[i])))), - string.concat("[rpc_endpoints] alias is not a supported network: ", rpcAliases[i]) - ); - } - - string[] memory etherscanKeys = vm.parseTomlKeys(config, ".etherscan"); - for (uint256 i = 0; i < etherscanKeys.length; i++) { - assertTrue( - networkSet.has(MemoryKVKey.wrap(keccak256(bytes(etherscanKeys[i])))), - string.concat("[etherscan] key is not a supported network: ", etherscanKeys[i]) - ); - } - - checkEtherscanEntriesResolvable(config, etherscanKeys); - } - /// Every declared suite MUST be internally consistent: what it records is /// what its own creation code derives. function testSnapshotInternallyConsistent() external { diff --git a/src/lib/LibRainDeploy.sol b/src/lib/LibRainDeploy.sol index e73e002..5ff7922 100644 --- a/src/lib/LibRainDeploy.sol +++ b/src/lib/LibRainDeploy.sol @@ -5,6 +5,28 @@ pragma solidity ^0.8.25; import {Vm} from "forge-std-1.16.2/src/Vm.sol"; import {console2} from "forge-std-1.16.2/src/console2.sol"; +/// One network the Rain org deploys to, as everything generated from the roster +/// needs it. The roster is the single statement of the set: `foundry.toml`'s +/// `[rpc_endpoints]` and `[etherscan]` sections and `.env.example` are written +/// from it by `BuildScript`, so a network arrives everywhere by being added +/// here. +/// @param name The `[rpc_endpoints]` alias, the `[etherscan]` key, and the stem +/// of both the `_RPC_URL` and `CI_DEPLOY__ETHERSCAN_API_KEY` +/// variables the generated entries interpolate. +/// @param chainId The chain id the bound endpoint reports. Generation cannot +/// settle this one — it is a claim about the world, checked by +/// `RainDeployVerifyChain` against `block.chainid` on a fork. +/// @param explorerUrl The explorer API `--verify` posts to, or empty for the +/// one Etherscan V2 resolves from `chainId`. +/// @param defaultRpcUrl The public endpoint `.env.example` binds for a local +/// run. CI binds its own, so this is a convenience rather than a contract. +struct SupportedNetwork { + string name; + uint256 chainId; + string explorerUrl; + string defaultRpcUrl; +} + /// @title LibRainDeploy /// Library for deploying contracts via the Zoltu factory across all the networks /// currently supported by Rain by default. The Rain contracts can be deployed @@ -303,19 +325,70 @@ library LibRainDeploy { return forkIds; } - /// Returns the list of networks currently supported by Rain deployments. + /// The networks currently supported by Rain deployments, with everything + /// the generated config states about each. + /// + /// The ONE list. `supportedNetworks()` is its names, `LibRainDeployConfig` + /// writes the config sections from it, `RainDeployBroadcast` deploys to it + /// and `RainDeployVerifyChain` forks it — so the config cannot disagree + /// with the roster without `Git is clean` failing the tree that says so, + /// and there is nothing left for a test to compare. + /// + /// Deriving it from the config instead would be the same list spelled once + /// as well, and wrong: a repo that deleted an alias would deploy to and + /// verify fewer chains, green, because the thing that would notice reads + /// the same file. + /// @return The supported networks. + function supportedNetworkConfigs() internal pure returns (SupportedNetwork[] memory) { + SupportedNetwork[] memory networks = new SupportedNetwork[](9); + networks[0] = SupportedNetwork({ + name: ARBITRUM_ONE, chainId: 42161, explorerUrl: "", defaultRpcUrl: "https://arb1.arbitrum.io/rpc" + }); + networks[1] = + SupportedNetwork({name: BASE, chainId: 8453, explorerUrl: "", defaultRpcUrl: "https://mainnet.base.org"}); + networks[2] = SupportedNetwork({ + name: BASE_SEPOLIA, chainId: 84532, explorerUrl: "", defaultRpcUrl: "https://sepolia.base.org" + }); + networks[3] = SupportedNetwork({ + name: BSC, chainId: 56, explorerUrl: "", defaultRpcUrl: "https://bsc-dataseed.binance.org" + }); + networks[4] = SupportedNetwork({ + name: ETHEREUM, chainId: 1, explorerUrl: "", defaultRpcUrl: "https://eth-pokt.nodies.app" + }); + networks[5] = SupportedNetwork({ + name: FLARE, chainId: 14, explorerUrl: "", defaultRpcUrl: "https://flare-api.flare.network/ext/C/rpc" + }); + networks[6] = SupportedNetwork({ + name: HYPEREVM, chainId: 999, explorerUrl: "", defaultRpcUrl: "https://rpc.hyperliquid.xyz/evm" + }); + networks[7] = SupportedNetwork({ + name: POLYGON, chainId: 137, explorerUrl: "", defaultRpcUrl: "https://polygon-bor-rpc.publicnode.com" + }); + // Robinhood Chain is not indexed by Etherscan V2, so `--verify` is + // pointed at its Blockscout explorer, which speaks the Etherscan API + // and ignores the key. Blockscout sits behind a browser challenge that + // has rejected non-browser clients, so if `--verify` fails on this + // network after a broadcast, verify afterwards through Sourcify (which + // supports 4663 and which Blockscout imports): + // `forge verify-contract --verifier sourcify --chain 4663 ...`. + networks[8] = SupportedNetwork({ + name: ROBINHOOD, + chainId: 4663, + explorerUrl: "https://robinhoodchain.blockscout.com/api", + defaultRpcUrl: "https://rpc.mainnet.chain.robinhood.com" + }); + return networks; + } + + /// The names of the networks currently supported by Rain deployments, in + /// roster order. /// @return The list of supported network names. function supportedNetworks() internal pure returns (string[] memory) { - string[] memory networks = new string[](9); - networks[0] = ARBITRUM_ONE; - networks[1] = BASE; - networks[2] = BASE_SEPOLIA; - networks[3] = BSC; - networks[4] = ETHEREUM; - networks[5] = FLARE; - networks[6] = HYPEREVM; - networks[7] = POLYGON; - networks[8] = ROBINHOOD; + SupportedNetwork[] memory configs = supportedNetworkConfigs(); + string[] memory networks = new string[](configs.length); + for (uint256 i = 0; i < configs.length; i++) { + networks[i] = configs[i].name; + } return networks; } diff --git a/src/lib/LibRainDeployConfig.sol b/src/lib/LibRainDeployConfig.sol new file mode 100644 index 0000000..cd84fc6 --- /dev/null +++ b/src/lib/LibRainDeployConfig.sol @@ -0,0 +1,337 @@ +// 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.2/src/Vm.sol"; +import {SupportedNetwork} from "./LibRainDeploy.sol"; + +/// Thrown when the file a generated block is written into does not carry that +/// block's markers exactly once each, the begin before the end. Refused rather +/// than appended: a file with no markers is a file whose config is hand +/// written, and appending a second copy of a section to a TOML file is a +/// duplicate key error at the next forge startup. +/// @param path The file the block was to be written into. +/// @param name The block name, as it appears in the markers. +error GeneratedBlockMalformed(string path, string name); + +/// Thrown when a roster entry states no chain id. A generated `[etherscan]` +/// entry states `chain` from this field and nothing else, and `chain = 0` is an +/// entry `--verify` resolves to no chain at all — so the roster cannot be +/// silent about it. +/// @param network The roster entry with no chain id. +error NoChainId(string network); + +/// Thrown when the roster is empty. Both sections would be generated with no +/// entries, which is a repo that deploys nowhere and verifies nothing while +/// every check that reads them stays green. +error EmptyRoster(); + +/// Thrown when the repo has no `script/build.sh`. That hook is the only thing +/// that moves a staged config into place, so without it the generated blocks +/// are written somewhere nothing reads, `foundry.toml` never changes, and `Git +/// is clean` passes a tree whose config has drifted from the roster it pins — +/// the silent green this whole mechanism exists to remove. +/// @param path The hook that was looked for. +error BuildHookMissing(string path); + +/// @title LibRainDeployConfig +/// @notice Writes the network config a deploy repo cannot state twice: the +/// `[rpc_endpoints]` and `[etherscan]` sections of `foundry.toml`, and the +/// `_RPC_URL` lines of `.env.example`, all of them from +/// `LibRainDeploy.supportedNetworkConfigs()`. +/// +/// Generated rather than compared. A comparison keeps both statements and +/// checks them, so the prose around them drifts silently and every assertion is +/// one somebody has to have thought of; generation leaves one statement, and +/// the enforcement is `Git is clean` — the same mechanism already holding +/// `src/generated/` and `src/lib/`. +/// +/// Delimited rather than whole-file, because everything else in `foundry.toml` +/// is hand written and a `.env.example` carries prose a generator has no way to +/// know. Each block is replaced between its markers and nothing outside them is +/// read or moved. +/// +/// ## Why the generated files are staged rather than written +/// +/// Foundry REFUSES every filesystem cheatcode write to the project root's own +/// `foundry.toml` — "vm.writeFile: access to `foundry.toml` is not allowed" — +/// and it is not an `fs_permissions` miss: the guard is on the path, so no +/// grant and no spelling of the path gets past it. Reads are allowed, which is +/// the whole reason this is possible at all. +/// +/// So the spliced file is written to `/.staged-config/` and installed by +/// `script/build.sh`, the hook rainix's `rainix-copy-artifacts` runs — outside +/// any devshell, after `forge script ./script/Build.sol` and before the `git +/// diff` that fails a stale tree. FFI is the other way to reach a shell from a +/// script, and is not taken: the invocation that matters passes no `--ffi`, and +/// granting it there would hand FFI to every consumer's build. +/// +/// `.env.example` is staged too, though foundry would allow that one written +/// directly. One mechanism, so which file foundry happens to guard is not +/// something the design depends on. +/// +/// Staging also removes the hazard the direct write carried: nothing under +/// `forge test` can race a rewrite of the config every other test reads, +/// because nothing rewrites it at all. +library LibRainDeployConfig { + /// The project root, which is what a relative path resolves against — the + /// CONSUMING repo's root rather than this package's. + string constant CONFIG_ROOT = "."; + + /// The config whose sections are generated, as it is named under the root. + string constant CONFIG_NAME = "foundry.toml"; + + /// The file the endpoint variables are generated into, as it is named under + /// the root. + string constant ENV_EXAMPLE_NAME = ".env.example"; + + /// The directory under the root that a generated file is staged in. Git + /// ignored: `script/build.sh` removes it once it has installed what is in + /// it, and a run that failed between the two must not leave a tracked file + /// behind. + string constant STAGED_DIR_NAME = ".staged-config"; + + /// The hook that installs what was staged. Matched exactly, because that is + /// the path `rainix-copy-artifacts` conditions its own step on. + string constant BUILD_HOOK_PATH = "script/build.sh"; + + /// The `[rpc_endpoints]` block, as it is named in its markers. + string constant RPC_ENDPOINTS_BLOCK = "rpc_endpoints"; + + /// The `[etherscan]` block, as it is named in its markers. + string constant ETHERSCAN_BLOCK = "etherscan"; + + /// The `.env.example` endpoint block, as it is named in its markers. + string constant ENV_BLOCK = "env"; + + /// What both markers start with. A `#` comment in TOML and in a dotenv + /// file alike, so one marker shape serves both. + string constant MARKER_PREFIX = "# rain-deploy:generated:"; + + /// The line a generated block starts after. + /// @param name The block name. + /// @return The begin marker, including the newline it ends the line with. + function beginMarker(string memory name) internal pure returns (string memory) { + return string.concat(MARKER_PREFIX, name, ":begin\n"); + } + + /// The line a generated block ends before. + /// @param name The block name. + /// @return The end marker, without a newline: whatever followed it stays. + function endMarker(string memory name) internal pure returns (string memory) { + return string.concat(MARKER_PREFIX, name, ":end"); + } + + /// The environment variable a network's `[rpc_endpoints]` alias + /// interpolates, and the one `.env.example` declares. + /// @param vm The Vm instance, for the case conversion only. + /// @param network The network name. + /// @return The variable name. + function rpcUrlVar(Vm vm, string memory network) internal pure returns (string memory) { + return string.concat(vm.toUppercase(network), "_RPC_URL"); + } + + /// The environment variable a network's `[etherscan]` entry interpolates + /// its key from. `rainix-manual-sol-artifacts` exports exactly these names. + /// @param vm The Vm instance, for the case conversion only. + /// @param network The network name. + /// @return The variable name. + function etherscanKeyVar(Vm vm, string memory network) internal pure returns (string memory) { + return string.concat("CI_DEPLOY_", vm.toUppercase(network), "_ETHERSCAN_API_KEY"); + } + + /// The `[rpc_endpoints]` section, header and all. + /// @param vm The Vm instance, for the case conversion only. + /// @param networks The roster. + /// @return The section text, newline terminated. + function rpcEndpointsSection(Vm vm, SupportedNetwork[] memory networks) internal pure returns (string memory) { + if (networks.length == 0) { + revert EmptyRoster(); + } + string memory section = "[rpc_endpoints]\n"; + for (uint256 i = 0; i < networks.length; i++) { + section = string.concat(section, networks[i].name, ' = "${', rpcUrlVar(vm, networks[i].name), '}"', "\n"); + } + return section; + } + + /// The `[etherscan]` section, header and all. + /// + /// Every entry states `chain`, and `url` as well wherever the roster gives + /// one. That is foundry's own condition for an entry it can resolve — "At + /// least one of `url` or `chain` must be present for Etherscan config with + /// unknown alias" is raised while resolving the SECTION, so one entry that + /// cannot be resolved takes verification down for every network in it and + /// not only its own. Stating the chain an alias already resolves to + /// resolves it to the same chain, so stating it on all of them cannot go + /// wrong when foundry adds or renames an alias. + /// @param vm The Vm instance, for the case conversion and `chain`. + /// @param networks The roster. + /// @return The section text, newline terminated. + function etherscanSection(Vm vm, SupportedNetwork[] memory networks) internal pure returns (string memory) { + if (networks.length == 0) { + revert EmptyRoster(); + } + string memory section = "[etherscan]\n"; + for (uint256 i = 0; i < networks.length; i++) { + if (networks[i].chainId == 0) { + revert NoChainId(networks[i].name); + } + string memory entry = string.concat( + networks[i].name, + ' = { key = "${', + etherscanKeyVar(vm, networks[i].name), + '}", chain = ', + vm.toString(networks[i].chainId) + ); + if (bytes(networks[i].explorerUrl).length > 0) { + entry = string.concat(entry, ', url = "', networks[i].explorerUrl, '"'); + } + section = string.concat(section, entry, " }\n"); + } + return section; + } + + /// The `_RPC_URL` lines of `.env.example`. + /// @param vm The Vm instance, for the case conversion only. + /// @param networks The roster. + /// @return The block text, newline terminated. + function envExampleSection(Vm vm, SupportedNetwork[] memory networks) internal pure returns (string memory) { + if (networks.length == 0) { + revert EmptyRoster(); + } + string memory section = ""; + for (uint256 i = 0; i < networks.length; i++) { + section = string.concat(section, rpcUrlVar(vm, networks[i].name), "=", networks[i].defaultRpcUrl, "\n"); + } + return section; + } + + /// Replaces whatever sits between a block's markers with `body`, leaving + /// the markers and everything outside them where they are. + /// + /// Both markers MUST appear exactly once, the begin before the end. Two + /// begin markers is a file with two claims about where the block is and no + /// rule for choosing between them that does not silently keep one of the + /// copies; none at all is a file nothing here has ever written, which is + /// what a consumer that has not adopted this looks like. + /// @param vm The Vm instance, for the string search only. + /// @param path The file the content came from, for the error only. + /// @param content The whole file. + /// @param name The block name. + /// @param body The block's new content, newline terminated. + /// @return The whole file, with the block replaced. + function spliceBlock(Vm vm, string memory path, string memory content, string memory name, string memory body) + internal + pure + returns (string memory) + { + string memory begin = beginMarker(name); + string memory end = endMarker(name); + + // `split` yields one more part than there are occurrences of the + // delimiter, so this counts them rather than finding the first and + // hoping it is the only one. + string[] memory beginParts = vm.split(content, begin); + if (beginParts.length != 2) { + revert GeneratedBlockMalformed(path, name); + } + string[] memory endParts = vm.split(beginParts[1], end); + if (endParts.length != 2) { + revert GeneratedBlockMalformed(path, name); + } + // An end marker BEFORE the begin marker is still in the prefix, and + // splicing would move it to the other side of the block. + if (vm.contains(beginParts[0], end)) { + revert GeneratedBlockMalformed(path, name); + } + return string.concat(beginParts[0], begin, body, end, endParts[1]); + } + + /// The config under a root. + /// @param root The project root. + /// @return The path. + function configPath(string memory root) internal pure returns (string memory) { + return string.concat(root, "/", CONFIG_NAME); + } + + /// The `.env.example` under a root. + /// @param root The project root. + /// @return The path. + function envExamplePath(string memory root) internal pure returns (string memory) { + return string.concat(root, "/", ENV_EXAMPLE_NAME); + } + + /// The staging directory under a root. + /// @param root The project root. + /// @return The path. + function stagedDir(string memory root) internal pure returns (string memory) { + return string.concat(root, "/", STAGED_DIR_NAME); + } + + /// Where a generated file is staged, named as it will be installed. + /// `script/build.sh` copies by name, so a staged file lands on the file of + /// the same name under the root and nowhere else. + /// @param root The project root. + /// @param name The file name under the root. + /// @return The path. + function stagedPath(string memory root, string memory name) internal pure returns (string memory) { + return string.concat(stagedDir(root), "/", name); + } + + /// Splices both `foundry.toml` sections from the roster, reading one file + /// and writing another. + /// @param vm The Vm instance for file operations. + /// @param path The config to read. + /// @param staged Where to write the spliced result. + /// @param networks The roster. + function writeNetworkConfig(Vm vm, string memory path, string memory staged, SupportedNetwork[] memory networks) + internal + { + string memory content = vm.readFile(path); + content = spliceBlock(vm, path, content, RPC_ENDPOINTS_BLOCK, rpcEndpointsSection(vm, networks)); + content = spliceBlock(vm, path, content, ETHERSCAN_BLOCK, etherscanSection(vm, networks)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(staged, content); + } + + /// Splices the `.env.example` endpoint block from the roster, reading one + /// file and writing another. + /// @param vm The Vm instance for file operations. + /// @param path The `.env.example` to read. + /// @param staged Where to write the spliced result. + /// @param networks The roster. + function writeEnvExample(Vm vm, string memory path, string memory staged, SupportedNetwork[] memory networks) + internal + { + string memory content = vm.readFile(path); + content = spliceBlock(vm, path, content, ENV_BLOCK, envExampleSection(vm, networks)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(staged, content); + } + + /// Stages every generated config file under a root, for the build hook to + /// install. + /// + /// Refuses a repo with no hook. Nothing else moves a staged file into + /// place, so generating for a repo that has none writes the roster to a + /// directory nothing reads while the config it is meant to hold goes on + /// saying whatever it said — green, from a build that regenerated nothing. + /// Presence is the same condition `rainix-copy-artifacts` runs the hook on, + /// so this is exactly the state in which the install would be skipped. + /// @param vm The Vm instance for file operations. + /// @param root The project root. + /// @param hookPath The hook that installs what this stages. + /// @param networks The roster. + function writeStagedConfig(Vm vm, string memory root, string memory hookPath, SupportedNetwork[] memory networks) + internal + { + if (!vm.exists(hookPath)) { + revert BuildHookMissing(hookPath); + } + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(stagedDir(root), true); + writeNetworkConfig(vm, configPath(root), stagedPath(root, CONFIG_NAME), networks); + writeEnvExample(vm, envExamplePath(root), stagedPath(root, ENV_EXAMPLE_NAME), networks); + } +} diff --git a/test/concrete/BuildScriptHarness.sol b/test/concrete/BuildScriptHarness.sol index 2b764b8..a31e923 100644 --- a/test/concrete/BuildScriptHarness.sol +++ b/test/concrete/BuildScriptHarness.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {BuildScript} from "../../src/abstract/BuildScript.sol"; +import {LibRainDeployConfig} from "../../src/lib/LibRainDeployConfig.sol"; import {LibRainDeploySnapshot} from "../../src/lib/LibRainDeploySnapshot.sol"; /// @title BuildScriptHarness @@ -38,6 +39,74 @@ contract BuildScriptHarness is BuildScript { return recordRoot(); } + /// @inheritdoc BuildScript + /// @dev The fixture root, never the repo's own. A `run()` here would + /// otherwise read the committed config and stage a spliced copy of it, + /// which the next `script/build.sh` would install. + function configRoot() internal view override returns (string memory) { + return sRoot; + } + + /// The seeded `foundry.toml` `regenerateConfig` reads. + /// @return The fixture config path. + function externalConfigPath() external view returns (string memory) { + return LibRainDeployConfig.configPath(configRoot()); + } + + /// The seeded `.env.example` `regenerateConfig` reads. + /// @return The fixture `.env.example` path. + function externalEnvExamplePath() external view returns (string memory) { + return LibRainDeployConfig.envExamplePath(configRoot()); + } + + /// Where `regenerateConfig` stages the spliced config. + /// @return The staged config path. + function externalStagedConfigPath() external view returns (string memory) { + return LibRainDeployConfig.stagedPath(configRoot(), LibRainDeployConfig.CONFIG_NAME); + } + + /// Where `regenerateConfig` stages the spliced `.env.example`. + /// @return The staged `.env.example` path. + function externalStagedEnvExamplePath() external view returns (string memory) { + return LibRainDeployConfig.stagedPath(configRoot(), LibRainDeployConfig.ENV_EXAMPLE_NAME); + } + + /// The staging directory, so a test can assert whether anything was staged + /// at all. + /// @return The staging directory. + function externalStagedDir() external view returns (string memory) { + return LibRainDeployConfig.stagedDir(configRoot()); + } + + /// A fixture file carrying both `foundry.toml` blocks, with a stale body in + /// each and hand-written text around them. + /// @return The seed config. + function configSeed() public pure returns (string memory) { + return string.concat( + "# hand written\n", + "# rain-deploy:generated:rpc_endpoints:begin\n", + "STALE\n", + "# rain-deploy:generated:rpc_endpoints:end\n", + "# rain-deploy:generated:etherscan:begin\n", + "STALE\n", + "# rain-deploy:generated:etherscan:end\n" + ); + } + + /// A fixture file carrying the `.env.example` block. + /// @return The seed `.env.example`. + function envExampleSeed() public pure returns (string memory) { + return string.concat( + "# hand written\n", "# rain-deploy:generated:env:begin\n", "STALE=1\n", "# rain-deploy:generated:env:end\n" + ); + } + + /// Writes both config fixtures, so `run()` has markers to splice into. + function seedConfig() external { + writeFixture(LibRainDeployConfig.configPath(configRoot()), configSeed()); + writeFixture(LibRainDeployConfig.envExamplePath(configRoot()), envExampleSeed()); + } + /// Where `regenerateSnapshots` writes. /// @return The rolling snapshot path. function rollingPath() public view returns (string memory) { diff --git a/test/script/Deploy.t.sol b/test/script/Deploy.t.sol index bef7db7..a156991 100644 --- a/test/script/Deploy.t.sol +++ b/test/script/Deploy.t.sol @@ -36,11 +36,10 @@ contract DeployTest is Test { /// Matched against the config rather than against `supportedNetworks()`, /// which is the value the script's inherited body returns: an assertion /// written against that would be the implementation compared to itself and - /// would pass for any override that happened to call it. The config is a - /// separate statement of the same set, and - /// `testSupportedNetworksAreFullyConfigured` is what holds the two together - /// — so a narrowing here fails, and a network added to one place and not - /// the other fails there. + /// would pass for any override that happened to call it. The config is + /// GENERATED from the roster, so what the file says is the roster as of the + /// last build — which makes reading it here a read of the committed tree + /// and not of the constant, and a narrowing in the script fails against it. /// /// Both directions, because containment one way passes for a script that /// deploys to a subset and the other way for a config carrying an alias diff --git a/test/src/abstract/BuildScript.t.sol b/test/src/abstract/BuildScript.t.sol index 923033b..5597412 100644 --- a/test/src/abstract/BuildScript.t.sol +++ b/test/src/abstract/BuildScript.t.sol @@ -3,6 +3,8 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {LibRainDeployConfig} from "../../../src/lib/LibRainDeployConfig.sol"; import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; import {BuildScriptHarness} from "../../concrete/BuildScriptHarness.sol"; @@ -38,6 +40,15 @@ contract BuildScriptTest is Test { /// Where the lib-ordering fixture's record is built. string constant LIBS_FIXTURE_ROOT = "test/generated-buildscript-libs"; + /// Where the `run()` config fixture's record is built. + string constant RUN_CONFIG_FIXTURE_ROOT = "test/generated-buildscript-run-config"; + + /// Where the `cutRelease()` config fixture's record is built. Its own root, + /// like every other fixture here: forge runs the tests in a contract in + /// parallel, so a root two of them share is one deleting the tree the other + /// is midway through reading. + string constant CUT_CONFIG_FIXTURE_ROOT = "test/generated-buildscript-cut-config"; + /// Carries its AST because `foundry.toml` sets `ast = true`. string constant BASE_ARTIFACT = "out/BuildScript.sol/BuildScript.json"; @@ -71,6 +82,7 @@ contract BuildScriptTest is Test { function testRunRegeneratesAndFreezesNothing() external { resetFixture(RUN_FIXTURE_ROOT); BuildScriptHarness harness = new BuildScriptHarness(RUN_FIXTURE_ROOT, FIXTURE_CONTRACT); + harness.seedConfig(); harness.run(); // Read while the fixture is still there, asserted once it is gone. @@ -143,6 +155,91 @@ contract BuildScriptTest is Test { assertEq(libs, harness.libsMarker(1, true)); } + /// PROPERTY: `run()` stages both `foundry.toml` network blocks and the + /// `.env.example` block from the roster, leaving everything outside the + /// markers where it was — and leaves the files it read untouched. + /// + /// This is the wiring, not the emission: what the sections SAY is pinned + /// against string literals in `LibRainDeployConfigTest`, over a fixture + /// roster no real network is named in. What is asserted here is that the + /// entry point CI runs on every push reaches the config at all — without + /// it the sections would exist, be correct, and be written nowhere, and + /// `Git is clean` would pass a tree whose config had drifted from the + /// roster it pins. + /// + /// The sources staying byte-identical is the half `script/build.sh` then + /// depends on: a `run()` that wrote them directly would be refused for + /// `foundry.toml` and would make the hook's copy a no-op for the other. + function testRunStagesTheNetworkConfig() external { + resetFixture(RUN_CONFIG_FIXTURE_ROOT); + BuildScriptHarness harness = new BuildScriptHarness(RUN_CONFIG_FIXTURE_ROOT, FIXTURE_CONTRACT); + harness.seedConfig(); + harness.run(); + + // Read while the fixture is still there, asserted once it is gone. + string memory stagedConfig = vm.readFile(harness.externalStagedConfigPath()); + string memory stagedEnvExample = vm.readFile(harness.externalStagedEnvExamplePath()); + string memory config = vm.readFile(harness.externalConfigPath()); + string memory envExample = vm.readFile(harness.externalEnvExamplePath()); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(RUN_CONFIG_FIXTURE_ROOT, true); + + assertEq( + stagedConfig, + string.concat( + "# hand written\n", + "# rain-deploy:generated:rpc_endpoints:begin\n", + LibRainDeployConfig.rpcEndpointsSection(vm, LibRainDeploy.supportedNetworkConfigs()), + "# rain-deploy:generated:rpc_endpoints:end\n", + "# rain-deploy:generated:etherscan:begin\n", + LibRainDeployConfig.etherscanSection(vm, LibRainDeploy.supportedNetworkConfigs()), + "# rain-deploy:generated:etherscan:end\n" + ) + ); + assertEq( + stagedEnvExample, + string.concat( + "# hand written\n", + "# rain-deploy:generated:env:begin\n", + LibRainDeployConfig.envExampleSection(vm, LibRainDeploy.supportedNetworkConfigs()), + "# rain-deploy:generated:env:end\n" + ) + ); + assertEq(config, harness.configSeed()); + assertEq(envExample, harness.envExampleSeed()); + } + + /// PROPERTY: `cutRelease()` stages nothing and leaves the config exactly as + /// it found it. + /// + /// The config is not part of a release record. A `cutRelease()` that + /// rewrote it would put a config change inside the one operation that can + /// never be repeated, where `run()` is the entry point every push already + /// runs and the only one `Git is clean` currency checks. + /// + /// The staging directory being ABSENT is what says so now: a staged file + /// left behind by a release is one the next `script/build.sh` installs, + /// which is the config change happening anyway, one step later. + function testCutReleaseLeavesTheConfigAlone() external { + resetFixture(CUT_CONFIG_FIXTURE_ROOT); + BuildScriptHarness harness = new BuildScriptHarness(CUT_CONFIG_FIXTURE_ROOT, FIXTURE_CONTRACT); + harness.seedConfig(); + harness.cutRelease(); + + // Read while the fixture is still there, asserted once it is gone. + string memory config = vm.readFile(harness.externalConfigPath()); + string memory envExample = vm.readFile(harness.externalEnvExamplePath()); + bool stagedAnything = vm.exists(harness.externalStagedDir()); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(CUT_CONFIG_FIXTURE_ROOT, true); + + assertEq(config, harness.configSeed()); + assertEq(envExample, harness.envExampleSeed()); + assertFalse(stagedAnything); + } + /// PROPERTY: a repo that overrides nothing freezes into its OWN record. /// /// The root is overridable only so a release can be cut somewhere a test @@ -184,56 +281,62 @@ contract BuildScriptTest is Test { ); } - /// PROPERTY: `run()` calls every hook that regenerates something, and holds + /// PROPERTY: `run()` calls every generator the base declares, and holds /// nothing else. /// /// THIS ASSERTION IS THE SPECIFICATION of the entry point CI calls on every - /// push: it regenerates everything this repo generates. A hook is what a - /// deriving repo implements, so a hook nothing calls is a generator that no - /// push ever runs — its output drifts from its inputs until a release cuts - /// the drift into the append-only record, which is the one place it can - /// never be fixed. + /// push: it regenerates everything this repo generates. A generator nothing + /// calls is one that no push ever runs — its output drifts from its inputs + /// until a release cuts the drift into the append-only record, which is the + /// one place it can never be fixed. + /// + /// A generator is an `internal` function that can write, whether or not it + /// is a hook. `regenerateConfig` is deliberately NOT a hook — the roster it + /// emits from is this package's own, and a repo able to override the + /// emission would deploy to and verify fewer chains with nothing red — and + /// a generator the base holds itself is as capable of being wired to + /// nothing as one a deriving repo implements. /// - /// The hooks are enumerated from the base's own declarations rather than - /// named here, because naming them is the failure: a third hook added - /// beside the two that exist today is wired by the same edit that adds it - /// or by nobody at all, and a test that lists today's two says nothing + /// They are enumerated from the base's own declarations rather than named + /// here, because naming them is the failure: a fourth generator added + /// beside the three that exist today is wired by the same edit that adds it + /// or by nobody at all, and a test that lists today's three says nothing /// either way. /// /// `run()` holding nothing but those calls is the other half. Every /// assertion here is about the set of declarations `run()` calls, and a set /// says nothing about a statement that is not a call — a guard, an early /// return, an inlined generator — which is exactly where a regeneration - /// that no hook can be overridden to change would land. + /// nothing declares would land. /// /// Order is not asserted here and cannot be: declaration order is not call /// order, and the order that is observable — /// `regenerateSnapshots` before `regenerateLibs` — is already pinned by /// `testRunRegeneratesAndFreezesNothing` through the harness's markers. /// - /// A hook that only reads is not `run()`'s to call: `recordRoot()` and - /// `snapshotContractNames()` answer questions for whoever asks one, and - /// `run()` asks neither. They are held to being called by something in - /// `testEveryHookIsReachedFromAnEntryPoint` instead. - function testRunCallsEveryHookThatRegenerates() external view { + /// A function that only reads is not `run()`'s to call: `recordRoot()`, + /// `configRoot()` and `snapshotContractNames()` answer questions for + /// whoever asks one, and `run()` asks none of them. They are held to being + /// called by something in `testEveryHookIsReachedFromAnEntryPoint` instead. + function testRunCallsEveryGenerator() external view { string memory json = baseArtifact(); string[] memory members = functionPaths(json); int256[] memory called = statementCallIds(json, namedFunctionPath(json, members, "run")); - uint256 hooks = 0; + uint256 generators = 0; for (uint256 i = 0; i < members.length; i++) { - if (!isHook(json, members[i]) || !regenerates(json, members[i])) { + if (!isGenerator(json, members[i])) { continue; } - hooks++; + generators++; assertTrue( referencesId(called, declarationId(json, members[i])), - string.concat("run() does not call the hook ", nodeField(json, members[i], "name")) + string.concat("run() does not call the generator ", nodeField(json, members[i], "name")) ); } - assertGt(hooks, 0, "the base declares no hook that regenerates anything"); - assertEq(called.length, hooks, "run() holds a call that is not one of those hooks"); + assertGt(generators, 0, "the base declares no generator"); + assertEq(called.length, generators, "run() holds a call that is not one of those generators"); } /// PROPERTY: every hook the base declares is reached from an entry point. @@ -397,10 +500,13 @@ contract BuildScriptTest is Test { && vm.parseJsonBool(json, string.concat(path, ".virtual")); } - /// Whether a function can write anything, which for a hook is what makes it - /// a generator rather than an answer to a question. - function regenerates(string memory json, string memory path) internal pure returns (bool) { - return keccak256(bytes(nodeField(json, path, "stateMutability"))) == keccak256("nonpayable"); + /// Whether a function is a generator: `internal`, and able to write. That + /// is what makes it something `run()` has to call, and it is independent of + /// `virtual` — which is about who can change the emission, not about + /// whether anything runs it. + function isGenerator(string memory json, string memory path) internal pure returns (bool) { + return keccak256(bytes(nodeField(json, path, "visibility"))) == keccak256("internal") + && keccak256(bytes(nodeField(json, path, "stateMutability"))) == keccak256("nonpayable"); } /// Whether a function can be called from outside the contract, which is diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index 0a32066..5fa68de 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -5,13 +5,11 @@ pragma solidity =0.8.25; import {DerivedDeploy} from "../../../src/abstract/RainDeployVerifyBase.sol"; import { CodeHashMismatchOnNetwork, - DeclaredChainId, NetworkChainIdMismatch, - NoDeclaredChainIds, NotDeployedOnNetwork, RainDeployVerifyChain } from "../../../src/abstract/RainDeployVerifyChain.sol"; -import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {LibRainDeploy, SupportedNetwork} from "../../../src/lib/LibRainDeploy.sol"; import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import { @@ -406,144 +404,82 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain assertFalse(extraFork, "the run opened a fork that is not one of the supported networks"); } - function exampleEtherscanConfig() internal pure returns (string memory) { - return "[etherscan]\n" "arbitrum = { key = \"k\", chain = 11 }\n" - "base = { key = \"k\", url = \"https://example.com/api\" }\n" "ethereum = { key = \"k\", chain = 33 }\n"; - } - /// External wrapper for `checkNetworkChainId` so `vm.expectRevert` works at /// the correct call depth. + /// @param network The network name, for the error only. + /// @param declared The chain id the roster states. + /// @param reported The chain id the endpoint answers with. function externalCheckNetworkChainId(string memory network, uint256 declared, uint256 reported) external pure { checkNetworkChainId(network, declared, reported); } /// External wrapper for `checkNetworkChainIds` so `vm.expectRevert` works /// at the correct call depth. - function externalCheckNetworkChainIds(DeclaredChainId[] memory declared) external { - checkNetworkChainIds(declared); + /// @param networks The roster to check. + function externalCheckNetworkChainIds(SupportedNetwork[] memory networks) external { + checkNetworkChainIds(networks); } - /// A declared chain id that is not the reported one MUST fail, naming the - /// network and BOTH ids. Which one is wrong — the declaration or the alias - /// the endpoint is bound to — is not something the check can know, and the - /// two are opposite fixes, so both ids are in the failure. + /// PROPERTY: a declared chain id that is not the reported one fails hard, + /// naming the network and BOTH ids. + /// + /// Exact values rather than a bare revert: the two ids are what tells a + /// reader whether the declaration is wrong or the alias is bound to a + /// different network, and those are opposite fixes. function testChainIdMismatchReverts() external { - vm.expectRevert(abi.encodeWithSelector(NetworkChainIdMismatch.selector, "arbitrum", 11, 22)); - this.externalCheckNetworkChainId("arbitrum", 11, 22); + vm.expectRevert(abi.encodeWithSelector(NetworkChainIdMismatch.selector, "alpha", 11, 22)); + this.externalCheckNetworkChainId("alpha", 11, 22); } - /// A declared chain id that IS the reported one MUST pass. Without this a - /// comparison that rejected every pair would satisfy the case above. + /// PROPERTY: a declared chain id that IS the reported one passes. + /// + /// The other half of the discrimination: without it a check that reverted + /// on every pair would satisfy the case above. function testChainIdMatchPasses() external view { - this.externalCheckNetworkChainId("arbitrum", 11, 11); + this.externalCheckNetworkChainId("alpha", 11, 11); } - /// The reported id MUST come from a fork of the network's own - /// `[rpc_endpoints]` alias. + /// PROPERTY: the chain id read is the one the ENDPOINT reports, on a fork + /// that really was created. /// - /// The inherited `testSupportedNetworkChainIdsAreBound` passing cannot say - /// that: a comparison reading `block.chainid` off the unforked 31337 EVM - /// fails there for every network, and so does one reading it off the wrong - /// fork, and a green run tells the two apart from neither. So this declares - /// an id no network has, for an alias that really resolves, and the id in - /// the failure is the one THAT endpoint answers with — `1`, which is - /// Ethereum's and is neither 31337 nor the declared value. + /// The roster's own pass is the inherited + /// `testSupportedNetworkChainIdsAreBound`, which cannot say where the + /// number came from — a check reading `block.chainid` off the unforked + /// 31337 EVM would fail there for every network, but so would one reading + /// it off the wrong fork, and neither is distinguishable from the other by + /// a green run. This declares a chain id no network has for an alias that + /// really resolves, and the id in the revert is the one that endpoint + /// answers with: `1`, which is Ethereum's and not 31337. function testChainIdIsReadFromTheForkedEndpoint() external { - DeclaredChainId[] memory declared = new DeclaredChainId[](1); - declared[0] = DeclaredChainId({network: LibRainDeploy.ETHEREUM, chainId: 987654}); + SupportedNetwork[] memory networks = new SupportedNetwork[](1); + networks[0] = + SupportedNetwork({name: LibRainDeploy.ETHEREUM, chainId: 987654, explorerUrl: "", defaultRpcUrl: ""}); vm.expectRevert(abi.encodeWithSelector(NetworkChainIdMismatch.selector, LibRainDeploy.ETHEREUM, 987654, 1)); - this.externalCheckNetworkChainIds(declared); + this.externalCheckNetworkChainIds(networks); } - /// EVERY declaration MUST be checked, not just the first. The wrong id here - /// is on the LAST entry, behind one that is right, so a loop that stopped - /// at the first agreement would pass. - function testChainIdChecksEveryDeclaration() external { - DeclaredChainId[] memory declared = new DeclaredChainId[](2); - declared[0] = DeclaredChainId({network: LibRainDeploy.ETHEREUM, chainId: 1}); - declared[1] = DeclaredChainId({network: LibRainDeploy.BASE, chainId: 987654}); + /// PROPERTY: EVERY entry in the roster is checked, not just the first. + /// + /// The wrong id is on the LAST entry, behind one that is right, so a loop + /// that stopped at the first agreement would pass. + function testChainIdChecksEveryEntry() external { + SupportedNetwork[] memory networks = new SupportedNetwork[](2); + networks[0] = SupportedNetwork({name: LibRainDeploy.ETHEREUM, chainId: 1, explorerUrl: "", defaultRpcUrl: ""}); + networks[1] = SupportedNetwork({name: LibRainDeploy.BASE, chainId: 987654, explorerUrl: "", defaultRpcUrl: ""}); vm.expectRevert(abi.encodeWithSelector(NetworkChainIdMismatch.selector, LibRainDeploy.BASE, 987654, 8453)); - this.externalCheckNetworkChainIds(declared); - } - - /// Nothing declared MUST fail rather than pass having forked nothing. It is - /// the one input that satisfies the loop without a subject, and it is what - /// a config whose every entry resolves through a `url` alone hands in. - function testChainIdNoDeclarationsReverts() external { - vm.expectRevert(NoDeclaredChainIds.selector); - this.externalCheckNetworkChainIds(new DeclaredChainId[](0)); - } - - /// The declarations MUST be the ids the config text states, paired with the - /// networks that state them. Distinct values, so a pairing that slipped by - /// one is a different number rather than the same one twice. - function testDeclaredChainIdsReadsTheConfigText() external view { - string[] memory networks = new string[](2); - networks[0] = LibRainDeploy.ARBITRUM_ONE; - networks[1] = LibRainDeploy.ETHEREUM; - - DeclaredChainId[] memory declared = declaredChainIds(exampleEtherscanConfig(), networks); - - assertEq(declared.length, 2); - assertEq(declared[0].network, LibRainDeploy.ARBITRUM_ONE); - assertEq(declared[0].chainId, 11); - assertEq(declared[1].network, LibRainDeploy.ETHEREUM); - assertEq(declared[1].chainId, 33); + this.externalCheckNetworkChainIds(networks); } - /// An entry that states no `chain` MUST be skipped rather than read as a - /// zero. The config group requires only `chain` OR `url` of an entry, so an - /// entry resolving through its `url` claims no chain id — and a zero - /// standing in for the absent claim is a mismatch against every network - /// there is. + /// PROPERTY: an empty roster fails rather than passing having forked + /// nothing. /// - /// The skipped entry is in the MIDDLE, so the entry after it is still read - /// and still paired with its own network. - function testDeclaredChainIdsSkipsEntriesWithNoChain() external view { - string[] memory networks = new string[](3); - networks[0] = LibRainDeploy.ARBITRUM_ONE; - networks[1] = LibRainDeploy.BASE; - networks[2] = LibRainDeploy.ETHEREUM; - - DeclaredChainId[] memory declared = declaredChainIds(exampleEtherscanConfig(), networks); - - assertEq(declared.length, 2); - assertEq(declared[0].network, LibRainDeploy.ARBITRUM_ONE); - assertEq(declared[0].chainId, 11); - assertEq(declared[1].network, LibRainDeploy.ETHEREUM); - assertEq(declared[1].chainId, 33); - } - - /// A config where nothing states a `chain` MUST produce nothing to check, - /// which `checkNetworkChainIds` then refuses. Read through the same pair of - /// calls the inherited test makes, so the refusal is reachable from config - /// text rather than only from an array a test built. - function testDeclaredChainIdsOfUrlOnlyEntriesIsRefused() external { - string[] memory networks = new string[](1); - networks[0] = LibRainDeploy.BASE; - - DeclaredChainId[] memory declared = declaredChainIds(exampleEtherscanConfig(), networks); - assertEq(declared.length, 0); - - vm.expectRevert(NoDeclaredChainIds.selector); - this.externalCheckNetworkChainIds(declared); - } - - /// A network with no `[etherscan]` entry at all MUST be skipped here rather - /// than reverting on the read. Membership is the config group's assertion - /// and it names the missing network; a parse error here would fail first, - /// on a network, with nothing about the section it is missing from. - function testDeclaredChainIdsSkipsNetworksWithNoEntry() external view { - string[] memory networks = new string[](2); - networks[0] = LibRainDeploy.FLARE; - networks[1] = LibRainDeploy.ETHEREUM; - - DeclaredChainId[] memory declared = declaredChainIds(exampleEtherscanConfig(), networks); - - assertEq(declared.length, 1); - assertEq(declared[0].network, LibRainDeploy.ETHEREUM); - assertEq(declared[0].chainId, 33); + /// The one shape of roster that satisfies every loop here without a + /// subject, and the shape a repo that narrowed the list all the way down + /// would be in. + function testChainIdEmptyRosterReverts() external { + vm.expectRevert(LibRainDeploy.NoNetworks.selector); + this.externalCheckNetworkChainIds(new SupportedNetwork[](0)); } } diff --git a/test/src/abstract/RainDeployVerifySnapshotBase.t.sol b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol index 1fc3d5f..bf1f8ef 100644 --- a/test/src/abstract/RainDeployVerifySnapshotBase.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshotBase.t.sol @@ -5,7 +5,6 @@ pragma solidity =0.8.25; import {ZoltuDerivationMismatch} from "../../../src/abstract/RainDeployVerifyBase.sol"; import {CandidateSourceMismatch, DeployCandidate, DeploySuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; import { - EtherscanEntryUnresolvable, FrozenSnapshotAmbiguous, FrozenSnapshotNotReleased, FrozenSnapshotUnreadable, @@ -88,12 +87,6 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri checkFrozenSnapshotsReleased(paths, released); } - /// External wrapper for `checkNetworksConfigured` so `vm.expectRevert` works - /// at the correct call depth. - function externalCheckNetworksConfigured(string memory config, string[] memory networks) external view { - checkNetworksConfigured(config, networks); - } - /// External wrapper for `recordedDeployedAddress` so `vm.expectRevert` /// works at the correct call depth. /// @param path The record file, for the error only. @@ -740,65 +733,4 @@ contract RainDeployVerifySnapshotBaseTest is ExampleDeploySuites, RainDeployVeri assertEq(suites[i].storedDeployedAddress.code.length, 0); } } - - /// The networks the config fixtures below are written against. Named for - /// nothing real, so no fixture here reads as a claim about the networks - /// this repo actually deploys to. - function fixtureNetworks() internal pure returns (string[] memory) { - string[] memory networks = new string[](3); - networks[0] = "alpha"; - networks[1] = "beta"; - networks[2] = "gamma"; - return networks; - } - - /// A config whose sections name exactly the fixture networks and whose - /// `[etherscan]` entries can all resolve — `chain` alone on two of them and - /// `url` alone on the third, because each is sufficient by itself. - string constant CONFIG_RESOLVABLE = "[rpc_endpoints]\n" "alpha = \"${ALPHA_RPC_URL}\"\n" - "beta = \"${BETA_RPC_URL}\"\n" "gamma = \"${GAMMA_RPC_URL}\"\n" "\n" "[etherscan]\n" - "alpha = { key = \"${A}\", chain = 999 }\n" "beta = { key = \"${B}\", url = \"https://example.com/api\" }\n" - "gamma = { key = \"${C}\", chain = 1000 }\n"; - - /// `CONFIG_RESOLVABLE` with the LAST `[etherscan]` entry stripped back to - /// its key, so a check that stops before the end of the section lets it - /// through. - string constant CONFIG_LAST_ENTRY_UNRESOLVABLE = "[rpc_endpoints]\n" "alpha = \"${ALPHA_RPC_URL}\"\n" - "beta = \"${BETA_RPC_URL}\"\n" "gamma = \"${GAMMA_RPC_URL}\"\n" "\n" "[etherscan]\n" - "alpha = { key = \"${A}\", chain = 999 }\n" "beta = { key = \"${B}\", url = \"https://example.com/api\" }\n" - "gamma = { key = \"${C}\" }\n"; - - /// `CONFIG_RESOLVABLE` with the MIDDLE `[etherscan]` entry stripped back to - /// its key. That entry is the one `url` alone was carrying, so this is also - /// what a `url` deleted from an otherwise untouched entry leaves behind. - string constant CONFIG_MIDDLE_ENTRY_UNRESOLVABLE = "[rpc_endpoints]\n" "alpha = \"${ALPHA_RPC_URL}\"\n" - "beta = \"${BETA_RPC_URL}\"\n" "gamma = \"${GAMMA_RPC_URL}\"\n" "\n" "[etherscan]\n" - "alpha = { key = \"${A}\", chain = 999 }\n" "beta = { key = \"${B}\" }\n" - "gamma = { key = \"${C}\", chain = 1000 }\n"; - - /// A config whose sections name the networks and whose `[etherscan]` - /// entries can all resolve MUST pass, so the failing cases below are - /// discriminating rather than a check that cannot succeed. - function testConfigWithResolvableEtherscanEntriesPasses() external view { - this.externalCheckNetworksConfigured(CONFIG_RESOLVABLE, fixtureNetworks()); - } - - /// An `[etherscan]` entry carrying only a `key` MUST fail, naming itself. - /// This is the whole subject of the shape assertion: such an entry - /// satisfies every membership assertion beside it — the sections here name - /// exactly the networks, in both directions — and is still config that - /// verifies nothing, on any network in the section rather than only on its - /// own. - function testConfigWithUnresolvableLastEtherscanEntryReverts() external { - vm.expectRevert(abi.encodeWithSelector(EtherscanEntryUnresolvable.selector, "gamma")); - this.externalCheckNetworksConfigured(CONFIG_LAST_ENTRY_UNRESOLVABLE, fixtureNetworks()); - } - - /// The unresolvable entry MUST be caught wherever it sits, and reported as - /// itself. A config section is keyed rather than ordered, so which entry is - /// the broken one is not something the check gets to assume. - function testConfigWithUnresolvableMiddleEtherscanEntryReverts() external { - vm.expectRevert(abi.encodeWithSelector(EtherscanEntryUnresolvable.selector, "beta")); - this.externalCheckNetworksConfigured(CONFIG_MIDDLE_ENTRY_UNRESOLVABLE, fixtureNetworks()); - } } diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index ee40f16..c46f13f 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; -import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {LibRainDeploy, SupportedNetwork} from "../../../src/lib/LibRainDeploy.sol"; import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; import {MockAddressRevertingFactory} from "../../concrete/MockAddressRevertingFactory.sol"; @@ -209,6 +209,57 @@ contract LibRainDeployTest is Test { assertEq(networks[8], LibRainDeploy.ROBINHOOD); } + /// `supportedNetworks` MUST be the names of `supportedNetworkConfigs`, in + /// the same order and with nothing dropped. + /// + /// The two are one list or they are two lists, and two lists is what this + /// whole arrangement removes: the config sections are written from the + /// roster while the deploy and the fork matrix read the names. + function testSupportedNetworksAreTheRosterNames() external pure { + SupportedNetwork[] memory configs = LibRainDeploy.supportedNetworkConfigs(); + string[] memory networks = LibRainDeploy.supportedNetworks(); + assertEq(networks.length, configs.length); + for (uint256 i = 0; i < configs.length; i++) { + assertEq(networks[i], configs[i].name); + } + } + + /// Every roster entry MUST state the chain id its network really has. + /// + /// Spelled out here rather than looped, because a loop over the roster + /// comparing it to itself asserts nothing. These nine numbers came from + /// `cast chain-id` against the endpoints `.env.example` binds, and they are + /// what `--verify` submits as `chain`. + function testSupportedNetworkChainIds() external pure { + SupportedNetwork[] memory configs = LibRainDeploy.supportedNetworkConfigs(); + assertEq(configs.length, 9); + assertEq(configs[0].chainId, 42161); + assertEq(configs[1].chainId, 8453); + assertEq(configs[2].chainId, 84532); + assertEq(configs[3].chainId, 56); + assertEq(configs[4].chainId, 1); + assertEq(configs[5].chainId, 14); + assertEq(configs[6].chainId, 999); + assertEq(configs[7].chainId, 137); + assertEq(configs[8].chainId, 4663); + } + + /// Only Robinhood Chain states an explorer url, because it is the only + /// supported network Etherscan V2 does not index. + /// + /// A url stated on a network Etherscan does index would point `--verify` at + /// that url instead, silently, for that network alone. + function testSupportedNetworkExplorerUrls() external pure { + SupportedNetwork[] memory configs = LibRainDeploy.supportedNetworkConfigs(); + for (uint256 i = 0; i < configs.length; i++) { + if (keccak256(bytes(configs[i].name)) == keccak256(bytes(LibRainDeploy.ROBINHOOD))) { + assertEq(configs[i].explorerUrl, "https://robinhoodchain.blockscout.com/api"); + } else { + assertEq(configs[i].explorerUrl, "", configs[i].name); + } + } + } + /// `ZOLTU_FACTORY_CODEHASH` MUST match the actual codehash of the Zoltu /// factory on every supported network. Every name in `supportedNetworks` /// MUST also be a configured fork alias, otherwise it cannot be deployed diff --git a/test/src/lib/LibRainDeployConfig.t.sol b/test/src/lib/LibRainDeployConfig.t.sol new file mode 100644 index 0000000..a663319 --- /dev/null +++ b/test/src/lib/LibRainDeployConfig.t.sol @@ -0,0 +1,508 @@ +// 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.2/src/Test.sol"; +import {LibRainDeploy, SupportedNetwork} from "../../../src/lib/LibRainDeploy.sol"; +import { + BuildHookMissing, + EmptyRoster, + GeneratedBlockMalformed, + LibRainDeployConfig, + NoChainId +} from "../../../src/lib/LibRainDeployConfig.sol"; + +/// @title LibRainDeployConfigTest +/// @notice The generator that replaces the comparison. What a comparison +/// asserted about two lists, this has to EMIT, so the expectations here are +/// string literals rather than anything the library builds — an expectation +/// concatenated the way the source concatenates it would pass for any spelling +/// the source happens to use, including a broken one. +/// +/// The fixture roster is `alpha`/`beta`/`gamma`, which no real network is +/// named, so nothing here can pass against this repo's own config by accident. +contract LibRainDeployConfigTest is Test { + /// Where the `writeNetworkConfig` fixture is built. Under `test/`, which is + /// the root the config grants read-write; `foundry.toml` itself is written + /// only by `script/Build.sol`, never from here. + /// + /// One root per test, because forge runs the tests in a contract in + /// parallel: a shared root is one test deleting the tree another is + /// midway through reading. + string constant CONFIG_FIXTURE_ROOT = "test/generated-config-write"; + + /// Where the `writeEnvExample` fixture is built. + string constant ENV_FIXTURE_ROOT = "test/generated-config-env"; + + /// Where the fixture carrying no markers is built. + string constant NO_MARKERS_FIXTURE_ROOT = "test/generated-config-no-markers"; + + /// Where the `writeStagedConfig` fixture is built. + string constant STAGED_FIXTURE_ROOT = "test/generated-config-staged"; + + /// Where the fixture for a repo with no build hook is built. + string constant NO_HOOK_FIXTURE_ROOT = "test/generated-config-no-hook"; + + /// A roster of three, one of which carries an explorer url, so both + /// `[etherscan]` entry shapes are emitted by one call. + /// @return The fixture roster. + function fixtureRoster() internal pure returns (SupportedNetwork[] memory) { + SupportedNetwork[] memory networks = new SupportedNetwork[](3); + networks[0] = + SupportedNetwork({name: "alpha", chainId: 11, explorerUrl: "", defaultRpcUrl: "https://alpha.example"}); + networks[1] = SupportedNetwork({ + name: "beta", chainId: 22, explorerUrl: "https://beta.example/api", defaultRpcUrl: "https://beta.example" + }); + networks[2] = + SupportedNetwork({name: "gamma", chainId: 33, explorerUrl: "", defaultRpcUrl: "https://gamma.example"}); + return networks; + } + + /// A roster of one, for the cases where three would say nothing extra. + /// @param name The network name. + /// @param chainId The chain id it declares. + /// @return The roster. + function singleRoster(string memory name, uint256 chainId) internal pure returns (SupportedNetwork[] memory) { + SupportedNetwork[] memory networks = new SupportedNetwork[](1); + networks[0] = SupportedNetwork({name: name, chainId: chainId, explorerUrl: "", defaultRpcUrl: "https://one"}); + return networks; + } + + /// Clears a fixture tree an earlier failure left behind. A cheatcode write + /// is not undone by a revert, so a failing test leaves its files on disk + /// and the next run reads THOSE. + /// @param root The fixture root to clear. + function resetFixtures(string memory root) internal { + if (vm.exists(root)) { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(root, true); + } + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(root, true); + } + + /// Writes a fixture file, creating the root above it. + /// @param root The fixture root the file sits under. + /// @param path The file to write. + /// @param content What to write there. + function writeFixture(string memory root, string memory path, string memory content) internal { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(root, true); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, content); + } + + /// External wrappers, so `vm.expectRevert` sees a call at a lower depth + /// than its own. An internal library call reverts at this contract's depth + /// and the cheatcode refuses it. + /// @param networks The roster to render. + /// @return The section. + function externalRpcEndpointsSection(SupportedNetwork[] memory networks) external pure returns (string memory) { + return LibRainDeployConfig.rpcEndpointsSection(vm, networks); + } + + /// @param networks The roster to render. + /// @return The section. + function externalEtherscanSection(SupportedNetwork[] memory networks) external pure returns (string memory) { + return LibRainDeployConfig.etherscanSection(vm, networks); + } + + /// @param networks The roster to render. + /// @return The block. + function externalEnvExampleSection(SupportedNetwork[] memory networks) external pure returns (string memory) { + return LibRainDeployConfig.envExampleSection(vm, networks); + } + + /// @param path The file the content came from, for the error only. + /// @param content The whole file. + /// @param name The block name. + /// @param body The block's new content. + /// @return The whole file, with the block replaced. + function externalSpliceBlock(string memory path, string memory content, string memory name, string memory body) + external + pure + returns (string memory) + { + return LibRainDeployConfig.spliceBlock(vm, path, content, name, body); + } + + /// @param path The config to read. + /// @param staged Where to write the spliced result. + /// @param networks The roster. + function externalWriteNetworkConfig(string memory path, string memory staged, SupportedNetwork[] memory networks) + external + { + LibRainDeployConfig.writeNetworkConfig(vm, path, staged, networks); + } + + /// @param root The project root to stage under. + /// @param hookPath The build hook that would install what is staged. + /// @param networks The roster. + function externalWriteStagedConfig(string memory root, string memory hookPath, SupportedNetwork[] memory networks) + external + { + LibRainDeployConfig.writeStagedConfig(vm, root, hookPath, networks); + } + + /// PROPERTY: the `[rpc_endpoints]` section is the roster, in roster order, + /// each alias interpolating its own `_RPC_URL`. + function testRpcEndpointsSectionIsTheRoster() external pure { + assertEq( + LibRainDeployConfig.rpcEndpointsSection(vm, fixtureRoster()), + "[rpc_endpoints]\n" 'alpha = "${ALPHA_RPC_URL}"\n' 'beta = "${BETA_RPC_URL}"\n' + 'gamma = "${GAMMA_RPC_URL}"\n' + ); + } + + /// PROPERTY: the `[etherscan]` section states `chain` on every entry, and + /// `url` as well on the entries the roster gives one for. + /// + /// This is #192's closing assertion, carried across the change that removed + /// the test which made it. What was asserted about a hand-written section + /// is emitted here, so a section carrying an entry with neither is no + /// longer a state this repo or a consumer can be in. + function testEtherscanSectionStatesChainOnEveryEntry() external pure { + assertEq( + LibRainDeployConfig.etherscanSection(vm, fixtureRoster()), + "[etherscan]\n" 'alpha = { key = "${CI_DEPLOY_ALPHA_ETHERSCAN_API_KEY}", chain = 11 }\n' + 'beta = { key = "${CI_DEPLOY_BETA_ETHERSCAN_API_KEY}", chain = 22, url = "https://beta.example/api" }\n' + 'gamma = { key = "${CI_DEPLOY_GAMMA_ETHERSCAN_API_KEY}", chain = 33 }\n' + ); + } + + /// PROPERTY: what is emitted parses as the section it claims to be, and + /// every entry in it satisfies foundry's own condition for an entry it can + /// resolve. + /// + /// Asked of foundry's TOML parser rather than of the emitted text, so the + /// oracle is the file format and not the concatenation under test. The + /// literal above pins the bytes; this pins what they MEAN. + function testEtherscanSectionEntriesAreResolvable() external view { + string memory section = LibRainDeployConfig.etherscanSection(vm, fixtureRoster()); + string[] memory entries = vm.parseTomlKeys(section, ".etherscan"); + assertEq(entries.length, 3); + for (uint256 i = 0; i < entries.length; i++) { + assertTrue( + vm.keyExistsToml(section, string.concat(".etherscan.", entries[i], ".chain")) + || vm.keyExistsToml(section, string.concat(".etherscan.", entries[i], ".url")), + string.concat("[etherscan] entry states neither chain nor url: ", entries[i]) + ); + } + } + + /// PROPERTY: the same holds of the section generated from the REAL roster, + /// which is the one every consumer's config is written from. + function testEtherscanSectionOfTheSupportedNetworksIsResolvable() external view { + SupportedNetwork[] memory networks = LibRainDeploy.supportedNetworkConfigs(); + string memory section = LibRainDeployConfig.etherscanSection(vm, networks); + string[] memory entries = vm.parseTomlKeys(section, ".etherscan"); + assertEq(entries.length, networks.length); + for (uint256 i = 0; i < entries.length; i++) { + assertTrue( + vm.keyExistsToml(section, string.concat(".etherscan.", entries[i], ".chain")) + || vm.keyExistsToml(section, string.concat(".etherscan.", entries[i], ".url")), + string.concat("[etherscan] entry states neither chain nor url: ", entries[i]) + ); + } + } + + /// PROPERTY: a roster entry with no chain id is refused rather than emitted + /// as `chain = 0`, which resolves to no chain at all. + function testEtherscanSectionZeroChainIdReverts() external { + SupportedNetwork[] memory networks = fixtureRoster(); + networks[1].chainId = 0; + vm.expectRevert(abi.encodeWithSelector(NoChainId.selector, "beta")); + this.externalEtherscanSection(networks); + } + + /// PROPERTY: the `.env.example` block declares each network's endpoint + /// variable bound to the roster's default for it. + function testEnvExampleSectionIsTheRosterDefaults() external pure { + assertEq( + LibRainDeployConfig.envExampleSection(vm, fixtureRoster()), + "ALPHA_RPC_URL=https://alpha.example\n" "BETA_RPC_URL=https://beta.example\n" + "GAMMA_RPC_URL=https://gamma.example\n" + ); + } + + /// PROPERTY: a network name that is not already uppercase reaches the + /// variable names uppercased, and an underscore survives it. + /// + /// Every real network name is lowercase, so a generator that passed the + /// name through unchanged would emit `base_sepolia = "${base_sepolia_RPC_URL}"` + /// and every endpoint would be unbound at the same time. + function testVariableNamesAreUppercased() external pure { + assertEq(LibRainDeployConfig.rpcUrlVar(vm, "base_sepolia"), "BASE_SEPOLIA_RPC_URL"); + assertEq(LibRainDeployConfig.etherscanKeyVar(vm, "base_sepolia"), "CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY"); + } + + /// PROPERTY: an empty roster is refused by every emitter rather than + /// written as an empty section. + /// + /// An empty `[rpc_endpoints]` is a repo that deploys nowhere and forks + /// nothing, with every check that reads it green for want of a subject. + function testEmptyRosterReverts() external { + SupportedNetwork[] memory networks = new SupportedNetwork[](0); + + vm.expectRevert(EmptyRoster.selector); + this.externalRpcEndpointsSection(networks); + + vm.expectRevert(EmptyRoster.selector); + this.externalEtherscanSection(networks); + + vm.expectRevert(EmptyRoster.selector); + this.externalEnvExampleSection(networks); + } + + /// PROPERTY: a splice replaces what is between the markers and moves + /// nothing outside them. + /// + /// The whole reason the blocks are delimited: everything else in a + /// `foundry.toml` is hand written, and a generator that owned the file + /// would take the profile with it. + function testSpliceReplacesOnlyBetweenTheMarkers() external pure { + string memory before = "# leading prose\n" "# rain-deploy:generated:env:begin\n" "STALE=1\n" + "# rain-deploy:generated:env:end\n" "# trailing prose\n"; + + assertEq( + LibRainDeployConfig.spliceBlock(vm, "fixture", before, "env", "FRESH=2\n"), + "# leading prose\n" "# rain-deploy:generated:env:begin\n" "FRESH=2\n" "# rain-deploy:generated:env:end\n" + "# trailing prose\n" + ); + } + + /// PROPERTY: splicing the same body twice is the same file, so a build that + /// changed nothing leaves nothing for `Git is clean` to see. + function testSpliceIsIdempotent() external pure { + string memory before = "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n"; + string memory once = LibRainDeployConfig.spliceBlock(vm, "fixture", before, "env", "FRESH=2\n"); + assertEq(LibRainDeployConfig.spliceBlock(vm, "fixture", once, "env", "FRESH=2\n"), once); + } + + /// PROPERTY: a file carrying more than one block has the named one replaced + /// and the others left where they are. + function testSpliceLeavesTheOtherBlocksAlone() external pure { + string memory before = "# rain-deploy:generated:rpc_endpoints:begin\n" "[rpc_endpoints]\n" + "# rain-deploy:generated:rpc_endpoints:end\n" "\n" "# rain-deploy:generated:etherscan:begin\n" "STALE\n" + "# rain-deploy:generated:etherscan:end\n"; + + assertEq( + LibRainDeployConfig.spliceBlock(vm, "fixture", before, "etherscan", "[etherscan]\n"), + "# rain-deploy:generated:rpc_endpoints:begin\n" "[rpc_endpoints]\n" + "# rain-deploy:generated:rpc_endpoints:end\n" "\n" "# rain-deploy:generated:etherscan:begin\n" + "[etherscan]\n" "# rain-deploy:generated:etherscan:end\n" + ); + } + + /// PROPERTY: a file with no begin marker is refused, naming the file and + /// the block. + /// + /// This is what a consumer that has not adopted the generated blocks looks + /// like, and it MUST be loud: appending the section instead would be a + /// duplicate TOML key at the next forge startup, and skipping it silently + /// would leave a hand-written config nothing regenerates and nothing + /// compares any more. + function testSpliceMissingBeginMarkerReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock( + "fixture", "# nothing generated here\n# rain-deploy:generated:env:end\n", "env", "FRESH=2\n" + ); + } + + /// PROPERTY: a file with a begin marker and no end marker is refused. + function testSpliceMissingEndMarkerReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock("fixture", "# rain-deploy:generated:env:begin\nSTALE=1\n", "env", "FRESH=2\n"); + } + + /// PROPERTY: a second begin marker is refused rather than one of the two + /// blocks being picked. + function testSpliceDuplicateBeginMarkerReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock( + "fixture", + "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n" + "# rain-deploy:generated:env:begin\n" "ALSO_STALE=1\n" "# rain-deploy:generated:env:end\n", + "env", + "FRESH=2\n" + ); + } + + /// PROPERTY: a second end marker is refused. + function testSpliceDuplicateEndMarkerReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock( + "fixture", + "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n" + "# rain-deploy:generated:env:end\n", + "env", + "FRESH=2\n" + ); + } + + /// PROPERTY: an end marker BEFORE the begin marker is refused rather than + /// spliced around, which would move the marker to the other side of the + /// block and leave a file that never splices again. + function testSpliceEndBeforeBeginReverts() external { + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, "fixture", "env")); + this.externalSpliceBlock( + "fixture", + "# rain-deploy:generated:env:end\n" "STALE=1\n" "# rain-deploy:generated:env:begin\n", + "env", + "FRESH=2\n" + ); + } + + /// PROPERTY: the config writer replaces BOTH sections of the file it reads, + /// and nothing else in it, writing the result WHERE IT WAS TOLD rather than + /// back over what it read. + /// + /// The source staying untouched is the property: foundry refuses a + /// cheatcode write to the project root's own `foundry.toml`, so the only + /// thing that can put this result there is `script/build.sh`. + function testWriteNetworkConfigWritesBothSections() external { + resetFixtures(CONFIG_FIXTURE_ROOT); + string memory path = string.concat(CONFIG_FIXTURE_ROOT, "/foundry.toml"); + string memory staged = string.concat(CONFIG_FIXTURE_ROOT, "/staged-foundry.toml"); + string memory source = "[profile.default]\n" "src = \"src\"\n" "\n" + "# rain-deploy:generated:rpc_endpoints:begin\n" "STALE\n" "# rain-deploy:generated:rpc_endpoints:end\n" "\n" + "# rain-deploy:generated:etherscan:begin\n" "STALE\n" "# rain-deploy:generated:etherscan:end\n"; + writeFixture(CONFIG_FIXTURE_ROOT, path, source); + + LibRainDeployConfig.writeNetworkConfig(vm, path, staged, singleRoster("alpha", 11)); + + string memory written = vm.readFile(staged); + string memory read = vm.readFile(path); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(CONFIG_FIXTURE_ROOT, true); + + assertEq(read, source); + assertEq( + written, + "[profile.default]\n" "src = \"src\"\n" "\n" "# rain-deploy:generated:rpc_endpoints:begin\n" + "[rpc_endpoints]\n" 'alpha = "${ALPHA_RPC_URL}"\n' "# rain-deploy:generated:rpc_endpoints:end\n" "\n" + "# rain-deploy:generated:etherscan:begin\n" "[etherscan]\n" + 'alpha = { key = "${CI_DEPLOY_ALPHA_ETHERSCAN_API_KEY}", chain = 11 }\n' + "# rain-deploy:generated:etherscan:end\n" + ); + } + + /// PROPERTY: the `.env.example` writer replaces its block and nothing else, + /// and leaves what it read where it was. + function testWriteEnvExampleWritesTheBlock() external { + resetFixtures(ENV_FIXTURE_ROOT); + string memory path = string.concat(ENV_FIXTURE_ROOT, "/.env.example"); + string memory staged = string.concat(ENV_FIXTURE_ROOT, "/staged.env.example"); + string memory source = "# prose\n" "# rain-deploy:generated:env:begin\n" "STALE=1\n" + "# rain-deploy:generated:env:end\n" "HAND_WRITTEN=1\n"; + writeFixture(ENV_FIXTURE_ROOT, path, source); + + LibRainDeployConfig.writeEnvExample(vm, path, staged, singleRoster("alpha", 11)); + + string memory written = vm.readFile(staged); + string memory read = vm.readFile(path); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(ENV_FIXTURE_ROOT, true); + + assertEq(read, source); + assertEq( + written, + "# prose\n" "# rain-deploy:generated:env:begin\n" "ALPHA_RPC_URL=https://one\n" + "# rain-deploy:generated:env:end\n" "HAND_WRITTEN=1\n" + ); + } + + /// PROPERTY: the writer names the FILE it was pointed at when that file + /// carries no markers, rather than the file it would have been pointed at + /// by default. + function testWriteNetworkConfigWithoutMarkersReverts() external { + resetFixtures(NO_MARKERS_FIXTURE_ROOT); + string memory path = string.concat(NO_MARKERS_FIXTURE_ROOT, "/foundry.toml"); + string memory staged = string.concat(NO_MARKERS_FIXTURE_ROOT, "/staged-foundry.toml"); + writeFixture(NO_MARKERS_FIXTURE_ROOT, path, "[profile.default]\n"); + + vm.expectRevert(abi.encodeWithSelector(GeneratedBlockMalformed.selector, path, "rpc_endpoints")); + this.externalWriteNetworkConfig(path, staged, singleRoster("alpha", 11)); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(NO_MARKERS_FIXTURE_ROOT, true); + } + + /// PROPERTY: a staged file is named EXACTLY as the file it will be + /// installed over, and sits under the staging directory of the root it was + /// generated for. + /// + /// `script/build.sh` copies each staged file onto the file of the same name + /// at the root, so the name is the whole of the mapping between the two. + function testStagedPathsAreNamedAsTheFilesTheyInstallOver() external pure { + assertEq(LibRainDeployConfig.configPath("."), "./foundry.toml"); + assertEq(LibRainDeployConfig.envExamplePath("."), "./.env.example"); + assertEq(LibRainDeployConfig.stagedDir("."), "./.staged-config"); + assertEq(LibRainDeployConfig.stagedPath(".", LibRainDeployConfig.CONFIG_NAME), "./.staged-config/foundry.toml"); + assertEq( + LibRainDeployConfig.stagedPath(".", LibRainDeployConfig.ENV_EXAMPLE_NAME), "./.staged-config/.env.example" + ); + } + + /// PROPERTY: staging puts BOTH generated files in the staging directory and + /// changes neither of the files it read. + function testWriteStagedConfigStagesBothFiles() external { + resetFixtures(STAGED_FIXTURE_ROOT); + string memory configSource = "# rain-deploy:generated:rpc_endpoints:begin\n" "STALE\n" + "# rain-deploy:generated:rpc_endpoints:end\n" "# rain-deploy:generated:etherscan:begin\n" "STALE\n" + "# rain-deploy:generated:etherscan:end\n"; + string memory envSource = "# rain-deploy:generated:env:begin\n" "STALE=1\n" "# rain-deploy:generated:env:end\n"; + writeFixture(STAGED_FIXTURE_ROOT, LibRainDeployConfig.configPath(STAGED_FIXTURE_ROOT), configSource); + writeFixture(STAGED_FIXTURE_ROOT, LibRainDeployConfig.envExamplePath(STAGED_FIXTURE_ROOT), envSource); + + LibRainDeployConfig.writeStagedConfig( + vm, STAGED_FIXTURE_ROOT, LibRainDeployConfig.BUILD_HOOK_PATH, singleRoster("alpha", 11) + ); + + string memory stagedConfig = + vm.readFile(LibRainDeployConfig.stagedPath(STAGED_FIXTURE_ROOT, LibRainDeployConfig.CONFIG_NAME)); + string memory stagedEnv = + vm.readFile(LibRainDeployConfig.stagedPath(STAGED_FIXTURE_ROOT, LibRainDeployConfig.ENV_EXAMPLE_NAME)); + string memory readConfig = vm.readFile(LibRainDeployConfig.configPath(STAGED_FIXTURE_ROOT)); + string memory readEnv = vm.readFile(LibRainDeployConfig.envExamplePath(STAGED_FIXTURE_ROOT)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(STAGED_FIXTURE_ROOT, true); + + assertEq(readConfig, configSource); + assertEq(readEnv, envSource); + assertEq( + stagedConfig, + "# rain-deploy:generated:rpc_endpoints:begin\n" "[rpc_endpoints]\n" 'alpha = "${ALPHA_RPC_URL}"\n' + "# rain-deploy:generated:rpc_endpoints:end\n" "# rain-deploy:generated:etherscan:begin\n" "[etherscan]\n" + 'alpha = { key = "${CI_DEPLOY_ALPHA_ETHERSCAN_API_KEY}", chain = 11 }\n' + "# rain-deploy:generated:etherscan:end\n" + ); + assertEq( + stagedEnv, + "# rain-deploy:generated:env:begin\n" "ALPHA_RPC_URL=https://one\n" "# rain-deploy:generated:env:end\n" + ); + } + + /// PROPERTY: a repo with no build hook is refused, naming the hook, and + /// nothing is staged. + /// + /// The hook is the only thing that installs a staged file. Staging for a + /// repo that has none writes the roster where nothing reads it while the + /// config goes on saying whatever it said, and the build reports success — + /// the silent green this whole mechanism exists to remove. Presence is what + /// `rainix-copy-artifacts` conditions its own run of the hook on, so this + /// is exactly the state in which the install would be skipped. + function testWriteStagedConfigWithoutBuildHookReverts() external { + resetFixtures(NO_HOOK_FIXTURE_ROOT); + string memory missing = string.concat(NO_HOOK_FIXTURE_ROOT, "/build.sh"); + + vm.expectRevert(abi.encodeWithSelector(BuildHookMissing.selector, missing)); + this.externalWriteStagedConfig(NO_HOOK_FIXTURE_ROOT, missing, singleRoster("alpha", 11)); + + bool stagedAnything = vm.exists(LibRainDeployConfig.stagedDir(NO_HOOK_FIXTURE_ROOT)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(NO_HOOK_FIXTURE_ROOT, true); + + assertFalse(stagedAnything); + } +}