Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions src/beacon/L2SlashingConnector.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { ICrossChainMessenger } from "./interfaces/ICrossChainMessenger.sol";

interface IBeaconSlashPod {
function beaconChainSlashingFactor() external view returns (uint64);
/// @notice Parked execution-layer ETH (in gwei) the pod has accounted via checkpoints.
/// @dev Tips, partial withdrawals and exited principal already in pod custody. This ETH
/// cannot be slashed on the beacon chain, so it must be excluded from the slash base.
function withdrawableRestakedExecutionLayerGwei() external view returns (uint64);
}

/// @title L2SlashingConnector
Expand Down Expand Up @@ -34,6 +38,10 @@ contract L2SlashingConnector {
error UnsupportedDestinationChain();
error MessengerNotConfigured();
error UnknownPod(address pod);
/// @dev `registerPodOperator` / `batchRegisterPodOperators` reject an operator the
/// PodManager does not recognise, closing the BCN-004 vector where the owner could
/// map an arbitrary pod to an arbitrary address and ship slashing against it.
error NotOperator(address operator);
error SlashingFactorMismatch(address pod, uint64 expected, uint64 provided);
/// @dev The factor delta resolves to a zero-bps L2 slash (operator has no L2 stake,
/// or the loss is sub-bps and truncates to 0). The L2 receiver hard-reverts on a
Expand Down Expand Up @@ -190,6 +198,15 @@ contract L2SlashingConnector {
/// @notice Batch propagate multiple beacon slashings
/// @param pods Array of pods to process
/// @param newSlashingFactors Corresponding slashing factors
/// @dev Each self-call is funded with the full call-attributable balance so the bridge
/// fee is actually paid. Previously the self-calls forwarded `{value: 0}`, so with a
/// non-zero relay fee (the production case for OP-Stack/Arbitrum L1→L2 bridges) every
/// `_propagateBeaconSlashing` reverted `InsufficientFee`, the `try/catch` swallowed
/// it, and the batch silently advanced ZERO slashing factors. `_propagateBeaconSlashing`
/// refunds its own excess to `msg.sender` — which for these self-calls is this contract
/// — so unspent value returns here and is forwarded to the next pod; any final
/// remainder is swept back to the caller. Pre-existing contract funds (`baseline`) are
/// never touched.
function batchPropagateBeaconSlashing(
address[] calldata pods,
uint64[] calldata newSlashingFactors
Expand All @@ -200,13 +217,25 @@ contract L2SlashingConnector {
{
require(pods.length == newSlashingFactors.length, "Length mismatch");

uint256 baseline = address(this).balance - msg.value;

for (uint256 i = 0; i < pods.length; i++) {
// Try to propagate, continue on failure
try this.propagateBeaconSlashingInternal{ value: 0 }(
// Forward the whole call-attributable balance; the self-call refunds its unused
// portion back here (refund target is this contract), so the next iteration can
// reuse it. A caught revert rolls back its value transfer, leaving the balance intact.
uint256 forwardValue = address(this).balance - baseline;
try this.propagateBeaconSlashingInternal{ value: forwardValue }(
pods[i], newSlashingFactors[i], defaultDestinationChainId
) { }
catch { }
}

// Sweep any unspent fee budget back to the caller, leaving pre-existing funds in place.
uint256 leftover = address(this).balance - baseline;
if (leftover > 0) {
(bool success,) = msg.sender.call{ value: leftover }("");
require(success, "Refund failed");
}
}

/// @notice Internal propagation (for try/catch in batch)
Expand Down Expand Up @@ -270,7 +299,7 @@ contract L2SlashingConnector {
// it to. This bounds the on-L2 slash to the pod's contribution, eliminating the
// base mismatch (whole-stake over-slash) and the multi-pod amplification where
// each pod independently slashed a share of the shared total stake.
uint256 podPrincipal = podManager.totalAssetsOf(podManager.podToOwner(pod));
uint256 podPrincipal = _podBeaconPrincipal(pod);
uint256 podSlashAmount = (podPrincipal * slashPercentage) / 1e18;

// Operator's TOTAL L2 stake (self + delegated) — the exact base that L2's
Expand Down Expand Up @@ -363,7 +392,7 @@ contract L2SlashingConnector {

// Mirror propagation: scale the slash to the pod's own contribution, then
// re-express against the operator's total L2 stake (see `_propagateBeaconSlashing`).
uint256 podPrincipal = podManager.totalAssetsOf(podManager.podToOwner(pod));
uint256 podPrincipal = _podBeaconPrincipal(pod);
uint256 podSlashAmount = (podPrincipal * slashPercentage) / 1e18;
uint256 operatorStake = podManager.getOperatorStake(operator);
if (operatorStake == 0) return 0;
Expand Down Expand Up @@ -412,7 +441,7 @@ contract L2SlashingConnector {
// (slashBps is a fixed-width uint16, so its magnitude does not change payload
// size; this keeps the estimate consistent with the value actually shipped.)
uint256 slashPercentage = (uint256(lastFactor - newSlashingFactor) * 1e18) / lastFactor;
uint256 podPrincipal = podManager.totalAssetsOf(podManager.podToOwner(pod));
uint256 podPrincipal = _podBeaconPrincipal(pod);
uint256 podSlashAmount = (podPrincipal * slashPercentage) / 1e18;
uint256 operatorStake = operator == address(0) ? 0 : podManager.getOperatorStake(operator);
uint16 slashBps = 0;
Expand Down Expand Up @@ -454,20 +483,36 @@ contract L2SlashingConnector {
}

/// @notice Register pod to operator mapping
/// @dev BCN-004: validate both legs against the PodManager so the owner cannot map an
/// unknown/arbitrary pod to an arbitrary address and then ship a slash against it.
function registerPodOperator(address pod, address operator) external onlyOwner {
_validatePodOperator(pod, operator);
podOperator[pod] = operator;
}

/// @notice Batch register pod to operator mappings
function batchRegisterPodOperators(address[] calldata pods, address[] calldata operators) external onlyOwner {
require(pods.length == operators.length, "Length mismatch");
for (uint256 i = 0; i < pods.length; i++) {
_validatePodOperator(pods[i], operators[i]);
podOperator[pods[i]] = operators[i];
}
}

/// @notice Reject registrations the PodManager does not back: a real pod (non-zero
/// owner) mapped to a registered operator. Closes the BCN-004 amplification
/// where the owner could fabricate pod→operator pairs to target victims.
function _validatePodOperator(address pod, address operator) internal view {
if (pod == address(0) || operator == address(0)) revert ZeroAddress();
if (podManager.podToOwner(pod) == address(0)) revert UnknownPod(pod);
if (!podManager.isOperator(operator)) revert NotOperator(operator);
}

/// @notice Update the slashing oracle address
/// @dev BCN-002: reject the zero address so the oracle role cannot be silently bricked
/// (mirrors the `transferOwnership` guard below).
function setSlashingOracle(address newOracle) external onlyOwner {
if (newOracle == address(0)) revert ZeroAddress();
address oldOracle = slashingOracle;
slashingOracle = newOracle;
emit SlashingOracleUpdated(oldOracle, newOracle);
Expand All @@ -483,6 +528,20 @@ contract L2SlashingConnector {
// INTERNAL FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════

/// @notice Beacon principal (in wei) still exposed to beacon-chain slashing for `pod`.
/// @dev BCN-001: `totalAssetsOf` aggregates beacon principal PLUS parked execution-layer
/// ETH (tips, partial withdrawals and exited principal already in pod custody,
/// credited via `recordBeaconChainRebase`). That parked ETH can never be slashed on
/// the beacon chain, so including it in the slash base over-states the loss and lets
/// a modest beacon slash saturate `slashBps` at 100% of the operator's L2 stake.
/// Slash only against on-beacon principal: `totalAssets` minus the parked tally the
/// pod tracks (`withdrawableRestakedExecutionLayerGwei`).
function _podBeaconPrincipal(address pod) internal view returns (uint256) {
uint256 totalAssets = podManager.totalAssetsOf(podManager.podToOwner(pod));
uint256 parkedWei = uint256(IBeaconSlashPod(pod).withdrawableRestakedExecutionLayerGwei()) * 1 gwei;
return totalAssets > parkedWei ? totalAssets - parkedWei : 0;
}

/// @notice Get the operator address for a pod
function _getOperatorForPod(address pod) internal view returns (address) {
address operator = podOperator[pod];
Expand Down
36 changes: 34 additions & 2 deletions src/beacon/ValidatorPod.sol
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ contract ValidatorPod is ReentrancyGuard {
/// @notice Authorized proof submitter (optional, for third-party proof submission)
address public proofSubmitter;

/// @notice Beacon principal (in wei) burned by an L2/service slash that remains
/// physically in this pod.
/// @dev A service slash reduces the manager's beacon-pool `totalAssets` bookkeeping (the
/// escrow burn in `ValidatorPodManager.completeUndelegation`) but moves no ETH out of
/// the pod, so the slashed principal is stranded here and is economically destroyed.
/// `withdrawNonBeaconChainEth` floors withdrawals at `totalAssetsOf + this`, so the
/// stranded principal can never be re-extracted as "non-beacon surplus" once
/// `totalAssets` drops. Monotonically increasing; the manager is the only writer.
uint256 public slashedPrincipalRetainedWei;

// ═══════════════════════════════════════════════════════════════════════════
// EVENTS
// ═══════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -124,6 +134,7 @@ contract ValidatorPod is ReentrancyGuard {
error NotOwnerOrProofSubmitter();
error ValidatorNotSlashed();
error CurrentlyInCheckpoint();
error ZeroAddress();

// ═══════════════════════════════════════════════════════════════════════════
// MODIFIERS
Expand Down Expand Up @@ -512,7 +523,10 @@ contract ValidatorPod is ReentrancyGuard {
revert InsufficientBalance();
}

uint256 reservedPrincipal = podManager.totalAssetsOf(podOwner);
// Floor includes principal burned by a service slash that is still physically here:
// `totalAssetsOf` drops on the manager's escrow burn but the ETH never left the pod,
// so without this term the owner could drain the slashed principal as fake surplus.
uint256 reservedPrincipal = podManager.totalAssetsOf(podOwner) + slashedPrincipalRetainedWei;
uint256 surplus = balance > reservedPrincipal ? balance - reservedPrincipal : 0;
if (amount > surplus) {
revert InsufficientBalance();
Expand All @@ -524,11 +538,29 @@ contract ValidatorPod is ReentrancyGuard {
emit NonBeaconChainETHWithdrawn(recipient, amount);
}

/// @notice Record beacon principal burned by a service slash that stays in this pod.
/// @dev Called by the manager from `completeUndelegation` when it burns the slashed
/// portion of a delegator's escrow shares. The corresponding ETH is not moved out
/// of the pod, so it is added to the `withdrawNonBeaconChainEth` floor to keep it
/// un-extractable (closes the drain-slashed-principal finding). Only the manager
/// can call this; it is the sole writer of `slashedPrincipalRetainedWei`.
function recordSlashedPrincipalRetained(uint256 amount) external onlyPodManager {
if (amount > 0) {
slashedPrincipalRetainedWei += amount;
}
}

/// @notice Recover ERC20 tokens accidentally sent to this pod
/// @param token Token to recover
/// @param recipient Address to send tokens to
/// @param amount Amount to recover
function recoverTokens(IERC20 token, address recipient, uint256 amount) external onlyPodOwner {
/// @dev BCN-003: pods are designed to custody ETH only — any ERC20 here is an accidental
/// transfer. `nonReentrant` + a non-zero recipient guard harden the recovery path.
/// If the protocol ever routes accounted ERC20s (LSTs, reward/slash-proceeds tokens)
/// into pods, this function MUST gain a reservation against that accounting before
/// those flows ship, exactly as `withdrawNonBeaconChainEth` reserves beacon principal.
function recoverTokens(IERC20 token, address recipient, uint256 amount) external onlyPodOwner nonReentrant {
if (recipient == address(0)) revert ZeroAddress();
token.safeTransfer(recipient, amount);
}

Expand Down
10 changes: 10 additions & 0 deletions src/beacon/ValidatorPodManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,16 @@ contract ValidatorPodManager is IStaking, Ownable, ReentrancyGuard {
bp.totalAssets = burnAssets >= bp.totalAssets ? 0 : bp.totalAssets - burnAssets;
// forge-lint: disable-next-line(unsafe-typecast)
emit BeaconRebase(msg.sender, -int256(burnAssets), bp.totalAssets, bp.totalShares);

// The burn lowered `totalAssets`, but the slashed ETH (if it has physically
// arrived) is still in the pod. Tell the pod to floor `withdrawNonBeaconChainEth`
// at the burned amount so the owner cannot drain the slashed principal as fake
// "non-beacon surplus" once the floor drops. Without this the service slash is
// non-punitive: the owner re-extracts 100% of principal despite the slash.
address podAddr = ownerToPod[msg.sender];
if (podAddr != address(0) && burnAssets > 0) {
ValidatorPod(payable(podAddr)).recordSlashedPrincipalRetained(burnAssets);
}
}
}

Expand Down
48 changes: 28 additions & 20 deletions test/audit/medlow/BeaconL2.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,21 @@ contract MockPodManager {
mapping(address => address) public podToOwner;
mapping(address => uint256) internal _totalAssetsOf;
mapping(address => uint256) internal _operatorStake;
mapping(address => bool) internal _isOperator;

function setPodOwner(address pod, address owner) external {
podToOwner[pod] = owner;
}

function setOperator(address operator, bool registered) external {
_isOperator[operator] = registered;
}

/// @notice Used by the connector's BCN-004 registration validation.
function isOperator(address operator) external view returns (bool) {
return _isOperator[operator];
}

function setTotalAssetsOf(address owner, uint256 amount) external {
_totalAssetsOf[owner] = amount;
}
Expand All @@ -60,14 +70,24 @@ contract MockPodManager {
/// @notice Pod that reports a controllable `beaconChainSlashingFactor`.
contract MockSlashPod {
uint64 public factor;
uint64 public parkedGwei;

function setFactor(uint64 f) external {
factor = f;
}

function setParkedGwei(uint64 g) external {
parkedGwei = g;
}

function beaconChainSlashingFactor() external view returns (uint64) {
return factor;
}

/// @notice Parked execution-layer tally the connector subtracts from the slash base.
function withdrawableRestakedExecutionLayerGwei() external view returns (uint64) {
return parkedGwei;
}
}

/// @notice Cross-chain messenger that always quotes a zero fee and records the last payload.
Expand All @@ -79,16 +99,7 @@ contract MockMessenger {
return 0;
}

function sendMessage(
uint256,
address,
bytes calldata payload,
uint256
)
external
payable
returns (bytes32)
{
function sendMessage(uint256, address, bytes calldata payload, uint256) external payable returns (bytes32) {
lastPayload = payload;
sendCount += 1;
return keccak256(payload);
Expand Down Expand Up @@ -150,9 +161,12 @@ contract BeaconL2ConnectorAuditTest is Test {
connector.setMessenger(address(messenger));
connector.setChainConfig(DEST_CHAIN, makeAddr("receiver"), 200_000, true);
connector.setDefaultDestinationChain(DEST_CHAIN);
connector.registerPodOperator(address(pod), operator);

// BCN-004: registration now validates the pod has a known owner and the operator is
// registered, so seed both on the mock manager BEFORE registering the pod→operator.
podManager.setPodOwner(address(pod), podOwner);
podManager.setOperator(operator, true);
connector.registerPodOperator(address(pod), operator);
}

/// @notice A factor decrease that resolves to 0 bps (operator has zero L2 stake) MUST revert
Expand All @@ -170,9 +184,7 @@ contract BeaconL2ConnectorAuditTest is Test {
assertEq(connector.lastProcessedSlashingFactorByChain(address(pod), DEST_CHAIN), 0, "baseline starts unset");

vm.prank(oracle);
vm.expectRevert(
abi.encodeWithSelector(L2SlashingConnector.NothingToSlash.selector, address(pod), DEST_CHAIN)
);
vm.expectRevert(abi.encodeWithSelector(L2SlashingConnector.NothingToSlash.selector, address(pod), DEST_CHAIN));
connector.propagateBeaconSlashing(address(pod), newFactor);

// SECURE INVARIANT 1: no message was shipped (no wasted bridge fee on a guaranteed-reject).
Expand Down Expand Up @@ -200,9 +212,7 @@ contract BeaconL2ConnectorAuditTest is Test {
pod.setFactor(newFactor);

vm.prank(oracle);
vm.expectRevert(
abi.encodeWithSelector(L2SlashingConnector.NothingToSlash.selector, address(pod), DEST_CHAIN)
);
vm.expectRevert(abi.encodeWithSelector(L2SlashingConnector.NothingToSlash.selector, address(pod), DEST_CHAIN));
connector.propagateBeaconSlashing(address(pod), newFactor);

// Operator re-acquires L2 stake; the identical factor delta must now be propagable.
Expand Down Expand Up @@ -236,9 +246,7 @@ contract BeaconL2ConnectorAuditTest is Test {
connector.propagateBeaconSlashing(address(pod), newFactor);

assertEq(messenger.sendCount(), 1, "real slash ships exactly one message");
assertEq(
connector.lastProcessedSlashingFactorByChain(address(pod), DEST_CHAIN), newFactor, "baseline advanced"
);
assertEq(connector.lastProcessedSlashingFactorByChain(address(pod), DEST_CHAIN), newFactor, "baseline advanced");
}
}

Expand Down
Loading
Loading