feat: Versioned Registry Router + Upgradeable Proxies for All Core Contracts - #310
feat: Versioned Registry Router + Upgradeable Proxies for All Core Contracts#3100xCardiE wants to merge 7 commits into
Conversation
- 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.
|
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:
[*] 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. |
|
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 Bee keeps talking to stable addresses. User funds live in contracts that never upgrade and have no admin (but has escape hatch for users). 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). |
Summary
This PR introduces a versioned registry + router for Swarm storage-incentive contracts deployed behind upgradeable proxies. The registry publishes immutable
version → implementationmappings (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)onVersionedRegistryRouter— same implementation-level checks as insideverifyProxy, without requiring a registeredproxyId(useful for tooling and pure reads).RegistryGuardedTransparentUpgradeableProxy— extends OZTransparentUpgradeableProxy: on every non-adminfallback/receive, runsverifyProxy(registryProxyId)then delegates (msg.senderunchanged ).pinnedExecute(address expectedImpl, bytes calldata data)on that guarded proxy — atomic path for Bee: verifies registry + requiresaddress(this)’s current implementation equal toexpectedImpl, then **delegatecall**s into the live implementation withdata. 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)
VersionedRegistryRouter.forwardremains an optional path: selector-gatedverifyProxy+callto the proxy. Implementations invoked viaforwardseemsg.sender == router, so it is not a universal replacement for direct node calls.On-chain API changes (VersionedRegistryRouter)
All view unless noted.
registerRelease(versionId, semver, implementation, codehash)deprecateRelease(versionId)getRelease(versionId)ReleaseInfo:implementation,exists,deprecated,codehash,semver.getVersionForImplementation(implementation)bytes32version id or zero).verifyImplementation(implementation)extcodehash(implementation)matches storedcodehash. ReturnsversionIdon success.registerProxy(proxyId, proxy)proxymust use the sameProxyAdmininstance the router was constructed with.deprecateProxy(proxyId)verifyProxy/ guarded fallback revert for this proxy id.getProxyImplementation(proxyId)verifyProxy(proxyId)verifyImplementationrules. Returnsimplementationon success.verifyAllProxies()forward(proxyId, data)/forwardUnchecked(proxyId, data)msg.sendercaveat above.On-chain API — RegistryGuardedTransparentUpgradeableProxy
Deploy args (in addition to OZ transparent proxy):
VersionedRegistryRouter registry,bytes32 registryProxyId(must matchregisterProxy).fallback)_beforeFallback:verifyProxy(registryProxyId)→ delegate to_implementation().pinnedExecute(expectedImpl, data)verifyProxy→require(_implementation() == expectedImpl)→delegatecall(implementation, data).msg.senderin the implementation is the EOA / contract that calledpinnedExecute. Payable; ETH forwards to implementation via delegate context rules.upgradeTo, etc.)ifAdminpath — does not runpinnedExecute/ pin checks on those admin functions.pinnedExecutereverts formsg.sender == proxy admin(AdminCannotPin).Selector / collision note:
pinnedExecuteusesbytes4(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 throughpinnedExecuteexplicitly in the client).What Bee nodes should do
1. Configuration (local, trusted)
registryRoutercontract address (per chain).trustedVersionId(or semver →versionIdmapping) per product line — from governance, release notes, or baked into the Bee binary.PostageStamp,Redistribution, etc.).proxyIdper core proxy as registered inVersionedRegistryRouter(same id used at deploy for that proxy row).Pin the implementation Bee trusts:
Cache
expectedImpland refresh on config change / reconnect (registry rows are immutable, but deprecation can flip;verifyProxy/pinnedExecutestill 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 deprecatedgetProxyImplementation(proxyId)and compare address equality withgetRelease(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:ProxyAdminrepointed the proxy to another registered implementation,pinnedExecutereverts (PinMismatch) even thoughverifyProxyalone would pass for the new impl—because Bee did not update its pin.4. Writes — legacy / stock
TransparentUpgradeableProxyBee can keep direct
Cto the proxy. No on-chain atomic pin; mitigate with maturity, timelocks, and re-reads before sensitive batches, or migrate deploys toRegistryGuardedTransparentUpgradeableProxy.5. Optional:
verifyImplementationUse when you have an implementation address from
getProxyImplementationor an EIP-1967 storage read and want a single call that enforces registration + non-deprecated + codehash without aproxyId.Security notes (short)
versionIdand point the proxy there. PinningtrustedVersionId+pinnedExecute(expectedImpl from that row)prevents accepting a different row’s implementation without the node updating config.fallbackalone only enforces “current impl is some valid release”;pinnedExecuteenforces “current impl is my expected address.”forward: convenient but wrongmsg.senderfor many core methods; use only where semantics allow.Deploy / ops
RegistryGuardedTransparentUpgradeableProxyfor core proxies when Bee should usepinnedExecute.(_logic, admin, initData, registry, registryProxyId); thenregisterProxy(registryProxyId, proxy)with the same id.registerReleasebefore 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
VersionedRegistryRoutertests cover registry,verifyProxy,verifyImplementation,forward, roles, invariants,RegistryGuardedTransparentUpgradeableProxyfallback paths, andpinnedExecute(success,PinMismatch, registry failure, admin rejection, bubbled revert).npx hardhat testafter merge (exact counts depend on branch).Test plan (PR checklist)
RegistryGuardedTransparentUpgradeableProxy+ documentproxyId/registerProxyorderingpinnedExecuteTx path + config fortrustedVersionId/expectedImplpinnedExecuteselector and no collision rule for new implementation ABIs