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
30 changes: 23 additions & 7 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, 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 | `vm.getCode(artifactPath)` | 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 All @@ -148,6 +148,22 @@ to have diverged from current source, so anchoring one to source asserts
something false by design. That is a property of the assertion, and there is no
field on a released version with which to opt in or out.

Neither is there a field on a CANDIDATE with which to satisfy it. A candidate
names the contract it is a snapshot of, in its `artifactPath`, and that is the
whole of what it says about its source; the anchor resolves that `<path>:<Name>`
through `vm.getCode` and compares the record against what the compiler's own
artifact holds. A declaration that supplied the source side as a value could
point it at the same generated constant as the record and make the one check
that catches a snapshot of the wrong contract compare a value with itself —
green for any candidate whatsoever, on the broadcast path as well as in CI. It
used to be able to (rainlanguage/rain.factory.deploy#34); the field is gone.

`artifactPath` is therefore LOAD-BEARING on a candidate. It must resolve,
uniquely, to the contract the snapshot is of. A path left behind by a moved or
renamed source file now fails at the anchor — before the broadcast — where it
previously only produced a `forge verify-contract` line a human read after the
deploy.

It runs over EVERY candidate, and a declaration that names none at all is
refused with `NoDeployCandidates` rather than passed as a loop with nothing in
it. A candidate the source anchor never reaches is a contract whose snapshot
Expand Down
5 changes: 1 addition & 4 deletions script/Build.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ struct GeneratedContract {
string contractName;
/// Prefix for the constants the alias lib exports, e.g. `ADDRESS_REGISTRY`.
string constantPrefix;
/// Snapshots are written from its `sourceCreationCode` and
/// `snapshot.dependencies`; the released lib takes its suite key and
/// artifact path from its `snapshot`.
DeployCandidate candidate;
}

Expand Down Expand Up @@ -98,7 +95,7 @@ contract Build is BuildScript, RegistryDeploySuites {
recordRoot(),
LibRainDeploySnapshot.CANDIDATE,
contracts[i].contractName,
contracts[i].candidate.sourceCreationCode,
vm.getCode(contracts[i].candidate.snapshot.artifactPath),
contracts[i].candidate.snapshot.dependencies
);
}
Expand Down
54 changes: 23 additions & 31 deletions src/abstract/RainDeploySuitesBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity ^0.8.25;

import {StdConstants} from "forge-std-1.16.2/src/StdConstants.sol";

/// Thrown when two suites share a key. The key selects what gets broadcast, so
/// a duplicate makes the selection ambiguous and one of the two unreachable.
/// @param suite The key declared more than once.
Expand Down Expand Up @@ -81,8 +83,8 @@ error NoDeployCandidates();
/// @param suite The candidate's key.
/// @param storedCreationCodeHash Hash of the creation code the candidate
/// records.
/// @param sourceCreationCodeHash Hash of `type(X).creationCode` for the
/// contract the candidate claims to be.
/// @param sourceCreationCodeHash Hash of the creation code the contract the
/// candidate NAMES — its `artifactPath` — currently compiles to.
error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash);

/// One deployable unit: a named snapshot of one contract.
Expand Down Expand Up @@ -119,13 +121,10 @@ struct DeploySuite {
/// suite broadcasts the exact bytes its audit covered, whatever the current
/// source now compiles to.
///
/// `type(X).creationCode` is what a candidate pairs this AGAINST, so
/// spelling the type expression here puts both operands of
/// Spelling `type(X).creationCode` here puts both operands of
/// `checkCandidatesAnchoredToSource` on the source side and leaves the one
/// check that catches a snapshot of the wrong contract comparing source to
/// itself, green. Fixtures that derive a whole mock suite do that on
/// purpose, because they have no record and are exercising other
/// assertions; a declaration of a real deployment never does.
/// itself, green.
bytes creationCode;
/// The deploy address recorded for this suite.
address storedDeployedAddress;
Expand All @@ -134,11 +133,15 @@ struct DeploySuite {
/// The runtime code recorded for this suite. A generated `RUNTIME_CODE`
/// constant.
bytes storedRuntimeCode;
/// `<path>:<Name>`, for the explorer verification command.
/// `<path>:<Name>`: the contract this suite is a snapshot OF.
///
/// Declared rather than derived from the contract name. `src/concrete/`
/// holds only the flattest repos; a repo that groups concretes into
/// subdirectories has paths no naming convention recovers.
///
/// For a candidate this is load-bearing: `checkCandidatesAnchoredToSource`
/// resolves it through `vm.getCode`, so a path that resolves to no
/// artifact, or to more than one, fails at the anchor before the broadcast.
string artifactPath;
/// Addresses that MUST already have code on a network before this suite is
/// broadcast there. Ordinarily other suites' recorded addresses: a
Expand All @@ -147,25 +150,13 @@ struct DeploySuite {
address[] dependencies;
}

/// The rolling candidate: the snapshot that tracks current source rather than a
/// frozen release, paired with the current source's creation code it MUST
/// equal.
///
/// This pairing is the ONLY thing that catches a snapshot of the wrong
/// contract. Every check internal to a snapshot is satisfied by a consistent
/// snapshot of the wrong thing, so without an anchor to source there is nothing
/// that says the recorded bytes belong to the contract this repo compiles.
///
/// It is deliberately absent from `DeploySuite` and therefore from released
/// suites: a released tag is MEANT to diverge from current source, so anchoring
/// one to source would fail on every release that is not the newest. That is a
/// property of the assertion, not an opt-out — there is no way for a caller to
/// spell "released, and also skip the checks that do apply".
/// The rolling candidate: a snapshot that tracks current source rather than a
/// frozen release, and that MUST equal what the contract it names currently
/// compiles to.
struct DeployCandidate {
/// The candidate's own recorded snapshot, checked exactly as any other.
/// The candidate's own recorded snapshot, checked exactly as any other
/// suite is, and anchored to what its `artifactPath` compiles to.
DeploySuite snapshot;
/// `type(X).creationCode` for the contract the candidate claims to be.
bytes sourceCreationCode;
}

/// @title RainDeploySuitesBase
Expand All @@ -191,7 +182,8 @@ abstract contract RainDeploySuitesBase {
function releasedSuites() internal pure virtual returns (DeploySuite[] memory);

/// The rolling candidates — one snapshot per contract this repo compiles
/// right now, each paired with the source it MUST equal.
/// right now, each naming the contract it MUST be the current compilation
/// of.
///
/// A list because a repo deploys as many contracts as it deploys, and each
/// of them has its own rolling snapshot and its own source to be anchored
Expand Down Expand Up @@ -228,7 +220,8 @@ abstract contract RainDeploySuitesBase {
return candidates;
}

/// EVERY candidate MUST record the creation code this repo compiles.
/// EVERY candidate MUST record the creation code the contract it NAMES
/// currently compiles to.
///
/// This is the ONLY check that catches a snapshot of the wrong contract.
/// Everything else a snapshot is asked is internal to the snapshot — the
Expand Down Expand Up @@ -262,13 +255,12 @@ abstract contract RainDeploySuitesBase {
/// Candidates alone, and there is no way to spell an exemption. A released
/// suite is MEANT to diverge from current source — it records bytes that
/// are already on chain — so anchoring one to source asserts something
/// false by design, which is why `DeploySuite` carries no source at all and
/// only `DeployCandidate` does.
function checkCandidatesAnchoredToSource() internal pure {
/// false by design.
function checkCandidatesAnchoredToSource() internal view {
DeployCandidate[] memory candidates = checkedCandidateSuites();
for (uint256 i = 0; i < candidates.length; i++) {
bytes32 stored = keccak256(candidates[i].snapshot.creationCode);
bytes32 source = keccak256(candidates[i].sourceCreationCode);
bytes32 source = keccak256(StdConstants.VM.getCode(candidates[i].snapshot.artifactPath));
if (stored != source) {
revert CandidateSourceMismatch(candidates[i].snapshot.suite, stored, source);
}
Expand Down
7 changes: 4 additions & 3 deletions src/abstract/RainDeployVerifySnapshotBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -387,15 +387,16 @@ abstract contract RainDeployVerifySnapshotBase is RainDeployVerifyBase {
}
}

/// EVERY candidate MUST be a snapshot of the contract this repo compiles,
/// not of some other contract that happens to be internally consistent.
/// EVERY candidate MUST be a snapshot of the contract it NAMES, as this
/// repo currently compiles it, not of some other contract that happens to
/// be internally consistent.
///
/// The check itself is `RainDeploySuitesBase.checkCandidatesAnchoredToSource`
/// rather than anything here, because `RainDeployBroadcast` runs the same
/// definition before it broadcasts. A second spelling on this side is a
/// spelling the deploy does not run, which is exactly the state this test
/// would otherwise be reporting green about.
function testSnapshotMatchesSource() external pure {
function testSnapshotMatchesSource() external view {
checkCandidatesAnchoredToSource();
}
}
13 changes: 4 additions & 9 deletions src/abstract/RegistryDeploySuites.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
pragma solidity ^0.8.25;

import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol";
import {AddressRegistry} from "../concrete/AddressRegistry.sol";
import {MigrationRegistry} from "../concrete/MigrationRegistry.sol";
import {
CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE_CANDIDATE,
RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE_CANDIDATE
Expand Down Expand Up @@ -98,9 +96,8 @@ abstract contract RegistryDeploySuites is RainDeploySuitesBase {
/// The creation code and runtime code are RECORDED, read from the rolling
/// `src/generated/candidate/` snapshot. That is what makes the source
/// anchor mean something: it compares the recorded creation code against
/// `type(AddressRegistry).creationCode`, so editing the contract without
/// re-running `script/Build.sol` fails. While nothing was recorded, that
/// check compared source against itself and could only pass.
/// whatever the `artifactPath` below currently compiles to, so editing the
/// contract without re-running `script/Build.sol` fails.
///
/// `AddressRegistry` reads nothing and calls nothing at construction, so it
/// has no dependency that must already be on chain.
Expand All @@ -122,8 +119,7 @@ abstract contract RegistryDeploySuites is RainDeploySuitesBase {
storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE_CANDIDATE,
artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry",
dependencies: new address[](0)
}),
sourceCreationCode: type(AddressRegistry).creationCode
})
});
}

Expand All @@ -150,8 +146,7 @@ abstract contract RegistryDeploySuites is RainDeploySuitesBase {
storedRuntimeCode: MIGRATION_REGISTRY_RUNTIME_CODE_CANDIDATE,
artifactPath: "src/concrete/MigrationRegistry.sol:MigrationRegistry",
dependencies: new address[](0)
}),
sourceCreationCode: type(MigrationRegistry).creationCode
})
});
}
}
6 changes: 2 additions & 4 deletions test/abstract/ExampleDeploySuites.sol
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,7 @@ abstract contract ExampleDeploySuites is RainDeploySuitesBase {
storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE,
artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry",
dependencies: new address[](0)
}),
sourceCreationCode: type(AddressRegistry).creationCode
})
});
candidates[1] = DeployCandidate({
snapshot: DeploySuite({
Expand All @@ -95,8 +94,7 @@ abstract contract ExampleDeploySuites is RainDeploySuitesBase {
storedRuntimeCode: type(MockDeployableV2).runtimeCode,
artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2",
dependencies: new address[](0)
}),
sourceCreationCode: type(MockDeployableV2).creationCode
})
});
}
}
2 changes: 1 addition & 1 deletion test/abstract/ExternalDeploySuites.sol
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ abstract contract ExternalDeploySuites is RainDeploySuitesBase {
}

/// Runs the source anchor over the fixture's own declaration.
function externalCheckCandidatesAnchoredToSource() external pure {
function externalCheckCandidatesAnchoredToSource() external view {
checkCandidatesAnchoredToSource();
}
}
31 changes: 31 additions & 0 deletions test/abstract/MisanchoredDeploySuites.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity ^0.8.25;

import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol";
import {LibRainDeploy} from "../../src/lib/LibRainDeploy.sol";
import {MockDeployableV2} from "../concrete/MockDeployableV2.sol";

abstract contract MisanchoredDeploySuites is RainDeploySuitesBase {
/// @inheritdoc RainDeploySuitesBase
function releasedSuites() internal pure override returns (DeploySuite[] memory suites) {
suites = new DeploySuite[](0);
}

/// @inheritdoc RainDeploySuitesBase
function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) {
candidates = new DeployCandidate[](1);
candidates[0] = DeployCandidate({
snapshot: DeploySuite({
suite: "misanchored-candidate",
creationCode: type(MockDeployableV2).creationCode,
storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode),
storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode),
storedRuntimeCode: type(MockDeployableV2).runtimeCode,
// Deliberately NOT `MockDeployableV2`, which every recorded field above is.
artifactPath: "test/concrete/MockDeployable.sol:MockDeployable",
dependencies: new address[](0)
})
});
}
}
17 changes: 6 additions & 11 deletions test/abstract/SourceMismatchDeploySuites.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,10 @@ import {MockDeployableV2} from "../concrete/MockDeployableV2.sol";
/// The broken candidate is a CONSISTENT snapshot: the address it records is the
/// address its recorded creation code derives, the code hash is the one that
/// creation code produces, and the runtime code hashes to it. Every check
/// internal to a snapshot passes on it. Only the pairing with
/// `sourceCreationCode` says it describes the wrong contract, which is why the
/// source anchor is the only thing that can catch it.
/// internal to a snapshot passes on it. Only the source anchor can catch it.
///
/// `MockDeployable` and `MockDeployableV2` are the pair, deliberately: the
/// snapshot is `V2`'s while the source is `MockDeployable`'s, which is exactly
/// the shape of a snapshot regenerated from a build that has since moved, or
/// generated from the wrong contract in a repo that compiles several.
/// snapshot is `V2`'s while the contract it names is `MockDeployable`.
///
/// TWO candidates, broken one LAST, behind a genuinely anchored one. A loop
/// that stops at the first entry is invisible against a single candidate and
Expand Down Expand Up @@ -61,8 +57,7 @@ abstract contract SourceMismatchDeploySuites is RainDeploySuitesBase {
storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE,
artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry",
dependencies: new address[](0)
}),
sourceCreationCode: type(AddressRegistry).creationCode
})
});
candidates[1] = DeployCandidate({
snapshot: DeploySuite({
Expand All @@ -71,10 +66,10 @@ abstract contract SourceMismatchDeploySuites is RainDeploySuitesBase {
storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode),
storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode),
storedRuntimeCode: type(MockDeployableV2).runtimeCode,
artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2",
// Deliberately NOT `MockDeployableV2`, which every recorded field above is.
artifactPath: "test/concrete/MockDeployable.sol:MockDeployable",
dependencies: new address[](0)
}),
sourceCreationCode: type(MockDeployable).creationCode
})
});
}
}
6 changes: 2 additions & 4 deletions test/concrete/CollidingCandidateDeploySuites.sol
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ contract CollidingCandidateDeploySuites is ExternalDeploySuites {
storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE,
artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry",
dependencies: new address[](0)
}),
sourceCreationCode: type(AddressRegistry).creationCode
})
});
candidates[1] = DeployCandidate({
snapshot: DeploySuite({
Expand All @@ -56,8 +55,7 @@ contract CollidingCandidateDeploySuites is ExternalDeploySuites {
storedRuntimeCode: type(MockDeployableV2).runtimeCode,
artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2",
dependencies: new address[](0)
}),
sourceCreationCode: type(MockDeployableV2).creationCode
})
});
}
}
Loading
Loading