@@ -181,24 +181,19 @@ async fn submit_tx(
181181 } )
182182}
183183
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 (
184+ /// Reads the Dstack attestation expiry currently stored on chain for `tls_public_key`, or `None`
185+ /// if none is stored. This is the baseline for confirming our own `submit_participant_info` landed
186+ /// (see [`confirm_participant_info_submission`]): a successful submit *changes* the stored expiry,
187+ /// so the baseline is read *before* submitting and compared afterwards. It is read per attempt
188+ /// (where the view client lives); correctness does not depend on re-reading, since the confirmation
189+ /// only checks that the expiry changed (see [`attestation_expiry_changed`]).
190+ async fn read_stored_dstack_expiry (
189191 indexer_state : & IndexerState ,
190- request : & ChainSendTransactionRequest ,
192+ tls_public_key : & Ed25519PublicKey ,
191193) -> anyhow:: Result < Option < u64 > > {
192- let SubmitParticipantInfo ( submit_participant_info_args) = request else {
193- return Ok ( None ) ;
194- } ;
195-
196194 let stored_attestation = indexer_state
197195 . view_client
198- . get_participant_attestation (
199- & indexer_state. mpc_contract_id ,
200- & submit_participant_info_args. tls_public_key ,
201- )
196+ . get_participant_attestation ( & indexer_state. mpc_contract_id , tls_public_key)
202197 . await ?;
203198
204199 Ok ( match stored_attestation {
@@ -209,46 +204,102 @@ async fn read_pre_submit_attestation_expiry(
209204 } )
210205}
211206
212- /// Confirms a `submit_participant_info` landed by checking the stored expiry advanced past the
213- /// pre-submit baseline: a successful submit re-stamps expiry forward, a failed one leaves it
214- /// unchanged, and expiry only advances via our own submit for this key — so this never yields a
215- /// false positive. Returns `true` iff `stored_expiry > pre_submit_expiry`, or there is no baseline
216- /// (`None`).
207+ /// Confirms a `submit_participant_info` landed by checking the stored expiry *changed* from the
208+ /// pre-submit baseline: a successful submit re-stamps the expiry to a new value, while a failed one
209+ /// leaves it untouched. Returns `true` iff `stored_expiry != pre_submit_expiry`, or there is no
210+ /// baseline (`None`, i.e. nothing was stored before).
217211///
218- /// Avoids reconstructing creation time as `expiry - constant`, which breaks under node/contract
219- /// version skew and the verifier-rotation expiry cap (stored expiry is not `creation + constant`).
212+ /// We compare for inequality rather than `stored_expiry > baseline`: a contract upgrade that lowers
213+ /// the expiration constant can make a landed submit set an *earlier* expiry than a stale stored
214+ /// entry, which `>` would miss. The only thing that changes our key's expiry other than our own
215+ /// submit is the verifier-rotation cap (#3734) lowering it — a rare race that would read as landed,
216+ /// bounded and self-correcting via the hourly resubmit. Avoids reconstructing the creation time as
217+ /// `expiry - constant`, which breaks under node/contract version skew and under that cap.
220218// TODO(#1639): confirm via a creation timestamp read from the certificate itself.
221- fn attestation_expiry_advanced ( pre_submit_expiry : Option < u64 > , stored_expiry : u64 ) -> bool {
219+ fn attestation_expiry_changed ( pre_submit_expiry : Option < u64 > , stored_expiry : u64 ) -> bool {
222220 match pre_submit_expiry {
223- Some ( expiry_before_submit) => stored_expiry > expiry_before_submit,
221+ Some ( expiry_before_submit) => stored_expiry != expiry_before_submit,
224222 None => true ,
225223 }
226224}
227225
228- /// Whether the attestation we submitted is the one now stored on chain. Dstack is confirmed via
229- /// [`attestation_expiry_advanced`]; Mock (tests) by direct equality. Mismatched kinds never match.
226+ /// Whether the attestation we submitted is the one now stored on chain.
227+ ///
228+ /// Mock attestations (tests) carry a full identity, so we match `submitted` against `stored`
229+ /// directly. Dstack can't be matched that way: the stored `VerifiedDstackAttestation` is a
230+ /// different type from the submitted `DstackAttestation` and keeps no per-submission identity (no
231+ /// creation time) to compare on — so `submitted` is unused in that arm and we confirm indirectly,
232+ /// via [`attestation_expiry_changed`] against the pre-submit baseline.
233+ // TODO(#1639): give Dstack a real per-submission identity (a certificate creation timestamp) so it
234+ // can be matched directly like Mock, instead of via the expiry-change heuristic.
230235fn submitted_attestation_landed (
231236 pre_submit_expiry : Option < u64 > ,
232237 stored : & VerifiedAttestation ,
233238 submitted : & Attestation ,
234239) -> bool {
235240 match ( stored, submitted) {
236241 ( VerifiedAttestation :: Dstack ( stored) , Attestation :: Dstack ( _) ) => {
237- attestation_expiry_advanced ( pre_submit_expiry, stored. expiry_timestamp_seconds )
242+ attestation_expiry_changed ( pre_submit_expiry, stored. expiry_timestamp_seconds )
238243 }
239244 ( VerifiedAttestation :: Mock ( stored) , Attestation :: Mock ( submitted) ) => stored == submitted,
240245 _ => false ,
241246 }
242247}
243248
249+ /// Confirms whether a `submit_participant_info` landed: reads the currently-stored attestation and
250+ /// checks, via [`submitted_attestation_landed`], that it matches what we submitted. `pre_submit_expiry`
251+ /// is the expiry observed *before* submitting (see [`read_stored_dstack_expiry`]), used as the
252+ /// baseline for the Dstack expiry-advance check.
253+ async fn confirm_participant_info_submission (
254+ indexer_state : & IndexerState ,
255+ tls_public_key : & Ed25519PublicKey ,
256+ submitted_attestation : & Attestation ,
257+ pre_submit_expiry : anyhow:: Result < Option < u64 > > ,
258+ ) -> anyhow:: Result < TransactionStatus > {
259+ let stored_attestation = indexer_state
260+ . view_client
261+ . get_participant_attestation ( & indexer_state. mpc_contract_id , tls_public_key)
262+ . await ?;
263+
264+ let Some ( stored_attestation) = stored_attestation else {
265+ tracing:: debug!(
266+ ?tls_public_key,
267+ "no attestation stored on chain for our key; submission not yet landed"
268+ ) ;
269+ return Ok ( TransactionStatus :: NotExecuted ) ;
270+ } ;
271+
272+ let pre_submit_expiry = pre_submit_expiry?;
273+ let stored_expiry = match & stored_attestation {
274+ VerifiedAttestation :: Dstack ( stored) => Some ( stored. expiry_timestamp_seconds ) ,
275+ VerifiedAttestation :: Mock ( _) => None ,
276+ } ;
277+ let attestation_landed = submitted_attestation_landed (
278+ pre_submit_expiry,
279+ & stored_attestation,
280+ submitted_attestation,
281+ ) ;
282+
283+ tracing:: info!(
284+ ?pre_submit_expiry,
285+ ?stored_expiry,
286+ attestation_landed,
287+ "checked attestation submission on chain"
288+ ) ;
289+
290+ Ok ( if attestation_landed {
291+ TransactionStatus :: Executed
292+ } else {
293+ TransactionStatus :: NotExecuted
294+ } )
295+ }
296+
244297/// Confirms whether the intended effect of the transaction request has been observed on chain.
245- ///
246- /// `pre_submit_expiry` is the attestation expiry read *before* submitting, used by the
247- /// `submit_participant_info` confirmation (see [`read_pre_submit_attestation_expiry`]).
298+ /// `SubmitParticipantInfo` is confirmed separately by [`confirm_participant_info_submission`] (it
299+ /// needs a pre-submit baseline), so it is never routed here.
248300async fn observe_tx_result (
249301 indexer_state : Arc < IndexerState > ,
250302 request : & ChainSendTransactionRequest ,
251- pre_submit_expiry : anyhow:: Result < Option < u64 > > ,
252303) -> anyhow:: Result < TransactionStatus > {
253304 match request {
254305 Respond ( respond_args) => {
@@ -302,48 +353,10 @@ async fn observe_tx_result(
302353
303354 Ok ( transaction_status)
304355 }
305- SubmitParticipantInfo ( submit_participant_info_args) => {
306- let tls_public_key = & submit_participant_info_args. tls_public_key ;
307-
308- let attestation_stored_on_contract = indexer_state
309- . view_client
310- . get_participant_attestation ( & indexer_state. mpc_contract_id , tls_public_key)
311- . await ?;
312-
313- let Some ( stored_attestation) = attestation_stored_on_contract else {
314- tracing:: debug!(
315- ?tls_public_key,
316- "no attestation stored on chain for our key; submission not yet landed"
317- ) ;
318- return Ok ( TransactionStatus :: NotExecuted ) ;
319- } ;
320-
321- let submitted_attestation =
322- & submit_participant_info_args. proposed_participant_attestation ;
323-
324- let pre_submit_expiry = pre_submit_expiry?;
325- let stored_expiry = match & stored_attestation {
326- VerifiedAttestation :: Dstack ( stored) => Some ( stored. expiry_timestamp_seconds ) ,
327- VerifiedAttestation :: Mock ( _) => None ,
328- } ;
329- let attestation_landed = submitted_attestation_landed (
330- pre_submit_expiry,
331- & stored_attestation,
332- submitted_attestation,
333- ) ;
334-
335- tracing:: info!(
336- ?pre_submit_expiry,
337- ?stored_expiry,
338- attestation_landed,
339- "checked attestation submission on chain"
340- ) ;
341-
342- if attestation_landed {
343- Ok ( TransactionStatus :: Executed )
344- } else {
345- Ok ( TransactionStatus :: NotExecuted )
346- }
356+ SubmitParticipantInfo ( _) => {
357+ unreachable ! (
358+ "submit_participant_info is confirmed by confirm_participant_info_submission"
359+ )
347360 }
348361 // We don't care. The contract state change will handle this.
349362 StartKeygen ( _)
@@ -373,10 +386,14 @@ async fn ensure_send_transaction(
373386 public_key : Ed25519PublicKey :: from ( & tx_signer. public_key ( ) ) ,
374387 method,
375388 } ;
376- // Baseline for the submit_participant_info confirmation: the attestation expiry stored
377- // before we submit. Captured before submit_tx so a successful submit is detected as an
378- // advance in observe_tx_result.
379- let pre_submit_expiry = read_pre_submit_attestation_expiry ( & indexer_state, & request) . await ;
389+ // Only submit_participant_info needs a pre-submit baseline (its confirmation checks that the
390+ // stored expiry advanced); read it before submitting, and only for that request type.
391+ let pre_submit_expiry = match & request {
392+ SubmitParticipantInfo ( args) => {
393+ read_stored_dstack_expiry ( & indexer_state, & args. tls_public_key ) . await
394+ }
395+ _ => Ok ( None ) ,
396+ } ;
380397
381398 let submitted_metadata = submit_tx (
382399 tx_signer. clone ( ) ,
@@ -409,8 +426,18 @@ async fn ensure_send_transaction(
409426 time:: sleep ( TRANSACTION_TIMEOUT ) . await ;
410427
411428 // Then try to check whether it had the intended effect
412- let transaction_status =
413- observe_tx_result ( indexer_state. clone ( ) , & request, pre_submit_expiry) . await ;
429+ let transaction_status = match & request {
430+ SubmitParticipantInfo ( args) => {
431+ confirm_participant_info_submission (
432+ & indexer_state,
433+ & args. tls_public_key ,
434+ & args. proposed_participant_attestation ,
435+ pre_submit_expiry,
436+ )
437+ . await
438+ }
439+ _ => observe_tx_result ( indexer_state. clone ( ) , & request) . await ,
440+ } ;
414441
415442 let ( outcome_label, recorded_status) = match & transaction_status {
416443 Ok ( TransactionStatus :: Executed ) => ( "succeeded" , SubmittedTransactionStatus :: Executed ) ,
@@ -436,59 +463,59 @@ async fn ensure_send_transaction(
436463#[ cfg( test) ]
437464mod tests {
438465 use super :: {
439- Attestation , VerifiedAttestation , attestation_expiry_advanced , submitted_attestation_landed,
466+ Attestation , VerifiedAttestation , attestation_expiry_changed , submitted_attestation_landed,
440467 } ;
441468 use near_mpc_contract_interface:: types:: MockAttestation ;
442469
443470 #[ test]
444471 #[ expect( non_snake_case) ]
445- fn attestation_expiry_advanced__should_confirm_when_expiry_advances ( ) {
472+ fn attestation_expiry_changed__should_confirm_when_expiry_increases ( ) {
446473 // Given: an attestation was stored before submitting
447474 let pre_submit_expiry = Some ( 100 ) ;
448475
449- // When: the stored expiry is now higher than before
450- let landed = attestation_expiry_advanced ( pre_submit_expiry, 200 ) ;
476+ // When: the stored expiry is now higher than before (a fresh submit landed)
477+ let landed = attestation_expiry_changed ( pre_submit_expiry, 200 ) ;
451478
452479 // Then: our submission is confirmed to have landed
453480 assert ! ( landed) ;
454481 }
455482
456483 #[ test]
457484 #[ expect( non_snake_case) ]
458- fn attestation_expiry_advanced__should_reject_when_expiry_unchanged ( ) {
485+ fn attestation_expiry_changed__should_reject_when_expiry_unchanged ( ) {
459486 // Given: an attestation was stored before submitting
460487 let pre_submit_expiry = Some ( 200 ) ;
461488
462489 // When: the stored expiry is unchanged (our submit did not land)
463- let landed = attestation_expiry_advanced ( pre_submit_expiry, 200 ) ;
490+ let landed = attestation_expiry_changed ( pre_submit_expiry, 200 ) ;
464491
465492 // Then: the submission is treated as not executed
466493 assert ! ( !landed) ;
467494 }
468495
469496 #[ test]
470497 #[ expect( non_snake_case) ]
471- fn attestation_expiry_advanced__should_reject_when_expiry_regresses ( ) {
472- // Given: an attestation was stored before submitting, and a verifier rotation has since
473- // capped the stored expiry to a lower value
498+ fn attestation_expiry_changed__should_confirm_when_expiry_decreases ( ) {
499+ // Given: an attestation was stored before submitting, and a contract upgrade has lowered
500+ // the expiration constant, so a landed submit now stamps an *earlier* expiry
474501 let pre_submit_expiry = Some ( 300 ) ;
475502
476503 // When: the stored expiry is now lower than before
477- let landed = attestation_expiry_advanced ( pre_submit_expiry, 200 ) ;
504+ let landed = attestation_expiry_changed ( pre_submit_expiry, 200 ) ;
478505
479- // Then: the submission is treated as not executed (a harmless false negative that
480- // self-heals on the next resubmission, never a false positive )
481- assert ! ( ! landed) ;
506+ // Then: the change still confirms our submission landed (this is why we compare for
507+ // inequality rather than a strict increase )
508+ assert ! ( landed) ;
482509 }
483510
484511 #[ test]
485512 #[ expect( non_snake_case) ]
486- fn attestation_expiry_advanced__should_confirm_when_no_prior_attestation ( ) {
513+ fn attestation_expiry_changed__should_confirm_when_no_prior_attestation ( ) {
487514 // Given: no attestation was stored before submitting
488515 let pre_submit_expiry = None ;
489516
490517 // When: an attestation is now stored
491- let landed = attestation_expiry_advanced ( pre_submit_expiry, 200 ) ;
518+ let landed = attestation_expiry_changed ( pre_submit_expiry, 200 ) ;
492519
493520 // Then: its presence confirms our submission landed
494521 assert ! ( landed) ;
0 commit comments