Skip to content

Commit c2b51c6

Browse files
authored
feat: merge-train/fairies-v5 (#24455)
BEGIN_COMMIT_OVERRIDE feat: use fact origin block state in offchain reception (#24292) END_COMMIT_OVERRIDE
2 parents 413a8d2 + ed3fbfc commit c2b51c6

2 files changed

Lines changed: 106 additions & 41 deletions

File tree

noir-projects/aztec-nr/aztec/src/messages/processing/offchain/mod.nr

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ pub global MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL: u32 = 16;
2525
/// of, etc.
2626
///
2727
/// The only current implementation of an [`OffchainInboxSync`] is [`sync_inbox`], which manages an inbox with
28-
/// expiration
29-
/// based eviction and automatic transaction context resolution.
28+
/// expiration and finality-based eviction and automatic transaction context resolution.
3029
pub type OffchainInboxSync = unconstrained fn(
3130
/* contract_address */AztecAddress, /* scope */ AztecAddress) -> EphemeralArray<OffchainMessageWithContext>;
3231

@@ -40,7 +39,8 @@ pub type OffchainInboxSync = unconstrained fn(
4039
/// Messages are processed when their originating transaction is found onchain (providing the context needed to
4140
/// validate resulting notes and events).
4241
///
43-
/// Messages are kept in the inbox until they expire. The expiration is set to `anchor_block_timestamp + MAX_MSG_TTL`
42+
/// Messages are eventually removed from the inbox: an unprocessed message once its TTL (`anchor_block_timestamp +
43+
/// MAX_MSG_TTL`) elapses, and a processed message once the block its transaction was found in finalizes.
4444
///
4545
/// Processing order is not guaranteed.
4646
pub unconstrained fn receive(

noir-projects/aztec-nr/aztec/src/messages/processing/offchain/reception.nr

Lines changed: 103 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
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) |
@@ -28,19 +28,22 @@
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
//!
@@ -50,15 +53,13 @@
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
//!
@@ -81,15 +82,14 @@
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

Comments
 (0)