diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs index a34bd62af..2b1a7ca60 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs @@ -34,6 +34,14 @@ //! So a device holding value cannot activate. A migration protocol for //! existing holdings is future work; it must never be an implicit snapshot. //! +//! What is checked is what the device held at its **activation point**: the +//! head its first admission was built on. While that admission is pending the +//! head already carries its credit (the advance and the pending admission +//! commit together), so the activation point is the head less exactly that +//! admission's own witness — +//! [`EconomicActivationSnapshot::before_first_admission`]. Value the first +//! admission did not credit is still value held at the activation point. +//! //! ## Advancing a validated root //! //! Conjunctive, and every clause is checked: @@ -66,16 +74,19 @@ //! acyclicity rule structural: an external source resolves from a root this //! verifier has itself validated, never from the transition being validated. +use crate::economic::admission::PendingAdmissionKind; use crate::economic::claim::{verify_manifest_provenance_index, EconomicAdmissionManifest}; use crate::economic::provenance::{ verify_transition_provenance, FundedCredit, ProvenanceContext, ProvenanceError, ProvenanceResolver, }; use crate::economic::register::RegisteredEconomicRoot; +use crate::economic::state::EconomicLeafState; use crate::economic::tree::empty_economic_root; use crate::economic::witness::{ verify_mutation_sequence, EconomicTransitionWitness, EconomicWitnessError, }; +use crate::types::device_state::DeviceState; /// A root this verifier has established is the result of a valid transition /// from a validated predecessor. @@ -382,8 +393,147 @@ impl EconomicActivationSnapshot { outstanding_offline_allocation: false, } } + + /// What `head` held at its activation point while its FIRST economic + /// admission is pending: the head less exactly that admission's own + /// transition, as `first`, the admission's frozen witness, states it. + /// + /// The activation point is the head the first admission was built on, and + /// once the admission is locally accepted that is no longer the head: the + /// advance and the pending admission commit in one transaction, so an + /// admission held at any later step (its evidence not yet stored, its + /// claim not yet final) leaves its own credit on the head, fenced. Asking + /// whether the head as it stands holds value counts that credit as value + /// held before position 0, and refuses the one transition that roots the + /// lineage. + /// + /// From the activation root every mutation of `first` inserts a leaf, and + /// each balance leaf it inserts is a credit the advance applied, so taking + /// those credits off the head gives back the head the admission was built + /// on. Activation is not loosened: whatever the head holds that the first + /// admission did not credit was held at the activation point, and + /// [`activate`] refuses it. An outstanding allocation is read off the head + /// as it stands, because a DSM-backed admission moves none. + /// + /// `first` is taken only as the pending admission's own witness: its + /// operation digest and post-root are the admission's, and its mutations + /// verify from the activation root under this head's identity. + pub fn before_first_admission( + head: &DeviceState, + first: &EconomicTransitionWitness, + ) -> Result { + let pending = head + .pending_economic_admission() + .ok_or(ActivationPointUnreadable::NothingPending)?; + let activation_root = empty_economic_root(); + let not_first = || ActivationPointUnreadable::NotAFirstAdmission { + economic_position: pending.economic_position, + }; + // Only a DSM-backed admission can be a device's first: an offline load + // debits an online balance the activation root does not hold, an + // unload moves an allocation activation refuses, and a conditional + // position right after the activation root is Invalid. + if pending.kind != PendingAdmissionKind::DsmBacked + || pending.economic_position != 1 + || pending.pre_economic_root != activation_root + { + return Err(not_first()); + } + let coords = pending.acceptance().ok_or_else(not_first)?; + if first.pre_economic_root != activation_root + || first.operation_digest != pending.operation_digest + || first.post_economic_root != coords.post_economic_root + { + return Err(ActivationPointUnreadable::WitnessOfAnotherTransition); + } + verify_mutation_sequence( + &first.mutation_sequence(), + &head.genesis_digest(), + &head.devid(), + ) + .map_err(ActivationPointUnreadable::WitnessDoesNotVerify)?; + + let mut held = head.balances_snapshot().clone(); + for mutation in &first.mutations { + if let Some(EconomicLeafState::Balance(credit)) = &mutation.post_state { + let rest = held + .get(&credit.policy_commit) + .and_then(|amount| amount.checked_sub(credit.amount)) + .ok_or(ActivationPointUnreadable::CreditNotOnHead { + policy_commit: credit.policy_commit, + })?; + // The advance removes a balance that reaches zero, so the + // credit that created an entry takes the entry back with it. + if rest == 0 { + held.remove(&credit.policy_commit); + } else { + held.insert(credit.policy_commit, rest); + } + } + } + Ok(Self { + online_balances_empty: held.is_empty(), + outstanding_offline_allocation: !head.offline_allocations_snapshot().is_empty(), + }) + } +} + +/// Why the activation point of a device with a pending first admission could +/// not be read off its head and that admission's witness. +/// +/// Each is incoherent local state, never a verdict about value: the head, the +/// pending admission and its frozen witness are written together. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActivationPointUnreadable { + /// The head carries no pending admission: its activation point, if it has + /// not activated, is the head itself. + NothingPending, + /// The pending admission is not a locally accepted, DSM-backed admission + /// at position 1 on the activation root. + NotAFirstAdmission { economic_position: u64 }, + /// The witness names another operation, another post-root, or a pre-root + /// that is not the activation root. + WitnessOfAnotherTransition, + /// The witness's mutations do not verify from the activation root under + /// the head's identity. + WitnessDoesNotVerify(EconomicWitnessError), + /// The head does not hold a credit the first admission made. + CreditNotOnHead { policy_commit: [u8; 32] }, +} + +impl core::fmt::Display for ActivationPointUnreadable { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::NothingPending => write!( + f, + "the head carries no pending admission: its activation point is the head itself" + ), + Self::NotAFirstAdmission { economic_position } => write!( + f, + "the pending admission at position {economic_position} is not a first admission \ + (a locally accepted, DSM-backed admission at position 1 on the activation root)" + ), + Self::WitnessOfAnotherTransition => write!( + f, + "the witness is not the pending first admission's: it names another operation, \ + another post-root, or a pre-root other than the activation root" + ), + Self::WitnessDoesNotVerify(e) => write!( + f, + "the pending first admission's witness does not verify from the activation \ + root: {e}" + ), + Self::CreditNotOnHead { policy_commit } => write!( + f, + "the head does not hold the credit of {} its pending first admission made", + crate::utils::text_id::encode_base32_crockford(policy_commit) + ), + } + } } +impl std::error::Error for ActivationPointUnreadable {} + /// Why a device may not activate an economic lineage at the empty root. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UnsupportedLegacyEconomicState { diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs index 14d8ee24f..1ba560a70 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs @@ -926,3 +926,218 @@ fn a_setup_transition_binds_its_position_and_its_derived_root() { "the derived body must pass both setup conjuncts, got {outcome:?}" ); } + +// ── The activation point of a held first admission ───────────────────────── +// +// The advance and the pending admission commit together, so a first admission +// held at any later step leaves its own credit on the head. The activation +// point is the head less exactly that credit: the admission resumes on the +// activation root, and anything else the head holds is still value held +// before position 0. + +/// The first faucet claim's admission, locally accepted for `fx`'s witness at +/// `position` on the activation root, naming `operation`. +fn held_first_claim( + fx: &FaucetFixture, + kind: PendingAdmissionKind, + position: u64, + operation: &Operation, +) -> PendingEconomicAdmission { + PendingEconomicAdmission::prepared( + kind, + position, + dsm::economic::tree::empty_economic_root(), + dsm::economic::admission::dsm_operation_digest(&operation.to_bytes()), + ) + .into_locally_accepted(dsm::economic::admission::AcceptedAdmissionCoords { + post_economic_root: fx.post_root, + accepted_substrate_addr: SUBSTRATE_ADDR, + admission_manifest_addr: manifest_for(&fx.witness).addr().expect("addressable"), + c_dsm_plus: C_DSM_PLUS, + embedded_parent: EMBEDDED_PARENT, + }) + .expect("prepared -> accepted") +} + +/// The trader's head as it reloads: `balances`, `allocations`, and the +/// admission in flight. +fn head_of( + genesis: [u8; 32], + fx: &FaucetFixture, + balances: &[([u8; 32], u64)], + allocations: &[([u8; 32], u64)], + pending: Option, +) -> dsm::types::device_state::DeviceState { + dsm::types::device_state::DeviceState::restore( + genesis, + *dev(), + fx.pk.clone(), + None, + balances.iter().copied().collect(), + Vec::new(), + std::collections::BTreeMap::new(), + allocations + .iter() + .map(|(key, amount)| { + ( + *key, + dsm::types::device_state::OfflineAllocation { + amount: *amount, + sequence: 1, + }, + ) + }) + .collect(), + pending, + ) + .expect("the head reloads") +} + +fn era_payout() -> ([u8; 32], u64) { + ( + dsm::core::token::token_state_manager::era_policy_commit(), + dsm::economic::native_reserve::ERA_FAUCET_PAYOUT, + ) +} + +/// A first claim held after its advance: the head holds the claim's own +/// credit and nothing else. Read off the head as it stands, that credit is +/// value held before position 0 and activation refuses; at the activation +/// point the device held nothing, and the admission resumes on the activation +/// root. +#[test] +fn a_held_first_admission_resumes_on_the_activation_root() { + let fx = faucet_fixture(1); + let pending = held_first_claim(&fx, PendingAdmissionKind::DsmBacked, 1, &fx.op); + let head = head_of(G, &fx, &[era_payout()], &[], Some(pending)); + + let as_it_stands = EconomicActivationSnapshot { + online_balances_empty: head.balances_snapshot().is_empty(), + outstanding_offline_allocation: !head.offline_allocations_snapshot().is_empty(), + }; + activate(as_it_stands).expect_err("the head as it stands carries the held credit"); + + let snapshot = EconomicActivationSnapshot::before_first_admission(&head, &fx.witness) + .expect("the activation point is readable"); + assert_eq!(snapshot, EconomicActivationSnapshot::fresh()); + let root = activate(snapshot).expect("nothing was held at the activation point"); + assert_eq!(root.economic_position(), 0); + assert_eq!( + root.economic_root(), + dsm::economic::tree::empty_economic_root() + ); +} + +/// No self-rooting through a held first admission: whatever the head holds +/// that the first admission did not credit — more of the same asset, another +/// asset, an outstanding allocation — was held at the activation point, and +/// activation refuses it exactly as it refuses a device with nothing pending. +#[test] +fn value_the_first_admission_did_not_credit_still_blocks_activation() { + let fx = faucet_fixture(1); + let (era, payout) = era_payout(); + type Holdings = (&'static str, Vec<([u8; 32], u64)>, Vec<([u8; 32], u64)>); + let cases: [Holdings; 3] = [ + ( + "more of the credited asset", + vec![(era, payout + 1)], + vec![], + ), + ( + "another asset", + vec![(era, payout), ([0x7A; 32], 7)], + vec![], + ), + ( + "an outstanding allocation", + vec![(era, payout)], + vec![([0x7B; 32], 5)], + ), + ]; + for (name, balances, allocations) in cases { + let pending = held_first_claim(&fx, PendingAdmissionKind::DsmBacked, 1, &fx.op); + let head = head_of(G, &fx, &balances, &allocations, Some(pending)); + let snapshot = match EconomicActivationSnapshot::before_first_admission(&head, &fx.witness) + { + Ok(snapshot) => snapshot, + Err(e) => panic!("{name}: the activation point is readable: {e}"), + }; + assert_ne!( + snapshot, + EconomicActivationSnapshot::fresh(), + "{name}: held at the activation point" + ); + let refused = + activate(snapshot).expect_err("value held at the activation point blocks activation"); + assert_eq!(refused.snapshot, snapshot, "{name}"); + } +} + +/// The activation point is read only through the pending first admission's +/// own witness, from the head that carries it. +#[test] +fn the_activation_point_is_read_only_through_the_first_admissions_own_witness() { + use dsm::economic::lineage::ActivationPointUnreadable; + let fx = faucet_fixture(1); + let (era, _) = era_payout(); + let read = |head: &dsm::types::device_state::DeviceState| { + EconomicActivationSnapshot::before_first_admission(head, &fx.witness) + }; + + // Nothing pending: the head is its own activation point. + assert_eq!( + read(&head_of(G, &fx, &[era_payout()], &[], None)), + Err(ActivationPointUnreadable::NothingPending) + ); + + // Not a first admission: a later position, or a kind no first admission has. + for (kind, position) in [ + (PendingAdmissionKind::DsmBacked, 2), + ( + PendingAdmissionKind::OfflineLoad { + asset_policy_commit: era, + }, + 1, + ), + ] { + let pending = held_first_claim(&fx, kind, position, &fx.op); + assert_eq!( + read(&head_of(G, &fx, &[era_payout()], &[], Some(pending))), + Err(ActivationPointUnreadable::NotAFirstAdmission { + economic_position: position + }) + ); + } + + // The witness of another operation than the one pending. + let other = Operation::FaucetClaim { + reserve_id: reserve_genesis().reserve_id, + generation: 2, + }; + let pending = held_first_claim(&fx, PendingAdmissionKind::DsmBacked, 1, &other); + assert_eq!( + read(&head_of(G, &fx, &[era_payout()], &[], Some(pending))), + Err(ActivationPointUnreadable::WitnessOfAnotherTransition) + ); + + // Another identity's head: the witness's leaves are keyed to this + // trader's genesis, so they do not verify under another's. + let pending = held_first_claim(&fx, PendingAdmissionKind::DsmBacked, 1, &fx.op); + assert!(matches!( + read(&head_of( + [0x12; 32], + &fx, + &[era_payout()], + &[], + Some(pending) + )), + Err(ActivationPointUnreadable::WitnessDoesNotVerify(..)) + )); + + // A head that does not hold the credit its pending admission made. + let pending = held_first_claim(&fx, PendingAdmissionKind::DsmBacked, 1, &fx.op); + assert_eq!( + read(&head_of(G, &fx, &[], &[], Some(pending))), + Err(ActivationPointUnreadable::CreditNotOnHead { policy_commit: era }) + ); +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_flow_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_flow_tests.rs index 10393e1b5..5cb2dd0ab 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_flow_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_flow_tests.rs @@ -529,3 +529,178 @@ async fn a_faucet_lineage_is_walkable_by_a_foreign_verifier() { dsm::types::operations::Operation::FaucetClaim { .. } )); } + +// ── A device's first admission, held after its advance ──────────────────── +// +// The advance and the pending admission commit in one transaction, so a +// first admission held at any later step leaves the device with its credit on +// the head (fenced) and nothing admitted. Resuming it must reach the +// activation root it was built on, whichever production path resumes it. + +/// The seats of this device's first root cell `K_root(1)` after its first +/// two, in route order, and the cell's key. Refusing their writes lets the +/// first root claim reach the leader and one more seat and no further: a chain +/// of two links, which is never final (storage spec §9). +fn seats_past_the_second_of_the_first_root_cell(d: &Device) -> (Vec, [u8; 32]) { + let set = canonical_set(NETWORK).expect("canonical set"); + let cell = crate::sdk::economic_registers::root_cell( + &set, + NETWORK, + &d.identity.genesis, + &d.identity.device_id, + 1, + &dsm::economic::tree::empty_economic_root(), + ) + .expect("K_root(1)"); + let seats = cell.routed().route().seats()[2..] + .iter() + .map(|id| String::from_utf8(id.clone()).expect("member ids are UTF-8")) + .collect(); + (seats, *cell.routed().key()) +} + +/// Run `d`'s first claim while its root claim cannot become final, so the +/// claim is held for resume after its advance credited the head; then let the +/// members take writes again. Returns the held admission. +async fn hold_the_first_claim(d: &Device) -> dsm::economic::admission::PendingEconomicAdmission { + let (seats, key) = seats_past_the_second_of_the_first_root_cell(d); + for seat in &seats { + d.nodes.refuse_cell_writes(seat, &[key]).await; + } + let held = match claim_era_faucet(d.core(), NETWORK).await { + Ok(outcome) => panic!( + "a root claim of two links is not final, yet the claim admitted position {}", + outcome.economic_position + ), + Err(e) => e.to_string(), + }; + assert!(held.contains("the claim is not final yet"), "{held}"); + let head = d.core().device_head().expect("head"); + let pending = head + .pending_economic_admission() + .cloned() + .expect("the first claim is held for resume"); + assert_eq!(pending.economic_position, 1, "the first admission"); + assert_eq!( + pending.pre_economic_root, + dsm::economic::tree::empty_economic_root(), + "built on the activation root" + ); + assert_eq!( + head.balance(&era()), + whole_era(100), + "the held advance has already credited the head" + ); + assert!( + client_db::economic_lineage::get_admitted_coordinate() + .expect("read admitted") + .is_none(), + "nothing is admitted yet" + ); + for seat in &seats { + d.nodes.accept_cell_writes(seat).await; + } + pending +} + +/// `core`'s lineage is admitted at `position` and nothing is pending. +fn admitted_at(core: &crate::sdk::core_sdk::CoreSDK, position: u64) { + assert!( + core.device_head() + .expect("head") + .pending_economic_admission() + .is_none(), + "admitted ⇒ unfenced" + ); + let (admitted, _root) = client_db::economic_lineage::get_admitted_coordinate() + .expect("read admitted") + .expect("admitted recorded"); + assert_eq!(admitted, position); +} + +/// A held first claim is finished by resuming it: its predecessor is the +/// activation root, though the head already holds the claim's own credit. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_held_first_claim_is_finished_by_resuming_it() { + let d = Device::start(0xA9).await; + let pending = hold_the_first_claim(&d).await; + let admitted = + crate::sdk::economic_admission_flow::resume_pending_admission(d.core(), NETWORK, pending) + .await + .expect("the held first claim resumes"); + assert_eq!(admitted.economic_position, 1); + admitted_at(d.core(), 1); + assert_eq!(d.era_balance(), whole_era(100), "credited once"); + assert_eq!(recipient_of(&release_at(1).await), d.identity.device_id); +} + +/// The next claim finishes a held first claim before it claims again: the +/// held one lands at position 1 and the new one at position 2. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_held_first_claim_is_finished_by_the_next_claim() { + let d = Device::start(0xAA).await; + hold_the_first_claim(&d).await; + let next = claim_era_faucet(d.core(), NETWORK) + .await + .expect("the next claim finishes the held one, then claims"); + assert_eq!(next.economic_position, 2); + admitted_at(d.core(), 2); + assert_eq!(d.era_balance(), whole_era(200)); + assert_eq!( + reserve_head().await.generation, + 2, + "released twice, once each" + ); +} + +/// After a restart the sync finishes a first claim the previous run left held +/// (the stranded-admission sweep). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_held_first_claim_is_finished_by_the_sync_after_a_restart() { + use dsm::types::proto; + use prost::Message; + let d = Device::start(0xAB).await; + hold_the_first_claim(&d).await; + let restarted = crate::economic_fixtures::restart_router(&d.fleet); + let answered = restarted + .query(AppQuery { + path: "storage.sync".to_string(), + params: proto::ArgPack { + codec: proto::Codec::Proto as i32, + body: crate::sdk::inbox_poller::poll_sync_request().encode_to_vec(), + schema_hash: None, + } + .encode_to_vec(), + }) + .await; + assert!( + answered.success, + "storage.sync: {:?}", + answered.error_message + ); + let sync = match crate::handlers::response_helpers::decode_local_envelope(&answered.data) + .expect("storage.sync answers an envelope") + .payload + { + Some(proto::envelope::Payload::StorageSyncResponse(resp)) => resp, + other => panic!("storage.sync answered {other:?}"), + }; + assert!( + !sync.errors.iter().any(|e| e.contains("stranded admission")), + "{:?}", + sync.errors + ); + admitted_at(&restarted.core_sdk, 1); + assert_eq!( + restarted + .core_sdk + .device_head() + .expect("head") + .balance(&era()), + whole_era(100), + "credited once" + ); +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs index 855cb3525..0a1529420 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs @@ -82,9 +82,14 @@ pub struct ClaimOutcome { /// The validated economic root this device holds, or a fresh activation. /// -/// `activate` refuses a device already holding value — calling its current -/// holdings "position 0" would be self-rooting at the base — so a legacy -/// value-holding device surfaces `UnsupportedLegacyEconomicState` here. +/// `activate` refuses a device that held value at its activation point — +/// calling those holdings "position 0" would be self-rooting at the base — so +/// a legacy value-holding device surfaces `UnsupportedLegacyEconomicState` +/// here. The activation point is the head the first admission was built on: +/// with nothing pending that is the head itself, and with the first admission +/// pending it is the head less that admission's own credit, read from its +/// frozen witness +/// ([`EconomicActivationSnapshot::before_first_admission`]). pub(crate) fn validated_root_or_activate( core: &CoreSDK, ) -> Result { @@ -103,13 +108,60 @@ pub(crate) fn validated_root_or_activate( let head = core .device_head() .ok_or_else(|| DsmError::storage("no device head".to_string(), None::))?; - let snapshot = EconomicActivationSnapshot { - online_balances_empty: head.balances_snapshot().is_empty(), - outstanding_offline_allocation: !head.offline_allocations_snapshot().is_empty(), + let snapshot = match head.pending_economic_admission() { + None => EconomicActivationSnapshot { + online_balances_empty: head.balances_snapshot().is_empty(), + outstanding_offline_allocation: !head.offline_allocations_snapshot().is_empty(), + }, + Some(first) => { + let (_, witness) = frozen_witness_of(first)?; + EconomicActivationSnapshot::before_first_admission(&head, &witness) + .map_err(|e| storage_err("activation point", e))? + } }; activate(snapshot).map_err(|e| DsmError::invalid_operation(e.to_string())) } +/// The frozen witness of `pending`: the exact bytes frozen with its advance, +/// and their decode, bound to the admission by its operation digest and +/// post-root. Never re-derived. +fn frozen_witness_of( + pending: &PendingEconomicAdmission, +) -> Result<(Vec, EconomicTransitionWitness), DsmError> { + let witness_key_prefix = format!( + "immutable::{}::", + String::from_utf8_lossy( + dsm::common::domain_tags::TAG_DSM_ECONOMIC_TRANSITION_WITNESS_OBJ.source_bytes() + ) + ); + let witness_bytes = + crate::storage::client_db::frozen_publication_artifact::find_current_payload_with_prefix_and_purpose( + &witness_key_prefix, + "economic-transition-witness", + ) + .map_err(|e| storage_err("load frozen witness", e))? + .ok_or_else(|| { + DsmError::storage( + "no frozen witness for the pending admission".to_string(), + None::, + ) + })?; + let witness = dsm::economic::decode::decode_transition_witness(&witness_bytes) + .map_err(|e| storage_err("decode frozen witness", e))?; + let coords = pending + .accepted_coords() + .map_err(|e| DsmError::invalid_operation(e.to_string()))?; + if witness.operation_digest != pending.operation_digest + || witness.post_economic_root != coords.post_economic_root + { + return Err(DsmError::storage( + "frozen witness does not match the pending admission".to_string(), + None::, + )); + } + Ok((witness_bytes, witness)) +} + /// This device's admitted economic root and the tree that recomputes it, for /// a holdings proof to a connected application (DSM Amendment A11): the /// proof names `(position, root)` for a foreign verifier, which validates the @@ -1077,7 +1129,8 @@ pub(crate) async fn resume_pending_admission( } // The validated PREDECESSOR: the admitted coordinate, or the activation - // root before a first admission. Its root must equal the pending + // root before a first admission, decided at the activation point (the + // head less this admission's own credit). Its root must equal the pending // pre-root — a mismatch means the local store is incoherent, which is a // stop, not a guess. An admitted position that has selected no root // stops here too: a device that crashed between a fulfillment's @@ -1095,37 +1148,10 @@ pub(crate) async fn resume_pending_admission( } // Reconstruct the witness from the FROZEN bytes (never re-derived). - let witness_key_prefix = format!( - "immutable::{}::", - String::from_utf8_lossy( - dsm::common::domain_tags::TAG_DSM_ECONOMIC_TRANSITION_WITNESS_OBJ.source_bytes() - ) - ); - let witness_bytes = - crate::storage::client_db::frozen_publication_artifact::find_current_payload_with_prefix_and_purpose( - &witness_key_prefix, - "economic-transition-witness", - ) - .map_err(|e| storage_err("load frozen witness", e))? - .ok_or_else(|| { - DsmError::storage( - "no frozen witness for the pending admission".to_string(), - None::, - ) - })?; - let witness = dsm::economic::decode::decode_transition_witness(&witness_bytes) - .map_err(|e| storage_err("decode frozen witness", e))?; + let (witness_bytes, witness) = frozen_witness_of(&pending)?; let coords = *pending .accepted_coords() .map_err(|e| DsmError::invalid_operation(e.to_string()))?; - if witness.operation_digest != pending.operation_digest - || witness.post_economic_root != coords.post_economic_root - { - return Err(DsmError::storage( - "frozen witness does not match the pending admission".to_string(), - None::, - )); - } // Reconstruct the manifest from FROZEN artifacts and derivation-only // inputs, and REQUIRE its address to equal the one the pending admission diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 44fb93e69..fea56ffd7 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -2762,7 +2762,7 @@ Outside this round: the anchor firmware's signing call sites turn an error into - The same held for a payment the player approved by hand: the approval was spent and the request answered FAILED. The approval is now kept on the waiting request, and the first sync after the relationship settles carries it out. - Nothing serialized processing. The listener's sync, a sync the player starts, the player's decision and a disconnect could each read the replay guard before another recorded the request it was carrying out. One processing lock now spans reading the guard and carrying the request out, and a disconnect waits for the request in flight. - The Apps screen reported "carried out" after an approval whatever happened. `connect.respond` now answers with the outcome and a line Rust renders, and the screen shows that line. -- Outside A11, found by the real-connection test and filed separately: a device whose first economic admission is held cannot resume it. A held faucet claim has already credited the head (fenced). Resuming takes its predecessor from `validated_root_or_activate`, which before a first admission activates against that head and refuses it as already holding value ("cannot activate an economic lineage on a device that already holds value"). The SDK suites' funding fixture meets the same refusal intermittently under load. +- Outside A11, found by the real-connection test and filed separately: a device whose first economic admission is held cannot resume it. A held faucet claim has already credited the head (fenced). Resuming takes its predecessor from `validated_root_or_activate`, which before a first admission activates against that head and refuses it as already holding value ("cannot activate an economic lineage on a device that already holds value"). The SDK suites' funding fixture meets the same refusal intermittently under load. Fixed in §6.73. **Found on the phones** (three A16s on the beta fleet, the game's account on `dsm-app-host`, 2026-10-04): - A holdings request that arrived while the wallet was still admitting its latest position was answered FAILED. Like a payment on a settling relationship, it now waits unanswered, and the next sync proves it. @@ -2807,6 +2807,52 @@ Every gate above has a mutation control: each was removed or weakened in turn an - The per-request re-read of a session is reached alone only by a disconnect that lands while a sync is between requests. No test drives that interleaving; with the sync's own filter in place the re-read's mutation stays green. - A holdings proof walks the wallet's lineage from the application's frontier. Until the application has accepted a step from the wallet it has none, so the walk starts at the wallet's activation, within the walk budget. +### 6.73 A device's first admission, held after its advance, resumes on its activation root (`fix/resume-first-held-admission`, 2026-10-04) + +**The finding.** Found 2026-10-04 while building the DSM Connect real-connection test (`feat/dsm-connect-wallet-apps`). The advance and the pending admission commit in one transaction. So an admission held at any later step (its evidence not yet `Stored`, its root claim not yet final) leaves its own credit on the head, fenced. Every resume path took its predecessor from `validated_root_or_activate`: `resume_pending_admission`, reached from the next faucet claim, from `stage_admission`, from `wallet.send`, and from `storage.sync` (Step 0 and the stranded-admission sweep). With nothing admitted yet, that function activated against the head as it stood. The head holds the held claim's own credit, so `activate` refused: "cannot activate an economic lineage on a device that already holds value". A fresh wallet whose first claim was held could never register it (MR-DSM-0030), and every economic operation after it stayed fenced. The SDK suites' funding fixture met the same refusal whenever a node write lagged under load (`test_support/two_device.rs`, "funding claim 0: …"). + +**What decides the predecessor.** An identity's activation root is position 0 at the canonical empty root, authenticated by its device identity (DSM Amendment A8, MR-DSM-0274). A first admission is built on it. The local refusal in `activate` of a device that holds value is unchanged: calling legacy holdings position 0 would be self-rooting. The question that refusal answers is what the device held at its activation point: the head its first admission was built on, not the head after that admission's own advance. + +**The fix.** +- `dsm::economic::lineage::EconomicActivationSnapshot::before_first_admission` reads the activation point as the head less exactly the pending first admission's credits. It takes those credits from the admission's frozen witness, and counts the witness only when it is the admission's own: + - its operation digest and post-root are the admission's; + - its pre-root is the activation root; + - its mutations verify from the activation root under the head's identity. + + The admission itself must be locally accepted, DSM-backed, and at position 1 on the activation root. Anything else is `ActivationPointUnreadable`. An outstanding allocation is read off the head as it stands, because a DSM-backed admission moves none. +- `validated_root_or_activate` uses it when the head carries a pending admission and nothing is admitted. One helper, `frozen_witness_of`, reads the frozen witness for both it and `resume_pending_admission`. +- `activate` is unchanged. Whatever the first admission did not credit was held at the activation point and is refused: more of the credited asset, another asset, or an outstanding allocation. + +**Tests.** +- Three node-backed SDK tests: + - `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_resuming_it` + - `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_the_next_claim` + - `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_the_sync_after_a_restart` + + In each, a fresh device's first claim is held because the seats after the second refuse writes of `K_root(1)`. The root claim cannot reach three links, and the claim answers "the claim is not final yet". The test asserts the held state: position 1 on the activation root, 100 ERA on the head, nothing admitted. Then the members take writes again, and the claim is resumed through one production path: directly, by the next claim (which lands at position 2), or by `storage.sync` after a restart. All three were red before the fix, with the refusal above (commit `d8b85c953`). +- `dsm::economic_admission_lifecycle::a_held_first_admission_resumes_on_the_activation_root`: the head as it stands is refused. The activation point is fresh, and it activates at position 0 on the empty root. +- `dsm::economic_admission_lifecycle::value_the_first_admission_did_not_credit_still_blocks_activation`: a head that holds more of the credited asset, another asset, or an outstanding allocation beside the held credit is refused. +- `dsm::economic_admission_lifecycle::the_activation_point_is_read_only_through_the_first_admissions_own_witness`: each of these is refused, each with its own error: + - nothing pending; + - an admission at position 2; + - an offline load; + - the witness of another operation; + - another identity's head, where the witness does not verify; + - a head without the credit. + +**Mutation controls.** Each was run on commit `217491e6f` and restored. `git diff --exit-code` was clean after every restore. + +| Mutation | Red test | +|---|---| +| The pending first admission ignored: activation read off the head as it stands (the defect) | `a_held_first_claim_is_finished_by_resuming_it`, `a_held_first_claim_is_finished_by_the_next_claim`, `a_held_first_claim_is_finished_by_the_sync_after_a_restart` | +| The credit takes the whole balance of its asset with it | `value_the_first_admission_did_not_credit_still_blocks_activation` ("more of the credited asset") | +| The outstanding allocation not read off the head | `value_the_first_admission_did_not_credit_still_blocks_activation` ("an outstanding allocation") | +| The witness not bound to the pending operation | `the_activation_point_is_read_only_through_the_first_admissions_own_witness` (another operation) | +| The witness's mutations not verified under the head's identity | `the_activation_point_is_read_only_through_the_first_admissions_own_witness` (another identity) | +| Any pending kind and position taken as a first admission | `the_activation_point_is_read_only_through_the_first_admissions_own_witness` (position 2) | + +**Not driven by a test.** `wallet.send` and `storage.sync`'s Step 0 resume through the same `resume_pending_admission`, but no test reaches either with a held first admission. Step 0 runs only with a staged inbound transfer, and a send needs a funded device. `prevalidate_incoming_transfer_admission` still holds an inbound transfer while any admission is pending. That is unchanged. + ## 7 Totals | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | @@ -2853,7 +2899,7 @@ Every gate above has a mutation control: each was removed or weakened in turn an | MR-DSM-0027 | Partial | dsm · economic/peer_acceptance.rs (87-147, EK-ancestry walk) | — | A generic "relationship created only after mirror-verify" gate not located outside this SoFi path. | | MR-DSM-0028 | Met | `dsm::economic::peer_acceptance::resolve_expected_prev_pk` | `dsm::economic_peer_evidence::ek_ancestry_walks_one_step_and_refuses_unhashed_substitution` | — | | MR-DSM-0029 | Partial | `dsm_sdk::handlers::storage_routes::complete_staged_pairs`; `dsm_sdk::sdk::core_sdk::CoreSDK::enforce_policy_for_operation` | `dsm_sdk::handlers::sender_admission_tests::a_transfer_its_policy_refuses_is_refused_before_the_senders_register_is_read`; `dsm_sdk::handlers::recipient_admission_tests::a_receiver_that_never_added_the_token_is_not_credited_until_it_adopts` | Re-verified 2026-09-30 (§6.51) and 2026-10-01 (§6.55). On the online path, the receiver's sync checks the token's committed policy, which covers adoption, before prevalidation reads the sender's register. A transfer the policy refuses, or of a token the receiver never adopted, is held with no cell read. Partial: the relationship tip (the pinned counterparty head and the signed child's derivation) is still decided in the staged apply, after the read. No test yet presents a pair whose signed parent is not the pinned head (§6.55). | -| MR-DSM-0030 | Met | `dsm_sdk::handlers::app_router_impl::process_online_transfer_logic`; `dsm_sdk::sdk::economic_admission_flow::finish_admission`; `dsm_sdk::sdk::economic_registers::register_economic_root`; `dsm_sdk::sdk::economic_admission_flow::admitted_self_loop_operation` | `dsm_sdk::handlers::sender_admission_tests::a_transfer_registers_the_senders_root_at_the_next_position`; `dsm_sdk::handlers::sender_admission_tests::token_routes_admit_create_and_burn_end_to_end`; `dsm_sdk::handlers::sender_admission_tests::an_admitted_burn_advances_the_lineage_and_is_foreign_walkable` | Every value-moving advance registers its root at the next economic position, in the SDK rather than in Core (G6 withdrawn). The send path is covered: a transfer registers the sender's root at the next position, and a foreign walk validates that position as the transfer; with the register write removed from `finish_admission` the test is red (2026-09-24). | +| MR-DSM-0030 | Met | `dsm_sdk::handlers::app_router_impl::process_online_transfer_logic`; `dsm_sdk::sdk::economic_admission_flow::finish_admission`; `dsm_sdk::sdk::economic_registers::register_economic_root`; `dsm_sdk::sdk::economic_admission_flow::admitted_self_loop_operation`; `dsm::economic::lineage::EconomicActivationSnapshot::before_first_admission`; `dsm_sdk::sdk::economic_admission_flow::validated_root_or_activate` | `dsm_sdk::handlers::sender_admission_tests::a_transfer_registers_the_senders_root_at_the_next_position`; `dsm_sdk::handlers::sender_admission_tests::token_routes_admit_create_and_burn_end_to_end`; `dsm_sdk::handlers::sender_admission_tests::an_admitted_burn_advances_the_lineage_and_is_foreign_walkable`; `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_resuming_it`; `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_the_next_claim`; `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_the_sync_after_a_restart` | Every value-moving advance registers its root at the next economic position, in the SDK rather than in Core (G6 withdrawn). The send path is covered: a transfer registers the sender's root at the next position, and a foreign walk validates that position as the transfer; with the register write removed from `finish_admission` the test is red (2026-09-24). A device's first value-moving advance, held after it credited the head, registers when it is resumed (§6.73, 2026-10-04); before, its resume activated against that credit and was refused. | | MR-DSM-0031 | Met | `dsm::economic::register::economic_root_register_key` | `dsm::economic_lineage_register::each_position_of_each_identity_is_its_own_cell` | — | | MR-DSM-0032 | Met | `dsm::economic::register::position_seed`; `dsm::route_chain::RoutedCell::new`; `dsm::route_chain::Route::of`; `dsm::route_chain::Route::leader` | `dsm::sofi::fisher_yates::tests::different_seeds_can_choose_different_first_members`; `dsm::route_chain::tests::a_route_is_the_fisher_yates_permutation_of_the_committed_set` | The seed is `position_seed` over G, DevID, position and the validated parent root, which `RootCell::new` hands to the route; no node id or availability view enters it. The earlier citation, `position_leader`, is reached by no shipped build (§6.39). | | MR-DSM-0033 | Met | `dsm::economic::register::RegisteredEconomicRoot::from_verified_single_root` | `dsm::economic::register::registered_root_construction_tests::a_registered_root_is_a_projection_of_a_verified_claim`; `dsm::economic::register::registered_root_construction_tests::a_conditional_claim_has_nothing_to_construct_from` | — | diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index 8af21fa33..3358c5080 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -17,6 +17,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | Requirement | Gate | Negative test | Mutation control | Formal | |---|---|---|---|---| +| MR-DSM-0030, MR-DSM-0274: a device's first value-moving advance, held after it credited the head, registers on resume from its activation root; activation still refuses value the first admission did not credit (§6.73) | `dsm` · economic/lineage.rs · `EconomicActivationSnapshot::before_first_admission`, `activate`; `dsm_sdk` · sdk/economic_admission_flow.rs · `validated_root_or_activate`, `frozen_witness_of`, `resume_pending_admission` | `dsm::economic_admission_lifecycle::value_the_first_admission_did_not_credit_still_blocks_activation` (more of the credited asset, another asset, an outstanding allocation, each refused); `dsm::economic_admission_lifecycle::the_activation_point_is_read_only_through_the_first_admissions_own_witness` (nothing pending, position 2, an offline load, another operation's witness, another identity's head, a head without the credit, each refused); `dsm::economic_admission_lifecycle::a_held_first_admission_resumes_on_the_activation_root`; `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_resuming_it`; `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_the_next_claim`; `dsm_sdk::handlers::faucet_flow_tests::a_held_first_claim_is_finished_by_the_sync_after_a_restart` | Six mutations red, each restored (2026-10-04): the pending first admission ignored (the defect) → the three `a_held_first_claim_*` tests; the credit taking its asset's whole balance, or the allocation not read → `value_the_first_admission_did_not_credit_still_blocks_activation`; the witness not bound to the pending operation, its mutations not verified under the head's identity, or any kind and position taken as a first admission → `the_activation_point_is_read_only_through_the_first_admissions_own_witness`. | — | | Pre-audit item 4 (owner, 2026-10-02); MR-SOFI-0283 by commitment; MR-DSM-0272: a transfer's memo, ticker and nonce are in no public object, and a recipient takes a transfer only when its terms open the signed commitment | `dsm` · types/operations.rs · `TransferTerms::open`, `Operation::Transfer.terms_commitment`; `dsm_sdk` · handlers/recipient_dispatch.rs · `open_terms`; bluetooth/bilateral_session.rs · `step_terms`; sdk/core_sdk.rs · `apply_incoming_transfer_staged` | `dsm_sdk::handlers::recipient_dispatch::tests::a_transfer_without_terms_that_open_it_is_not_recognized`; `dsm_sdk::bluetooth::offline_step_tests::a_bearer_prepare_without_terms_that_open_it_is_refused`; `dsm_sdk::handlers::node_e2e_tests::no_node_holds_a_transfers_memo_ticker_or_nonce`; `dsm::types::operations::tests::transfer_terms::terms_that_differ_in_anything_do_not_open_the_transfer`; `dsm::types::operations::tests::transfer_terms::a_salt_is_fresh_for_every_transfer`; `dsm::types::operations::tests::transfer_terms::a_transfer_operation_carries_no_term_in_the_clear` | Commitment check removed → all three refusal tests red. Online or BLE terms taken without opening → the matching refusal test red. Salt from public data → `a_salt_is_fresh_for_every_transfer` red. | — | | MR-SOFI-0195, MR-SOFI-0197: output becomes canonical only when a position resolves Realized; `advance_resolved` is the only constructor of the root at q | `dsm` · sofi/lineage.rs · `advance_resolved` → `ResolvedBalances`; types/device_state.rs · `with_resolved_position`; `dsm_sdk` · sdk/core_sdk.rs · `admit_resolved_sofi_position` | `dsm::sofi::lineage::tests::a_head_that_does_not_hold_the_pre_balances_takes_no_resolution`; `dsm::sofi::lineage::tests::a_void_position_moves_no_balance` | Pre-balance check removed → `a_head_that_does_not_hold_the_pre_balances_takes_no_resolution` red. Balances left off the admitted head → `dsm_sdk::handlers::node_e2e_tests::a_sofi_trade_executes_end_to_end` red ("the trader paid 10 ERA": 200 ≠ 190). | — | | MR-SOFI-0200: vault creation debits the funding | `dsm` · types/device_state.rs · `validate_conservation` (`SofiVaultCreate` arm) | `dsm::types::device_state::tests::a_vault_creation_debits_exactly_its_two_funded_legs` | The delta comparison disabled → red (2026-09-24). | — |