The U256 Type
Every value on the EVM stack is a 256-bit unsigned integer. I represent this as a newtype over [u8; 32] in big-endian byte order (most significant byte first).
Why big-endian?
The EVM specification defines stack values and storage keys in big-endian. When you PUSH32 0x00...01, byte index 0 is the most significant and byte index 31 is the least significant. Matching this convention internally means no byte-swapping is needed when reading from bytecode.
The newtype pattern
Rather than passing around raw [u8; 32] arrays, I wrap them in a struct:
#![allow(unused)] fn main() { #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] pub struct U256([u8; 32]); }
This gives me:
- Type safety — you can't accidentally pass a random byte array where a U256 is expected
- Method namespace — I can implement
U256::wrapping_add,U256::checked_div, etc. - Operator overloading —
impl Add for U256lets me writea + bnaturally
Constants
The three fundamental constants:
#![allow(unused)] fn main() { /// The zero value (additive identity). pub const ZERO: Self = Self([0u8; 32]); /// The value one (multiplicative identity). pub const ONE: Self = { let mut bytes = [0u8; 32]; bytes[31] = 1; Self(bytes) }; /// The maximum value: 2^256 - 1. pub const MAX: Self = Self([0xFF; 32]); }
In byte layout (big-endian — byte 0 is the most significant):
Wrapping arithmetic
EVM arithmetic wraps on overflow — MAX + 1 = 0. This is not a bug; it's the specification. The EVM has no concept of overflow traps or panics at the arithmetic level.
Here's a concrete example of how addition works byte by byte:
a = U256::from(255u64) → [..., 0x00, 0xFF]
b = U256::from(1u64) → [..., 0x00, 0x01]
------
byte 31: 0xFF + 0x01 = 0x100 → result[31] = 0x00, carry = 1
byte 30: 0x00 + 0x00 + 1 = 0x01 → result[30] = 0x01, carry = 0
...
result = U256::from(256u64) → [..., 0x01, 0x00]
And overflow wrapping:
a = U256::MAX → [0xFF, 0xFF, ..., 0xFF]
b = U256::ONE → [0x00, 0x00, ..., 0x01]
------
Every byte carries: 0xFF + carry → 0x00, carry = 1
Final carry is discarded.
result = U256::ZERO → [0x00, 0x00, ..., 0x00]
The implementation adds from the least significant byte (index 31) toward the most significant (index 0), propagating the carry:
#![allow(unused)] fn main() { /// Wrapping addition: (self + rhs) mod 2^256. pub fn wrapping_add(self, rhs: Self) -> Self { let mut result = [0u8; 32]; let mut carry: u16 = 0; // Add from least significant byte (index 31) to most significant (index 0). for i in (0..32).rev() { let sum = self.0[i] as u16 + rhs.0[i] as u16 + carry; result[i] = sum as u8; carry = sum >> 8; } // carry is discarded — wrapping semantics. Self(result) } }
By implementing impl std::ops::Add for U256, we can write a + b instead of a.wrapping_add(b). The + operator calls our wrapping_add under the hood. We do the same for Sub, Mul, BitAnd, BitOr, BitXor, and Not.
Division by zero
In most languages, dividing by zero is undefined or panics. In the EVM, . This is explicit in the Yellow Paper. Our checked_div method returns U256::ZERO when the divisor is zero.
The name checked_div might suggest it returns Option<U256> — but in the EVM, division by zero is defined to return zero, not an error. We name it checked_div to distinguish it from Rust's panicking / operator, but it never fails.
From conversions
I implement From<u64>, From<u128>, and From<[u8; 32]> so we can write:
#![allow(unused)] fn main() { let a = U256::from(42u64); // places 42 in the lowest bytes let b = U256::from(u128::MAX); // fills the lower 16 bytes with 0xFF let c = U256::from([0u8; 32]); // raw byte array }
The From<u64> conversion puts the 8-byte big-endian representation into bytes 24..32:
U256::from(0xDEAD_BEEFu64) → [0x00 × 28, 0xDE, 0xAD, 0xBE, 0xEF]
↑ byte 28
Exercises
- 1.1 — Verify that
U256::ZERO,U256::ONE, andU256::MAXhave the correct byte patterns - 1.2 — Implement addition with wrapping overflow
- 1.3 — Implement comparison (
<,>,==) - 1.4 — Implement
From<u64>,From<u128>,From<[u8; 32]>, andto_u64()round-trip
Run: cargo test types::u256