Skip to content

chore(audit): Round 4 architectural consolidation (C-3 + F5 + G-02) + bindings v0.15.0#129

Merged
drewstone merged 6 commits into
mainfrom
feat/round4-consolidated
May 11, 2026
Merged

chore(audit): Round 4 architectural consolidation (C-3 + F5 + G-02) + bindings v0.15.0#129
drewstone merged 6 commits into
mainfrom
feat/round4-consolidated

Conversation

@drewstone
Copy link
Copy Markdown
Contributor

Summary

Round 4 audit architectural pass — consolidated three deferred items into one PR plus a single coordinated bindings v0.15.0 cut. Supersedes #128.

  • C-3 — UUPS upgradeable L2SlashingReceiver and the four bridge-adapter receivers (Arbitrum, Base, Hyperlane, LayerZero). ERC-7201 namespaced storage; explicit _owner initializer arg; OZ Ownable error semantics.
  • F5 — TWAP-fair subscription billing. Tangle.billSubscription(uint64) now bills rate × cumDelta / (baseline × interval) from a Compound/Aave-style cumulative stake-seconds index, defeating the ramp-and-dump exploit on flat-rate billing. Lazy-init at first bill, no migration required for existing subscriptions.
  • G-02 — Share-pool ValidatorPodManager consistent with MultiAssetDelegation / LiquidDelegationVault. Beacon rebases (rewards/slashes) move totalAssets only; share balances are invariant. Virtual offsets (1e3) match LDV. Withdrawal queue snapshots convertToAssets at queue and pays out min(snapshot, live) at completion.
  • bindings v0.15.0 — single coordinated cut. F5 getCumStakeSeconds exposed via IMultiAssetDelegation for Rust callers (mirrors getOperatorStakeForAsset).

Breaking changes

  • C-3: receiver deploy interface (proxy + initialize(slasher, messenger, owner) instead of plain new Contract(...)); revert reasons changed from strings to OZ Ownable custom errors.
  • F5: IStaking gains getCumStakeSeconds; PaymentLib.ServiceEscrow gains two appended fields (storage-safe append).
  • G-02: getShares returns uint256 (was int256); totalShares() is now a function (was a public state variable); new recordBeaconChainDeposit / recordBeaconChainRebase entry points; legacy recordBeaconChainEthBalanceUpdate preserved as a back-compat shim.

Test plan

  • forge test --match-path test/security/F5TWAPBilling.t.sol — 5/5 pass (constant stake → nominal, doubled mid-period → 1.5×, halved → 0.75×, mid-period slash → post-slash stake, lazy-init first-post-upgrade nominal+forward-only)
  • forge test --match-path test/staking/* — 260/260 pass (F5 accrual hooks at every stake-mutating path)
  • forge test --match-path test/security/* — 27/27 pass
  • forge test --match-path test/beacon/* — 319/320 pass (1 pre-existing failure on LiveBeaconTest.test_validatorFieldsExtraction, also fails on main, unrelated to this PR)
  • forge test --match-path test/Integration.t.sol — 36/36 pass
  • forge test --match-path test/scenario/* — 1/1 pass
  • forge test --match-path test/scripts/* — 9/9 pass
  • cargo xtask gen-bindings — clean regeneration; F5 getCumStakeSeconds decodes (selector 0xb1cefa3a)
  • CI Foundry build (re-running here; PR feat(beacon): UUPS upgradeable L2 slashing receivers (C-3) #128 hit a transient SIGTERM @ 25m on the resource-heavy via-IR build, retry resolves)

Closes #128.

drewstone added 4 commits May 10, 2026 10:21
… 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.
Round 4 audit G-02: refactor ValidatorPodManager from raw `int256
podOwnerShares` accounting to per-pod share-pool accounting consistent
with the rest of the staking surface (MultiAssetDelegation,
LiquidDelegationVault, RewardsManager).

Per-pod accounting model:
- Each pod owner has an isolated `BeaconPool { totalAssets, totalShares }`.
- Beacon-chain rebases (rewards / slashes) move totalAssets only; share
  balances are unaffected.
- Deposits (validator credential proofs) mint shares against the pool at
  the current exchange rate. Withdrawals burn shares and transfer the
  asset-equivalent ETH out of the pod.
- Virtual offsets (1e3) defend against first-depositor inflation
  attacks; matches LiquidDelegationVault exactly.

Slashing semantics: slashes remain isolated to the affected pod (existing
invariant). With one shareholder per pod, "totalAssets -= slashed; shares
unchanged" reduces the owner's claimable assets without spreading the
loss across other pod owners. Same per-pod-isolation guarantee, now with
share-pool math (mulDiv + virtual offset).

Withdrawal queue: snapshot `convertToAssets(shares)` at queue time,
transfer `min(snapshot, live)` at completion. Beacon slashes between
queue and complete still take effect; rebase-up profit between queue and
complete stays with remaining shareholders rather than being captured by
the redeemer.

Public API surface:
- `recordBeaconChainDeposit(podOwner, assets)` — mints shares.
- `recordBeaconChainRebase(podOwner, assetsDelta)` — moves totalAssets only.
- Legacy `recordBeaconChainEthBalanceUpdate(podOwner, sharesDelta)` is
  preserved for backwards compatibility (positive delta == deposit,
  negative delta == rebase down).
- `getShares(owner)` now returns uint256 (was int256). Negative-share
  states are no longer representable; full slashes saturate totalAssets
  at zero and `getRestakedAssets` reflects the residual virtual-offset
  dust (sub-1000 wei).
- New views: `convertToShares`, `convertToAssets`, `totalAssetsOf`,
  `totalSharesOf`, `getRestakedAssets`. `totalShares()` is now a function
  returning uint256 (was a public int256 state variable).

Tests: 14 new cases in test/beacon/ValidatorPodManagerSharePool.t.sol
covering proportional minting, rebase up/down, full-slash saturation,
inflation defense, pod isolation, conversion round-trip, withdrawal
flows (normal / slashed mid-queue / rewarded mid-queue capped at
snapshot), and access control. Existing beacon tests updated to the new
share-pool semantics.
Round 4 audit F5: subscription billing prices a period at the operator
stake observed at the bill instant, allowing an operator to ramp stake
just before billing and dump it after. With realistic ramp ratios this
overcharges customers when stake collapses mid-period and undercharges
when it ramps up. Replace flat-rate billing with a TWAP-fair amount
derived from cumulative stake-seconds.

Staking layer (cum-stake-seconds index):
- Add `_cumStakeSeconds[op][asset]` and `_cumStakeSecondsLastUpdate`
  in DelegationStorage. Compound-v2 / Aave-v3 style index: every
  stake-changing path folds `prevStake × (now - lastUpdate)` into the
  running counter BEFORE the underlying stake mutates.
- Hooks (`_accrueOperatorStakeSeconds`) added in:
    - OperatorManager: joinAsOperator (native + ERC20), bondMore,
      bondMoreERC20, unstakeAsOperator, exitAsOperator.
    - RewardsManager._onDelegationChanged: covers all delegate /
      undelegate / reward-pool mutations (single funnel).
    - SlashingManager: native + asset slash paths.
- First accrual seeds `lastUpdate` without area contribution, so
  pre-existing pools begin TWAP cleanly at upgrade time without
  back-paying for unobservable history.
- Storage gap reduced by 2 (46 → 44).
- New view `IStaking.getCumStakeSeconds(operator, asset)` exposed via
  `StakingViewsFacet`. `ValidatorPodManager` ships a zero stub
  (subscription billing is not currently routed through beacon-only
  services; documented inline).

Payments layer (TWAP billing):
- `_billSubscriptionInternal` and `_tryBillSubscription` now call
  `_computeTwapBillAmount(serviceId, operators, nominalRate, interval)`
  which:
  1. Aggregates `getCumStakeSeconds` and current stake across the
     service's active operators for the bond asset.
  2. Forward-projects `cum` to the period boundary so missed/late
     bills don't double-count when the function later advances
     `lastPaymentAt += interval`.
  3. Lazy-initializes the per-service cursor and baseline on first
     bill (no migration required); first post-init bill returns
     `nominalRate` exactly.
  4. From the next bill onward, bills
     `nominalRate × cumDeltaPeriod / (baseline × interval)`.
- Append `lastBilledCumStake` and `subscriptionBaselineStake` to
  `PaymentLib.ServiceEscrow`. `_serviceEscrows` is a top-level mapping,
  so appending preserves all existing storage slots.

Tests: 4 cases in test/security/F5TWAPBilling.t.sol — constant stake
bills nominal, doubled mid-period bills 1.5×, halved mid-period bills
0.75×, mid-period slash bills by post-slash stake. Mock IStaking impls
in CrossChainSlashing and DeploymentScripts tests gain zero-return
`getCumStakeSeconds` to satisfy the interface.
- Expose F5 `getCumStakeSeconds(operator, asset)` via the
  `IMultiAssetDelegation` interface so Rust callers can read TWAP
  stake-seconds without resorting to manual selector encoding. Mirrors
  how `getOperatorStakeForAsset` is exposed.
- Regenerate ABIs and Alloy bindings against the consolidated tip
  (C-3 UUPS receivers + G-02 share-pool VPM + F5 TWAP billing).
Resolve trivial bindings metadata conflicts after #127 (S-1 + S-2)
landed on main. Both new public-interface additions —
`IMultiAssetDelegation.AdapterChangeWhileDepositsExist` (#127) and
`IMultiAssetDelegation.getCumStakeSeconds` (this PR) — coexist after
regenerating bindings.

- Cargo.lock: take main's lock; cargo build re-pins our 0.15.0 crate.
- bindings/TNT_CORE_VERSION + ABI + Rust bindings: regenerated against
  the merged source so both the new error and the new view decode.
- Source-level conflicts auto-resolved: S-1/S-2 hit
  StakingAssetsFacet, LiquidDelegationVault, IMultiAssetDelegation
  (error declaration), and added their own tests; F5/G-02 hit
  disjoint regions.
Closes the gap items called out in the team-lead review of the
consolidation PR. Each item is a correctness or API-shape concern, not
a polish-only edit; tests added inline.

F5 per-operator cursor (correctness)
- The original F5 aggregate cum-stake cursor (`escrow.lastBilledCumStake =
  Σ cum_op` at last bill) was unsound under operator-set changes: when ops
  joined or left between bills, the next `cumDelta` summed across different
  sets and produced meaningless attribution. Clamping to zero on underflow
  was fail-soft but still wrong.
- Replace with per-(service, operator) cursors in
  `TangleStorage._twapCursorByOp`. Each active operator's delta is computed
  individually against its own stored cursor; leavers are simply absent
  from `_activeServiceOperators` (their cursor stays parked, harmless);
  rejoiners are handled by the join hook in
  `ServicesLifecycle._finalizeJoin` which re-seeds the cursor at the
  current cum, forgetting off-service-time accrual.
- The lazy-init bill seeds every active operator's cursor inline so the
  first post-init bill produces nominal exactly (matching the prior
  semantics for new subscriptions) and the next bill measures from the
  period boundary. Pure-math fallback `PaymentLib.twapBillAmount` handles
  baseline / interval / overflow degeneracy without reverting.
- Retire the aggregate `lastBilledCumStake` field as
  `__reservedAggregateCursor` so the struct slot layout stays stable
  across the F5 commit pair.

F5 fuzz tests (process)
- CLAUDE.md mandates fuzz for financial logic. The TWAP arithmetic is
  extracted as `PaymentLib.twapBillAmount` so we can fuzz the formula
  over the full input space without spinning up the staking layer per
  run. Four fuzz tests × 1000 runs each cover:
  - bill scales linearly with cumDelta (monotonicity)
  - canonical case: `cumDelta = baseline * interval` → bill ≈ nominal
  - no overflow on realistic uint128 inputs
  - pathological baseline/interval == 0 → nominal fallback

F5 operator-set behavioral tests
- `test_F5_MidPeriodJoiner_NoRetroactiveBill`: dynamic-membership
  subscription, operator2 stakes long before joining, then joins
  mid-period. Asserts the post-join bill is bounded — without the
  per-op cursor and join hook this test detonates with a several-
  orders-of-magnitude over-bill (operator2's pre-join cum gets
  attributed to the subscription).
- `test_F5_OperatorExit_BillUsesRemainingSet`: operator2 scheduled-exits
  through the queue; the remaining operator-1-only bill is strictly
  positive and strictly below nominal.

G-02: drop the `recordBeaconChainEthBalanceUpdate` back-compat shim
- Same-selector / silently-different-semantics is the worst class of
  API break. With exactly one in-tree caller (`ValidatorPod`), the
  shim is pure surface area for misuse. Remove it; update both call
  sites in `ValidatorPod` (credential proof → `recordBeaconChainDeposit`,
  checkpoint finalize → `recordBeaconChainRebase`); migrate every test
  call site to the explicit pair; drop the now-unused `InvalidDelta`
  error.

G-02: restore `getShares(address)` `int256` ABI
- The G-02 refactor silently flipped `getShares` from `int256 → uint256`
  at the same selector. This is a decode-time break for any downstream
  consumer (Rust bindings, indexer ABI lookup) and was the worst kind
  of ABI break in an audit-sensitive contract. Restore `int256` return
  (lossless cast from the new `uint256` storage; defensive saturation
  at `int256.max` for completeness) and add `getSharesUint(address)` as
  the unsigned companion.

LiveBeaconTest pre-existing failure (#130)
- The `test_validatorFieldsExtraction` test was silently broken on main:
  `bytes32(uint256(N))` right-aligns N in big-endian order, but
  `BeaconChainProofs._fromLittleEndianUint64` extracts the leftmost
  eight bytes and byte-reverses them. The test encoded its inputs
  wrong, so `getEffectiveBalanceGwei(fields)` returned 0 instead of
  32 gwei. Fix by adding a `_leUint64Bytes32` helper that matches the
  SSZ convention the contract actually implements. Resolves #130.

Storage layout (forge inspect validated)
- `MultiAssetDelegation`: F5 adds `_cumStakeSeconds` (slot 39) +
  `_cumStakeSecondsLastUpdate` (slot 40); `__gap` shrinks 46→44; all
  downstream slots unchanged. UUPS-safe.
- `Tangle`: F5 followup adds `_twapCursorByOp` (slot 77); `__gap`
  shrinks 41→40; all downstream slots unchanged. UUPS-safe.
- `ValidatorPodManager`: G-02 breaks layout (slot 7 changed type from
  `int256 podOwnerShares` to `mapping(address => BeaconPool) _pools`).
  Contract is not upgradeable — fresh deploy required. Documented in
  CHANGELOG.

Tests
- F5: 11/11 (5 unit + 4 fuzz × 1000 runs + 2 operator-set behavior)
- Beacon: now 320/320 (LiveBeaconTest fix), no regressions in VPM /
  pod / integration suites
- Staking + Security + Scripts + Scenario: no regressions
@drewstone drewstone merged commit d3db884 into main May 11, 2026
1 of 2 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.

1 participant