Skip to content

Commit b8d5b24

Browse files
committed
fix(protocol-contracts): skip zero-amount stake/unstake operations (N-08)
1 parent 9141432 commit b8d5b24

File tree

4 files changed

+30
-10
lines changed

4 files changed

+30
-10
lines changed

protocol-contracts/staking/contracts/OperatorStaking.sol

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ contract OperatorStaking is ERC1363Upgradeable, ReentrancyGuardTransient, UUPSUp
7575
/// @dev Thrown when the controller address is not valid (e.g., zero address).
7676
error InvalidController();
7777

78+
/// @dev Thrown when trying to redeem with no assets to withdraw from ProtocolStaking.
79+
error NoAssetsToWithdraw();
80+
7881
modifier onlyOwner() {
7982
require(msg.sender == owner(), CallerNotProtocolStakingOwner(msg.sender));
8083
_;
@@ -190,8 +193,11 @@ contract OperatorStaking is ERC1363Upgradeable, ReentrancyGuardTransient, UUPSUp
190193
IERC20(asset()).balanceOf(address(this)) + protocolStaking_.awaitingRelease(address(this))
191194
);
192195

196+
// Revert if no assets to withdraw to prevent zero-amount unstake from advancing release time.
197+
require(assetsToWithdraw > 0, NoAssetsToWithdraw());
198+
193199
(, uint48 lastReleaseTime, uint208 controllerSharesRedeemed) = $._redeemRequests[controller].latestCheckpoint();
194-
uint48 releaseTime = protocolStaking_.unstake(SafeCast.toUint256(SignedMath.max(assetsToWithdraw, 0)));
200+
uint48 releaseTime = protocolStaking_.unstake(SafeCast.toUint256(assetsToWithdraw));
195201
assert(releaseTime >= lastReleaseTime); // should never happen
196202
$._redeemRequests[controller].push(releaseTime, controllerSharesRedeemed + shares);
197203

protocol-contracts/staking/contracts/ProtocolStaking.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ contract ProtocolStaking is AccessControlDefaultAdminRulesUpgradeable, ERC20Vote
7575
error TransferDisabled();
7676
/// @dev The unstake cooldown period is invalid.
7777
error InvalidUnstakeCooldownPeriod();
78+
/// @dev The unstake amount is zero.
79+
error ZeroUnstakeAmount();
7880

7981
/// @custom:oz-upgrades-unsafe-allow constructor
8082
constructor() {
@@ -107,6 +109,8 @@ contract ProtocolStaking is AccessControlDefaultAdminRulesUpgradeable, ERC20Vote
107109
* @param amount The amount of tokens to stake.
108110
*/
109111
function stake(uint256 amount) public {
112+
if (amount == 0) return;
113+
110114
_mint(msg.sender, amount);
111115
IERC20(stakingToken()).safeTransferFrom(msg.sender, address(this), amount);
112116

@@ -120,11 +124,14 @@ contract ProtocolStaking is AccessControlDefaultAdminRulesUpgradeable, ERC20Vote
120124
* WARNING: Unstake release times are strictly increasing per account even if the cooldown period
121125
* is reduced. For a given account to fully realize the reduction in cooldown period, they may need
122126
* to wait up to `OLD_COOLDOWN_PERIOD - NEW_COOLDOWN_PERIOD` seconds after the cooldown period is updated.
127+
* NOTE: Unstake amount must be greater than zero to prevent zero-amount unstake from advancing release time.
123128
*
124129
* @param amount The amount of tokens to unstake.
125130
* @return releaseTime The timestamp when the unstaked tokens can be released.
126131
*/
127132
function unstake(uint256 amount) public returns (uint48) {
133+
require(amount > 0, ZeroUnstakeAmount());
134+
128135
_burn(msg.sender, amount);
129136

130137
ProtocolStakingStorage storage $ = _getProtocolStakingStorage();

protocol-contracts/staking/test/OperatorStaking.test.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -479,20 +479,15 @@ describe('OperatorStaking', function () {
479479
await this.mock.connect(this.delegator1).requestRedeem(ethers.parseEther('1'), this.delegator1, this.delegator1);
480480
await this.protocolStaking.slash(this.mock, ethers.parseEther('1'));
481481

482-
await timeIncreaseNoMine(30);
483-
482+
// Should revert when there are no assets to withdraw from ProtocolStaking.
484483
await expect(
485484
this.mock.connect(this.delegator2).requestRedeem(ethers.parseEther('1'), this.delegator2, this.delegator2),
486-
)
487-
.to.emit(this.protocolStaking, 'TokensUnstaked')
488-
.withArgs(this.mock, 0, anyValue);
485+
).to.be.revertedWithCustomError(this.mock, 'NoAssetsToWithdraw');
489486

490-
await time.increase(30);
487+
// Cooldown period is completed, only delegator1 can redeem now.
488+
await time.increase(60);
491489
await expect(this.mock.maxRedeem(this.delegator2)).to.eventually.eq(0);
492490
await expect(this.mock.maxRedeem(this.delegator1)).to.eventually.eq(ethers.parseEther('1'));
493-
494-
await time.increase(30);
495-
await expect(this.mock.maxRedeem(this.delegator2)).to.eventually.eq(ethers.parseEther('1'));
496491
});
497492

498493
it('symmetrically passes on losses from withdrawal balance', async function () {

protocol-contracts/staking/test/ProtocolStaking.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ describe('Protocol Staking', function () {
117117
await expect(this.mock.balanceOf(this.staker1)).to.eventually.equal(ethers.parseEther('100'));
118118
});
119119

120+
it('zero stake should return early without state changes', async function () {
121+
await expect(this.mock.connect(this.staker1).stake(0)).to.not.emit(this.mock, 'TokensStaked');
122+
await expect(this.mock.balanceOf(this.staker1)).to.eventually.equal(0);
123+
});
124+
120125
it("should not reward accounts that aren't eligible", async function () {
121126
await this.mock.connect(this.staker1).stake(ethers.parseEther('100'));
122127

@@ -213,6 +218,13 @@ describe('Protocol Staking', function () {
213218
.to.not.emit(this.token, 'Transfer');
214219
});
215220

221+
it('zero unstake should revert', async function () {
222+
await expect(this.mock.connect(this.staker1).unstake(0)).to.be.revertedWithCustomError(
223+
this.mock,
224+
'ZeroUnstakeAmount',
225+
);
226+
});
227+
216228
describe('Release', function () {
217229
it('should transfer after cooldown complete', async function () {
218230
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(60); // 1 minute

0 commit comments

Comments
 (0)