VTSwapHook Pricing Error — Midpoint Approximation and Fee Accounting Exploit
On 2026-03-28, the VTSwapHook contract (0xbf4b4a83708474528a93c123f817e7f2a0637a88) deployed on Arbitrum was exploited through a logic error in its custom pricing formula. The hook implements a nonlinear (logarithm-based) price curve but approximates execution price using a simple midpoint average — mathematically equivalent to estimating the area under a curve with a single rectangle. An attacker chained 69 swap calls across 58 unlock() callbacks inside a single transaction, accumulating profit on each round trip from the compounding midpoint approximation error and a separate fee accounting bug that inflated virtual reserves. The PoolManager (0x360e68faccca8ca495c1b759fd9eee466db9fb32) lost ~2,007,935 ATH and ~4,507,034 vATH tokens, which were transferred to the attacker EOA (0xf378840de079c70f55218cd3af99d2d81ba154ba). No external capital (flash loan) was required; the attacker deployed two contracts that self-destructed after the attack.
Root Cause
Vulnerable Contract
- Contract: VTSwapHook
- Address:
0xbf4b4a83708474528a93c123f817e7f2a0637a88 - Source Type: verified (Arbiscan)
- Proxy: no — direct implementation
Vulnerable Function
- Function:
_getUnspecifiedAmount(internal) /beforeSwap(external hook entry) - Signature:
_getUnspecifiedAmount(IPoolManager.SwapParams calldata params) internal override returns (uint256 unspecifiedAmount) - Selector (hook):
0x575e24b4(beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)) - File:
src/market/VTSwapHook.sol
The pricing computation is delegated to VTSwapHookHelper (0xa341e92b22b1a2c94b24163eae09aed34e8ea134, selector 0x5e2627f8). The helper is unverified; its pricing is analyzed through the trace and the console.log output values.
Vulnerable Code
// VTSwapHook.sol
function _getUnspecifiedAmount(
IPoolManager.SwapParams calldata params
) internal override returns (uint256 unspecifiedAmount) {
uint256 specifiedAmount;
address vtSwapHookHelper = IProtocol(protocol).vtSwapHookHelper();
(specifiedAmount, unspecifiedAmount) = IVTSwapHookHelper(vtSwapHookHelper).doGetUnspecifiedAmount(
IVTSwapHook(this), params.zeroForOne, params.amountSpecified
);
// Update reserves after swap
// Since we're charging a fee, we use the full specified amount for incoming token
// but use the amount calculated based on the reduced (after-fee) input amount for outgoing token
bool isTToVT = (params.zeroForOne == !isToken0VT);
if (isTToVT) {
// T -> VT swap (user sends T, receives VT)
if (isToken0VT) {
reserve0 -= unspecifiedAmount; // VT decreases (sent to user)
reserve1 += specifiedAmount; // T increases (received from user, including fee) // <-- VULNERABILITY #2
} else {
reserve0 += specifiedAmount; // T increases (received from user, including fee) // <-- VULNERABILITY #2
reserve1 -= unspecifiedAmount; // VT decreases (sent to user)
}
} else {
// VT -> T swap (user sends VT, receives T)
if (isToken0VT) {
reserve0 += specifiedAmount; // VT increases (received from user, including fee) // <-- VULNERABILITY #2
reserve1 -= unspecifiedAmount; // T decreases (sent to user)
} else {
reserve0 -= unspecifiedAmount; // T decreases (sent to user)
reserve1 += specifiedAmount; // VT increases (received from user, including fee) // <-- VULNERABILITY #2
}
}
return unspecifiedAmount;
}
The helper (UnknownHelper_a341e9, recovered approximation) computes the swap output amount using the midpoint price approximation:
// [recovered — approximation] — from 0xa341e92b22b1a2c94b24163eae09aed34e8ea134
// Confidence: medium. Function names are inferred; treat as indicative, not authoritative.
function doGetUnspecifiedAmount(
IVTSwapHook self, bool zeroForOne, int256 amountSpecified
) external view returns (uint256 specifiedAmount, uint256 unspecifiedAmount) {
(uint256 reserveVT, uint256 reserveT) = self.getVTAndTReserves();
// price_before = f(reserveVT_before, reserveT_before) [ln-curve evaluation]
// price_after = f(reserveVT_after, reserveT_after) [ln-curve evaluation at post-swap state]
// execution_price = (price_before + price_after) / 2 // <-- VULNERABILITY #1: midpoint approximation
// unspecifiedAmount = specifiedAmount_after_fee * execution_price
}
Why It’s Vulnerable
Vulnerability 1 — Midpoint price approximation (root cause)
Expected behavior: A correct AMM using a logarithmic bonding curve should compute the execution price as the exact integral of the price function over the traded interval: output = integral(price(r), r_before, r_after). For a ln()-based curve the integral yields a closed-form solution.
Actual behavior: The VTSwapHookHelper approximates the integral with a single midpoint rectangle: execution_price = (price_before + price_after) / 2. For any nonlinear (convex or concave) curve, this approximation introduces a systematic error. For a concave curve (as ln() is), the midpoint rule overestimates the true integral — meaning swappers receive slightly more output than the curve should provide. The error per swap is small but nonzero, and it is directionally biased in the same direction for each swap type.
The attacker exploits this by alternating T→VT and VT→T swaps. Each round trip (T→VT, then VT→T with the received VT) yields slightly more T than the starting amount. With 69 swaps across 58 unlock() callbacks, the attacker accumulates net profit equal to the sum of all approximation errors.
Vulnerability 2 — Fee double-counting in reserve update (compounding factor)
When updating reserves after a swap, _getUnspecifiedAmount adds specifiedAmount (the pre-fee gross input, including the swap fee) to the incoming reserve, while the helper computes the output (unspecifiedAmount) based only on the post-fee net input. The fee tokens are thus permanently credited to the reserve without a corresponding real token having been transferred, inflating the apparent reserve. This inflated reserve then produces more generous pricing on subsequent swaps, amplifying the profit beyond what the midpoint error alone would produce.
Normal flow vs Attack flow:
| Scenario | Expected | Actual |
|---|---|---|
| Single T→VT swap | Output = exact integral of ln() curve | Output = midpoint approximation, slightly overpays swapper |
| Single VT→T swap | Output = exact integral of ln() curve | Output = midpoint approximation, slightly overpays swapper |
| Reserve update | Incoming reserve += net input (post-fee) | Incoming reserve += gross input (pre-fee) — fee inflates reserve |
| After 69 chained swaps | No net gain from round trips | ~2M ATH + ~4.5M vATH extracted via compounding approximation errors |
Attack Execution
High-Level Flow
- Attacker EOA (
0xf378840de079c70f55218cd3af99d2d81ba154ba) deploys outer contract (0xfdd4ada171bf56e114f7b87cd5e92f20b9855642) via a CREATE transaction. - Outer contract deploys inner contract (
0x959ec1872100eccb8c9ac355304fed81fa5d237e) with ATH/vATH/PoolManager addresses hard-coded. - Outer contract calls
inner.exec(attacker_EOA). Inner contract approves both ATH and vATH to the PoolManager withtype(uint256).max. - Inner contract enters a loop of 58 iterations. In each iteration it calls
PoolManager.unlock(), which triggersinner.unlockCallback(). - Inside each
unlockCallback, the inner contract reads the current VT/T reserves from VTSwapHook (reserve1(),getVTAndTReserves()), computes a swap amount, then callsPoolManager.swap()— triggeringVTSwapHook.beforeSwap(). VTSwapHook.beforeSwap()callsVTSwapHookHelper.doGetUnspecifiedAmount()(selector0x5e2627f8), which reads protocol parameters and current reserves, then returns the midpoint-approximated output amount. The hook updates its internal reserves using the pre-fee input amount.- The PoolManager credits ERC-6909 claims (via
mint/burn) to the hook and debits them from the attacker, tracking the swap settlement. - Settlement occurs via ERC-6909
mint/burnwithin mostunlock()windows (48 total across 58 sessions).PoolManager.take()is called only 6 times total (not once per iteration) to withdraw accumulated ATH and vATH token balances at specific settlement points. Note: Call flow derived from on-chain trace;take()is called selectively, not after every individual swap. - After all 58 outer loops complete, the inner contract’s ATH and vATH balances (accumulated surplus from midpoint errors) are transferred to the attacker EOA. The outer contract then
SELFDESTRUCTs.
Detailed Call Trace
The following trace covers the top-level structure and one representative swap iteration. All 58 unlock() iterations follow the same pattern at depths 2–8.
AttackerEOA (0xf378840d) → AttackerOuter (0xfdd4ada1) [CREATE]
└─ AttackerOuter (0xfdd4ada1) → AttackerInner (0x959ec187) [CREATE]
└─ AttackerOuter (0xfdd4ada1) → AttackerInner (0x959ec187) [CALL, 0x6bb6126e exec(address)]
├─ AttackerInner → ATH/vATH (0xc87b37a5) [CALL, 0x095ea7b3 approve(PoolManager, max)]
│ └─ ATH proxy → Impl (0x7f9f70da) [DELEGATECALL, 0x095ea7b3 approve]
├─ AttackerInner → vATH (0x24ef95c3) [CALL, 0x095ea7b3 approve(PoolManager, max)]
│
└─ [58 iterations: each calls PoolManager.unlock()]
AttackerInner (0x959ec187) [CALL, 0x5aae8d5a func_0x5aae8d5a — sets swap direction]
AttackerInner → PoolManager (0x360e68fa) [CALL, 0x48c89491 unlock(bytes)]
└─ PoolManager → AttackerInner [CALL, 0x91dd7346 unlockCallback(bytes)]
├─ AttackerInner → VTSwapHook [STATICCALL, 0x5a76f25e reserve1()] ← reads current reserve
├─ AttackerInner → VTSwapHook [STATICCALL, 0x5a76f25e reserve1()] ← reads again
│
├─ AttackerInner → PoolManager [CALL, 0xf3cd914c swap(poolKey, params, hookData)]
│ └─ PoolManager → VTSwapHook [CALL, 0x575e24b4 beforeSwap(sender, key, params, hookData)]
│ ├─ VTSwapHook → Protocol (0x170e0c91) [STATICCALL, 0x38d5c2ce vtSwapHookHelper()]
│ └─ VTSwapHook → UnknownHelper (0xa341e92b) [STATICCALL, 0x5e2627f8]
│ ├─ UnknownHelper → VTSwapHook [STATICCALL, 0xb62c055d getVTAndTReserves()]
│ ├─ UnknownHelper → VTSwapHook [STATICCALL, 0xfbfa77cf vault()]
│ ├─ UnknownHelper → ProxyContract (0xf8dfaa09) [STATICCALL, 0x5caf9484]
│ │ └─ ProxyContract → ProxyImpl (0xefb7baab) [DELEGATECALL, 0x5caf9484]
│ ├─ UnknownHelper → ProxyContract [STATICCALL, 0xe16b6fde]
│ │ └─ ProxyContract → ProxyImpl [DELEGATECALL, 0xe16b6fde]
│ ├─ UnknownHelper → VTSwapHook [STATICCALL, 0x983ac1dd getParamValue("scalarRoot")]
│ │ └─ VTSwapHook → Settings (0x2f70e725) [STATICCALL, 0x5bec50be vaultParamValue]
│ ├─ UnknownHelper → VTSwapHook [STATICCALL, 0x983ac1dd getParamValue("initialAnchor")]
│ ├─ UnknownHelper → VTSwapHook [STATICCALL, 0x983ac1dd getParamValue("vtSwapFee")]
│ ├─ UnknownHelper → VTSwapHook [STATICCALL, 0x983ac1dd getParamValue("R")]
│ └─ UnknownHelper → VTSwapHook [STATICCALL, 0x8133cd65 isToken0VT()]
│ [VTSwapHook updates reserves: reserve += specifiedAmount (pre-fee)] ← VULNERABILITY #2
│ └─ [returns BeforeSwapDelta with unspecified amount to PoolManager]
│ ├─ PoolManager → VTSwapHook [CALL, 0x156e29f6 mint(hook, tokenId, amount)] ← credits hook
│ └─ PoolManager → VTSwapHook [CALL, 0xf5298aca burn(hook, tokenId, amount)] ← debits hook
│
└─ AttackerInner → PoolManager [CALL, 0x0b0d9c09 take(vATH, attacker, amount)]
└─ AttackerInner → PoolManager [CALL, 0x0b0d9c09 take(ATH, attacker, amount)]
└─ PoolManager → ATH/vATH [CALL, 0xa9059cbb transfer(attacker, amount)]
│
└─ [Final iteration — extra take/settle for accumulated surplus]
AttackerInner → PoolManager [CALL, 0xa5841194 sync(ATH)]
AttackerInner → PoolManager [CALL, 0x11da60b4 settle()]
AttackerInner → PoolManager [CALL, 0xa5841194 sync(vATH)]
AttackerInner → PoolManager [CALL, 0x11da60b4 settle()]
AttackerInner → PoolManager [CALL, 0x0b0d9c09 take(ATH, attacker, amount)]
AttackerInner → PoolManager [CALL, 0x0b0d9c09 take(vATH, attacker, amount)]
│
└─ AttackerInner → ATH [STATICCALL, 0x70a08231 balanceOf(attacker)]
└─ AttackerInner → ATH [CALL, 0xa9059cbb transfer(EOA, ~2,007,935 ATH)]
└─ AttackerInner → vATH [STATICCALL, 0x70a08231 balanceOf(attacker)]
└─ AttackerInner → vATH [CALL, 0xa9059cbb transfer(EOA, ~4,507,034 vATH)]
└─ AttackerOuter (0xfdd4ada1) [SELFDESTRUCT → 0xf378840d]
Selector verifications (confirmed with cast sig):
0x575e24b4=beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)✓0xb62c055d=getVTAndTReserves()✓0x48c89491=unlock(bytes)✓0xf3cd914c=swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes)✓0x91dd7346=unlockCallback(bytes)✓0x0b0d9c09=take(address,address,uint256)✓0x156e29f6=mint(address,uint256,uint256)✓0xf5298aca=burn(address,uint256,uint256)✓
Financial Impact
All figures are from funds_flow.json (primary evidence).
Protocol loss: The UniswapV4 PoolManager (0x360e68faccca8ca495c1b759fd9eee466db9fb32) — which holds the pool’s tokens — lost:
- 2,007,935.14 ATH (
0xc87b37a581ec3257b734886d9d3a581f5a9d056c) — net outflow from PoolManager - 4,507,034.03 vATH (
0x24ef95c39dfaa8f9a5adf58edf76c5b22c34ef46) — net outflow from PoolManager
These amounts represent the entire liquidity held by the ATH/vATH VTSwapHook pool; the pool was effectively drained.
Attacker profit: The attacker EOA (0xf378840de079c70f55218cd3af99d2d81ba154ba) received exactly:
- 2,007,935.135797166477561277 ATH
- 4,507,034.031976468367338089 vATH
The USD equivalent depends on ATH token price at time of attack (2026-03-28 01:37 UTC). No external capital (flash loan) was used; the attacker’s only cost was gas. Gas used: 0x61bd89 (6,405,513 gas units) at 0x1312d01 (0.02 Gwei = 20,000,001 wei on Arbitrum) = approximately 0.000128 ETH (~$0.26 at current prices). (Note: Validator corrected gas figures — receipt gasUsed is 0x61bd89, and Arbitrum gas prices are in the sub-Gwei range, not 20 Gwei.)
Funds flow summary (from funds_flow.json):
| Token | Flow | Amount |
|---|---|---|
| vATH | PoolManager → AttackerInner | +3,327,526 vATH (round 1) |
| ATH | PoolManager → AttackerInner | +889,574 ATH (round 1) |
| vATH | PoolManager → AttackerInner | +987,326 vATH (round 2) |
| ATH | PoolManager → AttackerInner | +815,677 ATH (round 2) |
| vATH | PoolManager → AttackerInner | +511,301 vATH (round 3) |
| ATH | AttackerInner → PoolManager | −284,208 ATH (partial repayment) |
| vATH | AttackerInner → PoolManager | −319,118 vATH (partial repayment) |
| ATH | PoolManager → AttackerInner | +586,893 ATH |
| ATH | AttackerInner → AttackerEOA | +2,007,935 ATH (final transfer) |
| vATH | AttackerInner → AttackerEOA | +4,507,034 vATH (final transfer) |
Net change at PoolManager: −2,007,935 ATH, −4,507,034 vATH. Net change at AttackerEOA: +2,007,935 ATH, +4,507,034 vATH. Net change at AttackerInner: 0 (all profit forwarded to EOA and contract self-destructed).
LPs who provided ATH/vATH liquidity to the VTSwapHook pool bear the full loss.
Evidence
Selector Confirmation
0x5e2627f8(UnknownHelper pricing function, unresolved viacast 4byte) — confirmed as the hook’s pricing subcontract call via trace: called exactly 69 times, once perbeforeSwapinvocation, always from0xbf4b4a83to0xa341e92b22.0x5aae8d5a(AttackerInner internal dispatch) — confirmed as the per-iteration swap dispatcher: called 38 times (self-call from inner contract), triggersPoolManager.unlock().
Call Count Evidence
From selectors.json:
unlock(bytes)called 58 times — confirms 58 outer unlock sessionsswap(...)called 69 times — confirms 69 individual swaps across those sessionsbeforeSwap(...)called 69 times (exactly matchingswapcount) — confirms every swap triggered the hookgetVTAndTReserves()called 69 times — confirms the pricing helper reads reserves on every swapvaultParamValue(address,bytes32)called 324 times (69 × 4 params + initial) — confirms 4 protocol parameters read per swaplog(string,uint256)called 723 times — confirms the unverified helper contract containsconsole.logdebugging statements, consistent with a production contract accidentally left in debug mode
Self-Destruct Confirmation
data_manifest.json records: “Bytecode unavailable — contract self-destructed after attack. cast code returns 0x.” for AttackerOuter (0xfdd4ada171bf56e114f7b87cd5e92f20b9855642). The trace confirms SELFDESTRUCT at decoded_calls.json index 2879, depth 1, from outer contract to attacker EOA.
Token Symbols
funds_flow.json records the token symbols as binary-encoded strings (\x00...\x03ATH and \x00...\x04vATH) — these are ABI-encoded bytes32 strings rather than proper UTF-8, confirming the tokens use a non-standard symbol encoding. The token addresses are confirmed as: ATH = 0xc87b37a581ec3257b734886d9d3a581f5a9d056c (EIP-1167 proxy → 0x7f9f70da, verified source), vATH = 0x24ef95c39dfaa8f9a5adf58edf76c5b22c34ef46 (recovered, high confidence).
Related URLs
- Transaction: https://arbiscan.io/tx/0x61a37afac7991e25391d72846819644a0938ce20ebab25ccf3a1123e1bb9459d
- VTSwapHook: https://arbiscan.io/address/0xbf4b4a83708474528a93c123f817e7f2a0637a88
- UniswapV4 PoolManager (Arbitrum): https://arbiscan.io/address/0x360e68faccca8ca495c1b759fd9eee466db9fb32
- Attacker EOA: https://arbiscan.io/address/0xf378840de079c70f55218cd3af99d2d81ba154ba
- Attacker Inner Contract: https://arbiscan.io/address/0x959ec1872100eccb8c9ac355304fed81fa5d237e