Skip to content

Initializations: version-addressed init contracts run atomically at publish - #86

Open
charlesHetterich wants to merge 5 commits into
versioned-proxiesfrom
initializations
Open

Initializations: version-addressed init contracts run atomically at publish#86
charlesHetterich wants to merge 5 commits into
versioned-proxiesfrom
initializations

Conversation

@charlesHetterich

@charlesHetterich charlesHetterich commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Constructors are dead behind per-name proxies: delegatecall enters the callee's call export, never deploy, so constructor writes land in the implementation's own storage which nothing reads. #76 shipped "storage zero-defaults are your initialization" as the interim rule. This PR gives contracts real initialization: custom logic that runs exactly once, atomically, in the same transaction that publishes a version, directly against the name's proxy storage — one concept covering both first-publish setup and upgrade-time storage transformation.

The model

An initialization is addressed by (contract, version). Rust: <crate>/initializations/<version>.rs — the crate names the contract. Solidity: initializations/<Contract>/<version>.sol — the directory names the contract. One rule, two idiomatic projections — the same split the publish version already has (Cargo.toml [package].version vs the @custom:cdm colon suffix). The file addressed to a version runs exactly when that version is published.

  • No matching file → plain publish, exactly as today.
  • A file for an already-published version is inert forever — deleting it is optional hygiene, never a correctness requirement. Nothing stale can re-run.
  • Only the published version's initialization runs: publishing 1.2 → 1.4 never fires 1.3.0; 1.4's init sees from = 1.2.
  • A file addressed above the publishing version earns a deploy-time warning (likely a forgotten bump).

Each initialization exposes one conventional entry point — initialize(uint128 from, address owner) (0x3a67c2f8, pinned in contract-registry-core and mirrored + drift-tested in proxy.ts) — where from is the previously-latest version key (0 on first publish) and owner is the name's registry owner. If it reverts, the entire publish rolls back (Utility.batch_all + revert bubbling).

Rust initializations are manifest-free: the user creates initializations/<version>.rs and nothing else — cdm deploy synthesizes a throwaway shim crate (edition + dependencies copied from the contract's resolved cargo metadata, one [[bin]] pointing at the file) and builds it with cargo-pvm-contract. Each file is fully self-contained, embedding its own copy of the storage layout it operates on: published initializations become frozen text the evolving contract can never break, while the deploy-time layout guard keeps the CURRENT initialization honest against the CURRENT implementation. Solidity initializations live in a per-contract subdirectory whose name is the router (initializations/Counter/1.3.0.sol), and the file's contract must inherit that contract (contract Init_1_3_0 is Counter) — inheritance demoted to validation, which is also what guarantees the shared storage layout. The subdirectory is mandatory even for single-contract projects: a flat form would plant a rename trap the day a second contract appears, and two contracts can legitimately need an initialization at the same version. A flat-placed file errors with a message that teaches the layout; a directory naming no detected contract errors listing the known names.

Frozen proxy: one new meta op

callCode(address,bytes) (0xd74c1f04, admin-gated): delegatecall an arbitrary address against the proxy's storage, bubbling return/revert verbatim. Deliberately live while the proxy is frozen — freeze halts the plain/versioned delegation planes only, so the freeze → publish-with-initialization → unfreeze migration window works end to end.

This required regenerating the frozen per-name proxy blob — the point of landing this before the first paseo deploy of the versioned-proxy generation:

CONTRACT_PROXY_CODE_HASH = 0x9c918a8b6fb50007becfcf66fdbc2ceed18060b3a75f6721669601083a542e86  (14936 bytes)

Fresh deploy-registry runs upload the new blob and set setProxyCodeHash; --upgrade pushes it to live registries via the existing ensureProxyCodeHash path.

Structural authorization: initialization contracts are never registered versions, so their selectors are unreachable through the proxy's plain/versioned planes. callCode — registry-only — is the single path into proxy storage, which is why user initializations need no access-control boilerplate inside initialize.

Registry

publishWithInit(name, key, target, metadataUri, initTarget) — validate + record the version + repoint latest exactly like publish, then compose callCode(initTarget, [initialize selector][from][owner]) and call the proxy. New Initialized(string indexed, uint128, address) event, InvalidInitTarget() error. The registry→proxy calldata is byte-locked by in-file dispatch tests on both sides of the wire (and mirrored in the TS drift tests); getAddress's frozen 64-byte format is untouched.

Pipeline / CLI

  • Detection: version-addressed filename parsing (strict canonical X.Y.Z, errors on malformed spellings), Solidity directory routing with inheritance validation. Rust initializations build on demand through the generated shim crate — no manifest requirements to detect or enforce.
  • Deploy: the initialization instantiates in the same batch_all chunk as its implementation (the chunker weighs impl+init pairs as one item so pairs never split), registered via publishWithInit; salted <package>#init at the publish version.
  • Storage-layout guard (mandatory safety net): at deploy time the initialization artifact's storage layout is compared against the implementation's — slot/offset/type, row by row — and the deploy is refused on mismatch, with language-specific advice. When neither artifact carries layout data the check is impossible: the deploy proceeds with a loud warning instead of a silent skip. Foundry template now sets extra_output = ["storageLayout"], hardhat the equivalent outputSelection.
  • DeployTable shows a subtle +init marker on the publishing row's version cell.

Templates

shared-counter keeps its storage in one inline #[storage] struct anchored at #[slot(0)] (packs identically to bare auto-numbered fields, and makes the build emit the storage layout the guard checks) and ships a working initializations/0.1.0.rs; foundry-counter ships initializations/CounterA/0.1.0.sol. Empty constructors are gone — #[contract] emits a default deploy export. Both templates double as docs; the README gains an Initializations section.

e2e (local PPN)

New initializations.e2e.test.ts (Rust matrix, against the freshly built registry + refrozen blob):

  • first publish WITH initialization — owner/from=0 read back through the proxy;
  • publish with NO initialization file — behaves exactly as today;
  • upgrade publish whose initialization transforms storage (count doubled), from = previous latest, old versions read the transformed state via versioned calls;
  • initialization revert rolls back the entire publish (version count, latest key, storage all intact);
  • freeze → publish-with-initialization → unfreeze window;
  • callCode at the proxy is registry-only; zero init target rejected;
  • the shipped shared-counter template initialization proven on-chain.

solidity.e2e.test.ts now drives the full pipeline over the template's real initializations/CounterA/0.1.0.sol (owner set through the proxy, cross-VM) and adds the reverting-initialization rollback case. The full pre-existing e2e suite stays green alongside.

Closes #77

@charlesHetterich

Copy link
Copy Markdown
Collaborator Author

Revised the Solidity convention per review: initializations are now addressed by (contract, version) uniformly — Rust <crate>/initializations/<version>.rs (the crate names the contract), Solidity initializations/<Contract>/<version>.sol (the directory names the contract, mandatory even for single-contract projects). The flat inheritance-routed form collided the moment two contracts needed an initialization at the same version and interleaved per-contract histories; the directory is now the router and inheritance is demoted to validation, with sharp errors for flat-placed files and directories naming no detected contract. PR body updated; foundry template, tests, and docs moved to the nested layout.

@charlesHetterich

Copy link
Copy Markdown
Collaborator Author

Second revision, per review — net +4 lines while adding the shim build path:

  • No manifest requirement: initializations/<version>.rs is now the ONLY thing a user creates — the CLI synthesizes a detached shim crate (edition + deps copied from the contract's resolved cargo metadata) and builds it with cargo-pvm-contract. The missing-[[bin]] error and all manifest docs are gone.
  • Self-contained init files: each embeds its own copy of the layout it operates on — published initializations are frozen text the evolving contract can never break; the deploy-time layout guard keeps the current one honest. Template contract storage is back inline (one #[storage] struct anchored at #[slot(0)]; the SDK hard-rejects both #[slot(0)]-on-first-field-only and per-field pins on sub-word types, and this form packs identically to bare auto-numbering while making the build emit the layout the guard needs).
  • initialize(uint128,address) (0x3a67c2f8) replaces cdmInit — re-pinned in core + TS with byte-lock tests; the frozen proxy blob is selector-agnostic and verified byte-identical, no regen.
  • Empty constructors deleted (default deploy export), stale slot comments gone, plus a trim pass over redundant tests and chatty errors.

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.

1 participant