From 63dad8f76bebbd39e5f800e9d99da18d93568f2e Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 13:28:47 +0000 Subject: [PATCH 1/3] docs: cut agent context under the 4096-byte cap `rainix / static` fails on main and every open PR: the repo loaded 24227 bytes of agent context at the start of every session, 20131 over the 4096-byte cap. It now loads 1341. CLAUDE.md keeps only irreversible hazards and rulings whose rationale is not recoverable from the code. The `src/` layout exception and the fork-test RPC requirement move to `.claude/rules/` with `paths:` frontmatter, which loads on demand and is not charged. Everything else is discoverable from README.md, the NatSpec, foundry.toml and the workflows, and is deleted. `RegistryDeploySuites`' NatSpec pointed at the scoped exception "recorded in CLAUDE.md" and now names the rules file that holds it. `.claude` is excluded from the published package alongside CLAUDE.md, and covered by REUSE.toml the way `.github` is. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/rules/fork-tests.md | 24 ++ .claude/rules/src-layout-exception.md | 44 +++ .soldeerignore | 1 + CLAUDE.md | 456 ++------------------------ REUSE.toml | 1 + src/abstract/RegistryDeploySuites.sol | 2 +- 6 files changed, 90 insertions(+), 438 deletions(-) create mode 100644 .claude/rules/fork-tests.md create mode 100644 .claude/rules/src-layout-exception.md diff --git a/.claude/rules/fork-tests.md b/.claude/rules/fork-tests.md new file mode 100644 index 0000000..414e9b0 --- /dev/null +++ b/.claude/rules/fork-tests.md @@ -0,0 +1,24 @@ +--- +paths: + - "test/**/*.sol" + - "foundry.toml" +--- + +# Fork tests need every RPC endpoint, not just the one under test + +`RainDeployVerifyChain` forks every network in +`LibRainDeploy.supportedNetworks()`, so ALL of the `[rpc_endpoints]` aliases in +`foundry.toml` must resolve. They read from a gitignored `.env`: + +```bash +ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc +BASE_RPC_URL=https://mainnet.base.org +BASE_SEPOLIA_RPC_URL=https://sepolia.base.org +FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc +POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com +``` + +A missing or rate-limited endpoint fails as a `vm.createSelectFork` error. That +is a different failure from the `NotDeployedOnNetwork` a reachable network +raises, and only the second one is a statement about the deployment — do not +read a flaky endpoint as a missing deploy. diff --git a/.claude/rules/src-layout-exception.md b/.claude/rules/src-layout-exception.md new file mode 100644 index 0000000..933988b --- /dev/null +++ b/.claude/rules/src-layout-exception.md @@ -0,0 +1,44 @@ +--- +paths: + - "src/**/*.sol" + - "script/**/*.sol" + - "test/**/*.sol" + - "slither.config.json" +--- + +# `src/` holds the deploy machinery here, and that is a SCOPED exception + +`src/abstract/RainDeploy*.sol` and `src/abstract/RegistryDeploySuites.sol` are +test and script infrastructure, and they live in `src/` rather than `test/`. Two +reasons, and the second is the one that matters: + +1. `.soldeerignore` excludes `test/` from the published package, and a + downstream repo has to import all of this — its `script/Deploy.sol` inherits + `RainDeployBroadcast`, its test contracts inherit `RainDeployVerify*`. An + abstract in a path the package excludes is unusable by every consumer. +2. **This repo's PRODUCT is the deployment process.** Machinery for deploying, + and for verifying deployments, is not scaffolding that happens to live here — + it is the thing the package exists to publish. + +**Do not copy this into a consumer repo.** There, `src/` is the product — +tokens, vaults, a factory — and deploy verification is scaffolding around it, so +the usual convention stands unchanged: `test/src/**` mirrors `src/**`, and test +abstracts live under `test/`. The exception is earned by what this repo IS, and +a repo that merely USES this machinery has not earned it. + +Even here it is scoped to the deploy/verify abstracts and the suite declaration. +`src/concrete/AddressRegistry.sol` and `src/concrete/MigrationRegistry.sol` are +ordinary deployed contracts, tested from `test/src/concrete/` exactly as the +convention requires. + +## Why `slither.config.json` filters those files by name + +They are the only `src/` files it filters out. They are inherited by test +contracts and never deployed, so slither's detectors — all of which are about +deployed-code risk — have nothing to say about them beyond an abstract not +implementing its own virtuals, a cheatcode called in a loop, and (because +slither skips `test/` and `script/`) an abstract's virtuals having no caller. + +The filter matches those filenames exactly rather than the `src/abstract/` +prefix, so a file added there later — including a deployable one — is analysed +rather than silently exempted. Keep it that way when adding an abstract. diff --git a/.soldeerignore b/.soldeerignore index 9e90a54..de0c6b9 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -1,5 +1,6 @@ .DS_Store .audit +.claude .coderabbitai.yaml .git .github diff --git a/CLAUDE.md b/CLAUDE.md index a5bd4b2..5ba584b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,440 +3,22 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with -code in this repository. - -## Project Overview - -rain.deploy is a Solidity library for deploying Rain Protocol contracts via the -Zoltu deterministic deployment proxy to multiple EVM networks. It ensures -identical contract addresses across all supported chains (Arbitrum, Base, Base -Sepolia, Flare, Polygon) because the Zoltu proxy is deployed at the same address -on every chain and deploys with `CREATE2` over its calldata under a zero salt, -so a contract's address is a pure function of its creation code. - -## Build & Development - -This project uses **Foundry** (forge) for Solidity development and **Nix** for -environment management. - -```bash -# Enter the nix dev shell (provides forge and all tooling) -nix develop - -# Install the dependencies declared in foundry.toml (dependencies/ is -# gitignored, so this is required on a fresh checkout before anything builds) -nix develop -c forge soldeer install - -# Build -nix develop -c forge build - -# Run tests -nix develop -c forge test -vvv - -# Run a single test -nix develop -c forge test --match-test "testName" - -# Static analysis / linting -nix develop -c slither . -nix develop -c forge fmt --check -nix develop -c rainix-sol-single-contract - -# License/legal checks (REUSE compliance) -nix develop -c reuse lint -``` - -CI runs three matrix tasks: `rainix-sol-legal`, `rainix-sol-test`, -`rainix-sol-static`. Those are rainix reusable workflow names, not commands — -the block above is what they run. - -A fourth workflow, `Manual sol artifacts`, is `workflow_dispatch` only and is -the on-chain deploy — nothing automatic ever broadcasts. - -## RPC Configuration - -Fork tests require RPC endpoints defined in `.env` (gitignored): - -```bash -ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc -BASE_RPC_URL=https://mainnet.base.org -BASE_SEPOLIA_RPC_URL=https://sepolia.base.org -FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc -POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com -``` - -All five are needed: `RainDeployVerifyChain` forks every network in -`supportedNetworks()`, so a missing or rate-limited endpoint fails it. Those -failures are `vm.createSelectFork` errors, distinct from the -`NotDeployedOnNetwork` a reachable network raises. - -These are referenced in `foundry.toml` under `[rpc_endpoints]`. - -## Architecture - -**`src/lib/LibRainDeploy.sol`** — the deploy library: - -- `etchZoltuFactory(Vm)` — etches the Zoltu factory bytecode at the factory - address (for networks where it isn't deployed) -- `zoltuAddress(bytes creationCode)` — derives the address the factory deploys - creation code to, without deploying -- `deployZoltu(bytes creationCode)` — deploys creation code via the Zoltu - factory (`0x7A0D94F55792C434d74a40883C6ed8545E406D12`) using low-level `call`, - returns the deployed address -- `supportedNetworks()` — returns the list of Rain-supported network names (used - as foundry RPC config aliases) -- `isStartBlock(...)` — reads two adjacent blocks: true when the target has the - expected code hash at a block and does not have it at the block before -- `findDeployBlock(...)` — binary searches a fork's history for a block where - the target has the expected code hash and did not have it at the block before. - Either one is "the block a contract first appears at" only where that code - hash is monotone; against a target that held the hash, lost it and holds it - again both answer about an appearance neither can identify -- `checkResolvedAddresses(...)` — asserts an already-deployed contract holds the - addresses the deployment expected, on the currently selected fork, via - consumer-supplied static reads -- `checkResolvedAddressesOnNetworks(...)` — runs that check on every network. It - runs AFTER the deploy, against state the deployment has already settled, which - is the only point at which such a check means anything: registry bindings are - mutable, so a pre-deploy check would read a source that can change before the - constructor that consumes it -- `deployToNetworks(...)` — forks each network; where the expected address is - still empty it verifies the factory and dependencies on that fork, deploys via - Zoltu and checks the address; the code hash is checked on every network, - deployed here or already there -- `deployAndBroadcast(...)` — the main entry point: derives the deployer from a - private key, then `deployToNetworks` - -**`src/interface/IAddressRegistryV1.sol`** — the address registry interface: an -immutable root binds a `bytes32` name to an address (`register`), anyone reads a -bound name (`get`), and reading an unbound name reverts. Bindings are mutable so -an owning multisig can rotate without moving any consumer's deterministic -address; a consumer resolves once in its constructor and stores the answer, so a -re-binding never moves anything already deployed. - -**`src/concrete/AddressRegistry.sol`** — the implementation. Two functions and -nothing else. `ADDRESS_REGISTRY_ROOT` is a compile-time constant and therefore -part of the creation code, so changing it moves the deterministic address and -code hash. - -`ADDRESS_REGISTRY_ROOT` is `address(0)` during rollout. Nothing calls from the -zero address, so no name can be bound, and `get` reverts on every name that is -not bound — a registry compiled under this root answers every read with a revert -and can never answer one with an address. Setting a real root later is an -ordinary source change that moves the creation code, the address, the code hash -and the snapshot together. - -**`src/lib/LibAddressRegistryDeploy.sol`** — those pins, derived from the -creation code this repo compiles under this repo's own settings and checked -against it by `RegistryDeploySnapshotTest`. GENERATED by `script/Build.sol`, -aliasing the rolling `src/generated/candidate/` snapshot. - -**`src/interface/IMigrationRegistryV1.sol`** — the migration registry interface: -a writer applies one of its own migrations onto the head it believes its -namespace is at (`applyMigration`), anyone reads when a given writer applied a -given migration (`applied`), and anyone reads where that writer's namespace is -(`head`). There is no removal, no upgrade and no authority beyond the writer -over its own namespace. - -It exists so a prod-state test decides what to assert by reading what happened -on chain rather than by reading the clock. Without it, a test that spans a -migration accepts either the pre- or the post-migration value until a hardcoded -deadline — which asserts nothing during the one window where it matters, and -red-lines CI on a date rather than on a fact once the deadline passes. - -It is an INDEX, not proof. It says which invariant applies; codehash and -bytecode pins are what say the invariant holds. A multisig can act out of band, -so replacing the pins with this would trade a clock-guess for a bookkeeping -guess. - -`applied` answers a TIMESTAMP, and zero still means "not applied". Time-shaped -invariants — a cliff, a rate change, a grace period — need the moment as well as -the fact, and a flag sends them back to the hardcoded date. Zero stays -unambiguous because `applyMigration` refuses to write in a block whose timestamp -is zero, rather than write a record that reads back as no record. - -The HEAD is what makes a sequence ordered. `applyMigration` names the migration -it is applying onto and refuses to write unless the namespace is there, so a -skipped predecessor and an out-of-order concurrent dispatch both fail at apply -time instead of diverging silently; the applied migration becomes the new head. -`MIGRATION_HEAD_GENESIS` is the head of a namespace that has applied nothing, -and it is deliberately NOT zero: a zero genesis would make an uninitialised -predecessor constant a successful first application on any empty namespace, -which is the state of every chain not yet migrated. It is not a valid migration -id either, for the same reason a head must mean one thing. - -The head does not replace the per-migration refusal. Re-applying a migration -whose successor has landed presents a matching head, and without -`MigrationAlreadyApplied` would drag the head backwards and overwrite the -original timestamp. One namespace on one chain is one linear sequence, so two -independent sequences want two writer accounts. - -**`src/concrete/MigrationRegistry.sol`** — the implementation. Three functions -and nothing else, and — unlike `AddressRegistry` — nothing CONFIGURED at compile -time. `MIGRATION_HEAD_GENESIS` is a compile-time constant, but it is one value -for every consumer on every chain and names nobody, so it cannot fragment the -deterministic address the way a root would. - -The namespace is `msg.sender`, which is the whole access control. A root would -have to be welded into the creation code, as `ADDRESS_REGISTRY_ROOT` is, and the -account that applies a migration is a different Safe, deployer or timelock for -every consumer and every chain — so one root would have to be all of them, and -baking each consumer's authority in would give each of them a different address -for what is meant to be one shared registry. Anyone may write, but only under -themselves, so a reader asking about an authority it already trusts is reading -something only that authority could have written. - -With nothing to configure there is no rollout state in which it is inert: it -does its whole job the moment it exists on a chain, which is the opposite of -`AddressRegistry` under a zero root. - -**`src/lib/LibMigrationRegistry.sol`** — the consumer surface: `applied`, `head` -and `applyMigration`, all verifying the registry's code hash first, exactly as -`LibAddressRegistry.resolve` does. There is deliberately no broadcast runner: -the dominant real migration shape is a Safe executing a bundle that never -broadcasts, and such a script appends `applyMigration` to the bundle it is -already emitting, which is what makes the record atomic with the migration. - -**`src/lib/LibMigrationRegistryDeploy.sol`** — its pins, generated exactly as -`LibAddressRegistryDeploy` is. - -### Generated snapshots, and the assertions that specify their shape - -Every deploy snapshot in this repo is GENERATED and committed. There is no -hand-maintained hex anywhere: `src/generated/candidate/` holds one deploy record -per deployed contract — `AddressRegistry.sol` and `MigrationRegistry.sol` — from -`forge script script/Build.sol`. - -`script/Build.sol` declares those contracts ONCE, in `generatedContracts()`, and -the regeneration, both lib writers and the freeze all read that list. A contract -added to it is generated, aliased, released and frozen together. That matters -most for the freeze: a contract regenerated but absent from the names `freeze` -is given is a contract silently missing from the release, and a tag that never -held it has nothing missing from it for anything downstream to notice. - -A compiler or optimiser change is therefore "run the script, commit". Never -hand-edit a generated file. - -`src/generated//` directories are the FROZEN record: what each release -deployed, written once by `cutRelease()` and never again. That tree is the only -description of what this repo has released that cannot fall behind, which is why -`RainDeployVerifySnapshot` checks the generated `releasedSuites()` against it. -`LibRainDeploySnapshot.frozenSnapshotPaths` is the walk: every file inside a -release-tag directory, where a release tag is exactly what `tagForVersion` -produces — so `candidate/`, a scratch directory and a `0_1_7-rc1` nobody could -have frozen all fall out under the same rule, and there is no name to remember -to exclude. - -The dependency list is frozen with the rest, and it is NOT metadata. -`RainDeployBroadcast.run` hands `suite.dependencies` to -`LibRainDeploy.deployToNetworks`, which reverts `MissingDependency` for any -dependency with no code on the target network — so it is a precondition of the -broadcast, decided when the release is cut. Re-broadcasting a past release onto -a newly supported chain is exactly the case that matters: it has to check the -list THAT release was cut with, not today's. So `releasedSuites()` aliases -`DEPENDENCIES` out of each release's own snapshot rather than rebuilding it from -the current declaration. A dependency dropped from current source stays required -by the releases cut with it, and one added is not retro-imposed on releases cut -without it. Only the suite key and the artifact path are still regenerated from -the current declaration. - -**`GeneratedSnapshotShapeTest` is the specification of the shape.** It asserts -named properties against the compiler's AST — not against a second reference -file, so there is no question of that file's provenance, and not against source -text, so formatting cannot affect it: - -1. exactly five constants, in order: `bytes32 BYTECODE_HASH`, - `address DEPLOYED_ADDRESS`, `bytes CREATION_CODE`, `bytes RUNTIME_CODE`, - `bytes DEPENDENCIES`. `DEPENDENCIES` is the `abi.encode` of the suite's - `address[]` dependency list, and it is `bytes` because Solidity has no - file-scope constant of dynamic array type — the released-suites lib emits the - matching `abi.decode` -2. every declaration is `constant` -3. no `ImportDirective` — a snapshot is read by repos that do not have the - contract it describes, which is the whole reason a frozen release stays - verifiable after its source has changed or gone -4. no `ContractDefinition` — it is a record, not code -5. the generated-file header is present - -Values are deliberately not asserted: a solc change moves every literal without -changing anything the shape test is about, and a wrong literal is caught -immediately by the group 1 derivation checks in `RainDeployVerifySnapshot`. - -`ast = true` in `foundry.toml` is what puts the AST in the artifacts a plain -`forge test` produces. - -### `src/` holds the deploy machinery here. That is a SCOPED EXCEPTION. - -`src/abstract/RainDeploy*.sol` are test and script infrastructure, and they live -in `src/` rather than `test/`. Two reasons, and the second is the one that -matters: - -1. `.soldeerignore` excludes `test/` from the published package, and a - downstream repo has to import all of this — its `script/Deploy.sol` inherits - `RainDeployBroadcast`, its test contracts inherit `RainDeployVerify*`. An - abstract in a path the package excludes is unusable by every consumer. -2. **This repo's PRODUCT is the deployment process.** Machinery for deploying - and for verifying deployments is not scaffolding that happens to live here — - it is the thing the package exists to publish. So `src/` is where it belongs. - -**Do not copy this into a consumer repo.** There, `src/` is the product — -tokens, vaults, a factory — and deploy verification is scaffolding around it, so -the usual convention stands unchanged: `test/src/**` mirrors `src/**`, and test -abstracts live under `test/`. The exception is earned by what this repo IS, and -a repo that merely USES this machinery has not earned it. - -The exception is scoped to the deploy/verify abstracts and the suite -declaration. `src/concrete/AddressRegistry.sol` and -`src/concrete/MigrationRegistry.sol` are ordinary deployed contracts, tested -from `test/src/concrete/` exactly as the convention requires. - -**`src/abstract/RainDeploySuitesBase.sol`** — the ONE declaration of what a repo -deploys: per suite, a key, the creation code, the recorded address/code -hash/runtime code, an artifact path, and dependencies. - -Both sides read it. `RainDeployBroadcast` deploys from it and -`RainDeployVerify*` verify against it, so "the deploy script broadcasts one -contract while the tests verify another" is not a statement that can be true — -not because something checks for it, but because there is one array and all -three contracts read it. - -Suites are a REGISTRY the abstract iterates, not a chain of `else if`. A repo -adds a suite by adding an array entry; the keys reported by a mistyped -`DEPLOYMENT_SUITE` are built from that same array, so the failure message cannot -fall behind the suites it describes. Keys are checked unique, because the key is -what selects what gets broadcast. - -**`src/abstract/RainDeployBroadcast.sol`** — the broadcast. Selects one suite by -`DEPLOYMENT_SUITE` and deploys it, before reading `DEPLOYMENT_KEY` so a mistyped -suite fails naming the valid ones rather than on a missing key. -`deployNetworks()` defaults to `supportedNetworks()` and is overridable for -repos that bootstrap one chain per dispatch. - -**`script/Deploy.sol`** — -`contract Deploy is RegistryDeploySuites, -RainDeployBroadcast {}`. Empty on -purpose: the suites and the broadcast are both inherited. Run only via the -`Manual sol artifacts` workflow. - -**`src/abstract/RegistryDeploySuites.sol`** — this repo's own declaration, one -named candidate per deployed registry, inherited by `script/Deploy.sol`, -`script/Build.sol`, the pins test contracts and `GeneratedSnapshotShapeTest`. - -**`src/abstract/RainDeployVerify*.sol`** — the deploy-pin verification every -deploy repo inherits instead of hand-writing. - -Nothing is per suite beyond an array entry, and nothing anywhere is per network. - -The creation code is the only parameter. The Zoltu factory is `CREATE2` over its -calldata under a zero salt, so the address is a pure function of it, and running -it once locally gives the runtime code and its hash. The address, code hash and -runtime code a generated file records are checked OUTPUTS. - -Four groups, sorted by what they are anchored to: - -1. **Internal to the recorded set** (`RainDeployVerifySnapshot`) — what a - version records is what its own creation code derives. Catches a set - generated inconsistently. CANNOT catch a snapshot of the wrong contract: a - consistent snapshot of the wrong thing satisfies all of it, which - `testWrongContractSnapshotPassesInternalConsistency` pins. -2. **Anchored to source** (`RainDeployVerifySnapshot`) — EVERY candidate's - recorded creation code is `type(X).creationCode`. The only check that catches - a wrong-contract snapshot. Candidates only, because a released tag is MEANT - to diverge from current source; there is no field on a released version to - spell it, so it cannot be opted into or out of. Every one, and refusing an - empty list, because a candidate the loop never reaches is a contract whose - snapshot nothing anywhere anchors — see `NoDeployCandidates`. -3. **Anchored to the record** (`RainDeployVerifySnapshot`) — every file in the - append-only `src/generated//` tree is declared by a released suite, - matched by the address that file's creation code derives. `releasedSuites()` - is generated from that same record and everything anchored to a chain reads - it, so a frozen tag it does not name is a release that quietly drops out of - every check there is — which is what this catches when the generated file is - hand edited, a record directory arrives out of band, or nobody re-ran the - generator after the record moved. Matched against RELEASED suites only: a - release and the candidate it was cut from are byte-identical from the moment - the release is cut, so matching the whole declaration would let a candidate - declare a release. -4. **Anchored to chain** (`RainDeployVerifyChain`) — across - `supportedNetworks()`, every RELEASED version's derived address carries code - with its derived code hash. The only check that catches "never deployed" or - "not there any more", neither of which the repo can hold: both go false with - nobody touching it. - -Group 4 is released-only for the mirror image of group 2's reason. A release IS -a deployment that happened; a candidate is what the next release will be, and -between releases it is ordinarily ahead of anything on chain, so demanding it be -live asserts something false by design. Neither exemption is a field a caller -can set. Group 3 is what makes group 4's scope complete — a release group 4 is -never handed is a release it cannot fail on. - -Group 4 lives in its own contract so an unreachable RPC endpoint fails only it, -never the snapshot assertions, and a failure names which of the two it was. - -A single recorded code hash per version can only be true if the runtime code is -the same on every network, so a constructor reading `block.chainid` or similar -is a DEFECT: it fails hard, naming the chain and both hashes. There is -deliberately no per-chain code hash to record. - -The `src/abstract/` files are the only `src/` files `slither.config.json` -filters out, by name. They are inherited by test contracts and never deployed, -so slither's detectors — all of which are about deployed-code risk — have -nothing to say about them except that an abstract does not implement its own -virtuals and that a cheatcode is called in a loop, and — because slither skips -`test/` and `script/` — that an abstract's virtuals have no caller. The filter -matches those filenames exactly, not the `src/abstract/` prefix, so a file added -there later — including a deployable one — is analyzed rather than silently -exempted. - -The libraries are designed to be called from Foundry scripts (`forge script`) in -consuming repos, not directly. Consuming repos provide their own creation code, -expected addresses, expected code hashes, and dependency lists. - -## Key Design Patterns - -- **Deterministic addresses**: Zoltu proxy ensures same address on every chain. - Deployments fail if the resulting address doesn't match `expectedAddress`. -- **Code hash verification**: Post-deploy bytecode integrity is verified against - `expectedCodeHash`. The address registry is verified the same way before it is - read. -- **Dependency checking**: per network, and only where the deploy actually runs. - On a network with no code at `expectedAddress`, the Zoltu factory must have - code and match `ZOLTU_FACTORY_CODEHASH` and every dependency must have code, - all read on that network's own fork immediately before broadcasting. A network - that already has the code skips the deploy and the dependency check with it: a - contract that is already deployed does not need its dependencies present to - stay deployed. There is no all-network pre-flight. -- **Idempotent deploys**: If code already exists at the expected address, - deployment is skipped for that network. -- **Resolve once, verify after**: registry bindings are mutable, so the - meaningful check is not "does the registry say what I expect" before a deploy - but "does the deployed contract hold what I expect" after one. A consumer - resolves in its constructor; the deployment is then verified across every - network before anything migrates onto it. -- **Deploy-repo lifecycle**: a manual `sol-v*` tag is the sole release trigger - (`rainix-tag-release`), because this repo carries deployed concretes whose - pins consumers rely on. `[package].version` is the version of the LAST Soldeer - publish, and it moves only in lockstep with the frozen `src/generated//` - record that release writes. A version published before this lifecycle has no - such record. -- **Deploy, then verify, then tag** — in that order, and they are three separate - things. `script/Deploy.sol` broadcasts the suite `DEPLOYMENT_SUITE` names to - every network in `supportedNetworks()`, dispatched by hand through - `.github/workflows/manual-sol-artifacts.yaml`, whose `suite` input is a choice - over the declared keys. One suite per dispatch, so this repo's two registries - are two dispatches. Only then is there a deployment for `rainix-tag-release` - to verify pins against — it verifies and publishes, it never broadcasts. - Broadcasting is key custody and real money, so it is `workflow_dispatch` and - nothing else. Deploying is idempotent: a network that already has the code is - skipped, so a partial run is fixed by running it again. - - `RegistryDeployChainTest` is what verifies it, and it checks the RELEASED - suites. Nothing is released yet, so it has nothing to check and forks nothing. - It gets a subject the moment a release is frozen and declared — from then on - it is red until that release is live on every supported network, which is why - the deploy comes first. - -## License - -DecentraLicense 1.0 (LicenseRef-DCL-1.0). All source files must have SPDX -headers. REUSE compliance is enforced in CI. +Only what an agent working here would get WRONG, never what it would merely take +a moment to find. Everything discoverable — the layout, what each contract does, +the verification design, the commands CI runs — is deliberately absent and stays +absent (rainlanguage/rainix#298). Path-scoped detail lives in `.claude/rules/`. + +- **Broadcasting is key custody and real money.** Nothing automatic ever + broadcasts: `Manual sol artifacts` is `workflow_dispatch` only, and no merge, + tag or schedule may be given a path to it. +- **A `sol-v*` tag is the sole release trigger.** `[package].version` in + `foundry.toml` is the version of the LAST Soldeer publish, not a next-version + slot, so an ordinary PR does not bump it. +- **`src/generated//` is an append-only record.** `cutRelease()` writes a + tag directory once. A cut tag can never be un-cut and consumers pin what it + holds, so a frozen record is never edited, renamed or deleted. +- **`ADDRESS_REGISTRY_ROOT == address(0)` is INTENDED.** It is the rollout + state, not a defect: nothing calls from the zero address, so the registry is + inert and fails loudly in both directions. Do not "fix" it. The root is welded + into the creation code, so setting a real one moves the deploy address, the + code hash and the snapshot together, as an ordinary source change. diff --git a/REUSE.toml b/REUSE.toml index 23d19f1..ebd8d70 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -3,6 +3,7 @@ version = 1 [[annotations]] path = [ ".audit/**/", + ".claude/**/", ".github/**/", ".gitignore", ".soldeerignore", diff --git a/src/abstract/RegistryDeploySuites.sol b/src/abstract/RegistryDeploySuites.sol index 85f0e46..1358f55 100644 --- a/src/abstract/RegistryDeploySuites.sol +++ b/src/abstract/RegistryDeploySuites.sol @@ -40,7 +40,7 @@ import {LibMigrationRegistryReleased} from "../lib/LibMigrationRegistryReleased. /// It lives in `src/` rather than `test/` for two reasons. `.soldeerignore` /// excludes `test/`, and a downstream `script/` has to import its own /// equivalent. And in THIS repo the deployment process is the product: see the -/// scoped exception recorded in `CLAUDE.md`. +/// scoped exception recorded in `.claude/rules/src-layout-exception.md`. /// /// This declaration is the whole of what a deploy repo writes. The derivation, /// the comparisons, the source anchor, the networks matrix and the broadcast From dc78508ae2cb65f08b05f1dbd9c86cbbd660c217 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 13:40:12 +0000 Subject: [PATCH 2/3] docs: one Claude file, no `.claude/rules/` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CLAUDE.md` is the repo's only agent-context file. The three rulings worth keeping from the two rules files move into it and the files are deleted; the five RPC URLs become a committed `.env.example`, which is read on demand and not charged. A rule scoped to `src/**/*.sol` + `script/**/*.sol` + `test/**/*.sol` is every Solidity file in the repo, so it loads on essentially every turn — the same permanent charge as `CLAUDE.md`, moved where the gate does not count it. Relocation is only for content that has already passed the inclusion test and merely happens to be path-scoped. `RegistryDeploySuites` NatSpec points back at `CLAUDE.md`, and `REUSE.toml` no longer annotates `.claude/**/`. agent-context-cap: 2213 bytes loaded at session start (cap 4096). Co-Authored-By: Claude Opus 5 (1M context) --- .claude/rules/fork-tests.md | 24 --------------- .claude/rules/src-layout-exception.md | 44 --------------------------- .env.example | 12 ++++++++ .soldeerignore | 1 + CLAUDE.md | 15 ++++++++- REUSE.toml | 2 +- src/abstract/RegistryDeploySuites.sol | 2 +- 7 files changed, 29 insertions(+), 71 deletions(-) delete mode 100644 .claude/rules/fork-tests.md delete mode 100644 .claude/rules/src-layout-exception.md create mode 100644 .env.example diff --git a/.claude/rules/fork-tests.md b/.claude/rules/fork-tests.md deleted file mode 100644 index 414e9b0..0000000 --- a/.claude/rules/fork-tests.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -paths: - - "test/**/*.sol" - - "foundry.toml" ---- - -# Fork tests need every RPC endpoint, not just the one under test - -`RainDeployVerifyChain` forks every network in -`LibRainDeploy.supportedNetworks()`, so ALL of the `[rpc_endpoints]` aliases in -`foundry.toml` must resolve. They read from a gitignored `.env`: - -```bash -ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc -BASE_RPC_URL=https://mainnet.base.org -BASE_SEPOLIA_RPC_URL=https://sepolia.base.org -FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc -POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com -``` - -A missing or rate-limited endpoint fails as a `vm.createSelectFork` error. That -is a different failure from the `NotDeployedOnNetwork` a reachable network -raises, and only the second one is a statement about the deployment — do not -read a flaky endpoint as a missing deploy. diff --git a/.claude/rules/src-layout-exception.md b/.claude/rules/src-layout-exception.md deleted file mode 100644 index 933988b..0000000 --- a/.claude/rules/src-layout-exception.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -paths: - - "src/**/*.sol" - - "script/**/*.sol" - - "test/**/*.sol" - - "slither.config.json" ---- - -# `src/` holds the deploy machinery here, and that is a SCOPED exception - -`src/abstract/RainDeploy*.sol` and `src/abstract/RegistryDeploySuites.sol` are -test and script infrastructure, and they live in `src/` rather than `test/`. Two -reasons, and the second is the one that matters: - -1. `.soldeerignore` excludes `test/` from the published package, and a - downstream repo has to import all of this — its `script/Deploy.sol` inherits - `RainDeployBroadcast`, its test contracts inherit `RainDeployVerify*`. An - abstract in a path the package excludes is unusable by every consumer. -2. **This repo's PRODUCT is the deployment process.** Machinery for deploying, - and for verifying deployments, is not scaffolding that happens to live here — - it is the thing the package exists to publish. - -**Do not copy this into a consumer repo.** There, `src/` is the product — -tokens, vaults, a factory — and deploy verification is scaffolding around it, so -the usual convention stands unchanged: `test/src/**` mirrors `src/**`, and test -abstracts live under `test/`. The exception is earned by what this repo IS, and -a repo that merely USES this machinery has not earned it. - -Even here it is scoped to the deploy/verify abstracts and the suite declaration. -`src/concrete/AddressRegistry.sol` and `src/concrete/MigrationRegistry.sol` are -ordinary deployed contracts, tested from `test/src/concrete/` exactly as the -convention requires. - -## Why `slither.config.json` filters those files by name - -They are the only `src/` files it filters out. They are inherited by test -contracts and never deployed, so slither's detectors — all of which are about -deployed-code risk — have nothing to say about them beyond an abstract not -implementing its own virtuals, a cheatcode called in a loop, and (because -slither skips `test/` and `script/`) an abstract's virtuals having no caller. - -The filter matches those filenames exactly rather than the `src/abstract/` -prefix, so a file added there later — including a deployable one — is analysed -rather than silently exempted. Keep it that way when adding an abstract. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a150be --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy to `.env` (gitignored) to run the fork tests locally. +# +# `RainDeployVerifyChain` forks every network in +# `LibRainDeploy.supportedNetworks()`, so every `[rpc_endpoints]` alias in +# `foundry.toml` has to resolve — not just the one for the network under test. +# +# These are public endpoints and they rate-limit. CI holds its own. +ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc +BASE_RPC_URL=https://mainnet.base.org +BASE_SEPOLIA_RPC_URL=https://sepolia.base.org +FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc +POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com diff --git a/.soldeerignore b/.soldeerignore index de0c6b9..ab433ec 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -2,6 +2,7 @@ .audit .claude .coderabbitai.yaml +.env.example .git .github .gitignore diff --git a/CLAUDE.md b/CLAUDE.md index 5ba584b..7e1382b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ Only what an agent working here would get WRONG, never what it would merely take a moment to find. Everything discoverable — the layout, what each contract does, the verification design, the commands CI runs — is deliberately absent and stays -absent (rainlanguage/rainix#298). Path-scoped detail lives in `.claude/rules/`. +absent (rainlanguage/rainix#298). This is the repo's ONE agent file: no +`.claude/rules/`, no `@path` import. Content that fails the bar is deleted, not +relocated. - **Broadcasting is key custody and real money.** Nothing automatic ever broadcasts: `Manual sol artifacts` is `workflow_dispatch` only, and no merge, @@ -22,3 +24,14 @@ absent (rainlanguage/rainix#298). Path-scoped detail lives in `.claude/rules/`. inert and fails loudly in both directions. Do not "fix" it. The root is welded into the creation code, so setting a real one moves the deploy address, the code hash and the snapshot together, as an ordinary source change. +- **The deploy/verify abstracts living in `src/` is a SCOPED exception.** This + repo's PRODUCT is the deploy process, so its machinery is published rather + than scaffolding. Do not carry the exception into a consumer repo: there + `src/` is the product, deploy verification is scaffolding around it, and + `test/src/**` mirrors `src/**` as usual. +- **`slither.config.json` filters those abstracts by exact filename, never by + the `src/abstract/` prefix.** Keep it that way when adding an abstract: a + prefix filter would silently exempt a deployable file added there later. +- **A `vm.createSelectFork` failure is not a missing deployment.** It is an + unreachable or rate-limited endpoint. Only `NotDeployedOnNetwork`, from a + network that forked, says anything about the deployment. diff --git a/REUSE.toml b/REUSE.toml index ebd8d70..1365626 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -3,7 +3,7 @@ version = 1 [[annotations]] path = [ ".audit/**/", - ".claude/**/", + ".env.example", ".github/**/", ".gitignore", ".soldeerignore", diff --git a/src/abstract/RegistryDeploySuites.sol b/src/abstract/RegistryDeploySuites.sol index 1358f55..85f0e46 100644 --- a/src/abstract/RegistryDeploySuites.sol +++ b/src/abstract/RegistryDeploySuites.sol @@ -40,7 +40,7 @@ import {LibMigrationRegistryReleased} from "../lib/LibMigrationRegistryReleased. /// It lives in `src/` rather than `test/` for two reasons. `.soldeerignore` /// excludes `test/`, and a downstream `script/` has to import its own /// equivalent. And in THIS repo the deployment process is the product: see the -/// scoped exception recorded in `.claude/rules/src-layout-exception.md`. +/// scoped exception recorded in `CLAUDE.md`. /// /// This declaration is the whole of what a deploy repo writes. The derivation, /// the comparisons, the source anchor, the networks matrix and the broadcast From 7269da6929775fde2fd7c74cbb3e67cfbbb2a906 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 13:43:29 +0000 Subject: [PATCH 3/3] docs(CLAUDE.md): fix subject-verb agreement in the `src/` exception bullet Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7e1382b..ae07fcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ relocated. inert and fails loudly in both directions. Do not "fix" it. The root is welded into the creation code, so setting a real one moves the deploy address, the code hash and the snapshot together, as an ordinary source change. -- **The deploy/verify abstracts living in `src/` is a SCOPED exception.** This +- **The deploy/verify abstracts live in `src/`, a SCOPED exception.** This repo's PRODUCT is the deploy process, so its machinery is published rather than scaffolding. Do not carry the exception into a consumer repo: there `src/` is the product, deploy verification is scaffolding around it, and