Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,13 @@ guard.
Five groups, sorted by what each is anchored to and therefore by what each can
catch:

| Group | Anchored to | Catches | Cannot catch |
| -------- | ---------------------- | ------------------------------------- | -------------------------------- |
| Internal | the recorded set | an inconsistently generated set | a snapshot of the wrong contract |
| Source | `type(X).creationCode` | a snapshot of the wrong contract | anything about any chain |
| Record | the frozen record | a release the declaration missed | what a declared suite records |
| Chain | the networks | never deployed, or not there any more | anything about 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 | 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 |

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
Expand Down Expand Up @@ -196,13 +196,30 @@ 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.
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 while the KEYS are the whole
contract, and the keys are 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).
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.

## Address registry

Expand Down Expand Up @@ -644,17 +661,19 @@ 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 group reads the CONSUMING repo's `foundry.toml`, so that repo has to
allow it and has to have the sections to be read:
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:

```toml
fs_permissions = [{ access = "read", path = "./foundry.toml" }]
```

`[rpc_endpoints]` and `[etherscan]` then have to name exactly the networks in
`supportedNetworks()`. 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.
`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.

## Develop

Expand Down
33 changes: 22 additions & 11 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,32 @@ robinhood = "${ROBINHOOD_RPC_URL}"
# 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 the entries whose alias foundry does not itself resolve
# to a chain. An entry with neither `chain` nor `url` under such an alias 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.
# `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.
#
# 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.
[etherscan]
arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}" }
base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}" }
base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}" }
arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}", chain = 42161 }
base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}", chain = 8453 }
base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}", chain = 84532 }
bsc = { key = "${CI_DEPLOY_BSC_ETHERSCAN_API_KEY}", chain = 56 }
ethereum = { key = "${CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY}", chain = 1 }
flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}" }
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}" }
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
Expand Down
130 changes: 129 additions & 1 deletion src/abstract/RainDeployVerifyChain.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,37 @@ 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.
/// @param network The network name, as configured in `[rpc_endpoints]`.
/// @param declared The chain id the `[etherscan]` entry 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
/// RELEASED suite's derived address carries code with its derived code hash.
/// RELEASED suite's derived address carries code with its derived code hash,
/// and every chain id `[etherscan]` declares is the one that network's alias
/// forks.
///
/// This is the only group that can catch a suite that never deployed to a
/// network, or that is not there any more. Neither is a fact the repo can hold:
Expand Down Expand Up @@ -149,4 +176,105 @@ abstract contract RainDeployVerifyChain is RainDeployVerifyBase {
function testSuitesLiveOnEverySupportedNetwork() external {
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.
/// @param network The network name, for the error only.
/// @param declared The chain id the `[etherscan]` entry 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) {
revert NetworkChainIdMismatch(network, declared, reported);
}
}

/// 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();
}

string[] memory names = new string[](declared.length);
for (uint256 i = 0; i < declared.length; i++) {
names[i] = declared[i].network;
}

uint256[] memory forkIds = LibRainDeploy.createForks(vm, names);
for (uint256 i = 0; i < declared.length; i++) {
vm.selectFork(forkIds[i]);
checkNetworkChainId(declared[i].network, declared[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.
///
/// 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.
///
/// `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.
function testSupportedNetworkChainIdsAreBound() external {
checkNetworkChainIds(declaredChainIds(vm.readFile("foundry.toml"), LibRainDeploy.supportedNetworks()));
}
}
Loading
Loading