diff --git a/contracts/v2/core/WithdrawalQueue.sol b/contracts/v2/core/WithdrawalQueue.sol index d2e836c..0d20893 100644 --- a/contracts/v2/core/WithdrawalQueue.sol +++ b/contracts/v2/core/WithdrawalQueue.sol @@ -64,6 +64,7 @@ contract WithdrawalQueue is AccessControl, ReentrancyGuard, GranularPause, FIFOQ uint256 assets ); event Redeem(address indexed requester, address indexed receiver, uint256 shares, uint256 assets); + event CancelRedeem(address indexed requester, address indexed receiver, uint256 shares, uint256 assets); constructor(address _minter, address _wsgEth, uint256 _epochLength) FIFOQueue(_epochLength) { MINTER = _minter; @@ -76,6 +77,14 @@ contract WithdrawalQueue is AccessControl, ReentrancyGuard, GranularPause, FIFOQ _grantRole(GOV, msg.sender); } + /// @notice Requests a redemption of vault assets. + /// @dev This function must be called by either the owner or an operator of the vault, and is only allowed when the contract is not paused. + + /// @param shares The number of shares to redeem. + /// @param requester The address requesting the redemption. + /// @param owner The owner of the vault being redeemed from. + + /// @return requestId The unique ID assigned to this redemption request. function requestRedeem( uint256 shares, address requester, @@ -98,6 +107,14 @@ contract WithdrawalQueue is AccessControl, ReentrancyGuard, GranularPause, FIFOQ emit RedeemRequest(requester, owner, requestId, msg.sender, shares); } + /// @notice Allows a user to redeem their vault shares. + /// @dev This function must be called by either the owner or an operator of the requester's vault, and is only allowed when the contract is not paused. + + /// @param shares The number of shares to redeem. + /// @param receiver The address that will receive the redeemed assets. + /// @param requester The address requesting the redemption. + + /// @return assets The amount of assets that were successfully redeemed. function redeem( uint256 shares, address receiver, @@ -137,6 +154,35 @@ contract WithdrawalQueue is AccessControl, ReentrancyGuard, GranularPause, FIFOQ emit Redeem(requester, receiver, shares, assets); } + /// @notice Cancel a redeem request and return funds to owner. Can only be done after the epoch has expired + function cancelRedeem( + address receiver, + address requester + ) external onlyOwnerOrOperator(requester) nonReentrant whenNotPaused(uint16(3)) returns (uint256 assets) { + uint256 shares = pendingRedeemRequest(requester); + assets = IERC4626(WSGETH).previewRedeem(shares); + + if (shares == 0) { + revert Errors.InvalidAmount(); + } + + _verifyEpochHasElapsed(requester); + + // checks if we have enough assets to fulfill the request and if epoch has passed + if (claimableRedeemRequest(requester) < assets) { + _checkWithdraw(requester, totalBalance(), assets); + return 0; // should never happen. previous fn will generate a rich error + } + + // Treat everything as claimableRedeemRequest and validate here if there's adequate funds + redeemRequests[requester] -= assets; // underflow would revert if not enough claimable shares + totalPendingRequest -= assets; + _withdraw(requester, assets); + IERC20(WSGETH).transfer(receiver, shares); // asset here is the Vault underlying asset + + emit CancelRedeem(requester, receiver, shares, assets); + } + function togglePause(uint16 func) external onlyRole(GOV) { bool paused = paused[func]; if (paused) { diff --git a/contracts/v2/lib/FIFOQueue.sol b/contracts/v2/lib/FIFOQueue.sol index 3d427ed..8283009 100644 --- a/contracts/v2/lib/FIFOQueue.sol +++ b/contracts/v2/lib/FIFOQueue.sol @@ -20,6 +20,14 @@ abstract contract FIFOQueue { epochLength = _epochLength; } + function _verifyEpochHasElapsed(address sender) public view returns (bool epochElapsed) { + UserEntry memory ue = userEntries[sender]; + if (!(block.number >= ue.blocknum + epochLength)) { + revert Errors.TooEarly(); + } + return true; + } + function _checkWithdraw( address sender, uint256 balanceOfSelf, @@ -30,11 +38,7 @@ abstract contract FIFOQueue { if (!(amountToWithdraw <= balanceOfSelf && amountToWithdraw <= ue.amount)) { revert Errors.InvalidAmount(); } - - if (!(block.number >= ue.blocknum + epochLength)) { - revert Errors.TooEarly(); - } - return true; + return _verifyEpochHasElapsed(sender); } function _isWithdrawalAllowed( diff --git a/test/v2/core/withdrawQueue.spec.ts b/test/v2/core/withdrawQueue.spec.ts index 79178c0..d779b81 100644 --- a/test/v2/core/withdrawQueue.spec.ts +++ b/test/v2/core/withdrawQueue.spec.ts @@ -73,6 +73,34 @@ describe("WithdrawalQueue", () => { }); }); + it("test cancelRedeem flow", async () => { + // make redeem request + await expect(withdrawalQueue.connect(alice).requestRedeem(parseEther("10"), alice.address, alice.address)) + .to.be.emit(withdrawalQueue, "RedeemRequest") + .withArgs(alice.address, alice.address, 0, alice.address, parseEther("10")); + await expect(withdrawalQueue.connect(bob).requestRedeem(parseEther("30"), bob.address, bob.address)) + .to.be.emit(withdrawalQueue, "RedeemRequest") + .withArgs(bob.address, bob.address, 1, bob.address, parseEther("30")); + + // confirm redeem request is processeable + expect(await withdrawalQueue.pendingRedeemRequest(alice.address)).to.eq(parseEther("10")); + expect(await withdrawalQueue.pendingRedeemRequest(bob.address)).to.eq(parseEther("30")); + await advanceTimeAndBlock(1); + expect(await withdrawalQueue.claimableRedeemRequest(alice.address)).to.eq(parseEther("10")); + expect(await withdrawalQueue.claimableRedeemRequest(bob.address)).to.eq(parseEther("30")); + + // cancel 1 + await expect(withdrawalQueue.connect(alice).cancelRedeem(alice.address, alice.address)) + .to.be.emit(withdrawalQueue, "CancelRedeem") + .withArgs(alice.address, alice.address, parseEther("10"), parseEther("10")); + + await expect(withdrawalQueue.connect(alice).redeem(parseEther("10"), alice.address, alice.address)) + .to.be.revertedWithCustomError(withdrawalQueue, "InvalidAmount") + .withArgs(); + + await withdrawalQueue.connect(bob).redeem(parseEther("30"), bob.address, bob.address); + }); + it("request redeem flow", async () => { await expect(withdrawalQueue.connect(alice).requestRedeem(parseEther("10"), alice.address, alice.address)) .to.be.emit(withdrawalQueue, "RedeemRequest")