Tooling to deploy Solidity contracts deterministically across EVM networks via the Zoltu deployment proxy.
Fundamentally Rain code is EVM-compatible and permissionlessly deployable anywhere, but only a curated subset of networks are active deploy targets for the Rain organisation. This library provides shared infrastructure for that subset.
It answers:
- Are the dependencies of the current deployment available on this network?
- Does this deployment match other deployments on other networks?
- Have I deployed successfully to all expected networks?
- How do I track deployments over time and share addresses with other people?
- How do I ensure deployed code is bytecode-equivalent to local compilations?
- How does a deployment get a configured address — an owner, say — without baking one into its creation code, where changing it would move every future deployment?
- Is every version I have ever released still live, with the code I compiled, on every network I support?
- Which operational migrations have actually been applied on this chain, and when they ran — including the ones that ran before there was anywhere to write them down — so a test can assert the state they imply instead of guessing from a date?
- Can a migration be skipped, repeated or applied out of order on one chain and not another?
Approach:
- Zoltu deterministic deployment proxy: same address on every supported network.
- Caller-provided supported-network and dependency lists.
- Hard guards against deploying to networks where dependencies are missing.
- Pre-calculated addresses asserted against the creation code before deploying, and against the chain after: silent failures fail loudly.
- Bytecode integrity checks post-deploy:
codehashis compared against the recorded pin on every network byRainDeployVerifyChain, and before every registry access byLibConfigRegistryandLibMigrationRegistry. - A config registry binding names to ABI-encoded values, read at run time rather than compiled into creation code, and a post-deploy check that every target network's deployment took the value it was supposed to, of any type.
- A migration registry, so operational scripts record what they applied and when it ran, each onto the head it is applying to, and tests assert the state that implies rather than branching on a deadline.
- One inherited deploy-pin verification, parameterized over versions, rather than assertions hand-enumerated per version and per chain in every deploy repo.
A repo declares its suites ONCE. A suite is a named snapshot: a key, the creation code, the recorded address/code hash/runtime code, the artifact path and the dependencies that must already be on chain before it can be deployed — each of those an address paired with the runtime code that belongs at it.
// src/abstract/MyDeploySuites.sol
abstract contract MyDeploySuites is RainDeploySuitesBase {
function releasedSuites() internal pure override returns (DeploySuite[] memory);
function candidateSuites() internal pure override returns (DeployCandidate[] memory);
}
// script/Deploy.sol
contract Deploy is MyDeploySuites, RainDeployBroadcast {}
// script/Build.sol
contract Build is MyDeploySuites, BuildScript {
function generatedContracts(DeployDeclaration memory d) internal view override returns (GeneratedContract[] memory);
function regenerateSnapshots() internal override;
}
// test/src/abstract/MyDeployVerify.t.sol
contract MyDeployVerifyTest is MyDeploySuites, RainDeployVerify {}RainDeployVerify is the whole of the verification binding, deliberately. It is
the union of RainDeployVerifyChain and RainDeployVerifySnapshot, so a check
added to either — or to the union — reaches every consumer on a version bump
with no downstream edit. A check a consumer has to remember to bind is a check
most consumers do not run, so a new assertion goes into something this already
inherits rather than into an abstract beside it. The pair underneath stays two
contracts because that split is runtime rather than binding: the chain half
forks every supported network and the snapshot half touches none, so a job with
no RPC credentials binds RainDeployVerifySnapshot alone and --match-contract
selects it.
The broadcast and the verification read the SAME array. "The deploy script ships one contract while the tests verify another" is therefore not a statement that can be true — not because something checks for it, but because there is nothing for it to disagree with. A repo that wrote its suites out twice would have that bug available to it; this one does not.
BuildScript carries both build entry points concrete. run() regenerates the
generated sources and the network config, and freezes nothing; cutRelease()
regenerates, freezes the release as src/generated/<tag>/, 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
the valid keys built from that same array, so the error cannot fall behind the
suites it describes, and keys are checked unique because the key is what selects
what gets broadcast. They are also checked against an alphabet: kebab case,
optionally followed by @ and a release tag, which is the shape a repo writes
by hand and the shape a cut release generates. A key outside it is one the
reported list cannot be read back as — a comma or a space in a key renders as
two keys and sends a reader after one that is declared nowhere — and the empty
key falls to the same rule, which matters most: the empty string is what an
unset DEPLOYMENT_SUITE arrives as, so leaving it declarable would let a
dispatch with the suite input blank select something instead of reporting that
it was told nothing.
Every suite is individually selectable, including a frozen release — which is how a snapshot from before a network existed reaches that network.
A suite with empty mechanismData deploys through Zoltu; every Zoltu hook
refuses non-empty data with UnexpectedMechanismData. Another mechanism is an
abstract extending RainDeploySuitesBase that overrides its virtual mechanism
hooks (deployedAddress, recordedAddress, addressChainIds,
deployStepData, deployStep, deployLocally, deployLocallyOnChain,
withUnrecordedNetworks, appendExtendedNetworks, checkNetworkFrozen,
suiteBeacon, logVerification, writeSuiteSnapshot, releasedMechanismData)
for the mechanismData it owns and falls through to super for the rest.
Broadcast, every verification group and BuildScript reach a suite only through
those hooks. Factory clones and beacon proxies are RainDeployCloneSuitesBase
in rain-factory.
Declare every beacon with its network's chain id, naming implementations by
suite key and moving them through MigrationRegistry migrations:
function beacons() internal view override returns (ChainBeacon[] memory) {
ChainBeacon[] memory chainBeacons = new ChainBeacon[](1);
chainBeacons[0].chainId = ARBITRUM_ONE_CHAIN_ID;
DeployBeacon memory beacon = chainBeacons[0].beacon;
beacon.beaconSuite = "vault-beacon@0_1_0";
beacon.initial = BeaconState({implementation: "vault@0_1_1", owner: ARBITRUM_TIMELOCK});
beacon.transitions = new BeaconTransition[](1);
beacon.transitions[0] = BeaconTransition({
migration: Prerequisite({writer: SAFE, namespace: NAMESPACE, migration: UPGRADE_0_2_0}),
state: BeaconState({implementation: "vault@0_2_0", owner: ARBITRUM_TIMELOCK})
});
return chainBeacons;
}On each network's fork the chain group expects the state of the latest
transition the network's registry reports appliedV5, else initial: the
beacon at its code hash, implementation() at that suite's address on the
network, owner() at that owner, and the beacon's own suite, when it names one,
and that state's implementation suite live there as any suite is. A suite a
beacon names MUST be released, refused otherwise with BeaconSuiteNotReleased:
a candidate's address moves with its source. Name a beacon that is itself a
released suite by its suite key in beaconSuite, and any other (one a
set-deployer news, nonce-deployed per chain, or one deployed elsewhere) by
both beacon and beaconCodeHash; naming it by beaconSuite and by either
beacon or beaconCodeHash is refused with BeaconNamedTwice, and naming it
by neither, or, without beaconSuite, by only one of beacon and
beaconCodeHash, with BeaconUnnamed. A beacon whose chain id is not one of
supportedNetworks(), in the catalogue or not, is refused with
BeaconOnUncheckedNetwork, and a second declaration of a beacon on its network
with BeaconDeclaredTwice. A beacon named by beaconSuite is deployed locally
at its address under its network's chain id, with only its suite's declared
dependencies etched, so its constructor MUST succeed there, and is refused with
BeaconSeatedOwner or BeaconSeatedImplementation when it answers owner() or
implementation() with data other than its initial state; a read that reverts
or answers nothing is left to the chain group. These refusals, and
BeaconTransitionMigrationRepeated and BeaconTransitionMigrationUnnamed
below, are raised offline for every beacon by the Beacons group
(testBeaconDeclarationsResolve, inherited through
RainDeployVerifySnapshotBase), and by the chain group, before any network is
forked, for the beacons on the networks it checks; BeaconOnUncheckedNetwork
there covers every beacon. A beacon with transitions reads MigrationRegistry
on its network, which MUST be deployed there. The chain group reads owner(),
and refuses a beacon that answers pendingOwner() with one nonzero word with
BeaconPendingOwner, or with data that is not one word with
BeaconPendingOwnerUnreadable; a pendingOwner() that reverts or returns
nothing passes. A two-step handover is red from transferOwnership until the
batch that accepts it applies its migration. Assert any other authority a beacon
exposes (roles) yourself, including that the deploy key holds none of it. The
deploy key is never a declared owner of any state, past states included, and a
broadcast whose key is one is refused with BeaconOwnedByDeployKey before any
network is read. A beacon named by beacon and beaconCodeHash that was owned
by a deploy key and has since been handed over declares its post-handover state
as initial, with no transition for the handover. A beacon named by
beaconSuite declares as initial the owner its suite seats
(BeaconSeatedOwner holds it there), so a released beacon suite that seated the
deploy key is declared by beacon and beaconCodeHash. A broadcast is also
refused before any network is read when its suite, deployed locally with the
deploy key as sender and origin under each target's chain id, answers owner()
with a first word whose low 160 bits are the deploy key (SuiteSeatsDeployKey),
so a suite seats its governance, not the deploy key, from its first state. For a
suite whose addressChainIds is non-empty the targets are those chain ids only
(its candidate's added networks included), each deploying only that network's
address; for any other suite they are every supported network, the networks the
broadcast does not reach included, then each network it reaches that is not
supported. The suite's constructor runs on the local EVM with only its declared
dependencies etched, so it MUST succeed there under each target's chain id: one
that reverts locally refuses the broadcast to every network. That check reads
owner() only: roles, pending owners, admin slots and contracts a constructor
creates are not read, so assert those yourself.
Declare transitions in the order their migrations are applied: each applied
transition's record MUST reach the latest earlier-declared applied one through
appliedAfterV5 (its line's head, then its prerequisites), refused otherwise
with BeaconTransitionsOutOfOrder; moments are never compared. Each transition
names a distinct migration, refused otherwise with
BeaconTransitionMigrationRepeated, and one the registry can record (nonzero
writer and namespace, a migration neither zero nor MIGRATION_HEAD_GENESIS),
refused otherwise with BeaconTransitionMigrationUnnamed. A transition written
in another line names the earlier one, or a record after it, as a prerequisite.
The order walk is unbounded and costs a fork read per record it reaches: apply
each transition onto the previous transition's migration as its line's head or,
when it is written in another line, name it as a prerequisite, so the walk reads
one record. Otherwise it reads at most every record reachable from the later
transition, across lines, in fewer steps than the earlier one, and every
reachable record back to each line's genesis when the earlier one is not
reached. Apply a transition's migration in the same transaction as the beacon
change it describes (one Safe batch: upgradeTo or transferOwnership, then
applyMigrationV5; for an Ownable2Step beacon the handover's migration goes
in the batch that calls acceptOwnership, as owner() moves only then).
Between two separate transactions the chain group refuses the network, as the
chain and the registry disagree.
The creation code is the only input. The Zoltu factory is CREATE2 over its
calldata under a zero salt, so the address is a pure function of the creation
code and identical on every network, and running that creation code once locally
yields the runtime code and its hash. Everything else a suite records is a
checked output.
Recorded rather than derived, deliberately. LibRainDeploy compares the
recorded address against the creation code before it forks anything, so a
stale pin fails instead of deploying to wherever the code happens to land.
Deriving the pins at broadcast time would make that comparison
derived-against-derived, and a guard that compares a value to itself is not a
guard.
A suite's declared dependencies are etched for the derivation, and only the
dependencies. The local derivation runs before anything forks, so a declared
dependency is absent there and a constructor that reads one cannot run.
deriveDeployment etches each declared dependency's runtime code at its
declared address, inside the state snapshot it already reverts. A dependency is
declared as an address AND that code — the depended-on snapshot's published
DEPLOYED_ADDRESS and RUNTIME_CODE — because an address alone cannot say what
to put there. The dependencies are etched first, then the derived address is
cleared, then the factory is planted, so a declaration can reach neither the
subject's address nor the factory's.
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 | 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 |
| Beacons | the beacon declarations | a beacon misnamed, unreleased, off the supported networks, declared twice, seating other than its initial state, or with a malformed transition | what any beacon holds on chain |
| Chain | the networks | a missing deployment, an alias on another chain, or a beacon off its declared state | 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 agree with each other perfectly. The source anchor is the only thing that catches it, and it applies to the candidates only — a released tag is meant to have diverged from current source, so anchoring one to source asserts something false by design. That is a property of the assertion, and there is no field on a released version with which to opt in or out.
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.
One kind of candidate cannot be anchored at all, and SAYS so. A third party
deployment vendored as a pinned hex literal, and a data contract assembled from
generated tables, have no source file: vm.getCode resolves nothing, so the
anchor has no second operand rather than a disagreeing one, and the recorded
bytes are the only statement of what the code is that exists. Such a candidate
declares an unanchorableReason — prose, in its own declaration, saying why no
compiler produces it — and the anchor passes over it. The field carries no
BYTES, so there is still nothing for a consumer to point at the record and
nothing for the check to compare with itself; and it is REFUSED wherever the
artifactPath does resolve (CandidateSourceCompiles), so it cannot be spelled
on an ordinary candidate as a way out of the check. Nothing reads what it says.
It is held to being said.
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. On a candidate that declares itself unanchorable it is the other way
round: resolving to nothing is the state being declared, and resolving to an
artifact is what refuses the declaration.
Uniqueness is not something vm.getCode reports. It resolves a BARE contract
name to whichever same-named artifact comes first, and answers rather than
reverting when several match, so a bare name anchors a candidate to a contract
that merely shares a name and nothing anywhere says which one it took. Every
candidate is therefore held to <path>:<Name> —
UnqualifiedCandidateArtifactPath, raised from the same read as
NoDeployCandidates — including one declaring itself unanchorable, which spells
the path of the file it would have had.
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
nothing anywhere anchors, and a repo with several contracts is exactly where a
snapshot generated from the wrong one comes from.
The source group is also the only one that is not only a test. It is defined on
the suite declaration, and the broadcast runs it before it selects a suite or
reads a key. The deploy reads the same recorded bytes, and the only other guard
in front of the CREATE2 compares the recorded address against what the
recorded creation code derives — both out of the same generated file, so it
catches a stale pin and cannot catch a snapshot of the wrong contract. An anchor
only a test contract could reach would be an anchor the irreversible action does
not run, and CREATE2 at a zero salt puts the wrong bytes at their own
permanent address on every chain the dispatch reached.
The chain group carries the mirror image of that exemption: it applies to released versions only. A release IS a deployment that happened, so "it is live on every supported network" is either true of it or a defect. A candidate is what the next release will be, ordinarily ahead of anything on chain, so demanding it be live asserts something false by design in the other direction. Neither exemption is a field a caller can set; both follow from which side of the declaration an entry is on.
Scoping to releases puts the whole weight on releasedSuites() naming every
release, which is what the record group is for. A frozen tag the declaration
misses is not an entry that turns up missing somewhere — it is a release the
chain group is never handed, and a check with no subject cannot fail on it, so
that release drops out of everything while the suite stays green. The
declaration is generated from the append-only src/generated/<tag>/ record and
checked back against it, matched by address, since matching by name would assert
only that a convention was followed.
Chain-independent runtime code is a requirement, not a caveat. One recorded
code hash per version can only be true if the runtime code is the same
everywhere. A constructor that reads block.chainid deploys different code per
chain: deploying through Zoltu buys address predictability, and such a
constructor spends it. So a per-chain difference fails hard, naming the chain
and both hashes, and there is deliberately no per-chain code hash to record.
The network config is generated, not compared.
LibRainDeploy.supportedNetworkConfigs() is the catalogue — each network's
name, chain id, explorer url and default endpoint — supportedNetworks() on the
suites declaration selects which of its entries a repo deals with, and
BuildScript.run() writes foundry.toml's [rpc_endpoints] and [etherscan]
sections and .env.example's endpoint variables from that selection.
Git is clean is the enforcement, the same mechanism already holding
src/generated/: a tree whose config has drifted from the declaration 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 rainix's
rainix-copy-artifacts installs them — see 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 catalogue is deliberately not overridable — a network's chain id and
explorer are facts about the network, so one arrives in a consumer's config by a
version bump and by nothing else. WHICH of them a repo emits is overridable, and
through exactly one hook: supportedNetworks(), the same answer the broadcast
targets and the chain group forks. A declared name the catalogue says nothing
about is refused, NetworkNotInCatalogue, on the build that would have
generated from it.
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 declared network and compares
block.chainid against the catalogue. A wrong id there is config that resolves,
passes everything that reads the file, and verifies a deployment against the
wrong explorer.
ConfigRegistry binds an opaque bytes32 name to ABI-encoded bytes: an
address, a role id, a threshold, a list of accounts, anything a deployment would
otherwise compile in. Bindings are keyed by authority: any account binds a name
under its own address, or under an authority that approved it as a delegate for
that name (approveV5, revokeV5, read by approvedV5); anyone reads a name
under an authority it names, and reading a name that authority has not bound
reverts rather than answering empty. The interface is IConfigRegistryV5. There
is no removal, no upgrade, and no way to write under an account that has not
approved the writer.
A bind is registerV5(authority, name, valueType, value, meta). valueType is
the value's normalized ABI type as a string — address, uint256,
(address,uint256) — and an empty one is refused (EmptyValueType). The
registry stores only its hash, keccak256(bytes(valueType)), beside the value
and the moment it was bound, replacing all three on a rebind, and getV5
returns (value, registeredAt, valueTypeHash). The string itself appears only
in the Register(sender, authority, name, valueType, value) event. The registry
never checks a type against its value and never canonicalises it: the hash is of
the exact string bound.
meta is optional rain metadata. When it is non-empty the same write emits
MetaV1_2(sender, subject, meta) (IMetaV1_2 from rain.metadata), with
sender the caller and subject keccak256(abi.encode(authority, name)), the
same for every bind of that name under that authority. An empty meta emits
nothing. A non-empty meta without the rain meta magic number reverts
NotRainMetaV1, as LibIMetaBoardV1_2.emitMeta checks it. It is never stored,
and nothing past that prefix is validated: it lives in the logs only.
A consumer names the authority it trusts beside the name it resolves, both in
its own creation code, and gets only what that authority bound. What the
consumer then trusts, and what a contract authority must do on every network, is
in IConfigRegistryV5's NatSpec under "The authority and its delegates are the
whole access control" and "Delegation is per authority and name". A delegate can
rebind the name it was approved for, so a consumer's prod tests assert
approvedV5 is false for every retired delegate beside the true answers for the
live ones.
Bindings do not carry over from the deprecated AddressRegistry. Once the
registry is deployed on a network, an authority binds each name its consumers
resolve with script/RegisterConfig.sol (value
cast abi-encode "f(address)" <account>, value type address, and
CONFIG_REGISTRY_NETWORKS naming the networks it is deployed on until that is
every declared one) before those consumers deploy against it. Which names and
which authority are each consumer's to declare; until a name is bound under that
authority, its LibConfigRegistry.resolve reverts.
Bindings are mutable, because the configuration they name is. Rotating an owning multisig is ordinary business and has to be expressible without moving anybody's deterministic address — which a binding welded to one value forever would make impossible, because the name is in the consumer's creation code, so a new name means new creation code and a new address. That is the problem the registry exists to remove, not a property worth keeping.
Mutability costs nothing already deployed. A consumer resolves a name once, in its constructor, and stores the answer; it never reads the registry again. So re-binding a name changes what the next deployment resolves and nothing else, which makes a rotation a deliberate migration rather than a silent change to live contracts.
LibConfigRegistry.resolve(authority, name, valueTypeHash) is the read,
verifying the registry's code hash first, the same way LibRainDeploy verifies
the Zoltu factory's. It resolves a name under an authority to the bound bytes
and the moment they were bound, and reverts ValueTypeMismatch unless the name
is bound as the type whose hash the consumer passes, keccak256("address") for
an address; resolveSafe(authority, name, valueTypeHash, minAge) takes the same
hash. Neither decodes the value: the consumer decodes, as
abi.decode(value, (address)) for an address. What it does with the value, and
when, is the consumer's business.
LibRainDeploy carries the post-deploy verification: on every target
network, the deployed contract must hold the value the deployment expected. It
runs after the deploy and before anything depends on it, against state the
deployment has already settled, so nothing it reads can move underneath it. The
same check run beforehand would be worth nothing against a mutable source. A
network where the deployment took something else is a burned deterministic
address, found while nothing points at it yet.
"After the deploy" is two different places, and they read two different chains. A deploy script broadcasts nothing itself: it simulates on a fork per network and records the transactions, which forge submits only once the script has returned. So for as long as the script runs, the deployment is on the deploy's own forks and nowhere else.
- Simulation verification is
checkResolvedAddressesOnForks(checkResolvedValuesOnForksfor a value of any type), handed the fork idsdeployToNetworksreturns. The only place a first-time deployment can be verified in the run that deploys it. - Confirmed-deployment verification is
checkResolvedAddressesOnNetworks(checkResolvedValuesOnNetworksfor a value of any type), a run of its own that forks each network fresh, once the transactions have been submitted and mined. In the same script as the deploy it reads chains that have no code at the address yet and takes the run down before forge broadcasts anything.
Only the consumer knows where it stored what it resolved, so the consumer
supplies the reads (abi.encodeCall(IERC5313.owner, ()) and the like) and this
library supplies the fork loop and the comparison. The address checks require
each answer to be one address word; the value checks compare the exact return
bytes, such as abi.encode(uint256(3)) for a threshold or abi.encode(signers)
for an address[].
Both registries have one root authority, the same on every network:
LibRegistryAuthority.ROOT_AUTHORITY,
0x0b300013CD54a8F1aC40981f80FaaA18b8Cc1E4c. Consumers import it rather than
redefining it. The scripts below act under it only; nothing selects another.
The authority, or a delegate it approved, binds a name with
script/RegisterConfig.sol, signed on the Trezor that holds it: the script
binds under the root authority and, on each network, refuses a signer that is
neither the authority nor a delegate the authority approved there for the name
(SenderNotApproved), since the registry refuses that bind. It is run by hand
on the machine the Trezor is plugged into, never in CI: forge's own --trezor
signer asks the device for each signature, and the key never leaves it.
The script signs as an EOA. A Safe or timelock authority either approves that
EOA as a delegate, once per network through its own execution path
(approveV5(name, delegate)), after which the EOA runs the script with
--sender set to itself, or submits
registerV5(authority, keccak256("<org>.<name>"), valueType, value, meta) to
the registry itself and then runs step 3 below (verify), which reads under any
authority. An approval lets the delegate rebind that one name under the
authority until it is revoked (revokeV5(name, delegate)) on that network. An
EOA authority approves or revokes a delegate, such as a Safe, with
script/ApproveConfigDelegate.sol ("Approving a delegate" below).
The name is the string consumers hash. Consumers derive a name as
keccak256("<org>.<name>") — st0x resolves its token-owner Safe as
keccak256("st0x.token-owner-safe") — so the script takes that string and
hashes it itself. There is no bytes32 input, so there is no other encoding to
bind under by mistake. The string must be lowercase letters and digits with
single . or - separators, starting with a letter and containing the .
after its <org>; anything else (a capital, a stray space or newline, an
already-hashed 0x…, a name with its <org>. dropped) is refused, because it
is a near miss of a real name that no consumer resolves.
The value is ABI-encoded. The script binds ABI-encoded values only and refuses one that is not a whole number of 32-byte words, though the registry itself binds any non-empty bytes.
The value type is named. CONFIG_REGISTRY_VALUE_TYPE is the value's
normalized ABI type, address for an address, bound as given; the run refuses
it empty. CONFIG_REGISTRY_META, optional, is 0x hex rain metadata emitted
with each bind; unset, the binds carry none.
It binds on every declared network by default — one binding everywhere, as the
deployment is the same everywhere — and a network already bound to the same
value as the same type is skipped, so a run that stopped partway is fixed by
running it again. The default needs the registry deployed on every declared
network: it checks the registry's code hash on each and reverts
UnexpectedConfigRegistryCodeHash on the first one without it, binding nothing.
While the registry is on only some networks, set CONFIG_REGISTRY_NETWORKS to
those.
-
The Trezor: unlocked, with the passphrase of the authority's wallet if it has one, and Trezor Suite closed (it holds the USB connection).
-
Name the signer: the root authority itself, or a delegate it approved on every network the run binds on:
export CONFIG_REGISTRY_SENDER=0x0b300013CD54a8F1aC40981f80FaaA18b8Cc1E4c # or the delegate
-
Check the device answers as the signer (add
--mnemonic-derivation-path "<path>"after--trezorinside the$(…)if the signer is not the first Ethereum account in Trezor Suite, and the same path as--mnemonic-derivation-paths "<path>"on theforge scriptcommands below):[ "$(nix develop -c cast wallet address --trezor)" = "${CONFIG_REGISTRY_SENDER:?}" ] && echo "signer OK" || echo "NOT the signer"
-
The signer needs native gas on every network it binds on.
-
cp .env.example .envfor the network endpoints. They are public and rate-limit; replace any that fail with your own.
export CONFIG_REGISTRY_NAME=st0x.token-owner-safe
# The value, as 0x hex of its ABI encoding. For an address:
export CONFIG_REGISTRY_VALUE=$(nix develop -c cast abi-encode "f(address)" 0x...)
# Its normalized ABI type:
export CONFIG_REGISTRY_VALUE_TYPE=address
# Optional rain metadata, as 0x hex, emitted with each bind:
# export CONFIG_REGISTRY_META=0x...
# Optional, to bind on fewer networks than all of them:
# export CONFIG_REGISTRY_NETWORKS=base,arbitrum
# 1. Dry run. Executes every check and the bind itself on a fork of each
# network, and sends nothing.
nix develop -c forge script script/RegisterConfig.sol:RegisterConfig \
--sender "$CONFIG_REGISTRY_SENDER" --trezor --legacy
# 2. The same command with --broadcast. The device asks once per network the
# dry run marked BIND or REBIND.
nix develop -c forge script script/RegisterConfig.sol:RegisterConfig \
--sender "$CONFIG_REGISTRY_SENDER" --trezor --legacy --broadcast
# 3. Once every transaction is mined: reads every network fresh and fails
# unless the name resolves to the value, as the type, under the authority
# on each.
nix develop -c forge script script/RegisterConfig.sol:RegisterConfig --sig "verify()"--legacy because HyperEVM's RPC rejects the fee-history ranges EIP-1559
estimation asks for. No --rpc-url: the script forks each network's alias
itself.
Before anything is signed the run refuses, with an error naming what is wrong: a
non-canonical name, an empty value, a value that is not a whole number of
32-byte words (a bare 20-byte address, say, rather than its ABI encoding), an
empty value type, a network the repo does not support or one named twice, a
network whose endpoint reports another chain id than the catalogue states for
it, a registry address that does not hold the pinned registry code, and a signer
that is neither the authority nor a delegate the authority approved on that
network. With --broadcast, forge itself is a second layer: it refuses a device
that is not the signer before sending on any network, with
No associated wallet for addresses: [<signer>]. A refused run signs nothing.
The dry run prints the name, its hash, the value type, its hash, the value, the
meta, the authority, the signer, the registry address and the exact calldata,
then for each network its chain id, the authority's current binding (unbound,
or the value, its type hash and the moment it was bound) and what will happen:
BIND or REBIND to the value as the type, or SKIP where the name is bound
to both already. That is the record to approve against. For each transaction the
device shows:
- Recipient: the registry address the dry run printed
(
LibConfigRegistryDeploy.CONFIG_REGISTRY_DEPLOYED_ADDRESS); - Amount: 0;
- Network: the network's name or chain id, which must be one the dry run
marked
BINDorREBIND; - Data: exactly the
Calldata:line the dry run printed:registerV5's selector0x41f0fc94, the authority, the name hash, the offsets of the value type, the value and the meta (the first is0xa0), then the value type's length and bytes, the value's length and bytes, and the meta's length and bytes, each padded to a multiple of 32 bytes. The device cannot decoderegisterV5, so it shows the raw bytes; open the full data on the device to compare them.
Labels vary by firmware; whatever they are called, the recipient, amount, chain and raw calldata must each match the dry run. Reject on the device if any of them differs.
script/ApproveConfigDelegate.sol approves a delegate for a list of names under
the root authority, or revokes it, on every declared network by default, in one
multicall transaction per network. approveV5 and revokeV5 act under their
caller, so the signer is the root authority, and the run refuses any other
--sender (SenderNotAuthority) before forking. It takes its own variables, so
nothing exported for RegisterConfig carries over. The Trezor setup is "Before
the first run" above, with the authority as the signer.
export CONFIG_DELEGATE_SCOPES=st0x.vault-beacon.v1,st0x.rbac.us.v1 # comma-separated
# Optional: true also approves the delegate unscoped, for every name present
# and future. SCOPES may then be left unset.
# export CONFIG_DELEGATE_UNSCOPED=true
export CONFIG_DELEGATE_ADDRESS=0x... # the delegate, such as the Safe
# Optional: false revokes. Unset approves.
# export CONFIG_DELEGATE_APPROVED=false
# Optional, to act on fewer networks than all of them:
# export CONFIG_DELEGATE_NETWORKS=base,arbitrum
# 1. Dry run.
nix develop -c forge script script/ApproveConfigDelegate.sol:ApproveConfigDelegate \
--sender 0x0b300013CD54a8F1aC40981f80FaaA18b8Cc1E4c --trezor --legacy
# 2. The same command with --broadcast. The device asks once per network the
# dry run gave a Calldata: line.
nix develop -c forge script script/ApproveConfigDelegate.sol:ApproveConfigDelegate \
--sender 0x0b300013CD54a8F1aC40981f80FaaA18b8Cc1E4c --trezor --legacy --broadcast
# 3. Once every transaction is mined: reads every network fresh and fails
# unless the delegate is approved (or, with false, not approved) on each.
nix develop -c forge script script/ApproveConfigDelegate.sol:ApproveConfigDelegate --sig "verify()"Before anything is signed the run refuses a signer that is not the authority, a
zero delegate, and every name, network, chain id and registry refusal of
RegisterConfig. A network already in the target state is SKIP, so a run that
stopped partway is fixed by running it again. Each approval covers its one name;
every name the run is given goes into that network's one transaction.
The dry run prints each name and its hash, the authority, the delegate, the
target and the registry address, then for each network its chain id, the size of
the code at the delegate (none where the delegate has no code there: a Safe
not yet deployed on that network), each name's current approval with APPROVE,
REVOKE or SKIP, and, where any name is not SKIP, the exact Calldata: of
that network's transaction. With one call to make, the transaction is that call,
which the device decodes. With more, it is multicall (selector 0xac9650d8)
over one approveV5 (0x50c453c7) or revokeV5 (0x42213144) per name not
SKIP, each with the name hash and the delegate, then, with UNSCOPED=true and
the unscoped line not SKIP, one approveUnscopedV5 (0xf2f0514c) or
revokeUnscopedV5 (0x9547ac1b) with the delegate. For a multicall the
device shows the registry as recipient, amount 0, the network, and that calldata
as opaque data, so it may need blind signing enabled: compare the data on the
device with the network's Calldata: line before confirming.
A Safe delegate then binds by executing
registerV5(authority, keccak256("<org>.<name>"), valueType, value, meta) on
the registry from the Safe, on each network, and RegisterConfig's verify()
checks the binding under the authority.
MigrationRegistry records that a migration has been applied, when, and after
what: a write names its writer and a namespace, and that writer or a delegate it
approved for the namespace applies one migration in that line after one list —
the migration it believes ran last in that line, then the migrations in any
other line it waits on (applyMigrationV5, or applyMigrationHistoryV5 for one
that already ran). Anyone reads when a given writer's line in a given namespace
applied a given one (appliedV5), what it applied it onto (appliedOntoV5),
what it applied it after (appliedAfterV5), which account wrote the record
(appliedByV5), and where that line has got to (headV5). A writer approves
and revokes delegates per namespace (approveV5, revokeV5, read by
approvedV5). There is no removal and no upgrade. The interface is
IMigrationRegistryV5.
The two writes differ in exactly one thing: where the recorded moment comes
from. applyMigrationV5 stamps the block the record lands in, for a script
applying its own migration in the same atomic unit as the migration itself.
applyMigrationHistoryV5 takes the moment as an argument, so a migration that
ran before the registry reached the chain is recordable with the time it
actually ran. They write the same record, into the same line, and make the same
refusals.
Both take a trailing bytes meta, as registerV5 does: non-empty, the same
write emits MetaV1_2(sender, subject, meta) with sender the caller and
subject keccak256(abi.encode(writer, namespace, migration)); empty, nothing.
Non-empty meta without the rain meta magic number reverts NotRainMetaV1; it
is never stored, and nothing past that prefix is validated.
It exists because prod-state tests otherwise decide what to assert by reading the clock. The pattern that emerges without it is a dual-state invariant — accept either the pre- or the post-migration value until a hardcoded deadline, and only the post value after it. That window asserts nothing during the one period you most want to know about, every migration costs a manual refactor to add and another to delete, and once the deadline passes CI red-lines on a date rather than on a fact. What you actually want is "exactly the value implied by the migrations that have run", which needs the chain to hold which ones have:
if (LibMigrationRegistry.appliedV5(SAFE, NAMESPACE, MIGRATION_V2) != 0) {
assertEq(vault.owner(), NEW_OWNER);
} else {
assertEq(vault.owner(), OLD_OWNER);
}Both branches assert exactly. Neither skips, and appliedV5 answering zero is
an ordinary expected answer rather than a revert — it is the state of every
migration before it runs, and of every migration on a chain that never got it.
appliedV5 is a timestamp, not a flag. "Which invariant applies" is
frequently "which invariant applies yet": a cliff that starts at the
migration, a rate that changes a week after it. A flag sends a consumer that
needs the moment back to a hardcoded date, which is the thing this registry
exists to delete. Zero and nonzero carry the same two distinct facts a flag did,
with the nonzero case saying more — and zero stays unambiguous because a zero
moment is refused outright rather than written as a record that reads back as no
record.
The moment is the caller's, inside a window the registry enforces. The fact being recorded is that a migration RAN, and the moment it ran is not in general the moment anybody gets to write it down. A registry that could only stamp its own block offers a writer with history two options and no third: record a time that is false for every past migration, or record nothing — and recording nothing strands the line, because the registry refuses anything not applied onto the current head, so a writer that skipped its past migrations cannot record its next one either.
What a reader gives up is not authenticity. A record is keyed by its writer
and no authority checks it, so every entry is already exactly as trustworthy as
that writer and the delegates it approved, and no more; a writer free to invent
an id was always free to invent the fact. What a reader gives up is precisely
that appliedAt is the block the record landed in. Everything else is kept, by
three refusals:
- Never zero (
ZeroTimestamp), or the record would read back throughappliedV5as no record while the head had moved and the migration could never be applied again. - Never after the block it is written in (
FutureTimestamp). A migration that has run has run, so a moment still to come is not a late record of anything, and a consumer measuring an interval since the migration — a cliff, a grace period, a rate that changes a week later — can subtract it from the current block without underflowing. - Never before the record it is applied onto (
TimestampBeforeHead), so a line's moments never go backwards along its chain and the gap between two of its migrations subtracts in chain order without underflowing either. The first migration in a line is applied ontoMIGRATION_HEAD_GENESIS, which holds no record and so bounds nothing.
Equal is allowed wherever there is a neighbour. A moment may be exactly the
block it is written in, and two records may carry the same moment: two
migrations applied in one transaction share a block, and two backfilled to the
same day share a moment, so forcing them apart would demand a precision the
moments do not have. Which of them ran first is the chain, not the moments —
total order comes from appliedOntoV5, and the bound above only stops a record
claiming to predate the one it is chained onto.
Both writes get all three, block.timestamp included: a block whose timestamp
is zero is ZeroTimestamp on applyMigrationV5, which a test that warps to
zero and a chain configured from a zero genesis both reach.
A set of applied migrations, not a high-water mark. A mark needs a total
order consumers do not have: two migrations authored on one day collide, and one
migration split across two scripts because it landed on two networks a week
apart cannot be one comparable value at all. A set represents both exactly, and
the ordering between migrations moves into the assertion —
appliedV5(V5) != 0 ? … : appliedV5(V4) != 0 ? … : … — which is where the
semantic dependency actually lives.
A head, so a step cannot be skipped or repeated. A line — one writer's
records in one namespace — has a head: the migration it applied most recently,
or MIGRATION_HEAD_GENESIS if it has applied none. Both writes name the head
they are applying onto as the first entry of their list, under the writer in the
namespace being written, so a chain that never got the predecessor fails at the
moment of applying rather than diverging silently, and two migrations dispatched
at once cannot land in the wrong order. An empty list, a first entry under
anyone but the writer or in any namespace but the one being written, or one
naming anything but the head is UnexpectedMigrationHead. An entry after the
first in the writer's own line is OwnPrerequisite: the head already says
everything about that line, so every later entry names a record in another line.
Prerequisite[] memory prerequisites = new Prerequisite[](1);
// The first migration in a line, applied in this transaction.
prerequisites[0] = Prerequisite({writer: address(this), namespace: NAMESPACE, migration: MIGRATION_HEAD_GENESIS});
LibMigrationRegistry.applyMigrationV5(address(this), NAMESPACE, MIGRATION_V1, prerequisites, "");
// Every later one names its predecessor.
prerequisites[0] = Prerequisite({writer: address(this), namespace: NAMESPACE, migration: MIGRATION_V1});
LibMigrationRegistry.applyMigrationV5(address(this), NAMESPACE, MIGRATION_V2, prerequisites, "");
// One that ran before the registry reached this chain names the moment it ran.
prerequisites[0] = Prerequisite({writer: address(this), namespace: NAMESPACE, migration: MIGRATION_V2});
LibMigrationRegistry.applyMigrationHistoryV5(address(this), NAMESPACE, MIGRATION_V3, 1750000000, prerequisites, "");Each record keeps that first entry, which appliedOntoV5 reads back, so a line
is a chain in storage rather than a set of moments to sort: from headV5, each
answer names the record before it, down to MIGRATION_HEAD_GENESIS. That chain
is the order the migrations ran in whatever moments the records carry.
Genesis is deliberately not zero. Zero is what an uninitialised bytes32
constant reads as, and a zero genesis would make a mis-set predecessor constant
a successful first application on any line that happens to be empty — the
state of every chain that has not been migrated yet, which is exactly where such
a mistake is most likely. A nonzero genesis makes it a revert everywhere.
The head does not replace the per-migration refusal, and both are kept.
Re-applying a migration whose successor has landed names a head that matches
perfectly; without MigrationAlreadyApplied it would drag the head backwards
and overwrite the original timestamp, which is a record un-happening. The two
answer different questions — the head is where in the sequence, the record is
whether at all.
A namespace is a line within a writer. One key writes for several repos —
the CI deploy key and the token-owner Safe each broadcast migrations for more
than one — and every repo keeps its own migration history. Under the writer
alone those histories would land in one line with one head: a migration in one
repo would name, as its head, whatever the last migration in some other repo
was, every dispatch across the repos would contend on one compare-and-set, and a
stalled line in one repo would hold the head for all of them. So records are
keyed by (writer, namespace, migration), with a head per writer and namespace,
and the same key keeps one line per repo. A namespace is an opaque bytes32 the
repo declares beside its migration ids, one named constant per repo; the
registry refuses only zero (ZeroNamespace), on every read and on every write
by the writer itself (any other caller is NotApproved first, since no approval
names zero), for the same reason it refuses the zero writer. There is no plain
line outside the namespaces: every write names one, and the writer's other
namespaces are other lines, which a write may wait on exactly as it waits on
another writer's.
A migration may wait on migrations in other lines. Every entry after the
head in the list of Prerequisite { writer, namespace, migration } is one, and
both writes check them in two passes. The first refuses an entry with a zero key
as a read would (ZeroWriter, ZeroNamespace, ZeroMigration,
GenesisMigration), and one in the line being written (OwnPrerequisite). Only
then does the second refuse with
PrerequisiteNotApplied(writer, namespace, migration) for the first entry whose
record does not exist on this registry. The head alone is a migration that waits
on nothing else. A dependent script names the migration it is waiting on as a
fact the registry checks, instead of re-deriving the other script's post-state,
and the head still orders its own line exactly as before.
// Lands only once FLEET_SAFE has recorded FLEET_UPGRADE in FLEET_NAMESPACE;
// refused with `PrerequisiteNotApplied(FLEET_SAFE, FLEET_NAMESPACE, FLEET_UPGRADE)`
// until then.
Prerequisite[] memory prerequisites = new Prerequisite[](2);
prerequisites[0] = Prerequisite({writer: address(this), namespace: NAMESPACE, migration: MIGRATION_V2});
prerequisites[1] = Prerequisite({writer: FLEET_SAFE, namespace: FLEET_NAMESPACE, migration: FLEET_UPGRADE});
LibMigrationRegistry.applyMigrationV5(address(this), NAMESPACE, MIGRATION_V3, prerequisites, "");The list is the record, and appliedAfterV5 reads it back exactly as the write
gave it: the head under the writer's own line first, then the prerequisites as
listed, duplicates included. A line's first migration's answer starts with
MIGRATION_HEAD_GENESIS, the one entry that is a head and not a record. Empty
only for a migration never applied. Within a line appliedOntoV5 walks the
chain back to genesis; appliedAfterV5 walks from a record to every record it
waited on, its predecessor included, so the cross-line order is on chain and not
only in the log. Migrated says nothing about the list: it is in the record,
not beside it. A prerequisite is an index check, not proof: it says the other
writer recorded its migration, not that the state it produced holds, and
consumers keep their pins. It bounds no moment either — a backfilled record may
carry an earlier moment than its prerequisite, because what is checked is that
the record existed when this write landed, which is chain order, and the moments
in another line are that writer's data.
The refusals and their order are specified once, on
IMigrationRegistryV5.applyMigrationHistoryV5. Duplicates are ordinary index
checks.
The writer decides who writes its lines, and that is the whole access
control. A write names its writer, and the registry refuses it with
NotApproved unless the caller is that writer or an account the writer approved
for that namespace (approveV5, revokeV5, approvedV5). An approval names
one namespace under the approving writer and nothing else, so a Safe and a
timelock can both write one line without either owning it, and moving a role
between them never moves where its records go. Anyone may write, but only into
their own lines or ones they were approved for, so a reader asking about the
lines of an authority it already trusts is reading something only that authority
or a delegate it approved could have written; every other writer's lines hold
unforgeable claims nobody asks about. A line is therefore only as trustworthy as
every delegate it has ever had, revoked ones included, so each record keeps the
account that wrote it (appliedByV5). An approved delegate can also move the
line's head onto any id, which no write can undo, so a compromised delegate can
stop every script queued against the line, leaving a new namespace as the only
way out. Approve only accounts held to the writer's own custody, and revoke
before rotating one out. Approvals cannot be enumerated on chain, only asked
about one delegate at a time, so a consumer's prod tests assert approvedV5 is
false for every retired delegate beside the true answers for the live ones. A
namespace is not an authority: it is the writer's to name, and a record in it is
that writer's claim. The repo's scripts write under the one root authority,
LibRegistryAuthority.ROOT_AUTHORITY.
An index, not proof. The registry says which invariant applies. It does not say the invariant holds — a multisig can act out of band and nothing here moves. Keep both layers: this selects, codehash and bytecode pins verify. Replacing the pins with it trades a clock-guess for a bookkeeping-guess.
LibMigrationRegistry is the surface — appliedV5, appliedOntoV5,
appliedAfterV5, appliedByV5, headV5, approvedV5, approveV5,
revokeV5, applyMigrationV5 and applyMigrationHistoryV5, plus the *Call
builders for a bundle, each verifying the registry's code hash before it reads
or writes, and the pure onto, which builds the head-only list. There is
deliberately no broadcast runner: the dominant real shape is a Safe
executing a bundle that never broadcasts, and such a script appends the
applyMigrationV4Call output to the bundle it is already emitting, which makes
the record atomic with the migration it describes.
script/ApproveMigrationDelegate.sol approves a delegate to write a list of
namespaces' lines under the root authority, or revokes it, on every declared
network by default. approveV5 and revokeV5 act under their caller, so the
signer is the root authority, and the run refuses any other --sender
(SenderNotAuthority) before forking. The namespace is the string the consumer
hashes, refused unless canonical as a config name is. Everything else — the
Trezor setup, the refusals, the dry run's record and SKIP — is "Approving a
delegate" above, with the migration registry as the recipient.
export MIGRATION_DELEGATE_SCOPES=st0x.deploy # comma-separated
# Optional: true also approves the delegate unscoped, for every namespace.
# export MIGRATION_DELEGATE_UNSCOPED=true
export MIGRATION_DELEGATE_ADDRESS=0x... # the delegate, such as a Safe or timelock
# Optional: false revokes. Unset approves.
# export MIGRATION_DELEGATE_APPROVED=false
# Optional, to act on fewer networks than all of them:
# export MIGRATION_DELEGATE_NETWORKS=base,arbitrum
# 1. Dry run.
nix develop -c forge script script/ApproveMigrationDelegate.sol:ApproveMigrationDelegate \
--sender 0x0b300013CD54a8F1aC40981f80FaaA18b8Cc1E4c --trezor --legacy
# 2. The same command with --broadcast.
nix develop -c forge script script/ApproveMigrationDelegate.sol:ApproveMigrationDelegate \
--sender 0x0b300013CD54a8F1aC40981f80FaaA18b8Cc1E4c --trezor --legacy --broadcast
# 3. Once every transaction is mined.
nix develop -c forge script script/ApproveMigrationDelegate.sol:ApproveMigrationDelegate --sig "verify()"The device shows the same multicall as above, per namespace hash. Each
approval covers its one namespace; each delegate is a run of its own.
Four separate steps, in this order. Nothing automatic ever broadcasts.
- Deploy. Dispatch the
Manual sol artifactsworkflow, choosing asuite. It runsscript/Deploy.soland broadcasts that suite to every network insupportedNetworks(). One suite per dispatch, so this repo's two registries are two dispatches.workflow_dispatchonly: this is key custody and real money, and no merge or tag should be able to trigger it. It is idempotent — a network that already has the code is skipped — so a partial run is fixed by running it again rather than by unpicking anything. - Cut. Set
[external.package].versioninfoundry.tomlto the release version first, becausecutRelease()freezes under that version's tag. Then runnix develop -c forge script ./script/Build.sol --sig 'cutRelease()', thennix develop -c forge script ./script/Build.solFOLLOWED BYnix run github:rainlanguage/rainix#rainix-static -- install-staged-config, and open the release PR. Its CI checks the newly released suites on chain, so merge it only once step 1 has landed on every supported network. A release freezes every contract the release before it froze, unlessBuild.retiredContracts()lists it:testNoReleaseDropsAContractrefuses any other drop. Retiring a contract is moving it fromgeneratedContracts()toretiredContracts(), asAddressRegistrywas. - Verify.
RegistryDeployVerifyTestpasses only once every released suite is live on every supported network, with the code that release froze. - Tag. Push a
sol-v*tag on the release PR's merge commit, the sole release trigger; a later commit whose candidate has moved fails the tag's frozen-equals-regenerated check. It verifies and publishes but never broadcasts, which is exactly why step 1 cannot be folded into it. The release mechanics arerainix-tag-release's.
This is a deploy repo: it carries deployed concretes whose addresses and
codehashes consumers pin, so releases are manual sol-v* tags, not merges.
[external.package].version is the newest release cut into
src/generated/<tag>/: only the release PR that cuts that tag moves it, and an
ordinary PR does not. A release cut under this lifecycle also names the frozen
src/generated/<tag>/ record cutRelease() wrote for it. Every version
published under the previous merge-driven lifecycle predates that record and has
none, so src/generated/ holds no directory for it; those versions stay
published, and consumers pin exact versions and are unaffected.
HyperEVM interleaves small fast blocks with large slow ones, and a deployment
too big for the fast block's gas cap has to go in a big block. Big blocks are
opted into with an evmUserModify action, which HyperCore accepts only from an
address it already knows — one that holds a Core asset. A deployer funded purely
to pay EVM gas is not that address, so the deploy above cannot reach HyperEVM at
all until something puts an asset on Core for it.
Nothing external has to. HYPE is HyperEVM's native gas token rather than an
ERC20, and value sent to the system contract at
0x2222222222222222222222222222222222222222 is credited on Core to whoever sent
it. The deployer already holds HYPE, because that is what it pays gas in, so it
credits itself.
The canonical way to run it is the
Manual credit hypercore
workflow. The amount is its credit-wei input, typed under its own name — which
is why this is not a Manual sol artifacts dispatch: that workflow exports
DEPLOYMENT_SUITE, DEPLOYMENT_NETWORK and DEPLOYMENT_KEY and nothing else,
so an amount of real money could only travel through it under a name that means
something else. Every dispatch executes the dry run; broadcasting takes the
broadcast input flipped to true as well, and even then the dry run still runs
and refuses first. Dispatching is admin-gated: GitHub itself gates
workflow_dispatch at write access and nothing finer, so the workflow's first
step asks GitHub what permission the dispatching actor holds on this repo and
fails the run unless the answer is admin — who dispatched and what they held
is in the run log either way.
The fallback, for when the workflow itself is what is broken, is the same script by hand:
read -rs DEPLOYMENT_KEY && export DEPLOYMENT_KEY
HYPERCORE_CREDIT_WEI=10000000000000000 \
nix develop -c forge script script/CreditHyperCore.sol:CreditHyperCore --legacyThe key is read rather than written into the command, because a
DEPLOYMENT_KEY=0x... prefix is a private key in the shell's history file,
where it outlives the run and the terminal both.
Run exactly that first, without --broadcast: it is a dry run against a fork of
HyperEVM that executes every guard and the transfer itself and sends nothing, so
anything the real run would refuse is refused there for free. Add --broadcast
to send it. --legacy for the same reason the deploy workflow carries a
legacy input — HyperEVM's RPC rejects the fee-history ranges EIP-1559
estimation asks for. No --rpc-url: the script forks the hyperevm alias
itself.
HYPERCORE_CREDIT_WEI is EVM wei, and required — an amount of real money is not
something to default. It has to be a whole number of Core wei, which is
10 ** 10 EVM wei, because HYPE has 8 wei decimals on Core against 18 on the
EVM and the remainder is burned rather than credited. An amount smaller than
that is burned in full: the transfer succeeds, the HYPE is gone, and the address
is still not a HyperCore user. LibHyperCore refuses it rather than rounding.
It also refuses to run anywhere but chain 999. That address is a system contract on HyperEVM and an ordinary unowned address on every other chain, where value sent to it is not rejected, just unrecoverable — so the chain id is checked before anything else, and the code hash at the system address is checked against a pin straight after, because a chain id alone does not say the contract behind it is the one that emits the log Core credits from.
However it is run, it is run once per deployer address and never again: a HyperCore user does not stop being one, so a second run is more money for no further effect.
Crediting made the deployer a HyperCore user; opting into big blocks is a
separate action on that user, and the one the credit exists to make possible.
{"type": "evmUserModify", "usingBigBlocks": true}, signed by the deployer key
and POSTed to HyperCore's exchange endpoint, sends the deployer's HyperEVM
transactions to big blocks from then on. The flag is persistent per address:
nothing expires it, so the same action with false is how the deployer gets
back OUT of big blocks once the sizeable deploy is done — which is why one
workflow serves both directions and its using-big-blocks input is required
rather than defaulted.
The canonical way to toggle it is the
Manual big blocks workflow,
admin-gated exactly as Manual credit hypercore is, and workflow_dispatch
only for the same reason: signing with the deployment key is key custody. The
signing scheme — msgpack of the action and nonce, keccak, an EIP-712 "phantom
agent" — is the official Hyperliquid Rust SDK's, called by the small
tools/hyperliquid-big-blocks crate and
deliberately not hand-rolled: an encoding wrong by one byte still yields a
signature that recovers as some OTHER address, which is not a failure mode to
meet live. The SDK is pinned to an exact git rev in the crate's manifest, which
also says why a rev rather than a crates.io version, and the big-blocks-tool
CI job builds and tests the crate on every push, so the first dispatch is not
the first compile.
The run fails unless the exchange answers "status": "ok" carrying the empty
envelope — {"type": "default"} with no data, which is what the live run on
record got. The tag alone is not enough: "status": "ok" is also how
Hyperliquid answers the actions that report a per-action outcome, with that
outcome, error included, nested under data.statuses. Either way the full
response is in the run log, and that log line is the record: Hyperliquid has no
info-endpoint query that reads usingBigBlocks back, so the only observable of
the flag afterwards is which blocks the deployer's next transactions land in.
The fallback, for when the workflow itself is what is broken, is the same crate by hand — the key read rather than written into the command, for the same shell-history reason as the credit:
read -rs DEPLOYMENT_KEY && export DEPLOYMENT_KEY
USING_BIG_BLOCKS=true nix develop github:rainlanguage/rainix#rust-shell \
-c cargo run --locked --manifest-path tools/hyperliquid-big-blocks/Cargo.tomlrust-shell comes from rainix directly because this repo's own dev shell is the
slim sol-shell, which carries no cargo.
Via soldeer:
forge soldeer install rain-deploy~<version>You also need forge-std 1.17.0 and rain-sol-codegen 0.1.39, remapped as
forge-std-1.17.0/ and rain-sol-codegen-0.1.39/. The published package ships
src/, script/ and the licence and README files — no test/, no
foundry.toml, no remappings.txt, no soldeer.lock, no dependencies/ — so
a consumer resolves both itself. The requirement is transitive rather than
incidental: the deployed contract imports nothing outside this package, but
every abstract a consumer inherits pulls them in — Script via
RainDeployBroadcast, Test via RainDeployVerifyBase, Vm via
LibRainDeploy beneath both, and LibCodeGen/LibFs via
LibRainDeploySnapshot, which RainDeployVerifySnapshot imports:
[dependencies]
forge-std = "1.17.0"
rain-sol-codegen = "0.1.39"
rain-deploy = "<version>"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.
BuildScript.run() regenerates the CONSUMING repo's own foundry.toml and
.env.example, so that repo has to allow it:
fs_permissions = [
{ access = "read", path = "./foundry.toml" },
{ access = "read", path = "./.env.example" },
{ access = "read-write", path = "./.staged-config" },
]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 rainix's rainix-copy-artifacts, after
forge script ./script/Build.sol and before the git diff that fails a stale
tree. Locally:
nix run github:rainlanguage/rainix#rainix-static -- install-staged-config.
# .gitignore
.staged-configBoth 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:
# rain-deploy:generated:rpc_endpoints:begin
# rain-deploy:generated:rpc_endpoints:end
# rain-deploy:generated:etherscan:begin
# rain-deploy:generated:etherscan:end# .env.example
# rain-deploy:generated:env:begin
# rain-deploy:generated:env:endEverything outside the markers is the consumer's, and the build neither reads nor moves it.
A repo that deploys to FEWER networks than Rain supports overrides
supportedNetworks() on its suites declaration, and that one answer scopes
every side: deployNetworks() defaults to it, the chain group forks it, and the
generated config is the catalogue entries it names. script/Build.sol inherits
that declaration, which is what makes it one answer — a networks hook on the
build script instead would be a second statement of the repo's set, and config
generated from it could cover networks nothing verifies. There is deliberately
nothing per verification to override either: a verification set narrower than
the one a repo broadcasts to is every release held to nothing at all on the
networks that were dropped, with no assertion left to notice.
An [etherscan] entry is generated for every network in that set, from the
catalogue's own facts. Whether a network HAS an Etherscan deployment is one of
them rather than anything about the repo — Robinhood (4663) is not indexed by
Etherscan v2, so its entry carries its Blockscout url and the deployment is
verified through Sourcify.
DeploySuite gains mechanismData, so a consumer's generated
Lib<Contract>Released stops compiling before its BuildScript can regenerate
it. In order:
- Add
mechanismData: ""to every entry of the generated released libs. - Make
viewevery function of yours that callscheckedCandidateSuites,allSuites,suiteNames,suiteByNameorcandidateSuites, which areviewnow. Apureoverride ofcandidateSuitesstill compiles. - Update the moved errors.
NetworkChainIdMismatch,NotDeployedOnNetworkandCodeHashMismatchOnNetworkare members ofLibRainDeploy: refer to them asLibRainDeploy.NotDeployedOnNetwork.selectorand so on, and drop their imports.NotDeployedOnNetworkandCodeHashMismatchOnNetworkno longer carry the suite. ImportZoltuDerivationMismatchfromRainDeploySuitesBase, andFrozenSnapshotUnreadableandFrozenSnapshotAmbiguousfromLibRainDeploySnapshot.FrozenSnapshotsOutnumberReleasescarries anaddress[]. A code hash mismatch indeployToNetworksrevertsCodeHashMismatchOnNetwork, orNotDeployedOnNetworkwhen the address holds no code, where it revertedUnexpectedDeployedCodeHash. - Replace the removed or re-signed internal functions and members:
DerivedDeploy.deployedAddressisaddress[] deployedAddresses, one per address the suite's mechanism deploys to, in the suite's address order.RainDeployVerifySnapshotBase.DEPLOYED_ADDRESS_DECLARATIONisLibRainDeploySnapshot.DEPLOYED_ADDRESS_DECLARATION.RainDeployVerifyChain.checkDeployedOnNetwork(string, DerivedDeploy)is removed: callLibRainDeploy.checkDeclaredDependenciesandLibRainDeploy.checkDeployedCodeon the selected fork, or run the whole matrix withcheckDeployedOnNetworks(SupportedNetwork[], DeploySuite[]).RainDeployVerifyChain.checkDeployedOnSupportedNetworkstakesDeploySuite[], notDerivedDeploy[].RainDeployVerifyChain.checkNetworkChainId(string, uint256, uint256)isLibRainDeploy.checkNetworkChainId(SupportedNetwork), which reads the selected fork's chain id.RainDeployVerifySnapshotBase.recordedDeployedAddress(string, string)isLibRainDeploySnapshot.recordedDeployedAddresses(Vm, string, string), returning the chain id and address of every declaration.RainDeployVerifySnapshotBase.withoutBlockCommentsisLibRainDeploySnapshot.withoutBlockComments(string), which takes noVm.LibRainDeploy.checkDeployRequirementsisLibRainDeploy.checkDeclaredDependencies, with the same parameters.LibTimelockController.timelockInitCodeandexpectedTimelockAddresstake the timelock'sbytes creationCodefirst, andassertTimelockStateitsbytes32 bytecodeHashfirst.LibTimelockControllerDeploy.deployTimelocktakes both afterdeployer.LibRainDeploySnapshot.releasedImport,releasedImportBlockandreleasedLibraryBlockareview.
- For each per-network release broadcast, or to be broadcast, to a network it
does not record, override
extendedNetworksto return that network as broadcast: declare, then broadcast. Without it the broadcast and the chain group refuse the release there withExtendedNetworkUnfrozen. The address that refusal names at broadcast is what the candidate's current init data derives: a release already live on that network is declared with the init data it was broadcast with, from itsNewCloneevent, not with that address. Then run the build and commit the record it writes undersrc/generated/extended/(overrideextendedNetworksSpdxLicenseIdentifierandextendedNetworksCopyrightTextfor a licence other than this org's): the broadcast and the chain group refuse a declaration it does not hold withExtendedNetworksUnrecorded. - Move to the V4 migration registry.
LibMigrationRegistry.applied,appliedOnto,appliedAfterandheadareappliedV5,appliedOntoV5,appliedAfterV5andheadV5.applyMigrationandapplyMigrationHistoryareapplyMigrationV5andapplyMigrationHistoryV5, and take theaddress writerfirst and a trailingbytes meta(""for none);applyMigrationCallandapplyMigrationHistoryCallareapplyMigrationV4CallandapplyMigrationHistoryV4Call, likewise. Delegation isIRegistryDelegatesV3:approveV5,revokeV5andapprovedV5. ImportPrerequisitefromsrc/interface/IMigrationRegistryV5.sol;IMigrationRegistryV1,IMigrationRegistryV2,IMigrationRegistryV3andIRegistryDelegatesV1moved tosrc/interface/deprecated/. The registry has a new address, so migrations recorded in the 0.1.18 registry stay there and read as unapplied in V4: apply them again, withapplyMigrationHistoryV5for one that already ran. - Regenerate:
forge script ./script/Build.solfollowed bynix run github:rainlanguage/rainix#rainix-static -- install-staged-config.
LibRainDeploy.deployToNetworks, RainDeployBroadcast.deployAndBroadcast,
RainDeployBroadcast.broadcastSuite and
RainDeployVerifyChain.checkDeployedOnSupportedNetworks take catalogue aliases
only (NetworkNotInCatalogue), refuse a fork reporting another chain id
(NetworkChainIdMismatch), and check the declared dependencies on every
network, including one the suite is already deployed on.
RainDeployVerifyChain.checkDeployedOnNetworks takes SupportedNetwork entries
as given, with no catalogue lookup, and holds each fork's chain id to its
entry's chainId, checking the declared dependencies likewise.
LibRainDeploy.createForks, checkResolvedAddressesOnNetworks and
checkResolvedAddressesOnForks take raw [rpc_endpoints] aliases, with no
catalogue lookup and no chain id check.
This repo uses nix. The default shell is the
slim sol-shell from rainix.
nix develop # enter the shell
forge soldeer install # install deps declared in foundry.toml
forge testRegenerating what this repo generates is two commands, and the second is not optional — the first only stages the network config:
nix develop -c forge script ./script/Build.sol
nix run github:rainlanguage/rainix#rainix-static -- install-staged-configThree 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:
rainix-sol-test—forge test -vvvrainix-sol-legal—reuse lintrainix-sol-static—slither .,forge fmt --check, thenrainix-sol-single-contractbig-blocks-tool—cargo fmt --all -- --check,cargo clippyandcargo test --lockedintools/hyperliquid-big-blocks/, in rainix'srust-shell
Use the nix-pinned forge for all development.
DecentraLicense 1.0 (SPDX: LicenseRef-DCL-1.0) — full text in
LICENSES/. Roughly CAL-1.0
(opensource.org) plus user-data
disclosure obligations consistent with permissionless-blockchain assumptions.
This repo is REUSE 3.2 compliant. Verify locally:
nix develop -c reuse lintWelcome under the same license. Contributors warrant that their contributions are compliant.