1515//! | | tx block re-orged | |
1616//! +-----------+ +-----------+
1717//! | |
18- //! | TTL expired | TTL expired
18+ //! | TTL expired | tx block finalized
1919//! v v
2020//! +---------------------------------------------------------+
2121//! | TERMINATED (process ends) |
2828//! message is tx-less; see below). While the reception process is in this state, it keeps looking for the
2929// transaction onchain.
3030//! - **Processed**: the message has been handed off for processing. If there weren't reorgs, reaching this state would
31- //! be equivalent to terminating the reception process. Since reorgs are a possibility, this state is not terminal.
32- //! - **Terminated**: the message has reached its TTL which means nothing else can be done with it. This is the
33- //! terminal state of this process.
31+ //! be equivalent to terminating the reception process. Since reorgs are a possibility, this state is not terminal
32+ //! until the block the originating transaction was found in finalizes.
33+ //! - **Terminated**: nothing else can be done with the message, either because its originating transaction never
34+ //! appeared within the TTL, or because the block it was processed in has finalized. This is the terminal state of
35+ //! this process.
3436//!
3537//! ## Transitions (each evaluated by [`OffchainReception::step`])
3638//!
3739//! - **Received -> Processed**: the originating transaction is found onchain. The message (packaged with its
3840//! transaction context) is queued to have its actual contents processed (which can lead for example to the discovery
3941//! of notes and events).
4042//! - **Processed -> Received**: the block where the message transaction was originally found is re-orged out.
41- //! - **Received -> Terminated**: the message TTL has elapsed (see below), so the message is no longer processable.
42- //! - **Processed -> Terminated**: the message TTL has elapsed (see below), so the effects of its processing are
43- //! permanent.
43+ //! - **Received -> Terminated**: the message TTL has elapsed (see below), so the originating transaction can no longer
44+ //! appear and the message is no longer processable.
45+ //! - **Processed -> Terminated**: the block the originating transaction was found in has finalized, so the effects of
46+ //! its processing are permanent and reorg-proof.
4447//!
4548//! # Message reception lifecycle and the TTL
4649//!
5053//! window (plus a safety margin of 2 hours) has elapsed the originating transaction can no longer appear, and it is
5154//! safe to stop looking for it.
5255//!
53- //! Already-processed receptions reuse the same TTL: we could instead wait for the block that included the transaction
54- //! to finalize, but we currently do not have the primitives to reliably get that information .
56+ //! The TTL only matters for unprocessed messages. Already-processed message receptions complete when the block that
57+ //! included the originating transaction finalizes, which is exactly when the processing becomes reorg-proof .
5558//!
5659//! # Current limitations, future plans
5760//!
5861//! - Tx-less messages never reach `Processed` and are only ever removed by expiry. This will be supported in the
5962//! future.
60- //! - Already-processed receptions stay active longer than needed. As discussed, we need access to block finality
61- //! information to properly implement that case, which will be added in the future.
6263//!
6364//! # Implementation
6465//!
8182//! Note that since this fact is retractable, a reorg dropping said block would result in the fact being automatically
8283//! removed by PXE, effectively pushing the reception process back to `RECEIVED` state.
8384//!
84- //! To determine in which state of the reception process we are we just need to analyze the recorded facts available
85- //! in combination with the TTL:
85+ //! To determine in which state of the reception process we are we just analyze the recorded facts:
8686//!
87- //! - If the TTL has elapsed, we reached the end of the process and the [fact collection](crate::facts::FactCollection)
88- //! can (and should) be deleted.
89- //! - Otherwise:
90- //! - If there is an `OFFCHAIN_MESSAGE_PROCESSED ` fact we are in PROCESSED state and there is nothing to do.
91- //! - If there is only an `OFFCHAIN_MESSAGE_RECEIVED` fact we are in RECEIVED state, so we need to check if the
92- //! message transaction is now available onchain, and if so, exercise the RECEIVED->PROCESSED transition .
87+ //! - If there is an `OFFCHAIN_MESSAGE_PROCESSED` fact we are in PROCESSED state. Once that fact's origin block has
88+ //! finalized the processing is reorg-proof, so the [fact collection](crate::facts::FactCollection) can (and should)
89+ //! be deleted; until then there is nothing to do.
90+ //! - If there is only an `OFFCHAIN_MESSAGE_RECEIVED ` fact we are in RECEIVED state, so we check if the message
91+ //! transaction is now available onchain, and if so, exercise the RECEIVED->PROCESSED transition. Otherwise, once the
92+ //! TTL has elapsed the originating transaction can no longer appear and the collection can (and should) be deleted .
9393//!
9494//! The PROCESSED->RECEIVED transition is transparently handled by PXE: if a re-org caused the message transaction to
9595//! fall off the chain, the `OFFCHAIN_MESSAGE_PROCESSED` fact disappears, taking us back to the RECEIVED state.
@@ -207,13 +207,11 @@ impl OffchainReception {
207207 maybe_resolved_tx : Option <ResolvedTx >,
208208 now : u64 ,
209209 ) -> Option <OffchainMessageWithContext > {
210- // TODO(@mverzilli): we could annotate retractable facts as "final" when their corresponding blocks are below
211- // the final tip. Then we could use that as termination condition instead of relying on timestamp arithmetics.
212210 let message = self .read_message ();
213- let expired = now > message .anchor_block_timestamp + MAX_MSG_TTL ;
214- let already_processed = self .collection .facts .any (|f : Fact | f .fact_type_id == OFFCHAIN_MESSAGE_PROCESSED );
211+ let processed_fact = self .collection .facts .find (|f : Fact | f .fact_type_id == OFFCHAIN_MESSAGE_PROCESSED );
215212
216- if !already_processed & maybe_resolved_tx .is_some () {
213+ if processed_fact .is_none () & maybe_resolved_tx .is_some () {
214+ // Received -> Processed: the originating tx is onchain.
217215 let resolved = maybe_resolved_tx .unwrap ();
218216 self .mark_processed (resolved .block_number , resolved .block_hash );
219217 let message_context = MessageContext {
@@ -224,10 +222,19 @@ impl OffchainReception {
224222 Option ::some (
225223 OffchainMessageWithContext { message_ciphertext : message .ciphertext , message_context },
226224 )
227- } else if expired {
228- self .terminate ();
225+ } else if processed_fact .is_some () {
226+ if processed_fact .unwrap ().origin_block .unwrap ().block_state .is_finalized () {
227+ // Processed -> Terminated: once the marker's origin block finalizes, the processing is reorg-proof and
228+ // the reception is complete. Until then we wait: a reorg dropping that block removes the retractable
229+ // marker and PXE pushes us back to Received.
230+ self .terminate ();
231+ }
229232 Option ::none ()
230233 } else {
234+ // Received -> Terminated: the TTL caps how long we keep looking for the originating tx.
235+ if now > message .anchor_block_timestamp + MAX_MSG_TTL {
236+ self .terminate ();
237+ }
231238 Option ::none ()
232239 }
233240 }
@@ -306,17 +313,23 @@ mod test {
306313 OffchainMessage { ciphertext : BoundedVec ::new (), recipient , tx_hash , anchor_block_timestamp }
307314 }
308315
309- /// Builds the resolved-tx context PXE would return for `tx_hash`.
310- fn resolved_tx (tx_hash : Field ) -> ResolvedTx {
316+ /// Builds the resolved-tx context PXE would return for `tx_hash`, found in `block_number` .
317+ fn resolved_tx_at_block (tx_hash : Field , block_number : u32 ) -> ResolvedTx {
311318 ResolvedTx {
312319 tx_hash ,
313320 unique_note_hashes_in_tx : BoundedVec ::new (),
314321 first_nullifier_in_tx : 0 ,
315- block_number : 1 ,
322+ block_number ,
316323 block_hash : 0 ,
317324 }
318325 }
319326
327+ /// Builds a resolved-tx context found in a low block, which TXE (proven == finalized == latest) reports as
328+ /// `Finalized`.
329+ fn resolved_tx (tx_hash : Field ) -> ResolvedTx {
330+ resolved_tx_at_block (tx_hash , 1 )
331+ }
332+
320333 #[test]
321334 unconstrained fn expired_reception_is_terminated () {
322335 let (env , scope ) = setup ();
@@ -377,25 +390,30 @@ mod test {
377390 let (env , scope ) = setup ();
378391 let tx_hash = random ();
379392 let msg = make_msg (scope , Option ::some (tx_hash ), 0 );
393+ // A future origin block stays unfinalized, so the reception lingers in Processed across steps.
394+ let future_block = env .last_block_number () + 100 ;
380395
381396 env .utility_context (|context | {
382397 let address = context .this_address ();
383398 OffchainReception ::init (address , msg );
384399
385400 // First step processes the message.
386401 let reception = OffchainReception ::load_all (address , scope ).get (0 );
387- assert (reception .step (Option ::some (resolved_tx (tx_hash )), 100 ).is_some ());
402+ assert (reception .step (Option ::some (resolved_tx_at_block (tx_hash , future_block )), 100 ).is_some ());
388403 assert (OffchainReception ::is_processed (address , scope , msg ), "should be processed" );
389404
390- // Second step (still resolved, not expired ) recognizes it as processed and does not re-push it.
405+ // Second step (still resolved, not finalized ) recognizes it as processed and does not re-push it.
391406 let reloaded = OffchainReception ::load_all (address , scope ).get (0 );
392- assert (reloaded .step (Option ::some (resolved_tx (tx_hash )), 100 ).is_none (), "already processed" );
407+ assert (
408+ reloaded .step (Option ::some (resolved_tx_at_block (tx_hash , future_block )), 100 ).is_none (),
409+ "already processed" ,
410+ );
393411 assert (OffchainReception ::is_active (address , scope , msg ), "processed reception should still be active" );
394412 });
395413 }
396414
397415 #[test]
398- unconstrained fn expired_resolved_reception_is_processed_once_before_termination () {
416+ unconstrained fn processed_reception_terminates_once_its_origin_block_finalizes () {
399417 let (env , scope ) = setup ();
400418 let tx_hash = random ();
401419 let msg = make_msg (scope , Option ::some (tx_hash ), 0 );
@@ -404,18 +422,65 @@ mod test {
404422 let address = context .this_address ();
405423 OffchainReception ::init (address , msg );
406424
407- // Expired but resolved: the message is processed once (not terminated yet) .
425+ // Process the message against a finalized block .
408426 let reception = OffchainReception ::load_all (address , scope ).get (0 );
409- assert (reception .step (Option ::some (resolved_tx (tx_hash )), MAX_MSG_TTL + 1 ).is_some ());
427+ assert (reception .step (Option ::some (resolved_tx (tx_hash )), 100 ).is_some ());
410428 assert (OffchainReception ::is_processed (address , scope , msg ), "should be processed" );
411429
412- // Next step: still expired and now processed, so the reception is terminated.
430+ // Next step, still well within the TTL: a finalized origin block makes the processing reorg-proof, so the
431+ // reception terminates regardless of the TTL.
413432 let reloaded = OffchainReception ::load_all (address , scope ).get (0 );
414- assert (reloaded .step (Option ::some (resolved_tx (tx_hash )), MAX_MSG_TTL + 1 ).is_none ());
433+ assert (reloaded .step (Option ::some (resolved_tx (tx_hash )), 100 ).is_none ());
415434 assert (
416435 !OffchainReception ::is_active (address , scope , msg ),
417- "expired processed reception is terminated on the next step" ,
436+ "finalized processed reception should be terminated" ,
437+ );
438+ });
439+ }
440+
441+ #[test]
442+ unconstrained fn unfinalized_processed_reception_survives_past_the_ttl () {
443+ let (env , scope ) = setup ();
444+ let tx_hash = random ();
445+ let msg = make_msg (scope , Option ::some (tx_hash ), 0 );
446+ // A future origin block never finalizes here, so the reception must not be terminated by the TTL.
447+ let future_block = env .last_block_number () + 100 ;
448+
449+ env .utility_context (|context | {
450+ let address = context .this_address ();
451+ OffchainReception ::init (address , msg );
452+
453+ let reception = OffchainReception ::load_all (address , scope ).get (0 );
454+ assert (reception .step (Option ::some (resolved_tx_at_block (tx_hash , future_block )), 100 ).is_some ());
455+ assert (OffchainReception ::is_processed (address , scope , msg ), "should be processed" );
456+
457+ // Past the TTL, but the origin block has not finalized: the reception stays active. The TTL no longer
458+ // governs an already-processed reception.
459+ let reloaded = OffchainReception ::load_all (address , scope ).get (0 );
460+ assert (reloaded .step (Option ::some (resolved_tx_at_block (tx_hash , future_block )), MAX_MSG_TTL + 1 ).is_none ());
461+ assert (
462+ OffchainReception ::is_active (address , scope , msg ),
463+ "unfinalized processed reception should survive past the TTL" ,
418464 );
419465 });
420466 }
467+
468+ #[test]
469+ unconstrained fn expired_reception_is_still_processed_when_its_tx_resolves () {
470+ let (env , scope ) = setup ();
471+ let tx_hash = random ();
472+ let msg = make_msg (scope , Option ::some (tx_hash ), 0 );
473+
474+ env .utility_context (|context | {
475+ let address = context .this_address ();
476+ OffchainReception ::init (address , msg );
477+
478+ // Even past the TTL, a freshly resolved tx is still handed off: expiry only caps the search for the
479+ // originating tx, it does not pre-empt processing once that tx is found.
480+ let reception = OffchainReception ::load_all (address , scope ).get (0 );
481+ assert (reception .step (Option ::some (resolved_tx (tx_hash )), MAX_MSG_TTL + 1 ).is_some ());
482+ assert (OffchainReception ::is_processed (address , scope , msg ), "should be processed" );
483+ assert (OffchainReception ::is_active (address , scope , msg ), "just-processed reception should stay active" );
484+ });
485+ }
421486}
0 commit comments