Bit Packing in Solidity: One Slot, Multiple Fields
A storage slot on Ethereum is 256 bits. A price is 64 bits. An amount is 64 bits. A maker index is 32 bits. Eight boolean flags are 8 bits.
That's 168 bits total. It all fits in one slot.
Solidity can auto-pack consecutive state variables into a single slot if they fit within 32 bytes. So why bother doing it manually?
Because order data in DEX protocols isn't stored as separate state variables. It's passed as function parameters, packed into mappings, and hashed for EIP-712 signatures. In those contexts, each field is ABI-encoded as a full 32-byte word. A struct with four fields costs 128 bytes of calldata. A single uint256 costs 32 bytes. That's 4x the calldata cost, and on L2s where calldata dominates gas fees, that difference matters.
Manual bit packing also gives you explicit control over the layout (critical when the packed value is hashed for signatures) and lets you wrap it in a user-defined value type for compile-time safety.
Table of Contents
- The Bit Layout
- How Protocols Define the Layout
- Extracting a Field: Shift and Mask
- Writing a Field: Clear Then Set
- Checking Individual Flags
- Packing From Scratch
- Why Not abi.encodePacked?
- Where the Gas Savings Come From
- Mistakes That Corrupt Data
- Takeaways
The Bit Layout
You pick a layout: which bits belong to which field. High bits to low is a common convention.
Bit 255 Bit 0
┌──────────────┬──────────────┬────────────┬────────┬───────┐
│ price (64) │ amount (64) │ maker (32) │flags(8)│ empty │
│ 255 - 192 │ 191 - 128 │ 127 - 96 │ 95-88 │ 87-0 │
└──────────────┴──────────────┴────────────┴────────┴───────┘
- Price: bits 255 to 192 (64 bits)
- Amount: bits 191 to 128 (64 bits)
- Maker index: bits 127 to 96 (32 bits)
- Flags: bits 95 to 88 (8 bits)
- Unused: bits 87 to 0 (padding, available for future fields)
The layout is arbitrary. You could put flags in the high bits and price in the low bits. What matters is that every piece of code reading or writing the packed value agrees on the same layout.
How Protocols Define the Layout
Production protocols like 1inch's Limit Order Protocol define bit positions using explicit constants:
// Offsets: how many bits to shift right to reach each field
uint256 private constant PRICE_OFFSET = 192;
uint256 private constant AMOUNT_OFFSET = 128;
uint256 private constant MAKER_OFFSET = 96;
uint256 private constant FLAGS_OFFSET = 88;
// Masks: isolate the field after shifting
uint256 private constant PRICE_MASK = type(uint64).max; // 0xFFFFFFFFFFFFFFFF
uint256 private constant AMOUNT_MASK = type(uint64).max;
uint256 private constant MAKER_MASK = type(uint32).max; // 0xFFFFFFFF
uint256 private constant FLAGS_MASK = type(uint8).max; // 0xFF
Named constants for every offset and mask. No magic numbers scattered through the codebase. If a bit range changes, you update one constant, not twenty call sites.
1inch wraps the raw uint256 in a user-defined value type for type safety:
type MakerTraits is uint256;
This prevents accidentally passing a raw uint256 where a MakerTraits is expected, or vice versa. The compiler catches it. Zero runtime cost.
Extracting a Field: Shift and Mask
To read a field from the packed value, two operations:
- Shift right to move the target field into the lowest bits
- Mask to zero out everything above the field
function getMakerIndex(uint256 packed) internal pure returns (uint256) {
return (packed >> MAKER_OFFSET) & MAKER_MASK;
}
function getFlags(uint256 packed) internal pure returns (uint256) {
return (packed >> FLAGS_OFFSET) & FLAGS_MASK;
}
function getPrice(uint256 packed) internal pure returns (uint256) {
return (packed >> PRICE_OFFSET) & PRICE_MASK;
}
Walk through getMakerIndex step by step:
Original packed value (256 bits):
[price: 64 bits][amount: 64 bits][maker: 32 bits][flags: 8 bits][empty: 88 bits]
After >> 96:
[000...000][price: 64 bits][amount: 64 bits][maker: 32 bits]
↑ now in lowest 32 bits
After & 0xFFFFFFFF:
[000...000][maker: 32 bits]
↑ everything above zeroed out
One SLOAD for the packed value. Then pure bitwise operations, which cost 3 gas each. Total cost to extract any field: ~2,106 gas (one storage read + a couple of bit ops).
Why Mask, Not Cast?
You might see an alternative extraction pattern:
// Cast-based extraction
function getMakerIndex(uint256 packed) internal pure returns (uint32) {
return uint32(packed >> 96);
}
This works because uint32(someUint256) in Solidity takes the lowest 32 bits and discards everything above. After shifting right by 96, the maker index is in the lowest 32 bits, so the cast isolates it correctly.
But there's a subtlety. In Solidity 0.8+, explicit downcasts silently truncate. They do not revert on overflow. This is different from arithmetic operations (+, -, *, /), which do revert on overflow in 0.8+. The Solidity docs spell it out:
uint32 a = 0x12345678;
uint16 b = uint16(a); // b = 0x5678, higher bits silently gone
If the packing was done incorrectly and bits leaked outside the expected 32-bit range, the cast silently returns the wrong value. The mask approach (& MAKER_MASK) does the same truncation, but you're being explicit about which bits you want. No reliance on implicit truncation behavior.
Production protocols (1inch, Uniswap, 0x) use shift-and-mask. OpenZeppelin's SafeCast exists specifically because silent truncation is a known footgun.
Writing a Field: Clear Then Set
Reading is straightforward. Writing is where corruption happens.
To update a single field without touching the others, you need to:
- Clear the old bits in that field's range
- Set the new value into those bits
function setMakerIndex(uint256 packed, uint256 newMaker) internal pure returns (uint256) {
// Step 1: clear the maker bits (invert the mask, AND to zero them)
uint256 cleared = packed & ~(MAKER_MASK << MAKER_OFFSET);
// Step 2: shift new value into position and OR it in
return cleared | ((newMaker & MAKER_MASK) << MAKER_OFFSET);
}
Breaking it down:
MAKER_MASK << 96:
[000...000][11111111111111111111111111111111][000...000]
↑ 32 ones at bits 127-96
~(MAKER_MASK << 96):
[111...111][00000000000000000000000000000000][111...111]
↑ 32 zeros at bits 127-96, everything else ones
packed & ~(MAKER_MASK << 96):
[price preserved][amount preserved][000...zeroed...000][flags preserved][empty preserved]
↑ maker field cleared
(newMaker & MAKER_MASK) << 96:
[000...000][newMaker in bits 127-96][000...000]
cleared | shifted:
[price preserved][amount preserved][newMaker][flags preserved][empty preserved]
The newMaker & MAKER_MASK on the input is a safety check. It ensures the new value doesn't exceed 32 bits. Without it, a value like 2^33 would overflow into the amount field above.
The Corruption Bug
Skip the clear step and you get silent data corruption:
// WRONG: doesn't clear old bits first
function setMakerIndexBroken(uint256 packed, uint256 newMaker) internal pure returns (uint256) {
return packed | ((newMaker & MAKER_MASK) << MAKER_OFFSET);
}
OR-ing new bits onto old bits doesn't replace them. If the old maker was 0xFFFF0000 and the new maker is 0x0000FFFF, the result is 0xFFFFFFFF, not 0x0000FFFF. You need to zero the field first.
Skip the input mask and an oversized value corrupts neighboring fields:
// WRONG: doesn't mask the input
function setMakerIndexAlsoBroken(uint256 packed, uint256 newMaker) internal pure returns (uint256) {
uint256 cleared = packed & ~(MAKER_MASK << MAKER_OFFSET);
return cleared | (newMaker << MAKER_OFFSET); // newMaker could be > 32 bits
}
If newMaker is 0x1FFFFFFFF (33 bits), shifting left by 96 pushes that extra bit into the amount field. The amount silently changes and nobody gets an error.
Checking Individual Flags
The 8 flag bits (95 to 88) each represent a boolean. To check a specific flag:
uint256 private constant FLAG_0 = 1 << 88; // bit 88
uint256 private constant FLAG_1 = 1 << 89; // bit 89
uint256 private constant FLAG_2 = 1 << 90; // bit 90
// ... up to FLAG_7 = 1 << 95
function hasFlag(uint256 packed, uint256 flag) internal pure returns (bool) {
return (packed & flag) != 0;
}
function setFlag(uint256 packed, uint256 flag) internal pure returns (uint256) {
return packed | flag;
}
function clearFlag(uint256 packed, uint256 flag) internal pure returns (uint256) {
return packed & ~flag;
}
No shifting needed for single-bit checks. AND with the flag constant tells you if that bit is set. OR sets it. AND with the inverted flag clears it.
This is how 1inch defines order flags in their MakerTraitsLib:
uint256 private constant _NO_PARTIAL_FILLS_FLAG = 1 << 255;
uint256 private constant _ALLOW_MULTIPLE_FILLS_FLAG = 1 << 254;
uint256 private constant _PRE_INTERACTION_CALL_FLAG = 1 << 252;
Each flag is a named constant at a specific bit position. Reading any flag is a single bitwise AND.
Packing From Scratch
To build a packed value from individual fields:
function pack(
uint64 price,
uint64 amount,
uint32 makerIndex,
uint8 flags
) internal pure returns (uint256) {
return (uint256(price) << PRICE_OFFSET)
| (uint256(amount) << AMOUNT_OFFSET)
| (uint256(makerIndex) << MAKER_OFFSET)
| (uint256(flags) << FLAGS_OFFSET);
}
Each value is widened to uint256, shifted into its bit range, then OR-ed together. Since each field occupies a different range, the OR combines them without overlap.
The upcast to uint256 is required before shifting. In Solidity, uint32(x) << 96 shifts a 32-bit value left by 96 bits, but the result is still a uint32, which only has 32 bits of space. The shift would overflow. Casting to uint256 first gives room for the shift.
Why Not abi.encodePacked?
You might wonder: Solidity has abi.encodePacked(), which concatenates values using their minimum byte width. Can you use it for packing?
bytes memory packed = abi.encodePacked(price, amount, makerIndex, flags);
// Produces 21 bytes: [8][8][4][1]
This produces a bytes array, not a uint256. To use it as a packed storage value, you'd need to convert through bytes32:
uint256 packedValue = uint256(bytes32(abi.encodePacked(price, amount, makerIndex, flags)));
This works (the 21 bytes left-align into the bytes32, matching the high-to-low layout), but it's wasteful. abi.encodePacked allocates memory, copies bytes, and produces a dynamic array. The manual shift-and-OR approach operates entirely in the stack. No memory allocation, no copying.
The Hash Collision Problem
abi.encodePacked has a known collision vulnerability when used with dynamic types (string, bytes, dynamic arrays). The Solidity docs state this directly:
// These produce identical output
abi.encodePacked("ab", "c") // → 0x616263
abi.encodePacked("a", "bc") // → 0x616263
Dynamic types have no length prefix in packed mode, so boundaries are ambiguous. If you hash the result for signature verification, an attacker can rearrange the inputs to produce the same hash and forge a valid signature.
The fix is abi.encode, which pads every value to 32 bytes, making boundaries unambiguous. Or use only fixed-size types, where each value always occupies exactly its defined byte width and no collision is possible.
For the fixed-size packing scenario in this post (uint64, uint32, uint8), this collision does not apply. Every field has a fixed byte width. There's no ambiguity. But abi.encodePacked is still the wrong tool for constructing packed uint256 values because of the unnecessary memory overhead.
Where the Gas Savings Come From
The savings aren't about storage slots. Solidity's compiler auto-packs small types into a single slot anyway. The savings come from calldata and memory.
Calldata
ABI encoding pads every struct field to 32 bytes. Passing an order as a struct with four fields costs 128 bytes of calldata:
price: 32 bytes (24 bytes padding + 8 bytes value)
amount: 32 bytes (24 bytes padding + 8 bytes value)
makerIndex: 32 bytes (28 bytes padding + 4 bytes value)
flags: 32 bytes (31 bytes padding + 1 byte value)
Total: 128 bytes
Passing the same data as a single uint256 costs 32 bytes. That's a 4x reduction.
On L1, calldata costs 16 gas per nonzero byte (EIP-2028). On L2s, calldata is the dominant cost since it gets posted to L1 as data availability. Fewer bytes of calldata means cheaper transactions.
Memory
Structs in memory also pad each field to 32 bytes. A four-field struct occupies 128 bytes of memory. A uint256 occupies 32. Memory expansion costs scale quadratically, so keeping data compact matters in functions that process many orders.
Bit operations are nearly free
SHL, SHR, AND, and OR each cost 3 gas (EIP-145). Extracting a field from a packed value costs two bit ops (shift + mask) = 6 gas. Compared to 2,100 gas for a cold SLOAD or 16 gas per calldata byte, the extraction overhead is negligible.
Mistakes That Corrupt Data
Off-by-one in shift amounts
Shift by 95 instead of 96 and the extracted maker index includes one bit from the flags field. The value is wrong, and there's no error or revert. The contract silently processes a corrupted value.
Always define offsets as named constants and derive them from field widths:
uint256 private constant FLAGS_WIDTH = 8;
uint256 private constant MAKER_WIDTH = 32;
uint256 private constant AMOUNT_WIDTH = 64;
uint256 private constant FLAGS_OFFSET = 88; // bottom of flags
uint256 private constant MAKER_OFFSET = FLAGS_OFFSET + FLAGS_WIDTH; // 96
uint256 private constant AMOUNT_OFFSET = MAKER_OFFSET + MAKER_WIDTH; // 128
uint256 private constant PRICE_OFFSET = AMOUNT_OFFSET + AMOUNT_WIDTH; // 192
Derive each offset from the previous. Change one field width and everything adjusts.
Missing input validation on writes
If a caller passes a maker index larger than type(uint32).max, it bleeds into adjacent fields. Always mask inputs before packing:
return cleared | ((newMaker & MAKER_MASK) << MAKER_OFFSET);
// ^^^^^^^^^^^^^^^^^^^^^^^^
// ensures only 32 bits survive
Forgetting to clear before write
OR-ing new bits onto old bits merges them instead of replacing. Always AND with the inverted mask first to zero the target field.
Dirty data from external sources
If the packed value comes from calldata, another contract, or assembly, don't assume the unused bits (87-0) are zero. Mask every extraction. The shift-and-mask pattern handles this correctly by design; the mask discards anything outside the target range.
Takeaways
One SLOAD, one SSTORE. Packing multiple fields into a single uint256 reduces storage operations proportionally to the number of fields.
Shift and mask, not shift and cast. (packed >> offset) & mask is explicit about which bits you're extracting. uintN(packed >> offset) relies on implicit silent truncation.
Clear before write. Zero the target bits with & ~(mask << offset), then set new bits with | (value << offset). Skipping the clear step silently corrupts data.
Mask inputs before packing. A value wider than its field bleeds into neighbors. newValue & mask ensures it fits.
Define layout as named constants. Derive offsets from field widths. No magic numbers.