Skip to content

Commit 85eb512

Browse files
chore(protocol-contracts): remove unstake recipient from ProtocolStaking (#1370)
* chore(protocol-contracts): remove unstake recipient from `ProtocolStaking` * Update protocol-contracts/staking/contracts/ProtocolStaking.sol Co-authored-by: James Toussaint <[email protected]> * docs * terminate early for 0 shares redemption * Remove recipient from `TokensUnstaked` event --------- Co-authored-by: James Toussaint <[email protected]>
1 parent a1f03d4 commit 85eb512

File tree

4 files changed

+61
-66
lines changed

4 files changed

+61
-66
lines changed

protocol-contracts/staking/contracts/OperatorStaking.sol

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ contract OperatorStaking is ERC20, Ownable, ReentrancyGuardTransient {
110110
* @param owner The owner of the shares.
111111
*/
112112
function requestRedeem(uint208 shares, address controller, address owner) public virtual {
113+
if (shares == 0) return;
113114
require(controller != address(0), InvalidController());
114115
if (msg.sender != owner) {
115116
_spendAllowance(owner, msg.sender, shares);
@@ -126,10 +127,7 @@ contract OperatorStaking is ERC20, Ownable, ReentrancyGuardTransient {
126127
);
127128

128129
(, uint48 lastReleaseTime, uint208 controllerSharesRedeemed) = _unstakeRequests[controller].latestCheckpoint();
129-
uint48 releaseTime = protocolStaking_.unstake(
130-
address(this),
131-
SafeCast.toUint256(SignedMath.max(assetsToWithdraw, 0))
132-
);
130+
uint48 releaseTime = protocolStaking_.unstake(SafeCast.toUint256(SignedMath.max(assetsToWithdraw, 0)));
133131
assert(releaseTime >= lastReleaseTime); // should never happen
134132
_unstakeRequests[controller].push(releaseTime, controllerSharesRedeemed + shares);
135133

protocol-contracts/staking/contracts/ProtocolStaking.sol

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ contract ProtocolStaking is AccessControlDefaultAdminRulesUpgradeable, ERC20Vote
3535
uint256 _totalEligibleStakedWeight;
3636
// Stake - release
3737
uint48 _unstakeCooldownPeriod;
38-
mapping(address recipient => Checkpoints.Trace208) _unstakeRequests;
39-
mapping(address recipient => uint256) _released;
38+
mapping(address account => Checkpoints.Trace208) _unstakeRequests;
39+
mapping(address account => uint256) _released;
4040
// Reward - issuance curve
4141
uint256 _lastUpdateTimestamp;
4242
uint256 _lastUpdateReward;
@@ -58,7 +58,7 @@ contract ProtocolStaking is AccessControlDefaultAdminRulesUpgradeable, ERC20Vote
5858
/// @dev Emitted when tokens are staked by an account.
5959
event TokensStaked(address indexed account, uint256 amount);
6060
/// @dev Emitted when tokens are unstaked by an account.
61-
event TokensUnstaked(address indexed account, address indexed recipient, uint256 amount, uint48 releaseTime);
61+
event TokensUnstaked(address indexed account, uint256 amount, uint48 releaseTime);
6262
/// @dev Emitted when tokens are released to a recipient after the unstaking cooldown period.
6363
event TokensReleased(address indexed recipient, uint256 amount);
6464
/// @dev Emitted when rewards of an account are claimed.
@@ -70,8 +70,6 @@ contract ProtocolStaking is AccessControlDefaultAdminRulesUpgradeable, ERC20Vote
7070
/// @dev Emitted when the reward recipient of an account is updated.
7171
event RewardsRecipientSet(address indexed account, address indexed recipient);
7272

73-
/// @dev Emitted when an account unstakes to the zero address.
74-
error InvalidUnstakeRecipient();
7573
/// @dev The account cannot be made eligible.
7674
error InvalidEligibleAccount(address account);
7775
/// @dev The tokens cannot be transferred.
@@ -117,34 +115,33 @@ contract ProtocolStaking is AccessControlDefaultAdminRulesUpgradeable, ERC20Vote
117115
}
118116

119117
/**
120-
* @dev Unstake `amount` tokens from `msg.sender`'s staked balance to `recipient`.
121-
* @param recipient The recipient where unstaked tokens should be sent.
122-
* @param amount The amount of tokens to unstake.
123-
* @return releaseTime The timestamp when the unstaked tokens can be released.
118+
* @dev Unstake `amount` tokens from `msg.sender`'s staked balance to `msg.sender`.
124119
*
125120
* NOTE: Unstaked tokens are released by calling {release} after {unstakeCooldownPeriod}.
121+
* WARNING: Unstake release times are strictly increasing per account even if the cooldown period
122+
* is reduced. For a given account to fully realize the reduction in cooldown period, they may need
123+
* to wait up to `OLD_COOLDOWN_PERIOD - NEW_COOLDOWN_PERIOD` seconds after the cooldown period is updated.
124+
*
125+
* @param amount The amount of tokens to unstake.
126+
* @return releaseTime The timestamp when the unstaked tokens can be released.
126127
*/
127-
function unstake(address recipient, uint256 amount) public returns (uint48) {
128-
require(recipient != address(0), InvalidUnstakeRecipient());
128+
function unstake(uint256 amount) public returns (uint48) {
129129
_burn(msg.sender, amount);
130130

131131
ProtocolStakingStorage storage $ = _getProtocolStakingStorage();
132132
(, uint256 lastReleaseTime, uint256 totalRequestedToWithdraw) = $
133-
._unstakeRequests[recipient]
133+
._unstakeRequests[msg.sender]
134134
.latestCheckpoint();
135135
uint48 releaseTime = SafeCast.toUint48(Math.max(Time.timestamp() + $._unstakeCooldownPeriod, lastReleaseTime));
136-
$._unstakeRequests[recipient].push(releaseTime, uint208(totalRequestedToWithdraw + amount));
136+
$._unstakeRequests[msg.sender].push(releaseTime, uint208(totalRequestedToWithdraw + amount));
137137

138-
emit TokensUnstaked(msg.sender, recipient, amount, releaseTime);
138+
emit TokensUnstaked(msg.sender, amount, releaseTime);
139139
return releaseTime;
140140
}
141141

142142
/**
143143
* @dev Releases tokens requested for unstaking after the cooldown period to `account`.
144144
* @param account The account to release tokens to.
145-
*
146-
* WARNING: If this contract is upgraded to add slashing, the ability to withdraw to a
147-
* different address should be reconsidered.
148145
*/
149146
function release(address account) public virtual {
150147
ProtocolStakingStorage storage $ = _getProtocolStakingStorage();
@@ -206,7 +203,7 @@ contract ProtocolStaking is AccessControlDefaultAdminRulesUpgradeable, ERC20Vote
206203

207204
/**
208205
* @dev Sets the {unstake} cooldown period in seconds to `unstakeCooldownPeriod`. Only callable
209-
* by `MANAGER_ROLE` role.
206+
* by `MANAGER_ROLE` role. See {unstake} for important notes regarding the cooldown period.
210207
* @param unstakeCooldownPeriod_ The new unstake cooldown period.
211208
*/
212209
function setUnstakeCooldownPeriod(uint48 unstakeCooldownPeriod_) public onlyRole(MANAGER_ROLE) {

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ describe('OperatorStaking', function () {
7878
await expect(this.token.balanceOf(this.mock)).to.eventually.be.eq(0);
7979
});
8080

81+
it('zero redemption should terminate early', async function () {
82+
await expect(this.mock.connect(this.staker1).requestRedeem(0, this.staker1, this.staker1)).to.not.emit(
83+
this.mock,
84+
'RedeemRequest',
85+
);
86+
});
87+
8188
it('should not redeem twice', async function () {
8289
await this.mock.connect(this.staker2).deposit(ethers.parseEther('5'), this.staker2);
8390
await this.mock.connect(this.staker1).deposit(ethers.parseEther('10'), this.staker1);
@@ -306,7 +313,7 @@ describe('OperatorStaking', function () {
306313

307314
await expect(this.mock.connect(this.staker2).requestRedeem(ethers.parseEther('2'), this.staker2, this.staker2))
308315
.to.emit(this.protocolStaking, 'TokensUnstaked')
309-
.withArgs(this.mock, this.mock, ethers.parseEther('0.5'), anyValue);
316+
.withArgs(this.mock, ethers.parseEther('0.5'), anyValue);
310317
});
311318

312319
it('take excess into account on requestRedeem after slashing fully covered', async function () {
@@ -320,7 +327,7 @@ describe('OperatorStaking', function () {
320327

321328
await expect(this.mock.connect(this.staker2).requestRedeem(ethers.parseEther('1'), this.staker2, this.staker2))
322329
.to.emit(this.protocolStaking, 'TokensUnstaked')
323-
.withArgs(this.mock, this.mock, 0, anyValue);
330+
.withArgs(this.mock, 0, anyValue);
324331

325332
await time.increase(30);
326333
await expect(this.mock.maxRedeem(this.staker2)).to.eventually.eq(0);

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

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -208,34 +208,18 @@ describe('Protocol Staking', function () {
208208

209209
it('should not transfer instantly', async function () {
210210
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(60); // 1 minute
211-
await expect(this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('50')))
211+
await expect(this.mock.connect(this.staker1).unstake(ethers.parseEther('50')))
212212
.to.emit(this.mock, 'Transfer')
213213
.withArgs(this.staker1, ethers.ZeroAddress, ethers.parseEther('50'))
214-
.to.not.emit(this.token, 'Transfer');
215-
});
216-
217-
it('should be able to unstake to someone else', async function () {
218-
const currentTime = BigInt(await time.latest());
219-
const cooldownPeriod = BigInt(await this.mock.unstakeCooldownPeriod());
220-
await expect(this.mock.connect(this.staker1).unstake(this.staker2, ethers.parseEther('50')))
221214
.to.emit(this.mock, 'TokensUnstaked')
222-
.withArgs(this.staker1, this.staker2, ethers.parseEther('50'), currentTime + cooldownPeriod + 1n);
223-
await mine();
224-
await expect(this.mock.release(this.staker2))
225-
.to.emit(this.token, 'Transfer')
226-
.withArgs(this.mock, this.staker2, ethers.parseEther('50'));
227-
});
228-
229-
it('should not unstake to zero address', async function () {
230-
await expect(
231-
this.mock.connect(this.staker1).unstake(ethers.ZeroAddress, ethers.parseEther('50')),
232-
).to.be.revertedWithCustomError(this.mock, 'InvalidUnstakeRecipient');
215+
.withArgs(this.staker1, ethers.parseEther('50'), anyValue)
216+
.to.not.emit(this.token, 'Transfer');
233217
});
234218

235219
describe('Release', function () {
236220
it('should transfer after cooldown complete', async function () {
237221
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(60); // 1 minute
238-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('50'));
222+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('50'));
239223
await expect(this.mock.awaitingRelease(this.staker1)).to.eventually.eq(ethers.parseEther('50'));
240224

241225
await timeIncreaseNoMine(60);
@@ -248,7 +232,7 @@ describe('Protocol Staking', function () {
248232

249233
it('should only release once', async function () {
250234
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(60); // 1 minute
251-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('50'));
235+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('50'));
252236

253237
await timeIncreaseNoMine(60);
254238

@@ -262,18 +246,18 @@ describe('Protocol Staking', function () {
262246

263247
it("should not release if cooldown isn't complete", async function () {
264248
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(60);
265-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('50'));
249+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('50'));
266250

267251
await timeIncreaseNoMine(30);
268252
await expect(this.mock.release(this.staker1)).to.not.emit(this.token, 'Transfer');
269253
});
270254

271255
it('should combine multiple complete withdrawals', async function () {
272256
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(60); // 1 minute
273-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('50'));
257+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('50'));
274258

275259
await timeIncreaseNoMine(30);
276-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('50'));
260+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('50'));
277261
await expect(this.mock.awaitingRelease(this.staker1)).to.eventually.eq(ethers.parseEther('100'));
278262

279263
await timeIncreaseNoMine(60);
@@ -285,13 +269,13 @@ describe('Protocol Staking', function () {
285269

286270
it('should only release completed cooldowns in batch', async function () {
287271
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(60); // 1 minute
288-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('25'));
272+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('25'));
289273

290274
await timeIncreaseNoMine(20);
291-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('25'));
275+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('25'));
292276

293277
await timeIncreaseNoMine(20);
294-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('25'));
278+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('25'));
295279

296280
await timeIncreaseNoMine(40);
297281
await expect(this.mock.release(this.staker1))
@@ -301,11 +285,11 @@ describe('Protocol Staking', function () {
301285

302286
it('should handle decrease in cooldown period gracefully', async function () {
303287
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(120);
304-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('25'));
288+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('25'));
305289

306290
await timeIncreaseNoMine(30);
307291
await this.mock.connect(this.manager).setUnstakeCooldownPeriod(30);
308-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('25'));
292+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('25'));
309293

310294
// advance 30 seconds. Still need to wait another 60 seconds for the original unstake request to complete.
311295
await timeIncreaseNoMine(30);
@@ -323,7 +307,7 @@ describe('Protocol Staking', function () {
323307

324308
const beforetotalStakedWeight = await this.mock.totalStakedWeight();
325309
const beforeStaker1Log = await this.mock.weight(await this.mock.balanceOf(this.staker1));
326-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('75'));
310+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('75'));
327311
const afterStaker1Log = await this.mock.weight(await this.mock.balanceOf(this.staker1));
328312
const aftertotalStakedWeight = await this.mock.totalStakedWeight();
329313
expect(beforetotalStakedWeight - aftertotalStakedWeight).to.equal(beforeStaker1Log - afterStaker1Log);
@@ -375,7 +359,7 @@ describe('Protocol Staking', function () {
375359

376360
await timeIncreaseNoMine(10);
377361

378-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('100'));
362+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('100'));
379363

380364
// time passes (10 seconds) while no one is staked
381365
await timeIncreaseNoMine(10);
@@ -400,9 +384,9 @@ describe('Protocol Staking', function () {
400384
await timeIncreaseNoMine(10);
401385

402386
// 3 in rewards for 1 (since 1 block at the beginning alone)
403-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('100'));
387+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('100'));
404388
// 3 in rewards for 2 (since 1 block at the end alone)
405-
await this.mock.connect(this.staker2).unstake(this.staker2, ethers.parseEther('100'));
389+
await this.mock.connect(this.staker2).unstake(ethers.parseEther('100'));
406390

407391
await timeIncreaseNoMine(10);
408392

@@ -431,7 +415,7 @@ describe('Protocol Staking', function () {
431415

432416
await timeIncreaseNoMine(10);
433417

434-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('100'));
418+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('100'));
435419

436420
// time passes (10 seconds) while no one is staked
437421
await timeIncreaseNoMine(10);
@@ -456,9 +440,9 @@ describe('Protocol Staking', function () {
456440
await timeIncreaseNoMine(10);
457441

458442
// 3 in rewards for 1 (since 1 block at the beginning alone)
459-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('100'));
443+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('100'));
460444
// 3 in rewards for 2 (since 1 block at the end alone)
461-
await this.mock.connect(this.staker2).unstake(this.staker2, ethers.parseEther('100'));
445+
await this.mock.connect(this.staker2).unstake(ethers.parseEther('100'));
462446

463447
await timeIncreaseNoMine(10);
464448

@@ -487,7 +471,7 @@ describe('Protocol Staking', function () {
487471

488472
await timeIncreaseNoMine(10);
489473

490-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('100'));
474+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('100'));
491475

492476
// time passes (10 seconds) while no one is staked
493477
await timeIncreaseNoMine(10);
@@ -512,9 +496,9 @@ describe('Protocol Staking', function () {
512496
await timeIncreaseNoMine(10);
513497

514498
// 3 in rewards for 1 (since 1 block at the beginning alone)
515-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('100'));
499+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('100'));
516500
// 3 in rewards for 2 (since 1 block at the end alone)
517-
await this.mock.connect(this.staker2).unstake(this.staker2, ethers.parseEther('100'));
501+
await this.mock.connect(this.staker2).unstake(ethers.parseEther('100'));
518502

519503
await timeIncreaseNoMine(10);
520504

@@ -543,7 +527,7 @@ describe('Protocol Staking', function () {
543527

544528
await timeIncreaseNoMine(10);
545529

546-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('100'));
530+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('100'));
547531

548532
// time passes (10 seconds) while no one is staked
549533
await timeIncreaseNoMine(10);
@@ -568,9 +552,9 @@ describe('Protocol Staking', function () {
568552
await timeIncreaseNoMine(10);
569553

570554
// 3 in rewards for 1 (since 1 block at the beginning alone)
571-
await this.mock.connect(this.staker1).unstake(this.staker1, ethers.parseEther('100'));
555+
await this.mock.connect(this.staker1).unstake(ethers.parseEther('100'));
572556
// 3 in rewards for 2 (since 1 block at the end alone)
573-
await this.mock.connect(this.staker2).unstake(this.staker2, ethers.parseEther('100'));
557+
await this.mock.connect(this.staker2).unstake(ethers.parseEther('100'));
574558

575559
await timeIncreaseNoMine(10);
576560

@@ -595,6 +579,15 @@ describe('Protocol Staking', function () {
595579
.withArgs(eligibleAccountRole, this.staker1, this.manager);
596580
});
597581

582+
it('should not update total staked amount if no balance', async function () {
583+
await this.mock.connect(this.manager).addEligibleAccount(this.staker1);
584+
await this.mock.connect(this.staker1).stake(ethers.parseEther('1'));
585+
586+
const weightBefore = await this.mock.totalStakedWeight();
587+
await this.mock.connect(this.manager).addEligibleAccount(this.staker2);
588+
await expect(this.mock.totalStakedWeight()).to.eventually.eq(weightBefore);
589+
});
590+
598591
it('should reflect in eligible account storage', async function () {
599592
await this.mock.connect(this.manager).addEligibleAccount(this.staker1);
600593
await this.mock.connect(this.manager).addEligibleAccount(this.staker2);

0 commit comments

Comments
 (0)