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
15 changes: 12 additions & 3 deletions src/CountersigStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,14 @@ contract CountersigStaking is
}

uint256 remaining = s.amount - amount;
// If agent is still active, enforce minimum stake post-withdrawal.
// While Active, the remaining active stake must stay at or above minimumStake —
// withdrawing to zero (fully exiting) requires the operator to Suspend first (see
// CountersigIdentity.updateStatus). Previously a `remaining != 0` carve-out let an
// active agent drain its entire backing stake in one step, which the total-balance
// check in initiateSlash also now guards, but an active agent should never be
// unbacked to begin with.
bool active = id.registeredAt != 0 && id.status == CountersigIdentity.AgentStatus.Active;
if (active && remaining < minimumStake && remaining != 0) {
if (active && remaining < minimumStake) {
revert InsufficientStake(didHash, remaining, minimumStake);
}

Expand Down Expand Up @@ -291,7 +296,11 @@ contract CountersigStaking is
bytes calldata evidenceHash
) external nonReentrant onlyRole(SLASHING_COMMITTEE_ROLE) {
if (victim == address(0)) revert ZeroAddress();
if (stakes[didHash].amount == 0) revert NoStake(didHash);
// Slashable balance is the active stake PLUS anything queued for withdrawal.
// Checking only the active stake let an operator drain everything into the
// unbonding queue (which executeSlash still sweeps) and thereby block the slash
// from ever being initiated — nullifying accountability. Gate on the total.
if (stakes[didHash].amount + stakes[didHash].unbondingAmount == 0) revert NoStake(didHash);
if (slashProposals[didHash].state == SlashState.Pending) revert SlashAlreadyPending(didHash);

// Write state before the external call (CEI pattern).
Expand Down
60 changes: 60 additions & 0 deletions test/CountersigStaking.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,66 @@ contract CountersigStakingTest is Test {
assertEq(csig.balanceOf(operator), before + MIN_STAKE * 2);
}

// Regression: an operator must not be able to dodge a slash by draining the
// entire stake into the unbonding queue. Before the fix, initiateSlash checked
// only the active stake (s.amount) and reverted NoStake once everything was
// queued, so the committee could never even start a slash.
function test_exitDodge_slashStillInitiableWhenFullyQueued() public {
_deposit(); // 2 * MIN_STAKE

// Legitimate full-exit path: Suspend, then withdraw everything to the queue.
vm.prank(operator);
identity.updateStatus(didHash, CountersigIdentity.AgentStatus.Suspended);
vm.prank(operator);
staking.initiateWithdrawal(didHash, MIN_STAKE * 2);

assertEq(staking.getStake(didHash), 0);
(uint256 queued,) = staking.getPendingWithdrawal(didHash);
assertEq(queued, MIN_STAKE * 2);

// The committee can still initiate a slash against the queued funds.
vm.prank(committee);
staking.initiateSlash(didHash, victim, "evidence");

// ... the operator cannot claim while the slash is pending ...
vm.expectRevert(
abi.encodeWithSelector(CountersigStaking.SlashAlreadyPending.selector, didHash)
);
vm.prank(operator);
staking.claimWithdrawal(didHash);

// ... and after the challenge window the queued funds are swept and burned/distributed.
vm.warp(block.timestamp + CHALLENGE + 1);
staking.executeSlash(didHash);

assertEq(csig.balanceOf(address(0xdead)), (MIN_STAKE * 2) / 2);
(uint256 pendingAmount,) = staking.getPendingWithdrawal(didHash);
assertEq(pendingAmount, 0);

vm.expectRevert(
abi.encodeWithSelector(CountersigStaking.NoWithdrawalPending.selector, didHash)
);
vm.prank(operator);
staking.claimWithdrawal(didHash);
}

// An Active agent must not be able to withdraw its entire backing stake in one
// step; full exit requires suspending first.
function test_initiateWithdrawal_reverts_toZeroWhileActive() public {
_deposit(); // 2 * MIN_STAKE

vm.expectRevert(
abi.encodeWithSelector(
CountersigStaking.InsufficientStake.selector,
didHash,
0, // remaining after draining everything
MIN_STAKE
)
);
vm.prank(operator);
staking.initiateWithdrawal(didHash, MIN_STAKE * 2);
}

function test_initiateWithdrawal_reverts_pendingSlash() public {
_deposit();

Expand Down
Loading