Skip to content

feat(registry): contract registry and chain deployment - #66

Open
bdchatham wants to merge 9 commits into
mainfrom
brandon2/plt-1055-contract-registry-package
Open

feat(registry): contract registry and chain deployment#66
bdchatham wants to merge 9 commits into
mainfrom
brandon2/plt-1055-contract-registry-package

Conversation

@bdchatham

@bdchatham bdchatham commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes PLT-1055, PLT-1056, PLT-1057, PLT-1058 and PLT-1059 — the whole of PLT-1060.

Scope note: this PR opened as PLT-1055 alone and grew to the full feature. One PR rather than five stacked, because both gating workflows are pull_request: branches: [main] — a PR based on a feature branch runs zero checks. Five stacked PRs would mean four merging untested. The five commits are the review path:

Commit What it does
2e6ce8b the registry/ package: types, chain file format, embedded loader, import boundary
58a8f5f resolve, verify, record, write
a8b2534 rewire generator/ to consult the registry before deploying
3567773 close what four blinded reviewers found
84276b6 + HEAD fail closed on an unrecordable run; /brandon-code pass; the three format decisions

The problem

A contract scenario deploys its contract at the start of every run. Correct on a fresh chain, wrong on every long-lived one. Each run against arctic-1, atlantic-2 or pacific-1 leaves another copy behind, and every canary restart moves the contract — so a metric series spanning two restarts describes two different contracts, and nothing says the address changed.

sei-load already had the mechanism and no profile could reach it: Attach bound a scenario to an existing address, only the unit-test path called it, and config.Scenario had no address field.

What a run does now

Resolve every contract the profile drives, verify every recorded address, deploy only what nothing named, bind, and optionally record what it deployed. A stale entry stops the run before it sends a transaction.

Address precedence is fixed: a configured contractAddress, then forceDeploy, then a registry entry, then a deployment.

The review found things I had not

Four independent blinded reviewers — idiom, systems (assigned dissenter), platform, prose. All four dissented. The dissenter wrote probes and seven of seven failed. Everything ship-blocking is fixed, each with a test that fails without it.

AS-3.3 was false. The spec says a failed run deployed nothing. Resolution and deployment ran interleaved per scenario, so a stale entry on the second scenario left the first one's contract deployed, paid for, and recorded nowhere — the exact litter this feature removes. Reproduced, then fixed with three passes: every address decided and every recorded address verified before anything deploys.

Two paths failed open. A run naming no genesisHash matched nothing and redeployed on every restart in silence. An explicit contractAddress skipped verification entirely, so a typo produced a full run of green metrics against an address holding nothing.

Startup had no timeout. ethclient.Dial over HTTP performs no I/O and its client sets no deadline, so an endpoint that accepts and never answers held startup open with nothing logged.

The registry key was case-sensitive while every other name lookup in sei-load lowercases. Profiles write "ERC20Conflict"; the constants are lowercase.

The frozen format admitted duplicate names that the generator could itself produce, leaving the later entry unreachable forever.

A test certified a property the system does not have. TestTwoRunsOnOneChainDeployTheirOwn minted a fresh key per run; funder.Deployer hands every pod the same funding root. Renamed to say what it proves; the real limitation is in the package doc.

Three defects were mine and slipped every verifier:

  • Three orphaned AttachScenario methods survived. They compile because nothing requires them, so the build stayed green — and SC-006's verifier is grep for the removed method, which returned seven hits. Now zero.
  • A regex deletion truncated CreateContractTransaction's doc comment in StorageRW.go, taking the frozen draw-order invariant with it. gofmt, vet, staticcheck and golangci-lint were all clean over it, because none of them read comments.
  • The two new config fields skipped Scenario.Validate, so contractAddress and forceDeploy set together were accepted and one silently won.

One-way doors, approved 2026-08-24

The chain file format is frozen once a file exists. chains/ ships empty, so this was the last free moment.

  1. Contract names are lowercase, matching how the factory resolves them.
  2. contractKey overrides the recording key. Two runs on one chain must not share a contract — they write the same storage slots, and that contention is in neither profile. The three arctic-1 canary cells are the case.
  3. Long-lived chains are committed; re-genesised ones are supplied by --chain-file. Recovering a committed arctic-1 entry after a re-genesis needs a PR, a build, and a hand-edited image pin per cell — and the three cells are pinned independently on purpose. chains/README.md states the security cost of supplying instead.

Verification

gofmt, go vet, staticcheck, golangci-lint — clean, 0 issues. All 14 packages pass. 19 registry tests, 17 generator tests.

Every guard was proven failing before being trusted: the import boundary against a real config import, the write refusal by neutering the check, the CI gate against a malformed chain file, the no-deployment assertion by pointing the fixture elsewhere.

make verify stops at check-bindings locally — the Makefile fetches solc-static-linux and this host is Darwin. CI covers it, and this change touches no .sol file.

Known and not fixed

Filed rather than dropped:

  • --chain-record-path can only write to /dev/stdout in a pod; both deployed shapes set readOnlyRootFilesystem with read-only mounts. Bootstrapping a long-lived chain is a local operation, and the README now says so.
  • A symlink walks past checkWritePath. The doc says guardrail, not sandbox.
  • The code hash cannot distinguish two instances of one contract, or see through a proxy.
  • Startup emits no span, and the run summary carries no contract address — the spec's own motivating gap.

🤖 Generated with Claude Code

A contract scenario deploys its contract at the start of every run, so a
run against a long-lived chain leaves another copy behind and every
canary restart moves its contract to a new address. Nothing in the repo
can say "this contract already exists on this chain."

This adds the leaf package that will answer that. It holds the types, the
chain file format, and the loader. It resolves nothing and verifies
nothing yet; PLT-1056 is the first caller that reads a chain.

The import boundary comes first, deliberately. boundary_test.go asserts
that registry imports no sei-load package except generator/bindings, and
it went in before any type existed so it guards every later task. It
shells out to `go list -deps` rather than walking imports by hand, so a
package this one imports cannot smuggle a forbidden dependency in behind
it. Verified by adding a config import and watching it fail.

Two notes on what landed differently from the plan:

chains/ carries a README rather than a .gitkeep. `//go:embed chains`
cannot compile against a directory holding only a dotfile, because embed
excludes them. The README satisfies the pattern and tells an operator
what belongs there, which the dotfile did not.

Load validates rather than trusting the file. A misspelled key would
leave a field at its zero value, and a contract at the zero address holds
no code on any chain, so the run would send load that does nothing. The
strict decode mirrors config.decodeStrict: unknown fields and trailing
data both fail.

Closes CDR-005, CDR-014, CDR-015, CDR-017, CDR-018, CDR-019, CDR-020.

Verifiers: gofmt clean, go vet clean, staticcheck clean, golangci-lint
0 issues, go test ./registry/ 8 tests passing. `make verify` reaches
check-bindings and stops there: the Makefile fetches solc-static-linux
and this host is Darwin, so that step is CI-only. No .sol file or binding
changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Startup now depends on registry resolution and on-chain code verification; misconfiguration or stale entries block the run (intentionally), but wrong contractAddress or shared deployer keys can still skew measured workloads.

Overview
Adds a registry package and rewires startup so contract scenarios resolve or deploy once per chain identity (chainId + genesisHash) instead of deploying on every run.

Startup (prepareAll) plans every contract address first (explicit contractAddress, forceDeploy, registry hit, or deploy), verifies recorded code on-chain before any deployment, then binds via a shared client and optionally writes a chain file of what this run deployed. Stale registry entries fail startup with no transactions sent.

Config & CLI: --chain-file (layered over embedded registry), --chain-record-path, scenario contractKey, contractAddress, forceDeploy, and ValidateRecording (recording without genesisHash is refused before deploy).

Scenarios drop per-scenario Attach; they use Ready + Binder so addresses live in the preparation step, not in scenario state.

Reviewed by Cursor Bugbot for commit eff6aa1. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread registry/registry.go
bdchatham and others added 3 commits August 24, 2026 10:24
Adds the surface a run calls. The registry now answers "does this
contract already exist on this chain?", and produces the entry to write
when it did not.

Verify hashes what eth_getCode serves and compares it to the recorded
hash. A mismatch returns *MismatchError, which is a distinct type so a
caller can tell a stale registry from a dial failure — the two need
different responses from an operator, and collapsing them would send
someone looking for the wrong problem. A test asserts that separation.

An address holding no code hashes to the zero hash rather than to
Keccak-256 of the empty string. One error type therefore covers both
mismatches, and Error tells them apart.

Resolve verifies before it returns an address, and reports a deploy for
an ordinary miss rather than failing. A mismatch is an error and never a
deploy signal: redeploying over a stale entry looks like a fix and is
not, because it hides that the registry no longer describes the chain.

WriteChain refuses to write into the compiled-in chains directory. The
binary cannot know where its own source tree is, so the check matches the
two paths a run can realistically be given from inside the repo,
"registry/chains" and a bare "chains". It is a guardrail against that
mistake rather than a sandbox, and the doc comment says so.

Record reads the code back from the chain rather than hashing the
compiled bytecode. Creation bytecode runs the constructor and returns the
runtime code, and only the runtime code is what eth_getCode serves. It
refuses an address holding nothing, so a deployment that did not take
effect fails here instead of producing an entry no later run can verify.

Both guards were verified failing before being trusted. The write
refusal test runs from a temp working directory with parents created, so
a broken guard fails by writing the file rather than by hitting a missing
directory; neutering the check turns both refusal cases red. Two control
cases assert a directory that merely contains the word is still allowed.

Closes CDR-001, CDR-002, CDR-003, CDR-006, CDR-007, CDR-008, CDR-009,
CDR-010, CDR-011.

Verifiers: gofmt clean, go vet clean, staticcheck clean, 17 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A run now asks the registry for each contract its profile drives, deploys
only what the registry does not name, and records what it deployed. A
stale entry stops the run at startup instead of corrupting it.

Removing AttachScenario found more than the spec described. Each of the
six contract scenarios overrode Attach to dial and bind — and that
override called the base, which dialed and bound again. Every contract
scenario was dialing twice and binding twice on attach. Both paths are
gone.

What replaces them: Ready(config) marks a scenario able to generate, and
Binder() returns a closure that binds one contract and stores the
instance. The closure is built where the contract type is known, so the
preparation step drives it without knowing that type, and the step owns
the backend and the address. No scenario dials, and none panics.

ScenarioBase no longer has an address field, and GetAddress is gone. Both
were dead — nothing outside the type read either — and deleting them
makes CDR-021 structurally true rather than a comment: a scenario cannot
hold what does not exist. staticcheck flagged the leftover write, which
is what surfaced it.

Address precedence is fixed and explicit: a configured contractAddress,
then forceDeploy, then a registry entry, then a deployment because
nothing named one.

Every assertion was verified failing before being trusted. The
no-deployment test has a control that points the fixture at a different
address and asserts the run refuses to start. The CI gate on committed
chain files was checked against a deliberately malformed file.

Closes CDR-004, CDR-012, CDR-013, CDR-016, CDR-019, CDR-020, CDR-021,
CDR-022, CDR-023, and the T014/T019/T022/T023/T026/T027/T028 assertions.

Verifiers: gofmt clean, go vet clean, staticcheck clean, golangci-lint 0
issues, all 14 packages passing. check-bindings is CI-only on Darwin and
this change touches no .sol file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An xreview slate of four independent reviewers dissented. The systems
lens reproduced seven defects with real tests; the platform lens found a
cross-cutting one. This closes the ship-blocking set. Each fix carries a
test that fails without it.

AS-3.3 was false for any profile of more than one scenario. Resolution
and deployment ran interleaved per instance, so a stale entry on the
second scenario left the first one's contract deployed, paid for, and
recorded nowhere — the litter this feature exists to remove. Reproduced,
then fixed: prepareAll now runs three passes, and every address is
decided and every recorded address verified before anything deploys.

Startup had no timeout. ethclient.Dial over HTTP performs no I/O and its
client sets no deadline, so an endpoint that accepts and never answers
held startup open with nothing logged. Both chain-reading phases now run
inside WithinBudget, which the deploy path already used.

Two paths failed open, which is the one behaviour that cannot be right
here. A run naming no genesisHash matched nothing and redeployed on every
restart in silence; it now fails when the registry describes that chain
id. An explicit contractAddress skipped verification entirely, so a typo
produced a full run of green metrics against an address holding nothing;
it now checks for code.

The registry key was the raw profile string while every other name lookup
in sei-load lowercases. Profiles write "ERC20Conflict"; the constants are
lowercase. My test passed only because it keyed off the constant, which
no real profile writes.

The frozen file format admitted duplicate contract names, which the
generator could itself produce from a profile naming one scenario twice.
The later entry was unreachable forever. validate now rejects it, before
any file is committed and the format is load-bearing.

TestTwoRunsOnOneChainDeployTheirOwn certified CDR-013, which does not
hold. It minted a fresh key per run; funder.Deployer hands every pod the
same funding root. Renamed to say what it proves, and the real limitation
is now in the package doc: concurrent runs on one key produce identical
deployments. No test asserts that — reproducing it needs a race, and a
test that asserts a bad property by winning a race is worse than the gap.

Also: three orphaned AttachScenario methods survived on the EVMTransfer
family, so SC-006's verifier — grep for the removed method — returned
seven hits. Both package docs still described deployAll and Attach. A
regex deletion had truncated CreateContractTransaction's doc comment in
StorageRW.go, taking the frozen draw-order invariant with it; no verifier
caught that, because none of them read comments. recordDeployments dialed
a second client against CDR-023. The two new config fields skipped
Scenario.Validate, so contractAddress and forceDeploy set together were
accepted and one silently won.

Verifiers: gofmt, go vet, staticcheck, golangci-lint 0 issues, all 14
packages passing. SC-006's grep now returns nothing.

Known and not fixed, recorded in the review notes: chain-record-path
cannot write in either deployed pod shape except /dev/stdout; symlinks
walk past checkWritePath; the code hash cannot distinguish two instances
of one contract; startup emits no span and the run summary carries no
contract address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread generator/prepare.go
Comment thread generator/prepare.go
bdchatham and others added 2 commits August 24, 2026 11:31
…nt volume

Adds the startup check for the one pairing that still failed late:
chainRecordPath set with genesisHash empty deployed every contract and
then failed on WriteChain's validation, leaving contracts on the chain
that nothing recorded. ValidateRecording refuses it before the first
dial. It runs in runLoadTest rather than loadConfig, because the flag
that sets the field is merged after the profile is parsed.

Then a brandon-code pass over the feature.

prepareAll now reads as its step sequence — plan, deploy, bind, record —
with the WithinBudget ceremony behind planAllWithinBudget. The closure
had also been reassigning the orchestration's own err from inside itself,
which worked and read badly.

Comment volume came down against principles 13 and 14. An unexported
function defaults to no comment and has to earn one: addEmbedded,
describeHash and bindAll lost theirs, and the ones that hold an invariant
a future editor could break silently kept theirs, trimmed. codeHashAt
keeps its zero-hash rule because both error branches depend on it.
deployPlanned keeps the nonce-ordering invariant. validate, decodeStrict
and contractNameFor each keep the one sentence that says why.

Verified placement with go doc -u rather than by reading the file: a
group fused by a missing blank line renders under the wrong symbol and
looks correct in source. No fused groups.

checkWritePath led with a claim its own second paragraph withdrew —
"enforceable rather than a convention" and "still cannot edit it" — and
the review proved a symlink walks past it. It now leads with the two
paths it actually matches and names the escape. chains/README.md had
copied the strong form and dropped the caveat; it now carries the honest
one, says bootstrapping is a local operation rather than a deployed one,
and states the forward-compatibility cost of adding a field.

Verifiers: gofmt, go vet, staticcheck, golangci-lint 0 issues, all 14
packages passing unchanged — which is the proof this changed structure
and comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chain file is a one-way door and these three sit inside it. Approved
2026-08-24 before any file is committed, which is the last moment they
are free.

Contract names are lowercase. The scenario factory already resolves a
name that way, every profile in this repo writes CamelCase, and the
constants are lowercase. Keying on the raw string would record
"ERC20Conflict" from one profile and miss it from another that wrote
"erc20conflict".

A scenario may set contractKey to override the key its contract is
recorded under. Two runs driving one chain must not share a contract:
they write the same storage slots, and that contention is in neither
profile. The three arctic-1 canary cells run one profile against one
chain, so without distinct keys all three would measure a workload nobody
configured.

Long-lived chains are committed; re-genesised ones are supplied. pacific-1
and atlantic-2 keep their addresses, so committing puts them in a reviewed
pull request and a signed image. arctic-1 changes its genesis hash on
every re-genesis, and recovering a committed entry needs a sei-load pull
request, a CI build, and a hand-edited image pin per cell — where the
three cells are pinned independently on purpose.

The cost of supplying a file is stated rather than left implicit: whoever
can edit that source can name a contract of their choosing, and the
code-hash check cannot catch it, because it proves the address holds the
code the file recorded and not that the code is ours. The canary mounts a
funded key, so the loss is bounded by that key's balance. That bound is
why this is acceptable for arctic-1 and not for pacific-1.

Verifiers: gofmt, go vet, staticcheck, golangci-lint 0 issues, all 14
packages passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham bdchatham changed the title feat(registry): contract registry package and chain file format feat(registry): contract registry and chain deployment Aug 24, 2026
@bdchatham
bdchatham requested a review from kollegian August 24, 2026 19:26
@bdchatham bdchatham assigned amir-deris and unassigned amir-deris Aug 24, 2026
@bdchatham
bdchatham requested review from amir-deris and masih August 24, 2026 19:26
Comment thread generator/prepare.go Outdated
Bugbot caught a defect my own duplicate-name check introduced. A profile
naming one scenario twice deployed two contracts, then WriteChain
rejected the file for holding two entries under one name — so the run
died after both deployments, leaving them on-chain and unrecorded. That
is the same failure class as the AS-3.3 violation this branch already
fixed, reintroduced by the fix for a different finding. Reproduced
before fixing.

The root cause was two vocabularies. createScenarios suffixes the display
name to storagerw_0 and storagerw_1, while contractNameFor keys on the
unsuffixed profile name, so two instances shared one key.

Resolution now groups instances by contract name: resolve once, deploy
once, bind every instance in the group. That also closes the asymmetry
the systems review raised separately — deploying per instance gave a
fresh chain two contracts and a covered chain one, from the same profile,
so the same profile measured a different workload depending on which
chain it ran against. An operator who wants two contracts sets a distinct
contractKey on each, which is what that field is for.

Renames prepared to binding, per review. A past participle reads as
something that happened rather than a thing the code holds; binding names
what it is, and pairs with the plan-then-bind sequence around it.

prepareAll now reads as five named steps: plan, deploy what is missing,
ready, bind, record.

Verifiers: gofmt, go vet, staticcheck, golangci-lint 0 issues, all 14
packages passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread generator/prepare.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ff25edd. Configure here.

Comment thread generator/prepare.go
bdchatham and others added 2 commits August 24, 2026 12:42
…ording check

Round 2 of the systems review verified F1, F2, F4, F5, F6 and F15 closed
against real probes. It found one new defect and one incomplete fix, both
reproduced before this change.

Grouping instances by contract name let the group silently inherit the
first instance's selection. forceDeploy on a second instance was dropped
without a word, and config validation accepted it: an operator sets
forceDeploy to get virgin storage for a contention measurement and binds
a contract carrying another run's state instead. That is the hazard the
comment on ContractAddress already names, moved from the field to the
group. groupByContractName now rejects a group whose instances disagree,
and names contractKey as the way to have two contracts on purpose.

planOne's doc had asserted "validate rejects the config where they do
not". Nothing did. A comment long enough to assert a clause, plausible
enough that nobody checked — the P13 failure mode, inside the change that
cites P13. The clause is now true because the guard exists and the doc
names it.

ValidateRecording sat only in main, so NewGenerator still deployed a
contract and then failed the write the function exists to pre-empt. It
now runs at prepareAll as well, which is the function every caller of the
package reaches. main keeps its call for the early exit before the
metrics server starts.

MismatchError asserted a cause it had not checked. A lagging or syncing
endpoint produces the identical symptom, and the run reads one endpoint
at latest. It now states what was observed and offers both causes, so
nobody chases a re-genesis that did not happen at 3am.

Structure, from the same review: the budget wrapper is folded into
planAll and now covers only the chain reads, which is what its constant
says it bounds; grouping needs no timeout. prepareAll returns an error
rather than a slice nobody read. recordDeployments gets its own line
instead of riding a return that paired a non-nil value with a non-nil
error. The doc said "three passes" in the commit that made it five.

Two comments moved rather than being deleted. The read-only-pod claim was
a cross-repo invariant a comment cannot hold; it is now in the
--chain-record-path flag help, where the operator who needs it reads it.
The duplicated green-metrics rationale now lives once, on VerifyHasCode.

Verifiers: git status clean, gofmt, go vet, staticcheck, golangci-lint 0
issues, all 14 packages passing. Reporting the tree state explicitly
because last round I called it clean while an untracked failing probe sat
in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checked claims against behaviour rather than reading for typos. Six were
wrong, and one was a code defect the sweep surfaced.

The registry imports no sei-load package at all — `go list -deps` returns
only itself. doc.go said "imports no other sei-load package except
generator/bindings", which implies a dependency that does not exist, and
boundary_test.go justified the allowance as "the bytecode the registry
hashes against". It hashes what eth_getCode serves, not compiled
bytecode, so nothing needs the bindings yet. Both now say the allowance
is CDR-017's ceiling rather than a list of what is used.

--dry-run previewed something a real run would not do. mockPrepareAll
gave each instance its own random address while the live path groups by
contract name and shares one, so a dry-run of a profile naming one
scenario twice showed two contracts where a run gives one. It groups now.
A preview that does not match is worse than no preview.

generator/doc.go told an operator to "give each concurrent run its own
deployer key until this is fixed". funder.Deployer returns the funding
root, so no profile can do that — the advice reads as actionable and is
not. It now says closing the gap needs a code change.

Three stale names: deployPlanned for deployMissing in generator/doc.go,
mockDeployAll for mockPrepareAll in StorageRW_test.go, and the mock-deploy
paragraph describing per-instance binding.

README's Command Line Options table gained --chain-file and
--chain-record-path, including the two things an operator learns the hard
way otherwise: a --chain-file path that does not exist fails startup, and
recording needs genesisHash.

Verified no phantom documentation: every flag named in a doc exists in
main.go, and every JSON field named in a doc has a struct tag.

Verifiers: gofmt, go vet, staticcheck, golangci-lint 0 issues, all 14
packages passing, git status clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham requested a review from blindchaser August 24, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants