Skip to content

Commit 5a3bf79

Browse files
authored
perf: compact merkle tree for transaction placeholder proof (#769)
## 📝 Summary Compute CL placeholder transaction proof using the compact merkle tree. ## ✅ I have completed the following steps: * [x] Run `make lint` * [x] Run `make test` * [ ] Added tests (if applicable)
1 parent fc9e037 commit 5a3bf79

File tree

5 files changed

+400
-160
lines changed

5 files changed

+400
-160
lines changed

crates/rbuilder-primitives/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,7 @@ ring = "0.17"
6565
[[bench]]
6666
name = "sha_pair"
6767
harness = false
68+
69+
[[bench]]
70+
name = "ssz_proof"
71+
harness = false
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
use alloy_primitives::Bytes;
2+
use criterion::{
3+
criterion_group, criterion_main, measurement::WallTime, BenchmarkGroup, Criterion,
4+
};
5+
use proptest::{prelude::*, strategy::ValueTree as _, test_runner::TestRunner};
6+
7+
use impls::SszTransactionProof;
8+
9+
criterion_main!(ssz_proof);
10+
criterion_group!(ssz_proof, ssz_proof_bench);
11+
12+
fn ssz_proof_bench(c: &mut Criterion) {
13+
let mut group = c.benchmark_group("ssz_proof");
14+
15+
// Start with asserting equivalence of all implementations.
16+
impls::assert_equivalence();
17+
18+
for num_txs in [100, 500, 1_000] {
19+
let target = num_txs - 1;
20+
21+
for tx_size in [128, 1_024] {
22+
let mut runner = TestRunner::deterministic();
23+
let txs = generate_test_data(&mut runner, num_txs, tx_size);
24+
25+
run_bench::<impls::VanillaSszTxProof>(&mut group, &txs, target);
26+
run_bench::<impls::VanillaBufferedSszTxProof>(&mut group, &txs, target);
27+
run_bench::<impls::CompactSszTxProof>(&mut group, &txs, target);
28+
}
29+
}
30+
}
31+
32+
fn run_bench<T: SszTransactionProof>(
33+
group: &mut BenchmarkGroup<'_, WallTime>,
34+
txs: &[Bytes],
35+
target: usize,
36+
) {
37+
let tx_size = txs.first().unwrap().0.len();
38+
let id = format!(
39+
"{} | num txs {} | tx size {} bytes",
40+
T::description(),
41+
txs.len(),
42+
tx_size
43+
);
44+
group.bench_function(id, |b| {
45+
b.iter_with_setup(
46+
|| T::default(),
47+
|mut gen| {
48+
gen.generate(txs, target);
49+
},
50+
)
51+
});
52+
}
53+
54+
fn generate_test_data(runner: &mut TestRunner, num_txs: usize, tx_size: usize) -> Vec<Bytes> {
55+
proptest::collection::vec(proptest::collection::vec(any::<u8>(), tx_size), num_txs)
56+
.new_tree(runner)
57+
.unwrap()
58+
.current()
59+
.into_iter()
60+
.map(Bytes::from)
61+
.collect::<Vec<_>>()
62+
}
63+
64+
mod impls {
65+
use super::*;
66+
use alloy_primitives::{Bytes, B256};
67+
use rbuilder_primitives::mev_boost::ssz_roots::{
68+
sha_pair, tx_ssz_leaf_root, CompactSszTransactionTree,
69+
};
70+
71+
const TREE_DEPTH: usize = 20; // log₂(MAX_TRANSACTIONS_PER_PAYLOAD)
72+
const MAX_CHUNK_COUNT: usize = 1 << TREE_DEPTH;
73+
74+
pub fn assert_equivalence() {
75+
let num_txs = 100;
76+
let proof_target = num_txs - 1;
77+
let tx_size = 1_024;
78+
let mut runner = TestRunner::deterministic();
79+
80+
let mut vanilla = VanillaSszTxProof::default();
81+
let mut vanilla_buf = VanillaBufferedSszTxProof::default();
82+
let mut compact = CompactSszTxProof::default();
83+
for _ in 0..100 {
84+
let txs = generate_test_data(&mut runner, num_txs, tx_size);
85+
let expected = vanilla.generate(&txs, proof_target);
86+
assert_eq!(expected, vanilla_buf.generate(&txs, proof_target));
87+
assert_eq!(expected, compact.generate(&txs, proof_target));
88+
}
89+
}
90+
91+
pub trait SszTransactionProof: Default {
92+
fn description() -> &'static str;
93+
94+
fn generate(&mut self, txs: &[Bytes], target: usize) -> Vec<B256>;
95+
}
96+
97+
/// === VanillaSszTransactionProof ===
98+
#[derive(Default)]
99+
pub struct VanillaSszTxProof;
100+
101+
impl SszTransactionProof for VanillaSszTxProof {
102+
fn description() -> &'static str {
103+
"vanilla"
104+
}
105+
106+
fn generate(&mut self, txs: &[Bytes], target: usize) -> Vec<B256> {
107+
vanilla_transaction_proof_ssz(txs, target, &mut Vec::new(), &mut Vec::new())
108+
}
109+
}
110+
111+
/// === VanillaBufferedSszTransactionProof ===
112+
#[derive(Default)]
113+
pub struct VanillaBufferedSszTxProof {
114+
current_buf: Vec<B256>,
115+
next_buf: Vec<B256>,
116+
}
117+
118+
impl SszTransactionProof for VanillaBufferedSszTxProof {
119+
fn description() -> &'static str {
120+
"vanilla with buffers"
121+
}
122+
123+
fn generate(&mut self, txs: &[Bytes], target: usize) -> Vec<B256> {
124+
vanilla_transaction_proof_ssz(txs, target, &mut self.current_buf, &mut self.next_buf)
125+
}
126+
}
127+
128+
fn vanilla_transaction_proof_ssz(
129+
txs: &[Bytes],
130+
target: usize,
131+
current_buf: &mut Vec<B256>,
132+
next_buf: &mut Vec<B256>,
133+
) -> Vec<B256> {
134+
current_buf.clear();
135+
for idx in 0..MAX_CHUNK_COUNT {
136+
let leaf = txs
137+
.get(idx)
138+
.map(|tx| tx_ssz_leaf_root(&tx))
139+
.unwrap_or(B256::ZERO);
140+
current_buf.insert(idx, leaf);
141+
}
142+
143+
let mut branch = Vec::new();
144+
let (current_level, next_level) = (current_buf, next_buf);
145+
let mut current_index = target;
146+
147+
for _level in 0..TREE_DEPTH {
148+
let sibling_index = current_index ^ 1;
149+
branch.push(current_level[sibling_index]);
150+
151+
next_level.clear();
152+
for i in (0..current_level.len()).step_by(2) {
153+
let left = current_level[i];
154+
let right = current_level[i + 1];
155+
next_level.push(sha_pair(&left, &right));
156+
}
157+
158+
std::mem::swap(current_level, next_level);
159+
current_index /= 2;
160+
161+
if current_level.len() == 1 {
162+
break;
163+
}
164+
}
165+
166+
branch
167+
}
168+
169+
/// === CompactSszTxProof ===
170+
#[derive(Default)]
171+
pub struct CompactSszTxProof;
172+
173+
impl SszTransactionProof for CompactSszTxProof {
174+
fn description() -> &'static str {
175+
"compact"
176+
}
177+
178+
fn generate(&mut self, txs: &[Bytes], target: usize) -> Vec<B256> {
179+
let mut leaves = Vec::with_capacity(txs.len());
180+
for tx in txs {
181+
leaves.push(tx_ssz_leaf_root(tx));
182+
}
183+
CompactSszTransactionTree::from_leaves(leaves).proof(target)
184+
}
185+
}
186+
}

crates/rbuilder-primitives/src/mev_boost/ssz_roots.rs

Lines changed: 64 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use alloy_primitives::{Address, Bytes, B256};
44
use sha2::{Digest, Sha256};
55
use ssz_types::{FixedVector, VariableList};
6+
use std::sync::LazyLock;
67
use tree_hash::TreeHash as _;
78

89
#[derive(tree_hash_derive::TreeHash)]
@@ -62,74 +63,83 @@ pub fn calculate_transactions_root_ssz(transactions: &[Bytes]) -> B256 {
6263

6364
const TREE_DEPTH: usize = 20; // log₂(MAX_TRANSACTIONS_PER_PAYLOAD)
6465

65-
const MAX_CHUNK_COUNT: usize = 1 << TREE_DEPTH;
66-
67-
/// Generate SSZ proof for target transaction.
68-
pub fn generate_transaction_proof_ssz(transactions: &[Bytes], target: usize) -> Vec<B256> {
69-
generate_transaction_proof_ssz_with_buffers(
70-
transactions,
71-
target,
72-
&mut Vec::new(),
73-
&mut Vec::new(),
74-
)
75-
}
76-
77-
/// Generate SSZ proof for target transaction with reusable buffer.
78-
pub fn generate_transaction_proof_ssz_with_buffers(
79-
transactions: &[Bytes],
80-
target: usize,
81-
current_buf: &mut Vec<B256>,
82-
next_buf: &mut Vec<B256>,
83-
) -> Vec<B256> {
84-
// Compute all leaf hashes and fill remaining slots with 0 hashes.
85-
// SSZ always pads to the maximum possible size defined by the type
86-
current_buf.clear();
87-
for idx in 0..MAX_CHUNK_COUNT {
88-
let leaf = transactions
89-
.get(idx)
90-
.map(ssz_leaf_root)
91-
.unwrap_or(B256::ZERO);
92-
current_buf.insert(idx, leaf);
66+
// Precompute HASHES[k] = hash of a full-zero subtree at level k.
67+
static ZERO_SUBTREE: LazyLock<[B256; TREE_DEPTH + 1]> = LazyLock::new(|| {
68+
let mut hashes = [B256::ZERO; TREE_DEPTH + 1];
69+
for lvl in 0..TREE_DEPTH {
70+
hashes[lvl + 1] = sha_pair(&hashes[lvl], &hashes[lvl]);
9371
}
72+
hashes
73+
});
74+
75+
#[derive(Debug)]
76+
pub struct CompactSszTransactionTree(Vec<Vec<B256>>);
77+
78+
impl CompactSszTransactionTree {
79+
/// Build a compact Merkle tree over `n = txs.len()` leaves.
80+
/// Level 0 = leaves; Level k has len = ceil(prev_len/2).
81+
/// Padding beyond n uses structural zeros Z[k].
82+
pub fn from_leaves(mut leaves: Vec<B256>) -> Self {
83+
// Degenerate case: treat as single zero leaf so we still have a root
84+
if leaves.is_empty() {
85+
leaves.push(ZERO_SUBTREE[0]);
86+
}
9487

95-
// Build the merkle tree bottom-up and collect the proof
96-
let mut branch = Vec::new();
97-
let (current_level, next_level) = (current_buf, next_buf);
98-
let mut current_index = target;
99-
100-
// Build the complete tree to depth TREE_DEPTH (20 levels)
101-
for _level in 0..TREE_DEPTH {
102-
// Get the sibling at this level
103-
let sibling_index = current_index ^ 1;
104-
branch.push(current_level[sibling_index]);
105-
106-
// Build next level up
107-
next_level.clear();
108-
for i in (0..current_level.len()).step_by(2) {
109-
let left = current_level[i];
110-
let right = current_level[i + 1];
111-
next_level.push(sha_pair(&left, &right));
88+
// Level 0: leaves
89+
let mut levels: Vec<Vec<B256>> = Vec::new();
90+
levels.push(leaves);
91+
92+
// Upper levels
93+
for level in 0..TREE_DEPTH {
94+
let prev = &levels[level];
95+
if prev.len() == 1 {
96+
break; // reached root
97+
}
98+
let parents = prev.len().div_ceil(2);
99+
let mut next = Vec::with_capacity(parents);
100+
for i in 0..parents {
101+
// NOTE: left node should always be set
102+
let l = prev.get(2 * i).copied().unwrap_or(ZERO_SUBTREE[level]);
103+
let r = prev.get(2 * i + 1).copied().unwrap_or(ZERO_SUBTREE[level]);
104+
next.push(sha_pair(&l, &r));
105+
}
106+
levels.push(next);
112107
}
113108

114-
std::mem::swap(current_level, next_level);
115-
current_index /= 2;
109+
Self(levels)
110+
}
116111

117-
// Stop when we reach the root
118-
if current_level.len() == 1 {
119-
break;
112+
pub fn proof(&self, target: usize) -> Vec<B256> {
113+
let mut branch = Vec::with_capacity(TREE_DEPTH);
114+
for level in 0..TREE_DEPTH {
115+
if level >= self.0.len() || self.0[level].len() == 1 {
116+
// Either level wasn't built or compact root reached - structural zero sibling.
117+
branch.push(ZERO_SUBTREE[level]);
118+
continue;
119+
}
120+
121+
let segment_index = target >> level;
122+
let sibling_index = segment_index ^ 1;
123+
let sibling = self.0[level]
124+
.get(sibling_index)
125+
.copied()
126+
.unwrap_or(ZERO_SUBTREE[level]); // structural zero if beyond built range
127+
branch.push(sibling);
120128
}
121-
}
122129

123-
branch
130+
branch
131+
}
124132
}
125133

134+
/// Create the leaf root for transaction bytes.
126135
#[inline]
127-
fn ssz_leaf_root(data: &Bytes) -> B256 {
136+
pub fn tx_ssz_leaf_root(data: &[u8]) -> B256 {
128137
B256::from_slice(&BinaryTransaction::from(data.to_vec()).tree_hash_root()[..])
129138
}
130139

140+
/// Compute a SHA-256 hash of the pair of 32 byte hashes.
131141
#[inline]
132-
fn sha_pair(a: &B256, b: &B256) -> B256 {
142+
pub fn sha_pair(a: &B256, b: &B256) -> B256 {
133143
let mut h = Sha256::new();
134144
h.update(a);
135145
h.update(b);

0 commit comments

Comments
 (0)