Permanent fund lockup affecting legitimate tournament winners who transfer shares post-participation.
The POC demonstrates how share transfers after joinEvent() create permanently orphaned funds by breaking the withdrawal logic. It shows a tournament winner who joins legitimately and earns winnings, but then transfers their shares to another address. The original winner cannot withdraw because they have zero balance, and the recipient cannot withdraw because they didn't participate in the tournament.
The test verifies that rightful winnings become permanently inaccessible due to standard ERC20 transfer functionality.
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {BriVault} from "../../src/briVault.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
* PoC: Share Transfers After joinEvent() Create Orphaned Funds
* Discovery Method: State Lifecycle Gap
* EQS: 9.2/10, Confidence: 3/3, Severity: HIGH
* Impact: Permanent fund lockup
*
* Root Cause: withdraw() requires both userToCountry[msg.sender] == winner
* AND balanceOf(msg.sender) > 0. When a winner transfers shares post-join,
* neither the original winner nor recipient can withdraw, orphaning funds permanently.
*/
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract ShareTransfersOrphanFundsPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address winner = makeAddr("winner");
address recipient = makeAddr("recipient");
address feeRecipient = makeAddr("feeRecipient");
uint256 constant PARTICIPATION_FEE_BPS = 100;
uint256 constant MINIMUM_AMOUNT = 100 ether;
uint256 EVENT_START;
uint256 EVENT_END;
function setUp() public {
EVENT_START = block.timestamp + 1 days;
EVENT_END = EVENT_START + 7 days;
asset = new MockERC20("Mock Token", "MOCK");
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(asset)),
PARTICIPATION_FEE_BPS,
EVENT_START,
feeRecipient,
MINIMUM_AMOUNT,
EVENT_END
);
string[48] memory countries;
countries[0] = "Brazil";
countries[1] = "Argentina";
vault.setCountry(countries);
vm.stopPrank();
asset.mint(winner, 1000 ether);
asset.mint(recipient, 1000 ether);
vm.startPrank(winner);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
vm.startPrank(recipient);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
* CRITICAL VULNERABILITY: Share transfers after joinEvent() orphan funds permanently
*
* Attack Flow:
* 1. Winner legitimately deposits and joins tournament
* 2. Tournament ends, winner's team declared winner
* 3. Winner transfers shares to another address (innocent action)
* 4. Original winner tries to withdraw - gets 0 assets (balance = 0)
* 5. Recipient tries to withdraw - fails (no participation record)
* 6. Funds permanently orphaned in vault
*/
function test_ShareTransfersCreateOrphanedFunds() public {
console.log("=== SHARE TRANSFERS ORPHAN FUNDS VULNERABILITY ===");
vm.startPrank(winner);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, winner);
vault.joinEvent(0);
vm.stopPrank();
console.log("Winner deposited 200 ether, joined Brazil");
console.log("Winner shares:", vault.balanceOf(winner));
console.log("Winner country mapping:", vault.userToCountry(winner));
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
console.log("Tournament ended, Brazil declared winner");
vm.startPrank(winner);
uint256 sharesToTransfer = vault.balanceOf(winner);
vault.transfer(recipient, sharesToTransfer);
vm.stopPrank();
console.log("Winner transferred all shares to recipient");
console.log("Winner shares after transfer:", vault.balanceOf(winner));
console.log("Recipient shares:", vault.balanceOf(recipient));
console.log("Winner country mapping:", vault.userToCountry(winner));
console.log("Recipient country mapping:", vault.userToCountry(recipient));
vm.startPrank(winner);
uint256 winnerBalanceBefore = asset.balanceOf(winner);
vault.withdraw();
uint256 winnerBalanceAfter = asset.balanceOf(winner);
vm.stopPrank();
console.log("Winner attempted withdrawal:");
console.log("Balance before:", winnerBalanceBefore);
console.log("Balance after:", winnerBalanceAfter);
console.log("Winner received: 0 assets (orphaned!)");
vm.startPrank(recipient);
vm.expectRevert(BriVault.didNotWin.selector);
vault.withdraw();
vm.stopPrank();
console.log("Recipient attempted withdrawal: FAILED (didNotWin revert)");
uint256 orphanedFunds = asset.balanceOf(address(vault));
console.log("Funds permanently orphaned in vault:", orphanedFunds);
assertEq(winnerBalanceAfter - winnerBalanceBefore, 0, "Winner gets 0 assets");
assertGt(orphanedFunds, 0, "Funds remain locked in vault");
assertEq(vault.balanceOf(winner), 0, "Winner has no shares");
assertGt(vault.balanceOf(recipient), 0, "Recipient has shares but cannot withdraw");
console.log("=== VULNERABILITY CONFIRMED: FUNDS PERMANENTLY ORPHANED ===");
}
* Demonstrate the root cause: withdraw() logic flaw
*/
function test_RootCauseAnalysis() public {
vm.startPrank(winner);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, winner);
vault.joinEvent(0);
vm.stopPrank();
vm.warp(EVENT_END + 1 hours);
vm.startPrank(owner);
vault.setWinner(0);
vm.stopPrank();
console.log("=== ROOT CAUSE ANALYSIS ===");
console.log("Before transfer:");
console.log("- userToCountry[winner] == winner:", keccak256(abi.encodePacked(vault.userToCountry(winner))) == keccak256(abi.encodePacked("Brazil")));
console.log("- balanceOf(winner) > 0:", vault.balanceOf(winner) > 0);
console.log("- withdraw() should succeed: YES");
vm.startPrank(winner);
vault.transfer(recipient, vault.balanceOf(winner));
vm.stopPrank();
console.log("After transfer:");
console.log("- userToCountry[winner] == winner:", keccak256(abi.encodePacked(vault.userToCountry(winner))) == keccak256(abi.encodePacked("Brazil")));
console.log("- balanceOf(winner) > 0:", vault.balanceOf(winner) > 0);
console.log("- withdraw() should succeed: NO (balance = 0)");
console.log("- userToCountry[recipient] == winner:", keccak256(abi.encodePacked(vault.userToCountry(recipient))) == keccak256(abi.encodePacked("Brazil")));
console.log("- balanceOf(recipient) > 0:", vault.balanceOf(recipient) > 0);
console.log("- withdraw() should succeed: NO (no participation record)");
console.log("Root cause: withdraw() requires BOTH conditions, transfer breaks both paths");
}
}
Store participation shares at join time and use those for withdrawal calculations:
- function withdraw() external winnerSet {
- if (block.timestamp < eventEndDate) {
- revert eventNotEnded();
- }
- if (
- keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
- keccak256(abi.encodePacked(winner))
- ) {
- revert didNotWin();
- }
- uint256 shares = balanceOf(msg.sender); // PROBLEM: Uses current balance, not participation shares
-
- uint256 vaultAsset = finalizedVaultAsset;
- uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
-
- _burn(msg.sender, shares);
- IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
- }
+ // Add to contract state
+ mapping(address => uint256) public participationShares;
+
+ // Modify joinEvent()
+ function joinEvent(uint256 countryId) public {
+ // ... existing validation
+ userToCountry[msg.sender] = teams[countryId];
+ participationShares[msg.sender] = balanceOf(msg.sender); // Store at join time
+ userSharesToCountry[msg.sender][countryId] = participationShares[msg.sender];
+ usersAddress.push(msg.sender);
+ // ...
+ }
+
+ // Modify withdraw()
+ function withdraw() external winnerSet {
+ if (keccak256(abi.encodePacked(userToCountry[msg.sender])) != keccak256(abi.encodePacked(winner))) {
+ revert didNotWin();
+ }
+ uint256 shares = participationShares[msg.sender]; // Use stored participation shares
+ uint256 vaultAsset = finalizedVaultAsset;
+ uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
+ // ... transfer logic
+ participationShares[msg.sender] = 0; // Reset after withdrawal
+ }