The problem
Deterministic deploys and configured addresses are in tension. The CREATE2
address is a function of the creation code, so any address baked into a contract
is part of its identity — change the address, and every future deployment lands
somewhere else. So configured addresses get hardcoded, one copy per repo, and
can never be changed without moving deployments.
Initial ownership is the case that prompted this — src/lib/LibRainDeploy.sol
contains no notion of an owner, so every repo hardcodes one — but the problem is
not specific to owners, and neither is the fix.
The concrete — here
Deployed via Zoltu, same address on every network.
- An immutable root authority, hardcoded.
mapping(bytes32 name => address). Root binds a name, and may re-bind one it
has already bound.
- Reverts on an unset name, so no caller has to remember to check.
- Rejects the zero address, because an unset name reads as zero, so a zero
binding would be a name that is both bound and unreadable.
That is the whole contract. No removal, no upgrade, no authority besides root.
It lives here rather than in a separate concrete repo. The address and codehash
are a function of the creation code, which is a function of the compiler
settings that compiled it; keeping the concrete, the settings and the pins in
one repo means there is no boundary across which they can silently diverge, and
nothing has to depend on another deploy repo to get them.
Bindings are mutable
The name a consumer resolves is in that consumer's creation code. A binding
welded to one address forever therefore cannot express an ordinary rotation of
an owning Safe: it would need a new name, which means new creation code and a
new deterministic address. That is the exact problem this exists to remove, just
relocated.
Write-once bought less than it looked like it did. It protected bindings on
chains already in use from a compromised root, and it never protected a fresh
chain, because an attacker who is root registers the name there first either
way. So it cost ordinary rotation to buy safety only on chains already
established.
Mutability costs nothing already deployed. A consumer resolves a name once,
in its constructor, and stores the answer; it never consults the registry again.
Re-binding a name changes what the next deployment resolves and nothing else,
which makes a rotation a deliberate migration rather than a silent, retroactive
change to live contracts.
The lib — here
Reads the registry for a given name, and verifies its codehash the way
ZOLTU_FACTORY_CODEHASH is checked here today.
That is all it does. What a consumer resolves a name for, and when — an owner in
a constructor or an initializer, under Ownable or RBAC, or anything else
entirely — is its own business.
The check — after the deploy, not before
A check against the registry before a deploy would be TOCTOU against a mutable
value and worth nothing: the binding can move between the check and the
constructor that consumes it.
The working model is: deploy as step 1, verify, then migrate onto it as step 2,
never using an untested deployment. So verification runs after the deploy,
against the value the deployed contract has already snapshotted. That is settled
state and cannot move under the check.
LibRainDeploy already runs the multi-network broadcast, so the cross-network
half of that belongs beside it rather than in every consumer's deploy script.
But only the consumer knows where it stored what it resolved, so the split is:
the consumer supplies the reads, and LibRainDeploy supplies the fork loop and
the comparison. It is deliberately source-agnostic — it asserts that the
deployed contract holds the expected address, not where that address came from,
because re-reading the registry would assert a value that can move rather than
the value the deployment actually took.
The security model
Compromising root cannot touch anything already deployed. Every consumer
snapshotted its address at construction and never reads the registry again, so
there is no live contract a re-binding can reach.
What a compromised root can do is poison a future deployment, by pointing a
name at an address it controls so that the next deploy snapshots that address.
Post-deploy verification is what catches it: the deployment is checked before
anything depends on it, so the outcome is a burned deterministic address,
discovered while nothing points at it, rather than a compromise.
Nothing a bound address does can break determinism. No resolved address is
in any creation code, so whatever a consumer does with one afterwards —
transferring an ownership, revoking a role — moves no address anywhere.
The problem
Deterministic deploys and configured addresses are in tension. The CREATE2
address is a function of the creation code, so any address baked into a contract
is part of its identity — change the address, and every future deployment lands
somewhere else. So configured addresses get hardcoded, one copy per repo, and
can never be changed without moving deployments.
Initial ownership is the case that prompted this —
src/lib/LibRainDeploy.solcontains no notion of an owner, so every repo hardcodes one — but the problem is
not specific to owners, and neither is the fix.
The concrete — here
Deployed via Zoltu, same address on every network.
mapping(bytes32 name => address). Root binds a name, and may re-bind one ithas already bound.
binding would be a name that is both bound and unreadable.
That is the whole contract. No removal, no upgrade, no authority besides root.
It lives here rather than in a separate concrete repo. The address and codehash
are a function of the creation code, which is a function of the compiler
settings that compiled it; keeping the concrete, the settings and the pins in
one repo means there is no boundary across which they can silently diverge, and
nothing has to depend on another deploy repo to get them.
Bindings are mutable
The name a consumer resolves is in that consumer's creation code. A binding
welded to one address forever therefore cannot express an ordinary rotation of
an owning Safe: it would need a new name, which means new creation code and a
new deterministic address. That is the exact problem this exists to remove, just
relocated.
Write-once bought less than it looked like it did. It protected bindings on
chains already in use from a compromised root, and it never protected a fresh
chain, because an attacker who is root registers the name there first either
way. So it cost ordinary rotation to buy safety only on chains already
established.
Mutability costs nothing already deployed. A consumer resolves a name once,
in its constructor, and stores the answer; it never consults the registry again.
Re-binding a name changes what the next deployment resolves and nothing else,
which makes a rotation a deliberate migration rather than a silent, retroactive
change to live contracts.
The lib — here
Reads the registry for a given name, and verifies its codehash the way
ZOLTU_FACTORY_CODEHASHis checked here today.That is all it does. What a consumer resolves a name for, and when — an owner in
a constructor or an initializer, under
Ownableor RBAC, or anything elseentirely — is its own business.
The check — after the deploy, not before
A check against the registry before a deploy would be TOCTOU against a mutable
value and worth nothing: the binding can move between the check and the
constructor that consumes it.
The working model is: deploy as step 1, verify, then migrate onto it as step 2,
never using an untested deployment. So verification runs after the deploy,
against the value the deployed contract has already snapshotted. That is settled
state and cannot move under the check.
LibRainDeployalready runs the multi-network broadcast, so the cross-networkhalf of that belongs beside it rather than in every consumer's deploy script.
But only the consumer knows where it stored what it resolved, so the split is:
the consumer supplies the reads, and
LibRainDeploysupplies the fork loop andthe comparison. It is deliberately source-agnostic — it asserts that the
deployed contract holds the expected address, not where that address came from,
because re-reading the registry would assert a value that can move rather than
the value the deployment actually took.
The security model
Compromising root cannot touch anything already deployed. Every consumer
snapshotted its address at construction and never reads the registry again, so
there is no live contract a re-binding can reach.
What a compromised root can do is poison a future deployment, by pointing a
name at an address it controls so that the next deploy snapshots that address.
Post-deploy verification is what catches it: the deployment is checked before
anything depends on it, so the outcome is a burned deterministic address,
discovered while nothing points at it, rather than a compromise.
Nothing a bound address does can break determinism. No resolved address is
in any creation code, so whatever a consumer does with one afterwards —
transferring an ownership, revoking a role — moves no address anywhere.