BuildSlashing

Slashing

When an operator misbehaves, the protocol burns a fraction of their stake and, proportionally, their delegators’ stake. This page covers the on-chain lifecycle, the authorization surface, dispute economics, and runbooks for operators and the slashing admin.

The slashing path is implemented in:

Lifecycle

                 proposeSlash
                      |
                      v
                 [ Pending ]
                /     |     \
     disputeSlash     |      isExecutable && executeSlash
        |             |              |
        v             v              v
   [ Disputed ]   cancelSlash   [ Executed ]
     |    \           |
     |     \          v
     |    cancelSlash (bond claimable)   [ Cancelled ]
     v
   isExecutable after disputeDeadline
   -> auto-fails, executeSlash succeeds, bond forfeit to treasury

Each transition has a single responsible caller and a single state effect.

proposeSlash(serviceId, operator, slashBps, evidence) (nonReentrant)

Anyone the protocol accepts as a slasher creates a SlashProposal. The proposal:

  1. Validates slashBps <= maxSlashBps and caps it.
  2. Computes the operator’s effective exposure for this service from their per-asset commitments.
  3. Increments two counters: _operatorPendingSlashCount[operator] (in staking, blocks delegator withdrawals) and _operatorActiveSlashProposals[operator] (in Tangle, enforces the per-operator cap).
  4. Sets executeAfter = block.timestamp + disputeWindow.
  5. Calls the blueprint manager’s onUnappliedSlash hook (best effort, capped gas).

If the operator already has maxPendingSlashesPerOperator pending, the call reverts. Default cap is 8.

evidence MUST be non-zero. The contract rejects bytes32(0) evidence so off-chain monitors keying off non-zero evidence never observe silently-zero entries.

disputeSlash(slashId, reason) payable (nonReentrant)

The operator, a SLASH_ADMIN_ROLE holder, or a BSM-declared dispute origin contests the slash within the dispute window.

The operator must post config.disputeBond in native asset. SLASH_ADMIN posts no bond. A BSM-declared bondless dispute origin also posts no bond, unless the blueprint itself authored the slash, in which case the bondless path closes and the full disputeBond is required. The dispute snapshots config.disputeResolutionDeadline onto the proposal (disputeDeadline = block.timestamp + deadline), so a later admin-driven shrink of the live config cannot retroactively shorten the operator’s review window.

A SLASH_ADMIN_ROLE holder that is also the proposer of a slash CANNOT dispute their own proposal. Without this, a single role-holder could propose, immediately self-dispute (no bond), and freeze operator stake for the full disputeResolutionDeadline window - and (when treasury == admin) capture the operator’s bond on auto-execution. To dispute as SLASH_ADMIN, route the dispute through a different admin-keyed account or the operator.

executeSlash(slashId) (permissionless)

Anyone calls this. The proposal’s isExecutable check accepts:

  • A Pending proposal whose executeAfter + TIMESTAMP_BUFFER has elapsed
  • A Disputed proposal whose disputeDeadline + TIMESTAMP_BUFFER has elapsed (symmetric 15s buffer with the dispute window, so a sequencer with timestamp influence cannot sandwich the deadline tick to land an executeSlash before the operator’s dispute window closes)

Execution routes through _executeSlashOnStaking:

  • If the operator made explicit per-asset commitments to the service, calls slashForService and only burns the committed assets proportionally.
  • Otherwise (legacy services with no commitments) falls back to slashForBlueprint.

After execution: pending counters decrement, slash is recorded for metrics, blueprint manager’s onSlash hook fires (capped gas, best effort), and any dispute bond is forwarded to the treasury.

cancelSlash(slashId, reason) (SLASH_ADMIN_ROLE)

The admin agrees with a dispute or otherwise wants to drop a proposal. State transitions complete first, then the bond refund executes. CEI ordering plus nonReentrant defends against a malicious disputer contract re-entering on the refund.

Authorization

FunctionCaller
proposeSlashService owner, blueprint owner, or BSM-declared querySlashingOrigin. Operator must already be in the service (OperatorNotInService revert otherwise). slashBps must be in (0, BPS_DENOMINATOR] and produce non-zero effective bps after exposure scaling.
disputeSlashThe slashed operator (must post bond), SLASH_ADMIN_ROLE (no bond), or a BSM-declared dispute origin (no bond, unless the blueprint authored the slash)
executeSlashAnyone, gated by isExecutable
executeSlashBatchAnyone. Non-executable ids in the batch are SILENTLY SKIPPED, not reverted.
cancelSlashSLASH_ADMIN_ROLE only. Can cancel from Pending or Disputed.
claimDisputeBondAnyone with a credited balance in _pendingDisputeBondRefunds (the disputer after a cancel, or the treasury after a failed push). Withdraws that balance.
setSlashConfigADMIN_ROLE (route through TangleTimelock in production)

The BSM-declared slashing origin is queried dynamically on each proposeSlash call. Operators MUST audit the BSM’s code AND its upgradeability before joining any blueprint that uses one. A BSM whose owner is compromised can grant slashing rights to an attacker. If you do not accept that risk, only join blueprints whose BSM is non-upgradeable.

See Auth Surface for the full role-by-role table across all protocol contracts.

Configuration Parameters

Set with setSlashConfig(disputeWindow, instantSlashEnabled, maxSlashBps, disputeResolutionDeadline, disputeBond, maxPendingSlashesPerOperator). All parameters are admin-tunable and SHOULD be timelocked in production.

ParameterDefaultBoundsPurpose
disputeWindow7 days[1 hour, 30 days]How long after proposeSlash the operator has to dispute
instantSlashEnabledfalseboolReserved for emergencies. The current public proposeSlash hardcodes instant=false, so this flag has no effect through the standard API. A future internal entrypoint (or upgrade) would honor it.
maxSlashBps10000(0, 10000]Hard cap on any single slash proposal
disputeResolutionDeadline14 days[1 day, 60 days]Time SLASH_ADMIN has to resolve a dispute before it auto-fails
disputeBond0.02 etheruint256Native-asset bond required from the operator to dispute. Forfeit to treasury on auto-fail or execute, refunded on cancel
maxPendingSlashesPerOperator8(0, uint16.max]Cap on concurrent pending slashes per operator (anti-spam)

disputeBond ships with a non-zero default (0.02 ether) so an unconfigured deployment is never free to self-dispute. On a live network you SHOULD tune it high enough to deter free self-disputes but low enough that legitimate disputes are not gated by capital.

Operator Runbook

You Received a Slash Proposal

You will see a SlashProposed(slashId, serviceId, operator, ...) event indexed against your operator address. The dispute window has started.

  1. Read the evidence. The evidence field is typically an IPFS CID. Fetch it. Verify whether the alleged misbehavior happened.
  2. Decide whether to dispute.
    • If the slash is correct, do nothing. It will execute after disputeWindow + TIMESTAMP_BUFFER. Your delegators’ withdrawals are blocked until then.
    • If the slash is incorrect, dispute before executeAfter.
  3. To dispute, call disputeSlash{value: config.disputeBond}(slashId, "concise reason"). If you do not post exactly disputeBond, the call reverts.
  4. After disputing, escalate to SLASH_ADMIN. Provide your evidence off-chain. The admin has up to disputeResolutionDeadline to call cancelSlash (credits your bond back to you, claimable) or do nothing (slash auto-executes, bond forfeit).
  5. Claim your bond. cancelSlash does NOT push the bond back to you. It credits _pendingDisputeBondRefunds[disputer]. You must call claimDisputeBond() yourself to receive the ETH. A cancelled slash with an unclaimed bond is the common operator mistake here.
  6. Watch your _operatorPendingSlashCount. If it stays elevated long after a dispute resolved, raise a ticket with protocol governance to investigate.

You Want to Leave the Network

_startLeaving requires BOTH zero pending slashes AND zero active services for the operator (the latter is queried from Tangle via getOperatorTotalActiveServices). If either count is non-zero you must resolve first.

If you were slashed below minOperatorStake and your status flipped to Inactive, you can still call _startLeaving to exit through the standard delay. The remaining stake is returned at _completeLeaving time.

SLASH_ADMIN Runbook

SLASH_ADMIN_ROLE is the protocol’s slashing oversight role. It SHOULD be a multisig distinct from ADMIN_ROLE and UPGRADER_ROLE.

Reviewing a Dispute

A SlashDisputed(slashId, disputer, reason) event fires. You have disputeResolutionDeadline (default 14 days) to act.

  1. Read the dispute reason and the original evidence.
  2. Off-chain communication with the operator and the slash proposer is expected.
  3. If the dispute is valid: cancelSlash(slashId, "summary"). The bond is credited to the disputer’s pull-claimable balance (_pendingDisputeBondRefunds) and pending counters decrement. Tell the disputer to call claimDisputeBond(); the protocol never pushes the ETH back for them.
  4. If the dispute is spurious: do nothing. After the deadline passes, anyone can call executeSlash, the slash applies, and the bond is forfeit to the treasury.
  5. To force-resolve early in the spurious case: call cancelSlash only if you mean to drop the proposal entirely. There is no “reject the dispute” path. The auto-execution path is the right answer when the operator is wrong.

Detecting Stuck State

If _operatorPendingSlashCount[op] stays elevated and there is no matching Pending/Disputed proposal, the counter has drifted. Recovery:

  • Investigate whether a slash was cancelled or executed without decrementing the counter (would indicate a protocol bug; file an issue).
  • As a last resort, SlashingManager.resetPendingSlashCount(operator, count) corrects the counter. Default implementation reverts; an admin override SHOULD be deployed only when a real drift is observed and only by governance.

Dispute Economics

The dispute bond is the only economic counter to free DoS by the operator. Without a bond, an operator slashed for misbehavior could:

  1. Be slashed
  2. Self-dispute (free)
  3. Lock all their delegators for disputeResolutionDeadline
  4. Continue extracting value during the window

With the bond, in the common case:

  • A spurious self-dispute costs the operator disputeBond, forfeit to treasury when the dispute auto-fails.
  • A legitimate dispute is credited back to the operator on cancelSlash. The refund is pull-based: cancelSlash only credits _pendingDisputeBondRefunds[disputer], and the operator calls claimDisputeBond() to withdraw it.

Edge cases (handled by _settleDisputeBond):

  • If _treasury == address(0) when a forfeit would occur, the bond is credited to _pendingDisputeBondRefunds[disputer] instead of being stranded, and the disputer drains it with claimDisputeBond(). It is only restored on the proposal in the unreachable case where the disputer is also address(0). Configure the treasury before any forfeit-eligible slash auto-fails.
  • If the treasury push (treasury.call) reverts, the bond is credited to _pendingDisputeBondRefunds[treasury], NOT restored on the proposal. There is no push to the disputer anywhere in _settleDisputeBond; every refund path is pull-based. The bond is never silently lost, but it is never delivered without the credited address calling claimDisputeBond().
  • If proposal.disputer == address(0) on a refund call (no disputer recorded), the bond falls through to treasury rather than reverting.

Setting disputeBond correctly is a governance call. Too low and DoS is cheap. Too high and operators with legitimate disputes cannot afford to defend themselves.

The deadline (disputeResolutionDeadline) matters because without auto-resolution, a compromised, lost-key, or forgetful SLASH_ADMIN would create a permanent lockup vector. The protocol prefers automatic forward progress over indefinite adjudication.

Per-Asset Commitment Slashing

When an operator joins a service via approveService(ApprovalParams) with a non-empty securityCommitments, they declare per-asset exposure (AssetSecurityCommitment[]). On slash:

  • _executeSlashOnStaking loads the commitment snapshot taken at proposeSlash time (_slashCommitmentSnapshots[slashId]), not live storage, so commitment changes after the proposal cannot shrink the slash.
  • If commitments exist, it routes to slashForService(operator, blueprintId, serviceId, commitments, slashBps, evidence) which only burns the committed assets proportionally to commitment.exposureBps.
  • If no commitments exist (legacy services), it falls back to slashForBlueprint which burns all enabled assets uniformly.

This differs from prior behavior, which slashed all enabled assets uniformly. An operator who committed TNT@5000bps and USDC@2000bps to service A but only TNT@5000bps to service B will have only TNT slashed for a service-B violation.

What can change without redeployment

The Tangle contract is UUPS-upgradeable through TangleTimelock. The slashing model can evolve without redeployment:

  • Config tunings (no upgrade): all six SlashConfig fields via setSlashConfig
  • Behavior changes (UUPS upgrade through timelock):
    • Operator-specific emergency freeze
    • Slash escrow with redistribution to harmed parties
    • Migration to UMA optimistic oracle, Kleros, or multi-tier escalation
    • Per-blueprint maxSlashBps overrides
    • Cumulative slash caps per operator per epoch

Storage gaps are left in TangleStorage and MBSMRegistry with the discipline documented in Upgrade Discipline. Adding fields to SlashProposal or SlashConfig is non-destructive when appended (the structs are stored in mappings, not packed arrays).

Reference