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 overloadingimpl Add for U256 lets me write a + b naturally

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):

U256 byte layout: ZERO, ONE, MAX

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)
    }
}

Rust Pattern: Operator Overloading

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.

Pitfall: EVM Division Semantics

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, and U256::MAX have 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]>, and to_u64() round-trip

Run: cargo test types::u256