I created a new test file, copying much of the initialization code and some test cases from FeeCollector.test.js
. I added some lines where Treasury::withdraw()
is called.
import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
import { time } from "@nomicfoundation/hardhat-network-helpers";
describe("FeeCollector & Treasury", function () {
let raacToken, feeCollector, treasury, veRAACToken, testToken;
let owner, user1, repairFund, emergencyAdmin;
let defaultFeeType;
const BASIS_POINTS = 10000;
const WEEK = 7 * 24 * 3600;
const ONE_YEAR = 365 * 24 * 3600;
const INITIAL_MINT = ethers.parseEther("10000");
const SWAP_TAX_RATE = 100;
const BURN_TAX_RATE = 50;
beforeEach(async function () {
[owner, user1, repairFund, emergencyAdmin] = await ethers.getSigners();
const RAACToken = await ethers.getContractFactory("RAACToken");
raacToken = await RAACToken.deploy(owner.address, SWAP_TAX_RATE, BURN_TAX_RATE);
await raacToken.waitForDeployment();
const VeRAACToken = await ethers.getContractFactory("veRAACToken");
veRAACToken = await VeRAACToken.deploy(await raacToken.getAddress());
await veRAACToken.waitForDeployment();
const Treasury = await ethers.getContractFactory("Treasury");
treasury = await Treasury.deploy(owner.address);
await treasury.waitForDeployment();
const FeeCollector = await ethers.getContractFactory("FeeCollector");
feeCollector = await FeeCollector.deploy(
await raacToken.getAddress(),
await veRAACToken.getAddress(),
await treasury.getAddress(),
repairFund.address,
owner.address
);
await feeCollector.waitForDeployment();
await raacToken.setFeeCollector(await feeCollector.getAddress());
await raacToken.manageWhitelist(await feeCollector.getAddress(), true);
await raacToken.manageWhitelist(await veRAACToken.getAddress(), true);
await raacToken.setMinter(owner.address);
await veRAACToken.setMinter(owner.address);
await feeCollector.grantRole(await feeCollector.FEE_MANAGER_ROLE(), owner.address);
await feeCollector.grantRole(await feeCollector.EMERGENCY_ROLE(), emergencyAdmin.address);
await feeCollector.grantRole(await feeCollector.DISTRIBUTOR_ROLE(), owner.address);
await raacToken.mint(user1.address, INITIAL_MINT);
await raacToken.connect(user1).approve(await feeCollector.getAddress(), ethers.MaxUint256);
await raacToken.connect(user1).approve(await veRAACToken.getAddress(), ethers.MaxUint256);
defaultFeeType = {
veRAACShare: 5000,
burnShare: 1000,
repairShare: 1000,
treasuryShare: 3000
};
for (let i = 0; i < 8; i++) {
await feeCollector.connect(owner).updateFeeType(i, defaultFeeType);
}
const taxRate = SWAP_TAX_RATE + BURN_TAX_RATE;
const grossMultiplier = BigInt(BASIS_POINTS * BASIS_POINTS) / BigInt(BASIS_POINTS * BASIS_POINTS - taxRate * BASIS_POINTS);
const protocolFeeGross = ethers.parseEther("50") * grossMultiplier / BigInt(10000);
const lendingFeeGross = ethers.parseEther("30") * grossMultiplier / BigInt(10000);
const swapTaxGross = ethers.parseEther("20") * grossMultiplier / BigInt(10000);
await feeCollector.connect(user1).collectFee(protocolFeeGross, 0);
await feeCollector.connect(user1).collectFee(lendingFeeGross, 1);
await feeCollector.connect(user1).collectFee(swapTaxGross, 6);
await veRAACToken.connect(user1).lock(ethers.parseEther("1000"), ONE_YEAR);
await time.increase(WEEK);
});
describe("Fee Collection and Distribution", function () {
it("should distribute fees correctly between stakeholders and recover from treasury", async function () {
const initialTreasuryBalance = await raacToken.balanceOf(treasury.target);
const initialRepairFundBalance = await raacToken.balanceOf(repairFund.address);
const initialUserBalance = await raacToken.balanceOf(user1.address);
const taxRate = SWAP_TAX_RATE + BURN_TAX_RATE;
const netMultiplier = BigInt(BASIS_POINTS - taxRate) / BigInt(BASIS_POINTS);
const protocolFeesNet = ethers.parseEther("50") * netMultiplier;
const lendingFeesNet = ethers.parseEther("30") * netMultiplier;
const swapTaxesNet = ethers.parseEther("20") * netMultiplier;
const treasuryShare = (
(protocolFeesNet * 3000n) / 10000n +
(lendingFeesNet * 3000n) / 10000n
);
const repairShare = (
(protocolFeesNet * 1000n) / 10000n +
(swapTaxesNet * 1000n) / 10000n
);
const treasuryShareNet = treasuryShare * netMultiplier;
const repairShareNet = repairShare * netMultiplier;
await feeCollector.connect(owner).distributeCollectedFees();
const finalTreasuryBalance = await raacToken.balanceOf(treasury.target);
const finalRepairFundBalance = await raacToken.balanceOf(repairFund.address);
const margin = ethers.parseEther("0.01");
expect(finalTreasuryBalance).to.be.closeTo(
initialTreasuryBalance + treasuryShareNet,
margin
);
expect(finalRepairFundBalance).to.be.closeTo(
initialRepairFundBalance + repairShareNet,
margin
);
await expect(treasury.connect(owner).withdraw(
await raacToken.getAddress(),
finalTreasuryBalance,
user1.address
)).to.be.fulfilled;
expect(await raacToken.balanceOf(treasury.target)).to.equal(initialTreasuryBalance);
expect(await raacToken.balanceOf(user1.address)).to.be.greaterThan(initialUserBalance);
});
});
describe("Emergency Controls", function () {
beforeEach(async function () {
await feeCollector.connect(owner).grantRole(await feeCollector.EMERGENCY_ROLE(), owner.address);
await raacToken.connect(user1).transfer(feeCollector.target, ethers.parseEther("100"));
});
it("should allow emergency withdrawal and recovery of RAACToken by admin", async function () {
await feeCollector.connect(owner).pause();
const amount = ethers.parseEther("100");
const taxRate = SWAP_TAX_RATE + BURN_TAX_RATE;
const firstTaxAmount = amount * BigInt(taxRate) / BigInt(10000);
const remainingAfterFirstTax = amount - firstTaxAmount;
const secondTaxAmount = remainingAfterFirstTax * BigInt(taxRate) / BigInt(10000);
const expectedAmount = remainingAfterFirstTax - secondTaxAmount;
const initialBalance = await raacToken.balanceOf(feeCollector.target);
expect(initialBalance).to.equal(100010000000000000000n);
await raacToken.connect(user1).transfer(feeCollector.target, amount);
const newBalance = await raacToken.balanceOf(feeCollector.target);
expect(newBalance).to.equal(200010000000000000000n);
const treasuryBalanceBeforeEmergencyWithdrawal = await raacToken.balanceOf(treasury.target);
await feeCollector.connect(owner).emergencyWithdraw(raacToken.target);
const finalTreasuryBalance = await raacToken.balanceOf(treasury.target);
expect(finalTreasuryBalance).to.equal(200010000000000000000n);
const userBalanceBeforeRecovery = await raacToken.balanceOf(user1.address);
await expect(treasury.connect(owner).withdraw(
await raacToken.getAddress(),
finalTreasuryBalance,
user1.address
)).to.be.fulfilled;
expect(await raacToken.balanceOf(treasury.target)).to.equal(treasuryBalanceBeforeEmergencyWithdrawal);
expect(await raacToken.balanceOf(user1.address)).to.be.greaterThan(userBalanceBeforeRecovery);
});
it("should allow emergency withdrawal and recovery of any test token by admin", async function () {
await feeCollector.connect(owner).pause();
const MockToken = await ethers.getContractFactory("MockToken");
testToken = await MockToken.deploy("Test Token", "TEST", 18);
const initUserBalance = ethers.parseEther("1000");
await testToken.mint(user1.address, initUserBalance);
const amount = ethers.parseEther("100");
await testToken.connect(user1).transfer(feeCollector.target, amount);
expect(await testToken.balanceOf(user1)).to.equal(initUserBalance - amount);
expect(await testToken.balanceOf(feeCollector.target)).to.equal(amount);
expect(await testToken.balanceOf(treasury.target)).to.equal(0);
expect(await treasury.getTotalValue()).to.equal(0);
expect(await treasury.getBalance(testToken.target)).to.equal(0);
await feeCollector.connect(owner).emergencyWithdraw(testToken.target);
expect(await testToken.balanceOf(feeCollector.target)).to.equal(0);
expect(await testToken.balanceOf(treasury.target)).to.equal(amount);
expect(await treasury.getTotalValue()).to.equal(amount);
expect(await treasury.getBalance(testToken.target)).to.equal(amount);
await expect(treasury.connect(owner).withdraw(
testToken.target,
amount,
user1.address
)).to.be.fulfilled;
expect(await testToken.balanceOf(user1)).to.equal(initUserBalance);
expect(await testToken.balanceOf(feeCollector.target)).to.equal(0);
expect(await testToken.balanceOf(treasury.target)).to.equal(0);
expect(await treasury.getTotalValue()).to.equal(0);
expect(await treasury.getBalance(testToken.target)).to.equal(0);
});
});
});
The following changes can be applied to the contracts, after which the above test cases as well as the existing test files FeeCollector.test.js
and Treasury.test.js
would pass:
@@ -7,6 +7,7 @@ import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "../../interfaces/core/collectors/IFeeCollector.sol";
+import "../../interfaces/core/collectors/ITreasury.sol";
import "../../interfaces/core/tokens/IveRAACToken.sol";
import "../../interfaces/core/tokens/IRAACToken.sol";
@@ -270,14 +271,8 @@ contract FeeCollector is IFeeCollector, AccessControl, ReentrancyGuard, Pausable
if (!hasRole(EMERGENCY_ROLE, msg.sender)) revert UnauthorizedCaller();
if (token == address(0)) revert InvalidAddress();
- uint256 balance;
- if (token == address(raacToken)) {
- balance = raacToken.balanceOf(address(this));
- raacToken.safeTransfer(treasury, balance);
- } else {
- balance = IERC20(token).balanceOf(address(this));
- SafeERC20.safeTransfer(IERC20(token), treasury, balance);
- }
+ uint256 balance = IERC20(token).balanceOf(address(this));
+ _depositTokensToTreasury(token, balance);
emit EmergencyWithdrawal(token, balance);
}
@@ -420,7 +415,7 @@ contract FeeCollector is IFeeCollector, AccessControl, ReentrancyGuard, Pausable
if (shares[1] > 0) raacToken.burn(shares[1]);
if (shares[2] > 0) raacToken.safeTransfer(repairFund, shares[2]);
- if (shares[3] > 0) raacToken.safeTransfer(treasury, shares[3]);
+ if (shares[3] > 0) _depositTokensToTreasury(address(raacToken), shares[3]);
}
/**
@@ -522,6 +517,42 @@ contract FeeCollector is IFeeCollector, AccessControl, ReentrancyGuard, Pausable
return 0;
}
+ /**
+ * @dev Deposits tokens to treasury address. If it is the dedicated
+ * treasury contract, calls deposit(). If not, just transfers the tokens.
+ * @param _token token address
+ * @param _amount amount of tokens to send
+ */
+ function _depositTokensToTreasury(address _token, uint256 _amount) internal {
+ address to = treasury;
+ bool _isTreasury = _isTreasuryContract(to);
+ if (_isTreasury) {
+ SafeERC20.forceApprove(IERC20(_token), to, _amount);
+ ITreasury(to).deposit(_token, _amount);
+ } else {
+ SafeERC20.safeTransfer(IERC20(_token), to, _amount);
+ }
+ }
+
+ /**
+ * @notice Tells if the supplied address is treasury contract
+ * @param _treasury the address
+ * @return bool true if the address is a treasury contract.
+ * false if it's an EOA or some other contract, e.g. a multisig wallet.
+ */
+ function _isTreasuryContract(address _treasury) internal view returns (bool) {
+ uint256 _size;
+ assembly {
+ _size := extcodesize(_treasury)
+ }
+ if (_size > 0) {
+ try ITreasury(_treasury).hasCustomDeposit() returns (bool _isTreasury) {
+ return _isTreasury;
+ } catch { }
+ }
+ return false;
+ }
+
// View Functions
/**
@@ -121,4 +121,14 @@ contract Treasury is ITreasury, AccessControl, ReentrancyGuard {
function getAllocation(address allocator, address recipient) external view returns (uint256) {
return _allocations[allocator][recipient];
}
+
+ /**
+ * @notice Tells the caller that this contract has custom token deposit strategy
+ * so that the caller should call deposit() instead of directly transferring
+ * their tokens.
+ * @return bool true
+ */
+ function hasCustomDeposit() external pure returns (bool) {
+ return true;
+ }
}
@@ -61,6 +61,14 @@ interface ITreasury {
*/
function getAllocation(address allocator, address recipient) external view returns (uint256);
+ /**
+ * @notice Tells the caller that this contract has custom token deposit strategy
+ * so that the caller should call deposit() instead of directly transferring
+ * their tokens.
+ * @return bool true
+ */
+ function hasCustomDeposit() external view returns (bool);
+
/**
* @notice Events
*/
@@ -61,4 +61,8 @@ contract MockTreasury is ITreasury {
function mock_setAllocation(address allocator, address recipient, uint256 amount) external {
_allocations[allocator][recipient] = amount;
}
+
+ function hasCustomDeposit() external pure returns (bool) {
+ return true;
+ }
}