Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/governance/Redeemer.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
186 changes: 186 additions & 0 deletions test/unit/governance/Redeemer.t.sol
Original file line number Diff line number Diff line change
@@ -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));
}
}