This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a291265550x84524baa1951247b3a2617a843e6ece915bb9674EthereumIn Ethereum mainnet transaction 0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555 at block 18342121, an unprivileged adversary used a Balancer flash loan to borrow 50 WBTC, minted two fresh Wise position NFTs, deposited 1 satoshi of WBTC into each NFT, and then transferred 49.99999998 WBTC directly into Wise Lending at 0x84524baa1951247b3a2617a843e6ece915bb9674. Wise treated that unsolicited transfer as pool assets during _cleanUp, but it did not mint corresponding deposit shares. Each attacker-held WBTC share was therefore repriced to roughly one third of the entire pool, after which the attacker borrowed assets across other Wise pools and drained 49.98496326 WBTC from the WBTC pool through repeated exact-amount withdrawals that burned 0 shares.
The root cause is the interaction of two public, permissionless accounting paths. First, contracts/MainHelper.sol::_cleanUp socializes raw ERC20 balance increases into pseudoTotalPool and totalPool by calling _increaseTotalAndPseudoTotalPool, even when no deposit shares were minted. Second, contracts/MainHelper.sol::calculateLendingShares floors amount * totalDepositShares / pseudoTotalPool, and accepts that floored result. After the donation, the attacker can choose withdrawal amounts small enough that the share burn rounds to zero while the pool balance still decreases. The realized EOA cluster native ETH balance increased from ETH to ETH, a net delta of ETH, with ETH of sender gas already reflected in the sender's post-state delta.
contracts/WiseLending.sol::withdrawExactAmount72.121950080247058500241.414875378389068206169.2929252981420097060.110044056426845282Wise Lending tracks lender ownership through share accounting, not by recording per-user token balances. The important WBTC pool getters before the exploit were getTotalDepositShares(WBTC) == 1 and getPseudoTotalPool(WBTC) == 1, meaning the pool only held Wise's bootstrap dummy share and bootstrap pseudo balance before the adversary transaction.
The protocol also depends on permissionless Position NFTs. The verified PositionNFTs contract at 0x9d6d4e2afab382ae9b52807a4b36a8d2afc78b07 makes the next NFT id predictable and exposes public minting:
function getNextExpectedId()
public
view
returns (uint256)
{
return totalReserved + totalSupply();
}
function mintPositionForUser(
address _user
)
external
returns (uint256)
{
return _mintPositionForUser(
_user
);
}
Because these entrypoints are public, any EOA can create fresh collateral and withdrawal NFTs in the same transaction. Combined with public Balancer flash liquidity and public Uniswap V3 liquidity, the full exploit path is ACT-feasible for an unprivileged searcher.
This incident is an ATTACK category protocol exploit, not a pure MEV unwind. The violated invariant is: pool assets added to accounting must be matched by proportional share issuance, and every positive exact-amount withdrawal must burn a nonzero amount of lending shares. Wise breaks the first half of that invariant in _cleanUp, which reads the raw ERC20 balance of the lending contract and socializes any excess into pool accounting without minting new shares. Wise breaks the second half in the exact-amount withdrawal path, because calculateLendingShares floors the share burn and withdrawExactAmount accepts the result. When the pool has only three deposit shares after two attacker dust deposits, a direct WBTC donation reprices each attacker share to roughly 16.66 WBTC. From there, the attacker can borrow against the inflated share value and repeatedly withdraw amounts whose computed share burn is 0, leaving the withdrawing NFT's one WBTC share intact while totalPool and pseudoTotalPool continue to fall. The exploit therefore combines a donation-accounting bug with a zero-share-withdraw rounding bug.
The relevant victim-side code shows the two breakpoints directly:
function calculateLendingShares(
address _poolToken,
uint256 _amount
)
public
view
returns (uint256)
{
uint256 shares = getTotalDepositShares(_poolToken);
if (shares <= 1) {
return _amount;
}
uint256 pseudo = getPseudoTotalPool(_poolToken);
if (pseudo == 0) {
return _amount;
}
return _amount * shares / pseudo;
}
function _cleanUp(
address _poolToken
)
internal
{
uint256 amountContract = IERC20(_poolToken).balanceOf(address(this));
uint256 totalPool = getTotalPool(_poolToken);
uint256 bareToken = getTotalBareToken(_poolToken);
if (_checkCleanUp(amountContract, totalPool, bareToken)) {
return;
}
uint256 diff = amountContract - (totalPool + bareToken);
_increaseTotalAndPseudoTotalPool(_poolToken, diff);
}
function withdrawExactAmount(
uint256 _nftId,
address _poolToken,
uint256 _withdrawAmount
)
external
syncPool(_poolToken)
returns (uint256)
{
uint256 withdrawShares = _preparationsWithdraw(
_nftId,
msg.sender,
_poolToken,
_withdrawAmount
);
_coreWithdrawToken(
msg.sender,
_nftId,
_poolToken,
_withdrawAmount,
withdrawShares
);
}
function _coreWithdrawBare(
uint256 _nftId,
address _poolToken,
uint256 _amount,
uint256 _shares
)
internal
{
_updatePoolStorage(
_poolToken,
_amount,
_shares,
_decreaseTotalPool,
_decreasePseudoTotalPool,
_decreaseTotalDepositShares
);
_decreaseLendingShares(_nftId, _poolToken, _shares);
}
The transaction starts from a publicly reconstructible pre-state in which the WBTC pool is effectively empty from an accounting perspective. Position NFT id 31 is the next available id, the WBTC pool has only Wise's bootstrap share, and the Balancer Vault at 0xBA12222222228d8Ba445958a75a0704d566BF2C8 holds enough WBTC to fund the flash-loan leg.
The attacker first created two fresh WBTC positions. The seed trace shows PositionNFTs::mintPositionForUser(0x3AA228...) returning NFT 31 and PositionNFTs::mintPositionForUser(0x0D1693...) returning NFT 32. Each NFT then received a 1 satoshi WBTC deposit. At that point the pool had exactly three deposit shares: the Wise bootstrap share plus one share in each attacker-controlled NFT.
The critical accounting corruption occurs when the attacker transfers WBTC directly to the Wise contract instead of calling depositExactAmount. The trace shows the raw token transfer and then a borrow against the now-inflated collateral:
PositionNFTs::mintPositionForUser(0x3AA228...) -> tokenId 31
PositionNFTs::mintPositionForUser(0x0D1693...) -> tokenId 32
WiseLending::depositExactAmount(32, WBTC, 1)
WiseLending::depositExactAmount(31, WBTC, 1)
WBTC::transfer(WiseLending, 4999999998)
WiseLending::borrowExactAmount(32, WETH, 339996372423526589)
On the next synced Wise action, _cleanUp compares the raw WBTC balance held by the contract against totalPool + bareToken, computes diff = amountContract - (totalPool + bareToken), and calls _increaseTotalAndPseudoTotalPool(diff). That function increases pseudoTotalPool and totalPool, but it never increases totalDepositShares or any position's lending-share balance. Because there are only three total shares after the two dust deposits, each attacker share is suddenly worth roughly one third of the entire WBTC pool. The root cause report's breakpoint statement is therefore accurate: share value was inflated by an unsolicited transfer that became protocol-accounted collateral without corresponding share issuance.
The second bug is triggered during exact-amount withdrawal. _preparationsWithdraw returns calculateLendingShares(_poolToken, _amount), and calculateLendingShares floors the division. After the donation, the attacker can repeatedly choose amount < pseudoTotalPool / totalDepositShares, so the computed burn is 0 even though the withdrawal amount is positive. _coreWithdrawBare still decreases totalPool and pseudoTotalPool, but _decreaseTotalDepositShares and _decreaseLendingShares receive _shares = 0, so the withdrawing NFT keeps its single WBTC share.
The trace shows this zero-share pattern directly. The first withdrawal moves 1.666666656 WBTC and emits a FundsWithdrawn event with shares: 0; subsequent withdrawals continue with the same pattern until the helper has extracted 49.98496326 WBTC in aggregate:
WiseLending::withdrawExactAmount(31, WBTC, 1666666656)
emit FundsWithdrawn(... amount: 1666666656, shares: 0, ...)
WiseLending::withdrawExactAmount(31, WBTC, 1111111104)
emit FundsWithdrawn(... amount: 1111111104, shares: 0, ...)
...
WBTC::transfer(0x0D1693..., 4998496326)
That zero-share drain is what turns the share-inflation bug into a full exploit. One NFT keeps its inflated WBTC share value for borrowing other pool assets, while the other NFT drains the donated WBTC back out to help close the flash loan. The combination causes permanent protocol loss across multiple non-WBTC Wise pools and leaves the adversary with realized profit.
The adversary used a single adversary-crafted Ethereum transaction, 0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555, with the following account roles:
0xc0ffeebabe5d496b2dde509f9fa189c25cf29671: sender EOA and net-positive native balance recipient.0x3aa228a80f50763045bdfc45012da124bd0a6809: helper contract that owned NFT 31 and executed the repeated WBTC withdrawals.0x0d1693819f3ec064a1c6150e11f1b6fa33537ef4: flash-loan receiver that owned NFT 32, donated WBTC, borrowed the cross-pool assets, and swapped WETH back into WBTC.0x25b71878850d008ec4237c55f0a59198bcc72b43: payout EOA that received 169.267446979085204912 ETH in the native balance diff.The execution flow is:
50 WBTC to the attacker receiver contract.31 and 32 through the public mintPositionForUser path.1 satoshi WBTC deposit, creating two attacker-controlled WBTC shares on top of Wise's bootstrap share.49.99999998 WBTC directly into Wise, ensuring _cleanUp will reprice the pool without issuing new shares.32 borrows assets across multiple Wise pools, including wstETH, WETH, aWETH, DAI, sDAI, aDAI, aUSDC, aUSDT, and USDC.31 executes twenty WBTC exact-amount withdrawals that each burn 0 shares, draining 49.98496326 WBTC from the pool.This flow is fully ACT-feasible. Every meaningful entrypoint in the sequence is public: Balancer flash loans, PositionNFT minting, Wise deposit/borrow/withdraw methods, and Uniswap V3 swaps.
Wise Lending suffered permanent loss across multiple pools after the inflated WBTC share was used as temporary collateral amplification. The measured asset losses from the incident are:
wstETH: 33538664799002267467 units (18 decimals)WETH: 339996372423526589 units (18 decimals)aWETH: 98969695913405122899 units (18 decimals)DAI: 200094287736946980059 units (18 decimals)sDAI: 16161480100000000000000 units (18 decimals)aDAI: 1302840070263627457089 units (18 decimals)aUSDC: 5108839054 units (6 decimals)aUSDT: 26082605241 units (6 decimals)USDC: 50000000 units (6 decimals)The realized adversary EOA cluster native ETH balance rose from 72.121950080247058500 ETH before the transaction to 241.414875378389068206 ETH after it, for a delta of 169.292925298142009706 ETH. The sender's gas cost was 0.110044056426845282 ETH, and the sender's balance-diff entry is already net of that fee.
0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/metadata.json/workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/trace.cast.log/workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/balance_diff.jsonhttps://etherscan.io/address/0x84524baa1951247b3a2617a843e6ece915bb9674#codehttps://etherscan.io/address/0x5f8b6c17c3a6ef18b5711f9b562940990658400d#code/workspace/session/artifacts/collector/seed/1/0x9d6d4e2afab382ae9b52807a4b36a8d2afc78b07/src/PositionNFTs.sol0x84524baa1951247b3a2617a843e6ece915bb96740x5f8b6c17c3a6ef18b5711f9b562940990658400d0xd2caa748b66768ac9c53a5443225bdf1365dd4b60x9d6d4e2afab382ae9b52807a4b36a8d2afc78b07