diff --git a/src/nostr/identities.rs b/src/nostr/identities.rs index fcdfe3ed..d89975f6 100644 --- a/src/nostr/identities.rs +++ b/src/nostr/identities.rs @@ -58,16 +58,19 @@ pub const MAX_IDENTITIES: usize = 8; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct HeldEntry { /// Public key, lowercase hex — the stable id of this identity. + #[serde(default)] pub pubkey: String, /// Path to the identity's `identity.json`, RELATIVE to the nostr dir: /// `"identity.json"` for the legacy identity #1, else /// `"identities//identity.json"`. + #[serde(default)] pub path: String, /// A short human label: the identity's claimed name (local part of its NIP-05) /// when it has one, else empty. NOT rendered — the UI derives its display from /// the name or a truncated npub — kept only as a convenience field in the /// index. Plaintext by design (this index carries no secret). Never a /// placeholder word. + #[serde(default)] pub label: String, } @@ -91,16 +94,27 @@ impl HeldEntry { /// active. Persisted as `identities.json`. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct HeldIdentities { + /// Format version. Defaults to 1 when absent so a file that dropped the field + /// still parses. + #[serde(default = "default_held_ver")] pub ver: u8, /// Active identity, lowercase hex. Drives the single live subscription and /// all display; the only pointer a switch moves. + #[serde(default)] pub active: String, /// Display order, lowercase hex. + #[serde(default)] pub order: Vec, /// Entry metadata (no secrets). + #[serde(default)] pub identities: Vec, } +/// The held-index format version default (1). +fn default_held_ver() -> u8 { + 1 +} + impl HeldIdentities { /// Index file path inside the nostr dir. pub fn index_path(nostr_dir: &PathBuf) -> PathBuf { @@ -552,6 +566,32 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn held_index_parse_is_forward_compatible() { + // A blob in the CURRENT format plus an unknown extra field still parses + // (unknown fields ignored), so a newer build's index is not rejected. + let idx: HeldIdentities = serde_json::from_str( + r#"{"ver":1,"active":"ab","order":["ab"],"identities":[{"pubkey":"ab","path":"identity.json","label":"","future_field":true}],"future_top":42}"#, + ) + .unwrap(); + assert_eq!(idx.ver, 1); + assert_eq!(idx.active, "ab"); + assert_eq!(idx.len(), 1); + // A blob MISSING non-essential fields parses with the correct defaults: + // ver -> 1, order/identities -> empty, active -> empty. + let idx: HeldIdentities = serde_json::from_str(r#"{"active":"cd"}"#).unwrap(); + assert_eq!(idx.ver, 1, "ver must default to 1"); + assert_eq!(idx.active, "cd"); + assert!(idx.order.is_empty()); + assert!(idx.identities.is_empty()); + // A HeldEntry missing its label still parses (label defaults empty). + let idx: HeldIdentities = serde_json::from_str( + r#"{"ver":1,"active":"ab","order":["ab"],"identities":[{"pubkey":"ab","path":"identity.json"}]}"#, + ) + .unwrap(); + assert_eq!(idx.identities[0].label, ""); + } + #[test] fn catchup_since_prefers_identity_then_wallet_then_now() { let lookback = 3 * 86_400; diff --git a/src/wallet/proof_addrs.rs b/src/wallet/proof_addrs.rs index 2f7791ef..578ebf6c 100644 --- a/src/wallet/proof_addrs.rs +++ b/src/wallet/proof_addrs.rs @@ -48,13 +48,34 @@ pub const MAX_PROOF_ADDRESS_INDEX: u32 = 1023; /// app's default address, so per-sale allocation starts at 1. #[derive(Serialize, Deserialize, Clone, Debug)] struct ProofAddrRegistry { + /// Format version. Defaults to 1 when absent so an older/newer file that + /// dropped the field still parses. + #[serde(default = "default_ver")] ver: u8, + /// Next index to hand out. MUST default to 1 (never 0: index 0 is the app's + /// default address), so a file missing this field parses to the fresh-wallet + /// counter rather than re-handing-out index 0. + #[serde(default = "default_next")] next: u32, } +/// The registry format version default (1). +fn default_ver() -> u8 { + 1 +} + +/// The allocation counter default. MUST be 1: index 0 is the app's default +/// address, so per-sale minting starts at 1 and never 0. +fn default_next() -> u32 { + 1 +} + impl Default for ProofAddrRegistry { fn default() -> Self { - Self { ver: 1, next: 1 } + Self { + ver: default_ver(), + next: default_next(), + } } } @@ -180,6 +201,33 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn registry_parse_is_forward_compatible() { + // A blob in the CURRENT format plus an unknown extra field still parses + // (unknown fields ignored, so it is NOT treated as corrupt). + let reg: ProofAddrRegistry = + serde_json::from_str(r#"{"ver":1,"next":7,"future_field":true}"#).unwrap(); + assert_eq!(reg.next, 7); + assert_eq!(reg.ver, 1); + // A blob MISSING the non-essential field parses with the correct default: + // next defaults to 1 (never 0), ver to 1. + let reg: ProofAddrRegistry = serde_json::from_str(r#"{"ver":1}"#).unwrap(); + assert_eq!(reg.next, 1, "next must default to 1, never 0"); + let reg: ProofAddrRegistry = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(reg.ver, 1); + assert_eq!(reg.next, 1); + } + + #[test] + fn present_registry_with_unknown_field_still_allocates() { + // Interaction with the corrupt-refuse rule: an unknown extra field is + // forward-compat, not corruption, so allocation proceeds normally. + let path = tmpfile("forward"); + std::fs::write(&path, r#"{"ver":1,"next":9,"future_field":true}"#).unwrap(); + assert_eq!(allocate(&path).unwrap(), 9); + let _ = std::fs::remove_file(&path); + } + #[test] fn concurrent_allocation_hands_out_unique_indices() { use std::collections::HashSet;