BriVault

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

Funds recorded for receiver, shares minted to caller — breaks join()/payout logic

deposit records stakedAsset[receiver] but mints shares to msg.sender, creating a mismatch between staking records and share ownership. Legitimate depositors can lose participation rights or cause payout logic to break (zero snapshots → panic / incorrect distribution).

Description

  • Normal behaviour:

    A caller who deposits tokens into the vault should cause (1) the vault to record the economic stake for the actual participant, (2) mint corresponding vault shares to the same participant, and (3) allow that participant to later call joinEvent() and receive a proper snapshot used for payouts. In short: payer → stake recorded; payer (or designated beneficiary) → shares minted; owner of shares → able to join and collect payouts.

  • Actual issue:

    deposit(assets, receiver) records the stake under stakedAsset[receiver] but mints vault shares to msg.sender. This creates a mismatch between who is recorded as staked and who holds the minted shares. As a result, the address that actually holds shares may not be able to join the event (because joinEvent() checks stakedAsset[msg.sender]), while the recorded staker has no shares — leading to zero snapshots, division-by-zero during withdrawal, wrong payouts, or denial/confusion of benefit.

function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 fee = _getParticipationFee(assets);
// charge on a percentage basis points
if (minimumAmount + fee > assets) {
revert lowFeeAndAmount();
}
uint256 stakeAsset = assets - fee;
@> stakedAsset[receiver] = stakeAsset; // stake recorded for `receiver` (not for caller)
uint256 participantShares = _convertToShares(stakeAsset);
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
@> _mint(msg.sender, participantShares); // shares minted to msg.sender (caller) instead of `receiver`
emit deposited (receiver, stakeAsset);
return participantShares;
}

Risk

Likelihood:

  • Misuse is trivial and reproducible in normal operation: any depositor can call deposit(amount, receiver) with a different receiver value; this pattern is part of the public interface, so the condition will occur during regular deposits initiated by any participant or front-end that uses a receiver parameter (for example: delegated deposits, meta-transactions, or mistaken UI fields).

  • Integrations and UX increase exposure: wallet integrations, custodial flows, meta-txs or automated scripts commonly use a receiver or beneficiary field — these flows make this mismatch likely in production deployments when caller ≠ receiver occurs.

Impact:

  • Logical denial / incorrect entitlement: the economic stake is recorded for one address while shares (ownership rights) are assigned to a different address. The recorded staker cannot claim their expected shares; the minted-share holder cannot participate because joinEvent() checks the staked mapping — causing denial of benefit or inconsistent ownership semantics.

  • Financial integrity / runtime failure: this mismatch can produce zero snapshots for winners (because the joiner had no shares), yielding totalWinnerShares == 0 and causing panics (division by zero) at withdrawal or causing incorrect payouts to all winners (payout dilution or incorrect distribution). This undermines payout correctness and can be used to confuse or grief honest participants.

Proof of Concept

Just paste in briVault.t.sol

function test_wrongUserInteract() public {
uint256 countryId = 4;
uint256 depositAmount = 1 ether;
// 1) user1 deposits but sets receiver = user2
vm.startPrank(user1);
mockToken.approve(address(briVault), type(uint256).max);
briVault.deposit(depositAmount, user2);
vm.stopPrank();
// Compute expected fee/stake
uint256 fee = (depositAmount * participationFeeBsp) / 10000; // participationFeeBsp is set in setUp()
uint256 stake = depositAmount - fee;
// 2) sanity checks
// stakedAsset: recorded for receiver (user2), not caller (user1)
assertEq(briVault.stakedAsset(user1), 0, "expected user1 stakedAsset == 0");
assertEq(briVault.stakedAsset(user2), stake, "expected user2 stakedAsset == stake");
// minted vault-shares (BTT) went to caller (user1)
assertEq(briVault.balanceOf(user1), stake, "expected user1 to hold minted shares");
assertEq(briVault.balanceOf(user2), 0, "expected user2 to hold no shares");
// log a compact state summary
console.log("STATES: stake(user2):", briVault.stakedAsset(user2), " shares(user1):", briVault.balanceOf(user1));
// 3) user2 joins: but balanceOf(user2) == 0 so snapshot will be zero
vm.prank(user2);
briVault.joinEvent(countryId);
// Confirm snapshot was zero (this is the root of the later panic)
uint256 snap2 = briVault.userSharesToCountry(user2, countryId);
assertEq(snap2, 0, "expected snapshot for user2 == 0 (no shares)");
// 4) user1 cannot join (noDeposit) — assert that revert is the custom error
bytes4 noDepositSelector = bytes4(keccak256("noDeposit()"));
vm.expectRevert(abi.encodeWithSelector(noDepositSelector));
vm.prank(user1);
briVault.joinEvent(countryId);
// 5) advance time and set winner
vm.warp(block.timestamp + 40 days);
vm.prank(owner);
briVault.setWinner(countryId);
// 6) withdraw would panic with division-by-zero because totalWinnerShares == 0
// Expect exact Panic(uint256) with code 0x12 (division or modulo by zero)
bytes memory panicPayload = abi.encodeWithSelector(bytes4(keccak256("Panic(uint256)")), uint256(0x12));
vm.expectRevert(panicPayload);
vm.prank(user2);
briVault.withdraw();
}

PoC steps:

  1. Call deposit(1 ETH, receiver = user2) from msg.sender = user1.

  2. Assert stakedAsset[user1] == 0 and stakedAsset[user2] == 1 ETH - fee.

  3. Assert briVault.balanceOf(user1) == shares and briVault.balanceOf(user2) == 0.

  4. Call joinEvent() as user2 and confirm userSharesToCountry[user2][cid] == 0 (snapshot = 0).

  5. Advance time, call setWinner and then attempt withdraw() as user2 — expect a panic (division by zero) or zero payout; user1 cannot join (revert noDeposit), so the intended participant is excluded.

Recommended Mitigation

Replace the incorrect mint target so that the beneficiary receives the minted vault shares.
Specifically: change _mint(msg.sender, participantShares)_mint(receiver, participantShares) so the recorded stakedAsset[receiver] and share ownership are consistent (prevents caller/receiver mismatch, zero-snapshot states and payout/division errors).

function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 fee = _getParticipationFee(assets);
// charge on a percentage basis points
if (minimumAmount + fee > assets) {
revert lowFeeAndAmount();
}
uint256 stakeAsset = assets - fee;
stakedAsset[receiver] = stakeAsset;
uint256 participantShares = _convertToShares(stakeAsset);
IERC20(asset()).safeTransferFrom(msg.sender, participationFeeAddress, fee);
IERC20(asset()).safeTransferFrom(msg.sender, address(this), stakeAsset);
- _mint(msg.sender, participantShares);
+ // Mint shares to receiver (beneficiary)
+ _mint(receiver, participantShares);
emit deposited (receiver, stakeAsset);
return participantShares;
}
Updates

Appeal created

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

Shares Minted to msg.sender Instead of Specified Receiver

Support

FAQs

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

Give us feedback!