fix(staking): close slash exit-dodge via the unbonding queue#19
Merged
Conversation
initiateSlash gated only on the active stake (s.amount), so an operator could move their entire stake into the unbonding queue and make the slash impossible to even initiate (NoStake), despite the queued funds being nominally slashable. The claimWithdrawal/executeSlash defenses never fired because no proposal could be created. This nullified slashing, the protocol's core accountability. - initiateSlash now gates on the total slashable balance (amount + unbondingAmount), so a slash can be initiated against fully-queued stake; claimWithdrawal then blocks (pending slash) and executeSlash sweeps the queue as before. - initiateWithdrawal no longer lets an Active agent drain to zero in one step (removed the remaining != 0 carve-out); full exit requires suspending first, matching the documented intent and keeping an active agent always backed. - Regression tests: slash stays initiable/executable against a fully-queued stake; Active withdraw-to-zero reverts. Full suite 171 passing. Found during an internal adversarial review of the stake-exit/slash lifecycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes a HIGH-severity gap that let an agent operator escape slashing entirely, defeating the protocol's core "accountability that costs something" guarantee.
initiateSlashgated only on the active stake (stakes[didHash].amount). But slashable funds can all sit in the unbonding queue (unbondingAmount), andinitiateWithdrawallet an operator move their entire stake there. Once the active balance hit zero,initiateSlashrevertedNoStake, so no proposal could be created,executeSlashnever ran, and the operator claimed the full stake after the unbonding period. The existingclaimWithdrawal/executeSlashprotections never fired, because they all assume a slash can first be initiated.Attack
initiateWithdrawal(didHash, 1000). Active stake is now 0, all queued.initiateSlashand it revertsNoStake.claimWithdrawalreturns the full 1,000. Slash dodged.The operator reliably wins the race: the withdrawal is one instant transaction, while committee initiation (multisig, evidence gathering) is not.
Fix
initiateSlashnow gates on the total slashable balance (amount + unbondingAmount), so a slash can be initiated against fully-queued stake.claimWithdrawalthen blocks (pending slash) andexecuteSlashsweeps the queue as it already did.initiateWithdrawalno longer lets an Active agent drain to zero in one step (removed theremaining != 0carve-out). Full exit requires suspending first, matching the documented intent and keeping an active agent always backed. Defense-in-depth on top of the primary fix.Tests
test_exitDodge_slashStillInitiableWhenFullyQueued: a slash stays initiable and executable against a fully-queued stake, funds are swept, and the operator cannot claim.test_initiateWithdrawal_reverts_toZeroWhileActive: Active withdraw-to-zero reverts.test_initiateSlash_reverts_noStakestill correctly reverts for a genuinely stakeless agent.Scope note
CountersigOracleBondis not affected: its bond stays in a singleop.bondfield throughExiting, andslash()operates on it regardless of status, so there is no "funds moved somewhere slash cannot reach." The issue was specific toCountersigStaking's split active/unbonding accounting.Found during an internal adversarial review of the stake-exit and slash lifecycle. This was a read-level pass over two contracts and is not a substitute for a full audit before mainnet.
🤖 Generated with Claude Code