DEV Community

Am0MuK
Am0MuK

Posted on

The Uniswap v4 tick that only breaks when it's negative

A wallet's DeFi positions quietly disappeared from a report I generate. Not zeroed — absent. No error, no stack trace, no failed run. The pipeline said it finished successfully.

The cause was one line of tick decoding that is wrong only when the number is negative. Everything about how it hid is more interesting than the bug itself.

Two ways to read a tick

The code reads Uniswap v4 positions in two steps.

First, PositionManager.getPoolAndPositionInfo(tokenId) returns the pool key and a PositionInfo — a single 256-bit word with several fields bit-packed into it. From the least significant bit: 8 bits hasSubscriber, 24 bits tickLower, 24 bits tickUpper, 200 bits of truncated pool id.

To get a tick out of that word you shift, mask, and sign-extend by hand:

def _sign_extend_24(value: int) -> int:
    return value - (1 << 24) if value >= (1 << 23) else value

tick_lower = _sign_extend_24((position_info >> 8) & 0xFFFFFF)
tick_upper = _sign_extend_24((position_info >> 32) & 0xFFFFFF)
Enter fullscreen mode Exit fullscreen mode

This is correct, and it matches what Uniswap's own library does:

_tickLower := signextend(2, shr(TICK_LOWER_OFFSET, info))
Enter fullscreen mode Exit fullscreen mode

Second, StateView.getSlot0(poolId) returns the pool's current state. Its signature:

function getSlot0(PoolId poolId)
    external view
    returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee);
Enter fullscreen mode Exit fullscreen mode

Four separate return values. And here I wrote this:

current_tick = _sign_extend_24(_parse_u256(_word(data, 1)))
Enter fullscreen mode Exit fullscreen mode

Same helper, same idea, second word of the return data. It looks consistent with the packed decode three lines above it. It is wrong.

Why it's wrong

getSlot0 is an ordinary external function returning an ordinary tuple. The ABI encoder gives every return value its own 32-byte word, and for a signed type it sign-extends into the full word. By the time the bytes reach you, int24 tick = -100 is already two's complement across all 256 bits:

0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9c
Enter fullscreen mode Exit fullscreen mode

That is not a 24-bit field inside a larger word. It is a complete 256-bit signed integer, and the only correct way to read it is as one:

def _parse_i256(hex_word: str) -> int:
    value = _parse_u256(hex_word)
    return value - (1 << 256) if value >= (1 << 255) else value
Enter fullscreen mode Exit fullscreen mode

Feed that same word to the 24-bit path instead and you get:

_parse_u256(word)              # 115792089237316195423570985008687907853269984665640564039457584007913129639836
_sign_extend_24(...)           # 115792089237316195423570985008687907853269984665640564039457584007913112862620
_parse_i256(word)              # -100   ← correct
Enter fullscreen mode Exit fullscreen mode

_sign_extend_24 checks whether bit 23 is set. In a fully sign-extended negative number every high bit is set, so the check passes, it subtracts 2²⁴, and returns a number that is still astronomically large. The guard fires and accomplishes nothing.

Why it stayed hidden

Positive ticks are unaffected. int24 tick = 12345 ABI-encodes to a word with leading zeros — indistinguishable from an unsigned 24-bit field. _sign_extend_24 sees bit 23 clear, returns the value untouched, and everything downstream is correct.

So the bug is invisible in exactly the situation you test first: a pool whose price sits above the 1:1 raw ratio.

Negative ticks are not an edge case. A tick is log₁.₀₀₀₁(price) where price is currency1 per currency0 in raw base units, and decimals are part of that ratio. In any v4 pool paired with native ETH, ETH is currency0 — address(0) sorts below every token. Pair it with USDC:

1 ETH ≈ 3000 USDC
raw ratio = 3000 × 10⁶ / 10¹⁸ = 3 × 10⁻⁹
tick = ln(3 × 10⁻⁹) / ln(1.0001) ≈ -196,000
Enter fullscreen mode Exit fullscreen mode

Every ETH/stablecoin pool in v4 sits at a deeply negative tick, permanently. Not a boundary condition — the common case.

Why nothing reported it

The corrupted tick goes into a price calculation:

def _sqrt_price_at_tick(tick: int) -> float:
    return 1.0001 ** (tick / 2)
Enter fullscreen mode Exit fullscreen mode

With a tick around 1.16 × 10⁷⁷ that raises OverflowError. Which would be a fine, loud failure — except the enrichment calls were wrapped like this:

try:
    snapshot.uniswap_v3_positions = get_uniswap_v3_positions(...)
    snapshot.uniswap_v4_positions = get_uniswap_v4_positions(...)
    snapshot.morpho_positions     = get_morpho_positions(...)
    snapshot.compound_v3_positions = get_compound_v3_positions(...)
    snapshot.euler_positions      = get_euler_positions(...)
    snapshot.pendle_positions     = get_pendle_positions(...)
    snapshot.curve_positions      = get_curve_positions(...)
except Exception as e:
    logger.info(f"Position enrichment failed (non-fatal): {e}")
Enter fullscreen mode Exit fullscreen mode

One except around seven protocols, logged at info. A single negative tick in one Uniswap pool therefore removed the wallet's Morpho, Compound, Euler, Pendle and Curve positions from the report as well — because they are simply the lines that never ran. The log line that would have explained it sits below the level anyone filters for in production.

Three failures stacked: a decode that is wrong only for negative numbers, a sign asymmetry that hides it during testing, and an exception handler broad enough to turn it into silence.

The rule

Two decodings, one file, opposite handling — and the difference is not the type, it's the path the value took to reach you:

Source What you receive How to decode
A return value of an external function Already sign-extended to 32 bytes by the ABI encoder Read the whole word as signed
A field inside a manually packed word A raw bit range, no sign extension Mask, then sign-extend by width

Uniswap's own library shows both. PositionInfoLibrary calls signextend(2, ...) because it is pulling bits out of storage it packed itself. getSlot0 doesn't, because returning int24 through the ABI already did it.

The fix was one line:

current_tick = _parse_i256(_word(data, 1))
Enter fullscreen mode Exit fullscreen mode

which is what the v3 path in the same file had been doing correctly all along, a few hundred lines up.

Two changes worth making beyond the fix: raise that logger.info to warning, and treat "a whole class of positions vanished from output" as a condition worth asserting on, not something to discover by reading a report and noticing an absence.


Written from a real bug in a multi-chain DeFi tax engine I maintain. References: StateView, PositionInfoLibrary.

Top comments (0)