The POC demonstrates how cancelParticipation() burns all accumulated shares but only refunds the last deposit amount due to deposit() overwriting stakedAsset instead of accumulating. It shows a user making multiple deposits, accumulating shares, then calling cancelParticipation() which burns all shares but refunds only the final deposit amount. The test verifies that all prior deposits are permanently lost, creating a critical accounting bug in the cancellation mechanism.
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: cancelParticipation() Refunds Only Last Deposit, Burns All Shares
* Discovery Method: Accounting Logic Analysis
* Severity: HIGH
* Impact: permanent user fund loss
*
* Root Cause: stakedAsset mapping overwritten on deposit, cancelParticipation burns all shares
* but refunds only last deposit amount, causing users to lose all prior deposits 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 CancelParticipationAccountingBugPoC is Test {
BriVault vault;
MockERC20 asset;
address owner = makeAddr("owner");
address victim = makeAddr("victim");
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(victim, 1000 ether);
vm.startPrank(victim);
asset.approve(address(vault), type(uint256).max);
vm.stopPrank();
}
* CRITICAL VULNERABILITY: cancelParticipation() accounting bug causes permanent fund loss
*
* Attack Flow:
* 1. User makes multiple deposits legitimately (each overwrites stakedAsset)
* 2. User calls cancelParticipation() before event starts
* 3. Function burns all accumulated shares but refunds only last deposit amount
* 4. User permanently loses all prior deposits
*/
function test_CancelParticipationAccountingBug() public {
console.log("=== CANCEL PARTICIPATION ACCOUNTING BUG ===");
vm.startPrank(victim);
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim);
console.log("First deposit: 200 ether, stakedAsset =", vault.stakedAsset(victim));
vault.deposit(200 ether, victim);
console.log("Second deposit: 200 ether, stakedAsset =", vault.stakedAsset(victim));
console.log("Notice: stakedAsset overwritten, lost track of first 198 ether!");
vault.deposit(200 ether, victim);
console.log("Third deposit: 200 ether, stakedAsset =", vault.stakedAsset(victim));
console.log("Total deposited: 600 ether, total fees: 6 ether");
console.log("Expected refund on cancel: 594 ether");
console.log("But stakedAsset only tracks: 198 ether (last deposit)");
vm.stopPrank();
vm.startPrank(victim);
uint256 balanceBeforeCancel = asset.balanceOf(victim);
console.log("Balance before cancelParticipation():", balanceBeforeCancel);
vault.cancelParticipation();
uint256 balanceAfterCancel = asset.balanceOf(victim);
uint256 actualRefund = balanceAfterCancel - balanceBeforeCancel;
vm.stopPrank();
console.log("Balance after cancelParticipation():", balanceAfterCancel);
console.log("Actual refund received:", actualRefund);
console.log("Expected refund:", 594 ether);
console.log("Missing funds:", 594 ether - actualRefund);
assertEq(vault.balanceOf(victim), 0, "All shares burned");
assertEq(vault.stakedAsset(victim), 0, "Staked asset cleared");
assertLt(actualRefund, 594 ether, "Victim received less than expected");
assertEq(actualRefund, 198 ether, "Victim only got last deposit refunded");
console.log("=== ACCOUNTING BUG CONFIRMED ===");
console.log("Victim deposited 600 ether total");
console.log("Victim expected 594 ether refund (minus 6 ether fees)");
console.log("Victim actually received 198 ether refund");
console.log("Victim permanently lost 396 ether!");
}
* Demonstrate the root cause: deposit() overwrites stakedAsset
*/
function test_RootCause_StakedAssetOverwrite() public {
vm.startPrank(victim);
console.log("=== ROOT CAUSE ANALYSIS ===");
vm.warp(EVENT_START - 1 hours);
vault.deposit(200 ether, victim);
uint256 expectedTotalStaked = 198 ether;
uint256 actualTotalStaked = vault.stakedAsset(victim);
console.log("After first deposit:");
console.log("- Expected total staked:", expectedTotalStaked);
console.log("- Actual stakedAsset value:", actualTotalStaked);
vault.deposit(200 ether, victim);
expectedTotalStaked += 198 ether;
actualTotalStaked = vault.stakedAsset(victim);
console.log("After second deposit:");
console.log("- Expected total staked:", expectedTotalStaked);
console.log("- Actual stakedAsset value:", actualTotalStaked);
console.log("- PROBLEM: stakedAsset overwritten instead of accumulated!");
vault.deposit(200 ether, victim);
expectedTotalStaked += 198 ether;
actualTotalStaked = vault.stakedAsset(victim);
console.log("After third deposit:");
console.log("- Expected total staked:", expectedTotalStaked);
console.log("- Actual stakedAsset value:", actualTotalStaked);
console.log("- DISASTER: Lost track of 396 ether from prior deposits!");
vm.stopPrank();
assertEq(expectedTotalStaked, 594 ether, "Expected total should be 594 ether");
assertEq(actualTotalStaked, 198 ether, "But stakedAsset only tracks last deposit");
assertLt(actualTotalStaked, expectedTotalStaked, "State corrupted - lost track of user funds");
}
}