Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
)
}
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
],
)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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}"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,
/// 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<u8>,
}

pub fn put_amm_vault_record(rec: &AmmVaultRecord) -> Result<()> {
Expand All @@ -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(),
Expand All @@ -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,
],
)?;
Expand Down Expand Up @@ -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<u8>) -> Option<[u8; 32]> {
<[u8; 32]>::try_from(v.as_slice()).ok()
}
Expand All @@ -132,7 +161,7 @@ pub fn get_amm_vault_record(vault_id: &[u8; 32]) -> Result<Option<AmmVaultRecord
.query_row(
"SELECT 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
baseline_state_ccb, baseline_presentation, vault_post_proto
FROM amm_vault_records WHERE vault_id = ?1",
params![vault_id.as_slice()],
|r| {
Expand All @@ -148,6 +177,7 @@ pub fn get_amm_vault_record(vault_id: &[u8; 32]) -> Result<Option<AmmVaultRecord
r.get::<_, Vec<u8>>(8)?,
r.get::<_, Vec<u8>>(9)?,
r.get::<_, Vec<u8>>(10)?,
r.get::<_, Vec<u8>>(11)?,
))
},
)
Expand All @@ -164,6 +194,7 @@ pub fn get_amm_vault_record(vault_id: &[u8; 32]) -> Result<Option<AmmVaultRecord
ss,
baseline_state_ccb,
baseline_presentation,
vault_post_proto,
)) = row
else {
return Ok(None);
Expand All @@ -190,6 +221,7 @@ pub fn get_amm_vault_record(vault_id: &[u8; 32]) -> Result<Option<AmmVaultRecord
storage_set_id,
baseline_state_ccb,
baseline_presentation,
vault_post_proto,
}))
}

Expand Down Expand Up @@ -243,6 +275,7 @@ mod tests {
storage_set_id: [0x6B; 32],
baseline_state_ccb: Vec::new(),
baseline_presentation: Vec::new(),
vault_post_proto: vec![0xC3; 48],
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ fn get_database_path() -> Result<PathBuf> {
/// 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.
///
Expand Down Expand Up @@ -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
);

Expand Down
Loading