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:
src/core/Slashing.sol— propose, dispute, execute, cancelsrc/libraries/SlashingLib.sol— proposal struct, status state machine, executability rulessrc/staking/SlashingManager.sol— actual stake reduction (_slashForBlueprint,_slashForService)
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 treasuryEach 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:
- Validates
slashBps <= maxSlashBpsand caps it. - Computes the operator’s effective exposure for this service from their per-asset commitments.
- Increments two counters:
_operatorPendingSlashCount[operator](in staking, blocks delegator withdrawals) and_operatorActiveSlashProposals[operator](in Tangle, enforces the per-operator cap). - Sets
executeAfter = block.timestamp + disputeWindow. - Calls the blueprint manager’s
onUnappliedSlashhook (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
Pendingproposal whoseexecuteAfter + TIMESTAMP_BUFFERhas elapsed - A
Disputedproposal whosedisputeDeadline + TIMESTAMP_BUFFERhas elapsed (symmetric 15s buffer with the dispute window, so a sequencer with timestamp influence cannot sandwich the deadline tick to land anexecuteSlashbefore the operator’s dispute window closes)
Execution routes through _executeSlashOnStaking:
- If the operator made explicit per-asset commitments to the service, calls
slashForServiceand 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
| Function | Caller |
|---|---|
proposeSlash | Service 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. |
disputeSlash | The slashed operator (must post bond), SLASH_ADMIN_ROLE (no bond), or a BSM-declared dispute origin (no bond, unless the blueprint authored the slash) |
executeSlash | Anyone, gated by isExecutable |
executeSlashBatch | Anyone. Non-executable ids in the batch are SILENTLY SKIPPED, not reverted. |
cancelSlash | SLASH_ADMIN_ROLE only. Can cancel from Pending or Disputed. |
claimDisputeBond | Anyone with a credited balance in _pendingDisputeBondRefunds (the disputer after a cancel, or the treasury after a failed push). Withdraws that balance. |
setSlashConfig | ADMIN_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.
| Parameter | Default | Bounds | Purpose |
|---|---|---|---|
disputeWindow | 7 days | [1 hour, 30 days] | How long after proposeSlash the operator has to dispute |
instantSlashEnabled | false | bool | Reserved 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. |
maxSlashBps | 10000 | (0, 10000] | Hard cap on any single slash proposal |
disputeResolutionDeadline | 14 days | [1 day, 60 days] | Time SLASH_ADMIN has to resolve a dispute before it auto-fails |
disputeBond | 0.02 ether | uint256 | Native-asset bond required from the operator to dispute. Forfeit to treasury on auto-fail or execute, refunded on cancel |
maxPendingSlashesPerOperator | 8 | (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.
- Read the evidence. The
evidencefield is typically an IPFS CID. Fetch it. Verify whether the alleged misbehavior happened. - 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.
- If the slash is correct, do nothing. It will execute after
- To dispute, call
disputeSlash{value: config.disputeBond}(slashId, "concise reason"). If you do not post exactlydisputeBond, the call reverts. - After disputing, escalate to
SLASH_ADMIN. Provide your evidence off-chain. The admin has up todisputeResolutionDeadlineto callcancelSlash(credits your bond back to you, claimable) or do nothing (slash auto-executes, bond forfeit). - Claim your bond.
cancelSlashdoes NOT push the bond back to you. It credits_pendingDisputeBondRefunds[disputer]. You must callclaimDisputeBond()yourself to receive the ETH. A cancelled slash with an unclaimed bond is the common operator mistake here. - 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.
- Read the dispute reason and the original evidence.
- Off-chain communication with the operator and the slash proposer is expected.
- 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 callclaimDisputeBond(); the protocol never pushes the ETH back for them. - 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. - To force-resolve early in the spurious case: call
cancelSlashonly 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:
- Be slashed
- Self-dispute (free)
- Lock all their delegators for
disputeResolutionDeadline - 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:cancelSlashonly credits_pendingDisputeBondRefunds[disputer], and the operator callsclaimDisputeBond()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 withclaimDisputeBond(). It is only restored on the proposal in the unreachable case where the disputer is alsoaddress(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 callingclaimDisputeBond(). - 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:
_executeSlashOnStakingloads the commitment snapshot taken atproposeSlashtime (_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 tocommitment.exposureBps. - If no commitments exist (legacy services), it falls back to
slashForBlueprintwhich 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
SlashConfigfields viasetSlashConfig - 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
maxSlashBpsoverrides - 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
- Interface:
ITangleSlashing - Source:
Slashing.sol - Library:
SlashingLib.sol - Stake reduction:
SlashingManager.sol - Auth surface: Auth Surface
- Upgrade discipline: Upgrade Discipline