Skip to content

upgradeability in Processor Endpoint - #162

Merged
saratnt merged 11 commits into
devfrom
HZN-2918/upgradeability-from-batch
Sep 4, 2026
Merged

saratnt merged 11 commits into
devfrom
HZN-2918/upgradeability-from-batch

Conversation

@andreanistico

Copy link
Copy Markdown
Contributor

No description provided.

@andreanistico
andreanistico changed the base branch from st/HZN-2810-batch-contracts to dev August 27, 2026 13:59

@saratnt saratnt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional comments:

  • The go bindings should be updated.
  • NOTICES still lists @openzeppelin/contracts at ^5.3.0 and gained no entries
    for the two new direct deps.
  • From Claude: "contracts/.openzeppelin/ has no defined home in the repo: not committed, not
    gitignored, not mentioned in the README or in
    docs/design/UPGRADABLE_CONTRACTS_DESIGN.md.

The first deploy against a real network writes the upgrades plugin's manifest
there — the record of each proxy, its current implementation, and that
implementation's storage layout. upgradeProxy reads it to check the new
implementation's layout against the deployed one
(hardhat-upgrades/dist/utils/validate-impl.js:31
getStorageLayoutForAddress). The layout isn't recoverable from chain, so
without the manifest an upgrade fails with Deployment at address ... is not registered — including when run from CI or by anyone who didn't do the deploy.
Right now it surfaces as untracked output and is easy to lose.

Three things:

  • Commit the manifests for public networks (OZ's recommendation) so the layout
    history is a repo artifact rather than a per-machine file.
  • Gitignore contracts/.openzeppelin/unknown-31337.json only — not
    unknown-*. horizen-l3-testnet's chain id isn't in the plugin's
    network-name map, so its manifest is an unknown-<chainId>.json too, and a
    blanket pattern would ignore exactly the one worth keeping.
  • Note it in the README's Upgrading section: the manifest must be present and
    current before running an upgrade script, and
    upgrades.forceImport(proxy, Factory, { kind: 'uups' }) re-registers a proxy
    if it's lost — with the caveat that it takes the layout from whatever source
    is currently compiled, so it's only correct if that matches what's deployed."

tokenAllowlist = _tokenAllowlist;
feeCollector = payable(updateStatusOperator);
_grantRole(UPDATE_STATUS_ROLE, updateStatusOperator);
_grantRole(ADMIN, admin);

@saratnt saratnt Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From Claude: The ADMIN role cannot be rotated or revoked. ProcessorEndpointExtension.sol:93-99
grants ADMIN but never grants DEFAULT_ADMIN_ROLE and never calls
_setRoleAdmin(ADMIN, ...), so getRoleAdmin(ADMIN) == DEFAULT_ADMIN_ROLE, held
by nobody.
So a compromised ADMIN key can never be revoked, a lost one freezes
upgradeability forever, and renounceRole bricks it.
Grant DEFAULT_ADMIN_ROLE to admin, or
_setRoleAdmin(ADMIN, ADMIN).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread contracts/scripts/util.ts Outdated
/// - `state-variable-immutable`: `_extension` stays `immutable`, set once per implementation
/// deployment in the constructor rather than through `initialize` — see the `_extension` doc
/// comment on `ProcessorEndpoint` for why that is safe under a proxy.
export const PROCESSOR_ENDPOINT_UPGRADE_OPTIONS = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From Claude:
unsafeAllow is applied per-deployment, not per-declaration

All three exceptions are blanket-approved for the whole validation run. state-variable-immutable is checked against every immutable in the contract (upgrades-core/dist/validate/run.js:358), so it currently covers _extension but will silently pass a future immutable that should have been proxy state.

All three can be narrowed to source annotations, which also documents each exception where it applies:

  • /// @Custom:oz-upgrades-unsafe-allow state-variable-immutable on _extension (ProcessorEndpoint.sol:42)
  • /// @Custom:oz-upgrades-unsafe-allow missing-initializer-call on initialize (ProcessorEndpoint.sol:70) — the check does skipCheck('missing-initializer-call', contractInitializer) on the function definition (upgrades-core/dist/validate/run/initializer.js:206-207), so this one is narrowable too
  • /// @Custom:oz-upgrades-unsafe-allow delegatecall on _delegateToExtensionCall (ProcessorEndpoint.sol:234) — only this one. delegateToExtension uses inline assembly, and the validator matches only Solidity-level .delegatecall() calls (typeIdentifier /^t_function_baredelegatecall/, run.js:44-46); there is no Yul/InlineAssembly handling, so annotating it would be a no-op.

With all three annotated, PROCESSOR_ENDPOINT_UPGRADE_OPTIONS is empty and can be dropped from its 6 call sites (scripts/deploy/{all,processorEndpoint}.ts, scripts/upgrade/processorEndpoint.ts, test/ProcessorEndpoint/{constructor.spec,fixture}.ts). Move the existing doc comment from util.ts into the contract next to each annotation — it's the only written record of why each exception is safe.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


const processorEndpoint = await ethers.getContractAt('ProcessorEndpoint', proxyAddress);
const extensionAddress =
process.env.PROCESSOR_ENDPOINT_EXTENSION || (await processorEndpoint.extension());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From Claude: PROCESSOR_ENDPOINT_EXTENSION defaults to await processorEndpoint.extension().
Change ProcessorEndpointExtension.sol, forget the env var, and you deploy a
new implementation delegating to the old extension bytecode — no revert, no
plugin check. The script prints the address, but a print isn't a guard.
Compare the locally compiled extension bytecode against
getCode(extensionAddress) and refuse on mismatch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@saratnt
saratnt merged commit 25af7d6 into dev Sep 4, 2026
3 checks passed
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