Skip to content

Fix: Replace raw u128 with layout-stable U128Bytes in zero-copy structs#292

Open
tom9000 wants to merge 1 commit into
openbook-dex:masterfrom
tom9000:fix/layout-key-u128
Open

Fix: Replace raw u128 with layout-stable U128Bytes in zero-copy structs#292
tom9000 wants to merge 1 commit into
openbook-dex:masterfrom
tom9000:fix/layout-key-u128

Conversation

@tom9000
Copy link
Copy Markdown

@tom9000 tom9000 commented May 3, 2026

Summary

Replace all raw u128 fields in zero-copy persisted account structs with
U128Bytes, a #[repr(C)] wrapper over [u8; 16] that guarantees stable
alignment on all platforms and Rust versions. This fixes compile failures on
modern stable Rust and aarch64 targets without changing on-chain layout,
protocol behavior, or account compatibility.

Closes #291 and related #281

Previous Changes

PR #260 ("Ensure all ordertree nodes share an alignment") by @ckamm added
force_align: u64 fields to FreeNode and AnyNode to ensure all node types
share the same alignment. That fix addressed the alignment matching between
node variants, but did not address the underlying issue: raw u128 fields cause
platform-dependent struct inflation. This PR is the logical next step.

Problem

Native u128 alignment is not guaranteed by Rust. It changed from 8 to 16
bytes on x86_64 starting in Rust 1.78 (via rust-lang/rust#116672,
adopting LLVM 18's data layout), and has always been 16 on aarch64.
When alignment is 16, the compiler inserts padding that inflates zero-copy
structs beyond their expected sizes, failing the existing const_assert_eq!
size checks.

The project currently works only on one specific combination: x86_64 + Rust
1.70. Every other combination fails to compile:

error[E0512]: cannot transmute between types of different sizes
   = note: source type: `InnerNode` (768 bits)    ← 96 bytes
   = note: target type: `TypeWithoutPadding` (704 bits)  ← 88 bytes expected

Solution

The U128Bytes type

#[repr(C)]
pub struct U128Bytes {
    data: [u8; 16],
}
  • Size: 16 bytes (identical to u128)
  • Alignment: 1 byte (guaranteed stable — [u8; 16] under #[repr(C)])
  • Encoding: little-endian, matching native u128 on all Solana targets
  • Conversion: from_u128() / to_u128()#[inline(always)], zero-cost on LE targets

Affected structs

Struct Field(s) changed
InnerNode key
LeafNode key
Market fees_accrued, fees_to_referrers, maker_volume, taker_volume_wo_oo
Position maker_volume, taker_volume
OpenOrder id

Change pattern

Every change follows the same mechanical pattern:

  1. Struct field: field: u128field: U128Bytes
  2. Reads: node.keynode.key() (returns u128)
  3. Writes: constructors use U128Bytes::from_u128(value)
  4. Accumulation: field += x as u128field.wrapping_add_assign(x as u128)
  5. Log/display: fieldfield.to_u128()

No algorithm logic, control flow, or protocol semantics were changed.

Why these are the minimal necessary changes

The fix touches only the storage representation of u128 values inside
persisted structs. Specifically:

  • No new features are introduced
  • No behavior changes — all arithmetic operates on native u128 via accessors
  • No protocol changes — instruction signatures, account schemas, and IDL are unchanged
  • No account migration — byte layout is identical (same LE encoding at same offsets)
  • No dependencies addedU128Bytes uses only bytemuck and anchor_lang which are already dependencies

The nodes module was changed from mod to pub mod solely to allow
U128Bytes to be imported by market.rs and open_orders_account.rs.

Dependency note

Cargo.lock updates time from 0.3.30 to 0.3.36. This is unrelated to the
layout fix — time 0.3.30 fails to compile on Rust >= 1.78 due to a type
inference change. Version 0.3.36 is compatible with both Rust 1.70 and modern
stable, and the lockfile format remains v3 (compatible with Rust 1.70's Cargo).

Why this does not introduce regression risk

1. Byte-level compatibility

The U128Bytes encoding uses u128::to_le_bytes() / u128::from_le_bytes().
On little-endian targets (all Solana validators are x86_64-LE), this produces
the exact same byte sequence as the compiler's native u128 representation.

The struct field offsets are unchanged because U128Bytes occupies the same
16 bytes at the same position — the only difference is that the compiler no
longer inserts alignment padding before the field.

2. Algorithm preservation

All tree operations — crit-bit traversal, key comparison, XOR/leading_zeros,
insertion, removal, expiry propagation — continue to operate on native u128
values obtained via key() accessors. The accessor methods are
#[inline(always)] and compile away to direct memory reads + byte reordering
(which is a no-op on LE targets).

3. Comprehensive test coverage

The existing test suite was run in full — both the unit test subset and the
complete CI-faithful cargo test-sbf pipeline.

Testing results

CI-faithful testing (cargo test-sbf, the project's actual CI command)

Environment: x86_64 Ubuntu (OrbStack), Solana CLI 1.16.1, Rust 1.70.0

Code Toolchain Result
Unfixed (baseline) Rust 1.70 44/44 pass
Unfixed Rust stable (1.95) FAIL — layout assertions
Fix branch Rust 1.70 44/44 pass
Fix branch Rust stable (1.95) 44/44 pass

The fix produces identical test results to the unfixed baseline on the
project's CI toolchain. All 44 tests pass, including 11 integration tests
that execute the program through the Solana BPF runtime.

Extended testing (cargo test --lib, unit tests only)

Architecture Toolchain Unfixed Fix branch
x86_64 Rust 1.70 33/33 pass 33/33 pass
x86_64 Rust stable (1.95) FAIL (layout) 33/33 pass
aarch64 Rust 1.70 FAIL (layout) 33/33 pass
aarch64 Rust stable (1.95) FAIL (layout) 33/33 pass

Tests exercised

The test suite covers the full order book behavioral surface:

Tree correctness (ordertree.rs):

  • order_tree_expiry_manual — deterministic insert/remove/expiry sequences
  • order_tree_expiry_random — randomized insert/remove with invariant verification
  • verify_order_tree_invariant — crit-bit prefix correctness on every mutation
  • verify_order_tree_iteration — ascending/descending traversal ordering

BookSide behavior (bookside.rs):

  • bookside_iteration_random — combined fixed + oracle-pegged ordering
  • bookside_order_filtering — invalid/expired/peg-limited order filtering
  • bookside_remove_worst — worst-order removal

Order book operations (mod.rs):

  • book_bids_full — capacity pressure and worst-order replacement
  • book_new_order — matching, fees, maker/taker settlement
  • book_max_quote_lots — quote lot accounting

Integration tests (test-sbf, 11 additional tests):

  • Market creation, order placement, cancellation, settlement
  • Fee accrual, referrer rebates, maker/taker fee accounting
  • Oracle pegged orders, self-trade behavior, event cranking
  • Permissioned markets, delegate operations

Like-for-like comparison methodology

To ensure the layout fix is tested in isolation:

  • Both baseline and fix branch use the same updated time crate (0.3.36)
  • Both use Cargo.lock format v3 (compatible with Rust 1.70)
  • The unfixed code on stable Rust was tested with the same time update,
    confirming it still fails with layout assertions (proving the layout fix
    is the operative change)

Files changed

 14 files changed, 151 insertions(+), 75 deletions(-)

 Cargo.lock                                          | 28 +++---   (time crate update)
 programs/openbook-v2/src/state/orderbook/nodes.rs   | 80 +++++++  (U128Bytes type + struct changes)
 programs/openbook-v2/src/state/orderbook/ordertree.rs | 22 ++-  (call-site updates)
 programs/openbook-v2/src/state/orderbook/book.rs    | 12 +-   (call-site updates)
 programs/openbook-v2/src/state/orderbook/mod.rs     | 20 ++-  (pub mod + test updates)
 programs/openbook-v2/src/state/orderbook/bookside.rs|  4 +-   (call-site updates)
 programs/openbook-v2/src/state/orderbook/bookside_iterator.rs | 2 +- (call-site update)
 programs/openbook-v2/src/state/open_orders_account.rs | 31 ++- (struct + call-site updates)
 programs/openbook-v2/src/state/market.rs            |  9 +-   (struct field updates)
 programs/openbook-v2/src/instructions/create_market.rs | 8 +- (initializer updates)
 programs/openbook-v2/src/instructions/cancel_order.rs  | 2 +- (call-site update)
 programs/openbook-v2/src/instructions/settle_funds.rs  | 2 +- (call-site update)
 programs/openbook-v2/tests/cases/test.rs            |  4 +-   (test call-site updates)
 programs/openbook-v2/tests/cases/test_oracle_peg.rs |  2 +-   (test call-site update)

Native u128 alignment varies by platform and Rust version (8 bytes on
x86_64 with Rust <= 1.77, 16 bytes on x86_64 with Rust >= 1.78, and
16 bytes on aarch64 with all Rust versions). When alignment is 16,
the compiler inserts padding that inflates zero-copy struct sizes
beyond their expected values, causing compile-time assertion failures
(e.g., NODE_SIZE = 88 becomes 96).

This prevents building on any modern stable Rust toolchain or on
aarch64 targets (including Apple Silicon contributor machines).

Introduce U128Bytes, a #[repr(C)] wrapper over [u8; 16] with
guaranteed 1-byte alignment and 16-byte size on all platforms.
Replace all raw u128 fields in persisted zero-copy account structs
with U128Bytes, and provide inline accessor methods to convert
to/from native u128 for algorithm code.

The stored byte representation is identical (little-endian u128),
so existing on-chain accounts are read correctly without migration.

### New type (nodes.rs)

- Add U128Bytes: #[repr(C)] wrapper over [u8; 16]
  - from_u128() / to_u128() using to_le_bytes() / from_le_bytes()
  - wrapping_add_assign() for += patterns on accumulator fields
  - From<u128>, PartialEq<u128>, Display impls for ergonomic use
  - Compile-time assertions: size == 16, align == 1

### Struct field changes

InnerNode (nodes.rs):
- key: u128 → key: U128Bytes
- Add key(&self) -> u128 accessor method

LeafNode (nodes.rs):
- key: u128 → key: U128Bytes
- Add key(&self) -> u128 accessor method
- Update price_data() to use key() accessor

Market (market.rs):
- fees_accrued: u128 → U128Bytes
- fees_to_referrers: u128 → U128Bytes
- maker_volume: u128 → U128Bytes
- taker_volume_wo_oo: u128 → U128Bytes

Position (open_orders_account.rs):
- maker_volume: u128 → U128Bytes
- taker_volume: u128 → U128Bytes

OpenOrder (open_orders_account.rs):
- id: u128 → U128Bytes

### Call-site updates

ordertree.rs:
- All direct .key field accesses on LeafNode/InnerNode replaced with
  .key() method calls (remove_by_key, insert_leaf, verify_order_tree_
  invariant, verify_order_tree_iteration)

bookside.rs:
- worse.node.key → worse.node.key()
- order.node.key → order.node.key() (test println)

bookside_iterator.rs:
- f.1.key / o.1.key → f.1.key() / o.1.key() in rank_orders

book.rs:
- best_opposing.node.key → best_opposing.node.key() (3 occurrences)
- oo.id → oo.id.to_u128() in cancel_all_orders
- market.fees_accrued += → .wrapping_add_assign()
- market.taker_volume_wo_oo += → .wrapping_add_assign()

open_orders_account.rs:
- oo.id == order_id → oo.id.to_u128() == order_id
- pa.maker_volume += → .wrapping_add_assign()
- pa.taker_volume += → .wrapping_add_assign()
- market.maker_volume += → .wrapping_add_assign()
- market.fees_accrued += → .wrapping_add_assign()
- pa.maker_volume / pa.taker_volume in log emissions → .to_u128()
- Default impls: 0 → U128Bytes::from_u128(0)

cancel_order.rs:
- oo.id → oo.id.to_u128()

create_market.rs:
- Literal 0 initializers → U128Bytes::from_u128(0)

settle_funds.rs:
- market.fees_to_referrers += → .wrapping_add_assign()

mod.rs (orderbook):
- nodes module visibility: mod → pub mod (for U128Bytes import)
- Test code: .id → .id.to_u128(), .key → .key()
- market.fees_accrued as i64 → market.fees_accrued.to_u128() as i64

AnyNode::key() (nodes.rs):
- inner.key / leaf.key → inner.key() / leaf.key()

### Integration test updates

test.rs:
- order_id_to_cancel → order_id_to_cancel.to_u128() (2 occurrences)

test_oracle_peg.rs:
- order.id → order.id.to_u128()

### Dependency update

Cargo.lock:
- time: 0.3.30 → 0.3.36 (0.3.30 fails to compile on Rust >= 1.78
  due to a type inference change; unrelated to the layout fix but
  necessary for stable toolchain compatibility)
@tom9000
Copy link
Copy Markdown
Author

tom9000 commented May 4, 2026

Hello @mschneider @ckamm @metaproph3t I would appreciate a review of this when you get a chance, and let me know if you have any feedback.
It builds on @ckamm's alignment work in #260. There are no changes to on-chain layout, protocol behavior or account compatibility. Full test suite passes on both Rust 1.70 and on the current stable toolchain. All passes on x86_64 and also aarch64.
Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix layout compatibility: raw u128 in zero-copy structs prevents Rust toolchain upgrade

1 participant