We do not have a reliable USD price for the recorded assets yet.
0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b90xdca503449899d5649d32175a255a8835a03e4006BSCOn BSC block 30056630, transaction 0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9 used helper contract 0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd to drain Ocean Pool 0xdca503449899d5649d32175a255a8835a03e4006. The helper had already been deployed by EOA 0xa6566574edc60d7b2adbacedb71d5142cf2677fb in tx 0x731b87a416bbf645a453f0617d6e4ff2388c04d8bcf2864722b20ae7dd2b34bf, but the exploit itself is fully realized in the seed transaction.
The root cause is a cross-function accounting bug. Pool.emergencyWithdraw() returns the user's principal stake and zeroes rewardDebt, but it does not clear nftAmount, nftAddition, poolInfo.stakeSupply, or poolInfo.nftAddition. Pool.unstakeNft(uint256[]) then calls pendingFit(msg.sender) and transfers BNO rewards before it removes NFTs or recomputes reward weight. That lets an unprivileged user receive positive BNO after allstake is already zero, violating the invariant that a zero-stake user must not keep live reward weight.
Ocean Pool distributes BNO rewards through pendingFit(address), which computes pending rewards from . The NFT boost is tracked separately: recomputes as and mirrors that value into .
(user.allstake + user.nftAddition) * accPerShare / 1e12 - user.rewardDebtupdatePool()user.nftAdditionuser.allstake * user.nftAmount * poolInfo.nftWeights / 100poolInfo.nftAdditionThe pool is permissionless at the relevant touchpoints. stakeNft(uint[] memory tokenIds) and unstakeNft(uint[] memory tokenIds) are public, and the only gate they enforce is payment of the public withdrawalFee. stakeNft() also checks nftContract.isOfficialNFT(tokenId) and nftContract.ownerOf(tokenId) == msg.sender, so any unprivileged holder of official NFTs can enter the boosted reward path.
The critical state variables are:
userInfo[msg.sender].allstake: principal stake.userInfo[msg.sender].rewardDebt: already-accounted rewards.userInfo[msg.sender].nftAmount: staked NFT count.userInfo[msg.sender].nftAddition: reward boost derived from stake and NFT count.poolInfo.stakeSupply and poolInfo.nftAddition: aggregate accounting used in reward distribution.This is an ATTACK-class protocol bug in the pool's own accounting, not an off-chain or privileged-access issue. The broken invariant is straightforward: once a user has withdrawn all principal stake, that user must not retain positive reward weight or receive additional BNO rewards unless new stake is added.
The verified Pool source shows why the invariant fails. pendingFit() pays based on user.allstake + user.nftAddition. updatePool() only refreshes nftAddition for the current caller when that function is reached. emergencyWithdraw() transfers out allstake and zeroes rewardDebt, but it leaves the NFT-related reward state and the aggregate pool accounting untouched. unstakeNft() then computes pending = pendingFit(msg.sender) and transfers BNO before it reduces nftAmount and before updatePool() clears the stale boost. As a result, a zero-stake user can still monetize stale NFT reward weight.
The relevant victim-side code path is visible in the verified Pool contract:
function pendingFit(address _user) public view returns(uint256){
UserInfo storage user = userInfo[_user];
...
uint256 userreward =
(user.allstake.add(user.nftAddition)).mul(accPerShare).div(1e12).sub(user.rewardDebt);
return userreward;
}
function updatePool() public {
...
if(userInfo[msg.sender].nftAddition != 0){
poolInfo.nftAddition = poolInfo.nftAddition.sub(userInfo[msg.sender].nftAddition);
}
userInfo[msg.sender].nftAddition =
userInfo[msg.sender].allstake.mul(userInfo[msg.sender].nftAmount).mul(poolInfo.nftWeights).div(100);
poolInfo.nftAddition = poolInfo.nftAddition.add(userInfo[msg.sender].nftAddition);
...
}
function emergencyWithdraw() public {
pledgeAddress.safeTransfer(address(msg.sender), userInfo[msg.sender].allstake);
userInfo[msg.sender].allstake = 0;
userInfo[msg.sender].rewardDebt = 0;
}
function unstakeNft(uint[] memory tokenIds) public payable notPause{
uint256 pending = pendingFit(msg.sender);
if(pending > 0){
safeGoodTransfer(msg.sender,pending);
emit Withdraw(msg.sender, pending);
}
...
userInfo[msg.sender].nftAmount = userInfo[msg.sender].nftAmount.sub(1);
...
updatePool();
}
The exploit is permissionless from pre-state sigma_B at block 30056629, immediately before the seed transaction. At that point the helper contract already exists, the pool still holds BNO inventory, and the adversary controls official NFT IDs 13 and 14. The ACT predicate is: an unprivileged user can call emergencyWithdraw() so that userInfo.allstake == 0, then still receive positive BNO from unstakeNft() before any new stake is added because stale NFT weight remains live.
The exploit sequence is:
stakeNft([13, 14]) deposits official NFTs into the pool and increases nftAmount.pledge(amount) adds BNO stake and refreshes nftAddition through updatePool().emergencyWithdraw() returns the full principal and zeroes only allstake and rewardDebt.unstakeNft([13, 14]) immediately calls pendingFit(msg.sender) and transfers BNO while stale nftAddition and aggregate accounting still exist.unstakeNft() remove NFTs and call updatePool().The seed trace shows this exact sequence in the first loop:
Pool::stakeNft{value: 8000000000000000}([13, 14])
...
emit Pledge(user: 0xD138b9a58D3e5f4be1CD5eC90B66310e241C13CD, amount: 277856480175428872286290)
Pool::emergencyWithdraw()
...
Pool::unstakeNft{value: 8000000000000000}([13, 14])
...
BNO::transfer(0xD138b9..., 3743027038091722541135)
emit Withdraw(user: 0xD138b9..., amount: 3743027038091722541135)
The same trace pattern repeats 100 times in one transaction. The trace evidence also shows:
stakeNft/unstakeNft calls with NFT callbacks.pledge calls.emergencyWithdraw() calls.Withdraw events emitted during post-emergency NFT exits.This validates the precise code-level breakpoint stated in the analysis: emergencyWithdraw() unwinds principal without unwinding reward weight, and unstakeNft() realizes that stale weight before fixing state.
The exploit conditions are limited and public:
emergencyWithdraw() followed by unstakeNft().withdrawalFee.The violated security principles are equally concrete:
The adversary cluster contains:
0xa6566574edc60d7b2adbacedb71d5142cf2677fb, which deployed the helper contract and sent the exploit transaction.0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd, which executed the repeated pool interactions.The helper contract page on BscScan identifies the same EOA as creator and links the creation tx 0x731b87a416bbf645a453f0617d6e4ff2388c04d8bcf2864722b20ae7dd2b34bf. The exploit transaction metadata independently confirms that tx 0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9 was sent from that EOA to the helper contract.
The end-to-end exploit flow is:
13 and 14.stakeNft -> pledge -> emergencyWithdraw -> unstakeNft.nftAddition, removes principal without clearing that boost, and cashes out a fresh BNO reward during unstakeNft.Victim-side public components involved in the flow are:
0xdca503449899d5649d32175a255a8835a03e40060xa4dbc813f7e1bf5827859e278594b1e0ec1f710f0x8ee0c2709a34e9fda43f2bd5179fa4c112bed89aThe balance diff for the seed transaction shows direct monetary loss:
{
"holder": "0xdca503449899d5649d32175a255a8835a03e4006",
"before": "1563370168598147731548146",
"after": "782078794329514643016889",
"delta": "-781291374268633088531257"
}
The helper contract simultaneously ends with:
{
"holder": "0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cd",
"before": "0",
"after": "763070793094412702283980",
"delta": "763070793094412702283980"
}
Measured loss and side effects:
781291374268633088531257 BNO.18.1563370168598147731548146 BNO to 782078794329514643016889 BNO in one transaction.2400000000000000000 wei to the fee wallet 0x978489611b7508e8515ee039d3f62612e128f50f.0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9artifacts/collector/seed/56/0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9/trace.cast.logartifacts/collector/seed/56/0x33fed54de490797b99b2fc7a159e43af57e9e6bdefc2c2d052dc814cfe0096b9/balance_diff.jsonhttps://bscscan.com/address/0xdca503449899d5649d32175a255a8835a03e4006#codehttps://bscscan.com/address/0xd138b9a58d3e5f4be1cd5ec90b66310e241c13cdartifacts/collector/seed/56/0x8ee0c2709a34e9fda43f2bd5179fa4c112bed89a/src/Contract.solartifacts/collector/seed/56/0xa4dbc813f7e1bf5827859e278594b1e0ec1f710f/src/Contract.solartifacts/auditor/forge-test.log