diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/actions/setup-foundry/action.yml b/.github/actions/setup-foundry/action.yml new file mode 100644 index 000000000..1c5122da2 --- /dev/null +++ b/.github/actions/setup-foundry/action.yml @@ -0,0 +1,15 @@ +name: Setup Foundry +description: Installs the foundry version this repo is known to build with. + +# One place for the version, so unpinning is one edit and not ten. +# +# Pinned at v1.7.1 because v1.8.0 breaks two things here: its linter aborts on +# DotnsPopControllerInvariant.t.sol, which kills `forge build` everywhere, and `forge doc` +# moved to Vocs, so deploy-docs.yml's mdbook step finds no book.toml. Fix both before unpinning. + +runs: + using: composite + steps: + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.7.1 diff --git a/.github/workflows/4naly3er.yml b/.github/workflows/4naly3er.yml index 6d511acdf..07f557d8c 100644 --- a/.github/workflows/4naly3er.yml +++ b/.github/workflows/4naly3er.yml @@ -36,9 +36,7 @@ jobs: submodules: recursive fetch-depth: 0 - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + - uses: ./.github/actions/setup-foundry - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/contract-coverage.yml b/.github/workflows/contract-coverage.yml index f78a9e78d..eedc307c2 100644 --- a/.github/workflows/contract-coverage.yml +++ b/.github/workflows/contract-coverage.yml @@ -35,9 +35,7 @@ jobs: with: submodules: recursive - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + - uses: ./.github/actions/setup-foundry - uses: oven-sh/setup-bun@v2 with: diff --git a/.github/workflows/deploy-contracts.yml b/.github/workflows/deploy-contracts.yml index 112418e6e..d86a264ee 100644 --- a/.github/workflows/deploy-contracts.yml +++ b/.github/workflows/deploy-contracts.yml @@ -68,9 +68,7 @@ jobs: no-cache: true - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + uses: ./.github/actions/setup-foundry - name: Make scripts executable run: chmod +x setup.bash diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index fd5ce6162..e7e4e1a39 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -39,9 +39,7 @@ jobs: with: submodules: recursive - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + - uses: ./.github/actions/setup-foundry - name: Install mdbook run: | diff --git a/.github/workflows/format-lint-product.yml b/.github/workflows/format-lint-product.yml index f591fe0b3..456bea454 100644 --- a/.github/workflows/format-lint-product.yml +++ b/.github/workflows/format-lint-product.yml @@ -16,9 +16,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + - uses: ./.github/actions/setup-foundry - uses: oven-sh/setup-bun@v2 with: bun-version: latest diff --git a/.github/workflows/gas-report.yml b/.github/workflows/gas-report.yml index 86b1184b6..716d33499 100644 --- a/.github/workflows/gas-report.yml +++ b/.github/workflows/gas-report.yml @@ -56,9 +56,7 @@ jobs: - id: foundry_toolchain continue-on-error: true - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + uses: ./.github/actions/setup-foundry - name: Retry Foundry install on transient failure if: steps.foundry_toolchain.outcome == 'failure' run: | diff --git a/.github/workflows/genesis-extractor-test.yml b/.github/workflows/genesis-extractor-test.yml new file mode 100644 index 000000000..d5c795dda --- /dev/null +++ b/.github/workflows/genesis-extractor-test.yml @@ -0,0 +1,30 @@ +name: Genesis Extractor Test + +# The release builds a pallet-revive genesis artifact from the deploy stages. The full build +# needs anvil, forge and the deployer keys, so it stays a release step; the state-to-GenesisConfig +# conversion is pure JSON and is worth checking on every PR that touches it. Without this, a +# broken extractor would only surface when a release is cut. +on: + pull_request: + paths: + - "scripts/genesis/**" + - ".github/workflows/genesis-extractor-test.yml" + +permissions: + contents: read + +jobs: + genesis-extractor-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Test the genesis extractor + run: node --test scripts/genesis/extract-genesis.test.mjs + + - name: Check the builder parses + run: bash -n scripts/genesis/build-genesis.sh diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index ed1732f21..3fe36756d 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -23,6 +23,10 @@ concurrency: jobs: beta-release: runs-on: ubuntu-latest + # Baked into the genesis registry, so it decides which networks the artifact suits. + # Job-level: five steps name the file derived from it and must not disagree. + env: + DOTNS_TLD: test steps: # On workflow_dispatch the tag does not exist yet; the release step creates it # from the branch this run was started on. Read through an env var rather than @@ -79,9 +83,7 @@ jobs: with: submodules: recursive - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + - uses: ./.github/actions/setup-foundry - uses: oven-sh/setup-bun@v2 with: @@ -109,6 +111,21 @@ jobs: FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" run: forge test -vv + # pallet-revive genesis, built from the code this tag ships. Unlike deployments.json + # below — which is withheld from a pre-release because it records a *previous* live + # deployment of different code — this genesis is a fresh deploy of exactly this commit, + # so its addresses and its ABIs do come from the same release. + # + # Present on pre-releases as well as releases so the asset sets do not diverge, and so + # this step is exercised before a real release depends on it. + - name: Build pallet-revive genesis + env: + FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" + FACTORY_DEPLOYER_KEY: ${{ secrets.FACTORY_DEPLOYER_KEY }} + DOTNS_ADMIN_KEY: ${{ secrets.DOTNS_ADMIN_KEY }} + DOTNS_ADMIN_MNEMONIC: ${{ secrets.DOTNS_ADMIN_MNEMONIC }} + run: bash scripts/genesis/build-genesis.sh release + - name: Extract ABIs run: | mkdir -p release/abis @@ -145,7 +162,8 @@ jobs: run: | TAG="$RELEASE_TAG" cd release - zip -r "../dotns-abis-${TAG}.zip" abis/ release-manifest.json + zip -r "../dotns-abis-${TAG}.zip" abis/ release-manifest.json \ + "dotns-genesis-${DOTNS_TLD}.json" - name: Generate release body env: @@ -193,10 +211,16 @@ jobs: echo "" echo "### Deployments" echo "" - echo "No addresses are published with a pre-release: this code is not deployed yet." - echo "Deploy this tag, then cut the release, which carries the addresses it produced." + echo "No **live** addresses are published with a pre-release: this code is not" + echo "deployed yet, so there is no deployment to record. Deploy this tag, then cut" + echo "the release, which carries the addresses it produced." + echo "" + echo "The genesis attached here is different: it is built by deploying this very" + echo "commit to a throwaway chain, so it carries **this commit's own addresses**," + echo "not the previous live deployment's." echo "" echo "- **Release contents:** [release-manifest.json]($ASSET_BASE/release-manifest.json)" + echo "- [dotns-genesis-${DOTNS_TLD}.json]($ASSET_BASE/dotns-genesis-${DOTNS_TLD}.json) — pallet-revive genesis, \`.${DOTNS_TLD}\` TLD, for ${DOTNS_TLD} networks only." } >> release-body.md - name: Create draft pre-release with artifacts @@ -210,6 +234,7 @@ jobs: dotns-abis-*.zip release/abis/*.json release/release-manifest.json + release/dotns-genesis-${{ env.DOTNS_TLD }}.json body_path: release-body.md draft: true prerelease: true @@ -226,6 +251,7 @@ jobs: { cat release/expected-assets.txt echo "dotns-abis-${TAG}.zip" echo "release-manifest.json" + echo "dotns-genesis-${DOTNS_TLD}.json" } | sort > "$RUNNER_TEMP/wanted-assets.txt" if ! diff -u "$RUNNER_TEMP/wanted-assets.txt" "$RUNNER_TEMP/actual-assets.txt"; then echo "::error::Draft pre-release $TAG does not match the expected asset set; delete the draft and re-run." diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index cc250fd65..265523df8 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -23,6 +23,10 @@ concurrency: jobs: release: runs-on: ubuntu-latest + # Baked into the genesis registry, so it decides which networks the artifact suits. + # Job-level: five steps name the file derived from it and must not disagree. + env: + DOTNS_TLD: test steps: # On workflow_dispatch the tag does not exist yet; the release step creates it # from the branch this run was started on. Read through an env var rather than @@ -76,9 +80,7 @@ jobs: with: submodules: recursive - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + - uses: ./.github/actions/setup-foundry - uses: oven-sh/setup-bun@v2 with: @@ -106,6 +108,22 @@ jobs: FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" run: forge test -vv + # pallet-revive genesis, so a chain can carry DotNS from block zero rather than + # deploying it afterwards. Built here rather than downstream: this repo owns the + # contracts, the deploy scripts and the CREATE3 factory key that fixes every address, + # and the artifact then ships from the same commit as the ABIs beside it. + # + # The owner key is taken from DOTNS_ADMIN_KEY if set, otherwise derived from + # DOTNS_ADMIN_MNEMONIC. It ends up owning the registry, resolvers, registrar, store factory + # and beacons in the genesis storage, so the script refuses to run without one. + - name: Build pallet-revive genesis + env: + FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" + FACTORY_DEPLOYER_KEY: ${{ secrets.FACTORY_DEPLOYER_KEY }} + DOTNS_ADMIN_KEY: ${{ secrets.DOTNS_ADMIN_KEY }} + DOTNS_ADMIN_MNEMONIC: ${{ secrets.DOTNS_ADMIN_MNEMONIC }} + run: bash scripts/genesis/build-genesis.sh release + - name: Extract ABIs run: | mkdir -p release/abis @@ -140,7 +158,8 @@ jobs: run: | TAG="$RELEASE_TAG" cd release - zip -r "../dotns-abis-${TAG}.zip" abis/ deployments.json release-manifest.json + zip -r "../dotns-abis-${TAG}.zip" abis/ deployments.json release-manifest.json \ + "dotns-genesis-${DOTNS_TLD}.json" - name: Generate release body env: @@ -195,6 +214,17 @@ jobs: echo "" echo "- **Addresses:** [deployments.json]($ASSET_BASE/deployments.json)" echo "- **Release contents:** [release-manifest.json]($ASSET_BASE/release-manifest.json)" + + # The genesis is TLD-specific, so the body has to say which TLD it carries — + # a reader choosing an artifact for their chain needs that before they download it. + echo "" + echo "### Genesis" + echo "" + echo "Pallet-revive genesis state, for a chain that should carry DotNS from block zero" + echo "rather than deploying it afterwards." + echo "" + echo "- [dotns-genesis-${DOTNS_TLD}.json]($ASSET_BASE/dotns-genesis-${DOTNS_TLD}.json) — registry initialised with the **\`.${DOTNS_TLD}\` TLD**, so it suits **${DOTNS_TLD} networks only** (this is what previewnet uses). Not for a production chain." + echo "- Contract addresses are in [deployments.json]($ASSET_BASE/deployments.json); they derive from the CREATE3 factory, not from the TLD." } >> release-body.md # `gh release view` with no tag resolves through /releases/latest, which skips @@ -222,6 +252,7 @@ jobs: release/abis/*.json release/deployments.json release/release-manifest.json + release/dotns-genesis-${{ env.DOTNS_TLD }}.json body_path: release-body.md draft: true prerelease: false @@ -239,6 +270,7 @@ jobs: echo "dotns-abis-${TAG}.zip" echo "deployments.json" echo "release-manifest.json" + echo "dotns-genesis-${DOTNS_TLD}.json" } | sort > "$RUNNER_TEMP/wanted-assets.txt" if ! diff -u "$RUNNER_TEMP/wanted-assets.txt" "$RUNNER_TEMP/actual-assets.txt"; then echo "::error::Draft release $TAG does not match the expected asset set; delete the draft and re-run." diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index 6bd128959..0943fafdf 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -53,9 +53,7 @@ jobs: with: submodules: recursive - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + - uses: ./.github/actions/setup-foundry - uses: oven-sh/setup-bun@v2 with: diff --git a/.github/workflows/slither.yaml b/.github/workflows/slither.yaml index f76282800..ab47694cd 100644 --- a/.github/workflows/slither.yaml +++ b/.github/workflows/slither.yaml @@ -35,9 +35,7 @@ jobs: with: submodules: recursive - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: stable + - uses: ./.github/actions/setup-foundry - uses: actions/setup-python@v5 with: diff --git a/scripts/genesis/build-genesis.sh b/scripts/genesis/build-genesis.sh new file mode 100755 index 000000000..8bde4c5c0 --- /dev/null +++ b/scripts/genesis/build-genesis.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# DotNS pallet-revive genesis builder +# +# Deploys the full DotNS contract set to a local anvil, then extracts the +# resulting EVM state (bytecodes + storage) as a pallet-revive GenesisConfig +# artifact. A chain can then carry DotNS from genesis instead of deploying it +# after the fact. +# +# Output, into $1 (default ./release): +# dotns-genesis-.json pallet-revive GenesisConfig accounts +# +# The TLD is in the filename because it is baked into the registry initialiser: a genesis built +# with DOTNS_TLD=test suits a test network and nothing else, and a file called plainly +# `dotns-genesis.json` is how a test registry ends up on a chain that wanted a real one. +# +# Addresses are NOT emitted here. `release-metadata.mjs build` already publishes deployments.json +# from the committed manifests (#242), and a second copy in the same release is the duplication +# that PR deliberately removed. +# +# Requires: forge, anvil, cast (foundry), node >= 18, jq — all already present +# in the release workflow. Run it after `forge build`, from the repo root. +# ============================================================================= + +OUT="${1:-./release}" +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" + +ANVIL_PORT="${ANVIL_PORT:-28545}" +RPC_URL="http://127.0.0.1:$ANVIL_PORT" +ANVIL_STATE="$OUT/anvil-state.json" +GENESIS_OUT="" # set once DOTNS_TLD is validated, below + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOYMENT_FILE="deployments/localhost/31337.json" +CANONICAL_MANIFEST="deployments/paseo-assethub/420420417.json" + +# Who OWNS the contracts in the genesis state (REQUIRED, one of the three below). +# +# No DotNS *address* depends on this key — with CREATE3 the addresses are a pure +# function of the factory (see FACTORY_DEPLOYER_KEY below, which owns only the +# factory) — but every ownership and role assignment written into genesis storage +# does: this key ends up owning the registry, the resolvers, the registrar, the +# store factory and the beacons. +# +# Accepted, in order of precedence: +# DOTNS_ADMIN_KEY a raw private key — the admin credential this repo already holds +# DOTNS_ADMIN_MNEMONIC the admin mnemonic; index $DOTNS_ADMIN_INDEX (default 0) +# +# Deliberately NOT accepted: DOTNS_MNEMONIC. That is the operational credential the +# whitelist workflows drive the `dotns` CLI with, not the contract admin, and quietly +# making it the owner of every contract in a genesis would be a hard mistake to spot. +# Not DEPLOYER_KEY: that name is dotns-releases' own secret, and accepting it here +# would make which key owns a published genesis depend on which repo the build ran in. +ADMIN_KEY="${DOTNS_ADMIN_KEY:-}" + +# Single-purpose CREATE3 factory deployer key (REQUIRED). Every DotNS address is +# a pure function of the Create3Factory address, and the factory address is +# keccak(deployer, nonce 0) — see "Deterministic addresses (CREATE3)" in +# DEPLOYMENTS.md. Deploying the factory from this key as its first transaction is +# what makes the genesis addresses equal the live ones, which is asserted below. +FACTORY_DEPLOYER_KEY="${FACTORY_DEPLOYER_KEY:-}" + +# WHITELIST_OPERATOR_ROLE recipient, required by the deploy scripts. Manages the +# public-controller whitelist on the registrar. Defaults to the deployer. +WHITELIST_OPERATOR="${WHITELIST_OPERATOR:-}" + +# TLD the genesis registry initialises with. Required by DeployCore, which reads +# it to build the registry initialiser and reverts when unset. Validated as a +# single lowercase DNS label so a bad value fails here rather than deep inside a +# forge stage. This does not affect any address — only registry storage — but it +# does decide which networks the resulting genesis is fit for, so it ends up in +# the output filename. +export DOTNS_TLD="${DOTNS_TLD:-test}" +if ! printf '%s' "$DOTNS_TLD" | grep -Eq '^[a-z]{2,63}$'; then + echo "Error: DOTNS_TLD ('$DOTNS_TLD') must be 2 to 63 lowercase ASCII letters (a-z)" >&2 + exit 1 +fi + +GENESIS_OUT="$OUT/dotns-genesis-$DOTNS_TLD.json" + +echo "=== DotNS genesis builder ===" +echo "Building a genesis for TLD .$DOTNS_TLD -> $(basename "$GENESIS_OUT")" +echo "" + +# ---- Pre-flight ---- +for tool in forge anvil cast node jq curl; do + command -v "$tool" >/dev/null 2>&1 || { echo "Error: $tool is not on PATH" >&2; exit 1; } +done + +# Needs cast, so it happens after the check above. +if [ -z "$ADMIN_KEY" ] && [ -n "${DOTNS_ADMIN_MNEMONIC:-}" ]; then + ADMIN_KEY="$(cast wallet private-key --mnemonic "$DOTNS_ADMIN_MNEMONIC" "${DOTNS_ADMIN_INDEX:-0}")" + echo "Owner key derived from DOTNS_ADMIN_MNEMONIC, index ${DOTNS_ADMIN_INDEX:-0}." +fi + +if [ -z "$ADMIN_KEY" ]; then + cat >&2 <<'MSG' +Error: no owner key. Set DOTNS_ADMIN_KEY or DOTNS_ADMIN_MNEMONIC. + +Whichever is given becomes the owner of every DotNS contract in the genesis +state, so this build refuses to fall back to a public dev key. +MSG + exit 1 +fi +if [ -z "$FACTORY_DEPLOYER_KEY" ]; then + cat >&2 <<'MSG' +Error: FACTORY_DEPLOYER_KEY is required. + +Every genesis address derives from the Create3Factory deployed by this key, so +without it the artifact would carry a different address set than the live +deployment and the parity check below would fail anyway. +MSG + exit 1 +fi + +DEPLOYER_ADDR="$(cast wallet address --private-key "$ADMIN_KEY")" +export WHITELIST_OPERATOR="${WHITELIST_OPERATOR:-$DEPLOYER_ADDR}" +echo "Contract owner: $DEPLOYER_ADDR" +echo "Whitelist operator: $WHITELIST_OPERATOR" +echo "TLD: .$DOTNS_TLD" +echo "" + +# ---- anvil ---- +cleanup() { + if [ -n "${ANVIL_PID:-}" ]; then kill "$ANVIL_PID" 2>/dev/null || true; fi +} +trap cleanup EXIT + +echo "Starting anvil on port $ANVIL_PORT..." +# The wait loop after shutdown only tests that this file is non-empty, so a dump left by +# an interrupted run would satisfy it instantly and be parsed as if it were this one. +rm -f "$ANVIL_STATE" +# Kept out of $OUT: that directory is what the release uploads, and a debug log has no +# business being a candidate for it. Left behind on purpose — it is the only record of +# why a stage failed. +ANVIL_LOG="${TMPDIR:-/tmp}/dotns-genesis-anvil.log" +# Not --silent: the redirect below already keeps the console clean, and --silent would +# leave $ANVIL_LOG empty — which is the only thing the failure path above has to print. +anvil --port "$ANVIL_PORT" --dump-state "$ANVIL_STATE" > "$ANVIL_LOG" 2>&1 & +ANVIL_PID=$! + +# Probe over plain JSON-RPC rather than with `cast`. cast parses foundry.toml on every +# invocation, so a config key its build does not recognise makes it exit non-zero before it +# ever reaches the network — which reads here as "anvil never started" while anvil is in fact +# healthy. Seen for real with `ignored_error_codes` on a foundry version that predates one of +# the entries. curl has no opinion about foundry config. +ready() { + curl -sf -m 3 -X POST "$RPC_URL" \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \ + 2>/dev/null | grep -q '"result"' +} +for _ in $(seq 1 60); do + if ready; then break; fi + # Fail fast if it died rather than waiting out the whole timeout on a corpse. + if ! kill -0 "$ANVIL_PID" 2>/dev/null; then break; fi + sleep 1 +done +if ! ready; then + echo "Error: anvil did not become ready on $RPC_URL" >&2 + echo "--- anvil output ---" >&2 + cat "$ANVIL_LOG" >&2 || true + exit 1 +fi +echo " ✓ anvil ready" +echo "" + +# ---- Deploy ---- +# Multi-stage: DotnsDeployer is split into five stages so each `forge script` +# invocation is its own EVM simulation, capping forge's per-tx memory accounting +# (the single-shot variant hits MemoryOOG with the current contract set). +# +# One `forge build` BEFORE the loop, and none inside it: otherwise each stage's +# `forge script` emits its own build-info file and OpenZeppelin's upgrade-safety +# validator fails with "multiple contracts found". The clean build is repeated +# here rather than inherited from an earlier step so this script is correct on +# its own, whatever ran before it. +echo "Building contracts..." +forge clean +forge build +echo "" + +# Deploy the Create3Factory from the single-purpose key at nonce 0 so it lands on +# the canonical address, then hand it to the stages via CREATE3_FACTORY (read by +# BaseDeployer._configuredCreate3Factory). +FACTORY_DEPLOYER="$(cast wallet address --private-key "$FACTORY_DEPLOYER_KEY")" +CREATE3_FACTORY="$(cast compute-address "$FACTORY_DEPLOYER" --nonce 0 | awk '{print $NF}')" +export CREATE3_FACTORY +echo "=== Create3Factory from $FACTORY_DEPLOYER -> $CREATE3_FACTORY ===" + +# Neither key is a prefunded anvil account; give both 1000 ETH. +cast rpc anvil_setBalance "$FACTORY_DEPLOYER" 0x3635C9ADC5DEA00000 --rpc-url "$RPC_URL" >/dev/null +cast rpc anvil_setBalance "$DEPLOYER_ADDR" 0x3635C9ADC5DEA00000 --rpc-url "$RPC_URL" >/dev/null + +forge script scripts/deploy/DeployCreate3Factory.s.sol:DeployCreate3Factory \ + --rpc-url "$RPC_URL" \ + --private-key "$FACTORY_DEPLOYER_KEY" \ + --sender "$FACTORY_DEPLOYER" \ + --broadcast \ + --slow + +# The stage list is read out of scripts/deploy/run.sh rather than repeated here. A copy would +# go stale the first time a stage is added or reordered, and the failure mode is the worst kind: +# a genesis that builds cleanly and is quietly missing whatever the new stage wired up. If the +# array cannot be parsed the build stops instead of guessing. +# `|| true` matters: grep exits 1 when it matches nothing, and under `set -o pipefail` +# that killed the assignment before the check below could explain what went wrong. +STAGES=$(sed -n '/^stages=(/,/^)/p' scripts/deploy/run.sh | sed '1d;$d' | tr -d ' \t' | grep -v '^$' || true) +if [ -z "$STAGES" ]; then + echo "Error: could not read the stage list from scripts/deploy/run.sh." >&2 + echo " That file defines the deploy stages; this build follows it rather than" >&2 + echo " keeping a second copy. Check whether its \`stages=(...)\` array moved." >&2 + exit 1 +fi +echo "Stages, from scripts/deploy/run.sh: $(printf '%s ' $STAGES)" + +# NOTE for anyone collapsing this into `bun run deploy:all` (which does the same deploy, with +# a factory-exists check and an EXPECTED_CREATE3_FACTORY guard this does not have): that flow +# takes its pipeline signer from PRIVATE_KEY, and .github/workflows/deploy-contracts.yml sets +# it to anvil test account 7 — a documented public key. That is correct there, because that +# job only checks addresses, which do not depend on the signer. It is NOT correct here: this +# signer becomes the owner of every contract in the genesis storage, so it must stay the admin +# key. Substituting the test key would hand control of a published genesis to a key printed in +# Foundry's docs. +echo "=== Baking TLD .$DOTNS_TLD into the genesis registry ===" +for stage in $STAGES; do + echo " === $stage ===" + forge script "scripts/deploy/${stage}.s.sol:${stage}" \ + --rpc-url "$RPC_URL" \ + --private-key "$ADMIN_KEY" \ + --sender "$DEPLOYER_ADDR" \ + --broadcast \ + --slow +done +echo "" + +[ -f "$DEPLOYMENT_FILE" ] \ + || { echo "Error: no deployment manifest at $DEPLOYMENT_FILE — a stage failed above" >&2; exit 1; } + +# ---- Address parity with the live deployment ---- +# The committed manifest is the only source of truth for DotNS addresses, so this is a +# hard failure, not a warning: a genesis built from a different factory key carries a +# different address set, and nothing downstream would notice. +# +# Underscore-prefixed keys are metadata rather than contracts (`_seed`, and +# `_deployedFrom` once dotns-releases#12 lands), so they are filtered by prefix. +# +# Compared entry-by-entry: the local manifest may carry newer contracts not yet deployed +# live, so only the canonical entries are asserted. +# +if [ ! -f "$CANONICAL_MANIFEST" ]; then + echo "Error: no canonical manifest at $CANONICAL_MANIFEST." >&2 + echo " Addresses cannot be verified, so the genesis would ship unchecked." >&2 + exit 1 +fi + +echo "Verifying address parity with $CANONICAL_MANIFEST..." +EXPECTED=$(jq -r 'with_entries(select(.key | startswith("_") | not)) | to_entries | sort_by(.key)[] + | "\(.key)=\(.value | ascii_downcase)"' "$CANONICAL_MANIFEST") +ACTUAL=$(jq -r --slurpfile canon "$CANONICAL_MANIFEST" ' + with_entries(select(.key | startswith("_") | not)) + | with_entries(select($canon[0][.key] != null)) + | to_entries | sort_by(.key)[] + | "\(.key)=\(.value | ascii_downcase)"' "$DEPLOYMENT_FILE") +if ! diff -u -L "expected ($CANONICAL_MANIFEST)" -L "actual (this build)" \ + <(printf '%s\n' "$EXPECTED") <(printf '%s\n' "$ACTUAL"); then + { + echo "Error: the deploy no longer reproduces the committed address set." + echo " Lines marked -/+ above differ from $CANONICAL_MANIFEST." + echo "" + echo " Two things cause this:" + echo " * FACTORY_DEPLOYER_KEY is not the key the live factory came from." + echo " Every DotNS address derives from the factory address, which is" + echo " keccak(deployer, nonce 0), so a different key moves all of them." + echo " * A salt or a label moved for one contract. Only that contract's" + echo " line differs; update $CANONICAL_MANIFEST, or restore the label." + } >&2 + exit 1 +fi +echo " ✓ all live addresses reproduced ($(jq 'with_entries(select(.key | startswith("_") | not)) | length' "$CANONICAL_MANIFEST") contracts)" +echo "" + +# ---- Dump anvil state and extract ---- +# anvil writes --dump-state only on shutdown. +echo "Stopping anvil to flush its state dump..." +kill "$ANVIL_PID" 2>/dev/null || true +# Wait for the process to actually exit, not just for the file to appear. The dump is +# several MB and written during shutdown, so `-s` alone goes true on the first byte and +# the extractor would parse a truncated file. +wait "$ANVIL_PID" 2>/dev/null || true +for _ in $(seq 1 30); do + [ -s "$ANVIL_STATE" ] && break + sleep 1 +done +ANVIL_PID="" +[ -s "$ANVIL_STATE" ] \ + || { echo "Error: anvil wrote no state to $ANVIL_STATE" >&2; exit 1; } + +node "$SCRIPT_DIR/extract-genesis.mjs" \ + --state "$ANVIL_STATE" \ + --deployments "$DEPLOYMENT_FILE" \ + --output "$GENESIS_OUT" \ + --tld "$DOTNS_TLD" + +rm -f "$ANVIL_STATE" + +echo "" +echo "=== Done ===" +echo " $GENESIS_OUT" +echo " registry TLD .$DOTNS_TLD — this genesis is only for a network that wants that TLD" +echo " contract addresses: see deployments.json, published by release-metadata.mjs" diff --git a/scripts/genesis/extract-genesis.mjs b/scripts/genesis/extract-genesis.mjs new file mode 100644 index 000000000..23211f57a --- /dev/null +++ b/scripts/genesis/extract-genesis.mjs @@ -0,0 +1,331 @@ +#!/usr/bin/env node + +/** + * DotNS Genesis State Extractor + * + * Reads an anvil state dump and a DotNS deployments file, extracts all + * contract bytecodes and storage, and outputs a JSON file compatible with + * pallet-revive's GenesisConfig format. + * + * The deployments manifest only names the contracts the deploy pipeline wants + * consumers to know about; those contracts point at further contracts that are + * just as load-bearing (UUPS implementations behind a proxy, store + * implementations behind an UpgradeableBeacon). Anything reachable from a named + * contract through storage is pulled in transitively, and the result is checked + * for dangling pointers before it is written — a genesis that names a contract + * it does not carry the code for looks fine but reverts at the first call. + * + * Zero external dependencies — uses only Node.js built-ins. + * + * Usage: + * node scripts/genesis/extract-genesis.mjs \ + * --state ./release/anvil-state.json \ + * --deployments ./deployments/localhost/31337.json \ + * --output ./release/dotns-genesis-test.json + */ + +import { readFileSync, writeFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { resolve } from "path"; + +// ============================================================================= +// EIP-1967 implementation storage slot +// ============================================================================= +export const EIP1967_IMPL_SLOT = + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + +// An UpgradeableBeacon is not a proxy and does not use the EIP-1967 slot: Ownable puts +// _owner in slot 0 and the beacon puts _implementation in slot 1. +export const BEACON_IMPL_SLOT = "0x" + "0".repeat(63) + "1"; + +const ZERO_ADDR = "0x" + "0".repeat(40); + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Pad a hex string to N bytes (left-pad with zeros) */ +export function padHex(hex, bytes) { + const clean = hex.startsWith("0x") ? hex.slice(2) : hex; + return "0x" + clean.padStart(bytes * 2, "0"); +} + +/** Normalize address to checksumless lowercase (manifests are checksummed, dumps are not) */ +export function normalizeAddr(addr) { + return addr.toLowerCase(); +} + +/** + * Read a storage word as a candidate address: the low 20 bytes, or null for zero. + * + * Deliberately does NOT require the top 12 bytes to be zero. A pointer Solidity + * packed beside a smaller value in the same slot has a dirty prefix and would be + * missed. Every candidate is filtered by `referencedContracts` for actual code, so + * a word that is really a hash or a balance is dropped there — the cost of being + * permissive here is a few extra lookups, and the cost of being strict is a + * silently absent implementation contract. + */ +export function addressFromWord(word) { + if (typeof word !== "string") return null; + const clean = padHex(word, 32).slice(2); + if (clean.length !== 64) return null; + const addr = "0x" + clean.slice(24); + return addr === ZERO_ADDR ? null : addr; +} + +/** + * Every contract that `acct` points at through its storage. Words that resolve + * to an EOA (an owner, an operator, a role holder) are not references we can + * follow — only accounts that carry code are. + */ +export function referencedContracts(acct, stateAccounts) { + const refs = []; + for (const [slot, word] of Object.entries(acct?.storage ?? {})) { + const addr = addressFromWord(word); + if (!addr) continue; + const target = stateAccounts[addr]; + if (!target || !target.code || target.code === "0x") continue; + refs.push({ address: addr, slot: padHex(slot, 32) }); + } + return refs; +} + +/** Human-readable name for a contract discovered through `slot` of `parent` */ +function refName(parentName, slot) { + if (slot === EIP1967_IMPL_SLOT) return `${parentName}_Implementation`; + const index = BigInt(slot); + return index < 1024n + ? `${parentName}_Ref@slot${index}` + : `${parentName}_Ref@${slot.slice(0, 10)}`; +} + +/** + * Walk out from the named contracts to everything they reach through storage. + * Returns a Map of lowercase address -> name, manifest entries first. + */ +export function collectAccounts(stateAccounts, contractEntries, log = () => {}) { + const found = new Map(); + const queue = []; + + for (const [name, address] of contractEntries) { + const addr = normalizeAddr(address); + if (found.has(addr)) continue; + found.set(addr, name); + queue.push(addr); + } + + while (queue.length > 0) { + const addr = queue.shift(); + const acct = stateAccounts[addr]; + if (!acct) continue; + + for (const { address, slot } of referencedContracts(acct, stateAccounts)) { + if (found.has(address)) continue; + const name = refName(found.get(addr), slot); + found.set(address, name); + queue.push(address); + log(` Discovered ${name}: ${address}`); + } + } + + return found; +} + +/** + * Accounts whose implementation pointer targets something the genesis does not carry. + * + * A different predicate from the one that built the account set: referencedContracts only + * follows a pointer whose target already has code, so an implementation that failed to + * deploy is dropped silently and nothing downstream notices. + * + * Checks the EIP-1967 slot on every account, and slot 1 on the beacons named in `beacons`. + * Beacons need naming because slot 1 holds an ordinary field on anything else, and a + * pointer-shaped word there is usually an EOA — an owner or an operator — which is not a + * missing implementation. The original previewnet failure was a beacon, so covering only + * EIP-1967 would leave exactly that case unguarded. + */ +export function findMissingImplementations(accounts, beacons = new Set()) { + const present = new Set(accounts.map((a) => normalizeAddr(a.address))); + const broken = []; + for (const acct of accounts) { + const address = normalizeAddr(acct.address); + const slots = beacons.has(address) + ? [EIP1967_IMPL_SLOT, BEACON_IMPL_SLOT] + : [EIP1967_IMPL_SLOT]; + for (const slot of slots) { + const word = acct.storage?.[slot]; + if (!word) continue; + const impl = addressFromWord(word); + if (!impl || present.has(impl)) continue; + broken.push({ proxy: address, impl }); + } + } + return broken; +} + +/** + * Build the pallet-revive genesis accounts from an anvil state dump. + * `deployments` is the dotns manifest: a flat `{ name: address }` map. + */ +export function buildGenesis(stateData, deployments, log = () => {}, tld) { + // dotns multi-stage deploy writes a flat `{ name: addr, _seed: 0x0 }` manifest + // (single-shot DotnsDeployer used to nest under `.contracts`). Underscore-prefixed + // keys are metadata, not contracts: `_seed` from BaseDeployer's vm.serializeAddress, + // and `_deployedFrom` once dotns-releases#12 lands. Filtering the prefix rather than + // the one known name keeps this working when the next one is added. + const contractEntries = Object.entries(deployments).filter( + ([name]) => !name.startsWith("_") + ); + const stateAccounts = stateData.accounts; + + log(`Found ${contractEntries.length} deployed contracts in the manifest`); + log(`State dump contains ${Object.keys(stateAccounts).length} accounts`); + + const accountsToExtract = collectAccounts(stateAccounts, contractEntries, log); + + log( + `Total accounts to extract: ${accountsToExtract.size} (${contractEntries.length} named + ${accountsToExtract.size - contractEntries.length} reachable through storage)` + ); + log(""); + + // Extract code + storage for every account + const genesisAccounts = []; + + // A contract the manifest names is load-bearing: shipping a genesis without it + // produces a chain that looks correctly configured and reverts at the first call. + // Anything reached transitively is already known to carry code (referencedContracts + // filters on it), so only these can legitimately be absent — and must not be. + const namedAddresses = new Set( + contractEntries.map(([, address]) => normalizeAddr(address)) + ); + + for (const [address, name] of accountsToExtract) { + const acct = stateAccounts[address]; + if (!acct) { + if (namedAddresses.has(address)) { + throw new Error( + `${name} @ ${address} is named in the manifest but absent from the state dump. ` + + `The deploy did not produce it, or the dump is from a different run.` + ); + } + log(` ${name} @ ${address} ... not in state dump, skipping`); + continue; + } + + const hasCode = acct.code && acct.code !== "0x"; + const storage = {}; + + if (acct.storage) { + for (const [slot, value] of Object.entries(acct.storage)) { + storage[padHex(slot, 32)] = padHex(value, 32); + } + } + + const slotCount = Object.keys(storage).length; + log( + ` ${name} @ ${address} ... ${hasCode ? "contract" : "EOA"}, ${slotCount} storage slots` + ); + + if (!hasCode) { + if (namedAddresses.has(address)) { + throw new Error( + `${name} @ ${address} is named in the manifest but carries no code in the ` + + `state dump. Its deploy stage did not run, or it reverted.` + ); + } + log(` no code at ${address}, skipping`); + continue; + } + + // pallet-revive genesis Account format + // contract_data is #[serde(flatten)] so code/storage are top-level fields + // balance is sp_core::U256, whose serde impl comes from impl_serde and is HEX + // ("0x0"). A decimal string here is not rejected, it is reinterpreted as hex, + // so a balance of 16 would silently become 22. + genesisAccounts.push({ + address, + balance: "0x" + BigInt(acct.balance).toString(16), + nonce: acct.nonce, + code: acct.code, + storage, + }); + } + + // Beacons are recognised by manifest name; both real ones end in "Beacon". + const beacons = new Set( + contractEntries + .filter(([name]) => name.endsWith("Beacon")) + .map(([, address]) => normalizeAddr(address)) + ); + const brokenProxies = findMissingImplementations(genesisAccounts, beacons); + if (brokenProxies.length > 0) { + const detail = brokenProxies + .map((b) => ` ${b.proxy} -> implementation ${b.impl} is not in the genesis`) + .join("\n"); + throw new Error( + `Genesis would ship ${brokenProxies.length} proxy/proxies with no implementation:\n` + + `${detail}\nEvery call through such a proxy reverts on the live chain.` + ); + } + + // The manifest is what consumers resolve against, so assert the output covers all of it. + // Holds by construction today, since the loop throws on anything named it cannot extract — + // checked anyway because it is the guarantee the artifact is judged on. + const extracted = new Set(genesisAccounts.map((a) => normalizeAddr(a.address))); + const missing = contractEntries + .filter(([, address]) => !extracted.has(normalizeAddr(address))) + .map(([name, address]) => ` ${name} @ ${normalizeAddr(address)}`); + if (missing.length > 0) { + throw new Error( + `Genesis is missing ${missing.length} contract(s) named in the manifest:\n` + + missing.join("\n") + ); + } + + // The TLD is in the filename as documentation; it is in here so a consumer can assert it. + // A rename defeats a filename, and the registry this genesis carries only suits one TLD. + return tld ? { tld, accounts: genesisAccounts } : { accounts: genesisAccounts }; +} + +// ============================================================================= +// CLI +// ============================================================================= + +function getArg(args, name) { + const idx = args.indexOf(`--${name}`); + if (idx === -1 || idx + 1 >= args.length) { + console.error(`Missing required argument: --${name}`); + process.exit(1); + } + return args[idx + 1]; +} + +function main() { + const args = process.argv.slice(2); + const statePath = getArg(args, "state"); + const deploymentsPath = getArg(args, "deployments"); + const outputPath = getArg(args, "output"); + const tld = getArg(args, "tld"); + + const stateData = JSON.parse(readFileSync(statePath, "utf8")); + const deployments = JSON.parse(readFileSync(deploymentsPath, "utf8")); + + console.log(`Reading manifest ${deploymentsPath}`); + const genesisConfig = buildGenesis( + stateData, + deployments, + (msg) => console.log(msg), + tld + ); + + writeFileSync(outputPath, JSON.stringify(genesisConfig, null, 2)); + console.log( + `\nWritten ${genesisConfig.accounts.length} genesis accounts to ${outputPath}` + ); +} + +const invokedDirectly = + process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + main(); +} diff --git a/scripts/genesis/extract-genesis.test.mjs b/scripts/genesis/extract-genesis.test.mjs new file mode 100644 index 000000000..edd384797 --- /dev/null +++ b/scripts/genesis/extract-genesis.test.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node --test + +/** + * Unit tests for the DotNS genesis extractor. + * + * The regression these guard against shipped once: the extractor followed only + * the EIP-1967 proxy slot, so the two store implementations behind the + * UpgradeableBeacons were left out of dotns-genesis.json. previewnet booted + * with beacons pointing at empty addresses and every store creation reverted + * with ERC1967InvalidImplementation. + * + * Run: node --test scripts/genesis/extract-genesis.test.mjs + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + EIP1967_IMPL_SLOT, + addressFromWord, + buildGenesis, + collectAccounts, + findMissingImplementations, + BEACON_IMPL_SLOT, +} from "./extract-genesis.mjs"; + +// ============================================================================= +// Fixture: a miniature anvil dump shaped like the real DotNS deploy +// ============================================================================= + +const REGISTRY = "0x00000000000000000000000000000000000000a1"; +const REGISTRY_IMPL = "0x00000000000000000000000000000000000000a2"; +const BEACON = "0x00000000000000000000000000000000000000b1"; +const STORE_IMPL = "0x00000000000000000000000000000000000000b2"; +const STORE_IMPL_DEP = "0x00000000000000000000000000000000000000b3"; +const FACTORY = "0x00000000000000000000000000000000000000c1"; +const OWNER_EOA = "0x00000000000000000000000000000000000000ee"; +const UNRELATED = "0x00000000000000000000000000000000000000ff"; + +const slot = (n) => "0x" + n.toString(16).padStart(64, "0"); +const word = (addr) => "0x" + addr.replace(/^0x/, "").padStart(64, "0"); + +function fixture() { + return { + accounts: { + [REGISTRY]: { + balance: "0x0", + nonce: 1, + code: "0xfe01", + storage: { + // UUPS proxy pointing at its implementation + [EIP1967_IMPL_SLOT]: word(REGISTRY_IMPL), + // an owner: an address, but an EOA, so not a reference to follow + [slot(0)]: word(OWNER_EOA), + // a hash-shaped word that must not be mistaken for a pointer + [slot(1)]: + "0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300", + // a pointer to an address that has no code — nothing to extract + [slot(2)]: word("0x00000000000000000000000000000000deadbeef"), + }, + }, + [REGISTRY_IMPL]: { balance: "0x0", nonce: 1, code: "0xfe02", storage: {} }, + [BEACON]: { + balance: "0x0", + nonce: 1, + code: "0xfe03", + storage: { + // UpgradeableBeacon: owner in slot 0, implementation in slot 1 — + // a plain slot, not the EIP-1967 one + [slot(0)]: word(FACTORY), + [slot(1)]: word(STORE_IMPL), + }, + }, + [STORE_IMPL]: { + balance: "0x0", + nonce: 1, + code: "0xfe04", + // unpadded slot and value, as anvil writes them + storage: { "0x0": "0xb3" }, + }, + [STORE_IMPL_DEP]: { balance: "0x0", nonce: 1, code: "0xfe05", storage: {} }, + [FACTORY]: { + balance: "0x2a", + nonce: 5, + code: "0xfe06", + storage: { [slot(0)]: word(OWNER_EOA) }, + }, + [OWNER_EOA]: { balance: "0xde0b6b3a7640000", nonce: 3, code: "0x" }, + [UNRELATED]: { balance: "0x0", nonce: 1, code: "0xfe07", storage: {} }, + }, + }; +} + +const MANIFEST = { + DotnsRegistry: REGISTRY, + StoreBeacon: BEACON, + StoreFactory: FACTORY, + _seed: "0x0000000000000000000000000000000000000000", +}; + +const addressesOf = (genesis) => genesis.accounts.map((a) => a.address).sort(); + +// ============================================================================= +// addressFromWord +// ============================================================================= + +test("addressFromWord reads the low 20 bytes, including a packed pointer", () => { + assert.equal(addressFromWord(word(STORE_IMPL)), STORE_IMPL); + assert.equal( + addressFromWord("0xb2"), + "0x" + "0".repeat(38) + "b2", + "anvil writes words unpadded, so a short word is still an address" + ); + assert.equal( + addressFromWord( + "0x000000000000000000000001" + STORE_IMPL.slice(2) + ), + STORE_IMPL, + "a pointer Solidity packed beside a bool in the same slot has a dirty high " + + "prefix; requiring zero there silently drops the implementation it points at" + ); + assert.equal(addressFromWord(word("0x" + "0".repeat(40))), null, "zero address"); + assert.equal(addressFromWord(undefined), null); +}); + +test("a hash-shaped word yields a candidate that is dropped for having no code", () => { + const hash = + "0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300"; + // addressFromWord is deliberately permissive, so the word does resolve... + assert.equal(addressFromWord(hash), "0x" + hash.slice(26)); + + // ...and referencedContracts is what discards it, because nothing lives there. + const state = fixture(); + state.accounts[REGISTRY].storage[slot(9)] = hash; + const genesis = buildGenesis(state, MANIFEST); + assert.ok( + !addressesOf(genesis).includes("0x" + hash.slice(26)), + "a word that is really a hash must not become a genesis account" + ); +}); + +// ============================================================================= +// Discovery +// ============================================================================= + +test("beacon implementations are extracted (the previewnet regression)", () => { + const genesis = buildGenesis(fixture(), MANIFEST); + const addresses = addressesOf(genesis); + + assert.ok( + addresses.includes(STORE_IMPL), + "the implementation behind an UpgradeableBeacon must be in the genesis — " + + "without it every store creation reverts with ERC1967InvalidImplementation" + ); +}); + +test("collectAccounts names what it discovers and how it got there", () => { + const found = collectAccounts(fixture().accounts, Object.entries(MANIFEST)); + + assert.equal(found.get(REGISTRY), "DotnsRegistry"); + assert.equal(found.get(REGISTRY_IMPL), "DotnsRegistry_Implementation"); + assert.equal(found.get(STORE_IMPL), "StoreBeacon_Ref@slot1"); + assert.equal(found.get(STORE_IMPL_DEP), "StoreBeacon_Ref@slot1_Ref@slot0"); +}); + +test("extraction reaches transitively and stops at what is actually referenced", () => { + const genesis = buildGenesis(fixture(), MANIFEST); + const addresses = addressesOf(genesis); + + assert.deepEqual( + addresses, + [REGISTRY, REGISTRY_IMPL, BEACON, STORE_IMPL, STORE_IMPL_DEP, FACTORY].sort(), + "manifest entries plus every contract reachable from them" + ); + assert.ok(!addresses.includes(OWNER_EOA), "EOAs are not contracts"); + assert.ok( + !addresses.includes(UNRELATED), + "contracts nothing points at stay out of the genesis" + ); +}); + +test("the _seed sentinel is not treated as a contract", () => { + const genesis = buildGenesis(fixture(), MANIFEST); + assert.ok( + !genesis.accounts.some( + (a) => a.address === "0x0000000000000000000000000000000000000000" + ) + ); +}); + +test("a manifest contract missing from the state dump is fatal", () => { + // The manifest is the only source of truth for what consumers expect to find. + // Skipping produced a genesis that looked fine and reverted at the first call. + assert.throws( + () => + buildGenesis(fixture(), { + ...MANIFEST, + Ghost: "0x0000000000000000000000000000000000009999", + }), + /Ghost .* absent from the state dump/ + ); +}); + +test("a manifest contract present but codeless is fatal", () => { + const state = fixture(); + const bare = "0x0000000000000000000000000000000000008888"; + state.accounts[bare] = { balance: "0x0", nonce: 0, code: "0x", storage: {} }; + assert.throws( + () => buildGenesis(state, { ...MANIFEST, Bare: bare }), + /Bare .* carries no code/ + ); +}); + +test("underscore-prefixed manifest keys are metadata, not contracts", () => { + // _seed today, _deployedFrom once dotns-releases#12 lands. + const genesis = buildGenesis(fixture(), { + ...MANIFEST, + _seed: "0x0000000000000000000000000000000000000000", + _deployedFrom: "some-tag", + }); + assert.equal(genesis.accounts.length, 6); +}); + +// ============================================================================= +// Output shape +// ============================================================================= + +test("accounts carry padded storage, hex balance and nonce", () => { + const genesis = buildGenesis(fixture(), MANIFEST); + + const factory = genesis.accounts.find((a) => a.address === FACTORY); + assert.equal( + factory.balance, + "0x2a", + "balance is sp_core::U256, whose impl_serde serde impl is hex — a decimal " + + "string is not rejected but reinterpreted, so 42 would be read as 0x42" + ); + assert.equal(factory.nonce, 5); + assert.equal(factory.code, "0xfe06"); + + const storeImpl = genesis.accounts.find((a) => a.address === STORE_IMPL); + assert.deepEqual( + storeImpl.storage, + { [slot(0)]: word("0xb3") }, + "anvil's unpadded slots and values are padded to 32 bytes" + ); +}); + +// ============================================================================= +// The proxy/implementation guard +// ============================================================================= + +test("findMissingImplementations catches a proxy whose impl never deployed", () => { + // The blind spot this exists for: referencedContracts only follows a pointer whose + // target already has code, so an implementation that failed to deploy is dropped + // silently and nothing downstream would notice. + const ghost = "0x00000000000000000000000000000000000000dd"; // in no fixture + const accounts = [ + { + address: REGISTRY, + balance: "0x0", + nonce: 1, + code: "0xfe01", + storage: { [EIP1967_IMPL_SLOT]: word(ghost) }, + }, + ]; + + assert.deepEqual(findMissingImplementations(accounts), [ + { proxy: REGISTRY, impl: ghost }, + ]); +}); + +test("findMissingImplementations passes on a complete genesis", () => { + const genesis = buildGenesis(fixture(), MANIFEST); + assert.deepEqual(findMissingImplementations(genesis.accounts), []); +}); + +test("buildGenesis throws when a proxy implementation is absent", () => { + const state = fixture(); + // Point the registry proxy at an address that carries no code, so the collector + // never walks to it and only the independent check can notice. + state.accounts[REGISTRY].storage[EIP1967_IMPL_SLOT] = word( + "0x00000000000000000000000000000000000000dd" + ); + assert.throws( + () => buildGenesis(state, MANIFEST), + /proxy\/proxies with no implementation/ + ); +}); + +test("a beacon whose implementation is absent is fatal", () => { + // The previewnet regression was a beacon, not an EIP-1967 proxy. A beacon keeps its + // implementation in slot 1, so a guard that reads only the EIP-1967 slot cannot see this. + const state = fixture(); + delete state.accounts[STORE_IMPL]; + assert.throws(() => buildGenesis(state, MANIFEST), /no implementation/); +}); + +test("an EOA pointer in slot 1 of a non-beacon is not an error", () => { + // Slot 1 holds an ordinary field on anything that is not a beacon, and a pointer-shaped + // word there is usually an owner or operator. Flagging those would break every build. + const accounts = [ + { + address: FACTORY, + balance: "0x0", + nonce: 1, + code: "0xfe06", + storage: { [BEACON_IMPL_SLOT]: word(OWNER_EOA) }, + }, + ]; + assert.deepEqual(findMissingImplementations(accounts), []); + assert.deepEqual( + findMissingImplementations(accounts, new Set([FACTORY])), + [{ proxy: FACTORY, impl: OWNER_EOA }], + "named as a beacon, the same word IS a missing implementation" + ); +}); + +test("the artifact records its TLD, so a rename cannot hide it", () => { + const withTld = buildGenesis(fixture(), MANIFEST, () => {}, "test"); + assert.equal(withTld.tld, "test"); + assert.ok(Array.isArray(withTld.accounts)); + + const without = buildGenesis(fixture(), MANIFEST); + assert.ok(!("tld" in without), "omitted rather than null when not supplied"); +});