diff --git a/src/contracts/burners/DC_sfrxETH_Burner.sol b/src/contracts/burners/DC_sfrxETH_Burner.sol new file mode 100644 index 0000000..fccea6d --- /dev/null +++ b/src/contracts/burners/DC_sfrxETH_Burner.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {SelfDestruct} from "src/contracts/SelfDestruct.sol"; + +import {IDC_sfrxETH_Burner} from "src/interfaces/burners/DC_sfrxETH/IDC_sfrxETH_Burner.sol"; +import {IFraxEtherRedemptionQueue} from "src/interfaces/burners/DC_sfrxETH/IFraxEtherRedemptionQueue.sol"; + +import {IDefaultCollateral} from "@symbiotic/collateral/interfaces/defaultCollateral/IDefaultCollateral.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + +contract DC_sfrxETH_Burner is IDC_sfrxETH_Burner, IERC721Receiver { + using Math for uint256; + using EnumerableSet for EnumerableSet.UintSet; + + /** + * @inheritdoc IDC_sfrxETH_Burner + */ + address public immutable COLLATERAL; + + /** + * @inheritdoc IDC_sfrxETH_Burner + */ + address public immutable ASSET; + + /** + * @inheritdoc IDC_sfrxETH_Burner + */ + address public immutable FRAX_ETHER_REDEMPTION_QUEUE; + + EnumerableSet.UintSet private _requestIds; + + constructor(address collateral, address fraxEtherRedemptionQueue) { + COLLATERAL = collateral; + + ASSET = IDefaultCollateral(collateral).asset(); + + FRAX_ETHER_REDEMPTION_QUEUE = fraxEtherRedemptionQueue; + + IERC20(ASSET).approve(FRAX_ETHER_REDEMPTION_QUEUE, type(uint256).max); + } + + /** + * @inheritdoc IDC_sfrxETH_Burner + */ + function requestIdsLength() external view returns (uint256) { + return _requestIds.length(); + } + + /** + * @inheritdoc IDC_sfrxETH_Burner + */ + function requestIds(uint256 index, uint256 maxRequestIds) external view returns (uint256[] memory requestIds_) { + uint256 length = Math.min(index + maxRequestIds, _requestIds.length()) - index; + + requestIds_ = new uint256[](length); + for (uint256 i; i < length;) { + requestIds_[i] = _requestIds.at(index); + unchecked { + ++i; + ++index; + } + } + } + + /** + * @inheritdoc IDC_sfrxETH_Burner + */ + function triggerWithdrawal() external returns (uint256 requestId) { + uint256 amount = IERC20(COLLATERAL).balanceOf(address(this)); + IDefaultCollateral(COLLATERAL).withdraw(address(this), amount); + + requestId = IFraxEtherRedemptionQueue(FRAX_ETHER_REDEMPTION_QUEUE).enterRedemptionQueueViaSfrxEth( + address(this), uint120(amount) + ); + + _requestIds.add(requestId); + + emit TriggerWithdrawal(msg.sender, requestId); + } + + /** + * @inheritdoc IDC_sfrxETH_Burner + */ + function triggerBurn(uint256 requestId) external { + if (!_requestIds.remove(requestId)) { + revert InvalidRequestId(); + } + + IFraxEtherRedemptionQueue(FRAX_ETHER_REDEMPTION_QUEUE).burnRedemptionTicketNft( + requestId, payable(address(this)) + ); + + new SelfDestruct{value: address(this).balance}(); + + emit TriggerBurn(msg.sender, requestId); + } + + /** + * @inheritdoc IERC721Receiver + */ + function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } + + receive() external payable {} +} diff --git a/src/interfaces/burners/DC_sfrxETH/IDC_sfrxETH_Burner.sol b/src/interfaces/burners/DC_sfrxETH/IDC_sfrxETH_Burner.sol new file mode 100644 index 0000000..8a285e4 --- /dev/null +++ b/src/interfaces/burners/DC_sfrxETH/IDC_sfrxETH_Burner.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +interface IDC_sfrxETH_Burner { + error InvalidRequestId(); + + /** + * @notice Emitted when a withdrawal is triggered. + * @param caller caller of the function + * @param requestId request ID that was created + */ + event TriggerWithdrawal(address indexed caller, uint256 requestId); + + /** + * @notice Emitted when a burn is triggered. + * @param caller caller of the function + * @param requestId request ID of the withdrawal that was claimed and burned + */ + event TriggerBurn(address indexed caller, uint256 requestId); + + /** + * @notice Get an address of the Default Collateral contract. + */ + function COLLATERAL() external view returns (address); + + /** + * @notice Get an address of the collateral's asset. + */ + function ASSET() external view returns (address); + + /** + * @notice Get an address of the Frax Ether Redemption queue. + */ + function FRAX_ETHER_REDEMPTION_QUEUE() external view returns (address); + + /** + * @notice Get the number of unprocessed request IDs. + */ + function requestIdsLength() external view returns (uint256); + + /** + * @notice Get a list of unprocessed request IDs. + * @param index index of the first request ID + * @param maxRequestIds maximum number of request IDs to return + * @return requestIds request IDs + */ + function requestIds(uint256 index, uint256 maxRequestIds) external view returns (uint256[] memory requestIds); + + /** + * @notice Trigger a withdrawal of ETH from the collateral's underlying asset. + * @return requestId request ID that was created + */ + function triggerWithdrawal() external returns (uint256 requestId); + + /** + * @notice Trigger a claim and a burn of ETH. + * @param requestId request ID of the withdrawal to process + */ + function triggerBurn(uint256 requestId) external; +} diff --git a/src/interfaces/burners/DC_sfrxETH/IFraxEtherRedemptionQueue.sol b/src/interfaces/burners/DC_sfrxETH/IFraxEtherRedemptionQueue.sol new file mode 100644 index 0000000..e7886aa --- /dev/null +++ b/src/interfaces/burners/DC_sfrxETH/IFraxEtherRedemptionQueue.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +interface IFraxEtherRedemptionQueue { + /// @notice State of Frax's frxETH redemption queue + /// @return nextNftId Autoincrement for the NFT id + /// @return queueLengthSecs Current wait time (in seconds) a new redeemer would have. Should be close to Beacon. + /// @return redemptionFee Redemption fee given as a percentage with 1e6 precision + /// @return earlyExitFee Early NFT back to frxETH exit fee given as a percentage with 1e6 precision + function redemptionQueueState() + external + view + returns (uint64 nextNftId, uint64 queueLengthSecs, uint64 redemptionFee, uint64 earlyExitFee); + + /// @notice Enter the queue for redeeming sfrxEth to frxETH at the current rate, then frxETH to ETH 1-to-1. Must have approved or permitted first. + /// @notice Will generate a FrxETHRedemptionTicket NFT that can be redeemed for the actual ETH later. + /// @param _recipient Recipient of the NFT. Must be ERC721 compatible if a contract + /// @param _sfrxEthAmount Amount of sfrxETH to redeem (in shares / balanceOf) + /// @param _nftId The ID of the FrxEthRedemptionTicket NFT + /// @dev Must call approve/permit on frxEth contract prior to this call + function enterRedemptionQueueViaSfrxEth( + address _recipient, + uint120 _sfrxEthAmount + ) external returns (uint256 _nftId); + + /// @notice Redeems a FrxETHRedemptionTicket NFT for ETH. Must have reached the maturity date first. + /// @param _nftId The ID of the NFT + /// @param _recipient The recipient of the redeemed ETH + function burnRedemptionTicketNft(uint256 _nftId, address payable _recipient) external; +} diff --git a/src/interfaces/burners/DC_swETH/IDC_swETH_Burner.sol b/src/interfaces/burners/DC_swETH/IDC_swETH_Burner.sol index b459cd3..8b17a0e 100644 --- a/src/interfaces/burners/DC_swETH/IDC_swETH_Burner.sol +++ b/src/interfaces/burners/DC_swETH/IDC_swETH_Burner.sol @@ -31,7 +31,7 @@ interface IDC_swETH_Burner { function ASSET() external view returns (address); /** - * @notice Get an address of the Lido withdrawal queue. + * @notice Get an address of the Swell Exit contract. */ function SWEXIT() external view returns (address); diff --git a/test/burners/DC_sfrxETH_Burner.t.sol b/test/burners/DC_sfrxETH_Burner.t.sol new file mode 100644 index 0000000..2060b50 --- /dev/null +++ b/test/burners/DC_sfrxETH_Burner.t.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Test, console2} from "forge-std/Test.sol"; + +import {DC_sfrxETH_Burner} from "src/contracts/burners/DC_sfrxETH_Burner.sol"; + +import {IFraxEtherRedemptionQueue} from "src/interfaces/burners/DC_sfrxETH/IFraxEtherRedemptionQueue.sol"; +import {IDC_sfrxETH_Burner} from "src/interfaces/burners/DC_sfrxETH/IDC_sfrxETH_Burner.sol"; + +import {IERC20} from "test/mocks/AaveV3Borrow.sol"; + +import {IDefaultCollateral} from "@symbiotic/collateral/interfaces/defaultCollateral/IDefaultCollateral.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract DC_sfrxETH_BurnerTest is Test { + address owner; + address alice; + uint256 alicePrivateKey; + address bob; + uint256 bobPrivateKey; + + DC_sfrxETH_Burner burner; + + address public constant COLLATERAL = 0x5198CB44D7B2E993ebDDa9cAd3b9a0eAa32769D2; + address public constant FRXETH = 0x5E8422345238F34275888049021821E8E08CAa1f; + address public constant SFRXETH = 0xac3E018457B222d93114458476f3E3416Abbe38F; + address public constant FRAX_ETHER_REDEMPTION_QUEUE = 0x82bA8da44Cd5261762e629dd5c605b17715727bd; + address public constant FRXETH_MINTER = 0xbAFA44EFE7901E04E39Dad13167D089C559c1138; + + function setUp() public { + // uint256 mainnetFork = vm.createFork(vm.rpcUrl("mainnet")); + // vm.selectFork(mainnetFork); + + owner = address(this); + (alice, alicePrivateKey) = makeAddrAndKey("alice"); + (bob, bobPrivateKey) = makeAddrAndKey("bob"); + + IERC20(SFRXETH).approve(COLLATERAL, type(uint256).max); + + vm.deal(address(this), 1_000_000 ether); + + vm.startPrank(IDefaultCollateral(COLLATERAL).limitIncreaser()); + IDefaultCollateral(COLLATERAL).increaseLimit(100_000_000 ether); + vm.stopPrank(); + + vm.startPrank(FRXETH_MINTER); + IFrxETH(FRXETH).minter_mint(address(this), 500_000 ether); + vm.stopPrank(); + + IERC20(FRXETH).approve(SFRXETH, type(uint256).max); + ISfrxETH(SFRXETH).deposit(500_000 ether, address(this)); + + IDefaultCollateral(COLLATERAL).deposit(address(this), 400_000 ether); + } + + function test_Create() public { + burner = new DC_sfrxETH_Burner(COLLATERAL, FRAX_ETHER_REDEMPTION_QUEUE); + vm.deal(address(burner), 0); + + assertEq(burner.COLLATERAL(), COLLATERAL); + assertEq(burner.ASSET(), SFRXETH); + assertEq(burner.FRAX_ETHER_REDEMPTION_QUEUE(), FRAX_ETHER_REDEMPTION_QUEUE); + assertEq(IERC20(SFRXETH).allowance(address(burner), FRAX_ETHER_REDEMPTION_QUEUE), type(uint256).max); + } + + function test_TriggerWithdrawal(uint256 depositAmount1, uint256 depositAmount2) public { + depositAmount1 = bound(depositAmount1, 1, 50_000 ether); + depositAmount2 = bound(depositAmount2, 1, 50_000 ether); + + burner = new DC_sfrxETH_Burner(COLLATERAL, FRAX_ETHER_REDEMPTION_QUEUE); + vm.deal(address(burner), 0); + + IERC20(COLLATERAL).transfer(address(burner), depositAmount1); + + assertEq(IERC20(COLLATERAL).balanceOf(address(burner)), depositAmount1); + assertEq(IERC20(SFRXETH).balanceOf(address(burner)), 0); + (uint256 nextRequestId,,,) = IFraxEtherRedemptionQueue(FRAX_ETHER_REDEMPTION_QUEUE).redemptionQueueState(); + assertEq(address(burner).balance, 0); + uint256 requestsId = burner.triggerWithdrawal(); + assertEq(address(burner).balance, 0); + assertEq(requestsId, nextRequestId); + assertEq(IERC20(COLLATERAL).balanceOf(address(burner)), 0); + assertEq(IERC20(SFRXETH).balanceOf(address(burner)), 0); + + assertEq(burner.requestIdsLength(), 1); + uint256[] memory requestsIds = burner.requestIds(0, type(uint256).max); + assertEq(requestsIds.length, 1); + for (uint256 i; i < 1; ++i) { + assertEq(requestsIds[i], nextRequestId + i); + } + requestsIds = burner.requestIds(0, 0); + assertEq(requestsIds.length, 0); + requestsIds = burner.requestIds(0, 1); + assertEq(requestsIds.length, 1); + assertEq(requestsIds[0], nextRequestId); + + IERC20(COLLATERAL).transfer(address(burner), depositAmount2); + + assertEq(IERC20(COLLATERAL).balanceOf(address(burner)), depositAmount2); + assertEq(IERC20(SFRXETH).balanceOf(address(burner)), 0); + (nextRequestId,,,) = IFraxEtherRedemptionQueue(FRAX_ETHER_REDEMPTION_QUEUE).redemptionQueueState(); + assertEq(address(burner).balance, 0); + requestsId = burner.triggerWithdrawal(); + assertEq(address(burner).balance, 0); + assertEq(requestsId, nextRequestId); + assertEq(IERC20(COLLATERAL).balanceOf(address(burner)), 0); + assertEq(IERC20(SFRXETH).balanceOf(address(burner)), 0); + + assertEq(burner.requestIdsLength(), 2); + requestsIds = burner.requestIds(0, type(uint256).max); + assertEq(requestsIds.length, 2); + for (uint256 i; i < 2; ++i) { + assertEq(requestsIds[i], nextRequestId - 1 + i); + } + requestsIds = burner.requestIds(0, 0); + assertEq(requestsIds.length, 0); + requestsIds = burner.requestIds(0, 2); + assertEq(requestsIds.length, 2); + assertEq(requestsIds[0], nextRequestId - 1); + assertEq(requestsIds[1], nextRequestId); + } + + function test_TriggerBurn(uint256 depositAmount1) public { + depositAmount1 = bound(depositAmount1, 1, 10_000 ether); + + burner = new DC_sfrxETH_Burner(COLLATERAL, FRAX_ETHER_REDEMPTION_QUEUE); + vm.deal(address(burner), 0); + + IERC20(COLLATERAL).transfer(address(burner), depositAmount1); + + uint256 requestsId = burner.triggerWithdrawal(); + + vm.deal(FRAX_ETHER_REDEMPTION_QUEUE, 1_000_000 ether); + + (, uint256 queueLengthSecs,,) = IFraxEtherRedemptionQueue(FRAX_ETHER_REDEMPTION_QUEUE).redemptionQueueState(); + + vm.warp(block.timestamp + queueLengthSecs); + + assertEq(address(burner).balance, 0); + burner.triggerBurn(requestsId); + assertEq(address(burner).balance, 0); + + assertEq(burner.requestIdsLength(), 0); + } + + function test_TriggerBurnRevertInvalidRequestId(uint256 depositAmount1) public { + depositAmount1 = bound(depositAmount1, 1, 10_000 ether); + + burner = new DC_sfrxETH_Burner(COLLATERAL, FRAX_ETHER_REDEMPTION_QUEUE); + vm.deal(address(burner), 0); + + IERC20(COLLATERAL).transfer(address(burner), depositAmount1); + + burner.triggerWithdrawal(); + + vm.deal(FRAX_ETHER_REDEMPTION_QUEUE, 1_000_000 ether); + + (, uint256 queueLengthSecs,,) = IFraxEtherRedemptionQueue(FRAX_ETHER_REDEMPTION_QUEUE).redemptionQueueState(); + + vm.warp(block.timestamp + queueLengthSecs); + + vm.expectRevert(IDC_sfrxETH_Burner.InvalidRequestId.selector); + burner.triggerBurn(0); + } +} + +interface IFrxETH { + // This function is what other minters will call to mint new tokens + function minter_mint(address m_address, uint256 m_amount) external; +} + +interface ISfrxETH { + function deposit(uint256 assets, address receiver) external returns (uint256 shares); +}