Skip to main content

tempo_payload_types/
attrs.rs

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