Skip to content

Commit 7fb2d91

Browse files
committed
fix(node): confirm submit_participant_info via advancing attestation expiry
The post-submit confirmation reconstructed the attestation's creation time as `stored_expiry - DEFAULT_EXPIRATION_DURATION_SECONDS`. This breaks when the node and contract compile different values for that constant (version skew), making the reconstructed age far exceed the freshness bound so every submission is treated as NotExecuted and retried indefinitely. Confirm instead that the stored expiry advanced past the value observed before submitting: a successful submit re-stamps expiry forward, a failed one leaves it unchanged. This drops the shared-constant dependency and also holds under the planned verifier-rotation expiry cap (#3734), where stored expiry is no longer `creation + constant`.
1 parent 187fffa commit 7fb2d91

1 file changed

Lines changed: 123 additions & 43 deletions

File tree

crates/node/src/indexer/tx_sender.rs

Lines changed: 123 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,18 @@ use crate::types::{
99
};
1010
use anyhow::Context;
1111
use ed25519_dalek::SigningKey;
12-
use mpc_attestation::attestation::DEFAULT_EXPIRATION_DURATION_SECONDS;
1312
use near_account_id::AccountId;
1413
use near_indexer_primitives::types::Gas;
1514
use near_mpc_contract_interface::types::{Attestation, Ed25519PublicKey, VerifiedAttestation};
1615
use near_time::Clock;
1716
use std::future::Future;
1817
use std::sync::Arc;
19-
use std::time::{Duration, SystemTime, UNIX_EPOCH};
18+
use std::time::Duration;
2019
use tokio::sync::{mpsc, oneshot};
2120
use tokio::time;
2221

2322
const TRANSACTION_PROCESSOR_CHANNEL_SIZE: usize = 10000;
2423
const TRANSACTION_TIMEOUT: Duration = Duration::from_secs(10);
25-
const MAX_ATTESTATION_AGE: Duration = Duration::from_secs(60 * 2);
2624

2725
pub 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`]).
187235
async 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

Comments
 (0)