Calculated from recorded token losses using historical USD prices at the incident time.
0x7acc896b8d82874c67127ff3359d7437a15fdb4229ed83da00da1f4d8370764e0xde59b88abefa5e6c8aa6d742eee0f887dab136acEthereum0x31d80ea33271891986d873b397d849a92ef49255EthereumOn Ethereum mainnet block 19277620, transaction 0x7acc896b8d82874c67127ff3359d7437a15fdb4229ed83da00da1f4d8370764e drained the GAIN/WETH Uniswap V2 pair with a permissionless single-transaction flash-loan exploit. The root cause is an internal accounting mismatch in GAIN: balanceOf reports balances for side-assigned accounts using side-specific denominators, while _transferFrom always debits and credits _gonBalances with the global _gonsPerFragment. Because the GAIN/WETH pair is assigned to SideA, tiny real transfers into the pair expand into enormous visible GAIN balances that Uniswap V2 trusts as real reserves during skim, sync, and swap.
GAIN is a rebasing token that stores balances internally in _gonBalances and maps those gons back to visible token balances. For ordinary accounts, visible balances are computed with _gonsPerFragment, but addresses assigned to SideA or SideB use TOTAL_GONS / _sideA or TOTAL_GONS / _sideB instead. The verified source shows the GAIN/WETH pair is part of this side-accounting regime, and the pre-exploit fork state reports while is far larger than .
getUniversalSalvation(pair) == "SideA"_sideAtotalSupplyUniswap V2 pairs do not track token balances internally beyond reserves. They rely on token.balanceOf(pair) for skim, sync, and for inferring amountIn during swaps. If a token overstates the pair’s visible balance without transferring the same real amount, the pair exposes public extraction primitives.
The vulnerability is a token-accounting bug in GAIN, not an oracle error or privileged-access issue. In the verified GAIN source, balanceOf(address who) returns _gonBalances[who].div(TOTAL_GONS.div(_sideA)) for SideA accounts and _gonBalances[who].div(TOTAL_GONS.div(_sideB)) for SideB accounts. In contrast, _transferFrom(address sender, address recipient, uint256 amount) always computes gonAmount = amount.mul(_gonsPerFragment) and books sender and recipient balances with that global conversion rate. Once a side denominator diverges from _totalSupply, the visible token delta implied by balanceOf no longer matches the amount booked by transfer accounting. The GAIN/WETH pair is a side-assigned account, so a small inbound transfer can create a massive visible surplus. Uniswap V2 then accepts that visible surplus as real inventory and lets any caller harvest it with public skim, sync, and swap calls.
The critical code path is in the verified GAIN contract. balanceOf contains the side-specific branches:
function balanceOf(address who) public view override returns (uint256) {
if (keccak256(abi.encodePacked(_children_of_gainos[who])) == keccak256(abi.encodePacked(sideA))) {
return _gonBalances[who].div(TOTAL_GONS.div(_sideA));
} else if (keccak256(abi.encodePacked(_children_of_gainos[who])) == keccak256(abi.encodePacked(sideB))) {
return _gonBalances[who].div(TOTAL_GONS.div(_sideB));
} else {
return _gonBalances[who].div(_gonsPerFragment);
}
}
The transfer path uses a different conversion rule:
function _transferFrom(address sender, address recipient, uint256 amount) internal returns (bool) {
uint256 gonAmount = amount.mul(_gonsPerFragment);
_gonBalances[sender] = _gonBalances[sender].sub(gonAmount);
uint256 gonAmountReceived = shouldTakeFee(sender) ? takeFee(sender, recipient, gonAmount) : gonAmount;
_gonBalances[recipient] = _gonBalances[recipient].add(gonAmountReceived);
emit Transfer(sender, recipient, gonAmountReceived.div(_gonsPerFragment));
return true;
}
This breaks the invariant that an externally visible token balance must move by the same amount that transfer accounting actually adds or removes. In the exploit trace, after the attacker buys 100000 GAIN, a transfer of 100 GAIN into the pair emits only 84 visible tokens after fee. Even so, the next GAIN::balanceOf(pair) call causes UniswapV2Pair::skim to transfer 72302203826587 GAIN back to the attacker contract. The same pattern repeats after a transfer of 188, which yields a second skim of 135928143193984 GAIN. After the attacker syncs the pair to these forged visible balances, a final transfer of 130000000000000 GAIN makes the pair believe it received 93992864974563097035314629 GAIN of input, and swap(6532946950955627430, 0, ...) releases 6.532946950955627430 WETH.
Nothing in this sequence requires privileged state changes, attacker-owned historical contracts, or non-public information. The exploit depends only on publicly callable pair functions and the token’s deterministic mismatch between side-based balance reporting and transfer booking.
The attacker EOA 0x0000000f95c09138dfea7d9bcf3478fc2e13dcab submits the exploit transaction and deploys helper contracts during execution. The trace shows the main helper contract borrowing 0.1 WETH from flash pool 0xc7bbec68d12a0d1830360f8ec58fa599ba1b0e9b, transferring that WETH into the GAIN/WETH pair, and calling swap(0, 100000, ...) to acquire a seed GAIN position.
The manipulation phase uses only public token and pair calls. First, the helper transfers 100 GAIN into the pair, then calls skim, which returns 72302203826587 GAIN because balanceOf(pair) overstates the pair’s visible inventory. After sync, the helper transfers 188 GAIN, calls skim again, and receives 135928143193984 GAIN. The pair’s visible reserves are now badly distorted while its real WETH balance remains intact.
The extraction phase then transfers 130000000000000 GAIN into the pair and calls swap(6532946950955627430, 0, ...), which sends 6.532946950955627430 WETH to the attacker helper. The helper repays 0.10001 WETH to the flash pool, unwraps the remaining WETH to ETH, and forwards the ETH proceeds back to the attacker-controlled payout chain.
The direct measurable loss is the WETH removed from the GAIN/WETH pair. The balance-diff artifact shows WETH contract balance dropping by 6432936951080891097 wei, which is 6.432936951080891097 WETH in smallest units. The attacker EOA’s native balance rises by 6375307861762256163 wei, reflecting 6.375307861762256163 ETH of net realized profit after flash-loan repayment and gas.
Affected public components are the GAIN token at 0xde59b88abefa5e6c8aa6d742eee0f887dab136ac and the GAIN/WETH Uniswap V2 pair at 0x31d80ea33271891986d873b397d849a92ef49255. The loss is a direct consequence of the token’s broken balance-reporting invariant.
0x7acc896b8d82874c67127ff3359d7437a15fdb4229ed83da00da1f4d8370764e0xde59b88abefa5e6c8aa6d742eee0f887dab136ac0x31d80ea33271891986d873b397d849a92ef492550xc7bbec68d12a0d1830360f8ec58fa599ba1b0e9btrace.cast.log
balance_diff.json
metadata.json
Contract.sol