--- eip: 8022 title: Calldata run-length encoding scheme description: A run-length encoding scheme for Ethereum calldata author: Vectorized (@Vectorized) discussions-to: https://ethereum-magicians.org/t/eip-8022-calldata-run-length-encoding-scheme/25483 status: Draft type: Standards Track category: Core created: 2025-09-16 --- ## Abstract A simple, bounded run-length encoding scheme optimized for Ethereum calldata. ## Motivation At the time of writing, Ethereum calldata often contains long sequences of zero bytes. This arises primarily from the Solidity ABI specification, which pads values to 32-byte words, and is further reinforced by common data patterns such as sparse vectors, Merkle proofs, and cryptographic arguments. This proposal standardizes a simple, deterministic run-length encoding (RLE) scheme tailored for calldata. Establishing a canonical encoding ensures that future work can rely on a common reference implementation. Potential applications include, but are not limited to: - Transaction compression schemes. - Automatic packing of calldata and storage values. - Precompiles to efficiently compress and decompress data. By providing a minimal and bounded codec, this standard enables experimentation and lays the groundwork for more efficient use of Ethereum bandwidth and storage. ## Specification ### Literals Any byte other than 0x00 is emitted directly as a literal. Literals may appear anywhere except the control position of a run marker. ### Runs A run is encoded as two bytes: (0x00, CONTROL). CONTROL is interpreted as follows: - Bit 7 (MSB): - `0` → run of `0x00` bytes. - `1` → run of `0xFF` bytes. - Bits 0–6: `(len − 1)`, where `len` is the run length. Valid ranges: - Zero-runs: 1 ≤ len ≤ 128. - FF-runs: 1 ≤ len ≤ 32. ### First-4-bytes negation After encoding, the first 4 bytes of the compressed stream are bitwise XORed with 0xFF. On decoding, the first 4 compressed bytes are un-negated before interpretation. This rule ensures compressed calldata naturally routes to the fallback function. ### Canonical encoding - Zero-runs Every sequence of 1 or more consecutive `0x00` bytes MUST be encoded as runs, using the longest possible run first, capped at 128. (Chunk as `128, 128, …, remainder`). - FF-runs Every sequence of 1 or more consecutive `0XFF` bytes MUST be encoded as runs, using the longest possible run first, capped at 32. (Chunk as `32, 32, …, remainder`). Everything else MUST be encoded as literals. ### Invalid encodings Invalid encodings includes the following cases (exhaustively): - A run marker `0x00` appears as the final byte of the stream (no CONTROL follows). - An FF-run encodes a run length greater than 32. A strict decoder MUST fail with an error for invalid encodings. A forgiving decoder MAY allow undefined behavior for invalid encodings. ## Rationale This RLE scheme was chosen for its simplicity, predictability, and bounded runtime: - **O(n) worst-case cost** (compute and memory), with expansion bounds that can be inferred in constant time. No risk of “zip bomb” style exploits. - **Streaming-friendly**: can be encoded/decoded in a single pass without allocating large buffers. - **Not CPU-bound**: processing will be dominated by network, memory, and storage bandwidth, which are the true bottlenecks in calldata-heavy systems. ### Bitwise negation of the first 4 bytes The first 4 bytes of calldata are used as function selectors. By negating these 4 bytes, compressed calldata will naturally route to the contract’s `fallback` function. The fallback can then decompress and forward the call back to the intended function via `DELEGATECALL`. This adds only a minuscule O(1) compute cost, and can be left in place even for applications that do not require this feature. A single canonical format improves code reuse and avoids confusion. ### Up to 128 consecutive `0x00` bytes The EVM provides a convenient way to zeroize memory regions by copying out-of-bounds calldata or code. On the processor level, most CPUs also have dedicated micro-ops for zeroing memory. For this RLE scheme, runs of up to 128 consecutive `0x00` bytes are supported. In the control byte, 1 bit denotes the run type (`0x00` vs `0xFF`), and the remaining 7 bits encode the run length (`0b1111111 + 1 = 128`). ### Up to 32 consecutive `0xFF` bytes Unlike zeros, the EVM does not provide a convenient way to fill memory with `0xFF`. The most efficient method is `MSTORE`, which writes 32 bytes per operation. Therefore, the maximum run length for `0xFF` is capped at 32. We select `0xFF` as the second RLE-eligible byte because it has semantic meaning in many applications: maximum unsigned integer values are often used as sentinels or to represent "infinite approvals". ### Canonical encoding vs. minimal encodings Note that the canonical encoding is not minimal (i.e. shortest possible compressed length for every input). For `0xFF` sequences, there are two corner cases. When the length is 1, emitting a single `0xff` can yield a more minimal encoding. When the length is 2, emitting either `FF FF` or `00 81` can lead to a valid encoding. This trade-off is intentional. - Simplicity: A streaming encoder only needs two counters (for `0x00` and `0XFF`). By treating all `0xFF` sequences as runs, the encoder can operate in one pass without backtracking or branching on edge cases. - Determinism: A canonical rule guarantees unique output, avoiding ambiguity. - Negligible difference in compression ratio: In realistic calldata, the occurrence of single byte `0xFF` sequences is low enough to be irrelevant compared to the simplicity gains. ### No other RLE-eligible bytes Allowing arbitrary non-zero bytes to be RLE-compressed would complicate the encoding and reduce its expected efficiency. Limiting RLE to `0x00` and `0xFF` strikes a balance between compression gains and implementation simplicity. ### Rationale for an EIP While the codec itself could be an ERC-level convention, the intended applications include precompiles and new transaction types, both of which require changes clients. ERCs cannot define such changes, and RIPs are limited to rollups. As an EIP, this specification provides a stable reference for both L1 and L2, with a path to protocol integration. ## Backwards Compatibility This is a new standalone encoding scheme. ## Test Cases | Input (hex) | Canonical compressed (pre-head-negation) | Canonical compressed (after head-negation) | |-----------------------------|------------------------------------------|--------------------------------------------| | `""` | `""` | `""` | | `01 02 03` | `01 02 03` | `FE FD FC` | | `00` | `00 00` | `FF FF` | | `00×128` | `00 7F` | `FF 80` | | `00×129` | `00 7F 00 00` | `FF 80 FF FF` | | `FF` | `00 80` | `FF 7F` | | `FF FF` | `00 81` | `FF 7E` | | `FF×33` | `00 9F 00 80` | `FF 60 FF 7F` | | `AA FF FF 00 00 01` | `AA 00 81 00 01 01` | `55 FF 7E FF FE FE` | Note: `×33` means repeated 33 times. ## Reference Implementation ```rust #![cfg_attr(not(feature = "std"), no_std)] #![cfg_attr(docsrs, feature(doc_cfg))] extern crate alloc; use alloc::vec::Vec; pub const MAX_ZERO_RUN: usize = 128; // 0x00 runs: 1..=128 pub const MAX_FF_RUN: usize = 32; // 0xFF runs: 1..=32 /// Canonical decoding errors (exhaustive by construction). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Error { /// The compressed stream ended right after a run marker (0x00), /// i.e. there was no CONTROL byte following it. RunMarkerWithoutControl, /// The CONTROL byte denotes an FF-run length > 32. /// (Decoded as len = (control & 0x7F) + 1; this error can ONLY occur for FF runs.) InvalidRunLength { len: usize }, } /// Compresses `input` and XOR-negates the first 4 bytes of the *compressed* stream. pub fn compress(input: &[u8]) -> Vec { let mut out = Vec::with_capacity(input.len()); // lower bound; worst case ~2× let mut zero = 0usize; let mut ff = 0usize; #[inline] fn emit_run(out: &mut Vec, is_ff: bool, n: usize) { debug_assert!(n >= 1); debug_assert!((!is_ff && n <= MAX_ZERO_RUN) || (is_ff && n <= MAX_FF_RUN)); let mut ctrl = ((n as u8) - 1) & 0x7f; if is_ff { ctrl |= 0x80; } out.push(0x00); out.push(ctrl); } #[inline] fn flush(out: &mut Vec, zero: &mut usize, ff: &mut usize) { if *ff != 0 { emit_run(out, true, *ff); *ff = 0; } if *zero != 0 { emit_run(out, false, *zero); *zero = 0; } } for &b in input { match b { 0x00 => { if ff != 0 { emit_run(&mut out, true, ff); ff = 0; } zero += 1; if zero == MAX_ZERO_RUN { emit_run(&mut out, false, MAX_ZERO_RUN); zero = 0; } } 0xFF => { if zero != 0 { emit_run(&mut out, false, zero); zero = 0; } ff += 1; if ff == MAX_FF_RUN { emit_run(&mut out, true, MAX_FF_RUN); ff = 0; } } _ => { flush(&mut out, &mut zero, &mut ff); out.push(b); } } } flush(&mut out, &mut zero, &mut ff); // Negate first 4 bytes of *compressed* stream. let lim = core::cmp::min(4, out.len()); for i in 0..lim { out[i] ^= 0xFF; } out } /// Decompresses `comp` produced by `compress`. /// Errors: /// - RunMarkerWithoutControl (0x00 as final byte) /// - InvalidRunLength{len} (FF-run with len > 32) pub fn decompress(comp: &[u8]) -> Result, Error> { let mut out = Vec::with_capacity(comp.len()); // conservative lower bound let mut i = 0usize; #[inline] fn read_unneg(comp: &[u8], i: &mut usize) -> u8 { let mut b = comp[*i]; if *i < 4 { b ^= 0xFF; } *i += 1; b } while i < comp.len() { let b = read_unneg(comp, &mut i); if b != 0x00 { out.push(b); continue; } if i >= comp.len() { return Err(Error::RunMarkerWithoutControl); } let c = read_unneg(comp, &mut i); let is_ff = (c & 0x80) != 0; let len = (c & 0x7F) as usize + 1; if is_ff && len > MAX_FF_RUN { return Err(Error::InvalidRunLength { len }); } let fill = if is_ff { 0xFF } else { 0x00 }; let base = out.len(); out.resize(base + len, fill); } Ok(out) } #[cfg(test)] mod tests { use super::*; use alloc::vec; fn rt(v: &[u8]) { let c = compress(v); let d = decompress(&c).unwrap(); assert_eq!(v, d.as_slice(), "in:{:x?} cmp:{:x?} dec:{:x?}", v, c, d); } #[test] fn empty() { rt(&[]); } #[test] fn literals() { rt(&[1,2,3,4,5]); } #[test] fn zeros() { rt(&vec![0x00; 1]); rt(&vec![0x00; 127]); rt(&vec![0x00; 128]); rt(&vec![0x00; 129]); // 128 + 1 } #[test] fn ffs() { rt(&vec![0xFF; 1]); rt(&vec![0xFF; 31]); rt(&vec![0xFF; 32]); rt(&vec![0xFF; 33]); // 32 + 1 } #[test] fn mixed() { rt(&[0,0,0,0, 42, 0xFF,0xFF,0xFF, 1,2,3, 0, 0xFF, 0, 0xAA,0xBB, 0, 0xFF]); } #[test] fn err_run_marker_without_control() { let mut c = vec![0x00]; for i in 0..c.len().min(4) { c[i] ^= 0xFF; } assert_eq!(decompress(&c), Err(Error::RunMarkerWithoutControl)); } #[test] fn err_invalid_ff_run_len() { let mut c = vec![0x00, 0xA0]; // ff=1, len-1=32 => len=33 for i in 0..c.len().min(4) { c[i] ^= 0xFF; } assert_eq!(decompress(&c), Err(Error::InvalidRunLength { len: 33 })); } } ``` ## Security Considerations - **DoS resistance:** Encoding and decoding are O(n) with trivial memory requirements. There are no "zip bomb" style inputs; expansion is bounded to ≤ 2× the input length. - **Consensus safety:** All clients must implement identical strict decoding rules; any divergence would risk consensus splits. - **Forgiving mode:** Forgiving decoders may be useful in tooling, but MUST NOT be used in consensus-critical contexts. - **Malleability:** Multiple valid (non-canonical) encodings exist for a given input; canonical encoding ensures deterministic outputs where needed. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).