Skip to main content

tempo_payload_types/
attrs.rs

1use crate::ValidationLatencyEstimate;
2use alloy_primitives::{Address, B256, Bytes, Keccak256};
3use alloy_rpc_types_engine::PayloadId;
4use alloy_rpc_types_eth::Withdrawal;
5use reth_ethereum_engine_primitives::EthPayloadAttributes;
6use reth_node_api::PayloadAttributes;
7use serde::{Deserialize, Serialize};
8use std::{sync::Arc, time::Duration};
9use tempo_primitives::{RecoveredSubBlock, TempoConsensusContext};
10
11/// Container type for all components required to build a payload.
12///
13/// It also carries DKG data to be included in the block's extra_data field.
14#[derive(
15    derive_more::Debug, Clone, Serialize, Deserialize, derive_more::Deref, derive_more::DerefMut,
16)]
17#[serde(rename_all = "camelCase")]
18pub struct TempoPayloadAttributes {
19    /// Inner [`EthPayloadAttributes`].
20    #[deref]
21    #[deref_mut]
22    #[serde(flatten)]
23    inner: EthPayloadAttributes,
24    /// Remaining local proposal budget available to this payload build.
25    ///
26    /// Consensus sets this to the proposal return budget left when it dispatches
27    /// the build. `None` means the build was not requested by consensus, so the
28    /// builder should not stop early for block pacing.
29    #[serde(skip)]
30    payload_build_budget: Option<Duration>,
31    /// Validation latency estimate for a consensus payload build.
32    ///
33    /// Consensus snapshots this from recent locally validated blocks. `None`
34    /// means the builder should use its conservative fallback.
35    #[serde(skip)]
36    validation_latency_estimate: Option<ValidationLatencyEstimate>,
37    /// Milliseconds portion of the timestamp.
38    timestamp_millis_part: u64,
39    /// DKG ceremony data to include in the block's extra_data header field.
40    ///
41    /// This is empty when no DKG data is available (e.g., when the DKG manager
42    /// hasn't produced ceremony outcomes yet, or when DKG operations fail).
43    extra_data: Bytes,
44    /// The proposer's public key used to resolve the fee recipient from the
45    /// validator config contract. When `None`, `suggested_fee_recipient` from
46    /// the inner attributes is used as-is.
47    proposer_public_key: Option<B256>,
48    /// Consensus view for this block
49    consensus_context: Option<TempoConsensusContext>,
50    /// Subblocks closure.
51    #[debug(skip)]
52    #[serde(skip, default = "default_subblocks")]
53    subblocks: Arc<dyn Fn() -> Vec<RecoveredSubBlock> + Send + Sync + 'static>,
54}
55
56impl Default for TempoPayloadAttributes {
57    fn default() -> Self {
58        Self::from(EthPayloadAttributes::default())
59    }
60}
61
62impl TempoPayloadAttributes {
63    /// Creates new `TempoPayloadAttributes` with `inner` attributes.
64    ///
65    /// The inner `suggested_fee_recipient` is always `Address::ZERO`; the
66    /// real beneficiary is resolved from the validator config v2 contract by
67    /// the payload builder.
68    pub fn new(
69        proposer_public_key: Option<B256>,
70        timestamp: u64,
71        timestamp_millis_part: u64,
72        extra_data: Bytes,
73        consensus_context: Option<TempoConsensusContext>,
74        subblocks: impl Fn() -> Vec<RecoveredSubBlock> + Send + Sync + 'static,
75    ) -> Self {
76        Self {
77            inner: EthPayloadAttributes {
78                timestamp,
79                suggested_fee_recipient: Address::ZERO,
80                prev_randao: B256::ZERO,
81                withdrawals: Some(Default::default()),
82                parent_beacon_block_root: Some(B256::ZERO),
83                slot_number: None,
84                target_gas_limit: None,
85            },
86            payload_build_budget: None,
87            validation_latency_estimate: None,
88            timestamp_millis_part,
89            extra_data,
90            proposer_public_key,
91            consensus_context,
92            subblocks: Arc::new(subblocks),
93        }
94    }
95
96    /// Returns the extra data to be included in the block header.
97    pub fn extra_data(&self) -> &Bytes {
98        &self.extra_data
99    }
100
101    /// Returns the proposer's public key.
102    pub fn proposer_public_key(&self) -> Option<&B256> {
103        self.proposer_public_key.as_ref()
104    }
105
106    /// Sets the remaining local proposal budget for a consensus payload build.
107    ///
108    /// The value should already account for any time spent before the build was
109    /// requested. The builder treats it as a shared budget for leader
110    /// build/persist work and validator replay/persist work.
111    pub fn with_payload_build_budget(mut self, budget: Duration) -> Self {
112        self.payload_build_budget = Some(budget);
113        self
114    }
115
116    /// Returns the consensus-provided build budget, if this is a paced build.
117    ///
118    /// `None` is intentional for non-consensus builds such as dev or external
119    /// payload requests; those builds are not constrained by the consensus
120    /// block-time budget.
121    pub fn payload_build_budget(&self) -> Option<Duration> {
122        self.payload_build_budget
123    }
124
125    /// Sets the validation latency estimate for a consensus payload build.
126    pub fn with_validation_latency_estimate(
127        mut self,
128        estimate: Option<ValidationLatencyEstimate>,
129    ) -> Self {
130        self.validation_latency_estimate = estimate;
131        self
132    }
133
134    /// Returns the consensus-provided validation latency estimate.
135    pub fn validation_latency_estimate(&self) -> Option<ValidationLatencyEstimate> {
136        self.validation_latency_estimate
137    }
138
139    /// Returns the milliseconds portion of the timestamp.
140    pub fn timestamp_millis_part(&self) -> u64 {
141        self.timestamp_millis_part
142    }
143
144    /// Returns the timestamp in milliseconds.
145    pub fn timestamp_millis(&self) -> u64 {
146        self.inner
147            .timestamp()
148            .saturating_mul(1000)
149            .saturating_add(self.timestamp_millis_part)
150    }
151
152    /// Returns the consensus context
153    pub fn consensus_context(&self) -> Option<TempoConsensusContext> {
154        self.consensus_context
155    }
156
157    /// Returns the subblocks.
158    pub fn subblocks(&self) -> Vec<RecoveredSubBlock> {
159        (self.subblocks)()
160    }
161}
162
163// Required by reth's e2e-test-utils for integration tests.
164// The test utilities need to convert from standard Ethereum payload attributes
165// to custom chain-specific attributes.
166impl From<EthPayloadAttributes> for TempoPayloadAttributes {
167    fn from(inner: EthPayloadAttributes) -> Self {
168        Self {
169            inner,
170            payload_build_budget: None,
171            validation_latency_estimate: None,
172            timestamp_millis_part: 0,
173            extra_data: Bytes::default(),
174            proposer_public_key: None,
175            consensus_context: None,
176            subblocks: Arc::new(Vec::new),
177        }
178    }
179}
180
181impl PayloadAttributes for TempoPayloadAttributes {
182    fn payload_id(&self, parent_hash: &B256) -> PayloadId {
183        // XXX: derives the payload ID from the parent so that
184        // overlong payload builds will eventually succeed on the
185        // next iteration: if all other nodes take equally as long,
186        // the consensus engine will kill the proposal task. Then eventually
187        // consensus will circle back to an earlier node, which then
188        // has the chance of picking up the old payload.
189        //
190        // The consensus context (epoch, view, parent_view, proposer) is
191        // mixed into the ID so that distinct consensus rounds proposing on
192        // the same parent block produce distinct payload IDs and do not
193        // collide in the payload builder cache.
194        payload_id_from_parent_and_context(parent_hash, self.consensus_context.as_ref())
195    }
196
197    fn timestamp(&self) -> u64 {
198        self.inner.timestamp()
199    }
200
201    fn parent_beacon_block_root(&self) -> Option<B256> {
202        self.inner.parent_beacon_block_root()
203    }
204
205    fn withdrawals(&self) -> Option<&Vec<Withdrawal>> {
206        self.inner.withdrawals()
207    }
208
209    fn slot_number(&self) -> Option<u64> {
210        self.inner.slot_number()
211    }
212}
213
214/// Constructs a [`PayloadId`] from the first 8 bytes of `block_hash`.
215fn payload_id_from_block_hash(block_hash: &B256) -> PayloadId {
216    PayloadId::new(
217        <[u8; 8]>::try_from(&block_hash[0..8])
218            .expect("a 32 byte array always has more than 8 bytes"),
219    )
220}
221
222/// Constructs a [`PayloadId`] from the parent block hash and consensus context.
223///
224/// When `consensus_context` is `None`, this is equivalent to
225/// [`payload_id_from_block_hash`] for backwards compatibility with pre-fork
226/// blocks. Otherwise the parent hash and each field of the consensus context
227/// are streamed into a Keccak256 hasher and the first 8 bytes of the digest
228/// form the ID.
229fn payload_id_from_parent_and_context(
230    parent_hash: &B256,
231    consensus_context: Option<&TempoConsensusContext>,
232) -> PayloadId {
233    let Some(ctx) = consensus_context else {
234        return payload_id_from_block_hash(parent_hash);
235    };
236
237    let mut hasher = Keccak256::new();
238    hasher.update(parent_hash);
239    hasher.update(ctx.epoch.to_be_bytes());
240    hasher.update(ctx.view.to_be_bytes());
241    hasher.update(ctx.parent_view.to_be_bytes());
242    hasher.update(B256::from(&ctx.proposer));
243    let digest = hasher.finalize();
244
245    PayloadId::new(
246        <[u8; 8]>::try_from(&digest[0..8]).expect("a 32 byte array always has more than 8 bytes"),
247    )
248}
249
250fn default_subblocks() -> Arc<dyn Fn() -> Vec<RecoveredSubBlock> + Send + Sync + 'static> {
251    Arc::new(Vec::new)
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use alloy_rpc_types_eth::Withdrawal;
258    use tempo_primitives::ed25519::PublicKey;
259
260    trait TestExt: Sized {
261        fn random() -> Self;
262        fn with_timestamp(self, millis: u64) -> Self;
263        fn with_subblocks(
264            self,
265            f: impl Fn() -> Vec<RecoveredSubBlock> + Send + Sync + 'static,
266        ) -> Self;
267    }
268
269    impl TestExt for TempoPayloadAttributes {
270        fn random() -> Self {
271            Self::new(
272                None,
273                1, // 1s
274                0,
275                Bytes::default(),
276                None,
277                Vec::new,
278            )
279        }
280
281        fn with_timestamp(mut self, millis: u64) -> Self {
282            self.inner.timestamp = millis / 1000;
283            self.timestamp_millis_part = millis % 1000;
284            self
285        }
286
287        fn with_subblocks(
288            mut self,
289            f: impl Fn() -> Vec<RecoveredSubBlock> + Send + Sync + 'static,
290        ) -> Self {
291            self.subblocks = Arc::new(f);
292            self
293        }
294    }
295
296    #[test]
297    fn test_builder_attributes_construction() {
298        let parent = B256::random();
299        let extra_data = Bytes::from(vec![1, 2, 3, 4, 5]);
300
301        // With extra_data
302        let attrs = TempoPayloadAttributes::new(
303            None,
304            1,
305            500, // 1.5s
306            extra_data.clone(),
307            None,
308            Vec::new,
309        );
310        assert_eq!(attrs.extra_data(), &extra_data);
311        assert_eq!(attrs.suggested_fee_recipient, Address::ZERO);
312        assert_eq!(
313            attrs.payload_id(&parent),
314            payload_id_from_block_hash(&parent)
315        );
316        assert_eq!(attrs.timestamp(), 1);
317        assert_eq!(attrs.timestamp_millis_part(), 500);
318
319        // Hardcoded in ::new()
320        assert_eq!(attrs.prev_randao, B256::ZERO);
321        assert_eq!(attrs.parent_beacon_block_root(), Some(B256::ZERO));
322        assert!(attrs.withdrawals().is_some_and(|w| w.is_empty()));
323
324        // Without extra_data
325        let attrs2 = TempoPayloadAttributes::new(
326            None,
327            2, // +500ms
328            0,
329            Bytes::default(),
330            None,
331            Vec::new,
332        );
333        assert_eq!(attrs2.extra_data(), &Bytes::default());
334        assert_eq!(attrs2.timestamp(), 2);
335        assert_eq!(attrs2.timestamp_millis_part(), 0);
336    }
337
338    #[test]
339    fn test_builder_attributes_timestamp_handling() {
340        // Exact second boundary
341        let attrs = TempoPayloadAttributes::random().with_timestamp(3000);
342        assert_eq!(attrs.timestamp(), 3);
343        assert_eq!(attrs.timestamp_millis_part(), 0);
344        assert_eq!(attrs.timestamp_millis(), 3000);
345
346        // With milliseconds remainder
347        let attrs = TempoPayloadAttributes::random().with_timestamp(3999);
348        assert_eq!(attrs.timestamp(), 3);
349        assert_eq!(attrs.timestamp_millis_part(), 999);
350        assert_eq!(attrs.timestamp_millis(), 3999);
351
352        // Zero timestamp
353        let attrs = TempoPayloadAttributes::random().with_timestamp(0);
354        assert_eq!(attrs.timestamp(), 0);
355        assert_eq!(attrs.timestamp_millis_part(), 0);
356        assert_eq!(attrs.timestamp_millis(), 0);
357
358        // Large timestamp (no overflow due to saturating ops)
359        let large_ts = u64::MAX / 1000 * 1000;
360        let attrs = TempoPayloadAttributes::random().with_timestamp(large_ts + 500);
361        assert_eq!(attrs.timestamp_millis_part(), 500);
362        assert!(attrs.timestamp_millis() >= large_ts);
363    }
364
365    #[test]
366    fn test_builder_attributes_subblocks() {
367        use std::sync::atomic::{AtomicUsize, Ordering};
368
369        let call_count = Arc::new(AtomicUsize::new(0));
370        let count_clone = call_count.clone();
371
372        let attrs = TempoPayloadAttributes::random().with_subblocks(move || {
373            count_clone.fetch_add(1, Ordering::SeqCst);
374            Vec::new()
375        });
376
377        // Closure invoked each call
378        assert_eq!(call_count.load(Ordering::SeqCst), 0);
379        let _ = attrs.subblocks();
380        assert_eq!(call_count.load(Ordering::SeqCst), 1);
381        let _ = attrs.subblocks();
382        assert_eq!(call_count.load(Ordering::SeqCst), 2);
383    }
384
385    #[test]
386    fn test_from_eth_payload_builder_attributes() {
387        let eth_attrs = EthPayloadAttributes {
388            timestamp: 1000,
389            suggested_fee_recipient: Address::random(),
390            prev_randao: B256::random(),
391            withdrawals: Some(Default::default()),
392            parent_beacon_block_root: Some(B256::random()),
393            slot_number: None,
394            target_gas_limit: None,
395        };
396
397        let tempo_attrs: TempoPayloadAttributes = eth_attrs.clone().into();
398
399        // Inner fields preserved
400        let parent = B256::random();
401        assert_eq!(
402            tempo_attrs.payload_id(&parent),
403            payload_id_from_block_hash(&parent)
404        );
405        assert_eq!(tempo_attrs.timestamp(), eth_attrs.timestamp);
406        assert_eq!(
407            tempo_attrs.suggested_fee_recipient,
408            eth_attrs.suggested_fee_recipient
409        );
410        assert_eq!(tempo_attrs.prev_randao, eth_attrs.prev_randao);
411        assert_eq!(tempo_attrs.withdrawals().as_ref().map(|w| w.len()), Some(0));
412        assert_eq!(
413            tempo_attrs.parent_beacon_block_root(),
414            eth_attrs.parent_beacon_block_root
415        );
416
417        // Tempo-specific defaults
418        assert_eq!(tempo_attrs.timestamp_millis_part(), 0);
419        assert_eq!(tempo_attrs.extra_data(), &Bytes::default());
420        assert!(tempo_attrs.subblocks().is_empty());
421    }
422
423    #[test]
424    fn test_tempo_payload_attributes_serde() {
425        let timestamp = 1234567890;
426        let timestamp_millis_part = 999;
427        let attrs = TempoPayloadAttributes {
428            inner: EthPayloadAttributes {
429                timestamp,
430                prev_randao: B256::ZERO,
431                suggested_fee_recipient: Address::random(),
432                withdrawals: Some(vec![]),
433                parent_beacon_block_root: Some(B256::random()),
434                slot_number: None,
435                target_gas_limit: None,
436            },
437            timestamp_millis_part,
438            ..Default::default()
439        };
440
441        // Roundtrip
442        let json = serde_json::to_string(&attrs).unwrap();
443        assert!(json.contains("timestampMillisPart"));
444
445        let deserialized: TempoPayloadAttributes = serde_json::from_str(&json).unwrap();
446        assert_eq!(deserialized.inner.timestamp, timestamp);
447        assert_eq!(deserialized.timestamp_millis_part, timestamp_millis_part);
448
449        // Deref works
450        assert_eq!(attrs.timestamp, timestamp);
451
452        // DerefMut works
453        let mut attrs = attrs;
454        attrs.timestamp = 123;
455        assert_eq!(attrs.inner.timestamp, 123);
456    }
457
458    #[test]
459    fn test_tempo_payload_attributes_trait_impl() {
460        let withdrawal_addr = Address::random();
461        let beacon_root = B256::random();
462
463        let attrs = TempoPayloadAttributes {
464            inner: EthPayloadAttributes {
465                timestamp: 9999,
466                prev_randao: B256::ZERO,
467                suggested_fee_recipient: Address::random(),
468                withdrawals: Some(vec![Withdrawal {
469                    index: 0,
470                    validator_index: 1,
471                    address: withdrawal_addr,
472                    amount: 500,
473                }]),
474                parent_beacon_block_root: Some(beacon_root),
475                slot_number: None,
476                target_gas_limit: None,
477            },
478            timestamp_millis_part: 123,
479            ..Default::default()
480        };
481
482        // PayloadAttributes trait methods
483        assert_eq!(PayloadAttributes::timestamp(&attrs), 9999);
484        assert_eq!(attrs.withdrawals().unwrap().len(), 1);
485        assert_eq!(attrs.withdrawals().unwrap()[0].address, withdrawal_addr);
486        assert_eq!(attrs.parent_beacon_block_root(), Some(beacon_root));
487
488        // None cases
489        let attrs_none = TempoPayloadAttributes {
490            inner: EthPayloadAttributes {
491                timestamp: 1,
492                prev_randao: B256::ZERO,
493                suggested_fee_recipient: Address::random(),
494                withdrawals: None,
495                parent_beacon_block_root: None,
496                slot_number: None,
497                target_gas_limit: None,
498            },
499            timestamp_millis_part: 0,
500            ..Default::default()
501        };
502        assert!(attrs_none.withdrawals().is_none());
503        assert!(attrs_none.parent_beacon_block_root().is_none());
504    }
505
506    #[test]
507    fn payload_id_includes_consensus_context() {
508        let parent = B256::random();
509        let proposer = PublicKey::from_seed([0xab; 32]);
510
511        let mk = |ctx: Option<TempoConsensusContext>| -> PayloadId {
512            let mut attrs = TempoPayloadAttributes::random();
513            attrs.consensus_context = ctx;
514            attrs.payload_id(&parent)
515        };
516
517        let no_ctx = mk(None);
518        let ctx_a = mk(Some(TempoConsensusContext {
519            epoch: 1,
520            view: 1,
521            parent_view: 0,
522            proposer,
523        }));
524        let ctx_b = mk(Some(TempoConsensusContext {
525            epoch: 1,
526            view: 2,
527            parent_view: 1,
528            proposer,
529        }));
530        let ctx_c = mk(Some(TempoConsensusContext {
531            epoch: 2,
532            view: 1,
533            parent_view: 0,
534            proposer,
535        }));
536        let ctx_d = mk(Some(TempoConsensusContext {
537            epoch: 1,
538            view: 1,
539            parent_view: 0,
540            proposer: PublicKey::from_seed([0xcd; 32]),
541        }));
542
543        // Without context, falls back to parent-hash-only ID.
544        assert_eq!(no_ctx, payload_id_from_block_hash(&parent));
545
546        // Each distinct consensus context produces a distinct ID, and all
547        // differ from the no-context fallback.
548        let ids = [no_ctx, ctx_a, ctx_b, ctx_c, ctx_d];
549        for i in 0..ids.len() {
550            for j in (i + 1)..ids.len() {
551                assert_ne!(ids[i], ids[j], "payload ids {i} and {j} collide");
552            }
553        }
554
555        // Same context on the same parent is deterministic.
556        let ctx_a_again = mk(Some(TempoConsensusContext {
557            epoch: 1,
558            view: 1,
559            parent_view: 0,
560            proposer,
561        }));
562        assert_eq!(ctx_a, ctx_a_again);
563
564        // Different parent with the same context yields a different ID.
565        let other_parent = B256::random();
566        let mut attrs = TempoPayloadAttributes::random();
567        attrs.consensus_context = Some(TempoConsensusContext {
568            epoch: 1,
569            view: 1,
570            parent_view: 0,
571            proposer,
572        });
573        assert_ne!(attrs.payload_id(&parent), attrs.payload_id(&other_parent));
574    }
575}