diff --git a/contracts/protocol/libraries/helpers/Errors.sol b/contracts/protocol/libraries/helpers/Errors.sol index 6801d7fe4..243b49610 100644 --- a/contracts/protocol/libraries/helpers/Errors.sol +++ b/contracts/protocol/libraries/helpers/Errors.sol @@ -125,4 +125,5 @@ library Errors { string public constant NOT_THE_BAKC_OWNER = "130"; //user is not the bakc owner. string public constant CALLER_NOT_EOA = "131"; //The caller of the function is not an EOA account string public constant MAKER_SAME_AS_TAKER = "132"; //maker and taker shouldn't be the same address + string public constant CALLER_NOT_ADMIN = "134"; //caller not admin } diff --git a/contracts/protocol/tokenization/NTokenApeStaking.sol b/contracts/protocol/tokenization/NTokenApeStaking.sol index 8d75be463..113ab9bcc 100644 --- a/contracts/protocol/tokenization/NTokenApeStaking.sol +++ b/contracts/protocol/tokenization/NTokenApeStaking.sol @@ -8,6 +8,9 @@ import {IERC20} from "../../dependencies/openzeppelin/contracts/IERC20.sol"; import {IERC721} from "../../dependencies/openzeppelin/contracts/IERC721.sol"; import {IRewardController} from "../../interfaces/IRewardController.sol"; import {ApeStakingLogic} from "./libraries/ApeStakingLogic.sol"; +import {MintableERC721Logic} from "./libraries/MintableERC721Logic.sol"; +import {Errors} from "../libraries/helpers/Errors.sol"; + import "../../interfaces/INTokenApeStaking.sol"; /** @@ -30,6 +33,20 @@ abstract contract NTokenApeStaking is NToken, INTokenApeStaking { */ uint256 internal constant DEFAULT_UNSTAKE_INCENTIVE_PERCENTAGE = 30; + function _onlyApeRescueAdmin() private view { + ApeStakingLogic.APEStakingParameter storage s = apeStakingDataStorage(); + + require(msg.sender == s.apeRescueAdmin, Errors.CALLER_NOT_ADMIN); + } + + /** + * @dev Only pool admin can call functions marked by this modifier. + **/ + modifier onlyApeRescueAdmin() { + _onlyApeRescueAdmin(); + _; + } + /** * @dev Constructor. * @param pool The address of the Pool contract @@ -227,4 +244,90 @@ abstract contract NTokenApeStaking is NToken, INTokenApeStaking { function getBAKCNTokenAddress() internal view returns (address) { return POOL.getReserveData(address(getBAKC())).xTokenAddress; } + + /** + * @dev Sets a new Ape Rescue Admin + * @param newAdmin The address of the new Ape Rescue Admin + */ + function setApeRescueAdmin(address newAdmin) external onlyPoolAdmin { + require(newAdmin != address(0), Errors.ZERO_ADDRESS_NOT_VALID); + + ApeStakingLogic.APEStakingParameter storage s = apeStakingDataStorage(); + s.apeRescueAdmin = newAdmin; + } + + /** + * @dev Rescues locked APE tokens + * @param amount The amount of APE tokens to rescue + * @param to The address to send the rescued tokens + */ + function rescueLockedAPE(uint256 amount, address to) + external + onlyApeRescueAdmin + { + IERC20 apeCoin = _apeCoinStaking.apeCoin(); + MintableERC721Logic.executeRescueERC20(address(apeCoin), to, amount); + } + + /** + * @dev Updates the Ape Rescue Claim + * @param tokenId The token ID associated with the claim + * @param txHash The transaction hash of the claim + * @param amount The claim amount + * @param status The claim status + */ + function updateApeRescueClaim( + uint256 tokenId, + bytes32 txHash, + uint128 amount, + ApeStakingLogic.APERescueClaimStatus status + ) external onlyApeRescueAdmin { + ApeStakingLogic.APEStakingParameter storage s = apeStakingDataStorage(); + ApeStakingLogic.ClaimData memory claimData = s.apeRescueClaims[tokenId][ + txHash + ]; + + require( + claimData.status != ApeStakingLogic.APERescueClaimStatus.CLAIMED, + "Already Claimed" + ); + require(amount > 0, "amount can't be zero"); + + s.apeRescueClaims[tokenId][txHash].amount = amount; + s.apeRescueClaims[tokenId][txHash].status = status; + } + + /** + * @dev Claims the locked APE tokens + * @param tokenId The token ID associated with the claim + * @param txHash The transaction hash of the claim + */ + function claimLockedAPE(uint256 tokenId, bytes32 txHash) external { + ApeStakingLogic.APEStakingParameter storage s = apeStakingDataStorage(); + ApeStakingLogic.ClaimData memory claimData = s.apeRescueClaims[tokenId][ + txHash + ]; + + require( + claimData.status == ApeStakingLogic.APERescueClaimStatus.APPROVED, + "Claim not approved" + ); + require( + IERC721(_ERC721Data.underlyingAsset).ownerOf(tokenId) == + msg.sender || + ownerOf(tokenId) == msg.sender, + Errors.NOT_THE_OWNER + ); + + s.apeRescueClaims[tokenId][txHash].status = ApeStakingLogic + .APERescueClaimStatus + .CLAIMED; + + IERC20 apeCoin = _apeCoinStaking.apeCoin(); + MintableERC721Logic.executeRescueERC20( + address(apeCoin), + msg.sender, + claimData.amount + ); + } } diff --git a/contracts/protocol/tokenization/libraries/ApeStakingLogic.sol b/contracts/protocol/tokenization/libraries/ApeStakingLogic.sol index 57c53e4f0..c61f6a758 100644 --- a/contracts/protocol/tokenization/libraries/ApeStakingLogic.sol +++ b/contracts/protocol/tokenization/libraries/ApeStakingLogic.sol @@ -25,8 +25,21 @@ library ApeStakingLogic { uint256 constant MAYC_POOL_ID = 2; uint256 constant BAKC_POOL_ID = 3; + enum APERescueClaimStatus { + REVOKED, + APPROVED, + CLAIMED + } + + struct ClaimData { + APERescueClaimStatus status; + uint128 amount; + } + struct APEStakingParameter { uint256 unstakeIncentive; + address apeRescueAdmin; + mapping(uint256 => mapping(bytes32 => ClaimData)) apeRescueClaims; } event UnstakeApeIncentiveUpdated(uint256 oldValue, uint256 newValue); diff --git a/test/_pool_ape_staking.spec.ts b/test/_pool_ape_staking.spec.ts index c2a525ed4..b8defcdbd 100644 --- a/test/_pool_ape_staking.spec.ts +++ b/test/_pool_ape_staking.spec.ts @@ -2982,4 +2982,73 @@ describe("APE Coin Staking Test", () => { .data; expect(isUsingAsCollateral(configDataAfter, sApeReserveData.id)).true; }); + + it("sets a new Ape Rescue Admin", async () => { + const { + users: [user1, , user3], + ape, + mayc, + pool, + nMAYC, + deployer, + } = await loadFixture(fixture); + + await expect( + await nMAYC + .connect(deployer.signer) + .setApeRescueAdmin(await user1.address) + ); + }); + + it("reverts when trying to set zero address as Ape Rescue Admin", async () => { + const { + users: [user1, , user3], + ape, + mayc, + pool, + nMAYC, + deployer, + } = await loadFixture(fixture); + + await expect( + nMAYC.connect(deployer.signer).setApeRescueAdmin(ZERO_ADDRESS) + ).to.be.revertedWith("ZERO_ADDRESS_NOT_VALID"); + }); + + it("rescues locked APE tokens", async () => { + const { + users: [user1, , user3], + ape, + mayc, + pool, + nMAYC, + deployer, + } = await loadFixture(fixture); + + const initialBalance = await ape.balanceOf(user3.address); + + await ape["mint(address,uint256)"](nMAYC.address, parseEther("110")); + await nMAYC + .connect(user3.signer) + .rescueLockedAPE(parseEther("100"), user3.address); + + const finalBalance = await ape.balanceOf(user3.address); + expect(finalBalance).to.equal(initialBalance.add(parseEther("100"))); + }); + + it("reverts when non-apeRescueAdmin tries to rescue locked APE tokens", async () => { + const { + users: [user1, , user3], + ape, + mayc, + pool, + nMAYC, + deployer, + } = await loadFixture(fixture); + await expect( + nMAYC + .connect(user3.signer) + .rescueLockedAPE(parseEther("100"), user3.address) + ).to.be.revertedWith("Caller is not the Ape Rescue Admin"); + }); });