Skip to content

feat: Versioned Registry Router + Upgradeable Proxies for All Core Contracts#310

Open
0xCardiE wants to merge 7 commits into
masterfrom
feat/proxy_registry
Open

feat: Versioned Registry Router + Upgradeable Proxies for All Core Contracts#310
0xCardiE wants to merge 7 commits into
masterfrom
feat/proxy_registry

Conversation

@0xCardiE
Copy link
Copy Markdown
Contributor

@0xCardiE 0xCardiE commented Apr 21, 2026

Summary

This PR introduces a versioned registry + router for Swarm storage-incentive contracts deployed behind upgradeable proxies. The registry publishes immutable version → implementation mappings (address + codehash + semver + deprecation). Clients (e.g. Bee) must not blindly trust “whatever sits behind this proxy today”; they pin a trusted version and verify chain state before (or during) protocol calls.

New since the original PR text:

  • verifyImplementation(address implementation) on VersionedRegistryRouter — same implementation-level checks as inside verifyProxy, without requiring a registered proxyId (useful for tooling and pure reads).
  • RegistryGuardedTransparentUpgradeableProxy — extends OZ TransparentUpgradeableProxy: on every non-admin fallback / receive, runs verifyProxy(registryProxyId) then delegates ( msg.sender unchanged ).
  • pinnedExecute(address expectedImpl, bytes calldata data) on that guarded proxy — atomic path for Bee: verifies registry + requires address(this)’s current implementation equal to expectedImpl, then **delegatecall**s into the live implementation with data. If an operator upgrades the proxy to another registered build, a node still pinned to the old implementation reverts in the same transaction (PinMismatch).

Architecture (high level)

Registrar / deploy
    → registerRelease(versionId, semver, implementation, codehash)
    → ProxyAdmin upgrades proxy to that implementation (when adopted)
    → registerProxy(proxyId, proxyAddress) on router

Bee (policy)
    → locally: trusted versionId(s) / semver (from release, governance, or image config)
    → on-chain: read getRelease(versionId) → expected implementation + deprecations
    → optionally compare with live pointer (getProxyImplementation(proxyId) or EIP-1967 slot on proxy)

Bee (execution — recommended with guarded proxy)
    → tx to proxy: pinnedExecute(expectedImpl, abi.encodeCall(...))
    → expectedImpl := getRelease(trustedVersionId).implementation (cached / refreshed)

VersionedRegistryRouter.forward remains an optional path: selector-gated verifyProxy + call to the proxy. Implementations invoked via forward see msg.sender == router, so it is not a universal replacement for direct node calls.


On-chain API changes (VersionedRegistryRouter)

All view unless noted.

Function Purpose
registerRelease(versionId, semver, implementation, codehash) Registrar-only; immutable mapping (cannot overwrite version or reuse implementation).
deprecateRelease(versionId) Deprecator-only; release stays readable but fails verification.
getRelease(versionId) Returns ReleaseInfo: implementation, exists, deprecated, codehash, semver.
getVersionForImplementation(implementation) Reverse lookup (bytes32 version id or zero).
verifyImplementation(implementation) Reverts unless impl is registered, not deprecated, and extcodehash(implementation) matches stored codehash. Returns versionId on success.
registerProxy(proxyId, proxy) Router-admin; proxy must use the same ProxyAdmin instance the router was constructed with.
deprecateProxy(proxyId) Deprecator-only; verifyProxy / guarded fallback revert for this proxy id.
getProxyImplementation(proxyId) Current implementation address behind the registered TransparentUpgradeableProxy.
verifyProxy(proxyId) Reverts if proxy entry missing/deprecated, or current implementation fails verifyImplementation rules. Returns implementation on success.
verifyAllProxies() Batch view; skips deprecated proxy entries.
forward(proxyId, data) / forwardUnchecked(proxyId, data) Optional; see msg.sender caveat above.

On-chain API — RegistryGuardedTransparentUpgradeableProxy

Deploy args (in addition to OZ transparent proxy): VersionedRegistryRouter registry, bytes32 registryProxyId (must match registerProxy).

Path Behaviour
Normal ABI call (fallback) _beforeFallback: verifyProxy(registryProxyId) → delegate to _implementation().
pinnedExecute(expectedImpl, data) verifyProxyrequire(_implementation() == expectedImpl)delegatecall(implementation, data). msg.sender in the implementation is the EOA / contract that called pinnedExecute. Payable; ETH forwards to implementation via delegate context rules.
Admin (upgradeTo, etc.) OZ ifAdmin path — does not run pinnedExecute / pin checks on those admin functions. pinnedExecute reverts for msg.sender == proxy admin (AdminCannotPin).

Selector / collision note:
pinnedExecute uses bytes4(keccak256("pinnedExecute(address,bytes)")). Core implementations must not declare a public/external function with the same selector on the same proxy, or calls could be ambiguous (always route Bee traffic through pinnedExecute explicitly in the client).


What Bee nodes should do

1. Configuration (local, trusted)

  • registryRouter contract address (per chain).
  • trustedVersionId (or semver → versionId mapping) per product line — from governance, release notes, or baked into the Bee binary.
  • Proxy addresses Bee already uses (PostageStamp, Redistribution, etc.).
  • proxyId per core proxy as registered in VersionedRegistryRouter (same id used at deploy for that proxy row).

Pin the implementation Bee trusts:

expectedImpl = getRelease(trustedVersionId).implementation
require release.exists && !release.deprecated
require getVersionForImplementation(expectedImpl) == trustedVersionId   // consistency

Cache expectedImpl and refresh on config change / reconnect (registry rows are immutable, but deprecation can flip; verifyProxy / pinnedExecute still enforce “not deprecated” at call time).

2. Reads (no gas) — health / startup

Use eth_call:

  • getRelease(trustedVersionId)
  • verifyProxy(proxyId) — fails if proxy deprecated or implementation not registered / bad codehash / release deprecated
  • Optionally getProxyImplementation(proxyId) and compare address equality with getRelease(trustedVersionId).implementation (redundant with a strict pin policy but good for diagnostics)

3. Writes (transactions) — recommended with guarded proxy

For each protocol call today you send to the proxy with calldata C, instead send one tx:

to := proxyAddress   // RegistryGuardedTransparentUpgradeableProxy instance
data := abi.encodeWithSelector(
    pinnedExecute.selector,
    expectedImpl,    // from step 1; must match pinned version’s implementation
    C                // same calldata you would have sent to the proxy for the core contract
)
  • If ProxyAdmin repointed the proxy to another registered implementation, pinnedExecute reverts (PinMismatch) even though verifyProxy alone would pass for the new impl—because Bee did not update its pin.

4. Writes — legacy / stock TransparentUpgradeableProxy

Bee can keep direct C to the proxy. No on-chain atomic pin; mitigate with maturity, timelocks, and re-reads before sensitive batches, or migrate deploys to RegistryGuardedTransparentUpgradeableProxy.

5. Optional: verifyImplementation

Use when you have an implementation address from getProxyImplementation or an EIP-1967 storage read and want a single call that enforces registration + non-deprecated + codehash without a proxyId.


Security notes (short)

  • Dual compromise (registrar + proxy admin): attacker can register malicious new versionId and point the proxy there. Pinning trustedVersionId + pinnedExecute(expectedImpl from that row) prevents accepting a different row’s implementation without the node updating config.
  • Guarded fallback alone only enforces “current impl is some valid release”; pinnedExecute enforces “current impl is my expected address.”
  • forward: convenient but wrong msg.sender for many core methods; use only where semantics allow.

Deploy / ops

  • Default: deploy RegistryGuardedTransparentUpgradeableProxy for core proxies when Bee should use pinnedExecute.
  • Constructor: (_logic, admin, initData, registry, registryProxyId); then registerProxy(registryProxyId, proxy) with the same id.
  • Register registerRelease before or when adopting an implementation; upgrade order should avoid long windows where the proxy points at unregistered bytecode (guarded proxy reverts all user calls until registered).

Tests

  • VersionedRegistryRouter tests cover registry, verifyProxy, verifyImplementation, forward, roles, invariants, RegistryGuardedTransparentUpgradeableProxy fallback paths, and pinnedExecute (success, PinMismatch, registry failure, admin rejection, bubbled revert).
  • Full repo: run npx hardhat test after merge (exact counts depend on branch).

Test plan (PR checklist)

  • Full Hardhat suite green
  • Deploy scripts: optional switch to RegistryGuardedTransparentUpgradeableProxy + document proxyId / registerProxy ordering
  • Bee: implement pinnedExecute Tx path + config for trustedVersionId / expectedImpl
  • Document pinnedExecute selector and no collision rule for new implementation ABIs
  • Etherscan / explorer verification for new proxy type

- Wire core contracts and local deploy to the registry router
- Add registry deploy scripts, tests, and spec for the router
- Mine stats witnesses in tests; remove checked-in fixture JSON
Convert all core contract deployments (PostageStamp, PriceOracle,
StakeRegistry, Redistribution) from direct deploys to
TransparentUpgradeableProxy + DefaultProxyAdmin + initialize() across
local, main, test, and tenderly networks.

Add VersionedRegistryRouter deploy script to all networks that
registers all 4 proxies and their v1.0.0 releases with codehash
verification.

Update role scripts to use keccak256 hashes instead of read() calls
to avoid TransparentUpgradeableProxy admin-cannot-fallback errors.

Remove superseded deploy/registry/ directory.
Security and correctness fixes for the new proxy registry / router:

- Gate forwardUnchecked behind ROUTER_ADMIN_ROLE so it no longer bypasses
  the selector allowlist for arbitrary callers.
- Reject zero codehash in registerRelease so verifyProxy can't be
  silently disabled.
- Validate registered proxies are actually owned by this router's
  ProxyAdmin.
- Add deprecateProxy + ProxyDeprecated event; verifyProxy now rejects
  deprecated proxies and verifyAllProxies skips them instead of
  reverting.
- Reject calldata < 4 bytes in both forward paths instead of panicking.
- Add explicit ZeroAddress check on the constructor's _proxyAdmin arg
  and a SelectorRouted event for setRoutedSelector.

Multisig handover (deploy/main/012):

- Grant REGISTRAR_ROLE / DEPRECATOR_ROLE / ROUTER_ADMIN_ROLE to the
  multisig and renounce them (and DEFAULT_ADMIN_ROLE) from the deployer
  EOA so the multisig is the sole authority on the router.

Tooling and dead-code cleanup:

- Restore working solhint config (extends solhint:recommended; the
  removed solhint:default preset broke the linter).
- Drop unused imports/vars and dead scaffolding in
  scripts/mine-stats-witnesses.ts and deploy/local/011.
- Prettier-format remaining files touched on this branch.
- Expand VersionedRegistryRouter tests to cover the new behaviour
  (proxy admin mismatch, deprecateProxy, calldata length, zero
  codehash rejection, forwardUnchecked role gating, selector disable).

All 225 tests pass.
The .ts source was formatted in dad1e86 but the compiled .js sibling
(loaded by worker_threads at runtime) was missed; CI's prettier --check
runs against both.
It's the CommonJS sibling of mine-worker.ts, loaded directly by
worker_threads at runtime, so the require() calls flagged by
@typescript-eslint/no-var-requires are intentional and can't be
rewritten as ESM imports without breaking the worker.
…plementation

Add a new transparent proxy that runs `VersionedRegistryRouter.verifyProxy`
in `_beforeFallback`, so every non-admin call atomically rejects unregistered,
deprecated, or codehash-mismatched implementations without changing core
contract code.

Add `pinnedExecute(address expectedImpl, bytes data)` so callers (e.g. Bee
nodes) can pin the implementation address derived from their trusted
versionId; reverts with `PinMismatch` if the proxy is later upgraded behind
their back, and rejects admin to keep the transparent-proxy property.

Expose `verifyImplementation(address)` on the router for the per-address
checks (registration, deprecation, codehash) so clients can verify without
needing a registered proxyId.

Tests cover registry-side `verifyImplementation`, guarded-proxy delegation
and revert paths, and the new `pinnedExecute` happy path, mismatch, registry
failure, admin rejection, and bubbled implementation revert.
@0xCardiE 0xCardiE self-assigned this May 22, 2026
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