Reproduced Exploit

Blueberry VaultEscrow — precision loss makes the L1 bridge revert

_withdrawFromL1Vault converts assets_ from 8 spot decimals to 6 perp decimals by integer division, withdraws that smaller perp amount, then calls sendSpot with the original assets_. For assets_ = 100001111, the perp amount is 1000011 and the spot value of that withdrawal is 100001100. sendSpot(1000…

Mar 2025•Ethereum•arithmetic•4 min read

Loss

100001111 spot units stay locked. The user receives 0. The fair bridgeable amount is 100001100

Chain

Ethereum

Category

arithmetic

Date

Mar 2025

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: 61471-h-03-precision-loss-in-withdrawfroml1vault-can-cause-asset. Standalone Foundry PoC and full write-up: 61471-h-03-precision-loss-in-withdrawfroml1vault-can-cause-asset_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/arithmetic/precision-loss · vuln/dos/frozen-funds

Key info#

FieldValue
Loss100001111 spot units stay locked. The user receives 0. The fair bridgeable amount is 100001100
Vulnerable contractVaultEscrow._withdrawFromL1Vault
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2025-03
Compilersolc 0.8.27, cancun, via IR
Bug classSpot amount is not the inverse of the perp-decimal scale

TL;DR#

_withdrawFromL1Vault converts assets_ from 8 spot decimals to 6 perp decimals by integer division, withdraws that smaller perp amount, then calls sendSpot with the original assets_. For assets_ = 100001111, the perp amount is 1000011 and the spot value of that withdrawal is 100001100. sendSpot(100001111) reverts. The redeem claim stays at 100001111 and the user is paid 0. Bridging _scaleToSpotDecimals(amountPerp) instead pays 100001100 and leaves 11 units of dust.

Background#

Pashov Audit Group reviewed Blueberry on 2025-03-26 (H-03, high). VaultEscrow moves a Hyperliquid vault withdrawal back to the EVM spot balance. The worked example in the report is perp decimals 6, EVM spot decimals 8, and assets_ = 100001111. The EVM redeem has already succeeded when the L1 bridge reverts, so the assets are not returned to the escrow's user.

The vulnerable code#

SOLIDITY
function _withdrawFromL1Vault(uint64 assets_, address user) internal {
    uint256 amountPerp = _scaleToPerpDecimals(assets_);
    l1.sendVaultTransfer(address(this), false, uint64(amountPerp));
    l1.sendUsdClassTransfer(uint64(amountPerp), false);
    // @> VULN: bridges assets_ even though only the rounded perp amount was withdrawn
    l1.sendSpot(user, 0, assets_);
}
DIFF
-    l1.sendSpot(user, 0, assets_);
+    l1.sendSpot(user, 0, uint64(_scaleToSpotDecimals(amountPerp)));

Root cause#

_scaleToPerpDecimals is assets_ / 10^(spotDecimals - perpDecimals). With the exponent equal to 2, 100001111 / 100 = 1000011. Scaling that back is 1000011 * 100 = 100001100, which is 11 units short of assets_. sendSpot spends the original spot amount against a balance that only received the rounded amount, so the L1 transfer reverts after the redeem claim has been recorded.

Preconditions#

  • _perpDecimals is 6 and _evmSpotDecimals is 8.
  • assets_ is not divisible by 10^(spotDecimals - perpDecimals). The report's value 100001111 is not divisible by 100.
  • The redeem is stored before the L1 pull. A reverting pull must not clear the claim.

Attack walkthrough#

  1. L1 is funded with 100001111 spot units.
  2. The user requests a redeem of 100001111. claimOf stores that amount.
  3. Settlement scales the amount to 1000011 perp units and credits 100001100 spot units.
  4. sendSpot is called with 100001111. L1 reverts spot shortfall.
  5. The claim is still 100001111. The user's balance is 0. L1 still holds 100001111.

Diagrams#

flowchart TD A["Redeem records a claim of 100001111 spot units"] --> B["Scale divides by 100"] B --> C["L1 withdraws 1000011 perp units"] C --> D["That withdrawal is only 100001100 spot units"] D --> E["sendSpot asks for the original 100001111"] E --> F["L1 reverts because 100001111 exceeds 100001100"] F --> G["Claim stays 100001111 and the user receives 0"]

Impact#

The user cannot complete the withdrawal. 100001111 spot units remain on L1 and the claim stays open. The control sends the scaled amount 100001100, the user receives 100001100, and 11 units of rounding dust remain on L1.

PoC#

BASH
cd 61471-h-03-precision-loss-in-withdrawfroml1vault-can-cause-asset_exp
forge test -vv

test_exploit_sendSpot_reverts_and_locks_claim asserts the user balance is 0 and L1 still holds 100001111. test_control_scaled_spot_pays_fair_amount asserts the user receives 100001100 and L1 keeps 11.

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.