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
42 changes: 22 additions & 20 deletions .github/workflows/deploy-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ jobs:
# Shared across steps. ACCOUNT_* is anvil test account 7 (public test keys,
# never valid on a real network) used to run the pipeline. FACTORY_DEPLOYER is
# the public address of the single-purpose factory key. CANONICAL is the
# committed manifest, the address set this deploy has to reproduce; MANIFEST is
# committed expected-address set this deploy has to reproduce: what a fresh
# deploy of this revision lands through the pinned factory (see "Network
# manifests and the expected set" in DEPLOYMENTS.md). MANIFEST is
# the file this CI deploy writes. PINNED_FACTORY is read out of CANONICAL after
# checkout rather than repeated here, so the expectation cannot drift from the
# manifest it is meant to describe. PRIVATE_KEY is deliberately absent: it is set
# file it is meant to describe. PRIVATE_KEY is deliberately absent: it is set
# only on the first deploy to import the keystore, and must not reach the resume
# run, which reuses the already-imported account.
env:
Expand All @@ -40,7 +42,7 @@ jobs:
# any CREATE3 address, so the reproduction still lands the canonical set.
DOTNS_TLD: dot
FACTORY_DEPLOYER: "0xd498F7BC5bB3cBdd0068c3deEbbd814b69C3F164"
CANONICAL: deployments/paseo-assethub/420420417.json
CANONICAL: deployments/expected.json
MANIFEST: deployments/paseo-local/420420420.json

steps:
Expand Down Expand Up @@ -273,24 +275,24 @@ jobs:
exit 1
fi

# This CI deploy reproduces the published address set. The canonical factory
# This CI deploy reproduces the expected address set. The canonical factory
# was deployed above, and every DotNS address is a pure function of that
# factory plus a fixed salt, so the freshly deployed manifest must equal the
# committed manifest. Assert that, print the expected-vs-actual table, then
# committed expected set. Assert that, print the expected-vs-actual table, then
# rerun the pipeline to prove the deploy is resumable: a rerun adopts every
# contract and still lands on the same set.
- name: Verify addresses match the committed manifest
- name: Verify addresses match the expected set
id: verify_addresses
if: steps.verify.outcome == 'success'
run: |
set +e

# Markdown expected-vs-actual table: expected = committed manifest,
# Markdown expected-vs-actual table: expected = the committed expected set,
# actual = this CI deployment. Any row whose addresses differ is MOVED.
emit_table() {
echo "### Deployed addresses vs the committed manifest"
echo "### Deployed addresses vs the expected set"
echo ""
echo "Expected is the committed manifest; actual is this CI deployment of the same pipeline."
echo "Expected is the committed expected-address set; actual is this CI deployment of the same pipeline."
echo ""
echo "| Contract | Expected | Actual | Match |"
echo "|:---------|:---------|:-------|:-----:|"
Expand All @@ -302,11 +304,11 @@ jobs:
'
}

# True when the deployed manifest equals the committed one. Sort keys so only value
# True when the deployed manifest equals the expected set. Sort keys so only value
# differences, never ordering, register as a mismatch. Underscore-prefixed keys are
# deploy metadata rather than addresses: `_seed` varies per run, and `_deployedFrom`
# exists only in a committed manifest, never in a fresh deploy's output. The release
# generator and the dotns-releases report drop the same prefix.
# deploy metadata rather than addresses: `_seed` varies per run, and the expected
# set carries no metadata at all. The release generator and the dotns-releases
# report drop the same prefix.
addresses() {
jq -S 'with_entries(select(.key | startswith("_") | not))' "$1"
}
Expand All @@ -328,15 +330,15 @@ jobs:

if ! matches_canonical; then
{
echo "### Deployed addresses do not match the committed manifest"
echo "### Deployed addresses do not match the expected set"
echo ""
echo "The pipeline no longer reproduces the published set. Rows marked MOVED"
echo "below differ from the committed manifest; update it, or restore the"
echo "salt or label that moved."
echo "The pipeline no longer reproduces the expected addresses. Rows marked"
echo "MOVED below differ from deployments/expected.json; update it, or"
echo "restore the salt or label that moved."
echo ""
cat table.md
} > deploy-error.md
fail "Failed - addresses differ from the committed manifest"
fail "Failed - addresses differ from the expected set"
fi

# Resume: rerun the same pinned pipeline. It must adopt every contract
Expand All @@ -360,7 +362,7 @@ jobs:
{
echo "### Resume moved addresses"
echo ""
echo "The rerun no longer matches the committed manifest, so a resumed deploy would"
echo "The rerun no longer matches the expected set, so a resumed deploy would"
echo "relocate contracts."
echo ""
emit_table
Expand All @@ -371,7 +373,7 @@ jobs:
# Surface the table under the passing row too, so the address set is on
# record every run, not only on failure.
cp table.md deploy-error.md
echo "result=Reproduces the committed manifest; resume verified" >> "$GITHUB_OUTPUT"
echo "result=Reproduces the expected address set; resume verified" >> "$GITHUB_OUTPUT"
echo "has_details=true" >> "$GITHUB_OUTPUT"

- name: Set final result
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ Any new contract address that other contracts need to read must be looked up thr

If you are adding a new contract category, add a `bytes32` key for it in `DotnsConstants.sol`, wire it up in `WireDeployments.s.sol`, and list the contract and its interface in `.github/abi-contracts.txt` so their ABIs ship in the release artifact. Read it the same way every existing contract does.

A change that moves or adds an address (a new salt, a new contract, a contract restructured behind a proxy) must update `deployments/expected.json` in the same PR — that diff is where review sees the move — and must NOT touch any `deployments/<network>/<chainId>.json`. Those are records of live networks, updated only by a real deploy on that network; editing one from a code PR publishes an address nothing is deployed at. The expected set diverging from a network's manifest is normal and means a redeploy or migration is owed on that network — see "Network manifests and the expected set" in `DEPLOYMENTS.md`.

Bad — the registrar address is frozen at construction, so rotating it needs an upgrade:

```solidity
Expand Down
16 changes: 13 additions & 3 deletions DEPLOYMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,16 @@ forge test --match-path 'test/fork/**' -vvvvv

If the deployment was intended to update a public environment, update the address tables in this file from the deployment manifest in the same change that updates the generated deployment JSON.

### Network manifests and the expected set

`deployments/<network>/<chainId>.json` is a **network record**: what is deployed on that live network right now. It is updated only by a real deploy or migration on that network, never by a code change. Everything that answers for reality reads these files: releases copy their addresses verbatim, and pointing tooling or the wire stage at an address with nothing behind it breaks whatever reads it.

`deployments/expected.json` is the **expected set**: the addresses a fresh deploy of the current revision lands through the pinned CREATE3 factory. It is a property of the code, not of any network; the CI deploy job and `scripts/genesis/build-genesis.sh` verify against it, and releases never publish it.

The expected set can legitimately disagree with a network manifest: after a code change moves an address, the expected set carries the new address while every network manifest keeps the old one until that network actually redeploys. The difference between them is the migration backlog, readable as a diff, and it is resolved per network by the event that relocates the contract: a wipe-and-redeploy on a test network, a deliberate migration on one that never wipes.

Before deploying to a live network, diff its manifest against `deployments/expected.json`. If any address diverges, run the pipeline against that network only as that planned wipe or migration: run outside it, the pipeline deploys the diverged contracts beside the live ones with empty state and repoints their registry keys, stranding any state behind the old addresses. After the planned deploy, commit the manifest it writes and update the address tables in this file in the same change.

## Name grants (whitelisting)

Reserved registration is gated on `DotnsNameWhitelist`. A grant binds one label to one beneficiary address and is single use: `registerReserved` requires a grant naming `registration.owner`, spends it on the mint, and refuses a second attempt. See the [README economics section](./README.md#economics) for what a grant does and does not confer; this section covers the mechanics.
Expand Down Expand Up @@ -357,7 +367,7 @@ Choosing and changing addresses:
- To intentionally move the entire address set (a clean re-deploy that must not collide with the previous one), bump `CREATE3_SALT_NAMESPACE` (`v1` becomes `v2`). Every address shifts together.
- Do not reuse a `label` for a different contract. The wire stage and external tooling key off stable labels, so a reused label silently repoints them.

Two other manifest entries are not CREATE3-derived: `LabelStoreBeacon` and `UserStoreBeacon`. They are deployed inside the `StoreFactory` constructor (and owned by it, so the factory owner can upgrade store implementations), so their addresses are `keccak(StoreFactory, nonce)`. They stay put across resets while `StoreFactory`'s bytecode is unchanged, but a change to that constructor can move them. This is deliberate: only the core CREATE3 contracts are guaranteed stable, so do not treat the beacon addresses as network-stable, read them from the manifest or the factory.
Two other manifest entries are not CREATE3-derived: `LabelStoreBeacon` and `UserStoreBeacon`. They are deployed inside the `StoreFactory` initialiser, which runs by delegatecall from the proxy constructor, so they are owned by the `StoreFactory` proxy and their addresses are `keccak(StoreFactory proxy, nonce)`. Owning them from the proxy is what keeps store-implementation upgrades available across a factory upgrade: the beacons answer to an address whose logic can be replaced, rather than to the code deployed on day one. They stay put across resets while the initialiser is unchanged, but a change to it can move them. This is deliberate: only the core CREATE3 contracts are guaranteed stable, so do not treat the beacon addresses as network-stable, read them from the manifest or the factory.

The one address that is not CREATE3-derived is the CREATE3 factory itself: it bootstraps the scheme, so it cannot deploy itself. The first deploy stage deploys it directly and records it on the protocol registry under the `CREATE3_FACTORY` key; every later stage resolves it from there rather than from an environment variable. Because every other address is derived from the factory's address, the factory must sit at the same address on each chain for the rest of the set to match. Deploy it as the deployer's first transaction on a fresh account (or through a deterministic singleton deployer) so its nonce-derived address is identical across chains.

Expand All @@ -374,9 +384,9 @@ Matching works in two steps, in `BaseDeployer._requireExpectedCode`:

The reference copies are throwaway and are deployed with broadcasting paused, so they are never sent as transactions.

That second step is what rejects a genuine artefact deployed against someone else's constructor arguments: a real `StoreFactory` pointed at an attacker's protocol registry has the right length and shape, and differs only in the values its constructor wrote.
That second step is what rejects a genuine artefact deployed against someone else's constructor arguments: a real `DotnsPopLens` pointed at an attacker's protocol registry has the right length and shape, and differs only in the values its constructor wrote.

**What the bytecode check cannot cover.** Immutables whose values are address-derived are indistinguishable between an honest deploy and any other, because they legitimately differ every time. `StoreFactory` is the case that matters: it deploys its own beacons, so `labelStoreBeacon` and `userStoreBeacon` differ on every deploy and are skipped by the comparison. Its `protocolRegistry`, which is an immutable set from a constructor argument, is part of that comparison. Its owner is not: `Ownable` keeps that in storage rather than in runtime code, so it is caught by the wire stage's `owner()` assertions instead.
**What the bytecode check cannot cover.** Immutables whose values are address-derived are indistinguishable between an honest deploy and any other, because they legitimately differ every time. Only `UUPSUpgradeable.__self` is in that class now, and every UUPS implementation carries it, so the masking handles it uniformly. `StoreFactory` used to be the case that mattered, because it minted its own beacons into immutables; behind a proxy it holds the beacons and `protocolRegistry` in storage and carries no immutables of its own, so its implementation compares exactly. Immutables set from a constructor argument stay inside the comparison, which is what rejects an artefact built against someone else's addresses: `DotnsPopLens.protocolRegistry` and `DotnsFlatPricing.deposit` are the two that remain. An owner is never covered here, since `Ownable` keeps it in storage rather than runtime code; the wire stage's `owner()` assertions cover it instead.

The beacons are checked separately. The verification stage asserts that each beacon's code is the `UpgradeableBeacon` artefact, that the factory owns it, and that its implementation is the `LabelStore` or `UserStore` artefact this release builds. None of the three contracts carries immutables, so each comparison is exact.

Expand Down
3 changes: 2 additions & 1 deletion RELEASE_ARTIFACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ The release surface is decided in `.github/abi-contracts.txt` so a contract reac
- One entry can serve more than one live network. `paseo-assethub` is the deployment that both previewnet and Paseo Asset Hub Next V2 run, because every network deployed through the shared CREATE3 factory lands on the same addresses. So expect entries named after deployments, not after every chain you might connect to.
- The names under `contracts` (`DotnsRegistrar`, `PopRules`) do not change, and a name always means the same contract. Your code can depend on that.
- Addresses are copied from the manifest verbatim, which the deploy pipeline writes EIP-55 checksummed. Compare them case-insensitively rather than relying on the casing.
- `LabelStoreBeacon` and `UserStoreBeacon` appear when deployed but are not network-stable, because the `StoreFactory` constructor deploys them. Read them from the factory rather than pinning them.
- Only per-network manifests are published. `deployments/expected.json` — the fresh-deploy address set that CI and the genesis builder verify against (see `DEPLOYMENTS.md`) — is not a network and never appears here, so a release cut while an address move is awaiting its network's redeploy still advertises the addresses each live network actually runs.
- `LabelStoreBeacon` and `UserStoreBeacon` appear when deployed but are not network-stable, because the `StoreFactory` initialiser deploys them. Read them from the factory rather than pinning them.

## `release-manifest.json`

Expand Down
7 changes: 4 additions & 3 deletions contracts/store/IStoreFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ interface IStoreFactory {
/// @param user The invalid user argument.
error InvalidUser(address user);

/// @notice Thrown when a zero protocol registry address is supplied to the constructor.
/// @notice Thrown when a zero protocol registry address is supplied to the initialiser.
/// @param protocolRegistry The invalid registry argument.
error InvalidProtocolRegistry(address protocolRegistry);

/// @notice Thrown when a zero implementation address is supplied to the constructor or an
/// upgrade. @param implementation The invalid implementation argument.
/// @notice Thrown when a zero implementation address is supplied to a store-implementation
/// upgrade.
/// @param implementation The invalid implementation argument.
error InvalidImplementation(address implementation);

/// @notice Thrown when an unauthorised address attempts to deploy a label store.
Expand Down
Loading
Loading