Skip to content

Commit 4cd9e8e

Browse files
committed
refactor(node): confirm submit_participant_info off the generic tx path
Address review feedback on the attestation submit-confirmation: - Move the confirmation into a dedicated confirm_participant_info_submission fn; observe_tx_result no longer takes a pre-submit baseline, keeping it off the generic signature. - Read the pre-submit baseline only for submit_participant_info. - Confirm via expiry *changed* (!=) rather than *advanced* (>), so a landed submit is detected even if a lowered expiry constant sets an earlier expiry; correctness no longer depends on re-reading per attempt. - Rewrite comments to explain why (invariant, Mock-vs-Dstack asymmetry). - TODO(#3746): note the retry loop reuses a stale attestation and stops on timeout; to be split into inner+outer loops.
1 parent f89aaf1 commit 4cd9e8e

2 files changed

Lines changed: 124 additions & 93 deletions

File tree

crates/node/src/indexer/tx_sender.rs

Lines changed: 120 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
230235
fn 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.
248300
async 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)]
437464
mod 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);

crates/node/src/tee/remote_attestation.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ pub async fn submit_remote_attestation(
4444
tls_public_key,
4545
);
4646

47+
// TODO(#3746): this loop retries the *same* attestation for up to `MAX_RETRY_DURATION` and
48+
// returns an error on timeout (stopping the caller task), so a late success can store a stale
49+
// attestation. #3746 will split this into an inner submit loop and an outer loop that
50+
// re-generates a fresh attestation.
4751
let set_attestation = move || {
4852
let tx_sender = tx_sender.clone();
4953
let propose_join_args_clone = submit_participant_info_args.clone();

0 commit comments

Comments
 (0)