diff --git a/crates/contract-history/archive/signer-3_14_0.wasm b/crates/contract-history/archive/signer-3_14_0.wasm new file mode 100644 index 0000000000..0cf6cd76d3 Binary files /dev/null and b/crates/contract-history/archive/signer-3_14_0.wasm differ diff --git a/crates/contract-history/src/lib.rs b/crates/contract-history/src/lib.rs index f03faa5849..9c31524c71 100644 --- a/crates/contract-history/src/lib.rs +++ b/crates/contract-history/src/lib.rs @@ -1,9 +1,9 @@ pub const fn current_mainnet() -> &'static [u8] { - version_3_13_0() + version_3_14_0() } pub const fn current_testnet() -> &'static [u8] { - version_3_13_0() + version_3_14_0() } pub const fn version_2_2_0() -> &'static [u8; 566653] { @@ -70,6 +70,10 @@ pub const fn version_3_13_0() -> &'static [u8; 1509901] { include_bytes!("../archive/signer-3_13_0.wasm") } +pub const fn version_3_14_0() -> &'static [u8; 1187525] { + include_bytes!("../archive/signer-3_14_0.wasm") +} + #[cfg(test)] #[cfg(feature = "external-services-tests")] mod tests { diff --git a/crates/contract/src/config.rs b/crates/contract/src/config.rs index a6dc5b4889..9d2c5cd8dc 100644 --- a/crates/contract/src/config.rs +++ b/crates/contract/src/config.rs @@ -42,7 +42,7 @@ const DEFAULT_VERIFIER_TERA_GAS: u64 = 200; /// post-DCAP work (allowlist match, RTMR3 replay, app-compose validation, store). const DEFAULT_RESOLVE_VERIFICATION_TERA_GAS: u64 = 60; /// Default TTL after which a launcher image hash unused by any participant is evicted. -pub(crate) const DEFAULT_LAUNCHER_HASH_UNUSED_TTL_SECONDS: u64 = 14 * 24 * 60 * 60; // 14 days +pub(crate) const DEFAULT_LAUNCHER_HASH_UNUSED_TTL_SECONDS: u64 = 21 * 24 * 60 * 60; // 21 days /// Config for V2 of the contract. #[near(serializers=[borsh, json])] diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index f0515721c4..ca654e4769 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -16,7 +16,7 @@ pub mod update; #[cfg(feature = "dev-utils")] pub mod utils; -pub mod v3_13_0_state; +pub mod v3_14_0_state; #[cfg(feature = "bench-contract-methods")] mod bench; @@ -2125,11 +2125,11 @@ impl MpcContract { pub fn migrate() -> Result { log!("migrating contract"); - match try_state_read::() { + match try_state_read::() { Ok(Some(state)) => return Ok(state.into()), Ok(None) => return Err(InvalidState::ContractStateIsMissing.into()), Err(err) => { - log!("failed to deserialize state into 3.13.0 state: {:?}", err); + log!("failed to deserialize state into 3.14.0 state: {:?}", err); } }; @@ -8513,4 +8513,77 @@ mod tests { {WORST_CASE_ENTRY_COST_CEILING} at today's storage price" ); } + #[test] + fn migrate__should_extend_dstack_expiries_and_raise_the_launcher_ttl() { + // Given persisted 3.14.0 state holding a dstack and a mock attestation. + const EXPIRY_SECONDS: u64 = 1_000_000; + let mock = MpcMockAttestation::WithConstraints { + mpc_docker_image_hash: None, + launcher_docker_compose_hash: None, + expiry_timestamp_seconds: Some(EXPIRY_SECONDS), + expected_measurements: None, + }; + let (mut contract, _, _, _) = setup_running_contract_with_domain(3, 2, 2); + contract.config.launcher_hash_unused_ttl_seconds = 1_209_600; // deployed 3.14.0 value + let dstack_key = insert_attestation( + &mut contract, + "dstack.near", + VerifiedAttestation::Dstack(ValidatedDstackAttestation { + mpc_image_hash: MAX_HASH.into(), + launcher_compose_hash: MAX_HASH.into(), + expiry_timestamp_seconds: EXPIRY_SECONDS, + measurements: default_measurements()[0], + }), + ); + let mock_key = insert_attestation( + &mut contract, + "mock.near", + VerifiedAttestation::Mock(mock.clone()), + ); + env::state_write(&contract); + drop(contract); + + // When migrating, then writing state back as the `#[init]` wrapper does on chain. + let migrated = MpcContract::migrate().expect("3.14.0 state must migrate"); + env::state_write(&migrated); + drop(migrated); + + // Then the dstack expiry moved by one attestation window and the mock is untouched. + let reloaded = try_state_read::().unwrap().unwrap(); + let stored = &reloaded.tee_state.stored_attestations; + assert_matches!( + &stored.get(&dstack_key).unwrap().verified_attestation, + VerifiedAttestation::Dstack(dstack) + if dstack.expiry_timestamp_seconds + == EXPIRY_SECONDS + + mpc_attestation::attestation::DEFAULT_EXPIRATION_DURATION_SECONDS + ); + assert_matches!( + &stored.get(&mock_key).unwrap().verified_attestation, + VerifiedAttestation::Mock(stored) if *stored == mock + ); + + // And the launcher TTL was raised, keeping `Config::validate`'s invariant true. + assert_eq!( + reloaded.config.launcher_hash_unused_ttl_seconds, + config::DEFAULT_LAUNCHER_HASH_UNUSED_TTL_SECONDS + ); + } + + fn insert_attestation( + contract: &mut MpcContract, + account_id: &str, + verified_attestation: VerifiedAttestation, + ) -> Ed25519PublicKey { + let node_id = create_node_id(&account_id.parse().unwrap(), &bogus_ed25519_public_key()); + let tls_public_key = node_id.tls_public_key.clone(); + contract.tee_state.stored_attestations.insert( + tls_public_key.clone(), + NodeAttestation { + node_id, + verified_attestation, + }, + ); + tls_public_key + } } diff --git a/crates/contract/src/tee/proposal.rs b/crates/contract/src/tee/proposal.rs index 5892bd0388..cdffbc98cc 100644 --- a/crates/contract/src/tee/proposal.rs +++ b/crates/contract/src/tee/proposal.rs @@ -434,11 +434,6 @@ impl AllowedLauncherImages { } } - /// Migration constructor. - pub(crate) fn from_entries(entries: Vec) -> Self { - Self { entries } - } - /// Removes a launcher image hash and all its associated compose hashes. /// Returns `false` if the launcher hash was not found or if removal would leave the list empty. pub fn remove(&mut self, launcher_hash: &LauncherImageHash) -> bool { diff --git a/crates/contract/src/tee/tee_state.rs b/crates/contract/src/tee/tee_state.rs index a7587a937f..0a963679ab 100644 --- a/crates/contract/src/tee/tee_state.rs +++ b/crates/contract/src/tee/tee_state.rs @@ -497,6 +497,34 @@ impl TeeState { removed } + pub(crate) fn extend_dstack_attestation_expiries(&mut self, extension: Duration) { + let extension_seconds = extension.as_secs(); + + let dstack_tls_keys: Vec = self + .stored_attestations + .iter() + .filter(|(_, node_attestation)| { + matches!( + node_attestation.verified_attestation, + VerifiedAttestation::Dstack(_) + ) + }) + .map(|(tls_pk, _)| tls_pk.clone()) + .collect(); + + for tls_pk in dstack_tls_keys { + let Some(node_attestation) = self.stored_attestations.get_mut(&tls_pk) else { + continue; + }; + if let VerifiedAttestation::Dstack(dstack) = &mut node_attestation.verified_attestation + { + dstack.expiry_timestamp_seconds = dstack + .expiry_timestamp_seconds + .saturating_add(extension_seconds); + } + } + } + /// Returns the list of accounts that currently have TEE attestations stored. /// Note: This may include accounts that are no longer active protocol participants. pub fn get_tee_accounts(&self) -> Vec { diff --git a/crates/contract/src/v3_13_0_state.rs b/crates/contract/src/v3_13_0_state.rs deleted file mode 100644 index 11ad77250b..0000000000 --- a/crates/contract/src/v3_13_0_state.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! ## Overview -//! This module stores the previous contract state—the one you want to migrate from. -//! The goal is to describe the data layout _exactly_ as it existed before. -//! -//! ## Guideline -//! In theory, you could copy-paste every struct from the specific commit you're migrating from. -//! However, this approach (a) requires manual effort from a developer and (b) increases the binary size. -//! A better approach: only copy the structures that have changed and import the rest from the existing codebase. - -use borsh::{BorshDeserialize, BorshSerialize}; -use mpc_attestation::attestation::{self, VerifiedAttestation}; -use near_mpc_contract_interface::types::{ - Ed25519PublicKey, Metrics, VerifyForeignTransactionRequest, -}; -use near_sdk::{ - AccountId, env, - store::{Lazy, LookupMap}, -}; - -use crate::{ - SupportedForeignChainsByNode, - config::Config, - foreign_chains_metadata::ForeignChainsMetadata, - node_migrations::NodeMigrations, - primitives::{ - ckd::CKDRequest, - signature::{SignatureRequest, YieldIndex}, - }, - state::ProtocolContractState, - tee::tee_state::TeeState, - tee::verifier_votes::TeeVerifierVotes, - update::ProposedUpdates, -}; - -/// Shadow of the `3.13.0` [`Config`]: the deployed layout predates this release's new -/// `Config` fields — the async attestation gas fields (`fail_attestation_submission_tera_gas`, -/// `verifier_tera_gas`, `resolve_verification_tera_gas`) and the launcher-eviction field -/// (`launcher_hash_unused_ttl_seconds`) — so -/// migrating `3.13.0` state deserializes the old field set and defaults the new ones. -#[derive(Debug, BorshSerialize, BorshDeserialize)] -struct OldConfig { - key_event_timeout_blocks: u64, - tee_upgrade_deadline_duration_seconds: u64, - contract_upgrade_deposit_tera_gas: u64, - sign_call_gas_attachment_requirement_tera_gas: u64, - ckd_call_gas_attachment_requirement_tera_gas: u64, - return_signature_and_clean_state_on_success_call_tera_gas: u64, - return_ck_and_clean_state_on_success_call_tera_gas: u64, - fail_on_timeout_tera_gas: u64, - clean_tee_status_tera_gas: u64, - clean_invalid_attestations_tera_gas: u64, - cleanup_orphaned_node_migrations_tera_gas: u64, - remove_non_participant_update_votes_tera_gas: u64, - clean_foreign_chain_data_tera_gas: u64, - remove_non_participant_tee_verifier_votes_tera_gas: u64, -} - -impl From for Config { - fn from(old: OldConfig) -> Self { - // Carry the deployed values; the new fields (async attestation gas + launcher - // eviction) are added in this release, so take their defaults. - Config { - key_event_timeout_blocks: old.key_event_timeout_blocks, - tee_upgrade_deadline_duration_seconds: old.tee_upgrade_deadline_duration_seconds, - contract_upgrade_deposit_tera_gas: old.contract_upgrade_deposit_tera_gas, - sign_call_gas_attachment_requirement_tera_gas: old - .sign_call_gas_attachment_requirement_tera_gas, - ckd_call_gas_attachment_requirement_tera_gas: old - .ckd_call_gas_attachment_requirement_tera_gas, - return_signature_and_clean_state_on_success_call_tera_gas: old - .return_signature_and_clean_state_on_success_call_tera_gas, - return_ck_and_clean_state_on_success_call_tera_gas: old - .return_ck_and_clean_state_on_success_call_tera_gas, - fail_on_timeout_tera_gas: old.fail_on_timeout_tera_gas, - clean_tee_status_tera_gas: old.clean_tee_status_tera_gas, - clean_invalid_attestations_tera_gas: old.clean_invalid_attestations_tera_gas, - cleanup_orphaned_node_migrations_tera_gas: old - .cleanup_orphaned_node_migrations_tera_gas, - remove_non_participant_update_votes_tera_gas: old - .remove_non_participant_update_votes_tera_gas, - clean_foreign_chain_data_tera_gas: old.clean_foreign_chain_data_tera_gas, - remove_non_participant_tee_verifier_votes_tera_gas: old - .remove_non_participant_tee_verifier_votes_tera_gas, - ..Config::default() - } - } -} - -/// `3.13.0` layout of `AllowedLauncherImage`: the current type appends an `expires_at` -/// timestamp, so the real type can no longer decode old bytes. -#[derive(Debug, BorshSerialize, BorshDeserialize)] -struct OldAllowedLauncherImage { - launcher_hash: mpc_primitives::hash::LauncherImageHash, - compose_hashes: Vec, -} - -#[derive(Debug, BorshSerialize, BorshDeserialize)] -struct OldAllowedLauncherImages { - entries: Vec, -} - -/// `3.13.0` layout of `TeeState`. Only `allowed_launcher_images` changed borsh -/// layout; every other field reuses the real (byte-identical) type. Field order -/// must match [`crate::tee::tee_state::TeeState`] exactly. -#[derive(Debug, BorshSerialize, BorshDeserialize)] -struct OldTeeState { - allowed_docker_image_hashes: crate::tee::proposal::StoredDockerImageHashes, - allowed_launcher_images: OldAllowedLauncherImages, - votes: crate::tee::proposal::CodeHashesVotes, - launcher_votes: crate::tee::proposal::LauncherHashVotes, - stored_attestations: near_sdk::store::IterableMap< - near_mpc_contract_interface::types::Ed25519PublicKey, - crate::tee::tee_state::NodeAttestation, - >, - allowed_measurements: crate::tee::measurements::AllowedMeasurements, - measurement_votes: crate::tee::measurements::MeasurementVotes, -} - -impl From for crate::tee::tee_state::TeeState { - fn from(old: OldTeeState) -> Self { - // `new` stamps `expires_at = migration_block_time + default_TTL` (constant within - // this call), so migrated entries stay live for the default unused-TTL window. - let ttl = - std::time::Duration::from_secs(crate::config::DEFAULT_LAUNCHER_HASH_UNUSED_TTL_SECONDS); - let entries = old - .allowed_launcher_images - .entries - .into_iter() - .map(|e| { - crate::tee::proposal::AllowedLauncherImage::new( - e.launcher_hash, - e.compose_hashes, - ttl, - ) - }) - .collect(); - crate::tee::tee_state::TeeState { - allowed_docker_image_hashes: old.allowed_docker_image_hashes, - allowed_launcher_images: crate::tee::proposal::AllowedLauncherImages::from_entries( - entries, - ), - votes: old.votes, - launcher_votes: old.launcher_votes, - stored_attestations: old.stored_attestations, - allowed_measurements: old.allowed_measurements, - measurement_votes: old.measurement_votes, - } - } -} - -#[derive(Debug, BorshSerialize, BorshDeserialize)] -pub struct MpcContract { - protocol_state: ProtocolContractState, - pending_signature_requests: LookupMap>, - pending_ckd_requests: LookupMap>, - pending_verify_foreign_tx_requests: LookupMap>, - proposed_updates: ProposedUpdates, - node_foreign_chain_support: SupportedForeignChainsByNode, - config: OldConfig, - tee_state: OldTeeState, - accept_requests: bool, - node_migrations: NodeMigrations, - metrics: Metrics, - foreign_chains: Lazy, - tee_verifier_account_id: Option, - tee_verifier_votes: TeeVerifierVotes, -} - -/// Stamps an expiry on every stored mock attestation that lacks or exceeds one — -/// both user-submitted mocks and the genesis sentinels written by -/// [`TeeState::with_mocked_participant_attestations`]. Legacy -/// [`mpc_attestation::attestation::MockAttestation::Valid`] entries pass -/// re-verification forever and can therefore never be evicted by -/// [`TeeState::clean_invalid_attestations`]; -/// [`mpc_attestation::attestation::MockAttestation::with_expiry_capped_at`] rewrites them as -/// expiring mocks so the normal cleanup flow can remove stale entries once the -/// window elapses. An entry whose expiry is longer than (or missing) the default -/// window is capped at it; a shorter existing expiry is left as-is. -/// -// TODO(#3978): transitional one-time upgrade step — removed together with this -// module when the pre-expiry migration is retired. -fn stamp_expiry_on_legacy_mocks(tee_state: &mut TeeState, current_timestamp_seconds: u64) { - let expiry_timestamp_seconds = - current_timestamp_seconds + attestation::DEFAULT_EXPIRATION_DURATION_SECONDS; - - // Collect keys before mutating to avoid iterator invalidation. - let mock_tls_keys: Vec = tee_state - .stored_attestations - .iter() - .filter(|(_, node_attestation)| { - matches!( - node_attestation.verified_attestation, - VerifiedAttestation::Mock(_) - ) - }) - .map(|(tls_pk, _)| tls_pk.clone()) - .collect(); - - for tls_pk in mock_tls_keys { - let Some(node_attestation) = tee_state.stored_attestations.get_mut(&tls_pk) else { - continue; - }; - if let VerifiedAttestation::Mock(mock) = &node_attestation.verified_attestation { - let stamped = mock.clone().with_expiry_capped_at(expiry_timestamp_seconds); - node_attestation.verified_attestation = VerifiedAttestation::Mock(stamped); - } - } -} - -impl From for crate::MpcContract { - fn from(old: MpcContract) -> Self { - if !matches!(old.protocol_state, ProtocolContractState::Running(_)) { - env::panic_str("Contract must be in running state when migrating."); - } - - // First convert the shadowed `3.13.0` `TeeState` (stamping `expires_at` on launcher - // entries), then stamp an expiry on legacy `MockAttestation::Valid` entries — which - // never expire and could otherwise never be cleaned up. - let mut tee_state: crate::tee::tee_state::TeeState = old.tee_state.into(); - stamp_expiry_on_legacy_mocks(&mut tee_state, TeeState::current_time_seconds()); - - crate::MpcContract { - protocol_state: old.protocol_state, - pending_signature_requests: old.pending_signature_requests, - pending_ckd_requests: old.pending_ckd_requests, - pending_verify_foreign_tx_requests: old.pending_verify_foreign_tx_requests, - proposed_updates: old.proposed_updates, - node_foreign_chain_support: old.node_foreign_chain_support, - config: old.config.into(), - tee_state, - accept_requests: old.accept_requests, - node_migrations: old.node_migrations, - metrics: old.metrics, - foreign_chains: old.foreign_chains, - tee_verifier_account_id: old.tee_verifier_account_id, - tee_verifier_votes: old.tee_verifier_votes, - } - } -} - -#[cfg(test)] -#[expect(non_snake_case)] -mod tests { - use super::*; - use crate::primitives::test_utils::bogus_ed25519_public_key; - use crate::storage_keys::StorageKey; - use crate::tee::proposal::{ - CodeHashesVotes, LauncherHashVotes, StoredDockerImageHashes, get_docker_compose_hash, - }; - use crate::tee::tee_state::{NodeAttestation, NodeId}; - use crate::tee::test_utils::set_block_timestamp; - use mpc_attestation::attestation::MockAttestation; - use mpc_primitives::hash::{LauncherImageHash, NodeImageHash}; - use near_sdk::store::IterableMap; - use near_sdk::{test_utils::VMContextBuilder, testing_env}; - use std::time::Duration; - - /// The `3.13.0` launcher layout (no timestamp) must deserialize under the shadow and - /// migrate: launcher hash + compose hashes preserved, and `expires_at` set to - /// `migration_time + default_TTL` (NOT the borsh/epoch default, which would immediately - /// expire every migrated hash). Both entries surviving at the migration block time - /// proves the expiry was stamped forward rather than to epoch 0. - #[test] - fn migration__should_preserve_launcher_hashes_and_stamp_timestamps() { - // Given two 3.13.0 launcher entries in the old, timestamp-less layout. - const MIGRATION_TIME_SECS: u64 = 1_000_000; - let launcher_1 = LauncherImageHash::from([1u8; 32]); - let launcher_2 = LauncherImageHash::from([2u8; 32]); - let mpc_hash = NodeImageHash::from([10u8; 32]); - let compose_1 = get_docker_compose_hash(&launcher_1, &mpc_hash); - let compose_2 = get_docker_compose_hash(&launcher_2, &mpc_hash); - - testing_env!( - VMContextBuilder::new() - .block_timestamp(MIGRATION_TIME_SECS * 1_000_000_000) - .build() - ); - - let old = OldTeeState { - allowed_docker_image_hashes: StoredDockerImageHashes::default(), - allowed_launcher_images: OldAllowedLauncherImages { - entries: vec![ - OldAllowedLauncherImage { - launcher_hash: launcher_1, - compose_hashes: vec![compose_1], - }, - OldAllowedLauncherImage { - launcher_hash: launcher_2, - compose_hashes: vec![compose_2], - }, - ], - }, - votes: CodeHashesVotes::default(), - launcher_votes: LauncherHashVotes::default(), - stored_attestations: IterableMap::new(StorageKey::StoredAttestations), - allowed_measurements: Default::default(), - measurement_votes: Default::default(), - }; - - // When migrated (borsh round-trip through the shadow, then into the real `TeeState`). - let bytes = borsh::to_vec(&old).unwrap(); - let decoded: OldTeeState = borsh::from_slice(&bytes).unwrap(); - let migrated: crate::tee::tee_state::TeeState = decoded.into(); - - // Then launcher hashes and compose hashes are carried over. - assert_eq!( - migrated.get_allowed_launcher_hashes(), - vec![launcher_1, launcher_2] - ); - assert_eq!( - migrated.get_allowed_launcher_compose_hashes(), - vec![compose_1, compose_2] - ); - - // `expires_at` was stamped to `migration_time + default_TTL`: at the migration block - // time both entries are still live (both surface, not just the newest-only fallback). - // Had they defaulted to epoch 0, both would be expired and the fallback would surface - // only one. - assert_eq!(migrated.get_allowed_launcher_hashes().len(), 2); - } - - #[test] - fn stamp_expiry_on_legacy_mocks__should_make_valid_mock_cleanable() { - // Given: a legacy `MockAttestation::Valid` entry stored with no expiry, as - // written by older contract versions. Such entries pass re-verification - // forever and cannot be cleaned up. - testing_env!(VMContextBuilder::new().block_timestamp(0).build()); - - let mut tee_state = TeeState::default(); - let node_id = NodeId { - account_id: "legacy.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - tee_state.stored_attestations.insert( - node_id.tls_public_key.clone(), - NodeAttestation { - node_id: node_id.clone(), - verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), - }, - ); - - // Sanity: past the default window but without migration, the un-stamped - // entry survives cleanup indefinitely. - set_block_timestamp((attestation::DEFAULT_EXPIRATION_DURATION_SECONDS + 1) * 1_000_000_000); - assert_eq!( - tee_state.clean_invalid_attestations(Duration::from_secs(0), 100), - 0 - ); - - // When: the migration stamps an expiry as of block time 0 (window ends at - // DEFAULT), which the clock (already at DEFAULT + 1) is past. - stamp_expiry_on_legacy_mocks(&mut tee_state, 0); - let removed = tee_state.clean_invalid_attestations(Duration::from_secs(0), 100); - - // Then: the stale legacy mock entry is removed. - assert_eq!(removed, 1); - assert!( - !tee_state - .stored_attestations - .contains_key(&node_id.tls_public_key) - ); - } - - #[test] - fn migration__should_stamp_launcher_expiry_and_make_legacy_mocks_cleanable() { - // Given: a `3.13.0` TeeState carrying both a launcher image (old, timestamp-less - // layout) and a legacy `MockAttestation::Valid` stored attestation (no expiry) — - // the two things this release's migration must each handle. - const MIGRATION_TIME_SECS: u64 = 1_000_000; - testing_env!( - VMContextBuilder::new() - .block_timestamp(MIGRATION_TIME_SECS * 1_000_000_000) - .build() - ); - - let launcher = LauncherImageHash::from([1u8; 32]); - let mpc_hash = NodeImageHash::from([10u8; 32]); - let compose = get_docker_compose_hash(&launcher, &mpc_hash); - - let mut stored_attestations = IterableMap::new(StorageKey::StoredAttestations); - let node_id = NodeId { - account_id: "legacy.near".parse().unwrap(), - tls_public_key: bogus_ed25519_public_key(), - account_public_key: bogus_ed25519_public_key(), - }; - stored_attestations.insert( - node_id.tls_public_key.clone(), - NodeAttestation { - node_id: node_id.clone(), - verified_attestation: VerifiedAttestation::Mock(MockAttestation::Valid), - }, - ); - - let old = OldTeeState { - allowed_docker_image_hashes: StoredDockerImageHashes::default(), - allowed_launcher_images: OldAllowedLauncherImages { - entries: vec![OldAllowedLauncherImage { - launcher_hash: launcher, - compose_hashes: vec![compose], - }], - }, - votes: CodeHashesVotes::default(), - launcher_votes: LauncherHashVotes::default(), - stored_attestations, - allowed_measurements: Default::default(), - measurement_votes: Default::default(), - }; - - // When: the full `From` sequence runs on the TeeState — our shadow - // conversion (stamps launcher `expires_at`) followed by the legacy-mock stamping. - let mut tee_state: TeeState = old.into(); - stamp_expiry_on_legacy_mocks(&mut tee_state, TeeState::current_time_seconds()); - - // Then: the launcher was migrated with a stamped `expires_at` and is live. - assert_eq!(tee_state.get_allowed_launcher_hashes(), vec![launcher]); - - // And: the previously un-expiring legacy mock is now cleanable once the clock - // passes its stamped window — proving both migration steps applied. - set_block_timestamp( - (MIGRATION_TIME_SECS + attestation::DEFAULT_EXPIRATION_DURATION_SECONDS + 1) - * 1_000_000_000, - ); - let removed = tee_state.clean_invalid_attestations(Duration::from_secs(0), 100); - assert_eq!(removed, 1); - assert!( - !tee_state - .stored_attestations - .contains_key(&node_id.tls_public_key) - ); - } -} diff --git a/crates/contract/src/v3_14_0_state.rs b/crates/contract/src/v3_14_0_state.rs new file mode 100644 index 0000000000..5b482e01be --- /dev/null +++ b/crates/contract/src/v3_14_0_state.rs @@ -0,0 +1,86 @@ +//! ## Overview +//! This module stores the previous contract state—the one you want to migrate from. +//! The goal is to describe the data layout _exactly_ as it existed before. +//! +//! ## Guideline +//! In theory, you could copy-paste every struct from the specific commit you're migrating from. +//! However, this approach (a) requires manual effort from a developer and (b) increases the binary size. +//! A better approach: only copy the structures that have changed and import the rest from the existing codebase. + +use borsh::{BorshDeserialize, BorshSerialize}; +use near_mpc_contract_interface::types::{Metrics, VerifyForeignTransactionRequest}; +use near_sdk::{ + AccountId, env, + store::{Lazy, LookupMap}, +}; +use std::time::Duration; + +use crate::{ + SupportedForeignChainsByNode, + config::Config, + foreign_chains_metadata::ForeignChainsMetadata, + node_migrations::NodeMigrations, + primitives::{ + ckd::CKDRequest, + signature::{SignatureRequest, YieldIndex}, + }, + state::ProtocolContractState, + tee::{tee_state::TeeState, verifier_votes::TeeVerifierVotes}, + update::ProposedUpdates, +}; + +/// Keep this module in sync with [`crate::MpcContract`]: the moment a field's borsh +/// layout diverges, shadow the old type here (see this module's history for examples) so +/// state written by the `3.14.0` contract still deserializes during migration. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct MpcContract { + protocol_state: ProtocolContractState, + pending_signature_requests: LookupMap>, + pending_ckd_requests: LookupMap>, + pending_verify_foreign_tx_requests: LookupMap>, + proposed_updates: ProposedUpdates, + node_foreign_chain_support: SupportedForeignChainsByNode, + config: Config, + tee_state: TeeState, + accept_requests: bool, + node_migrations: NodeMigrations, + metrics: Metrics, + foreign_chains: Lazy, + tee_verifier_account_id: Option, + tee_verifier_votes: TeeVerifierVotes, +} + +impl From for crate::MpcContract { + fn from(old: MpcContract) -> Self { + if !matches!(old.protocol_state, ProtocolContractState::Running(_)) { + env::panic_str("Contract must be in running state when migrating."); + } + + let mut tee_state = old.tee_state; + tee_state.extend_dstack_attestation_expiries(Duration::from_secs( + mpc_attestation::attestation::DEFAULT_EXPIRATION_DURATION_SECONDS, + )); + + let mut config = old.config; + config.launcher_hash_unused_ttl_seconds = config + .launcher_hash_unused_ttl_seconds + .max(crate::config::DEFAULT_LAUNCHER_HASH_UNUSED_TTL_SECONDS); + + crate::MpcContract { + protocol_state: old.protocol_state, + pending_signature_requests: old.pending_signature_requests, + pending_ckd_requests: old.pending_ckd_requests, + pending_verify_foreign_tx_requests: old.pending_verify_foreign_tx_requests, + proposed_updates: old.proposed_updates, + node_foreign_chain_support: old.node_foreign_chain_support, + config, + tee_state, + accept_requests: old.accept_requests, + node_migrations: old.node_migrations, + metrics: old.metrics, + foreign_chains: old.foreign_chains, + tee_verifier_account_id: old.tee_verifier_account_id, + tee_verifier_votes: old.tee_verifier_votes, + } + } +} diff --git a/crates/contract/tests/sandbox/contract_configuration.rs b/crates/contract/tests/sandbox/contract_configuration.rs index 2e6f24dc4a..471b566901 100644 --- a/crates/contract/tests/sandbox/contract_configuration.rs +++ b/crates/contract/tests/sandbox/contract_configuration.rs @@ -107,7 +107,7 @@ async fn contract_configuration_can_be_set_on_initialization() { verifier_tera_gas: Some(15), resolve_verification_tera_gas: Some(16), // Must satisfy `Config::validate` (>= DEFAULT_EXPIRATION_DURATION_SECONDS). - launcher_hash_unused_ttl_seconds: Some(14 * 24 * 60 * 60), + launcher_hash_unused_ttl_seconds: Some(21 * 24 * 60 * 60), }; let SandboxTestSetup { contract, .. } = SandboxTestSetup::builder() diff --git a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs index b5e25ea7ef..151d8030bc 100644 --- a/crates/contract/tests/sandbox/upgrade_from_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_from_current_contract.rs @@ -126,7 +126,7 @@ async fn test_propose_update_config() { verifier_tera_gas: 15, resolve_verification_tera_gas: 16, // Must satisfy `Config::validate` (>= DEFAULT_EXPIRATION_DURATION_SECONDS). - launcher_hash_unused_ttl_seconds: 14 * 24 * 60 * 60, + launcher_hash_unused_ttl_seconds: 21 * 24 * 60 * 60, }; let propose_args = ProposeUpdateArgs { diff --git a/crates/contract/tests/sandbox/upgrade_to_current_contract.rs b/crates/contract/tests/sandbox/upgrade_to_current_contract.rs index 8fcf5f877d..876ba7ae8e 100644 --- a/crates/contract/tests/sandbox/upgrade_to_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_to_current_contract.rs @@ -182,8 +182,8 @@ async fn propose_upgrade_from_production_to_current_binary( ) .await; - // Vote in a launcher image hash so the launcher-image migration decodes a non-empty - // `entries` vec off the real 3.13.0 layout, not just the empty-vec path. + // Vote in a launcher image hash so migration decodes a non-empty `entries` vec off the + // real production layout, not just the empty-vec path. let launcher_hash = mpc_primitives::hash::LauncherImageHash::from([0xAA; 32]); for account in &accounts { vote_add_launcher_hash(account, &contract, &launcher_hash) @@ -209,8 +209,6 @@ async fn propose_upgrade_from_production_to_current_binary( "State of the contract should remain the same post upgrade." ); - // The launcher hash survives migration: it is decoded from the old (timestamp-less) - // layout and re-stamped with a fresh expiry, so it is still live post-upgrade. assert!( get_allowed_launcher_image_hashes(&contract) .await diff --git a/crates/mpc-attestation/src/attestation.rs b/crates/mpc-attestation/src/attestation.rs index ca14dee6ff..2b32f5d77b 100644 --- a/crates/mpc-attestation/src/attestation.rs +++ b/crates/mpc-attestation/src/attestation.rs @@ -25,7 +25,7 @@ use crate::alloc::string::{String, ToString}; /// re-verified via [`VerifiedAttestation::re_verify`]. Nodes resubmit hourly, /// well within this window, so valid attestations refresh in time. // TODO(#1639): extract timestamp from certificate itself -pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24 * 7; // 7 days +pub const DEFAULT_EXPIRATION_DURATION_SECONDS: u64 = 60 * 60 * 24 * 21; // 21 days // `large_enum_variant` fires only where `usize` is 64-bit; under the contract's // wasm32 build the variants are close enough in size that it doesn't, so gate the diff --git a/crates/test-utils/src/contract_types.rs b/crates/test-utils/src/contract_types.rs index b6def77411..b8adfcf28f 100644 --- a/crates/test-utils/src/contract_types.rs +++ b/crates/test-utils/src/contract_types.rs @@ -19,6 +19,6 @@ pub fn dummy_config(value: u64) -> near_mpc_contract_interface::types::Config { resolve_verification_tera_gas: value + 15, fail_attestation_submission_tera_gas: value + 16, // Must satisfy `Config::validate` (>= DEFAULT_EXPIRATION_DURATION_SECONDS). - launcher_hash_unused_ttl_seconds: value + (14 * 24 * 60 * 60), + launcher_hash_unused_ttl_seconds: value + (21 * 24 * 60 * 60), } }