diff --git a/contracts/LockingInfo.sol b/contracts/LockingInfo.sol index d84436f..dadc5ba 100644 --- a/contracts/LockingInfo.sol +++ b/contracts/LockingInfo.sol @@ -153,6 +153,30 @@ contract LockingInfo is ILockingInfo, OwnableUpgradeable { emit LockUpdate(_seqId, _nonce, _locked); } + /** + * @dev withdrawLocking is to withdraw locking + * @param _seqId the sequencer id + * @param _owner the sequencer owner address + * @param _nonce the sequencer nonce + * @param _amount amount to withdraw + * @param _locked the locked amount of the sequencer at last + */ + function withdrawLocking( + uint256 _seqId, + address _owner, + uint256 _nonce, + uint256 _amount, + uint256 _locked + ) external override OnlyManager { + require(_amount > 0 && _locked >= minLock, "invalid amount"); + // update current locked amount + totalLocked -= _amount; + + IERC20(l1Token).safeTransfer(_owner, _amount); + emit Withdraw(_seqId, _amount); + emit LockUpdate(_seqId, _nonce, _locked); + } + /** * @dev initializeUnlock the first step to unlock * current reward will be distributed diff --git a/contracts/LockingPool.sol b/contracts/LockingPool.sol index 41df0cf..c724902 100644 --- a/contracts/LockingPool.sol +++ b/contracts/LockingPool.sol @@ -415,6 +415,37 @@ contract LockingPool is ILockingPool, PausableUpgradeable, SequencerInfo { } } + /** + * @dev withdraw allow sequencer operator to withdraw the locking + * @param _seqId the id of your sequencer + * @param _amount amount to withdraw + */ + function withdraw( + uint256 _seqId, + uint256 _amount + ) external whenNotPaused whitelistRequired { + Sequencer storage seq = sequencers[_seqId]; + if (seq.status != Status.Active) { + revert SeqNotActive(); + } + + if (seq.owner != msg.sender) { + revert NotSeqOwner(); + } + + // owner can only withdraw once in the current reward period + require(curBatchState.id > seq.updatedBatch, "withdraw throttle"); + + uint256 locked = seq.amount - _amount; + uint256 nonce = seq.nonce + 1; + + seq.nonce = nonce; + seq.amount = locked; + seq.updatedBatch = curBatchState.id; + + escrow.withdrawLocking(_seqId, seq.owner, nonce, _amount, locked); + } + /** * @dev batchSubmitRewards Allow to submit L2 sequencer block information, and attach Metis reward tokens for reward distribution * @param _batchId The batchId that submitted the reward is that diff --git a/contracts/interfaces/ILockingInfo.sol b/contracts/interfaces/ILockingInfo.sol index 076cb8a..45b33fb 100644 --- a/contracts/interfaces/ILockingInfo.sol +++ b/contracts/interfaces/ILockingInfo.sol @@ -43,13 +43,20 @@ interface ILockingInfo { ); /** - * @dev Emitted when the sequencer increase lock amoun in 'relock()'. + * @dev Emitted when the sequencer increase lock amount in 'relock()'. * @param sequencerId unique integer to identify a sequencer. * @param amount locking new amount * @param total the total locking amount */ event Relocked(uint256 indexed sequencerId, uint256 amount, uint256 total); + /** + * @dev Emitted when the sequencer reduce lock amount in 'withdraw()'. + * @param sequencerId unique integer to identify a sequencer. + * @param amount withdraw new amount + */ + event Withdraw(uint256 indexed sequencerId, uint256 amount); + /** * @dev Emitted when sequencer relocking in 'relock()'. * @param sequencerId unique integer to identify a sequencer. @@ -153,6 +160,14 @@ interface ILockingInfo { uint256 _fromReward ) external; + function withdrawLocking( + uint256 _seqId, + address _owner, + uint256 _nonce, + uint256 _amount, + uint256 _locked + ) external; + function initializeUnlock( uint256 _seqId, uint256 _reward, diff --git a/ts-src/test/LockingManager.ts b/ts-src/test/LockingManager.ts index 052389a..6c09172 100644 --- a/ts-src/test/LockingManager.ts +++ b/ts-src/test/LockingManager.ts @@ -622,6 +622,98 @@ describe("locking", async () => { expect(newAmount, "newAmount").to.be.eq(minLock + relock); }); + it("withdrawLocking", async () => { + const { + lockingInfo, + lockingPool, + whitelised, + unwhitelist, + admin, + mpc, + metisToken, + } = await loadFixture(fixture); + + const [wallet0, wallet1] = whitelised; + const [wallet3] = unwhitelist; + const minLock = 1n; + await lockingInfo.setMinLock(minLock); + await lockingPool.updateMpc(mpc); + await metisToken.approve(await lockingInfo.getAddress(), ethers.MaxUint256); + await lockingInfo.setRewardPayer(admin); + + const wallet0Pubkey = trimPubKeyPrefix(wallet0.signingKey.publicKey); + await lockingPool.connect(wallet0).lockFor(wallet0, minLock, wallet0Pubkey); + const seqId = 1n; + + const withdrawAmount = 1n; + + await expect( + lockingPool.connect(wallet3).withdraw(3, withdrawAmount), + "NotWhitelisted", + ).to.be.revertedWithCustomError(lockingPool, "NotWhitelisted"); + + await expect( + lockingPool.connect(wallet1).withdraw(2, withdrawAmount), + "SeqNotActive", + ).to.be.revertedWithCustomError(lockingPool, "SeqNotActive"); + + await expect( + lockingPool.connect(wallet1).withdraw(seqId, withdrawAmount), + "NotSeqOwner", + ).to.be.revertedWithCustomError(lockingPool, "NotSeqOwner"); + + await expect( + lockingPool.connect(wallet0).withdraw(seqId, withdrawAmount), + "throttle", + ).to.be.revertedWith("withdraw throttle"); + + await lockingPool + .connect(mpc) + .batchSubmitRewards(2n, 1n, 2n, [wallet0], [1]); + + await expect( + lockingPool.connect(wallet0).withdraw(seqId, 0), + "zero withdraw", + ).to.be.revertedWith("invalid amount"); + + await expect( + lockingPool.connect(wallet0).withdraw(seqId, minLock), + "locking < minLock", + ).to.be.revertedWith("invalid amount"); + + // starts from 1, and add 1 after the relock + let seqNonce = 2; + const relock = 3n; + await lockingPool.connect(wallet0).relock(seqId, relock, false); + + seqNonce++; + const locking = minLock + relock - withdrawAmount; + await expect( + await lockingPool.connect(wallet0).withdraw(seqId, withdrawAmount), + "withdraw", + ) + .to.be.emit(lockingInfo, "Withdraw") + .withArgs(seqId, withdrawAmount) + .and.to.be.emit(lockingInfo, "LockUpdate") + .withArgs(seqId, seqNonce, locking) + .and.to.be.emit(metisToken, "Transfer") + .withArgs(await lockingInfo.getAddress(), wallet0, withdrawAmount); + + await expect( + lockingPool.connect(wallet0).withdraw(seqId, withdrawAmount), + "throttle again", + ).to.be.revertedWith("withdraw throttle"); + + const { + nonce: newNonce, + amount: newAmount, + updatedBatch: newBatchId, + } = await lockingPool.sequencers(seqId); + expect(newNonce, "newNonce").to.be.eq(seqNonce); + expect(newBatchId, "newBatchId").to.be.eq(2n); + expect(newAmount, "newAmount").to.be.eq(locking); + }); + it("relock/withReward", async () => { const { admin, lockingInfo, lockingPool, whitelised, metisToken, mpc } = await loadFixture(fixture);