paritytech / paritytech/contract-dependency-manager
Constructors are effectively ignored behind per-name proxies — initializer primitive + constructor sugar
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 4
- Forks
- 3
- Avg merge
- 4d 13h
- Merged PRs (30d)
- 3
Description
Problem
Under the versioned per-name proxy model (#76), a CDM contract's #[constructor] is effectively ignored: it runs when the implementation blob is instantiated, but against the implementation's own storage — which nothing ever reads. All real state lives in the per-name proxy's storage, reached only through delegate_call, and on pallet-revive a delegate call enters the callee's call export, never deploy (exec.rs: the frame's executable comes from the callee's code, the account — and therefore storage — stays the proxy's). So constructor writes are orphaned by construction.
The interim rule shipped in #76 is "storage defaults are your initialization" (untouched storage zero-defaults, which the SDK's Lazy/Mapping reads honor — all current templates happen to satisfy this). That's fine for counters; it's not fine for contracts that need owner addresses, config constants, or non-zero starting state — and it silently diverges from what every Rust contract author expects a constructor to mean.
How the ecosystem solves this
This is the most-solved problem in upgradeable contracts, and every major pattern converges on the same shape:
- OpenZeppelin upgradeable / EIP-1967: constructors are banned in implementation contracts (tooling statically rejects them). Initialization is a normal function (
initialize()) that the proxy/factory delegatecalls exactly once, atomically, at proxy creation, guarded by a once-ever flag in proxy storage. Upgrade-time re-initialization is explicit and versioned (reinitializer(n)). - Diamond (EIP-2535): every
diamondCutoptionally carries(address _init, bytes _calldata), delegatecalled atomically with the cut — initialization and migration are the same mechanism invoked at different lifecycle moments.
The semantics that fall out (answering the "does it run once?" question): init runs once per proxy, at birth; later upgrades never re-run it — upgrades get their own explicit migration hooks instead.
Proposed CDM design
Three layers; only the first is frozen-forever (it lives in the per-name proxy blob).
1. Proxy primitive: callImpl(uint128 key, bytes data) (frozen blob)
One generic admin meta op on the per-name proxy ([MAGIC][0][selector] plane, admin = registry): delegatecall data into version key's implementation against the proxy's storage. ~25 lines + tests.
This is Diamond's _init generalized, and it's the enabling primitive for both constructor-initialization and future atomic migrations. It grants the registry no new trust: the registry already decides what code exists behind every name (publish).
Timing note: the proxy blob freezes per name at first publish. If this primitive isn't in the generation that ships with #76's first real deploy, names published before a blob-generation bump (setProxyCodeHash) can never gain atomic init/migration.
2. Registry flow: publish(..., init_calldata: Bytes) (upgradeable)
publishgains aninit_calldataparameter (empty = none).- On a name's first publish, after registering v1, the registry forwards it via
callImpl(key, init_calldata)— same extrinsic as proxy instantiation, so it's atomic (no front-run window) and structurally once-ever: no guard flag needed, because the registry only sends it on first publish. - Follow-up in the same vein:
publishWithMigration(...)reusing the primitive at upgrade time, composing withfreezeContractfor long migrations (freeze → publish+migrate → unfreeze → ratchetminSupported).
3. Developer experience: constructors just work (cdm-macros sugar)
CDM owns cdm-macros, so a CDM attribute can rewrite an ordinary #[constructor] fn new(args) into both a real constructor and a hidden, guarded init method (__cdm_init-style) that the deploy pipeline encodes and passes as init_calldata at first publish. End users literally write a constructor; CDM makes it mean the right thing. No upstream cargo-pvm-contract changes required (unlike consumer-side version pinning).
Until the sugar lands, the documented pattern is an explicit init method — with the caveat that a plain public init remains callable via the passthrough path forever, so it must be guarded or idempotent. The macro-generated guard (a reserved once-flag slot checked inside the generated method) removes even that footgun.
4. CLI
cdm deploy has never taken constructor arguments (all current templates are no-arg, which is why this never surfaced). Needs an init-args channel: encode from the ABI's constructor inputs, sourced from a flag (--init "<args>") and/or a per-contract config field, first-publish only, with a clear error when a contract's constructor has inputs but no args were provided.
Also worth enforcing
- Publish-time warning (or error) when a contract's ABI constructor declares inputs and no init path is configured — today that contract deploys with silently-orphaned initialization.
- Docs: immutable data (
set_immutable_data/get_immutable_data) is unusable behind the proxy — under delegatecall the frame reads the proxy's (empty) immutables. Constructor sugar must not compile down to immutables.
Open questions
- Should
init_calldatatarget the user's ABI directly (CLI encodes__cdm_init(args)) or should the proxy'scallImplstay fully generic and the convention live entirely in tooling? (Proposed: generic primitive, tooling owns conventions.) - Reserved slot for the sugar's once-guard: piggyback on the proxy's
latest_key == 0window vs a dedicatedcdm.init.doneslot readable by generated code. - Migration hooks: separate
publishWithMigrationvs an optionalmigrate_calldataonpublish(symmetric withinit_calldata). - Whether
cdm deployshould hard-error on constructors-with-inputs until the args channel exists.
References
- #76 — the versioned per-name proxy model this builds on (wire format in
contract-registry-core::versioning, proxy meta plane insrc/contract/proxy/). - pallet-revive
exec.rsdelegate_call: frame executable = callee code, frame account/storage = caller's;get_immutable_datareads the executing account's contract info. - OpenZeppelin
Initializable(initializer/reinitializer), EIP-2535diamondCut(_init, _calldata).
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the versioning and proxy meta-plane described in #76, then read pallet-revive's exec.rs delegate_call behavior. Trace how publish creates a proxy and how cdm-macros and cdm deploy currently handle constructors. Done requires an agreed design for initialization, migration, constructor argument encoding, guarding, and the affected implementation and tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, typescript
- Domain
- blockchain, cli, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 32/100