@@ -9,20 +9,18 @@ use crate::types::{
99} ;
1010use anyhow:: Context ;
1111use ed25519_dalek:: SigningKey ;
12- use mpc_attestation:: attestation:: DEFAULT_EXPIRATION_DURATION_SECONDS ;
1312use near_account_id:: AccountId ;
1413use near_indexer_primitives:: types:: Gas ;
1514use near_mpc_contract_interface:: types:: { Attestation , Ed25519PublicKey , VerifiedAttestation } ;
1615use near_time:: Clock ;
1716use std:: future:: Future ;
1817use std:: sync:: Arc ;
19- use std:: time:: { Duration , SystemTime , UNIX_EPOCH } ;
18+ use std:: time:: Duration ;
2019use tokio:: sync:: { mpsc, oneshot} ;
2120use tokio:: time;
2221
2322const TRANSACTION_PROCESSOR_CHANNEL_SIZE : usize = 10000 ;
2423const TRANSACTION_TIMEOUT : Duration = Duration :: from_secs ( 10 ) ;
25- const MAX_ATTESTATION_AGE : Duration = Duration :: from_secs ( 60 * 2 ) ;
2624
2725pub trait TransactionSender : Clone + Send + Sync {
2826 fn send (
@@ -183,10 +181,61 @@ async fn submit_tx(
183181 } )
184182}
185183
184+ /// Reads the attestation expiry currently stored for a `submit_participant_info` request, to
185+ /// use as the pre-submit baseline for the advanced-expiry confirmation in [`observe_tx_result`].
186+ /// Must be called before submitting. Returns `Ok(None)` for other request types or when no
187+ /// Dstack attestation is stored yet.
188+ async fn read_pre_submit_attestation_expiry (
189+ indexer_state : & IndexerState ,
190+ request : & ChainSendTransactionRequest ,
191+ ) -> anyhow:: Result < Option < u64 > > {
192+ let SubmitParticipantInfo ( submit_participant_info_args) = request else {
193+ return Ok ( None ) ;
194+ } ;
195+
196+ let stored_attestation = indexer_state
197+ . view_client
198+ . get_participant_attestation (
199+ & indexer_state. mpc_contract_id ,
200+ & submit_participant_info_args. tls_public_key ,
201+ )
202+ . await ?;
203+
204+ Ok ( match stored_attestation {
205+ Some ( VerifiedAttestation :: Dstack ( attestation) ) => {
206+ Some ( attestation. expiry_timestamp_seconds )
207+ }
208+ _ => None ,
209+ } )
210+ }
211+
212+ /// Confirms a `submit_participant_info` landed by checking that the stored attestation expiry
213+ /// advanced past `pre_submit_expiry` (the expiry seen before submitting). A successful submit
214+ /// re-stamps expiry to `now + DEFAULT_EXPIRATION_DURATION_SECONDS`, strictly greater than any
215+ /// previously-stored value; a failed submit leaves the existing entry unchanged. Expiry only
216+ /// ever advances via our own submit for this key, so this never yields a false positive.
217+ ///
218+ /// A `None` baseline means nothing was stored before, so a stored attestation now means it landed.
219+ ///
220+ /// We deliberately avoid reconstructing the creation time as `expiry - constant`: that breaks
221+ /// under node/contract version skew, and the verifier-rotation expiry cap makes stored expiry no
222+ /// longer equal `creation + constant`.
223+ // TODO(#1637): confirm via a creation timestamp read from the certificate itself.
224+ fn attestation_expiry_advanced ( pre_submit_expiry : Option < u64 > , stored_expiry : u64 ) -> bool {
225+ match pre_submit_expiry {
226+ Some ( expiry_before_submit) => stored_expiry > expiry_before_submit,
227+ None => true ,
228+ }
229+ }
230+
186231/// Confirms whether the intended effect of the transaction request has been observed on chain.
232+ ///
233+ /// `pre_submit_expiry` is the attestation expiry read *before* submitting, used by the
234+ /// `submit_participant_info` confirmation (see [`read_pre_submit_attestation_expiry`]).
187235async fn observe_tx_result (
188236 indexer_state : Arc < IndexerState > ,
189237 request : & ChainSendTransactionRequest ,
238+ pre_submit_expiry : anyhow:: Result < Option < u64 > > ,
190239) -> anyhow:: Result < TransactionStatus > {
191240 match request {
192241 Respond ( respond_args) => {
@@ -257,45 +306,11 @@ async fn observe_tx_result(
257306
258307 let submitted_attestation_is_on_chain =
259308 match ( stored_attestation, submitted_attestation) {
260- (
261- VerifiedAttestation :: Dstack ( verified_dstack_attestation) ,
262- Attestation :: Dstack ( _) ,
263- ) => {
264- // Check if the attestation stored on chain is fresh by verifying its age
265- // is less than `MAX_ATTESTATION_AGE`
266- //
267- // TODO(#1637): extract expiration timestamp from the certificate itself,
268- // instead of using heuristics.
269- let expiry_timestamp_seconds =
270- verified_dstack_attestation. expiry_timestamp_seconds ;
271-
272- let Some ( attestation_duration_since_unix_epoch) = expiry_timestamp_seconds
273- . checked_sub ( DEFAULT_EXPIRATION_DURATION_SECONDS )
274- . map ( Duration :: from_secs)
275- else {
276- tracing:: error!(
277- ?expiry_timestamp_seconds,
278- "could not calculate attestation storage time"
279- ) ;
280-
281- return Ok ( TransactionStatus :: NotExecuted ) ;
282- } ;
283-
284- let timestamp_seconds_now = SystemTime :: now ( )
285- . duration_since ( UNIX_EPOCH )
286- . context ( "could not calculate system time" ) ?;
287-
288- let attestation_age =
289- attestation_duration_since_unix_epoch. abs_diff ( timestamp_seconds_now) ;
290- let attestation_is_fresh = attestation_age < MAX_ATTESTATION_AGE ;
291-
292- tracing:: info!(
293- ?attestation_age,
294- ?attestation_is_fresh,
295- "node found dstack attestation on chain"
296- ) ;
297-
298- attestation_is_fresh
309+ ( VerifiedAttestation :: Dstack ( stored) , Attestation :: Dstack ( _) ) => {
310+ attestation_expiry_advanced (
311+ pre_submit_expiry?,
312+ stored. expiry_timestamp_seconds ,
313+ )
299314 }
300315 (
301316 VerifiedAttestation :: Mock ( stored_mock_attestation) ,
@@ -338,6 +353,11 @@ async fn ensure_send_transaction(
338353 public_key : Ed25519PublicKey :: from ( & tx_signer. public_key ( ) ) ,
339354 method,
340355 } ;
356+ // Baseline for the submit_participant_info confirmation: the attestation expiry stored
357+ // before we submit. Captured before submit_tx so a successful submit is detected as an
358+ // advance in observe_tx_result.
359+ let pre_submit_expiry = read_pre_submit_attestation_expiry ( & indexer_state, & request) . await ;
360+
341361 let submitted_metadata = submit_tx (
342362 tx_signer. clone ( ) ,
343363 indexer_state. clone ( ) ,
@@ -369,7 +389,8 @@ async fn ensure_send_transaction(
369389 time:: sleep ( TRANSACTION_TIMEOUT ) . await ;
370390
371391 // Then try to check whether it had the intended effect
372- let transaction_status = observe_tx_result ( indexer_state. clone ( ) , & request) . await ;
392+ let transaction_status =
393+ observe_tx_result ( indexer_state. clone ( ) , & request, pre_submit_expiry) . await ;
373394
374395 let ( outcome_label, recorded_status) = match & transaction_status {
375396 Ok ( TransactionStatus :: Executed ) => ( "succeeded" , SubmittedTransactionStatus :: Executed ) ,
@@ -391,3 +412,62 @@ async fn ensure_send_transaction(
391412 SubmittedTransaction :: submitted ( signer, metadata, recorded_status, submitted_at) ,
392413 )
393414}
415+
416+ #[ cfg( test) ]
417+ mod tests {
418+ use super :: attestation_expiry_advanced;
419+
420+ #[ test]
421+ #[ expect( non_snake_case) ]
422+ fn attestation_expiry_advanced__should_confirm_when_expiry_advances ( ) {
423+ // Given: an attestation was stored before submitting
424+ let pre_submit_expiry = Some ( 100 ) ;
425+
426+ // When: the stored expiry is now higher than before
427+ let landed = attestation_expiry_advanced ( pre_submit_expiry, 200 ) ;
428+
429+ // Then: our submission is confirmed to have landed
430+ assert ! ( landed) ;
431+ }
432+
433+ #[ test]
434+ #[ expect( non_snake_case) ]
435+ fn attestation_expiry_advanced__should_reject_when_expiry_unchanged ( ) {
436+ // Given: an attestation was stored before submitting
437+ let pre_submit_expiry = Some ( 200 ) ;
438+
439+ // When: the stored expiry is unchanged (our submit did not land)
440+ let landed = attestation_expiry_advanced ( pre_submit_expiry, 200 ) ;
441+
442+ // Then: the submission is treated as not executed
443+ assert ! ( !landed) ;
444+ }
445+
446+ #[ test]
447+ #[ expect( non_snake_case) ]
448+ fn attestation_expiry_advanced__should_reject_when_expiry_regresses ( ) {
449+ // Given: an attestation was stored before submitting, and a verifier rotation has since
450+ // capped the stored expiry to a lower value
451+ let pre_submit_expiry = Some ( 300 ) ;
452+
453+ // When: the stored expiry is now lower than before
454+ let landed = attestation_expiry_advanced ( pre_submit_expiry, 200 ) ;
455+
456+ // Then: the submission is treated as not executed (a harmless false negative that
457+ // self-heals on the next resubmission, never a false positive)
458+ assert ! ( !landed) ;
459+ }
460+
461+ #[ test]
462+ #[ expect( non_snake_case) ]
463+ fn attestation_expiry_advanced__should_confirm_when_no_prior_attestation ( ) {
464+ // Given: no attestation was stored before submitting
465+ let pre_submit_expiry = None ;
466+
467+ // When: an attestation is now stored
468+ let landed = attestation_expiry_advanced ( pre_submit_expiry, 200 ) ;
469+
470+ // Then: its presence confirms our submission landed
471+ assert ! ( landed) ;
472+ }
473+ }
0 commit comments