diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs index 3345505b..84e80cc5 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs @@ -753,6 +753,9 @@ impl AppRouterImpl { storage_set_id: birth_set_id, baseline_state_ccb: Vec::new(), baseline_presentation: Vec::new(), + // Stamped after finalize + policy stamping below, + // the earliest point at which the bytes are final. + vault_post_proto: Vec::new(), }, ) } @@ -801,8 +804,8 @@ impl AppRouterImpl { "INSERT INTO amm_vault_records( vault_id, owner_genesis, owner_devid, policy_commit_a, policy_commit_b, fee_bps, anchor_enforcement, policy_digest, storage_set_id, - baseline_state_ccb, baseline_presentation, created_at) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + baseline_state_ccb, baseline_presentation, vault_post_proto, created_at) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", rusqlite::params![ rec.vault_id.as_slice(), rec.owner_genesis.as_slice(), @@ -815,6 +818,7 @@ impl AppRouterImpl { rec.storage_set_id.as_slice(), rec.baseline_state_ccb.as_slice(), rec.baseline_presentation.as_slice(), + rec.vault_post_proto.as_slice(), crate::util::deterministic_time::tick() as i64, ], ) @@ -1019,6 +1023,32 @@ impl AppRouterImpl { // so it cannot outlive a rolled-back creation or be lost to a crash that // leaves the reserves encumbered. + // FREEZE THE VAULT POST. The routing advertisement's full proto mirror + // is the encoded `VaultPostProto`; deriving it from the in-memory + // DLVManager made ad publication impossible after a restart (the + // manager's vaults are process-lifetime, by doctrine). The bytes are + // final only now — after `finalize_vault` applied the creator + // signature and the block above stamped enforcement + policy digest — + // so they are produced once here and stamped onto the vault's record, + // where the publisher replays them from durable state. MANDATORY for + // an AMM vault: a vault whose post cannot be frozen could never be + // advertised, so that is surfaced here rather than at first publish. + if record_to_persist.is_some() { + let post_bytes = match dlv_manager + .create_vault_post(&vault_id, "dlv.create", None) + .await + { + Ok(b) => b, + Err(e) => return err(format!("dlv.create: freezing the vault post failed: {e}")), + }; + if let Err(e) = crate::storage::client_db::amm_vault_records::update_vault_post_proto( + &vault_id, + &post_bytes, + ) { + return err(format!("dlv.create: stamping the vault post failed: {e}")); + } + } + // PUBLISH THE BIRTH — best-effort now; the generic sweep (cold boot and // every `storage.sync`) replays the exact frozen bytes until a quorum of // the birth set holds them. Until then the vault is FUNDED but NOT diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/route_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/route_routes.rs index b78133df..e3537d87 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/route_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/route_routes.rs @@ -498,33 +498,40 @@ impl AppRouterImpl { .into(), ); } - // Derive vault_proto_bytes from the local DLVManager when the - // caller passes empty. This is the path the SoFi test + - // production wallet UIs use: the wallet has the canonical - // vault state via `dlv.create`; making the caller serialise - // VaultPostProto bytes themselves is redundant + error-prone - // (the test was passing a UTF-8 placeholder string which then - // failed to decode as VaultPostProto at the trader's - // `route.syncVaultsForPair` step, leaving the trader's - // DLVManager empty and `dlv.unlockRouted` rejecting with - // "vault not in local DLVManager"). When the caller does - // pass non-empty bytes (router-service integrations), we - // honour them verbatim. - if req.vault_proto_bytes.is_empty() { - let dlv_manager = self.bitcoin_tap.dlv_manager(); - let mut vid_arr = [0u8; 32]; - vid_arr.copy_from_slice(&req.vault_id); - match dlv_manager - .create_vault_post(&vid_arr, "route.publishRoutingAdvertisement", None) - .await - { - Ok(bytes) => req.vault_proto_bytes = bytes, + // THE RECORD IS THE DURABLE SOURCE for everything below: the frozen + // `VaultPostProto` bytes and the birth presentation digest. Loaded + // once, here. The in-memory DLVManager is deliberately NOT consulted + // — its vaults are process-lifetime, so deriving the proto mirror + // from it made ad publication impossible after a restart. `dlv.create` + // froze the exact bytes onto the record; this publisher replays them. + let record = + match crate::storage::client_db::amm_vault_records::get_amm_vault_record(&vault_id) { + Ok(Some(r)) => r, + Ok(None) => { + return err( + "route.publishRoutingAdvertisement: no AMM vault record for this \ + vault on this device" + .into(), + ) + } Err(e) => { return err(format!( - "route.publishRoutingAdvertisement: vault_proto_bytes empty + local DLVManager create_vault_post failed: {e}" - )); + "route.publishRoutingAdvertisement: vault record read failed: {e}" + )) } + }; + // Frozen at `dlv.create`, replayed verbatim when the caller passes + // empty. Non-empty caller bytes (router-service integrations) are + // honoured verbatim, as before. + if req.vault_proto_bytes.is_empty() { + if record.vault_post_proto.is_empty() { + return err( + "route.publishRoutingAdvertisement: the vault record carries no frozen \ + vault post — reprovision (no legacy upgrade path exists)" + .into(), + ); } + req.vault_proto_bytes = record.vault_post_proto.clone(); } // Accept-or-stamp: empty owner pk → wallet pk; non-empty → // caller-supplied. Same pattern as chunk #6 / Track C.4 / @@ -556,23 +563,6 @@ impl AppRouterImpl { // published (stored on the vault record). No record blob, no ad — a // vault the trader cannot verify must not be discoverable. let presentation_digest = { - let record = - match crate::storage::client_db::amm_vault_records::get_amm_vault_record(&vault_id) - { - Ok(Some(r)) => r, - Ok(None) => { - return err( - "route.publishRoutingAdvertisement: no AMM vault record for this \ - vault on this device" - .into(), - ) - } - Err(e) => { - return err(format!( - "route.publishRoutingAdvertisement: vault record read failed: {e}" - )) - } - }; if record.baseline_presentation.is_empty() { return err( "route.publishRoutingAdvertisement: the vault record carries no birth \ @@ -1904,4 +1894,104 @@ mod stamping_tests { "and the signature covers the identity that replaced it" ); } + + /// THE RESTART, not the process that created the vault. + /// + /// `dlv.create` freezes the vault's `VaultPostProto` bytes onto its + /// record; the advertisement publisher replays them from there. A fresh + /// router — a fresh (empty) DLVManager over the same durable state — must + /// therefore publish the ad. Before this cut the publisher derived the + /// proto mirror from the in-memory manager, so this exact sequence failed + /// with "Vault not found" on hardware. + #[test] + #[serial] + fn the_advertisement_publishes_from_durable_state_after_a_restart() { + install_identity(); + let r = router(); + let (pc_a, pc_b) = crate::sdk::funded_vault_fixture::pair_commits(); + r.core_sdk + .set_device_head_for_testing(crate::sdk::funded_vault_fixture::owner_holding( + 50_000, 20_000, + )); + let vault_id = crate::sdk::funded_vault_fixture::create_funded_amm_vault( + &r, &pc_a, &pc_b, 10_000, 5_000, + ); + + // The producer ran: the record carries decodable frozen bytes. + let record = crate::storage::client_db::amm_vault_records::get_amm_vault_record(&vault_id) + .expect("record read") + .expect("record exists"); + assert!( + !record.vault_post_proto.is_empty(), + "dlv.create must freeze the vault post onto the record" + ); + let post = generated::VaultPostProto::decode(record.vault_post_proto.as_slice()) + .expect("the frozen bytes are a VaultPostProto"); + assert_eq!(post.vault_id, vault_id.to_vec()); + assert!( + !post.vault_data.is_empty(), + "the frozen post carries the encoded LimboVaultProto" + ); + + // THE RESTART: a new router is a new (empty) DLVManager. The device + // head survives a real restart via the persistence codec, so it is + // carried over; the manager's contents are not. + let head = r.core_sdk.device_head().expect("head after create"); + let r2 = router(); + r2.core_sdk.set_device_head_for_testing(head); + publish_ad(&r2, &vault_id, &pc_a, &pc_b); + } + + /// MUTATION CONTROL for the frozen-post gate: a record whose + /// `vault_post_proto` is empty (the producer never ran) must refuse to + /// publish rather than fall back to any in-memory derivation. + #[test] + #[serial] + fn an_empty_frozen_post_refuses_to_publish_instead_of_rederiving() { + install_identity(); + let r = router(); + let (pc_a, pc_b) = crate::sdk::funded_vault_fixture::pair_commits(); + r.core_sdk + .set_device_head_for_testing(crate::sdk::funded_vault_fixture::owner_holding( + 50_000, 20_000, + )); + let vault_id = crate::sdk::funded_vault_fixture::create_funded_amm_vault( + &r, &pc_a, &pc_b, 10_000, 5_000, + ); + + // Blank the frozen bytes — the state a pre-cut record would be in. + { + let binding = crate::storage::client_db::get_connection().expect("conn"); + let conn = binding.lock().expect("lock"); + conn.execute( + "UPDATE amm_vault_records SET vault_post_proto = X'' WHERE vault_id = ?1", + rusqlite::params![vault_id.as_slice()], + ) + .expect("blank the frozen post"); + } + + let req = generated::PublishRoutingAdvertisementRequest { + vault_id: vault_id.to_vec(), + token_a: pc_a.to_vec(), + token_b: pc_b.to_vec(), + fee_bps: 30, + unlock_spec_digest: vec![0x5A; 32], + unlock_spec_key: "sofi/spec/test".to_string(), + owner_public_key: Vec::new(), + vault_proto_bytes: Vec::new(), + }; + let res = crate::runtime::get_runtime().block_on(async { + r.invoke(AppInvoke { + method: "route.publishRoutingAdvertisement".to_string(), + args: pack(req.encode_to_vec()), + }) + .await + }); + assert!(!res.success, "an empty frozen post must refuse to publish"); + let msg = res.error_message.unwrap_or_default(); + assert!( + msg.contains("no frozen vault post"), + "the refusal names the missing producer, got: {msg}" + ); + } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/vault_rehydration.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/vault_rehydration.rs index 77e6a183..e70503ae 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/vault_rehydration.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/vault_rehydration.rs @@ -332,6 +332,7 @@ mod tests { storage_set_id: [0x6B; 32], baseline_state_ccb: Vec::new(), baseline_presentation: Vec::new(), + vault_post_proto: Vec::new(), }; (record, head) } @@ -568,6 +569,7 @@ mod tests { storage_set_id: [0x6B; 32], baseline_state_ccb: Vec::new(), baseline_presentation: Vec::new(), + vault_post_proto: Vec::new(), }; assert_eq!( rehydrate_amm_vault(&record, &head), diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/amm_vault_records.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/amm_vault_records.rs index dad1e313..98939d15 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/amm_vault_records.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/amm_vault_records.rs @@ -61,6 +61,12 @@ pub struct AmmVaultRecord { /// exactly as published — reused for every owner-side composition so the /// authority chain is not re-signed per quote. pub baseline_presentation: Vec, + /// The vault's frozen `VaultPostProto` bytes, produced once at + /// `dlv.create` after the vault is finalized and stamped. The routing + /// advertisement's full proto mirror replays these exact bytes, so + /// publishing survives a restart without consulting the in-memory + /// DLVManager. Empty means the producer never ran; consumers fail closed. + pub vault_post_proto: Vec, } pub fn put_amm_vault_record(rec: &AmmVaultRecord) -> Result<()> { @@ -74,8 +80,8 @@ pub fn put_amm_vault_record(rec: &AmmVaultRecord) -> Result<()> { "INSERT OR REPLACE INTO amm_vault_records( vault_id, owner_genesis, owner_devid, policy_commit_a, policy_commit_b, fee_bps, anchor_enforcement, policy_digest, storage_set_id, - baseline_state_ccb, baseline_presentation, created_at) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + baseline_state_ccb, baseline_presentation, vault_post_proto, created_at) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ rec.vault_id.as_slice(), rec.owner_genesis.as_slice(), @@ -88,6 +94,7 @@ pub fn put_amm_vault_record(rec: &AmmVaultRecord) -> Result<()> { rec.storage_set_id.as_slice(), rec.baseline_state_ccb.as_slice(), rec.baseline_presentation.as_slice(), + rec.vault_post_proto.as_slice(), now as i64, ], )?; @@ -115,6 +122,28 @@ pub fn update_baseline_with_conn( Ok(()) } +/// Stamp the vault's frozen `VaultPostProto` bytes onto its record. Runs once, +/// at `dlv.create`, after the vault is finalized and its enforcement/policy +/// digest are stamped — the earliest point at which the bytes are final. +pub fn update_vault_post_proto(vault_id: &[u8; 32], post_proto: &[u8]) -> Result<()> { + if post_proto.is_empty() { + anyhow::bail!("refusing to stamp empty vault-post bytes"); + } + let binding = get_connection()?; + let conn = binding.lock().unwrap_or_else(|poisoned| { + log::warn!("DB lock poisoned in update_vault_post_proto, recovering"); + poisoned.into_inner() + }); + let changed = conn.execute( + "UPDATE amm_vault_records SET vault_post_proto = ?2 WHERE vault_id = ?1", + params![vault_id.as_slice(), post_proto], + )?; + if changed != 1 { + anyhow::bail!("vault-post stamp touched {changed} rows for one vault id"); + } + Ok(()) +} + fn fixed32(v: Vec) -> Option<[u8; 32]> { <[u8; 32]>::try_from(v.as_slice()).ok() } @@ -132,7 +161,7 @@ pub fn get_amm_vault_record(vault_id: &[u8; 32]) -> Result Result>(8)?, r.get::<_, Vec>(9)?, r.get::<_, Vec>(10)?, + r.get::<_, Vec>(11)?, )) }, ) @@ -164,6 +194,7 @@ pub fn get_amm_vault_record(vault_id: &[u8; 32]) -> Result Result Result { /// written before any external step so recovery resumes instead of re-signing). /// Also durable protocol state that decides what "published", "which set" and /// "which claim" mean; same rule as v4 — the version is the authority, no shim. -pub const CLIENT_DB_SCHEMA_VERSION: i64 = 6; +pub const CLIENT_DB_SCHEMA_VERSION: i64 = 7; /// Honest incompatibility detection — NOT legacy support. /// @@ -1177,6 +1177,13 @@ fn create_schema(conn: &Connection) -> Result<()> { -- c_n recomputes from the blob, so no digest is cached beside it. baseline_state_ccb BLOB NOT NULL DEFAULT X'', baseline_presentation BLOB NOT NULL DEFAULT X'', + -- The vault's frozen `VaultPostProto` bytes, produced once at + -- `dlv.create` after the vault is finalized and stamped. The + -- routing advertisement's full proto mirror replays these exact + -- bytes, so publishing survives a restart without consulting the + -- in-memory DLVManager. Empty means the producer never ran: the + -- ad publisher fails closed rather than re-deriving. + vault_post_proto BLOB NOT NULL DEFAULT X'', created_at INTEGER NOT NULL );