Skip to content

feat(beacon): UUPS upgradeable L2 slashing receivers (C-3)#128

Closed
drewstone wants to merge 1 commit into
mainfrom
feat/c3-uups-receiver
Closed

feat(beacon): UUPS upgradeable L2 slashing receivers (C-3)#128
drewstone wants to merge 1 commit into
mainfrom
feat/c3-uups-receiver

Conversation

@drewstone
Copy link
Copy Markdown
Contributor

Summary

Round 4 audit fix C-3. Convert the immutable L2 slashing receivers to UUPS upgradeable so post-mainnet bug remediation does not require re-deploying every bridge endpoint and re-authorising every sender — an operational nightmare that would also leak slashing windows.

Five contracts are converted:

  • L2SlashingReceiver (src/beacon/L2SlashingReceiver.sol) — the canonical destination receiver applied to operator stake on the L2.
  • The four bridge adapter shims that route L1→L2 messages into the canonical receiver: ArbitrumL2Receiver, BaseL2Receiver, HyperlaneReceiver, LayerZeroReceiver.

Each contract now:

  • Inherits Initializable + UUPSUpgradeable + OwnableUpgradeable, matching the canonical Tangle UUPS pattern.
  • Holds all mutable state in a single ERC-7201 namespaced struct (tangle.beacon.L2SlashingReceiver, tangle.beacon.bridges.*Receiver) plus a 50-slot __gap.
  • Disables initializers on the implementation (_disableInitializers() in the constructor).
  • Routes upgrade authority through onlyOwner. The owner is the same role gating sender / messenger / slasher mutations, so upgrade authority is no broader than existing admin authority — production owner should be a multisig or timelock.

Init order: ownership is granted before any other configuration step so setMessenger / setAuthorizedSender / setTrustedSender / setPeer are gated from block one. The H-4 timelock on messenger / slasher swaps and the bootstrap exemption that allows the first setMessenger / setSlasher to write immediately are preserved.

Migration story

The receivers are not yet on mainnet, so this PR explicitly does not carry an in-place storage layout-compatibility shim. Operators redeploying onto a chain that had a previous (non-UUPS) receiver must:

  1. Deploy the new impl.
  2. Deploy a fresh ERC1967Proxy with initialize(slasher, address(0), owner).
  3. Re-authorise the L1 senders (subject to the existing 2-day timelock).
  4. Update the L1 connector's setChainConfig to point at the new proxy.

This is documented in bindings/CHANGELOG.md.

Per-contract changes

Contract New initializer signature
L2SlashingReceiver initialize(address slasher, address messenger, address owner)
ArbitrumL2Receiver initialize(address l1Sender, address receiver, uint256 sourceChainId, address owner)
BaseL2Receiver initialize(address l2Messenger, address l1Sender, address receiver, uint256 sourceChainId, address owner)
HyperlaneReceiver initialize(address mailbox, address receiver, address owner)
LayerZeroReceiver initialize(address endpoint, address receiver, address owner)

Owner-gated functions revert with OwnableUnauthorizedAccount(account) instead of "Only owner". transferOwnership(address(0)) reverts with OwnableInvalidOwner.

Deploy script (script/DeployL2Slashing.s.sol) now deploys impl + ERC1967Proxy and encodes initialize(...) calldata for all four bridge variants. The deployer is wired as initial owner so the post-deploy configuration succeeds, then ownership is transferred to admin.

Test coverage

  • test/beacon/L2SlashingReceiverUUPS.t.sol (new, 18 tests):
    • Initialization is one-shot for all five receivers (re-init reverts).
    • Implementation contracts directly cannot be initialized.
    • Upgrade authorisation: owner succeeds; non-owner reverts with OwnableUnauthorizedAccount.
    • State persistence across upgrade for L2SlashingReceiver, HyperlaneReceiver, LayerZeroReceiver.
    • ERC-7201 slot sanity: vm.load on the namespaced slot for L2SlashingReceiver confirms slasher and messenger land at slot+0 / slot+1, and slot 0 is empty.
    • ZeroAddress() reverts on init with zero owner.
  • All existing tests that touched these receivers (CrossChainMessengersTest, CrossChainSlashingTest, L2SlashingSilentDedupTest, DeploymentScriptsTest) were updated to deploy via ERC1967Proxy and to expect the OwnableUnauthorizedAccount error for owner-only paths.

Verified locally

Suite Result
test/beacon/L2SlashingReceiverUUPS.t.sol 18 / 18 PASS
test/beacon/bridges/CrossChainMessengersTest.t.sol 46 / 46 PASS
test/beacon/CrossChainSlashingTest.t.sol 39 / 39 PASS
test/security/L2SlashingSilentDedupTest.t.sol 2 / 2 PASS
test/scripts/DeploymentScriptsTest.t.sol 9 / 9 PASS

Bindings + versioning

  • cargo xtask gen-bindings re-run; bindings/abi/*.json reflect current artefact ordering.
  • bindings/Cargo.toml + fixtures/Cargo.toml bumped to 0.15.0 — the coordinated minor for all four Round 4 PRs.
  • bindings/CHANGELOG.md carries a BREAKING entry describing the receiver init-signature change and migration.
  • Note: the receivers themselves are not part of the Rust binding crate (which only re-exports ITangle* and MultiAssetDelegation), so no Rust API surface changes; the version bump is for parity with the protocol bump.

Test plan

  • FOUNDRY_PROFILE=local_build forge build clean.
  • All directly-affected tests pass (114 tests across 5 files; see table above).
  • cargo build --release -p tnt-core-bindings clean at v0.15.0.
  • forge test full suite (running locally; broader suite does not reference these contracts so a green directly-affected slice is the main signal).

… v0.15.0

Round 4 audit fix C-3. The five L2-side receivers that accept cross-chain
slashing and bridge messages were immutable, so any post-mainnet bug
required redeploying the receiver and re-authorising every sender on every
bridge. They are now UUPS upgradeable, with state moved to ERC-7201
namespaced slots and `_authorizeUpgrade` gated on the existing owner role.

Contracts converted:
  - src/beacon/L2SlashingReceiver.sol (`tangle.beacon.L2SlashingReceiver`)
  - src/beacon/bridges/{Arbitrum,Base,Hyperlane,LayerZero}*Receiver
    (`tangle.beacon.bridges.<name>`)

Each contract:
  - Inherits Initializable + UUPSUpgradeable + OwnableUpgradeable.
  - Disables initializers on the implementation (`_disableInitializers()`
    in the constructor).
  - Holds all mutable state in a single namespaced struct + 50-slot
    `__gap` for forward compatibility.
  - Routes upgrade authority through `onlyOwner` (production owner should
    be a multisig / timelock; the receiver's H-4 SENDER_ACTIVATION_DELAY
    timelock on messenger / slasher swaps remains unchanged).

Init order: ownership is granted before any other initialization step so
the configuration calls that follow (`setMessenger`, `setAuthorizedSender`,
`setTrustedSender`, `setPeer`, ...) are guarded from block one. The
bootstrap exemption that allows `setMessenger`/`setSlasher` to write
immediately when the current value is zero is preserved so deploy scripts
do not deadlock for two days.

Migration: existing deployments must be re-deployed behind a fresh
ERC1967 proxy. The receivers were not yet on mainnet, so there is no
in-place storage migration path to support; this is documented in the
bindings CHANGELOG.

Deploy + tests:
  - script/DeployL2Slashing.s.sol now deploys impl + ERC1967 proxy and
    encodes `initialize(...)` calldata for all four bridge variants.
  - New test/beacon/L2SlashingReceiverUUPS.t.sol covers init-is-one-shot,
    impl-cannot-be-initialized, owner-gated upgrade, attacker-rejected
    upgrade, state-persistence-across-upgrade, and ERC-7201 slot
    placement (vm.load on the namespaced slot).
  - All 5 affected existing test files were updated to deploy via proxy
    and to expect `OwnableUnauthorizedAccount` instead of "Only owner"
    for the receivers.

Bindings + versioning:
  - cargo xtask gen-bindings re-run.
  - bindings/Cargo.toml + fixtures/Cargo.toml bumped to 0.15.0
    (coordinated minor for all four Round 4 PRs).
  - CHANGELOG entry added.
@drewstone
Copy link
Copy Markdown
Contributor Author

Superseded by #129 — consolidated PR rolling C-3 + F5 + G-02 into a single coordinated bindings v0.15.0 cut. The CI failure here was a SIGTERM at the 25-minute mark on the resource-heavy via-IR forge build; the consolidated branch ships an identical C-3 commit and we'll re-run CI from scratch on #129.

@drewstone drewstone closed this May 10, 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