Skip to content

Commit 7f52f01

Browse files
authored
test: add grace period test for multi-node voting (#1183)
1 parent 3d21df4 commit 7f52f01

File tree

1 file changed

+173
-2
lines changed

1 file changed

+173
-2
lines changed

crates/contract/tests/inprocess/expired_attestation.rs

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use near_sdk::{
1919
test_utils::VMContextBuilder, testing_env, AccountId, CurveType, NearToken, PublicKey,
2020
VMContext,
2121
};
22-
use std::time::Duration;
22+
use std::{collections::HashSet, time::Duration};
2323

2424
const SECOND: Duration = Duration::from_secs(1);
2525
const NANOS_IN_SECOND: u64 = SECOND.as_nanos() as u64;
@@ -61,12 +61,21 @@ impl TestSetup {
6161
}
6262

6363
fn submit_attestation_for_node(&mut self, node_id: &NodeId, attestation: Attestation) {
64+
self.try_submit_attestation_for_node(node_id, attestation)
65+
.unwrap();
66+
}
67+
68+
/// Try to submit attestation and return Result for testing failures
69+
fn try_submit_attestation_for_node(
70+
&mut self,
71+
node_id: &NodeId,
72+
attestation: Attestation,
73+
) -> Result<(), mpc_contract::errors::Error> {
6474
let context = create_context_for_participant(&node_id.account_id);
6575
testing_env!(context);
6676
let tls_key_bytes: [u8; 32] = node_id.tls_public_key.as_bytes()[1..].try_into().unwrap();
6777
self.contract
6878
.submit_participant_info(attestation, Ed25519PublicKey::from(tls_key_bytes))
69-
.unwrap();
7079
}
7180

7281
/// Switches testing context to a given participant at a specific timestamp
@@ -84,11 +93,32 @@ impl TestSetup {
8493
self.contract.vote_code_hash(hash.into()).unwrap();
8594
}
8695
}
96+
97+
/// Get NodeIds created from the existing participants
98+
fn get_participant_node_ids(&self) -> Vec<NodeId> {
99+
self.participants_list
100+
.iter()
101+
.map(|(account_id, _, participant_info)| NodeId {
102+
account_id: account_id.clone(),
103+
tls_public_key: participant_info.sign_pk.clone(),
104+
})
105+
.collect()
106+
}
107+
108+
/// Helper to create attestation with hash constraints
109+
fn create_hash_attestation(hash: [u8; 32]) -> Attestation {
110+
Attestation::Mock(MockAttestation::WithConstraints {
111+
mpc_docker_image_hash: Some(hash),
112+
launcher_docker_compose_hash: None,
113+
expiry_time_stamp_seconds: None,
114+
})
115+
}
87116
}
88117

89118
fn create_context_for_participant(account_id: &AccountId) -> VMContext {
90119
VMContextBuilder::new()
91120
.signer_account_id(account_id.clone())
121+
.block_timestamp(near_sdk::env::block_timestamp())
92122
.build()
93123
}
94124

@@ -437,3 +467,144 @@ fn latest_image_never_expires_if_its_not_superseded() {
437467
assert_eq!(allowed_image_hashes.len(), 1);
438468
assert_eq!(*allowed_image_hashes[0], only_image_code_hash);
439469
}
470+
471+
/// **Test for nodes starting with old but valid image hashes during grace period**
472+
///
473+
/// This test simulates the scenario where new nodes join the network running
474+
/// older Docker image versions that are still within their grace period.
475+
/// It verifies that:
476+
/// 1. Multiple image versions can coexist during their grace periods
477+
/// 2. New nodes can successfully submit attestations with older but valid hashes
478+
/// 3. Nodes running older images remain valid until their specific grace period expires
479+
/// 4. The contract accepts attestations from nodes with any currently allowed hash
480+
///
481+
/// This validates the scenario where nodes may start up with slightly
482+
/// older images after new ones have been voted in, as long as they're still
483+
/// within the tee_upgrade_deadline_duration.
484+
///
485+
/// **Timeline Visualization (Grace Period = 15s):**
486+
/// ```
487+
/// Time: T=1s T=4s T=7s T=10s T=19s T=20s T=22s T=23s
488+
/// │ │ │ │ │ │ │ │
489+
/// v1 hash: ●─────────────────────────────────────────────────X (expires)
490+
/// v2 hash: ●────────────────────────────────────────────────────────────────X (expires)
491+
/// v3 hash: ●────────────────────────────────────────────────────────────→ (never expires)
492+
/// │ │ │ │ │ │ │ │
493+
/// Events: │ │ │ │ │ │ │ │
494+
/// v1 v2 v3 Test all v1 exp Check v1 v2 exp Check v2
495+
/// vote vote vote 3 versions @ T=19s expired @ T=22s expired
496+
/// still valid only v2,v3 only v3
497+
///
498+
/// Grace Period Rules:
499+
/// - v1 expires at: T=4s + 15s + 1s = T=20s
500+
/// - v2 expires at: T=7s + 15s + 1s = T=23s
501+
/// - v3 never expires (no successor hash)
502+
///
503+
/// Note: The +1s ensures we test *after* the grace period deadline has passed.
504+
/// Without it, the hash would still be valid exactly at the deadline timestamp.
505+
/// ```
506+
#[test]
507+
fn nodes_can_start_with_old_valid_hashes_during_grace_period() {
508+
const INITIAL_TIME_NANOS: u64 = NANOS_IN_SECOND;
509+
const GRACE_PERIOD_SECONDS: u64 = 15;
510+
const GRACE_PERIOD_NANOS: u64 = GRACE_PERIOD_SECONDS * NANOS_IN_SECOND;
511+
const HASH_DEPLOYMENT_INTERVAL_NANOS: u64 = 3 * NANOS_IN_SECOND;
512+
513+
let init_config = InitConfig {
514+
tee_upgrade_deadline_duration_seconds: Some(GRACE_PERIOD_SECONDS),
515+
..Default::default()
516+
};
517+
let mut test_setup = TestSetup::new(3, 2, Some(init_config));
518+
519+
let hash_v1 = [1; 32]; // Original version
520+
let hash_v2 = [2; 32]; // Updated version
521+
let hash_v3 = [3; 32]; // Latest version
522+
523+
// Deploy three hash versions at 3-second intervals (T=1s, T=4s, T=7s)
524+
let hashes = [hash_v1, hash_v2, hash_v3];
525+
let mut deployment_times = Vec::new();
526+
let mut deployment_time = INITIAL_TIME_NANOS;
527+
528+
for &hash in hashes.iter() {
529+
test_setup.vote_with_all_participants(hash, deployment_time);
530+
deployment_times.push(deployment_time);
531+
deployment_time += HASH_DEPLOYMENT_INTERVAL_NANOS;
532+
}
533+
534+
// At T=10s: All three versions should be allowed (within grace periods)
535+
let test_time_1 = deployment_times[2] + 3 * NANOS_IN_SECOND;
536+
set_system_time(test_time_1);
537+
538+
let allowed_set: HashSet<_> = test_setup
539+
.contract
540+
.allowed_code_hashes()
541+
.iter()
542+
.map(|h| **h)
543+
.collect();
544+
let expected_set: HashSet<_> = hashes.into_iter().collect();
545+
546+
assert_eq!(allowed_set, expected_set);
547+
548+
// Use existing participant nodes for testing different hash versions
549+
let node_ids = test_setup.get_participant_node_ids();
550+
551+
// Test that nodes can submit attestations with all hash versions at T=10s
552+
// All attestations should succeed during grace period (current time: T=10s)
553+
for (node, &hash) in node_ids.iter().zip(hashes.iter()) {
554+
let attestation = TestSetup::create_hash_attestation(hash);
555+
test_setup.submit_attestation_for_node(node, attestation);
556+
}
557+
558+
// Advance to T=19s: hash_v1 should expire (v2 deployed at T=4s + 15s grace = T=19s)
559+
// Note: v1 expires when its successor's (v2) grace period ends, not when v1's own grace period ends
560+
let v1_expiry_time = deployment_times[1] + GRACE_PERIOD_NANOS;
561+
562+
// +1s ensures we're testing *after* expiration occurs - at T=19s the hash is still valid,
563+
// but at T=20s it has expired and should be filtered out by allowed_code_hashes()
564+
set_system_time(v1_expiry_time + NANOS_IN_SECOND); // T=20s
565+
566+
// Verify that only non-expired hashes remain valid at T=20s
567+
// hash_v1 should have already expired, so only hash_v2 and hash_v3 should be allowed
568+
let allowed_after_v1_expiry = test_setup.contract.allowed_code_hashes();
569+
let allowed_set_after_v1_expiry: HashSet<_> =
570+
allowed_after_v1_expiry.iter().map(|h| **h).collect();
571+
let expected_set_after_v1_expiry: HashSet<_> = [hash_v2, hash_v3].into_iter().collect();
572+
573+
assert_eq!(
574+
allowed_set_after_v1_expiry, expected_set_after_v1_expiry,
575+
"Only hash_v2 and hash_v3 should remain valid at T=20s (hash_v1 expired)"
576+
);
577+
578+
// Verify that submitting attestation with expired hash_v1 now fails
579+
let expired_attestation = TestSetup::create_hash_attestation(hash_v1);
580+
let result = test_setup.try_submit_attestation_for_node(&node_ids[0], expired_attestation);
581+
assert!(
582+
result.is_err(),
583+
"Attestation with expired hash_v1 should fail"
584+
);
585+
586+
// Test late-joining nodes at current time T=20s (after hash_v1 expired)
587+
// Only hash_v2 and hash_v3 should be valid for new nodes
588+
// Reuse existing node_ids (nodes 2 and 3 since hash_v1 expired)
589+
for (node, &hash) in node_ids[1..]
590+
.iter()
591+
.zip(expected_set_after_v1_expiry.iter())
592+
{
593+
let late_attestation = TestSetup::create_hash_attestation(hash);
594+
test_setup.submit_attestation_for_node(node, late_attestation);
595+
}
596+
597+
// Advance to T=22s: hash_v2 should expire (v3 deployed at T=7s + 15s grace = T=22s)
598+
let v2_expiry_time = deployment_times[2] + GRACE_PERIOD_NANOS;
599+
set_system_time(v2_expiry_time + NANOS_IN_SECOND); // T=23s
600+
601+
let final_allowed_hashes = test_setup.contract.allowed_code_hashes();
602+
assert_eq!(final_allowed_hashes.len(), 1, "Only hash_v3 should remain");
603+
assert_eq!(*final_allowed_hashes[0], hash_v3);
604+
605+
// Verify that only the latest hash is now accepted
606+
// Reuse the third node (index 2) for final validation
607+
let final_attestation = TestSetup::create_hash_attestation(hash_v3);
608+
// This should succeed since hash_v3 is the only remaining valid hash
609+
test_setup.submit_attestation_for_node(&node_ids[2], final_attestation);
610+
}

0 commit comments

Comments
 (0)