How Basin's Pump-Encoding Code Produced Four Distinct Bug Classes
Basin's low-level bit-packing produced 4 documented bugs in Shadow Arena: off-by-one, slot confusion, bit-shift, and a missed update. One subsystem, four lessons.
TL;DR
- Basin is a Beanstalk Farms composable DEX in Zealynx Academy's Shadow Arena (1,145 SLOC, intermediate difficulty, 14 documented findings).
- Four of those 14 findings come from a single subsystem: the pump-encoding code that packs price-time data into compact storage slots.
- The four bugs span four different vulnerability classes: an off-by-one in slot indexing, a slot/load confusion, a bit-shift mistake, and a missed update in
shift()/sync(). - Pattern: low-level bit-packing code is bug-rich. The same subsystem produces multiple distinct vulnerabilities because the abstraction level forces every line to handle low-level concerns simultaneously.
- The audit lesson: when you see custom bit-packing in a protocol, expect to find multiple bugs in that subsystem. Allocate disproportionate review time to it.
Why this matters
Bit-packing is unavoidable in DeFi. Storage costs make it cheaper to pack four uint64 values into one slot than to use four separate slots. Uniswap V2 packs reserve0 (uint112) + reserve1 (uint112) + blockTimestampLast (uint32) into a single slot. Compound V2 packs interest rate snapshots. Basin packs price-time observations for its TWAP-like pump.
The cost of bit-packing is that every read or write now involves manual masking, shifting, casting. The compiler doesn't help; it can't catch a slot-index typo or an off-by-one in a shift count. Each bit-packed access is hand-rolled, and each piece of hand-rolled code is a place where a bug can hide.
Across our 6 Shadow Arena audit targets, Basin's pump-encoding subsystem is the single largest concentration of findings in one place. That's not because Basin's authors are worse engineers than the others. It's because they wrote more low-level code, and low-level code attracts bugs.
If you're auditing a fork that has custom bit-packing, this article gives you the priors.
What the pump-encoding code does
Basin's "Pump" is its on-chain price observer. It records observations of pair reserves over time so other contracts can compute TWAP-style averages. Conceptually similar to Uniswap V2's price0CumulativeLast accumulator, but Basin packs observations more densely: each observation is a tuple of (timestamp, reserve0_normalized, reserve1_normalized, additional_metadata) packed into one or more storage slots.
The packing layout is custom to Basin and lives in libraries that handle:
slot(index): compute the storage slot index for the observation at logical positionindexin the ring buffer.load(slot): read raw bytes from storage at the computed slot, with masking for the relevant bits.shift(observation): when a new observation arrives, shift older observations down the ring buffer by one position.sync(observation): write the new observation at the head of the buffer.
These four operations are the foundation. Every read or write of pump data goes through them. A bug in any one cascades into wrong TWAP outputs, which cascade into wrong prices, which cascade into wrong trades.
The four bugs
1. M-01: Off-by-one in pump slot indexing
The slot-index calculation contains an off-by-one. Specifically, slot(index) returns BASE_SLOT + index * BYTES_PER_OBSERVATION, but the index validation accepts index <= MAX_OBSERVATIONS instead of index < MAX_OBSERVATIONS. The boundary case (index == MAX_OBSERVATIONS) returns a slot one past the end of the allocated ring buffer, into adjacent unrelated storage.
The exploit path:
- A read with
index = MAX_OBSERVATIONSreturns whatever value happens to be in the slot one past the buffer. - That value is then interpreted as a packed observation, which produces a garbage timestamp and garbage reserves.
- The TWAP calculation built on top of that garbage produces a price that's deterministic (so an attacker can predict it) but uncorrelated with reality.
The fix is a one-character change: < instead of <=. The bug is in plain sight when you read the validation, but it's invisible when you're reasoning about the function's purpose.
2. M-02: Slot/load confusion
slot() and load() have similar names and similar return types. In one path through the code, slot() is called where load() was intended (or vice versa). The function compiles because both return uint256, but the values they return mean completely different things: slot() returns a storage SLOT INDEX, load() returns the VALUE at a slot.
The result: code that operates on slot indices as if they were observation data. The downstream calculation produces a number that has no relation to actual reserves, but again, it's deterministic. An attacker can predict it and trade against it.
This is a class of bug that compiles cleanly and passes basic unit tests. The only way to catch it is to manually trace the dataflow, asking at every variable: "what is this number representing right now?". The compiler can't help.
3. M-03: Bit-shift overflow
The packing uses bit-shifts to extract specific fields from a packed slot. One of the shifts is computed as shift_count = OBSERVATION_BITS * (index - 1). When index = 0, shift_count = -OBSERVATION_BITS, which under uint arithmetic underflows to a very large positive number, causing the resulting shift to either revert (under Solidity 0.8) or produce a garbage value (under earlier versions).
The dependent expression is in a function that's called for the most recent observation (index = 0 is the head of the ring buffer). So this bug fires on every TWAP read of the most recent data.
The fix is to special-case index = 0 (return the unshifted slot directly). The bug is a classic negative-arithmetic-on-unsigned-type mistake, common in low-level code.
4. H-01: Missed update in shift()/sync()
When a new observation is written, shift() should rotate the ring buffer so the oldest observation is dropped and the new one is written at the head. The bug: shift() updates the slot indices but does NOT update one of the metadata fields tracking "current head position". After shift(), the head pointer is stale by one position, so subsequent reads return the previously-second-newest observation instead of the actual newest.
This is a higher-severity finding because it corrupts every read for as long as the misalignment persists, not just the boundary case. Every TWAP computed during this window uses stale data.
The pattern of a missed state update in a multi-step operation was also the dominant bug class across all 63 Shadow Arena findings. It shows up in low-level code too.
Why low-level code attracts bugs
Four findings in one subsystem isn't bad luck. It's structural.
1. Multiple invariants in one expression
A normal Solidity line might be balance += amount. One operation, one invariant ("balance increases by amount"). A low-level bit-packing line might be (slot >> shift) & mask | (newValue << shift). Three operations, three invariants ("shift count is correct", "mask is correct", "value fits in mask width"). Each operation can be wrong independently. The compiler can't validate any of them.
2. No type-system protection
uint256 fits everything. A slot index, an observation value, a shift count, a mask, a packed observation, are all uint256. They're functionally distinct, but the compiler treats them identically. You can pass a slot index where an observation is expected, and the code compiles.
3. Hard to test exhaustively
Bit-packing is most often used in storage layouts that span many slots. The bug surfaces when an edge case (boundary index, ring-buffer wrap, type-width overflow) triggers. Standard test suites cover the happy path; they often miss the boundary cases that reveal bit-packing bugs.
4. The reader has to be the type-checker
Auditors of bit-packed code are doing manual type-checking. Every variable name needs an implicit "and this represents X". Every operation needs an implicit "valid because Y, Z constraints hold". The audit pace slows because you can't pattern-match; you have to derive correctness from first principles.
How to audit bit-packing code systematically
When you encounter custom bit-packing in a fork or audit:
Step 1: enumerate the operations
List every read and every write that touches the packed data. For Basin's pump, that's slot(index), load(slot), shift(observation), sync(observation), plus any inline accesses elsewhere in the codebase. Build a list.
Step 2: derive the invariants
For each operation, write down what must be true for it to be correct. For slot(index): index is in [0, MAX_OBSERVATIONS - 1], the result is a valid storage slot, the slot is owned by this subsystem (not adjacent storage). For load(slot): the slot was written by a corresponding sync(), the masking matches the packing layout, the result fits the expected type.
Step 3: check each operation against the invariants
Read each operation's code and confirm every invariant holds. This is the part where you'll find the bugs. Off-by-ones in indexing. Wrong masks. Slot/load confusion. Missed updates in compound operations.
Step 4: trace dataflow
For each piece of data emerging from the packing, follow it through the rest of the contract. Wrong data here turns into wrong data downstream. Check every consumer.
Step 5: test the boundaries
Specifically: index 0, index MAX-1, index MAX, ring-buffer wrap, the moment of shift() (just before, just after), uninitialized state. Each is a potential bug location.
This is slow. Auditing 200 lines of bit-packing code might take as long as auditing 1000 lines of normal Solidity. That's the cost.
Related questions
What's a "ring buffer" in DeFi storage? A ring buffer is a fixed-size array used as a circular sequence: when full, the next write overwrites the oldest entry. Common in TWAP-style observers because you only need recent observations, not the full history.
Why use bit-packing at all? Just use separate variables. Gas. A storage write costs 5,000 gas (warm) to 20,000 gas (cold). Reducing four writes to one saves 15,000-60,000 gas per call. Over millions of calls, this is thousands of dollars of gas cost. The trade-off is bug surface, which is the topic of this article.
Are these bugs Basin-specific or do they appear in other protocols? The bug classes (off-by-one, slot/load confusion, bit-shift, missed update) appear across the industry wherever bit-packing is custom. Each protocol's specific implementation has slightly different bugs, but the categories are the same.
Does Solidity's unchecked block help or hurt here? Both. unchecked is necessary for some bit-packing operations (you want shift overflow to wrap, not revert), but it removes a safety net for the operations where wrap is a bug. The right pattern is fine-grained unchecked blocks scoped to the specific operations that need wrap, with checked arithmetic everywhere else.
Did Basin's auditors miss these because of audit firm quality, or because the bugs are inherently hard to catch? Both, but the second factor dominates. Standard audit methodology is fast for "normal" Solidity and slow for low-level packing. Even strong auditors can miss bugs in dense bit-manipulation code, which is why these bugs ended up in the Shadow Arena documented set rather than being caught pre-launch.
Where to see this in Academy
Basin is one of six Shadow Arena audit targets in Zealynx Academy. You can audit the same code, with the bug list available, and practice the systematic methodology described above. The 14 documented findings (4 in pump-encoding, 10 elsewhere) give you a calibration target: an auditor competent at this style of code should find most of them; an auditor unfamiliar with bit-packing will miss most of the pump bugs.
The pattern, "low-level subsystem produces multiple distinct bug classes", repeats elsewhere in the Shadow Arena set too.
Train on Basin first. Then read forks of Beanstalk-style protocols differently afterward.
Tagged