Wise Lending WBTC Donation Drain
Exploit Transactions
0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555Victim Addresses
0x84524baa1951247b3a2617a843e6ece915bb9674EthereumLoss Breakdown
Similar Incidents
Metalend Empty-Market Donation Exploit
39%bZx iYFI Donation Inflation
39%WBTC Drain via Insecure Router transferFrom Path
37%Bao Donation Borrow Exploit
37%Hegic WBTC Pool Repeated Tranche Withdrawal Exploit
36%Onyx oPEPE Donation Overvaluation
35%Root Cause Analysis
Wise Lending WBTC Donation Drain
1. Incident Overview TL;DR
In 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 contracts/WiseLending.sol::withdrawExactAmount 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 72.121950080247058500 ETH to 241.414875378389068206 ETH, a net delta of 169.292925298142009706 ETH, with 0.110044056426845282 ETH of sender gas already reflected in the sender's post-state delta.
2. Key Background
Wise 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.
3. Vulnerability Analysis & Root Cause Summary
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);
}
4. Detailed Root Cause Analysis
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.
5. Adversary Flow Analysis
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 NFT31and executed the repeated WBTC withdrawals.0x0d1693819f3ec064a1c6150e11f1b6fa33537ef4: flash-loan receiver that owned NFT32, donated WBTC, borrowed the cross-pool assets, and swapped WETH back into WBTC.0x25b71878850d008ec4237c55f0a59198bcc72b43: payout EOA that received169.267446979085204912ETH in the native balance diff.
The execution flow is:
- Balancer flash-loans
50WBTC to the attacker receiver contract. - The attacker mints NFTs
31and32through the publicmintPositionForUserpath. - Each NFT receives a
1satoshi WBTC deposit, creating two attacker-controlled WBTC shares on top of Wise's bootstrap share. - The attacker transfers
49.99999998WBTC directly into Wise, ensuring_cleanUpwill reprice the pool without issuing new shares. - NFT
32borrows assets across multiple Wise pools, includingwstETH,WETH,aWETH,DAI,sDAI,aDAI,aUSDC,aUSDT, andUSDC. - NFT
31executes twenty WBTC exact-amount withdrawals that each burn0shares, draining49.98496326WBTC from the pool. - The attacker swaps enough borrowed WETH back into WBTC to close the flash-loan leg, repays Balancer, and routes the remaining proceeds into native ETH profit.
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.
6. Impact & Losses
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:33538664799002267467units (18decimals)WETH:339996372423526589units (18decimals)aWETH:98969695913405122899units (18decimals)DAI:200094287736946980059units (18decimals)sDAI:16161480100000000000000units (18decimals)aDAI:1302840070263627457089units (18decimals)aUSDC:5108839054units (6decimals)aUSDT:26082605241units (6decimals)USDC:50000000units (6decimals)
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.
7. References
- Seed transaction:
0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555 - Seed tx metadata:
/workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/metadata.json - Seed tx trace:
/workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/trace.cast.log - Seed tx balance diff:
/workspace/session/artifacts/collector/seed/1/0x7ac4a98599596adbf12fffa2bd23e2a2d2ac7e8989b6ea043fcc412a29126555/balance_diff.json - Wise Lending verified source:
https://etherscan.io/address/0x84524baa1951247b3a2617a843e6ece915bb9674#code - Wise Security verified source:
https://etherscan.io/address/0x5f8b6c17c3a6ef18b5711f9b562940990658400d#code - PositionNFTs source artifact:
/workspace/session/artifacts/collector/seed/1/0x9d6d4e2afab382ae9b52807a4b36a8d2afc78b07/src/PositionNFTs.sol - Victim contract addresses:
- WiseLending:
0x84524baa1951247b3a2617a843e6ece915bb9674 - WiseSecurity:
0x5f8b6c17c3a6ef18b5711f9b562940990658400d - WiseOracleHub:
0xd2caa748b66768ac9c53a5443225bdf1365dd4b6 - PositionNFTs:
0x9d6d4e2afab382ae9b52807a4b36a8d2afc78b07
- WiseLending: