diff --git a/protocol-contracts/staking/contracts/OperatorStaking.sol b/protocol-contracts/staking/contracts/OperatorStaking.sol index 2f047261d3..6b8330db18 100644 --- a/protocol-contracts/staking/contracts/OperatorStaking.sol +++ b/protocol-contracts/staking/contracts/OperatorStaking.sol @@ -75,6 +75,9 @@ contract OperatorStaking is ERC1363Upgradeable, ReentrancyGuardTransient, UUPSUp /// @dev Thrown when the controller address is not valid (e.g., zero address). error InvalidController(); + /// @dev Thrown when the number of shares to redeem or request redeem is zero. + error InvalidShares(); + modifier onlyOwner() { require(msg.sender == owner(), CallerNotProtocolStakingOwner(msg.sender)); _; @@ -170,9 +173,10 @@ contract OperatorStaking is ERC1363Upgradeable, ReentrancyGuardTransient, UUPSUp * @param shares Amount of shares to redeem. * @param controller The controller address for the request. * @param ownerRedeem The owner of the shares. + * @return releaseTime The timestamp when the assets will be available for withdrawal. */ - function requestRedeem(uint208 shares, address controller, address ownerRedeem) public virtual { - if (shares == 0) return; + function requestRedeem(uint208 shares, address controller, address ownerRedeem) public virtual returns (uint48) { + require(shares != 0, InvalidShares()); require(controller != address(0), InvalidController()); if (msg.sender != ownerRedeem) { _spendAllowance(ownerRedeem, msg.sender, shares); @@ -196,6 +200,8 @@ contract OperatorStaking is ERC1363Upgradeable, ReentrancyGuardTransient, UUPSUp $._redeemRequests[controller].push(releaseTime, controllerSharesRedeemed + shares); emit RedeemRequest(controller, ownerRedeem, 0, msg.sender, shares, releaseTime); + + return releaseTime; } /** @@ -210,6 +216,7 @@ contract OperatorStaking is ERC1363Upgradeable, ReentrancyGuardTransient, UUPSUp address receiver, address controller ) public virtual nonReentrant returns (uint256) { + require(shares != 0, InvalidShares()); require(msg.sender == controller || isOperator(controller, msg.sender), Unauthorized()); uint256 maxShares = maxRedeem(controller); diff --git a/protocol-contracts/staking/test/OperatorStaking.test.ts b/protocol-contracts/staking/test/OperatorStaking.test.ts index 086f48f0d7..e7544a9d8d 100644 --- a/protocol-contracts/staking/test/OperatorStaking.test.ts +++ b/protocol-contracts/staking/test/OperatorStaking.test.ts @@ -210,9 +210,21 @@ describe('OperatorStaking', function () { describe('redeem', async function () { it('simple redemption', async function () { await this.mock.connect(this.delegator1).deposit(ethers.parseEther('1'), this.delegator1); - await this.mock - .connect(this.delegator1) - .requestRedeem(await this.mock.balanceOf(this.delegator1), this.delegator1, this.delegator1); + const currentTimestamp = await time.latest(); + await expect( + this.mock + .connect(this.delegator1) + .requestRedeem(await this.mock.balanceOf(this.delegator1), this.delegator1, this.delegator1), + ) + .to.emit(this.mock, 'RedeemRequest') + .withArgs( + this.delegator1, + this.delegator1, + 0, + this.delegator1, + ethers.parseEther('1'), + BigInt(currentTimestamp) + 1n + (await this.protocolStaking.unstakeCooldownPeriod()), + ); await expect(this.mock.pendingRedeemRequest(0, this.delegator1)).to.eventually.eq(ethers.parseEther('1')); await expect(this.mock.claimableRedeemRequest(0, this.delegator1)).to.eventually.eq(0); @@ -228,11 +240,13 @@ describe('OperatorStaking', function () { await expect(this.token.balanceOf(this.mock)).to.eventually.be.eq(0); }); - it('zero redemption should terminate early', async function () { - await expect(this.mock.connect(this.delegator1).requestRedeem(0, this.delegator1, this.delegator1)).to.not.emit( - this.mock, - 'RedeemRequest', - ); + it('should return release time', async function () { + await this.mock.connect(this.delegator1).deposit(ethers.parseEther('1'), this.delegator1); + await expect( + this.mock + .connect(this.delegator1) + .requestRedeem.staticCall(await this.mock.balanceOf(this.delegator1), this.delegator1, this.delegator1), + ).to.eventually.eq(BigInt(await time.latest()) + (await this.protocolStaking.unstakeCooldownPeriod())); }); it('should not redeem twice', async function () { @@ -251,6 +265,18 @@ describe('OperatorStaking', function () { ).to.not.emit(this.token, 'Transfer'); }); + it('should revert on requestRedeem 0 shares', async function () { + await expect( + this.mock.connect(this.delegator1).requestRedeem(0, this.delegator1, this.delegator1), + ).to.be.revertedWithCustomError(this.mock, 'InvalidShares'); + }); + + it('should revert on redeem 0 shares', async function () { + await expect( + this.mock.connect(this.delegator1).redeem(0, this.delegator1, this.delegator1), + ).to.be.revertedWithCustomError(this.mock, 'InvalidShares'); + }); + it('should revert on redeem more than available', async function () { await this.mock.connect(this.delegator1).deposit(ethers.parseEther('10'), this.delegator1); await this.mock.connect(this.delegator1).requestRedeem(ethers.parseEther('1'), this.delegator1, this.delegator1);