Reproduced Exploit

ELFI: pool value ignores open funding

The pool holds 1000 BASE and is already owed 1000 BASE of open funding. getPoolIntValue sums amount + unsettledAmount + unPnl and leaves the open funding out, so the share price is 1e18 instead of 2e18. A deposit of 1000 BASE mints 1000 shares instead of 500. When the position update writes the fun…

May 2024•Other•logic•4 min read

Chain

Other

Category

logic

Date

May 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 34765-h-3-pool-value-does-not-consider-the-open-funding-fees-sherl. Standalone Foundry PoC and full write-up: 34765-h-3-pool-value-does-not-consider-the-open-funding-fees-sherl_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/fee-calculation · vuln/logic/price-calculation · vuln/defi/sandwich-attack

Reproduction: local synthetic. getPoolIntValue is the audited sum. No fork and no warp.

Key info#

ProtocolELFI
FindingSherlock H-3, AuditVault 34765
Vulnerable codeLpPoolQueryProcess.getPoolIntValue
HarmQuoted share price is 1e18. Fair price is 2e18. A 1000 BASE deposit withdraws 1500 BASE. The fair withdrawal is 1000 BASE.
Chainlocal synthetic

TL;DR#

The pool holds 1000 BASE and is already owed 1000 BASE of open funding. getPoolIntValue sums amount + unsettledAmount + unPnl and leaves the open funding out, so the share price is 1e18 instead of 2e18. A deposit of 1000 BASE mints 1000 shares instead of 500. When the position update writes the funding into unsettledAmount, those shares redeem 1500 BASE. The fair redemption is 1000 BASE. The depositor's profit is 500 BASE, taken from the existing LPs. The control adds openFundingFee to the sum. The same deposit mints 500 shares and withdraws 1000 BASE.

The vulnerable code#

SOLIDITY
if (pool.baseTokenBalance.amount > 0 || pool.baseTokenBalance.unsettledAmount > 0) {
    int256 unPnl = getMarketUnPnl(pool.symbol, oracles, true, pool.baseToken, true);
    int256 baseTokenPrice = OracleProcess.getIntOraclePrices(oracles, pool.baseToken, true);
    // @> VULN: openFundingFee is not in the sum, so the share price is 1e18 instead of 2e18
    value = tokenToUsdInt((amount + unsettledAmount + unPnl), 18, baseTokenPrice);
}

The report's expression is (amount + unsettledAmount + unPnl) converted to USD. Funding is only copied into unsettledAmount when a position is updated. Until then the share price is low, and after the update it jumps with no change in supply.

Root cause#

Open funding is a pool receivable that getPoolIntValue does not count. Mint and redeem both divide by that value. A depositor who arrives before the position update is given too many shares for the true value, then redeems them after the update at the spiked value.

Preconditions#

One pool, base price 1, no unsettled amount, no unrealised PnL. Cash is 1000 BASE. Open funding owed to the pool is 1000 BASE. Stake-token supply is 1000 shares, all held by an existing LP. The depositor has 1000 BASE. The position update that realizes the funding happens after the deposit and before the withdrawal. run() does not warp.

Attack walkthrough#

  1. sharePrice calls getPoolIntValue. The sum is 1000. Quoted price is 1e18. Fair price is (1000 + 1000) / 1000 = 2e18.
  2. Deposit 1000 BASE. Shares minted = 1000 * 1000 / 1000 = 1000. Fair mint = 1000 * 1000 / 2000 = 500.
  3. The position update sets unsettledAmount to 1000 and delivers those tokens to the pool. Cash plus unsettled is now 3000. Supply is 2000.
  4. Withdraw 1000 shares. Assets out = 1000 * 3000 / 2000 = 1500.
  5. Fair withdrawal of the 500 shares on a supply of 1500 is 500 * 3000 / 1500 = 1000.

The control's getPoolIntValue adds openFundingFee. Price is 2e18, the deposit mints 500 shares, and the withdrawal returns 1000 BASE.

Diagrams#

flowchart TD A["Pool cash 1000 BASE, open funding 1000 BASE, supply 1000"] --> B["VULN: getPoolIntValue returns 1000"] B --> C["Quoted share price 1e18. Fair price is 2e18"] C --> D["Deposit 1000 BASE mints 1000 shares, not 500"] D --> E["Position update adds the funding to unsettledAmount"] E --> F["1000 shares redeem 1500 BASE. Fair redemption is 1000"]

Impact#

The depositor puts in 1000 BASE and takes out 1500 BASE. The extra 500 BASE is the existing LP's share of funding that was not in the price. The quoted share price was 1e18 and the fair price was 2e18. The same gap works in the other direction for a redeemer who exits before a funding payment the pool owes: the quoted value is too high and they take too much cash. This case is the receivable side, and the withdrawal is too high.

Remediation#

Add the net open funding fee, across open positions, to getPoolIntValue before shares are minted or redeemed. Do not wait for a position update to copy it into unsettledAmount.

How to reproduce#

BASH
cd 34765-h-3-pool-value-does-not-consider-the-open-funding-fees-sherl_exp
forge test -vv

The forge log shows quoted price: 1000000000000000000, fair price: 2000000000000000000, withdrawn: 1500000000000000000000, fair withdraw: 1000000000000000000000. The control withdraws 1000000000000000000000.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.