-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathsettlement.rs
More file actions
2793 lines (2530 loc) · 105 KB
/
Copy pathsettlement.rs
File metadata and controls
2793 lines (2530 loc) · 105 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! L1 batch submitter for the zone sequencer.
//!
//! This module handles **Tempo L1** interactions — all transactions go to the
//! [`ZonePortal`](crate::abi::ZonePortal) contract deployed on L1. The sequencer
//! signing key is used for every L1 transaction.
//!
//! [`BatchData`] is produced by the zone monitor and passed to the submitter.
//!
//! # POC limitations
//!
//! Proof validation is currently **skipped** by the stub verifier. Both direct
//! and ancestry submissions use empty proof bytes until real proof generation is
//! implemented.
//!
//! # Anchor modes
//!
//! | Gap | Mode | Description |
//! |-----|------|-------------|
//! | < configured effective window | Direct | Portal reads hash from EIP-2935. |
//! | ≥ configured effective window | Ancestry | Use a recent anchor and collect ancestry headers for the batch. |
//!
//! [`AnchorMode`] handles submissions whose `tempoBlockNumber` is outside the
//! configured direct window by falling back to ancestry mode — a recent anchor
//! block plus a locally validated parent-hash header chain.
use std::{collections::BTreeMap, fmt, sync::OnceLock, time::Duration};
use crate::{
ZoneSequencerProvider,
abi::{self, BlockTransition, DepositQueueTransition, IZoneInbox, IZoneOutbox, ZonePortal},
attestation::{AttestationStore, SettlementAttestation, SettlementCertificate},
};
use alloy_consensus::{Transaction, TxReceipt as _, transaction::TxHashRef as _};
use alloy_eips::BlockHashOrNumber;
use alloy_network::ReceiptResponse;
use alloy_primitives::{Address, B256, Bytes, U256, keccak256};
use alloy_provider::{DynProvider, Provider};
use alloy_rlp::Encodable;
use alloy_signer::SignerSync;
use alloy_signer_local::PrivateKeySigner;
use alloy_sol_types::{SolCall, SolEvent, SolStruct, SolValue, eip712_domain};
use eyre::{OptionExt as _, Result, WrapErr as _};
use futures::{StreamExt, TryStreamExt};
use parking_lot::RwLock;
use reth_storage_api::BlockNumReader;
use schnellru::{ByLength, LruMap};
use tempo_alloy::{TempoNetwork, provider::ext::TempoProviderExt, rpc::TempoCallBuilderExt};
use tempo_primitives::{Block, TempoReceipt};
use tokio_util::sync;
use tracing::{info, instrument, warn};
use crate::nonce_keys::SUBMIT_BATCH_NONCE_KEY;
#[derive(Debug)]
pub enum BatchSubmitError {
Cancelled,
PortalAdvanced,
Other(eyre::Report),
}
impl From<eyre::Report> for BatchSubmitError {
fn from(error: eyre::Report) -> Self {
Self::Other(error)
}
}
impl fmt::Display for BatchSubmitError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Cancelled => formatter.write_str("settlement quorum wait cancelled"),
Self::PortalAdvanced => {
formatter.write_str("portal advanced while waiting for settlement quorum")
}
Self::Other(error) => error.fmt(formatter),
}
}
}
/// EIP-2935 stores the last 8192 block hashes, so the usable window is 8191 blocks.
const DEFAULT_EIP2935_HISTORY_WINDOW: u64 = 8192 - 1;
/// Safety margin (~3 min at 500ms block time) to avoid race conditions where
/// the block falls out of the window between our check and on-chain execution.
const DEFAULT_EIP2935_SAFETY_MARGIN: u64 = 360;
/// How often a quorum wait rechecks whether another leader has advanced the portal.
const SETTLEMENT_PORTAL_POLL_INTERVAL: Duration = Duration::from_secs(1);
/// Maximum number of encoded L1 headers retained between ancestry submissions.
///
/// At roughly 600 bytes per header, this caps payload storage near 150 MiB plus
/// map overhead while covering more than the current Zone E recovery gap.
const DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY: u32 = 262_144;
/// Bounded gas for one `submitBatch` call when gas estimation is unavailable.
///
/// Estimation against state N cannot see hash(N) in EIP-2935, but a transaction submitted after
/// observing N can only execute in N+1 or later, where that hash is available. Eight certificate
/// signatures still fit comfortably within this limit.
const SUBMIT_BATCH_GAS_LIMIT: u64 = 2_000_000;
/// Maximum number of pending withdrawal slots reconstructed in one recovery page.
/// Bounds L1 topic filters and temporary withdrawal data without limiting the on-chain FIFO.
pub(crate) const WITHDRAWAL_RECOVERY_PAGE_SIZE: u64 = 100;
/// Maximum block span for one bounded log query.
///
/// Native Zone reads no longer use this limit; it remains the bound for L1 portal log recovery.
pub(crate) const LOG_QUERY_BLOCK_CHUNK: u64 = 5_000;
/// Canonical local identity of the Zone block most recently accepted by the L1 portal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PortalZoneAnchor {
pub block_hash: B256,
pub block_number: u64,
}
/// Read the L1 portal tip and resolve it against the local canonical Zone chain.
///
/// A zero portal hash denotes genesis. A non-zero hash must be present locally; silently treating
/// a missing hash as genesis could replay already-submitted history and construct an invalid
/// transition from state that the portal has superseded.
pub async fn resolve_portal_zone_anchor<P>(
zone_provider: &P,
portal_address: Address,
l1_provider: &DynProvider<TempoNetwork>,
) -> Result<PortalZoneAnchor>
where
P: BlockNumReader,
{
let block_hash = ZonePortal::new(portal_address, l1_provider)
.blockHash()
.call()
.await
.wrap_err("failed to read ZonePortal block hash")?;
let block_number = if block_hash.is_zero() {
0
} else {
zone_provider.block_number(block_hash)?.ok_or_eyre(format!(
"portal block hash {block_hash} is not canonical in the Zone node"
))?
};
Ok(PortalZoneAnchor {
block_hash,
block_number,
})
}
/// EIP-2935 anchor limits used by the batch submitter.
///
/// Production uses the real 8191-block EIP-2935 history window with a safety
/// margin. This type exists primarily so tests can shrink that otherwise large
/// window and exercise ancestry behavior without mining thousands of L1 blocks.
/// Production code should normally use [`Default`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BatchAnchorConfig {
/// Total L1 block-hash history window to treat as available for EIP-2935
/// anchoring.
history_window: u64,
/// Number of most-recent L1 blocks to avoid when choosing an anchor, reducing
/// the chance that an anchor ages out before the on-chain transaction lands.
safety_margin: u64,
}
impl BatchAnchorConfig {
/// Build an anchor config with explicit limits.
pub fn new(history_window: u64, safety_margin: u64) -> Result<Self> {
if history_window == 0 {
return Err(eyre::eyre!("EIP-2935 history window must be non-zero"));
}
if safety_margin >= history_window {
return Err(eyre::eyre!(
"EIP-2935 safety margin ({safety_margin}) must be smaller than history window ({history_window})"
));
}
Ok(Self {
history_window,
safety_margin,
})
}
/// Configured history window in L1 blocks.
pub const fn history_window(self) -> u64 {
self.history_window
}
/// Configured safety margin in L1 blocks.
pub const fn safety_margin(self) -> u64 {
self.safety_margin
}
/// Effective direct-submission window after subtracting the safety margin.
pub const fn effective_window(self) -> u64 {
self.history_window - self.safety_margin
}
}
impl Default for BatchAnchorConfig {
fn default() -> Self {
Self {
history_window: DEFAULT_EIP2935_HISTORY_WINDOW,
safety_margin: DEFAULT_EIP2935_SAFETY_MARGIN,
}
}
}
/// Submits zone batches to the ZonePortal contract on Tempo L1.
///
/// Holds a contract instance pointing at the portal, backed by a shared
/// [`DynProvider`] with the sequencer's signing wallet.
pub struct BatchSubmitter {
/// ZonePortal contract address on Tempo L1 (used in tracing spans).
portal_address: Address,
/// Shared L1 provider (HTTP or WS) for querying the current block number
/// (EIP-2935 window check). The same provider backs the `portal` contract
/// instance.
l1_provider: DynProvider<TempoNetwork>,
/// ZonePortal contract instance for calling `submitBatch` and reading
/// on-chain state such as `blockHash()`.
portal: ZonePortal::ZonePortalInstance<DynProvider<TempoNetwork>, TempoNetwork>,
/// Immutable portal and chain identifiers, populated by the first metadata multicall.
stable_portal_metadata: OnceLock<StablePortalMetadata>,
/// Local sequencer key used to produce a 1-of-1 TIP-1091 settlement certificate.
signer: Option<PrivateKeySigner>,
/// Concurrency for pipelined L1 header fetching in ancestry mode.
l1_fetch_concurrency: usize,
/// EIP-2935 history and safety-margin limits used for anchor decisions.
anchor_config: BatchAnchorConfig,
/// Signatures from followers attesting to the batch.
attestation_store: Option<AttestationStore>,
/// Validated, RLP-encoded L1 headers retained across overlapping ancestry
/// requests. Settlement batches are submitted in order, so later requests
/// can reuse almost the entire preceding range.
ancestry_header_cache: RwLock<LruMap<u64, CachedAncestryHeader>>,
}
impl BatchSubmitter {
/// Shared Tempo L1 provider backing portal reads and submissions.
pub(crate) const fn l1_provider(&self) -> &DynProvider<TempoNetwork> {
&self.l1_provider
}
/// Create a batch submitter without a certificate signer.
///
/// This is useful for read-only operations and tests. Batch submission returns an error.
pub fn new(portal_address: Address, l1_provider: DynProvider<TempoNetwork>) -> Self {
Self::with_anchor_config(portal_address, l1_provider, BatchAnchorConfig::default())
}
/// Create a new batch submitter with custom EIP-2935 anchor limits.
pub fn with_anchor_config(
portal_address: Address,
l1_provider: DynProvider<TempoNetwork>,
anchor_config: BatchAnchorConfig,
) -> Self {
Self::with_optional_signer_and_anchor_config(
portal_address,
l1_provider,
None,
anchor_config,
)
}
/// Create a batch submitter that signs TIP-1091 settlement certificates locally.
pub fn with_signer_and_anchor_config(
portal_address: Address,
l1_provider: DynProvider<TempoNetwork>,
signer: PrivateKeySigner,
anchor_config: BatchAnchorConfig,
) -> Self {
Self::with_optional_signer_and_anchor_config(
portal_address,
l1_provider,
Some(signer),
anchor_config,
)
}
pub(crate) fn with_optional_signer_and_anchor_config(
portal_address: Address,
l1_provider: DynProvider<TempoNetwork>,
signer: Option<PrivateKeySigner>,
anchor_config: BatchAnchorConfig,
) -> Self {
let portal = ZonePortal::new(portal_address, l1_provider.clone());
Self {
portal_address,
l1_provider,
portal,
stable_portal_metadata: OnceLock::new(),
signer,
l1_fetch_concurrency: 16,
anchor_config,
attestation_store: None,
ancestry_header_cache: RwLock::new(LruMap::new(ByLength::new(
DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY,
))),
}
}
/// Attach the shared store populated by leader and follower settlement signatures.
pub fn set_attestation_store(&mut self, store: Option<AttestationStore>) {
self.attestation_store = store;
}
/// Submit a batch to the ZonePortal on Tempo L1.
///
/// Resolves the anchor mode based on how old `tempo_block_number` is:
///
/// - **Direct** — `tempo_block_number` is within the configured effective window,
/// the portal reads its hash directly from EIP-2935.
/// - **Ancestry** — `tempo_block_number` is outside the effective window. A
/// recent anchor block is used and ancestry headers are collected (for
/// future prover integration).
///
/// `verifierConfig` and `proof` are empty until real proof generation is
/// implemented.
///
/// Returns the `BatchSubmitted` event decoded from the confirmed receipt. Waiting for a
/// settlement quorum is cancelled when the leader generation shuts down.
// TODO: pass real proof bytes once proof generation is implemented.
#[instrument(skip_all, fields(
portal = %self.portal_address,
tempo_block = batch.tempo_block_number,
prev_block_hash = %batch.prev_block_hash,
next_block_hash = %batch.next_block_hash,
withdrawal_queue_hash = %batch.withdrawal_queue_hash,
withdrawal_batch_index = batch.withdrawal_batch_index,
))]
pub async fn submit_batch(
&self,
batch: &BatchData,
shutdown: &sync::CancellationToken,
) -> std::result::Result<ZonePortal::BatchSubmitted, BatchSubmitError> {
let block_transition = BlockTransition {
prevBlockHash: batch.prev_block_hash,
nextBlockHash: batch.next_block_hash,
};
let deposit_transition = DepositQueueTransition {
prevProcessedHash: batch.prev_processed_deposit_hash,
nextProcessedHash: batch.next_processed_deposit_hash,
prevDepositNumber: batch.prev_deposit_number,
nextDepositNumber: batch.next_deposit_number,
};
let verifier_config = Bytes::new();
let signer = self.signer.as_ref();
let metadata = self
.read_submission_metadata(signer.map_or(Address::ZERO, PrivateKeySigner::address))
.await?;
self.validate_submission_metadata(batch, metadata)?;
let (certificate, anchor_mode, current_l1_block) =
if let Some(store) = &self.attestation_store {
let threshold = metadata.sequencer_threshold as usize;
info!(
zone_height = batch.zone_height,
threshold, "Waiting for settlement quorum"
);
let certificate = self
.wait_for_settlement_or_portal_progress(
store,
batch.zone_height,
threshold,
batch.prev_block_hash,
shutdown,
)
.await?;
let anchor_mode = match self
.validate_certificate(batch, batch.zone_height, metadata, &certificate)
.await
{
Ok(anchor_mode) => anchor_mode,
Err(err) => {
store.remove_settlement(batch.zone_height, certificate.digest);
return Err(err.into());
}
};
let current_l1_block = self
.l1_provider
.get_block_number()
.await
.map_err(|error| BatchSubmitError::Other(error.into()))?;
(Some(certificate), anchor_mode, current_l1_block)
} else {
let (anchor_mode, current_l1_block) =
self.resolve_anchor_mode(batch.tempo_block_number).await?;
(None, anchor_mode, current_l1_block)
};
let recent_tempo_block_number = anchor_mode.recent_block_number();
// EIP-2935 exposes hash(N) starting in N+1. A transaction built after observing head N
// cannot land before N+1, so anchoring to the current tip is valid at execution time.
let anchors_to_current_tip =
anchor_mode.anchor_block_number(batch.tempo_block_number) == current_l1_block;
let signatures = if let Some(certificate) = &certificate {
certificate.signatures.clone()
} else {
// Legacy mode, where the 1-of-1 sequencer will self-sign the attestation
let anchor_block_number = anchor_mode.anchor_block_number(batch.tempo_block_number);
let anchor_block_hash = self
.l1_provider
.get_block_by_number(anchor_block_number.into())
.await
.map_err(|error| BatchSubmitError::Other(error.into()))?
.ok_or_eyre(format!("L1 anchor block {anchor_block_number} not found"))?
.header
.hash;
let signer = signer
.ok_or_eyre("TIP-1091 batch submission requires the local sequencer signer")?;
if !metadata.signer_is_sequencer {
return Err(eyre::eyre!(
"local sequencer signer {} is not active in the portal sequencer set",
signer.address()
)
.into());
}
vec![self.sign_settlement_attestation(
signer,
metadata,
SettlementAttestationInput {
batch,
anchor_block_number,
anchor_block_hash,
block_transition: &block_transition,
deposit_transition: &deposit_transition,
verifier_config: &verifier_config,
},
)?]
};
// Refetch the committed lane nonce for every submission attempt. The provider's
// process-local nonce cache advances before a send is known to have succeeded, so
// relying on it after a failed send can create an unfillable 2D-nonce gap.
let submission_address = signer
.ok_or_eyre("batch submission requires the local sequencer signer")?
.address();
let nonce = self
.l1_provider
.get_transaction_count_with_nonce_key(submission_address, SUBMIT_BATCH_NONCE_KEY)
.await
.map_err(|error| BatchSubmitError::Other(error.into()))?;
info!(
anchor_mode = %anchor_mode,
recent_tempo_block_number,
current_l1_block,
anchors_to_current_tip,
batch_prev_block_hash = %batch.prev_block_hash,
nonce_key = ?SUBMIT_BATCH_NONCE_KEY,
nonce,
"Submitting batch to ZonePortal on L1"
);
let mut submission = self
.portal
.submitBatch(
batch.tempo_block_number,
recent_tempo_block_number,
block_transition,
deposit_transition,
batch.withdrawal_queue_hash,
verifier_config,
Bytes::new(),
U256::from(batch.zone_height),
signatures,
)
.nonce_key(SUBMIT_BATCH_NONCE_KEY)
.nonce(nonce)
.max_fee_per_gas(crate::TEMPO_L1_MAX_FEE_PER_GAS)
.max_priority_fee_per_gas(0);
// Estimation against state N cannot see hash(N), although execution in N+1 can. If this
// send does not settle, a retry after the head advances uses normal estimation.
if anchors_to_current_tip {
submission = submission.gas(SUBMIT_BATCH_GAS_LIMIT);
}
let receipt =
tokio::time::timeout(std::time::Duration::from_secs(30), submission.send_sync())
.await
.map_err(|_| eyre::eyre!("submitBatch sync submission timed out after 30 seconds"))?
.map_err(|error| BatchSubmitError::Other(error.into()))?;
let tx_hash = receipt.transaction_hash();
if !receipt.status() {
return Err(
eyre::eyre!("submitBatch tx {tx_hash} was included but reverted on L1").into(),
);
}
let event = self.decode_batch_submitted(receipt.logs())?;
if let (Some(store), Some(_)) = (&self.attestation_store, &certificate) {
store.remove_submitted(batch.zone_height);
}
info!(
%tx_hash,
withdrawal_batch_index = event.withdrawalBatchIndex,
withdrawal_queue_index = %event.withdrawalQueueIndex,
"Batch submitted to L1"
);
Ok(event)
}
/// Wait for a local quorum while periodically checking that the proposal still extends the
/// portal tip. A portal change means another submission won the handoff race and the monitor
/// must resynchronize before attempting more work.
async fn wait_for_settlement_or_portal_progress(
&self,
store: &AttestationStore,
height: u64,
threshold: usize,
expected_portal_hash: B256,
shutdown: &sync::CancellationToken,
) -> std::result::Result<SettlementCertificate, BatchSubmitError> {
loop {
tokio::select! {
biased;
() = shutdown.cancelled() => {
return Err(BatchSubmitError::Cancelled);
}
certificate = store.wait_for_settlement(height, threshold, shutdown) => {
return certificate.ok_or(BatchSubmitError::Cancelled);
}
() = tokio::time::sleep(SETTLEMENT_PORTAL_POLL_INTERVAL) => {}
}
let portal_hash = tokio::select! {
biased;
() = shutdown.cancelled() => {
return Err(BatchSubmitError::Cancelled);
}
result = self.read_portal_block_hash() => result?,
};
if portal_hash != expected_portal_hash {
return Err(BatchSubmitError::PortalAdvanced);
}
}
}
fn sign_settlement_attestation(
&self,
signer: &PrivateKeySigner,
metadata: PortalSubmissionMetadata,
attestation: SettlementAttestationInput<'_>,
) -> Result<Bytes> {
let SettlementAttestationInput {
batch,
anchor_block_number,
anchor_block_hash,
block_transition,
deposit_transition,
verifier_config,
} = attestation;
let domain = eip712_domain! {
name: "ZonePortal",
version: "1",
chain_id: metadata.stable.chain_id,
verifying_contract: self.portal_address,
};
let message = SettlementAttestation {
zoneId: metadata.stable.zone_id,
sequencerSetVersion: metadata.sequencer_set_version,
zoneHeight: U256::from(batch.zone_height),
withdrawalBatchIndex: U256::from(batch.withdrawal_batch_index),
verifier: metadata.verifier,
tempoBlockNumber: batch.tempo_block_number,
anchorBlockNumber: anchor_block_number,
anchorBlockHash: anchor_block_hash,
blockTransitionHash: keccak256(block_transition.abi_encode()),
depositQueueTransitionHash: keccak256(deposit_transition.abi_encode()),
withdrawalQueueHash: batch.withdrawal_queue_hash,
verifierConfigHash: keccak256(verifier_config),
};
let digest = message.eip712_signing_hash(&domain);
let signature = signer.sign_hash_sync(&digest)?;
let mut encoded = Vec::with_capacity(65);
encoded.extend_from_slice(&signature.r().to_be_bytes::<32>());
encoded.extend_from_slice(&signature.s().to_be_bytes::<32>());
encoded.push(signature.v() as u8 + 27);
Ok(encoded.into())
}
/// Read all mutable portal state needed for one submission at a single L1 block.
///
/// The portal and chain identifiers are immutable, so the first call includes and caches them.
/// Sequencer membership and verifier configuration are deliberately refreshed on every submission.
async fn read_submission_metadata(&self, signer: Address) -> Result<PortalSubmissionMetadata> {
if let Some(stable) = self.stable_portal_metadata.get().copied() {
let (
withdrawal_batch_index,
sequencer_set_version,
sequencer_threshold,
signer_is_sequencer,
verifier,
) = self
.l1_provider
.multicall()
.add(self.portal.withdrawalBatchIndex())
.add(self.portal.sequencerSetVersion())
.add(self.portal.sequencerThreshold())
.add(self.portal.isSequencer(signer))
.add(self.portal.verifier())
.aggregate()
.await?;
return Ok(Self::build_submission_metadata(
RawPortalSubmissionMetadata {
withdrawal_batch_index,
sequencer_set_version,
sequencer_threshold,
signer_is_sequencer,
verifier,
},
stable,
));
}
let (
withdrawal_batch_index,
sequencer_set_version,
sequencer_threshold,
signer_is_sequencer,
verifier,
zone_id,
chain_id,
) = self
.l1_provider
.multicall()
.add(self.portal.withdrawalBatchIndex())
.add(self.portal.sequencerSetVersion())
.add(self.portal.sequencerThreshold())
.add(self.portal.isSequencer(signer))
.add(self.portal.verifier())
.add(self.portal.zoneId())
.get_chain_id()
.aggregate()
.await?;
let stable = StablePortalMetadata {
zone_id,
chain_id: chain_id
.try_into()
.map_err(|_| eyre::eyre!("Tempo L1 chain ID overflow"))?,
};
let _ = self.stable_portal_metadata.set(stable);
Ok(Self::build_submission_metadata(
RawPortalSubmissionMetadata {
withdrawal_batch_index,
sequencer_set_version,
sequencer_threshold,
signer_is_sequencer,
verifier,
},
stable,
))
}
fn build_submission_metadata(
raw: RawPortalSubmissionMetadata,
stable: StablePortalMetadata,
) -> PortalSubmissionMetadata {
PortalSubmissionMetadata {
withdrawal_batch_index: raw.withdrawal_batch_index,
stable,
sequencer_set_version: raw.sequencer_set_version,
sequencer_threshold: raw.sequencer_threshold,
signer_is_sequencer: raw.signer_is_sequencer,
verifier: raw.verifier,
}
}
fn validate_submission_metadata(
&self,
batch: &BatchData,
metadata: PortalSubmissionMetadata,
) -> Result<()> {
let expected_l2_index = metadata
.withdrawal_batch_index
.checked_add(1)
.ok_or_else(|| eyre::eyre!("portal withdrawal batch index overflow"))?;
eyre::ensure!(
batch.withdrawal_batch_index == expected_l2_index,
"withdrawal batch index mismatch for zone block {}: L2 finalized index {}, expected portal index + 1 ({expected_l2_index})",
batch.zone_height,
batch.withdrawal_batch_index,
);
eyre::ensure!(
metadata.sequencer_threshold > 0,
"portal sequencer threshold is zero"
);
if self.attestation_store.is_none() {
eyre::ensure!(
metadata.sequencer_threshold == 1,
"minimal TIP-1091 compatibility supports only a 1-of-1 sequencer set; portal threshold is {}",
metadata.sequencer_threshold
);
}
Ok(())
}
/// Decode the `BatchSubmitted` event from a confirmed `submitBatch` receipt's logs.
fn decode_batch_submitted(
&self,
logs: &[alloy_rpc_types_eth::Log],
) -> Result<ZonePortal::BatchSubmitted> {
logs.iter()
.filter(|log| log.address() == self.portal_address)
.find_map(|log| ZonePortal::BatchSubmitted::decode_log(&log.inner).ok())
.map(|log| log.data)
.ok_or_else(|| {
eyre::eyre!("confirmed submitBatch receipt is missing the BatchSubmitted event")
})
}
/// Validate that a collected certificate commits to the exact calldata this submitter will
/// send, and derive the anchor mode from the signed statement instead of recomputing it.
async fn validate_certificate(
&self,
batch: &BatchData,
zone_height: u64,
metadata: PortalSubmissionMetadata,
certificate: &SettlementCertificate,
) -> Result<AnchorMode> {
if certificate.height != zone_height {
return Err(eyre::eyre!(
"settlement certificate height {} does not match batch height {zone_height}",
certificate.height
));
}
let attestation = &certificate.attestation;
let expected_block_transition_hash = alloy_primitives::keccak256(
(batch.prev_block_hash, batch.next_block_hash).abi_encode(),
);
let expected_deposit_transition_hash = alloy_primitives::keccak256(
(
batch.prev_processed_deposit_hash,
batch.next_processed_deposit_hash,
batch.prev_deposit_number,
batch.next_deposit_number,
)
.abi_encode(),
);
// Run a bunch of checks to verify that whats in the attestation certificate is exactly what
// we expect. `submitBatch` will revert if any of these are wrong, so we should catch it early.
eyre::ensure!(
attestation.zoneId == metadata.stable.zone_id,
"certificate zone ID changed"
);
eyre::ensure!(
attestation.sequencerSetVersion == metadata.sequencer_set_version,
"certificate signer-set version changed"
);
eyre::ensure!(
attestation.zoneHeight == U256::from(zone_height),
"certificate zone height changed"
);
eyre::ensure!(
attestation.withdrawalBatchIndex == U256::from(batch.withdrawal_batch_index),
"certificate withdrawal batch index changed"
);
eyre::ensure!(
attestation.verifier == metadata.verifier,
"certificate verifier changed"
);
eyre::ensure!(
attestation.tempoBlockNumber == batch.tempo_block_number,
"certificate Tempo block changed"
);
eyre::ensure!(
attestation.blockTransitionHash == expected_block_transition_hash,
"certificate block transition changed"
);
eyre::ensure!(
attestation.depositQueueTransitionHash == expected_deposit_transition_hash,
"certificate deposit transition changed"
);
eyre::ensure!(
attestation.withdrawalQueueHash == batch.withdrawal_queue_hash,
"certificate withdrawal queue hash changed"
);
eyre::ensure!(
attestation.verifierConfigHash == alloy_primitives::keccak256(Bytes::new()),
"certificate verifier config changed"
);
let current_l1_block = self.l1_provider.get_block_number().await?;
validate_certificate_anchor(
attestation.anchorBlockNumber,
current_l1_block,
self.anchor_config.history_window(),
)?;
let anchor = self
.l1_provider
.get_block_by_number(attestation.anchorBlockNumber.into())
.await?
.ok_or_eyre(format!(
"missing certified L1 anchor block {}",
attestation.anchorBlockNumber
))?;
eyre::ensure!(
anchor.header.hash == attestation.anchorBlockHash,
"certificate anchor hash changed"
);
if attestation.anchorBlockNumber == batch.tempo_block_number {
Ok(AnchorMode::Direct)
} else {
eyre::ensure!(
attestation.anchorBlockNumber > batch.tempo_block_number,
"certificate ancestry anchor does not follow its Tempo block"
);
let ancestry_headers = self
.fetch_ancestry_headers(batch.tempo_block_number, attestation.anchorBlockNumber)
.await?;
Ok(AnchorMode::Ancestry {
anchor_block: attestation.anchorBlockNumber,
ancestry_headers,
})
}
}
/// Resolve the anchor mode for the given `tempo_block_number`.
///
/// - **Direct** (gap < configured effective window): the portal reads the
/// hash directly from EIP-2935.
/// - **Ancestry** (gap ≥ configured effective window): a recent L1 block
/// behind the configured safety margin is used as anchor. Ancestry headers
/// are collected and validated for future prover integration.
async fn resolve_anchor_mode(&self, tempo_block_number: u64) -> Result<(AnchorMode, u64)> {
let current_l1_block = self.l1_provider.get_block_number().await?;
if tempo_block_number > current_l1_block {
return Err(eyre::eyre!(
"tempo_block_number ({tempo_block_number}) is not yet confirmed on L1 \
(tip={current_l1_block}), will retry after L1 advances"
));
}
let gap = current_l1_block.saturating_sub(tempo_block_number);
if gap < self.anchor_config.effective_window() {
// The cache is only useful during ancestry recovery. Replace it
// instead of clearing it so the hash table's allocation is freed.
let has_cached_headers = !self.ancestry_header_cache.read().is_empty();
if has_cached_headers {
*self.ancestry_header_cache.write() =
LruMap::new(ByLength::new(DEFAULT_ANCESTRY_HEADER_CACHE_CAPACITY));
}
return Ok((AnchorMode::Direct, current_l1_block));
}
let anchor_block = current_l1_block.saturating_sub(self.anchor_config.safety_margin());
let ancestry_headers = self
.fetch_ancestry_headers(tempo_block_number, anchor_block)
.await?;
warn!(
tempo_block_number,
current_l1_block,
anchor_block,
gap,
header_count = ancestry_headers.len(),
total_bytes = ancestry_headers.iter().map(|h| h.len()).sum::<usize>(),
"tempo_block_number outside EIP-2935 effective window, using ancestry mode"
);
Ok((
AnchorMode::Ancestry {
anchor_block,
ancestry_headers,
},
current_l1_block,
))
}
/// Fetch and RLP-encode L1 block headers from `from + 1` to `to` (inclusive),
/// validating the parent-hash chain and reusing cached overlapping headers.
///
/// Returns headers in ascending block-number order. The first header's
/// `parent_hash` is validated against the hash of block `from`, ensuring the
/// chain is rooted at the expected block.
async fn fetch_ancestry_headers(&self, from: u64, to: u64) -> Result<Vec<Bytes>> {
use futures::stream;
if to <= from {
return Ok(Vec::new());
}
// Snapshot the cache without changing its LRU order. Network requests
// and validation happen after the read lock is released.
let (cached, missing) = {
let cache = self.ancestry_header_cache.read();
let mut cached = Vec::new();
let mut missing = Vec::new();
for block_number in from..=to {
if let Some(header) = cache.peek(&block_number) {
cached.push((block_number, header.clone()));
} else {
missing.push(block_number);
}
}
(cached, missing)
};
let cache_hits = cached.len();
// Fetch and encode only the cache misses.
let fetched = stream::iter(missing.iter().copied())
.map(|block_number| {
let provider = &self.l1_provider;
async move {
let header = provider
.get_header_by_number(block_number.into())
.await?
.ok_or_else(|| {
eyre::eyre!("L1 header not found for block {block_number}")
})?;
let header = header.inner.inner;
let mut encoded = Vec::with_capacity(600);
header.encode(&mut encoded);
let cached_header = CachedAncestryHeader {
parent_hash: header.inner.parent_hash,
hash: alloy_primitives::keccak256(&encoded),
encoded: Bytes::from(encoded),
};
Ok::<_, eyre::Report>((block_number, cached_header))
}
})
.buffer_unordered(self.l1_fetch_concurrency)
.try_collect::<Vec<_>>()
.await?;
// Pure resolution owns merging, ordering, completeness, duplicate, and
// parent-hash validation. Do not mutate the cache unless it succeeds.
let ResolvedAncestry {
headers,
fetched_headers,
} = resolve_ancestry_headers(from, to, cached, fetched)?;
let fetched_count = fetched_headers.len();
// Commit only entries fetched from the snapshot's misses. Another task
// may have filled one while the network requests were in flight.
let mut cache = self.ancestry_header_cache.write();
for (block_number, header) in fetched_headers {
if let Some(existing) = cache.peek(&block_number) {
if existing.hash != header.hash {
return Err(eyre::eyre!(
"conflicting L1 header at cached block {block_number}: \
cached={}, fetched={}",
existing.hash,
header.hash
));
}
continue;
}
if !cache.insert(block_number, header) {
return Err(eyre::eyre!(
"failed to cache L1 header for block {block_number}"
));
}
}
info!(
from,
to,
cache_hits,
fetched = fetched_count,
"resolved ancestry headers"
);
Ok(headers)
}
/// Read the current `blockHash` from the ZonePortal on L1.
///
/// Used to resync the monitor's `prev_block_hash` after repeated submission
/// failures, ensuring subsequent batches use the portal's actual state.
pub async fn read_portal_block_hash(&self) -> Result<B256> {
let hash = self.portal.blockHash().call().await?;
Ok(hash)
}
/// Read the current withdrawal queue bounds in one Multicall3 request.
async fn read_portal_withdrawal_queue_bounds(&self) -> Result<(u64, u64)> {
let (head, tail) = self
.l1_provider
.multicall()
.add(self.portal.withdrawalQueueHead())
.add(self.portal.withdrawalQueueTail())
.aggregate()
.await?;
Ok((
head.try_into()
.map_err(|_| eyre::eyre!("withdrawal queue head overflow"))?,