Spot-price share accounting and duplicate tranche IDs in the stNXM vault
stNXM is an ERC-4626 vault over wNXM that provides liquidity for its own share token on Uniswap V3. It valued that position at the pool's spot price, on both sides of the exchange rate, so a swap in either direction raised the share price inside one transaction. A second bug counted repeated tranche IDs twice in the staked balance.
I reported both of these during the Sherlock audit of stNXM by EaseDeFi, 17 to 21 November 2025. The vault is EaseDeFi/stNXM-Contracts, and the code below is from f901af0, the last change to stNXM.sol before the audit started. The fixes are in 41e13ca and 40630a0.
The vault
stNXM takes wNXM deposits and issues shares. The wNXM is spread over four places, and totalAssets() adds them up: NXM staked in Nexus Mutual staking pools, wNXM and NXM held by the contract, wNXM lent on Morpho, and a Uniswap V3 position in the stNXM/wNXM pool.
The position is the unusual part. The vault supplies both sides of it, and the stNXM side is minted by the vault itself. _mintNewPosition mints amountToAdd stNXM to the vault and deposits it next to the same amount of wNXM, across the full tick range. Those shares aren’t backed by a deposit, so totalSupply() subtracts whatever stNXM the position holds:
function totalAssets() public view override returns (uint256) {
// Add staked NXM, wNXM in the contract, wNXM in the dex and Morpho, subtract the admin fees.
return stakedNxm() + unstakedNxm() - adminFees;
}
function totalSupply() public view override(ERC20Upgradeable, IERC20) returns (uint256) {
// Do not include the "virtual" assets in the Uniswap pool in total supply calculations.
(, uint256 virtualShares) = dexBalances();
return super.totalSupply() - virtualShares;
}
function dexBalances() public view returns (uint256 assetsAmount, uint256 sharesAmount) {
(uint160 sqrtRatio,,,,,,) = dex.slot0();
for (uint256 i = 0; i < dexTokenIds.length; i++) {
(uint256 posAmount0, uint256 posAmount1) = PositionValue.total(nfp, dexTokenIds[i], sqrtRatio);
// stNXM goes to sharesAmount, wNXM to assetsAmount
}
}unstakedNxm() adds assetsAmount, the wNXM in the position, to the assets. totalSupply() removes sharesAmount, the stNXM in the position, from the supply. Both come from slot0(), the pool’s current price, which any swap moves.
Both sides of the exchange rate move
When the pool price moves, a Uniswap V3 position changes composition. Push the price of stNXM up and the position sells stNXM for wNXM; push it down and it buys stNXM with wNXM. In an ordinary vault that would move one term of the share price. Here it moves both: wNXM leaving the position comes out of totalAssets(), and stNXM entering it comes out of totalSupply().
Write the share price as f = (A + w) / (S - s), where w and s are the position’s wNXM and stNXM at pool price p, A is every other asset and S is super.totalSupply(). Along the curve, a marginal trade happens at the pool price, so dw = -p ds. Differentiating gives:
df/dp = (ds/dp) * (f - p) / (S - s)ds/dp is always negative, because the position holds less stNXM as stNXM gets more expensive. So f falls while the pool price is below the share price, rises once it’s above, and has its minimum exactly where the two are equal. Arbitrage keeps the pool near that point, since a share can be redeemed for f wNXM. So the vault normally sits at the lowest share price its own formula can produce, and a swap in either direction raises it.
1.00, the lowest point9,000 wNXM swapped in
1.74
So the attack works from either side. Buying stNXM with wNXM pushes the price up, selling stNXM into the pool pushes it down, and both raise f, from opposite sides of the minimum.
Turning the price into wNXM
Withdrawals go through a queue. redeem records a request and moves the shares to the vault. Two days later, withdrawFinalize pays it out, and it prices the shares again when it does:
function withdrawFinalize(address _user) external notPaused update {
WithdrawalRequest memory withdrawal = withdrawals[_user];
uint256 shares = uint256(withdrawal.shares);
uint256 assets = _convertToAssets(shares, Math.Rounding.Floor);
uint256 requestTime = uint256(withdrawal.requestTime);
require((requestTime + withdrawDelay) <= block.timestamp, "Not ready to withdraw");
// ...
_burn(address(this), shares);
wNxm.transfer(_user, assets);
}The amount computed at request time is stored but never used for the payout. So the attack is a request, a two-day wait, and then one transaction inside the one-day finalize window: flash-borrow wNXM, swap it into the pool, call withdrawFinalize, swap back, repay.
The example in the figure, ignoring the 0.05% pool fee: the vault holds 9,000 wNXM outside the pool, the position starts at 1,000 wNXM and 1,000 stNXM at a price of 1, and 10,000 stNXM circulate, so a share is worth exactly 1 wNXM. An attacker has 1,000 shares queued. Swapping 9,000 wNXM in takes 900 stNXM out and leaves the position at 10,000 wNXM and 100 stNXM. totalAssets() reads 19,000 and totalSupply() 10,900, so a share is now worth 1.743 wNXM, and withdrawFinalize pays 1,743 for the 1,000 shares. Selling the 900 stNXM back returns the 9,000 wNXM. The 743 wNXM gained comes from the other holders, whose shares are worth 0.917 wNXM once the pool is back where it started.
Limits
The payout comes out of the wNXM the contract holds, so the liquid balance caps it, and _withdraw already refuses a request that the balance can’t cover at the time it’s made. The more of the pool’s liquidity the vault’s own position provides, the more a given swap moves f. The round trip pays the pool fee twice. And the finalize call has to land inside its window: after requestTime + withdrawDelay + 1 days, the request is cancelled and the shares go back.
The slot0 fix
40630a0 values the position at a 30-minute TWAP instead of the spot price:
(uint160 sqrtRatio,,,,,,) = dex.slot0();
uint32 twapPeriod = 1800; // 30 minutes
uint160 sqrtRatio = OracleLibrary.getSqrtRatioAtTick(
OracleLibrary.consult(address(dex), twapPeriod)
);A flash-loan round trip in one block now barely moves the average, and moving it means holding the price away from the market for a meaningful share of 30 minutes, against arbitrage. The formula keeps its shape, though: the share price is still lowest where the two prices agree and higher on either side. The TWAP makes it expensive to move the price. Nothing in the formula stops a moved price from raising the share price.
Duplicate tranche IDs
The second bug is in stakedNxm(). Nexus Mutual staking deposits are NFTs, and each NFT can hold stake in several tranches, 91-day periods that decide when the stake expires. The vault keeps a list of tranche IDs per NFT and sums the stake in each:
function _stakeNxm(uint256 _amount, address _poolAddress, uint256 _trancheId, uint256 _requestTokenId) internal {
// ...
uint256 tokenId = pool.depositTo(_amount, _trancheId, _requestTokenId, address(this));
tokenIdToTranches[tokenId].push(_trancheId);
// ...
}
function stakedNxm() public view returns (uint256 assets) {
for (uint256 i = 0; i < tokenIds.length; i++) {
// ...
for (uint256 j = 0; j < trancheIds.length; j++) {
(,, uint256 stakeShares,) = IStakingPool(pool).getDeposit(token, trancheIds[j]);
assets += (activeStake * stakeShares) / stakeSharesSupply;
}
}
}getDeposit(token, tranche) returns the NFT’s whole stake in that tranche. Adding to an existing deposit is a normal operation: the owner passes the existing NFT as _requestTokenId and the same tranche, and Nexus adds the stake to it. Every such call pushes the tranche ID again, and stakedNxm() then counts the full deposit once per copy. Staking more into a tranche the vault already uses adds the tranche’s whole deposit, new stake included, to totalAssets() one extra time.
The inflated rate does the same damage as the first bug, without a swap. New deposits mint fewer shares than they should, and queued withdrawals finalize at a price the vault can’t back. resetTranches() rebuilds the lists from Nexus and removes the copies, and anyone can call it, but nothing calls it automatically. The inflation lasts until someone does.
41e13ca fixes it where it starts: _stakeNxm only pushes a tranche ID the NFT’s list doesn’t already have. 40630a0 then replaces that loop with a call to resetTranches() after staking and after extendDeposit. That also covers the tranche extendDeposit moves stake into, which f901af0 never added to the list.
Conclusion
Both bugs feed the same exchange rate. One reads a price an attacker can set within a transaction, and the other counts the same stake more than once. A TWAP makes the price expensive to move, but the position’s stNXM and wNXM still sit on opposite sides of the rate, so whatever price the vault reads for the position also sets its share price.