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

@0xCardiE 0xCardiE commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

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
@awmacpherson

Copy link
Copy Markdown

This proposed architecture can, and I think should, be broken into components and evaluated separately. The idea of an onchain version registry and upgradable proxies are independent: either one can exist and potentially provide a benefit without the other. Moreover, the tradeoffs of an upgradable proxy structure are different for different components of the storage incentives.

Onchain version registry. Can we interrogate exactly who benefits from this and what is the pathway to realising this benefit? Do we get a security win from this or is it mainly convenience? If it's a security win, what other measures must users take to actually enjoy increased security? How does having the version registry onchain compare to publishing it on ethersphere GitHub or under an ENS domain?

For example, staking operators currently use whatever version of the contracts bee points to. To compromise this trust, an attacker must either compromise the bee release keys OR fool bee's maintainers into merging code with a pointer to a malicious contract. Clearly, an onchain version registry makes no difference to the security of bee's release process. Will it make it harder for an attacker to convince bee's maintainer to merge a pointer to a bad contract? Are we expecting that operators will get the correct version tags from some other source and supply that to bee as configuration as well as upgrading bee itself (which of course is still vulnerable to any problem with bee's release process)? Or is this about making it easier to coordinate between multiple client teams?

Upgradable proxies. as I understand it, the main reasons to consider upgradable proxies is to allow the admins to roll out upgrades without having to ask their users to do a complicated migration. The tradeoff is usually that admins can steal funds from inattentive users. Assessing this tradeoff depends on (1) how complicated the migration is, and (2) the amount of funds at stake.

Here's my take on these assessments:

Contract Migration complexity Upgrade attack surface
Redistribution Low* Steal all funds in Postage, burn all stake deposits
StakeRegistry Medium** Steal all stake deposits
PriceOracle Low* Change drain rate of funds in Postage
Postage High*** Steal all funds in Postage

[*] From the client perspective, rolling out a new Redistribution contract means updating the contract address in the client. It holds no state that would benefit from an upgradable proxy, and the attack surface is significant (but note that the admin currently already has this attack surface in Swarm today, because it can assign new redistributors to existing Postage and StakeRegistry contracts). PriceOracle is similar, except it has a trivial bit of state to clone (1 number with no backing asset) and the attack surface is smaller.

[**] The way this currently works is rug-resistant: operators must move their stake themselves. Obviously, this is more complex than simply updating a pointer. However, we are already asking operators to upgrade their clients. Ideally, authorising a stake migration would be at most an additional authorisation as part of the node upgrade process. OTOH the attack surface for an upgradable stake registry is arguably largest of all: theft of all deposits.

[***] A user-driven Postage migration would be the most challenging coordination task among these because it requires ordinary users to be attentive to what version of the client software they are using. The way that this migration worked in the past was essentially admin-driven (cloning batch data to the new contract). IMO this component has the strongest argument for an upgradable proxy. Nonetheless I think there are ways that we can work towards executing reliable user-driven Postage migrations and hence remove the admin responsibility from this upgrade path too.

@0xCardiE

Copy link
Copy Markdown
Contributor Author

I can agree with most points written above. In summary security wouldn't be much greater while we are admins, idea is to have it on the same level as now but enable easier migrations and for us to iterate faster with updates so protocol can mature quicker and get to that elusive end state where we remove us as admins.

Just a important note is that take the whole idea with this addition that I wrote

"would propose that Stamps are split into 2 contracts. One is PostageAccounting and one is PostagePolicy
First one is frozen ledger: batches, remaining, pot, validChunkCount. This never changes, never has admins, funds move only through frozen functions.
And PostagePolicy is upgradeable: how batches are created/priced/ and other logic that might change.

Bee keeps talking to stable addresses. User funds live in contracts that never upgrade and have no admin (but has escape hatch for users).
Rules that we still want to iterate live in proxies.

Similar pattern would be for Staking as well."

@awmacpherson

Copy link
Copy Markdown

Similar pattern would be for Staking as well."

The main thing I wanted to highlight is that this would not be the same level as we have now: it increases the attack surface. If Staking were to become upgradable, admin can steal all funds, which is not the case now.

OTOH I feel that the stake migration is fairly easy to make seamless for operators (while Postage may well be more difficult).

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