use ethrex_common::H256;
pub const JOURNAL_VERSION: u8 = 1;
pub type ReverseDiffEntry = (Vec<u8>, Option<Vec<u8>>);
pub type FlatDiff = Vec<ReverseDiffEntry>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JournalEntry {
pub block_hash: H256,
pub parent_state_root: H256,
pub account_trie_diff: FlatDiff,
pub storage_trie_diff: FlatDiff,
pub account_flat_diff: FlatDiff,
pub storage_flat_diff: FlatDiff,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum JournalDecodeError {
#[error("journal entry truncated: expected {expected} more bytes at offset {offset}")]
Truncated { offset: usize, expected: usize },
#[error("journal entry version mismatch: expected {expected}, found {found}")]
VersionMismatch { expected: u8, found: u8 },
#[error("journal entry varint overflow at offset {offset}")]
VarintOverflow { offset: usize },
#[error(
"journal entry presence byte invalid: expected 0 or 1, found {found} at offset {offset}"
)]
InvalidPresenceByte { offset: usize, found: u8 },
#[error("journal entry has {trailing} trailing bytes after offset {offset}")]
TrailingBytes { offset: usize, trailing: usize },
#[error(
"journal entry length prefix {claimed} at offset {offset} exceeds remaining bytes {remaining}"
)]
LengthExceedsRemaining {
offset: usize,
claimed: u64,
remaining: usize,
},
}
impl JournalEntry {
pub fn encode(&self) -> Vec<u8> {
let approx = 1
+ 32
+ 32
+ diff_byte_estimate(&self.account_trie_diff)
+ diff_byte_estimate(&self.storage_trie_diff)
+ diff_byte_estimate(&self.account_flat_diff)
+ diff_byte_estimate(&self.storage_flat_diff);
let mut out = Vec::with_capacity(approx);
out.push(JOURNAL_VERSION);
out.extend_from_slice(self.block_hash.as_bytes());
out.extend_from_slice(self.parent_state_root.as_bytes());
encode_flat_diff(&mut out, &self.account_trie_diff);
encode_flat_diff(&mut out, &self.storage_trie_diff);
encode_flat_diff(&mut out, &self.account_flat_diff);
encode_flat_diff(&mut out, &self.storage_flat_diff);
out
}
pub fn decode_block_hash(bytes: &[u8]) -> Result<H256, JournalDecodeError> {
let Some(&version) = bytes.first() else {
return Err(JournalDecodeError::Truncated {
offset: 0,
expected: 1,
});
};
if version != JOURNAL_VERSION {
return Err(JournalDecodeError::VersionMismatch {
expected: JOURNAL_VERSION,
found: version,
});
}
let Some(hash) = bytes.get(1..33) else {
return Err(JournalDecodeError::Truncated {
offset: 1,
expected: 32,
});
};
Ok(H256::from_slice(hash))
}
pub fn decode(bytes: &[u8]) -> Result<Self, JournalDecodeError> {
let mut cur = Cursor::new(bytes);
let version = cur.read_byte()?;
if version != JOURNAL_VERSION {
return Err(JournalDecodeError::VersionMismatch {
expected: JOURNAL_VERSION,
found: version,
});
}
let block_hash = cur.read_h256()?;
let parent_state_root = cur.read_h256()?;
let account_trie_diff = decode_flat_diff(&mut cur)?;
let storage_trie_diff = decode_flat_diff(&mut cur)?;
let account_flat_diff = decode_flat_diff(&mut cur)?;
let storage_flat_diff = decode_flat_diff(&mut cur)?;
if cur.offset != bytes.len() {
return Err(JournalDecodeError::TrailingBytes {
offset: cur.offset,
trailing: bytes.len() - cur.offset,
});
}
Ok(Self {
block_hash,
parent_state_root,
account_trie_diff,
storage_trie_diff,
account_flat_diff,
storage_flat_diff,
})
}
}
fn encode_varint(out: &mut Vec<u8>, mut value: u64) {
while value >= 0x80 {
out.push((value as u8 & 0x7f) | 0x80);
value >>= 7;
}
out.push(value as u8);
}
fn encode_flat_diff(out: &mut Vec<u8>, diff: &[ReverseDiffEntry]) {
encode_varint(out, diff.len() as u64);
for (path, value) in diff {
encode_varint(out, path.len() as u64);
out.extend_from_slice(path);
match value {
None => out.push(0),
Some(v) => {
out.push(1);
encode_varint(out, v.len() as u64);
out.extend_from_slice(v);
}
}
}
}
fn varint_len(value: u64) -> usize {
let bits = 64 - value.leading_zeros() as usize;
bits.div_ceil(7).max(1)
}
fn diff_byte_estimate(diff: &[ReverseDiffEntry]) -> usize {
diff.iter()
.map(|(p, v)| {
varint_len(p.len() as u64)
+ p.len()
+ 1
+ v.as_ref()
.map_or(0, |v| varint_len(v.len() as u64) + v.len())
})
.sum::<usize>()
+ varint_len(diff.len() as u64)
}
struct Cursor<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> Cursor<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}
fn read_byte(&mut self) -> Result<u8, JournalDecodeError> {
if self.offset >= self.bytes.len() {
return Err(JournalDecodeError::Truncated {
offset: self.offset,
expected: 1,
});
}
let b = self.bytes[self.offset];
self.offset += 1;
Ok(b)
}
fn read_slice(&mut self, n: usize) -> Result<&'a [u8], JournalDecodeError> {
let remaining = self.bytes.len().saturating_sub(self.offset);
if remaining < n {
return Err(JournalDecodeError::Truncated {
offset: self.offset,
expected: n,
});
}
let s = &self.bytes[self.offset..self.offset + n];
self.offset += n;
Ok(s)
}
fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.offset)
}
fn read_h256(&mut self) -> Result<H256, JournalDecodeError> {
let s = self.read_slice(32)?;
Ok(H256::from_slice(s))
}
fn read_varint(&mut self) -> Result<u64, JournalDecodeError> {
let mut result: u64 = 0;
let mut shift: u32 = 0;
loop {
let b = self.read_byte()?;
if shift >= 64 {
return Err(JournalDecodeError::VarintOverflow {
offset: self.offset - 1,
});
}
if shift == 63 && (b & 0x7e != 0 || b & 0x80 != 0) {
return Err(JournalDecodeError::VarintOverflow {
offset: self.offset - 1,
});
}
result |= ((b & 0x7f) as u64) << shift;
if b & 0x80 == 0 {
return Ok(result);
}
shift += 7;
}
}
}
const MIN_ENTRY_BYTES: usize = 2;
fn decode_flat_diff(cur: &mut Cursor<'_>) -> Result<FlatDiff, JournalDecodeError> {
let count_offset = cur.offset;
let count_u64 = cur.read_varint()?;
let remaining = cur.remaining();
let max_possible = remaining / MIN_ENTRY_BYTES;
if count_u64 as usize > max_possible {
return Err(JournalDecodeError::LengthExceedsRemaining {
offset: count_offset,
claimed: count_u64,
remaining,
});
}
let count = count_u64 as usize;
let mut out = Vec::with_capacity(count);
for _ in 0..count {
let path_len_offset = cur.offset;
let path_len_u64 = cur.read_varint()?;
let remaining = cur.remaining();
if path_len_u64 > remaining as u64 {
return Err(JournalDecodeError::LengthExceedsRemaining {
offset: path_len_offset,
claimed: path_len_u64,
remaining,
});
}
let path_len = path_len_u64 as usize;
let path = cur.read_slice(path_len)?.to_vec();
let presence_offset = cur.offset;
let presence = cur.read_byte()?;
let value = match presence {
0 => None,
1 => {
let value_len_offset = cur.offset;
let value_len_u64 = cur.read_varint()?;
let remaining = cur.remaining();
if value_len_u64 > remaining as u64 {
return Err(JournalDecodeError::LengthExceedsRemaining {
offset: value_len_offset,
claimed: value_len_u64,
remaining,
});
}
let value_len = value_len_u64 as usize;
Some(cur.read_slice(value_len)?.to_vec())
}
other => {
return Err(JournalDecodeError::InvalidPresenceByte {
offset: presence_offset,
found: other,
});
}
};
out.push((path, value));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn h(b: u8) -> H256 {
H256::repeat_byte(b)
}
fn round_trip(entry: &JournalEntry) {
let bytes = entry.encode();
let decoded = JournalEntry::decode(&bytes).unwrap();
assert_eq!(&decoded, entry);
}
#[test]
fn empty_entry_round_trips() {
let entry = JournalEntry {
block_hash: h(0xaa),
parent_state_root: h(0xbb),
account_trie_diff: vec![],
storage_trie_diff: vec![],
account_flat_diff: vec![],
storage_flat_diff: vec![],
};
round_trip(&entry);
assert_eq!(entry.encode().len(), 69);
}
#[test]
fn typical_entry_round_trips() {
let entry = JournalEntry {
block_hash: h(0x11),
parent_state_root: h(0x22),
account_trie_diff: vec![
(vec![0x00, 0x01], Some(vec![0xde, 0xad, 0xbe, 0xef])),
(vec![0x02], None),
],
storage_trie_diff: vec![(vec![0x0a; 67], Some(vec![0xff])), (vec![0x0b; 68], None)],
account_flat_diff: vec![(vec![0xaa; 65], Some(vec![0x01, 0x02, 0x03]))],
storage_flat_diff: vec![(vec![0xbb; 131], None)],
};
round_trip(&entry);
}
#[test]
fn entry_with_only_absences_round_trips() {
let entry = JournalEntry {
block_hash: h(0x55),
parent_state_root: h(0x66),
account_trie_diff: vec![(vec![0x00], None), (vec![0x01], None), (vec![0x02], None)],
storage_trie_diff: vec![],
account_flat_diff: vec![(vec![0xaa; 32], None)],
storage_flat_diff: vec![],
};
round_trip(&entry);
}
#[test]
fn large_entry_round_trips() {
let mut account_trie_diff = Vec::with_capacity(10_000);
for i in 0u32..10_000 {
let path = i.to_be_bytes().to_vec();
let value = if i % 7 == 0 {
None
} else {
Some(vec![(i & 0xff) as u8; (i % 200) as usize])
};
account_trie_diff.push((path, value));
}
let entry = JournalEntry {
block_hash: h(0xee),
parent_state_root: h(0xff),
account_trie_diff,
storage_trie_diff: vec![],
account_flat_diff: vec![],
storage_flat_diff: vec![],
};
round_trip(&entry);
}
#[test]
fn rejects_unknown_version() {
let mut bytes = vec![0xff];
bytes.extend_from_slice(&[0; 32]);
bytes.extend_from_slice(&[0; 32]);
bytes.extend_from_slice(&[0, 0, 0, 0]);
let err = JournalEntry::decode(&bytes).unwrap_err();
assert_eq!(
err,
JournalDecodeError::VersionMismatch {
expected: JOURNAL_VERSION,
found: 0xff,
}
);
}
#[test]
fn rejects_truncated_input() {
let entry = JournalEntry {
block_hash: h(0x77),
parent_state_root: h(0x88),
account_trie_diff: vec![(vec![0x00], Some(vec![0xff]))],
storage_trie_diff: vec![],
account_flat_diff: vec![],
storage_flat_diff: vec![],
};
let bytes = entry.encode();
let err = JournalEntry::decode(&bytes[..bytes.len() - 1]).unwrap_err();
assert!(matches!(err, JournalDecodeError::Truncated { .. }));
}
#[test]
fn rejects_invalid_presence_byte() {
let mut bytes = Vec::new();
bytes.push(JOURNAL_VERSION);
bytes.extend_from_slice(&[0; 32]);
bytes.extend_from_slice(&[0; 32]);
bytes.push(1); bytes.push(1); bytes.push(0xab); bytes.push(2); let err = JournalEntry::decode(&bytes).unwrap_err();
assert!(matches!(
err,
JournalDecodeError::InvalidPresenceByte { found: 2, .. }
));
}
#[test]
fn varint_round_trip() {
for &v in &[0u64, 1, 127, 128, 16_383, 16_384, u32::MAX as u64, u64::MAX] {
let mut buf = Vec::new();
encode_varint(&mut buf, v);
let mut cur = Cursor::new(&buf);
assert_eq!(cur.read_varint().unwrap(), v);
}
}
#[test]
fn rejects_varint_with_truncating_10th_byte() {
let mut buf = vec![0x80; 9];
buf.push(0x7e);
let mut cur = Cursor::new(&buf);
let err = cur.read_varint().unwrap_err();
assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
}
#[test]
fn rejects_varint_with_11th_byte() {
let mut buf = vec![0x80; 10];
buf.push(0x00);
let mut cur = Cursor::new(&buf);
let err = cur.read_varint().unwrap_err();
assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
}
#[test]
fn rejects_varint_with_continuation_at_byte_10() {
let mut buf = vec![0x80; 9];
buf.push(0x81); let mut cur = Cursor::new(&buf);
let err = cur.read_varint().unwrap_err();
assert!(matches!(err, JournalDecodeError::VarintOverflow { .. }));
}
#[test]
fn u64_max_round_trips_after_tighter_decoder() {
let mut buf = Vec::new();
encode_varint(&mut buf, u64::MAX);
assert_eq!(buf.len(), 10);
let mut cur = Cursor::new(&buf);
assert_eq!(cur.read_varint().unwrap(), u64::MAX);
}
#[test]
fn rejects_oom_via_malformed_count() {
let mut bytes = vec![JOURNAL_VERSION];
bytes.extend_from_slice(&[0; 32]);
bytes.extend_from_slice(&[0; 32]);
encode_varint(&mut bytes, u64::MAX);
let err = JournalEntry::decode(&bytes).unwrap_err();
assert!(
matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
"expected LengthExceedsRemaining, got {err:?}"
);
}
#[test]
fn rejects_oom_via_malformed_path_len() {
let mut bytes = vec![JOURNAL_VERSION];
bytes.extend_from_slice(&[0; 32]);
bytes.extend_from_slice(&[0; 32]);
encode_varint(&mut bytes, 1); encode_varint(&mut bytes, u64::MAX); let err = JournalEntry::decode(&bytes).unwrap_err();
assert!(
matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
"expected LengthExceedsRemaining, got {err:?}"
);
}
#[test]
fn rejects_oom_via_malformed_value_len() {
let mut bytes = vec![JOURNAL_VERSION];
bytes.extend_from_slice(&[0; 32]);
bytes.extend_from_slice(&[0; 32]);
encode_varint(&mut bytes, 1); encode_varint(&mut bytes, 1); bytes.push(0xaa); bytes.push(1); encode_varint(&mut bytes, u64::MAX); let err = JournalEntry::decode(&bytes).unwrap_err();
assert!(
matches!(err, JournalDecodeError::LengthExceedsRemaining { .. }),
"expected LengthExceedsRemaining, got {err:?}"
);
}
#[test]
fn rejects_trailing_bytes() {
let entry = JournalEntry {
block_hash: h(0xaa),
parent_state_root: h(0xbb),
account_trie_diff: vec![],
storage_trie_diff: vec![],
account_flat_diff: vec![],
storage_flat_diff: vec![],
};
let mut bytes = entry.encode();
bytes.push(0xff); let err = JournalEntry::decode(&bytes).unwrap_err();
match err {
JournalDecodeError::TrailingBytes { trailing, .. } => assert_eq!(trailing, 1),
other => panic!("expected TrailingBytes, got {other:?}"),
}
}
#[test]
fn proptest_encode_decode_round_trip() {
use proptest::collection::vec;
use proptest::prelude::*;
let entry_strategy = (
any::<[u8; 32]>(),
any::<[u8; 32]>(),
vec(flat_diff_entry(), 0..16),
vec(flat_diff_entry(), 0..16),
vec(flat_diff_entry(), 0..16),
vec(flat_diff_entry(), 0..16),
)
.prop_map(|(bh, psr, a, b, c, d)| JournalEntry {
block_hash: H256::from(bh),
parent_state_root: H256::from(psr),
account_trie_diff: a,
storage_trie_diff: b,
account_flat_diff: c,
storage_flat_diff: d,
});
proptest!(|(entry in entry_strategy)| {
let bytes = entry.encode();
let decoded = JournalEntry::decode(&bytes).expect("encoded entry must decode");
prop_assert_eq!(decoded, entry);
});
}
#[test]
fn proptest_decoder_never_panics_on_arbitrary_bytes() {
use proptest::collection::vec;
use proptest::prelude::*;
proptest!(|(bytes in vec(any::<u8>(), 0..1024))| {
let _ = JournalEntry::decode(&bytes);
});
}
#[test]
fn proptest_single_byte_mutation_never_silently_accepted() {
use proptest::prelude::*;
let entry = JournalEntry {
block_hash: h(0x42),
parent_state_root: h(0x43),
account_trie_diff: vec![(vec![0x01, 0x02], Some(vec![0xaa, 0xbb]))],
storage_trie_diff: vec![(vec![0x0a; 67], None)],
account_flat_diff: vec![(vec![0xcc; 65], Some(vec![0xdd]))],
storage_flat_diff: vec![],
};
let baseline = entry.encode();
proptest!(|(idx in 0..baseline.len(), bit in 0u8..8)| {
let mut mutated = baseline.clone();
mutated[idx] ^= 1 << bit;
match JournalEntry::decode(&mutated) {
Err(_) => {}
Ok(decoded) => prop_assert_ne!(decoded, entry.clone()),
}
});
}
fn flat_diff_entry() -> impl proptest::strategy::Strategy<Value = ReverseDiffEntry> {
use proptest::collection::vec;
use proptest::prelude::*;
(
vec(any::<u8>(), 0..200),
proptest::option::of(vec(any::<u8>(), 0..200)),
)
}
#[test]
fn diff_byte_estimate_handles_large_lengths() {
let diff: Vec<ReverseDiffEntry> = vec![
(vec![0xaa; 200], Some(vec![0xbb; 300])), (vec![0xcc; 50], Some(vec![0xdd; 50])), ];
let mut buf = Vec::new();
encode_flat_diff(&mut buf, &diff);
assert_eq!(
diff_byte_estimate(&diff),
buf.len(),
"estimate must equal actual encoded length so encode() avoids realloc"
);
}
#[test]
fn decode_block_hash_reads_the_hash_without_the_diffs() {
let entry = JournalEntry {
block_hash: H256::repeat_byte(0xab),
parent_state_root: H256::repeat_byte(0xcd),
account_trie_diff: vec![(vec![1, 2, 3], Some(vec![4]))],
storage_trie_diff: vec![],
account_flat_diff: vec![(vec![9], None)],
storage_flat_diff: vec![],
};
let bytes = entry.encode();
assert_eq!(
JournalEntry::decode_block_hash(&bytes).unwrap(),
H256::repeat_byte(0xab)
);
assert_eq!(
JournalEntry::decode_block_hash(&bytes[..40]).unwrap(),
H256::repeat_byte(0xab)
);
let mut foreign = bytes.clone();
foreign[0] = JOURNAL_VERSION + 1;
assert!(matches!(
JournalEntry::decode_block_hash(&foreign),
Err(JournalDecodeError::VersionMismatch { .. })
));
assert!(matches!(
JournalEntry::decode_block_hash(&bytes[..20]),
Err(JournalDecodeError::Truncated { .. })
));
}
}