Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Invalid

Double Taxation on Burn Fee Collection

Summary

The burn() function in RAACToken.sol applies burn fees and transfers them to the feeCollector, but this transfer itself can trigger additional taxation due to the feeCollector not being whitelisted by default. This results in the feeCollector receiving less fees than intended.

Vulnerability Details

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/RAACToken.sol#L83C1-L86C6
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/RAACToken.sol#L198

function burn(uint256 amount) external {
uint256 taxAmount = amount.percentMul(burnTaxRate);
_burn(msg.sender, amount - taxAmount);
// Transfer to feeCollector triggers _update which applies additional tax
if (taxAmount > 0 && feeCollector != address(0)) {
_transfer(msg.sender, feeCollector, taxAmount);
}
}

The issue arises because:

  1. User calls burn() with an amount

  2. Initial burn tax is calculated

  3. Tax amount is transferred to feeCollector using _transfer()

  4. _transfer() calls _update() which applies additional tax since feeCollector isn't whitelisted

  5. This results in the feeCollector receiving less than the intended tax amount

Proof of concept

Add this in tokens/RAACToken.test.js

describe("Large Number Burns", () => {
it("should handle large burns correctly and demonstrate tax calculation", async () => {
// Mint a very large amount to user1
const LARGE_AMOUNT = ethers.parseEther("1000000000"); // 1 billion RAAC
await raacToken.connect(owner).mint(users[0].address, LARGE_AMOUNT);
// Current burn tax rate is 50 basis points (0.5%)
const burnTaxRate = await raacToken.burnTaxRate();
// Calculate expected values
const amountToBurn = LARGE_AMOUNT;
const expectedTaxAmount = (LARGE_AMOUNT * burnTaxRate) / 10000n;
const expectedBurnAmount = LARGE_AMOUNT - expectedTaxAmount;
console.log("\nBurn Calculation Breakdown:");
console.log("Initial Amount:", ethers.formatEther(LARGE_AMOUNT), "RAAC");
console.log("Burn Tax Rate:", Number(burnTaxRate)/100, "%");
console.log("Tax Amount:", ethers.formatEther(expectedTaxAmount), "RAAC");
console.log("Actual Burn Amount:", ethers.formatEther(expectedBurnAmount), "RAAC");
// Track balances before burning
const initialSupply = await raacToken.totalSupply();
const initialUserBalance = await raacToken.balanceOf(users[0].address);
const initialFeeCollectorBalance = await raacToken.balanceOf(feeCollector.target);
// Perform the burn
await raacToken.connect(users[0]).burn(amountToBurn);
// Check final balances
const finalSupply = await raacToken.totalSupply();
const finalUserBalance = await raacToken.balanceOf(users[0].address);
const finalFeeCollectorBalance = await raacToken.balanceOf(feeCollector.target);
console.log("\nBalance Changes:");
console.log("Total Supply Change:", ethers.formatEther(initialSupply - finalSupply), "RAAC");
console.log("User Balance Change:", ethers.formatEther(initialUserBalance - finalUserBalance), "RAAC");
console.log("Fee Collector Received:", ethers.formatEther(finalFeeCollectorBalance - initialFeeCollectorBalance), "RAAC");
// Verify the results
expect(finalSupply).to.equal(initialSupply - expectedBurnAmount);
expect(finalUserBalance).to.equal(initialUserBalance - LARGE_AMOUNT);
expect(finalFeeCollectorBalance).to.equal(initialFeeCollectorBalance + expectedTaxAmount);
});
it("should handle burns with no fee collector", async () => {
// Remove fee collector
await raacToken.connect(owner).setFeeCollector(ethers.ZeroAddress);
// Mint tokens to user
const LARGE_AMOUNT = ethers.parseEther("1000000000"); // 1 billion RAAC
await raacToken.connect(owner).mint(users[0].address, LARGE_AMOUNT);
// Track initial balances
const initialSupply = await raacToken.totalSupply();
const initialUserBalance = await raacToken.balanceOf(users[0].address);
// Burn tokens
await raacToken.connect(users[0]).burn(LARGE_AMOUNT);
// Check final balances
const finalSupply = await raacToken.totalSupply();
const finalUserBalance = await raacToken.balanceOf(users[0].address);
console.log("\nBurn with No Fee Collector:");
console.log("Amount to Burn:", ethers.formatEther(LARGE_AMOUNT), "RAAC");
console.log("Actual Supply Reduction:", ethers.formatEther(initialSupply - finalSupply), "RAAC");
console.log("User Balance Reduction:", ethers.formatEther(initialUserBalance - finalUserBalance), "RAAC");
// With no fee collector, only burnTaxRate (0.5%) should be applied
const burnTaxRate = await raacToken.burnTaxRate();
const expectedTaxAmount = (LARGE_AMOUNT * burnTaxRate) / 10000n;
const expectedBurnAmount = LARGE_AMOUNT - expectedTaxAmount;
// The taxed amount is lost when there's no fee collector
expect(finalSupply).to.equal(initialSupply - expectedBurnAmount);
expect(finalUserBalance).to.equal(initialUserBalance - LARGE_AMOUNT);
});
});

Impact

  • FeeCollector receives reduced burn fees due to double taxation

  • Loss of protocol revenue that was meant to be collected by the feeCollector

Recommendations

  1. Whitelist the feeCollector in the constructor

constructor(
address initialOwner,
uint256 initialSwapTaxRate,
uint256 initialBurnTaxRate
) ERC20("RAAC Token", "RAAC") Ownable(initialOwner) {
// ...existing code...
// Whitelist fee collector at initialization
whitelistAddress[initialOwner] = true;
emit AddressWhitelisted(initialOwner);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RAACToken::burn applies burn tax twice when transferring to feeCollector, causing excess tokens to be burned and reduced fees to be collected

This is by design, sponsor's words: Yes, burnt amount, done by whitelisted contract or not always occur the tax. The feeCollector is intended to always be whitelisted and the address(0) is included in the _transfer as a bypass of the tax amount, so upon burn->_burn->_update it would have not applied (and would also do another burn...). For this reason, to always apply such tax, the burn function include the calculation (the 2 lines that applies) and a direct transfer to feeCollector a little bit later. This is done purposefully

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RAACToken::burn applies burn tax twice when transferring to feeCollector, causing excess tokens to be burned and reduced fees to be collected

This is by design, sponsor's words: Yes, burnt amount, done by whitelisted contract or not always occur the tax. The feeCollector is intended to always be whitelisted and the address(0) is included in the _transfer as a bypass of the tax amount, so upon burn->_burn->_update it would have not applied (and would also do another burn...). For this reason, to always apply such tax, the burn function include the calculation (the 2 lines that applies) and a direct transfer to feeCollector a little bit later. This is done purposefully

Appeal created

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

RAACToken::burn applies burn tax twice when transferring to feeCollector, causing excess tokens to be burned and reduced fees to be collected

This is by design, sponsor's words: Yes, burnt amount, done by whitelisted contract or not always occur the tax. The feeCollector is intended to always be whitelisted and the address(0) is included in the _transfer as a bypass of the tax amount, so upon burn->_burn->_update it would have not applied (and would also do another burn...). For this reason, to always apply such tax, the burn function include the calculation (the 2 lines that applies) and a direct transfer to feeCollector a little bit later. This is done purposefully

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.