diff --git a/src/governance/Redeemer.sol b/src/governance/Redeemer.sol new file mode 100644 index 00000000..f82b4270 --- /dev/null +++ b/src/governance/Redeemer.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity =0.8.13; + +import {VoltRoles} from "@voltprotocol/core/VoltRoles.sol"; +import {CoreRefV2} from "@voltprotocol/refs/CoreRefV2.sol"; +import {GuildToken} from "@voltprotocol/governance/GuildToken.sol"; +import {RateLimitedV2} from "@voltprotocol/utils/RateLimitedV2.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @title contract used to redeem a list of tokens, by permanently +/// taking another token out of circulation. +/// @author Fei Protocol, eswak +/// This contract has been used by the Tribe DAO to allow redemptions of TRIBE for their +/// pro-rata share of the PCV. This version is modified to allow the redemption of GUILD +/// tokens, and the GUILD.totalSupply() is used instead of a hardcoded base. Moreover, +/// there is a rate limit to the redemptions, and the GUILD tokens are burnt on redeem. +/// The rate limit is expressed as a number of GUILD tokens available for redemption. +/// This contract also has a reference to Core that allow governance to recover funds +/// on this contract, and pause/unpause the redemptions. +contract Redeemer is ReentrancyGuard, RateLimitedV2 { + using SafeERC20 for IERC20; + + /// @notice event to track redemptions + event Redeemed( + address indexed owner, + address indexed receiver, + uint256 amount, + uint256 base + ); + + /// @notice token to redeem + address public immutable redeemedToken; + + /// @notice tokens to receive when redeeming + address[] private tokensReceived; + + constructor( + address _core, + address _redeemedToken, + address[] memory _tokensReceived, + uint256 _maxRateLimitPerSecond, + uint128 _rateLimitPerSecond, + uint128 _bufferCap + ) + CoreRefV2(_core) + RateLimitedV2(_maxRateLimitPerSecond, _rateLimitPerSecond, _bufferCap) + { + redeemedToken = _redeemedToken; + tokensReceived = _tokensReceived; + } + + /// @notice Public function to get `tokensReceived` + function tokensReceivedOnRedeem() public view returns (address[] memory) { + return tokensReceived; + } + + /// @notice Return the balances of `tokensReceived` that would be + /// transferred if redeeming `amountIn` of `redeemedToken`. + function previewRedeem( + uint256 amountIn + ) + public + view + returns ( + uint256 base, + address[] memory tokens, + uint256[] memory amountsOut + ) + { + tokens = tokensReceivedOnRedeem(); + amountsOut = new uint256[](tokens.length); + GuildToken _guild = GuildToken(redeemedToken); + + base = _guild.totalSupply(); + for (uint256 i = 0; i < tokensReceived.length; i++) { + uint256 balance = IERC20(tokensReceived[i]).balanceOf( + address(this) + ); + require(balance != 0, "ZERO_BALANCE"); + // @dev, this assumes all of `tokensReceived` and `redeemedToken` + // have the same number of decimals + uint256 redeemedAmount = (amountIn * balance) / base; + amountsOut[i] = redeemedAmount; + } + } + + /// @notice Redeem `redeemedToken` for a pro-rata basket of `tokensReceived` + function redeem( + address to, + uint256 amountIn + ) external whenNotPaused nonReentrant { + _depleteBuffer(amountIn); + + GuildToken _guild = GuildToken(redeemedToken); + _guild.transferFrom(msg.sender, address(this), amountIn); + + ( + uint256 base, + address[] memory tokens, + uint256[] memory amountsOut + ) = previewRedeem(amountIn); + + _guild.burn(amountIn); + + for (uint256 i = 0; i < tokens.length; i++) { + IERC20(tokens[i]).safeTransfer(to, amountsOut[i]); + } + + emit Redeemed(msg.sender, to, amountIn, base); + } + + /// @notice governor-only function to migrate funds to a new contract + function withdrawAll( + address token, + address to + ) external onlyVoltRole(VoltRoles.GOVERNOR) { + uint256 balance = IERC20(token).balanceOf(address(this)); + IERC20(token).transfer(to, balance); + } +} diff --git a/test/unit/governance/Redeemer.t.sol b/test/unit/governance/Redeemer.t.sol new file mode 100644 index 00000000..51d559f9 --- /dev/null +++ b/test/unit/governance/Redeemer.t.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.13; + +import {Test} from "@forge-std/Test.sol"; +import {CoreV2} from "@voltprotocol/core/CoreV2.sol"; +import {Redeemer} from "@voltprotocol/governance/Redeemer.sol"; +import {getCoreV2} from "@test/unit/utils/Fixtures.sol"; +import {MockERC20} from "@test/mock/MockERC20.sol"; +import {TestAddresses as addresses} from "@test/unit/utils/TestAddresses.sol"; + +contract RedeemerUnitTest is Test { + CoreV2 private core; + Redeemer private redeemer; + MockERC20 credit; + MockERC20 guild; + address constant alice = address(0x616c696365); + address constant bob = address(0xB0B); + + uint256 MAX_RATE_LIMIT_PER_SECOND = 31688087814028950000; // 31.7/s with 18 decimals + uint128 RATE_LIMIT_PER_SECOND = 9506426344208685000; // 9.5/s with 18 decimals + uint128 BUFFER_CAP = 20_000_000e18; // 20M GUILD + + function setUp() public { + vm.warp(1679067867); + vm.roll(16848497); + core = CoreV2(address(getCoreV2())); + credit = new MockERC20(); + guild = new MockERC20(); + + address[] memory tokensRedeemed = new address[](1); + tokensRedeemed[0] = address(credit); + redeemer = new Redeemer( + address(core), + address(guild), + tokensRedeemed, + MAX_RATE_LIMIT_PER_SECOND, + RATE_LIMIT_PER_SECOND, + BUFFER_CAP + ); + + // labels + vm.label(address(this), "test"); + vm.label(address(core), "core"); + vm.label(address(redeemer), "redeemer"); + vm.label(address(guild), "guild"); + vm.label(address(credit), "credit"); + vm.label(alice, "alice"); + vm.label(bob, "bob"); + } + + function testInitialState() public { + assertEq(address(redeemer.redeemedToken()), address(guild)); + assertEq(redeemer.tokensReceivedOnRedeem().length, 1); + assertEq(redeemer.tokensReceivedOnRedeem()[0], address(credit)); + } + + function testPreviewRedeem() public { + // initial situation: 1000 GUILD can claim 100 CREDIT + guild.mint(alice, 1000); + credit.mint(address(redeemer), 100); + + ( + uint256 base1, + address[] memory tokens1, + uint256[] memory amountsOut1 + ) = redeemer.previewRedeem(500); + + assertEq(base1, 1000); + assertEq(tokens1.length, 1); + assertEq(tokens1[0], address(credit)); + assertEq(amountsOut1.length, 1); + assertEq(amountsOut1[0], 50); + + // more GUILD in circulation reduce the redemption price + // here, 2000 GUILD share 100 CREDIT + guild.mint(alice, 1000); + + (uint256 base2, , uint256[] memory amountsOut2) = redeemer + .previewRedeem(500); + + assertEq(base2, 2000); + assertEq(amountsOut2[0], 25); + + // more CREDIT in the redeemer increase the redemption price + // here, 2000 GUILD share 500 CREDIT + credit.mint(address(redeemer), 400); + + (uint256 base3, , uint256[] memory amountsOut3) = redeemer + .previewRedeem(500); + + assertEq(base3, 2000); + assertEq(amountsOut3[0], 125); + } + + function testRedeemForSelf() public { + // 1000 GUILD can claim 100 CREDIT + guild.mint(alice, 1000); + credit.mint(address(redeemer), 100); + + vm.startPrank(alice); + guild.approve(address(redeemer), 1000); + redeemer.redeem(alice, 1000); + vm.stopPrank(); + + assertEq(credit.balanceOf(address(redeemer)), 0); + assertEq(credit.balanceOf(alice), 100); + assertEq(credit.balanceOf(bob), 0); + assertEq(guild.totalSupply(), 0); + } + + function testRedeemForOther() public { + // 1000 GUILD can claim 100 CREDIT + guild.mint(alice, 1000); + credit.mint(address(redeemer), 100); + + vm.startPrank(alice); + guild.approve(address(redeemer), 1000); + redeemer.redeem(bob, 1000); + vm.stopPrank(); + + assertEq(credit.balanceOf(address(redeemer)), 0); + assertEq(credit.balanceOf(alice), 0); + assertEq(credit.balanceOf(bob), 100); + assertEq(guild.totalSupply(), 0); + } + + function testRedeemPausable() public { + vm.prank(addresses.governorAddress); + redeemer.pause(); + + // 1000 GUILD can claim 100 CREDIT + guild.mint(alice, 1000); + credit.mint(address(redeemer), 100); + + vm.startPrank(alice); + guild.approve(address(redeemer), 1000); + vm.expectRevert("Pausable: paused"); + redeemer.redeem(bob, 1000); + vm.stopPrank(); + } + + function testRedeemRateLimit() public { + uint256 buffer = redeemer.buffer(); + + // `buffer` GUILD can claim 100 CREDIT + guild.mint(alice, buffer * 2); + credit.mint(address(redeemer), 100); + + vm.startPrank(alice); + guild.approve(address(redeemer), buffer * 2); + redeemer.redeem(bob, buffer); + vm.stopPrank(); + + assertEq(credit.balanceOf(address(redeemer)), 50); + assertEq(guild.balanceOf(alice), buffer); + assertEq(credit.balanceOf(alice), 0); + assertEq(credit.balanceOf(bob), 50); + assertEq(guild.totalSupply(), buffer); + + vm.expectRevert("RateLimited: no rate limit buffer"); + vm.prank(alice); + redeemer.redeem(bob, buffer); + + vm.warp(block.timestamp + 3600); + + vm.expectRevert("RateLimited: rate limit hit"); + vm.prank(alice); + redeemer.redeem(bob, buffer); + } + + function testGovernorRecoverFunds() public { + // 1000 GUILD can claim 100 CREDIT + guild.mint(alice, 1000); + credit.mint(address(redeemer), 100); + + // governor recovers the CREDIT that are on the redeemer + vm.prank(addresses.governorAddress); + redeemer.withdrawAll(address(credit), address(this)); + assertEq(credit.balanceOf(address(redeemer)), 0); + assertEq(credit.balanceOf(address(this)), 100); + + // non-governor cannot perform the call + vm.expectRevert("UNAUTHORIZED"); + redeemer.withdrawAll(address(credit), address(this)); + } +}