From 26da66710ef451a0fdeae106ad602bfd5f556a78 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:11:02 -0400 Subject: [PATCH] goblin: held nostr identity set (storage + migration) One wallet, one grin seed / one balance, but a SET of nostr identities (nsecs), exactly one active at a time. New identities module owns the held-identity index (identities.json): which identities the wallet holds, their order, and which is active. It stores no secrets; each held identity is its own NIP-49 ncryptsec on disk, exactly like the single identity today (identity.rs create/unlock/backup reused unchanged, plus save_at/load_at for the per-identity files and pubkey_hex for the index key). Migration is fund-safe and needs no key regen: a pre-feature wallet has only identity.json, which the index adopts in place as the single active identity number one; the legacy file is never overwritten, so an older build still opens the wallet on it (clean rollback). The store gains a per-identity last_active_at so a switch back to a dormant identity can catch up from when it last listened, not merely from the wallet-wide last connection. Unit tests cover migration, add/switch/cap/dedupe, active resolution across a reload, corrupt-index fallback, reencrypt-all, and the catch-up-since rule. --- src/nostr/identities.rs | 544 ++++++++++++++++++++++++++++++++++++++++ src/nostr/identity.rs | 32 +++ src/nostr/mod.rs | 3 + src/nostr/store.rs | 24 ++ 4 files changed, 603 insertions(+) create mode 100644 src/nostr/identities.rs diff --git a/src/nostr/identities.rs b/src/nostr/identities.rs new file mode 100644 index 00000000..10212a40 --- /dev/null +++ b/src/nostr/identities.rs @@ -0,0 +1,544 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! One wallet, one grin seed / one balance, but MANY nostr identities (nsecs), +//! exactly one of which is ACTIVE at a time. This module is the wallet-level +//! held-identity INDEX: it owns which identities the wallet holds, their display +//! order, and which is active. It stores NO secrets. Each held identity is a +//! full [`NostrIdentity`] on disk (its own NIP-49 ncryptsec, see +//! [`crate::nostr::identity`]); this index only points at those files. +//! +//! On-disk layout under `/nostr/`: +//! ```text +//! nostr/ +//! identities.json # this index (0600, no secrets): active + order + entries +//! identity.json # identity #1 (the legacy file, NEVER overwritten by a switch) +//! identities//identity.json # each additional held identity +//! db/ # shared rkv store (dedup, contacts, meta) — one for all identities +//! ``` +//! +//! Migration is trivial and fund-safe: a pre-feature wallet has only a bare +//! `identity.json`. On first load the index adopts it as the single, active +//! identity #1 — no key regeneration, no rewrite of the legacy file, and the +//! grin seed/balance are never touched (this module cannot reach them). +//! +//! A switch only moves the `active` pointer here and rebinds the running service +//! to the target's key (the wallet does the teardown + bring-up + catch-up). The +//! legacy `identity.json` is deliberately never overwritten, so an older build +//! that ignores this index still opens the wallet on identity #1 (clean rollback). + +use crate::nostr::identity::NostrIdentity; +use serde_derive::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +/// Index file name inside the nostr directory. +const INDEX_FILE: &str = "identities.json"; +/// The legacy single-identity file, which becomes identity #1. +const LEGACY_FILE: &str = "identity.json"; +/// Sub-directory holding each additional (non-legacy) identity. +const SUBDIR: &str = "identities"; + +/// Cap on how many identities one wallet may hold. Bounds the on-disk key files +/// and the switcher list, and stops a hostile import loop from ballooning either. +pub const MAX_IDENTITIES: usize = 8; + +/// One held identity, referenced by the index. No secret material. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct HeldEntry { + /// Public key, lowercase hex — the stable id of this identity. + 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"`. + pub path: String, + /// A short human label (its @name, or "Primary" / a fallback). Plaintext by + /// design (this index carries no secret); the npub/name are recoverable from + /// the referenced file anyway. + pub label: String, +} + +impl HeldEntry { + /// Absolute path to this entry's identity file under `nostr_dir`. + pub fn abs_path(&self, nostr_dir: &PathBuf) -> PathBuf { + let mut p = nostr_dir.clone(); + for seg in self.path.split('/') { + p.push(seg); + } + p + } + + /// Load the full [`NostrIdentity`] this entry points at. + pub fn load(&self, nostr_dir: &PathBuf) -> Option { + NostrIdentity::load_at(&self.abs_path(nostr_dir)) + } +} + +/// The held-identity index: which identities the wallet holds and which is +/// active. Persisted as `identities.json`. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct HeldIdentities { + pub ver: u8, + /// Active identity, lowercase hex. Drives the single live subscription and + /// all display; the only pointer a switch moves. + pub active: String, + /// Display order, lowercase hex. + pub order: Vec, + /// Entry metadata (no secrets). + pub identities: Vec, +} + +impl HeldIdentities { + /// Index file path inside the nostr dir. + pub fn index_path(nostr_dir: &PathBuf) -> PathBuf { + let mut p = nostr_dir.clone(); + p.push(INDEX_FILE); + p + } + + /// Relative path an additional identity's file lives at. + fn rel_path_for(hex: &str) -> String { + format!("{SUBDIR}/{hex}/{LEGACY_FILE}") + } + + /// Load the index if present and parseable. + pub fn load(nostr_dir: &PathBuf) -> Option { + let raw = fs::read_to_string(Self::index_path(nostr_dir)).ok()?; + serde_json::from_str(&raw).ok() + } + + /// Persist the index with owner-only (0600) permissions. It carries no + /// secret, but a consistent 0700/0600 posture across the nostr dir is + /// simplest to reason about. + pub fn save(&self, nostr_dir: &PathBuf) -> std::io::Result<()> { + fs::create_dir_all(nostr_dir)?; + let raw = serde_json::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + write_private_0600(&Self::index_path(nostr_dir), raw.as_bytes()) + } + + /// The active entry, if the pointer resolves to a held identity. + pub fn active_entry(&self) -> Option<&HeldEntry> { + self.identities.iter().find(|e| e.pubkey == self.active) + } + + /// Look up an entry by hex. + pub fn entry(&self, hex: &str) -> Option<&HeldEntry> { + self.identities.iter().find(|e| e.pubkey == hex) + } + + /// Whether the wallet already holds this pubkey (dedupe guard on add/import). + pub fn contains(&self, hex: &str) -> bool { + self.identities.iter().any(|e| e.pubkey == hex) + } + + /// Held-identity count. + pub fn len(&self) -> usize { + self.identities.len() + } + + pub fn is_empty(&self) -> bool { + self.identities.is_empty() + } + + /// True if another identity may still be added under the cap. + pub fn has_room(&self) -> bool { + self.identities.len() < MAX_IDENTITIES + } + + /// Build a fresh single-identity index from the legacy identity #1. This is + /// the migration shape: exactly one held identity, active, referencing the + /// legacy `identity.json` in place (never rewritten). + pub fn from_legacy(legacy: &NostrIdentity) -> Option { + let hex = legacy.pubkey_hex()?; + Some(HeldIdentities { + ver: 1, + active: hex.clone(), + order: vec![hex.clone()], + identities: vec![HeldEntry { + pubkey: hex, + path: LEGACY_FILE.to_string(), + label: label_for(legacy), + }], + }) + } + + /// Load the index, migrating a legacy single-identity wallet in place, and + /// self-healing an index whose `active` pointer no longer resolves. Returns + /// the index plus the ACTIVE identity to run. `legacy` is the identity loaded + /// from the bare `identity.json` (identity #1), used for migration/repair. + /// + /// Never touches funds and never regenerates a key. Writes only the index + /// (`identities.json`) — the identity files themselves are left as they are. + pub fn load_or_migrate( + nostr_dir: &PathBuf, + legacy: &NostrIdentity, + ) -> Option<(HeldIdentities, NostrIdentity)> { + let legacy_hex = legacy.pubkey_hex()?; + match Self::load(nostr_dir) { + Some(mut idx) => { + // Repair: ensure identity #1 is always represented (it is the + // rollback anchor), without disturbing the active pointer. + if !idx.contains(&legacy_hex) { + idx.identities.push(HeldEntry { + pubkey: legacy_hex.clone(), + path: LEGACY_FILE.to_string(), + label: label_for(legacy), + }); + if !idx.order.contains(&legacy_hex) { + idx.order.push(legacy_hex.clone()); + } + let _ = idx.save(nostr_dir); + } + // Resolve the active identity; if its file is missing/corrupt, + // fall back to identity #1 so the wallet always has a running + // identity rather than none. + let active = idx + .active_entry() + .and_then(|e| e.load(nostr_dir)) + .or_else(|| { + idx.active = legacy_hex.clone(); + let _ = idx.save(nostr_dir); + Some(legacy.clone()) + })?; + Some((idx, active)) + } + None => { + // Legacy layout: adopt identity.json as the sole, active identity. + let idx = Self::from_legacy(legacy)?; + let _ = idx.save(nostr_dir); + Some((idx, legacy.clone())) + } + } + } + + /// Add an already-built identity to the set (does NOT change the active + /// pointer). Writes the identity's file under `identities//` and records + /// the entry. Enforces the cap and dedupe. Returns the new entry's hex. + pub fn add( + &mut self, + nostr_dir: &PathBuf, + identity: &NostrIdentity, + ) -> Result { + let hex = identity.pubkey_hex().ok_or(HeldError::BadPubkey)?; + if self.contains(&hex) { + return Err(HeldError::AlreadyHeld); + } + if !self.has_room() { + return Err(HeldError::AtCapacity); + } + let rel = Self::rel_path_for(&hex); + let mut abs = nostr_dir.clone(); + for seg in rel.split('/') { + abs.push(seg); + } + identity + .save_at(&abs) + .map_err(|e| HeldError::Io(e.to_string()))?; + self.identities.push(HeldEntry { + pubkey: hex.clone(), + path: rel, + label: label_for(identity), + }); + self.order.push(hex.clone()); + self.save(nostr_dir) + .map_err(|e| HeldError::Io(e.to_string()))?; + Ok(hex) + } + + /// Move the active pointer to a held identity. The caller is responsible for + /// tearing down and re-standing the service on the new key; this only records + /// the choice so the next open lands on it too. + pub fn set_active(&mut self, nostr_dir: &PathBuf, hex: &str) -> Result<(), HeldError> { + if !self.contains(hex) { + return Err(HeldError::NotHeld); + } + self.active = hex.to_string(); + self.save(nostr_dir) + .map_err(|e| HeldError::Io(e.to_string())) + } + + /// Re-encrypt every held identity's ncryptsec from `old` to `new`, in place + /// on disk. Used by the wallet-password change so all front doors follow the + /// one password. Best-effort per file; returns the first error encountered + /// after attempting the rest. + pub fn reencrypt_all( + &self, + nostr_dir: &PathBuf, + old: &str, + new: &str, + ) -> Result<(), HeldError> { + let mut first_err = None; + for entry in &self.identities { + let abs = entry.abs_path(nostr_dir); + match NostrIdentity::load_at(&abs) { + Some(mut id) => { + if let Err(e) = id.reencrypt(old, new) { + first_err.get_or_insert(HeldError::Io(e.to_string())); + continue; + } + if let Err(e) = id.save_at(&abs) { + first_err.get_or_insert(HeldError::Io(e.to_string())); + } + } + None => { + first_err.get_or_insert(HeldError::Io(format!( + "identity file unreadable: {}", + entry.path + ))); + } + } + } + match first_err { + Some(e) => Err(e), + None => Ok(()), + } + } +} + +/// Errors from held-identity index operations. Carries no secret material. +#[derive(Debug, thiserror::Error)] +pub enum HeldError { + #[error("that identity is already in this wallet")] + AlreadyHeld, + #[error("this wallet already holds the maximum number of identities")] + AtCapacity, + #[error("identity not held by this wallet")] + NotHeld, + #[error("identity has a malformed public key")] + BadPubkey, + #[error("identity store error: {0}")] + Io(String), +} + +/// A short display label for an identity: its @name when claimed, else "Primary". +/// Never includes secret material. +fn label_for(id: &NostrIdentity) -> String { + id.nip05 + .as_deref() + .and_then(|n| n.split('@').next()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "Primary".to_string()) +} + +/// The catch-up `since` (unix seconds) for the identity we are bringing up. We +/// want to cover "since THIS identity last listened", not "since the wallet last +/// connected on any identity", so a payment that arrived while this identity was +/// dormant is fetched and redeemed on switch. Falls back to the wallet-wide last +/// connection, then to now, and always subtracts the same generous lookback so a +/// boundary payment is never missed. Pure — unit tested. +pub fn catchup_since( + identity_last_active: Option, + wallet_last_connected: Option, + now: i64, + lookback: i64, +) -> i64 { + let base = identity_last_active + .or(wallet_last_connected) + .unwrap_or(now); + (base - lookback).max(0) +} + +/// Write a file with owner-only (0600) permissions on Unix. +fn write_private_0600(path: &PathBuf, data: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(data)?; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + Ok(()) + } + #[cfg(not(unix))] + { + std::fs::write(path, data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmpdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "goblin-held-test-{tag}-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::create_dir_all(&d); + d + } + + #[test] + fn migration_adopts_legacy_as_single_active_identity() { + let dir = tmpdir("migrate"); + let (legacy, _) = NostrIdentity::create_random("pw").unwrap(); + legacy.save(&dir).unwrap(); // writes identity.json + let legacy_hex = legacy.pubkey_hex().unwrap(); + + // No index yet -> migrate. + assert!(HeldIdentities::load(&dir).is_none()); + let (idx, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap(); + assert_eq!(idx.len(), 1); + assert_eq!(idx.active, legacy_hex); + assert_eq!(active.npub, legacy.npub); + // Legacy entry points at the untouched identity.json. + assert_eq!(idx.active_entry().unwrap().path, "identity.json"); + // Index now persisted and reloads identically. + let reloaded = HeldIdentities::load(&dir).unwrap(); + assert_eq!(reloaded.active, legacy_hex); + assert_eq!(reloaded.len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn add_switch_and_cap() { + let dir = tmpdir("addswitch"); + let (legacy, _) = NostrIdentity::create_random("pw").unwrap(); + legacy.save(&dir).unwrap(); + let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap(); + let legacy_hex = legacy.pubkey_hex().unwrap(); + + // Add a second identity; active stays on #1 until we switch. + let (second, _) = NostrIdentity::create_random("pw").unwrap(); + let second_hex = idx.add(&dir, &second).unwrap(); + assert_eq!(idx.len(), 2); + assert_eq!(idx.active, legacy_hex); + // It has its own file under identities//. + assert!(idx.entry(&second_hex).unwrap().load(&dir).is_some()); + + // Dedupe: adding the same pubkey again is refused. + assert!(matches!( + idx.add(&dir, &second), + Err(HeldError::AlreadyHeld) + )); + + // Switch active pointer. + idx.set_active(&dir, &second_hex).unwrap(); + assert_eq!(idx.active, second_hex); + // Persisted. + assert_eq!(HeldIdentities::load(&dir).unwrap().active, second_hex); + // Switching to an unknown identity is refused. + assert!(matches!( + idx.set_active(&dir, "deadbeef"), + Err(HeldError::NotHeld) + )); + + // Fill to the cap and assert the (N+1)th is rejected. + while idx.has_room() { + let (extra, _) = NostrIdentity::create_random("pw").unwrap(); + idx.add(&dir, &extra).unwrap(); + } + assert_eq!(idx.len(), MAX_IDENTITIES); + let (overflow, _) = NostrIdentity::create_random("pw").unwrap(); + assert!(matches!( + idx.add(&dir, &overflow), + Err(HeldError::AtCapacity) + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn active_resolves_after_reload_and_survives_switch() { + let dir = tmpdir("resolve"); + let (legacy, _) = NostrIdentity::create_random("pw").unwrap(); + legacy.save(&dir).unwrap(); + let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap(); + let (second, _) = NostrIdentity::create_random("pw").unwrap(); + let second_hex = idx.add(&dir, &second).unwrap(); + idx.set_active(&dir, &second_hex).unwrap(); + + // Reopen: load_or_migrate must return the ACTIVE identity (#2), and the + // legacy identity.json must be UNCHANGED (still identity #1 for rollback). + let (reidx, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap(); + assert_eq!(reidx.active, second_hex); + assert_eq!(active.npub, second.npub); + let legacy_on_disk = NostrIdentity::load(&dir).unwrap(); + assert_eq!(legacy_on_disk.npub, legacy.npub); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn corrupt_index_active_falls_back_to_legacy() { + let dir = tmpdir("fallback"); + let (legacy, _) = NostrIdentity::create_random("pw").unwrap(); + legacy.save(&dir).unwrap(); + let legacy_hex = legacy.pubkey_hex().unwrap(); + // Index points active at an identity whose file does not exist. + let idx = HeldIdentities { + ver: 1, + active: "00ff00ff".repeat(8), + order: vec![legacy_hex.clone()], + identities: vec![HeldEntry { + pubkey: legacy_hex.clone(), + path: "identity.json".to_string(), + label: "Primary".to_string(), + }], + }; + idx.save(&dir).unwrap(); + let (repaired, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap(); + // Falls back to identity #1 rather than leaving no running identity. + assert_eq!(active.npub, legacy.npub); + assert_eq!(repaired.active, legacy_hex); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn reencrypt_all_moves_every_identity_to_new_password() { + let dir = tmpdir("reencrypt"); + let (legacy, _) = NostrIdentity::create_random("old").unwrap(); + legacy.save(&dir).unwrap(); + let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap(); + let (second, _) = NostrIdentity::create_random("old").unwrap(); + idx.add(&dir, &second).unwrap(); + + idx.reencrypt_all(&dir, "old", "new").unwrap(); + for entry in &idx.identities { + let id = entry.load(&dir).unwrap(); + assert!(id.unlock("new").is_ok(), "must open under the new password"); + assert!(id.unlock("old").is_err(), "must not open under the old one"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn catchup_since_prefers_identity_then_wallet_then_now() { + let lookback = 3 * 86_400; + let now = 1_000_000; + // Per-identity value wins: cover from when THIS identity last listened. + assert_eq!( + catchup_since(Some(500_000), Some(900_000), now, lookback), + 500_000 - lookback + ); + // Falls back to the wallet-wide last connection when the identity has none. + assert_eq!( + catchup_since(None, Some(900_000), now, lookback), + 900_000 - lookback + ); + // Falls back to now when nothing is known (never worse than a fresh start). + assert_eq!(catchup_since(None, None, now, lookback), now - lookback); + // Never negative. + assert_eq!(catchup_since(Some(10), None, now, lookback), 0); + } +} diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs index e4d5cb63..4b8b0264 100644 --- a/src/nostr/identity.rs +++ b/src/nostr/identity.rs @@ -146,6 +146,38 @@ impl NostrIdentity { let _ = fs::remove_file(Self::path(nostr_dir)); } + /// Load an identity from an explicit file path — a member of the held + /// identity set (see [`crate::nostr::identities`]), which stores each + /// additional identity in its own `identities//identity.json`. + pub fn load_at(path: &PathBuf) -> Option { + let raw = fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() + } + + /// Persist this identity to an explicit file path with owner-only (0600) + /// permissions, creating (and 0700-restricting) the parent directory. Used + /// by the held identity set for the non-legacy identities; the ncryptsec + /// blob must never be world-readable. + pub fn save_at(&self, path: &PathBuf) -> Result<(), IdentityError> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + restrict_dir(&dir.to_path_buf()); + } + let raw = serde_json::to_string_pretty(self)?; + write_private(path, raw.as_bytes())?; + Ok(()) + } + + /// The identity's public key as lowercase hex — the stable id used to key it + /// in the held-identity index and on disk. `None` if the stored npub is + /// malformed (never expected for an identity we wrote). + pub fn pubkey_hex(&self) -> Option { + use nostr_sdk::PublicKey; + PublicKey::from_bech32(&self.npub) + .ok() + .map(|pk| pk.to_hex()) + } + /// Build an identity from already-unlocked keys under a (possibly /// different) password — used when importing a backup that was exported /// under another wallet's password. diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 4f5f8fb1..e3eadc08 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -31,6 +31,9 @@ pub use store::NostrStore; mod identity; pub use identity::{IdentitySource, NostrIdentity}; +pub mod identities; +pub use identities::{HeldError, HeldIdentities, MAX_IDENTITIES, catchup_since}; + pub mod protocol; pub use protocol::*; diff --git a/src/nostr/store.rs b/src/nostr/store.rs index 661065ba..5bbb85fd 100644 --- a/src/nostr/store.rs +++ b/src/nostr/store.rs @@ -276,6 +276,30 @@ impl NostrStore { let _ = writer.commit(); } + /// Unix time this identity (by pubkey hex) was last the ACTIVE, live-listening + /// identity. Held per identity in the one shared settings store so that a + /// switch back to a dormant identity can catch up "since it last listened" + /// rather than "since the wallet last connected". `None` for an identity that + /// has never been active (fresh/imported), which the catch-up handles by + /// falling back to the wallet-wide last connection. + pub fn last_active_at(&self, pubkey_hex: &str) -> Option { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let reader = env.read().unwrap(); + let key = format!("last_active_at:{pubkey_hex}"); + if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, &key) { + return Some(v); + } + None + } + + pub fn set_last_active_at(&self, pubkey_hex: &str, ts: i64) { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let mut writer = env.write().unwrap(); + let key = format!("last_active_at:{pubkey_hex}"); + let _ = self.settings.put(&mut writer, &key, &Value::I64(ts)); + let _ = writer.commit(); + } + /// Unix time of the last contact-name re-verify sweep (persisted across /// restarts so a fresh launch only re-sweeps if it's been a while). pub fn last_name_sweep_at(&self) -> Option {