BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

BriVault & BriTechToken Protocol Audit Report

BriVault & BriTechToken Protocol Audit Report

Author: Shivanageshwarrao
Date: November 8, 2025


Table of Contents


Protocol Summary

This audit covers two Solidity contracts:

  • BriVault — an ERC4626-based tournament vault where participants deposit an ERC20 asset, pick a team/country, and winners share the vault after an event.

  • BriTechToken — an ERC20 token contract for the ecosystem.


Disclaimer

This report represents the auditor’s best effort to identify vulnerabilities.
It does not guarantee the absence of undiscovered issues or future exploit safety.
No liability is assumed by the auditor or organization.


Risk Classification

Severity Matrix

Impact ↓ / Likelihood → Low Medium High
High Medium High Critical
Medium Low Medium High
Low Info Low Medium

Audit Details

Scope

  • Static review of BriVault.sol and BriTechToken.sol

  • Solidity version: 0.8.24

  • Based on OpenZeppelin ERC4626 & ERC20 standards.

Tools & Methods

  • Manual static analysis

  • Slither / Mythril checks

  • ERC4626 compliance validation

  • Gas and DoS analysis


Executive Summary

The contracts implement the intended functionality but suffer from critical vulnerabilities and centralization risks:

  • Owner can arbitrarily decide winners or mint tokens.

  • Missing reentrancy protection and unbounded loops create DoS and theft risk.

  • ERC4626 compliance issues could break integrations.

Summary by Severity

Severity Count
Critical 3
High 5
Medium 3
Low 4
Info 6

Findings

Critical

[C-1] Unbounded O(n) iteration to compute winner shares (_getWinnerShares) — DoS & gas exhaustion

Description: _getWinnerShares() iterates over the entire usersAddress dynamic array to compute totalWinnerShares. As the number of users grows, this function becomes arbitrarily expensive and may run out of gas. Because setWinner() calls _getWinnerShares(), the owner (or any privileged flow) may be unable to finalize the vault if gas exceeds block limits.

Impact: Denial-of-service: event finalization may fail; attacker can bloat usersAddress to prevent setWinner() from succeeding.

Proof of Code

function _getWinnerShares() internal {
for (uint256 i = 0; i < usersAddress.length; ++i) {
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
}

Proof of Concept

// Simulate DoS by adding excessive participants
for (uint256 i = 0; i < 10000; i++) {
vault.joinEvent(1);
}
vault.setWinner(1); // Expected to revert due to out-of-gas

Recommendation: Maintain per-country aggregated share counters during joinEvent() (increment and decrement on join/cancel) so setWinner() uses O(1) reads instead of iterating. Alternatively, perform aggregation off-chain and accept a merkle-proof-based claim system.


[C-2] Missing reentrancy protection on external token transfers (deposit, cancelParticipation, withdraw)

Description: Several user-facing functions perform external ERC20 transfers after updating or reading state without reentrancy guards. Although Solidity 0.8.x provides safe arithmetic, token callbacks (malicious ERC777/ERC20 with hooks) or reentrant safeTransfer implementations could re-enter contract functions.

Impact: Funds could be stolen, users could withdraw multiple times, or state could be corrupted.

Proof of Code

function withdraw() public {
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
}

Proof of Concept

// Deploy a malicious ERC20 token with reentrant callback on transfer
// Reentrancy can trigger withdraw() multiple times before state is updated
maliciousToken.reenterVaultOnTransfer();
vault.withdraw();

Recommendation: Use ReentrancyGuard and mark external state-changing functions (deposit, cancelParticipation, withdraw) nonReentrant. Additionally, follow the Checks-Effects-Interactions pattern and minimize external calls.


[C-3] BriTechToken::mint() allows unlimited owner-controlled minting (centralized inflation / rug)

Description: mint() is callable by owner repeatedly and mints 10,000,000 tokens each call to the owner. There is no cap or one-time restriction.

Impact: Owner can arbitrarily inflate supply and drain value from token holders.

Proof of Code

function mint() public onlyOwner {
_mint(owner(), 10_000_000 * 1e18);
}

Proof of Concept

// Owner calls mint() multiple times, inflating total supply infinitely
briTechToken.mint();
briTechToken.mint();
briTechToken.mint(); // Supply increases without bound

Recommendation: Remove public mint or restrict it to a capped, controlled mechanism (e.g., ERC20Capped) or require timelock/multi-sig for mint operations.

High

[H-1] Division-by-zero and insufficient validation in withdraw() (totalWinnerShares == 0)

Description: withdraw() calculates assetToWithdraw = shares * vaultAsset / totalWinnerShares without checking that totalWinnerShares > 0.

Impact: If totalWinnerShares == 0, this reverts (division by zero) or behaves unexpectedly, blocking withdrawals.

Proof of Code

uint256 assetToWithdraw = (shares * vaultAsset) / totalWinnerShares;

Proof of Concept

// Call withdraw() when no winner is set
vault.withdraw(); // Reverts due to division by zero

Recommendation: Require totalWinnerShares > 0 before allowing withdrawal. Ensure totalWinnerShares is computed correctly and cannot be zero if winners exist.


[H-2] Owner can arbitrarily set the winner (setWinner) — centralization and rug risk

Description: setWinner() is onlyOwner and entirely on-chain; owner selects the winning country and finalizes distribution.

Impact: Owner can set a malicious winner, redirect funds, or collude with an address. Users have no verifiable path to trust the outcome.

Proof of Code

function setWinner(uint256 countryIndex) public onlyOwner returns (string memory) {
winnerCountryId = countryIndex;
winner = teams[countryIndex];
_getWinnerShares();
}

Proof of Concept

// Owner arbitrarily selects a losing team as winner
vault.setWinner(10); // Sets any arbitrary team as winner

Recommendation: Integrate an oracle, multisig approval, or a delay and challenge period: e.g., a timelocked proposeWinner that can be challenged or requires multisig to finalize.


[H-3] Incorrect ERC4626 compliance and deposit semantics (fee transfers & minting to msg.sender)

Description: deposit implements non-standard behavior: it uses two safeTransferFrom calls (fee and stake) and mints vault shares to msg.sender instead of receiver. It calls IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee) then again for stake. It also fails to call the ERC4626 _deposit() internal function.

Impact: Violates ERC4626 expectations — tooling and integrations may misbehave. Splitting transfers increases reentrancy exposure and reduces atomicity. Minting to msg.sender instead of receiver breaks transfer semantics.

Proof of Code

IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
_mint(msg.sender, participantShares);

Proof of Concept

// Deposit from an address different from intended receiver
vault.deposit(1000, receiver); // Shares minted to msg.sender, not receiver

Recommendation: Align with ERC4626: collect the full assets transfer to the vault first, perform internal accounting, then transfer fee to the fee address from the vault balance (not via safeTransferFrom). Mint shares to receiver. Use Checks-Effects-Interactions.


[H-4] Missing validations on constructor inputs (zero-address checks; start < end)

Description: Constructor accepts _eventStartDate, _eventEndDate, _asset, and _participationFeeAddress without validation (except fee bsp bound). No guard for zero addresses or start < end.

Impact: Misconfiguration risks at deploy time (broken timeline, funds locked, or fee address set to zero).

Proof of Code

constructor(
IERC20 _asset,
uint256 _eventStartDate,
uint256 _eventEndDate,
address _participationFeeAddress
) { ... }

Proof of Concept

// Deploy vault with invalid parameters
BriVault vault = new BriVault(token, 100, 100, address(0)); // No validation triggers

Recommendation: Add validation for zero addresses and event timeline as described.


[H-5] joinEvent allows multiple joins and stale accounting (double-join, active users not cleaned)

Description: Users can call joinEvent multiple times for different countryId values, overwriting userToCountry and pushing duplicates into usersAddress. cancelParticipation does not remove the user from usersAddress or decrement counters.

Impact: Inflation of participants array (DoS), incorrect winner share calculation, and unfair distribution.

Proof of Code

userToCountry[msg.sender] = teams[countryId];
usersAddress.push(msg.sender);

Proof of Concept

vault.joinEvent(1);
vault.joinEvent(2); // Overwrites mapping but adds duplicate user in array

Recommendation: Prevent double-join by checking an isJoined mapping or requiring bytes(userToCountry[msg.sender]).length == 0.

Medium

[M-1] deposit transfers split across two external calls — ordering & reentrancy

Proof of Code

IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);

Proof of Concept

// Malicious token triggers reentrancy between two transfers
vault.deposit(1000, msg.sender); // Reentrancy risk between safeTransferFrom calls

[M-2] Missing hasWithdrawn tracking — double-withdraw risk

Proof of Code

_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);

Proof of Concept

// Reentrant token can call withdraw() again before state updates
vault.withdraw();
vault.withdraw(); // Double-withdraw before state protection

[M-3] cancelParticipation keeps users in usersAddress and does not adjust totals

Proof of Code

function cancelParticipation() public {
userToCountry[msg.sender] = "";
}

Proof of Concept

// User cancels but still counted in array
vault.cancelParticipation();
vault.setWinner(1); // Still iterates through cancelled users

Low

[L-1] Naming & style: _setWinner boolean uses leading underscore and breaks naming convention

Recommendation: Rename to winnerSet or isWinnerSet.


[L-2] Events — some use non-indexed fields and some important actions lack events

Recommendation: Index up to 3 fields on commonly queried events (deposited, joinedEvent, Withdraw). Add CancelParticipation and FeeTaken events.


[L-3] getCountry uses non-robust validation (no explicit bounds check)

Recommendation: Use require(countryId < teams.length, "Invalid index"); and check bytes(teams[countryId]).length > 0.


[L-4] Minor ERC4626 non-compliance and missing use of internal _deposit/_mint helpers

Recommendation: Rework to delegate to OpenZeppelin ERC4626's _deposit and _withdraw helpers where appropriate.

Informational

  • Consider making participationFeeAddress public or add a getter so users can verify the fee receiver.

  • Consider making event timestamps immutable or exposed in events for off-chain verification.

  • Gas optimization: consider immutable for constructor arguments that never change.

  • Consider using ERC20Capped for BriTechToken or remove the mint function entirely.

  • Consider adopting a pausable or timelock for owner actions such as setWinner.

  • Replace magic numbers and hard-coded BASIS points logic with named constants and tests.

Recommendations (Action plan, prioritized)

  1. Immediate (blocker fixes)

  • Add ReentrancyGuard and mark deposit, withdraw, cancelParticipation nonReentrant.

  • Fix unlimited mint in BriTechToken immediately (remove public mint or add caps/timelock/multisig).

  • Prevent division by zero in withdraw().

  1. High-priority logic fixes

  • Remove the unbounded loop by tracking per-country aggregated shares on joinEvent and cancelParticipation.

  • Make setWinner() less centralized (multisig or timelock + oracle).

  • Rework deposit() to follow ERC4626: pull funds once, then distribute fee from the vault balance and mint to receiver.

  1. Medium-term improvements

  • Harden input validation in constructor and public setters.

  • Add events and index fields for observability and tooling.

  • Add active user tracking & safe removal on cancel.

  1. Long-term architectural suggestions

  • Consider a claim-based model where winners claim their share via proofs to avoid on-chain aggregation.

  • Add unit tests and fuzz tests for edge cases: many users, tiny shares, and last-share rounding.

  • Consider a governance or multisig flow for critical owner operations.

Appendix: Suggested Patches (snippets)

Note: These are illustrative patches; integrate and test thoroughly before deployment.

Constructor checks

- if (_participationFeeBsp > PARTICIPATIONFEEBSPMAX) {
- revert limiteExceede();
- }
+ if (_participationFeeBsp > PARTICIPATIONFEEBSPMAX) revert limiteExceede();
+ if (_eventStartDate >= _eventEndDate) revert("Invalid event timeline");
+ if (address(_asset) == address(0) || _participationFeeAddress == address(0)) revert("Zero address");

Deposit: single transfer, mint to receiver

- IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
- IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
- _mint(msg.sender, participantShares);
+ // Pull entire assets to vault
+ IERC20(asset()).safeTransferFrom(msg.sender, address(this), assets);
+ // Move fee from vault to fee receiver
+ IERC20(asset()).safeTransfer(participationFeeAddress, fee);
+ // Mint shares to the intended receiver
+ _mint(receiver, participantShares);

Prevent double-join

- userToCountry[msg.sender] = teams[countryId];
+ require(bytes(userToCountry[msg.sender]).length == 0, "Already joined");
+ userToCountry[msg.sender] = teams[countryId];

BriTechToken: one-time mint or capped

- function mint() public onlyOwner {
- _mint(owner(), 10_000_000 * 1e18);
- }
+ // Remove public mint; implement controlled distribution off-chain or via governance

End of report.

Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Division by Zero in Withdraw Function When No Winners Bet on Winning Team

When no one bet on the winning team, making totalWinnerShares = 0, causing division by zero in withdraw and preventing any withdrawals.

`cancelParticipation` Leaves Stale Winner Data

CancelParticipation burns shares but leaves the address inside usersAddress and keeps userSharesToCountry populated.

Unbounded Loop in _getWinnerShares Causes Denial of Service

The _getWinnerShares() function is intended to iterate through all users and sum their shares for the winning country, returning the total.

Duplicate registration through `joinEvent`

Unrestricted ERC4626 functions

Support

FAQs

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

Give us feedback!