Skip to content

Versioned per-name proxies: one stable address per contract, semver implementations behind it - #76

Open
charlesHetterich wants to merge 8 commits into
mainfrom
versioned-proxies
Open

Versioned per-name proxies: one stable address per contract, semver implementations behind it#76
charlesHetterich wants to merge 8 commits into
mainfrom
versioned-proxies

Conversation

@charlesHetterich

@charlesHetterich charlesHetterich commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Today every version of @org/name is a separate contract at its own address with its own storage — so "upgrading" means abandoning state, and a consumer's version pin is really just a pointer to one frozen deployment. This PR makes the registry a factory: each name's first publish instantiates a minimal per-name proxy that permanently owns the name's address, storage, and balance. Versions become semver-keyed implementation contracts behind it, and the proxy can route any call to any published version — multiple versions live simultaneously over one shared state.

(This is the direct answer to the "what are the guarantees for CDM consumers" thread: a consumer now gets one permanent address, real logic pinning — a call pinned to 1.2.3 executes 1.2.3's code — and state continuity across upgrades, with the owner's minSupported ratchet as the explicit, auditable storage-migration escape hatch instead of silent breakage.)

This is a clean break with the v1 era. Versions are semver strings everywhere; numeric indices no longer exist. The registry, CLI, and migration tooling do not read v1 registries, and legacy numeric cdm.json pins fail with instructions to reinstall. (paseo-next was reset mid-development, so no live deployment depends on v1 anymore; PCF's devnet registry was already condemned and bootstraps fresh on v2.)

The model

The per-name proxy (contract-proxy, src/contract/proxy/) declares no methods — the contract's own ABI passes through untouched — and reserves exactly one calldata pattern:

  • plain calldata → delegate-call the latest implementation (any non-CDM tooling keeps working, same living-latest semantics as today's getAddress);
  • [MAGIC][u128 versionKey][inner] → delegate-call that exact version; below the owner's floor → UnsupportedVersion();
  • [MAGIC][0][selector][args] → the CDM meta plane: O(1) point queries riding on state the proxy needs anyway (implOf, latest, minSupported, frozen, admin) plus registry-only publish / setMinSupported / freeze / unfreeze / setAdmin. Enumeration is deliberately NOT in the frozen blob — that's the registry's catalog job (getVersionCount/getVersionAt).

Every CDM contract also inherits an owner-controlled pause switch: freezeContract(name) halts all delegation (plain and versioned revert ContractFrozen()) while the meta plane and registry operations stay live, so the storage-migration flow is freeze → publish the reshaping version → unfreeze → ratchet minSupported — no window where old code can write mid-reshape.

Because every call passes through CDM-owned proxy code, this doubles as an inheritance layer: behavior added to the proxy blob is inherited by every contract published under it, in a namespace (the magic prefix) that can never collide with the developer's own functions. The blob is frozen per name at first publish, so its surface is deliberately minimal; future blob generations (via setProxyCodeHash) extend the inherited surface for names published after them.

Both routers use the SDK's bump allocator (pure passthrough frames make a handful of one-shot allocations — no free/reuse to gain from a real malloc), which cuts the per-name proxy blob to 13.7 kB and the registry proxy to 2.5 kB; blob size is per-call code-loading weight, so this is recurring savings on every CDM call. The registry implementation keeps picoalloc (its paging/import paths churn allocations under a fixed arena). The contracts track cargo-pvm-contract main (currently the #133 nested-storage API — chained get()/entry()).

Version keys pack semver as major<<64 | minor<<32 | patch (u128), publishes must be strictly increasing per name, and a version can never be republished. The version's source of truth is the crate's Cargo.toml package.version, which makes cdm deploy idempotent: anything at or below the registry's latest key is skipped as up-to-date.

Proxies are CREATE2-instantiated by the registry itself (salt = keccak256(name), zero-arg constructor, frozen blob at src/contract/proxy/artifacts/), so a name's address is a pure function of (registry, name, proxy blob) — offline-predictable, and identical across chains running the same registry address and blob generation. The registry stays the proxy's admin, so ownership/auth logic remains upgradable in the registry without touching deployed proxies.

What changed, per layer

  • contract-registry-core — the wire format (versioning.rs): magic prefix, key packing, calldata routing, meta selectors; every constant hex-pinned by tests and mirrored (drift-tested) in TS.
  • contract-registrypublish(name, key, target, uri) replaces publishLatest; factory instantiation; setMinSupported forwarding + mirror; getters (getProxy, getLatestKey, getMinSupported, getVersionAt); setProxyCodeHash admin surface; v2 adminImportContracts (records only, proxy required); dispatch tests additionally lock the registry→proxy meta calldata bytes.
  • @parity/cdm-builderproxy.ts wire mirror (browser-safe /proxy subpath); deploy pipeline reads Cargo versions, skips published keys, bakes stable addresses into Solidity imports, and warns if a published method's selector collides with the proxy call prefix; installs resolve latest/exact/npm-range specs; metadata gains the implementation's storage_layout; frozen-artifact loader; deploy-registry.ts --upgrade (in-place setCode with consumed-salt probing); migration snapshots use the cdm.registry.v2 schema.
  • CLI — version column + up-to-date state in the deploy table; install accepts @org/pkg:1.2.3 and @org/pkg:^1.2 (the old parser silently truncated 1.2.3 to index 1).
  • Frontend — semver versions with per-version targets, min-supported annotations, stable-address labeling.
  • cdm::import! — cdm.json versions are semver strings. The consumer call path is intentionally unchanged (runtime getAddress); contract-side version pinning needs a small generic calldata-preamble hook in cargo-pvm-contract's CallBuilder and is deferred — the wire format ships now, so pinned Rust calls activate later with no on-chain changes.

Verification

  • Rust: 105 host tests across the workspace crates, including byte-level dispatch locks for getAddress and the meta wire format on both sides of the Rust/TS boundary.
  • TS: 203 unit tests, full pnpm check green end to end.
  • e2e against a local ephemeral PPN (make start EPHEMERAL=1): 4 suites, 47/47 — including proxy.e2e.test.ts, which publishes the shared-counter blob twice and proves on-chain that a counter written through v1.1.0, written again through the superseded v1.0.0 via the calldata prefix, and read through both pinned versions is one and the same storage; plus meta-plane byte-exact queries, the ratchet and the freeze switch blocking calls while plain traffic/meta respectively continue, and monotonicity enforcement.

Rollout

Fresh v2 deploy on the reset paseo-next (same CREATE3 registry address under the registry-deployer key; the deploy flow uploads the per-name proxy blob and sets proxyCodeHash). PCF's devnet bootstraps fresh on v2 whenever they schedule it.

Parked follow-ups: upstream CallBuilder preamble → pinned Rust consumer calls + baked proxy addresses; #[cdm::init] initializer sugar (v1 rule: storage defaults are your initialization); publish-time storage-layout diff warnings (the metadata field ships now); Solidity versioned-call wrapper.

The registry becomes a factory: a name's first publish CREATE2-instantiates
a minimal per-name proxy (salt = keccak256(name), frozen blob, zero-arg
constructor so the address commits only to registry+name+blob) that
permanently owns the name's address, storage, and balance. Versions are
implementation contracts the proxy delegate-calls — plain calls route to
latest, [MAGIC][u128 key] calldata routes to an exact version, and a
[MAGIC][0][selector] meta plane serves CDM queries plus the registry-only
publish/setMinSupported/setAdmin ops. Version keys pack semver as
major<<64|minor<<32|patch, strictly increasing per name, so the version
list is sorted by construction; owners can ratchet a min-supported floor
below which pinned calls revert UnsupportedVersion().

The v1→v2 registry storage layout is append-only (info.proxy lands in a
previously-unwritten slot; key_of is a new mapping beside the untouched v1
tables), so setCode upgrades need no data migration: v1 records read back
as legacy entries with keys derived as 0.0.(index+1), resolve exactly as
before, and upgrade onto a proxy at their next publish. The getAddress
64-byte wire format is unchanged and now returns the stable proxy address.

cdm::import! accepts semver version strings in cdm.json (legacy numeric
indices still parse); the registry proxy crate moves to
src/contract/registry-proxy so contract-proxy can take src/contract/proxy.
… upgrades

proxy.ts mirrors the per-name proxy wire format (magic prefix, meta
selectors, slots, key packing) with every constant drift-tested against the
Rust pins, exposed browser-safe via the ./proxy subpath. Deploys read each
crate's Cargo.toml version, skip anything at or below the registry's latest
key (idempotent re-runs), salt implementation addresses by semver, publish
through the new registry ABI, and bake the stable per-name address into
Solidity imports. Installs resolve latest/exact/npm-range specs against the
on-chain version list (numeric pins keep resolving as legacy v1 indices)
and pin the resolved semver plus stable address in cdm.json. Metadata
gains the implementation's storage layout for future compat checks.

The contract-proxy blob is frozen under src/contract/proxy/artifacts with
a hash-verified loader (regenerate via freeze-proxy-artifact.ts); fresh
registry deploys upload it and set proxyCodeHash, and deploy-registry.ts
--upgrade performs in-place setCode upgrades with consumed-salt probing.
Migration snapshots gain a cdm.registry.v2 schema (version keys + per-name
proxies); v1 snapshots stay readable and import with derived legacy keys.
cdm deploy shows each crate's version and renders already-published crates
as up-to-date skips; the stable per-name address replaces the per-version
implementation address in tables and summaries. cdm install accepts exact
versions and npm-style ranges after the colon (the old parser silently
truncated 1.2.3 to index 1), passes cdm.json specs through verbatim, and
renders resolved semver. The frontend lists versions by key with their
implementation targets, marks releases below the owner's min-supported
floor, and labels the package's stable proxy address.
Resolves the harness and registry-suite conflicts by keeping the PPN
connect-only harness from main and re-applying the versioned-proxy
semantics: the harness additionally checks the contract-proxy blob, builds
the shared-counter template, and deploys raw blobs without dry-runs (the
Node/Bun instantiate divergence); the registry suite publishes semver keys
and asserts stable proxy addresses with per-run unique names; the new
proxy suite exercises multi-version shared state, the meta plane, and the
min-supported ratchet; the bulletin suite publishes with the v2 signature.
The proxy suite's raw versioned/meta calls (no ABI — the wire format is a
calldata prefix) now mirror product-sdk's exact papi shapes: the unsafe
api (generated descriptors lag PPN's runtime for these entries), dest as
a hex string, calldata as Uint8Array, and Revive.call's weight_limit
field (not gas_limit). Wire helpers import through the browser-safe
/proxy subpath — the package root loses named exports to the resolver's
tree-shaking under vitest, same story as /abi.

Template workspaces bump polkavm-derive 0.31 → 0.35 to match
cargo-pvm-contract main's polkavm line; 0.31's picosimd pin conflicts
with the current SDK resolution and broke template builds.

Full run against an ephemeral local PPN: 4 suites, 47/47 — including
live proof of two implementation versions sharing one proxy's storage,
the meta plane byte-for-byte, and the min-supported ratchet.
@socket-security

socket-security Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​types/​semver@​7.8.01001007487100

View full report

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

CDM CLI dev release

This PR includes a @parity/cdm-cli changeset, so CI published a dev CLI release for this branch.

Install it:

curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/c2d9ac4fcc7c6fbfeae37ce18174a54d37f9de24/install.sh | CDM_TAG=cdm-cli-dev-pr-76 bash

Update an existing install:

cdm update --tag cdm-cli-dev-pr-76

Release tag: cdm-cli-dev-pr-76
Commit: c2d9ac4fcc7c6fbfeae37ce18174a54d37f9de24

The proxy-based versioned system is now the only system. Versions are
semver strings everywhere: the registry drops the legacy branches,
derived 0.0.(index+1) keys, and the index-based getters (getVersionAt
covers enumeration); version rows merge into one VersionRecord mapping;
install/CLI/migrations lose the numeric-pin paths (old cdm.json pins fail
with reinstall instructions); v1 snapshots are neither exported nor
imported. Nothing live depends on v1 — paseo-next was reset and the PCF
devnet bootstraps fresh.

The per-name proxy keeps only what routing needs: the enumeration meta
ops (versionCount/versionAt/resolveMax) and their duplicate sorted key
list leave the frozen blob (27.5 → 24.1 kB); the O(1) point queries
(implOf/latest/minSupported/admin) stay, riding on state the proxy holds
anyway, with the latest key at a dedicated slot. The registry remains the
catalog for enumeration.

Also: cdm deploy now warns when a published method's selector collides
with the proxy call prefix, and the two changesets merge into one with an
explicit breaking notice.
… routers

Every CDM contract now inherits an owner-controlled pause:
freezeContract(name) halts all delegation at the per-name proxy (plain and
versioned calls revert ContractFrozen()) while the meta plane and registry
operations stay live — so a storage-reshaping migration is freeze →
publish → unfreeze → ratchet minSupported, with no window where old code
writes mid-reshape. New cdm.proxy.frozen slot and freeze/unfreeze/frozen
meta ops, registry forwarding with owner auth, wire mirror, and live e2e.

cargo-pvm-contract main replaced Mapping::view/view_mut with the #133
chained get()/entry() accessors; the registry adopts them (this is also
what CI compiles against — it floats on branch main by design, so the
Rust/TypeScript/Templates jobs red on the previous push go green here).

Both routers switch to the SDK's bump allocator: passthrough frames make
a handful of one-shot allocations, so a real malloc buys nothing — the
per-name proxy blob drops 26.1 → 13.7 kB and the registry proxy 15.4 →
2.5 kB, recurring per-call code-loading savings on every CDM call. The
registry implementation keeps picoalloc for its allocation-churning
paging and import paths.
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