Root + Impact
Description
The vault allows deposits up until owner finalization and computes winners’ payouts from live totals (or from totals taken at finalization without a protected deposit deadline or snapshot). This permits an attacker to perform very large last‑minute deposits (including via flash loans) to manipulate the per‑team share ratios used for payout calculation, causing honest winners to be diluted or enabling the attacker to extract value.
pragma solidity ^0.8.0;
interface IERC20 { function transferFrom(address, address, uint256) external returns (bool); }
contract SnapshotlessVaultDescription {
IERC20 public asset;
mapping(uint256 => uint256) public teamAssets;
mapping(address => uint256) public userTeam;
mapping(address => uint256) public userShares;
bool public winnerSet;
uint256 public winnerTeam;
function deposit(uint256 amount, uint256 teamId) external {
@> asset.transferFrom(msg.sender, address(this), amount);
@> teamAssets[teamId] += amount;
@> userShares[msg.sender] += amount;
@> userTeam[msg.sender] = teamId;
}
function setWinner(uint256 teamId) external {
@> winnerTeam = teamId;
@> winnerSet = true;
}
function payoutShare(address user) public view returns (uint256) {
uint256 total = teamAssets[0] + teamAssets[1];
return userShares[user] * teamAssets[winnerTeam] / total;
}
}
Risk
Likelihood:
-
The issue occurs whenever deposits remain allowed up to the moment the owner finalizes the winner, because an attacker can deposit immediately before finalization.
-
It occurs whenever the contract computes payouts from totals that are not snapshotted before a deposit cutoff, allowing recent deposits to change payout denominators.
Impact:
-
Impact 1: Honest winners receive a smaller share of the prize pool because the attacker’s last‑minute deposits change the pool ratios — resulting in loss of expected winnings.
-
Impact 2: Attackers can profit with minimal capital (flash‑loan) or cause significant griefing to participants, undermining fairness and user trust.
Proof of Concept
Explanation:
Deposits are accepted with immediate effect right up to finalization, and the payout formula uses team/total amounts that are influenced by those deposits. Without a deposit cutoff or snapshot, an attacker can change teamAssets or total just before setWinner or withdrawal, manipulating payoutShare().
pragma solidity ^0.8.0;
contract SimpleToken {
mapping(address => uint256) public balanceOf;
function mint(address to, uint256 amt) external { balanceOf[to] += amt; }
function transferFrom(address from, address to, uint256 amt) external returns (bool) {
require(balanceOf[from] >= amt);
balanceOf[from] -= amt;
balanceOf[to] += amt;
return true;
}
function transfer(address to, uint256 amt) external returns (bool) {
require(balanceOf[msg.sender] >= amt);
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
}
contract SnapshotlessVault {
SimpleToken public token;
mapping(uint256 => uint256) public teamAssets;
mapping(address => uint256) public shares;
mapping(address => uint256) public userTeam;
uint256 public winnerTeam;
bool public winnerSet;
constructor(SimpleToken _t) { token = _t; }
function deposit(uint256 amt, uint256 teamId) external {
require(token.transferFrom(msg.sender, address(this), amt));
shares[msg.sender] += amt;
userTeam[msg.sender] = teamId;
teamAssets[teamId] += amt;
}
function setWinner(uint256 teamId) external {
winnerTeam = teamId;
winnerSet = true;
}
function withdrawWinnings() external {
require(winnerSet && userTeam[msg.sender] == winnerTeam, "no winnings");
uint256 total = teamAssets[0] + teamAssets[1];
uint256 payout = shares[msg.sender] * teamAssets[winnerTeam] / total;
require(token.transfer(msg.sender, payout));
}
}
contract Attacker {
SnapshotlessVault public vault;
SimpleToken public token;
constructor(SnapshotlessVault _v, SimpleToken _t) { vault = _v; token = _t; }
function flashDepositAndExploit(uint256 amt, uint256 teamId) external {
token.mint(address(this), amt);
token.transferFrom(address(this), address(vault), amt);
vault.deposit(amt, teamId);
}
}
Recommended Mitigation
Set a deposit deadline so no new deposits can be made immediately before finalization. Take an immutable snapshot of team and total assets at the time the winner is finalized, and use these snapshots for payout calculations. This prevents last-minute or flash-loan deposits from manipulating payout ratios and ensures fair distribution.
- // deposits allowed until finalization; payouts use live totals
- function deposit(uint256 amount, uint256 teamId) external { ... }
- function setWinner(uint256 teamId) external { winnerTeam = teamId; winnerSet = true; }
- function payoutShare(address user) public view returns (uint256) { /* uses live totals */ }
+ uint256 public depositDeadline;
+ bool public snapshotTaken;
+ mapping(uint256 => uint256) public teamAssetsSnapshot;
+ uint256 public totalAssetsSnapshot;
+
+ modifier beforeDepositDeadline() { require(block.timestamp < depositDeadline, "deposits closed"); _; }
+ function deposit(uint256 amount, uint256 teamId) external beforeDepositDeadline { ... }
+ function setWinner(uint256 teamId) external { require(!snapshotTaken); teamAssetsSnapshot[...] = ...; totalAssetsSnapshot = ...; snapshotTaken = true; winnerTeam = teamId; }
+ function payoutShare(address user) public view returns (uint256) { require(snapshotTaken); return userShares[user] * teamAssetsSnapshot[winnerTeam] / totalAssetsSnapshot; }