Tornado Blast Pro League
Findings & Analysis Report
2024-06-18
Table of contents
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 Pro League Audit is an event where elite tier Code4rena contributors, commonly referred to as wardens, reviews, audits and analyzes smart contract logic in exchange for a bounty provided by sponsoring projects.
During the Pro League audit outlined in this document, C4 conducted an analysis of the Tornado Blast smart contract system written in Solidity. The audit took place between June 4 - June 14, 2024.
Wardens
2 Wardens contributed to Tornado Blast:
Final report assembled by bytes032 and thebrittfactor.
Summary
The C4 Pro League analysis yielded an aggregated total of 3 MEDIUM severity vulnerabilities.
Additionally, C4 Pro League analysis included 3 findings with a risk rating of LOW severity or non-critical and 3 Informational findings.
Scope
The source code was delivered to Code4rena in a private Git repository.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
Medium Risk Findings (3)
[M-01] VestingMaster
: Banning account causes future rewards in RevenueSharingVault
to get lost and tokens are stuck
Lines of Code
- VestingMaster.sol#L39-L42
- IndividualVestingVault.sol#L45-L51
Description
It is possible for the owner
of VestingMaster
to ban accounts from calling IndividualVestingVault.claim()
. As a result of this, the shares that belong to the banned account, are never withdrawn and just remain owned by IndividualVestingVault
. This means that any revenue earned on these shares is lost forever.
Recommendation
It is recommended to redeem the Vault shares for TRNDO and send them back to VestingMaster
when an account is banned. Thereby, no TRNDO token get stuck and revenue is not lost.
--- a/apps/contracts/src/Vesting/IndividualVestingVault.sol
+++ b/apps/contracts/src/Vesting/IndividualVestingVault.sol
@@ -42,6 +42,10 @@ contract IndividualVestingVault is VestingUtils, Initializable {
tokenizedVault.withdraw(amountToClaim, msg.sender, address(this));
}
+ function banAccount() external onlyVestingMaster() {
+ tokenizedVault.redeem(tokenizedVault.balanceOf(address(this)), msg.sender, address(this));
+ }
+
function claimableTokenAmount() public view returns (uint256) {
if (vaultIsBanned()) {
return 0;
--- a/apps/contracts/src/Vesting/VestingMaster.sol
+++ b/apps/contracts/src/Vesting/VestingMaster.sol
@@ -36,9 +36,14 @@ contract VestingMaster is IVestingMaster, VestingUtils, Ownable, MinimalProxyFac
}
}
+ function withdrawTornadoTokens(address recipient) external onlyOwner {
+ vestedToken.transfer(recipient, vestedToken.balanceOf(address(this)));
+ }
+
function banAccount(address account) external onlyOwner {
require(!claimingIsProhibitedFor[account]);
claimingIsProhibitedFor[account] = true;
+ vaultOf[account].banAccount();
}
Tornado Blast
Fixed.
Code4rena Pro League
The recommendation has been implemented. Instead of redeeming the shares to VestingMaster
, they are redeemed to the owner
of VestingMaster
.
[M-02] Initial mint amount of TRNDO is wrong
Lines of Code
TornadoBlastBotToken.sol#L40
Description
In the constructor of TornadoBlastBotToken
, 100 million tokens should be minted to the owner:
_mint(msg.sender, 10e8 * 10 ** decimals()); // 100 million
However, the code specifies 10e8
, which is one billion and not 100 million.
Recommendation
Consider using 100_000_000
instead of 10e8
, which is much more readeable:
- _mint(msg.sender, 10e8 * 10 ** decimals()); // 100 million
+ _mint(msg.sender, 100_000_000 * 10 ** decimals()); // 100 million
Tornado Blast
Fixed
Code4rena Pro League
The recommendation has been implemented.
[M-03] Fee and profit payments into RevenueSharingVault
can be sandwiched
Lines of Code
RevenueSharingVault.sol#L12-L24
Description
TRNDO transfer fees and trading profits are sent to RevenueSharingVault
where the shares of stakers appreciate. Rewards to stakers are paid out immediately. As a result, if large payouts are made, it can be profitable to make a flash-deposit to earn rewards without having staked TRNDO for any considerable amount of time.
Such behavior acts as a net transfer of rewards from unsophisticated to sophisticated stakers. Consider that the buy and sell fee payments to the Vault can be sandwiched within a single transaction by constructing a transaction consisting of:
- Deposit into the Vault.
- Buy or sell TRNDO and incur the buy/sell fee.
- Withdraw from the Vault.
Payments of trading gains are harder to sandwich because they can only be triggered by an external transaction, and Blast has no mempool that allows front-running. Still, the same considerations apply that users that have staked for a short period of time can earn an outsized portion of rewards.
Recommendation
It is recommended to send rewards to a separate VaultDistributor
contract that ensures rewards are paid out slowly. Available rewards are snapshotted and paid out linearly over a configurable timeframe. At the end of the timeframe, available rewards are snapshotted again.
This approach requires that additional functions in ERC4626 must be overridden.
Changes in RevenueSharingVault
:
--- a/apps/contracts/src/tornadoToken/RevenueSharingVault.sol
+++ b/apps/contracts/src/tornadoToken/RevenueSharingVault.sol
@@ -7,12 +7,18 @@ import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.
import { TornadoBlastBotToken } from "./TornadoBlastBotToken.sol";
import { BlastGasAndYield } from "../commons/BlastGasAndYield.sol";
+import {VaultDistributor} from "../VaultDistributor.sol";
+
/// @dev send tornado blast tokens to this contract to redistribute them to stakers
/// @dev treasury MUST stake a significant amount first to avoid future share/tokenAmount slippage
contract RevenueSharingVault is ERC4626, BlastGasAndYield {
+ VaultDistributor vaultDistributor;
constructor(
- TornadoBlastBotToken tornadoBlastToken
- ) ERC4626(tornadoBlastToken) ERC20("Staked Tornado Blast Token", "stTRNDO") {}
+ TornadoBlastBotToken tornadoBlastToken,
+ VaultDistributor _vaultDistributor
+ ) ERC4626(tornadoBlastToken) ERC20("Staked Tornado Blast Token", "stTRNDO") {
+ vaultDistributor = _vaultDistributor;
+ }
function _update(address from, address to, uint256 value) internal override {
// allow mint and burn, disallow transfers
@@ -21,4 +27,32 @@ contract RevenueSharingVault is ERC4626, BlastGasAndYield {
}
super._update(from, to, value);
}
+
+ function setVaultDistributor(VaultDistributor _vaultDistributor) external onlyOwner {
+ vaultDistributor = _vaultDistributor;
+ }
+
+ function totalAssets() public view override returns (uint256) {
+ return _asset.balanceOf(address(this)) + vaultDistributor.pendingRewards();
+ }
+
+ function deposit(uint256 assets, address receiver) public override returns (uint256) {
+ vaultDistributor.processRewards();
+ super.deposit(assets, receiver);
+ }
+
+ function mint(uint256 shares, address receiver) public override returns (uint256) {
+ vaultDistributor.processRewards();
+ super.mint(shares, receiver);
+ }
+
+ function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) {
+ vaultDistributor.processRewards();
+ super.withdarw(assets, receiver, owner);
+ }
+
+ function redeem(uint256 shares, address receiver, address owner) public override returns (uint256) {
+ vaultDistributor.processRewards();
+ super.redeem(shares, receiver, owner);
+ }
}
New VaultDistributor
contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { VestingUtils } from "./Vesting/VestingUtils.sol";
import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
contract VaultDistributor is Ownable, VestingUtils {
uint256 payoutPeriod;
uint256 activePayoutPeriod;
uint256 lastTimestamp;
uint256 allPaidOutTimestamp;
uint256 rewardBalance;
constructor(ERC4626 _tokenizedVault) VestingUtils(_tokenizedVault) Ownable(msg.sender) {
payoutPeriod = 1 weeks;
lastTimestamp = block.timestamp;
activePayoutPeriod = payoutPeriod;
allPaidOutTimestamp = block.timestamp;
}
function setPayoutPeriod(uint256 newPayoutPeriod) external onlyOwner {
require(payoutPeriod > 0, "payoutPeriod cannot be zero");
payoutPeriod = newPayoutPeriod;
}
function processRewards() external {
uint256 amountToPay = pendingRewards();
if (amountToPay > 0) {
vestedToken.transfer(address(tokenizedVault), amountToPay);
}
if (block.timestamp >= allPaidOutTimestamp) {
activePayoutPeriod = payoutPeriod;
allPaidOutTimestamp = block.timestamp + activePayoutPeriod;
rewardBalance = vestedToken.balanceOf(address(this));
}
lastTimestamp = block.timestamp;
}
function pendingRewards() public view returns(uint256) {
uint256 timestampNow = block.timestamp;
if (block.timestamp > allPaidOutTimestamp) {
timestampNow = allPaidOutTimestamp;
}
uint256 timePassed = timestampNow - lastTimestamp;
uint256 amountToPay = rewardBalance * timePassed / activePayoutPeriod;
return amountToPay;
}
}
Tornado Blast
All fixes implemented by commit 704dbc2d5f5828ee454aec9ee9d7dd10c1b14263
.
Code4rena Pro League
The recommended mitigation has been implemented. The implementation has been simplified and the payout period is hardcoded. In addition, LinearDistributor
inherits from BlastGasAndYield
such that Blast gas can be claimed.
Low Risk Findings (3)
[L-01] VestingMaster
: Check that schedule.cliffEndDate
is greater than block.timestamp
Lines of Code
VestingMaster.sol#L72-L76
Description
The checks in VestingMaster.validateSchedule()
fail to validate that schedule.cliffEndDate
is greater than block.timestamp
. A schedule with schedule.cliffEndDate < block.timestamp
would immediately make some of the locked funds claimable. According to the client, this is not intended and it is recommended to add the missing sanity check.
Recommendation
function validateSchedule(Schedule memory schedule) internal view {
require(schedule.totalClaimableTokens > 0);
- require(schedule.linearVestingEndDate > block.timestamp);
+ require(schedule.cliffEndDate > block.timestamp);
require(schedule.linearVestingEndDate > schedule.cliffEndDate);
}
Tornado Blast
Fixed.
Code4rena Pro League
The recommendation has been implemented.
[L-02] Maximum buy/sell tax is not checked
Lines of Code
TornadoBlastBotToken.sol#L46-L52
Description
When the TornadoBlastBotToken
contract is first deployed, the maximum buy and sell tax is set to 5% in MAX_SELL_TAX_SHARE
and MAX_BUY_TAX_SHARE
. However, setSellTaxShare()
and setBuyTaxShare()
do not check these values:
function setSellTaxShare(Ray newTaxShare) external onlyOwner {
sellTaxShare = newTaxShare;
}
function setBuyTaxShare(Ray newTaxShare) external onlyOwner {
buyTaxShare = newTaxShare;
}
This allows the owner to set the buy/sell tax to any arbitrary percentage.
Recommendation
Ensure newTaxShare
is not larger than MAX_SELL_TAX_SHARE
/MAX_BUY_TAX_SHARE
in their respective functions:
function setSellTaxShare(Ray newTaxShare) external onlyOwner {
+ require(MAX_SELL_TAX_SHARE.gte(newTaxShare));
sellTaxShare = newTaxShare;
}
function setBuyTaxShare(Ray newTaxShare) external onlyOwner {
+ require(MAX_BUY_TAX_SHARE.gte(newTaxShare));
buyTaxShare = newTaxShare;
}
Tornado Blast
Fixed.
Code4rena Pro League
The recommendation has been implemented.
[L-03] IndividualVestingVault.claimableTokenAmount()
can underflow due to rounding in ERC4626 Vault
Lines of Code
IndividualVestingVault.sol#L45-L51
Description
When withdrawing assets, RevenueSharingVault
rounds up the amount of shares to burn. As a result, if a user withdraws amount X of assets from the vault, their value of assets in the vault can drop by more than X.
The finding can be confirmed by adding the following test to Vesting.t.sol
:
function testClaimableTokenAmountUnderflow() public {
initBobVault();
skip(2 days);
grantGainedYieldToSharingVault(929384722818138423489);
claimAs(bob);
// @audit here the calculation underflows due to rounding up the shares to burn in the first calculation
claimAs(bob);
}
The severity of the issue is “Low” since the underflow is only temporary and as more funds are vested, the calculation can be executed successfully again.
Recommendation
Check for the underflow condition and return zero instead.
uint256 lockedAmount = tokenAmountInitiallyStaked() - claimableNowFromLinearVesting();
- return unstakableTokenAmount() - lockedAmount; // includes extra yield on top of linear vested tokens
+ uint256 unstakableTokenAmount = unstakableTokenAmount();
+ if (unstakableTokenAmount < lockedAmount) {
+ return 0;
+ } else {
+ return unstakableTokenAmount() - lockedAmount; // includes extra yield on top of linear vested tokens
+ }
}
Tornado Blast
Fixed.
Code4rena Pro League
The recommendation has been implemented.
Informational Findings (3)
[I-01] Remove unnecessary pool
equals owner()
condition in TornadoBlastBotToken.taxIfTaxIsActive()
Lines of Code
TornadoBlastBotToken.sol#L99
Description
owner()
will never be the same as an active pool, so the condition is redundant.
Recommendation
) internal returns (uint256 remainingAmount) {
- if (pool == owner() || taxIsDisabled(taxShare) || isBuyBack(transfer)) {
+ if (taxIsDisabled(taxShare) || isBuyBack(transfer)) {
return transfer.value;
}
return performTax(transfer, taxShare);
Tornado Blast
Fixed.
Code4rena Pro League
The recommendation has been implemented.
[I-02] IndividualVestingVault.unstakableTokenAmount()
can be simplified
Lines of Code
IndividualVestingVault.sol#L83-L86
Description
IndividualVestingVault.unstakableTokenAmount()
calculates the maximum amount of assets that can be withdrawn from tokenizedVault
as such:
function unstakableTokenAmount() internal view returns (uint256) {
VaultShares stakedAmount = VaultShares.wrap(tokenizedVault.balanceOf(address(this)));
return tokenizedVault.convertToAssets(VaultShares.unwrap(stakedAmount));
}
This can be simplified using ERC4626.maxWithdraw()
.
Recommendation
Use maxWithdraw()
instead, which returns the maximum amount of assets that can be withdrawn:
function unstakableTokenAmount() internal view returns (uint256) {
- VaultShares stakedAmount = VaultShares.wrap(tokenizedVault.balanceOf(address(this)));
- return tokenizedVault.convertToAssets(VaultShares.unwrap(stakedAmount));
+ return tokenizedVault.maxWithdraw(address(this));
}
This is essentially the same logic as the current implementation, just shorter.
Tornado Blast
Fixed.
Code4rena Pro League
The recommendation has been implemented.
[I-03] RevenueSharingVault
is vulnerable to share inflation attacks
Lines of Code
RevenueSharingVault.sol#L10-L24
Description
RevenueSharingVault
inherits Openzeppelin’s ERC4626
without overriding _decimalsOffset()
:
/// @dev send tornado blast tokens to this contract to redistribute them to stakers
/// @dev treasury MUST stake a significant amount first to avoid future share/tokenAmount slippage
contract RevenueSharingVault is ERC4626, BlastGasAndYield {
constructor(
TornadoBlastBotToken tornadoBlastToken
) ERC4626(tornadoBlastToken) ERC20("Staked Tornado Blast Token", "stTRNDO") {}
function _update(address from, address to, uint256 value) internal override {
// allow mint and burn, disallow transfers
if (from != address(0) && to != address(0)) {
revert("staked tornado blast tokens are not transferrable");
}
super._update(from, to, value);
}
}
This means that the vault’s virtual assets and shares are both 1
(i.e., the vault starts with 1 asset and 1 share). If the treasury does not deposit into the vault first, an attacker can donate TRNDO to the vault to cause future deposits to lose funds, for example:
- Assume
RevenueSharingVault
is newly deployed. - Attacker deposits 1 TRNDO into the vault, receiving 1 share in return.
- Attacker transfers
100e18
TRNDO into the vault. -
Owner calls
VestingMaster.vestTokensForNewAccounts()
to vest10e18
TRNDO for 20 individual addresses:10e18
TRNDO is deposited into the vault 20 times.- The amount of shares minted for each deposit is
10e18 * (1 + 1) / (100e18 + 1) = 0
. - All 20 deposits receive no shares for the vested TRNDO.
In the scenario above, the TRNDO deposited will accrue to the attacker’s 1 share, allowing him to make a profit.
Recommendation
Ensure that the treasury stakes some amount of TRNDO into the vault before tokens are distributed to users.
Tornado Blast
Acknowledged.
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.