Serialize wallet mutations and preserve network and seed recovery state

This commit is contained in:
archipelago
2026-10-06 15:39:44 -04:00
parent 9f0df2ac44
commit 9c95b8732f
11 changed files with 322 additions and 70 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ impl RpcHandler {
/// balance sitting in the *other* one so the UI can say what switching /// balance sitting in the *other* one so the UI can say what switching
/// would reveal rather than appearing to lose money. /// would reveal rather than appearing to lose money.
pub(super) async fn handle_wallet_ecash_network(&self) -> Result<serde_json::Value> { pub(super) async fn handle_wallet_ecash_network(&self) -> Result<serde_json::Value> {
let current = ecash::load_network(&self.config.data_dir).await; let current = ecash::load_network(&self.config.data_dir).await?;
let wallet = ecash::load_wallet(&self.config.data_dir).await?; let wallet = ecash::load_wallet(&self.config.data_dir).await?;
Ok(serde_json::json!({ Ok(serde_json::json!({
"network": current, "network": current,
+2 -13
View File
@@ -172,19 +172,8 @@ async fn process_payment(
session::save_sessions(data_dir, &store).await?; session::save_sessions(data_dir, &store).await?;
// Record the streaming revenue // Serialize the history write with all other wallet mutations.
let mut wallet = ecash::load_wallet(data_dir).await?; ecash::record_streaming_revenue(data_dir, received_sats, &service.service_id, peer_id).await?;
wallet.record_tx(
ecash::TransactionType::StreamingRevenue,
received_sats,
&format!(
"Streaming payment: {} sats for {} from {}",
received_sats, service.service_id, peer_id
),
&wallet.mint_url.clone(),
peer_id,
);
ecash::save_wallet(data_dir, &wallet).await?;
debug!( debug!(
"Gate: accepted {} sats from {} for {}, allotment={}", "Gate: accepted {} sats from {} for {}, allotment={}",
+85 -21
View File
@@ -267,40 +267,39 @@ impl EcashNetwork {
/// Read the node's ecash network. Absent file = mainnet, so nodes that never /// Read the node's ecash network. Absent file = mainnet, so nodes that never
/// touch this setting behave exactly as before. /// touch this setting behave exactly as before.
pub async fn load_network(data_dir: &Path) -> EcashNetwork { pub async fn load_network(data_dir: &Path) -> Result<EcashNetwork> {
let path = data_dir.join(NETWORK_FILE); let path = data_dir.join(NETWORK_FILE);
let Ok(content) = fs::read_to_string(&path).await else { match fs::read_to_string(&path).await {
return EcashNetwork::Mainnet; Ok(content) => Ok(serde_json::from_str::<NetworkConfig>(&content)
}; .context("Ecash network configuration is damaged; no wallet was selected")?
serde_json::from_str::<NetworkConfig>(&content) .network),
.map(|c| c.network) Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(EcashNetwork::Mainnet),
.unwrap_or_default() Err(error) => Err(error).context("Could not read ecash network configuration"),
}
} }
/// Switch the node's ecash network. The other network's wallet is left on /// Switch the node's ecash network. The other network's wallet is left on
/// disk untouched, so switching is reversible and loses nothing. /// disk untouched, so switching is reversible and loses nothing.
pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> { pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> {
let _mutation = super::mutation::guard(data_dir).await?;
let dir = data_dir.join("wallet"); let dir = data_dir.join("wallet");
fs::create_dir_all(&dir) fs::create_dir_all(&dir)
.await .await
.context("Failed to create wallet dir")?; .context("Failed to create wallet dir")?;
let content = serde_json::to_string_pretty(&NetworkConfig { network }) let content = serde_json::to_string_pretty(&NetworkConfig { network })
.context("Failed to serialize ecash network")?; .context("Failed to serialize ecash network")?;
fs::write(data_dir.join(NETWORK_FILE), content) write_file_atomically(&data_dir.join(NETWORK_FILE), &content).await?;
.await
.context("Failed to write ecash network")?;
Ok(()) Ok(())
} }
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
struct NetworkConfig { struct NetworkConfig {
#[serde(default)]
network: EcashNetwork, network: EcashNetwork,
} }
/// Load wallet state from disk. /// Load wallet state from disk.
pub async fn load_wallet(data_dir: &Path) -> Result<WalletState> { pub async fn load_wallet(data_dir: &Path) -> Result<WalletState> {
let network = load_network(data_dir).await; let network = load_network(data_dir).await?;
let path = data_dir.join(network.wallet_file()); let path = data_dir.join(network.wallet_file());
let content = match fs::read_to_string(&path).await { let content = match fs::read_to_string(&path).await {
Ok(content) => content, Ok(content) => content,
@@ -376,20 +375,42 @@ async fn write_file_atomically(path: &Path, content: &str) -> Result<()> {
} }
/// Save wallet state to disk. /// Save wallet state to disk.
pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> { pub(super) async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> {
let dir = data_dir.join("wallet"); let dir = data_dir.join("wallet");
fs::create_dir_all(&dir) fs::create_dir_all(&dir)
.await .await
.context("Failed to create wallet dir")?; .context("Failed to create wallet dir")?;
let path = data_dir.join(load_network(data_dir).await.wallet_file()); let path = data_dir.join(load_network(data_dir).await?.wallet_file());
let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?; let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?;
write_file_atomically(&path, &content).await?; write_file_atomically(&path, &content).await?;
Ok(()) Ok(())
} }
/// Record revenue without overwriting a simultaneous receipt/send snapshot.
pub(crate) async fn record_streaming_revenue(
data_dir: &Path,
amount_sats: u64,
service_id: &str,
peer_id: &str,
) -> Result<()> {
let _mutation = super::mutation::guard(data_dir).await?;
let mut wallet = load_wallet(data_dir).await?;
wallet.record_tx(
TransactionType::StreamingRevenue,
amount_sats,
&format!(
"Streaming payment: {} sats for {} from {}",
amount_sats, service_id, peer_id
),
&wallet.mint_url.clone(),
peer_id,
);
save_wallet(data_dir, &wallet).await
}
/// Load accepted mints list. /// Load accepted mints list.
pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> { pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
let network = load_network(data_dir).await; let network = load_network(data_dir).await?;
let path = data_dir.join(network.mints_file()); let path = data_dir.join(network.mints_file());
if !path.exists() { if !path.exists() {
return Ok(AcceptedMints { return Ok(AcceptedMints {
@@ -415,11 +436,12 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
/// Save accepted mints list. /// Save accepted mints list.
pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> { pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> {
let _mutation = super::mutation::guard(data_dir).await?;
let dir = data_dir.join("wallet"); let dir = data_dir.join("wallet");
fs::create_dir_all(&dir) fs::create_dir_all(&dir)
.await .await
.context("Failed to create wallet dir")?; .context("Failed to create wallet dir")?;
let path = data_dir.join(load_network(data_dir).await.mints_file()); let path = data_dir.join(load_network(data_dir).await?.mints_file());
let content = let content =
serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?; serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?;
write_file_atomically(&path, &content).await?; write_file_atomically(&path, &content).await?;
@@ -451,6 +473,7 @@ pub async fn mint_quote(
/// Mint new ecash tokens after a Lightning invoice has been paid. /// Mint new ecash tokens after a Lightning invoice has been paid.
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> { pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
let _mutation = super::mutation::guard(data_dir).await?;
let mut wallet = load_wallet(data_dir).await?; let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone(); let mint_url = wallet.mint_url.clone();
let client = mint_client(data_dir, &mint_url).await?; let client = mint_client(data_dir, &mint_url).await?;
@@ -481,6 +504,7 @@ pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_cli
/// Melt ecash tokens to pay a Lightning invoice. /// Melt ecash tokens to pay a Lightning invoice.
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> { pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
let _mutation = super::mutation::guard(data_dir).await?;
let mut wallet = load_wallet(data_dir).await?; let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone(); let mint_url = wallet.mint_url.clone();
let client = mint_client(data_dir, &mint_url).await?; let client = mint_client(data_dir, &mint_url).await?;
@@ -591,6 +615,17 @@ pub async fn swap_between_mints(
to_mint: &str, to_mint: &str,
amount_sats: u64, amount_sats: u64,
max_fee_sats: u64, max_fee_sats: u64,
) -> Result<u64> {
let _mutation = super::mutation::guard(data_dir).await?;
swap_between_mints_locked(data_dir, from_mint, to_mint, amount_sats, max_fee_sats).await
}
async fn swap_between_mints_locked(
data_dir: &Path,
from_mint: &str,
to_mint: &str,
amount_sats: u64,
max_fee_sats: u64,
) -> Result<u64> { ) -> Result<u64> {
if amount_sats == 0 { if amount_sats == 0 {
anyhow::bail!("swap amount must be greater than zero"); anyhow::bail!("swap amount must be greater than zero");
@@ -747,8 +782,9 @@ async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result
/// Create an ecash token string to send to a peer, drawing from the home mint. /// Create an ecash token string to send to a peer, drawing from the home mint.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> { pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
let _mutation = super::mutation::guard(data_dir).await?;
let mint_url = load_wallet(data_dir).await?.mint_url; let mint_url = load_wallet(data_dir).await?.mint_url;
send_token_at(data_dir, &mint_url, amount_sats).await send_token_at_locked(data_dir, &mint_url, amount_sats).await
} }
/// Create an ecash token denominated in a specific mint's tokens. /// Create an ecash token denominated in a specific mint's tokens.
@@ -757,6 +793,11 @@ pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder /// on the seeder's accepted mint, we send a token from *that* mint so the seeder
/// only ever receives its own mint's proofs (see plan §2a, payer-side swap). /// only ever receives its own mint's proofs (see plan §2a, payer-side swap).
pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> { pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
let _mutation = super::mutation::guard(data_dir).await?;
send_token_at_locked(data_dir, mint_url, amount_sats).await
}
async fn send_token_at_locked(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
let mut wallet = load_wallet(data_dir).await?; let mut wallet = load_wallet(data_dir).await?;
let mint_url = mint_url.to_string(); let mint_url = mint_url.to_string();
@@ -947,6 +988,7 @@ pub async fn build_payment_token(
amount_sats: u64, amount_sats: u64,
max_fee_sats: u64, max_fee_sats: u64,
) -> Result<String> { ) -> Result<String> {
let _mutation = super::mutation::guard(data_dir).await?;
if amount_sats == 0 { if amount_sats == 0 {
anyhow::bail!("payment amount must be greater than zero"); anyhow::bail!("payment amount must be greater than zero");
} }
@@ -975,15 +1017,16 @@ pub async fn build_payment_token(
"Payment plan: direct from {} for {} sats", "Payment plan: direct from {} for {} sats",
mint_url, amount_sats mint_url, amount_sats
); );
send_token_at(data_dir, &mint_url, amount_sats).await send_token_at_locked(data_dir, &mint_url, amount_sats).await
} }
PaymentPlan::Swap { from_mint, to_mint } => { PaymentPlan::Swap { from_mint, to_mint } => {
debug!( debug!(
"Payment plan: swap {}→{} then pay {} sats (fee cap {})", "Payment plan: swap {}→{} then pay {} sats (fee cap {})",
from_mint, to_mint, amount_sats, max_fee_sats from_mint, to_mint, amount_sats, max_fee_sats
); );
swap_between_mints(data_dir, &from_mint, &to_mint, amount_sats, max_fee_sats).await?; swap_between_mints_locked(data_dir, &from_mint, &to_mint, amount_sats, max_fee_sats)
send_token_at(data_dir, &to_mint, amount_sats).await .await?;
send_token_at_locked(data_dir, &to_mint, amount_sats).await
} }
PaymentPlan::Insufficient => anyhow::bail!( PaymentPlan::Insufficient => anyhow::bail!(
"cannot pay {} sats: no accepted mint covers it within balance/trust", "cannot pay {} sats: no accepted mint covers it within balance/trust",
@@ -1051,6 +1094,7 @@ async fn remove_pending_swap(data_dir: &Path, mint_quote_id: &str) -> Result<()>
/// Returns the total sats reclaimed. Safe to call repeatedly (idempotent): a /// Returns the total sats reclaimed. Safe to call repeatedly (idempotent): a
/// quote is only minted once, and `ISSUED` quotes are never re-claimed. /// quote is only minted once, and `ISSUED` quotes are never re-claimed.
pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> { pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
let _mutation = super::mutation::guard(data_dir).await?;
let pending = load_pending_swaps(data_dir).await?; let pending = load_pending_swaps(data_dir).await?;
let mut reclaimed = 0u64; let mut reclaimed = 0u64;
for swap in pending { for swap in pending {
@@ -1189,6 +1233,7 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
/// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones. /// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones.
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> { pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
let _mutation = super::mutation::guard(data_dir).await?;
// Handle legacy format for backwards compatibility // Handle legacy format for backwards compatibility
if token_str.starts_with("cashuSend_") { if token_str.starts_with("cashuSend_") {
return receive_legacy_token(data_dir, token_str).await; return receive_legacy_token(data_dir, token_str).await;
@@ -1314,6 +1359,7 @@ pub async fn verify_and_receive_payment(
token_str: &str, token_str: &str,
required_sats: u64, required_sats: u64,
) -> Result<u64> { ) -> Result<u64> {
let _mutation = super::mutation::guard(data_dir).await?;
let token_str = token_str.trim(); let token_str = token_str.trim();
// Synthetic legacy balances are not cryptographic proof of payment. // Synthetic legacy balances are not cryptographic proof of payment.
if token_str.starts_with("cashuSend_") { if token_str.starts_with("cashuSend_") {
@@ -1434,6 +1480,7 @@ pub struct RestoreOutcome {
/// coins or resurrecting spent ones, which matters because the most likely /// coins or resurrecting spent ones, which matters because the most likely
/// time to press this button is when something already looks wrong. /// time to press this button is when something already looks wrong.
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> { pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
let _mutation = super::mutation::guard(data_dir).await?;
let recovery = RecoverySource::load(data_dir).await?.ok_or_else(|| { let recovery = RecoverySource::load(data_dir).await?.ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"This wallet has no backup phrase yet, so there is nothing to restore from. \ "This wallet has no backup phrase yet, so there is nothing to restore from. \
@@ -2297,7 +2344,7 @@ mod tests {
async fn ecash_network_defaults_to_mainnet_and_leaves_files_alone() { async fn ecash_network_defaults_to_mainnet_and_leaves_files_alone() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let dir = tmp.path(); let dir = tmp.path();
assert_eq!(load_network(dir).await, EcashNetwork::Mainnet); assert_eq!(load_network(dir).await.unwrap(), EcashNetwork::Mainnet);
// A node that never touches this setting has no new file. // A node that never touches this setting has no new file.
assert!(!dir.join(NETWORK_FILE).exists()); assert!(!dir.join(NETWORK_FILE).exists());
assert_eq!( assert_eq!(
@@ -2391,6 +2438,23 @@ mod tests {
assert_eq!(std::fs::read_to_string(&path).unwrap(), damaged); assert_eq!(std::fs::read_to_string(&path).unwrap(), damaged);
} }
#[tokio::test]
async fn damaged_network_selection_never_falls_back_to_real_funds() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(NETWORK_FILE);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
for bytes in ["", "{}", "{broken", r#"{"network":"unknown"}"#] {
std::fs::write(&path, bytes).unwrap();
assert!(load_network(tmp.path()).await.is_err());
assert!(load_wallet(tmp.path()).await.is_err());
assert!(save_wallet(tmp.path(), &WalletState::default())
.await
.is_err());
assert!(!tmp.path().join("wallet/ecash.json").exists());
assert_eq!(std::fs::read_to_string(&path).unwrap(), bytes);
}
}
#[tokio::test] #[tokio::test]
async fn an_empty_wallet_file_is_preserved_as_damaged() { async fn an_empty_wallet_file_is_preserved_as_damaged() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
+2 -2
View File
@@ -559,7 +559,7 @@ async fn ecash_phrase(data_dir: &Path) -> Result<(String, [u8; 64])> {
/// so a node that restores the same ecash phrase recovers the same address. /// so a node that restores the same ecash phrase recovers the same address.
pub async fn lnaddress(data_dir: &Path) -> Result<serde_json::Value> { pub async fn lnaddress(data_dir: &Path) -> Result<serde_json::Value> {
let _state_guard = MINIBITS_STATE_LOCK.lock().await; let _state_guard = MINIBITS_STATE_LOCK.lock().await;
let network = ecash::load_network(data_dir).await; let network = ecash::load_network(data_dir).await?;
if network == EcashNetwork::Testnet { if network == EcashNetwork::Testnet {
return Err(anyhow!( return Err(anyhow!(
"Minibits Lightning addresses are mainnet-only — switch the ecash network to mainnet to set one up" "Minibits Lightning addresses are mainnet-only — switch the ecash network to mainnet to set one up"
@@ -901,7 +901,7 @@ async fn ensure_mint_accepted(data_dir: &Path, mint_url: &str) -> Result<()> {
/// so it isn't purely a log-line event. /// so it isn't purely a log-line event.
pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> { pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
let _state_guard = MINIBITS_STATE_LOCK.lock().await; let _state_guard = MINIBITS_STATE_LOCK.lock().await;
let network = ecash::load_network(data_dir).await; let network = ecash::load_network(data_dir).await?;
if network == EcashNetwork::Testnet { if network == EcashNetwork::Testnet {
return Ok(NO_CLAIMS); return Ok(NO_CLAIMS);
} }
+1
View File
@@ -8,5 +8,6 @@ pub mod ecash;
pub mod fedimint_client; pub mod fedimint_client;
pub mod minibits; pub mod minibits;
pub mod mint_client; pub mod mint_client;
mod mutation;
pub mod nut13; pub mod nut13;
pub mod profits; pub mod profits;
+73
View File
@@ -0,0 +1,73 @@
//! Serialize complete wallet mutations, independently for each node data directory.
//! This prevents lost updates; durable recovery of interrupted remote operations
//! requires the separate operation journal.
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as RegistryMutex, OnceLock, Weak};
use tokio::sync::{Mutex, OwnedMutexGuard};
type Registry = HashMap<PathBuf, Weak<Mutex<()>>>;
static LOCKS: OnceLock<RegistryMutex<Registry>> = OnceLock::new();
async fn lock_for(data_dir: &Path) -> Result<Arc<Mutex<()>>> {
tokio::fs::create_dir_all(data_dir)
.await
.context("Could not prepare wallet data directory")?;
let canonical = tokio::fs::canonicalize(data_dir)
.await
.context("Could not resolve wallet data directory")?;
let mut registry = LOCKS
.get_or_init(|| RegistryMutex::new(HashMap::new()))
.lock()
.map_err(|_| anyhow::anyhow!("Wallet mutation lock registry is unavailable"))?;
registry.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = registry.get(&canonical).and_then(Weak::upgrade) {
return Ok(lock);
}
let lock = Arc::new(Mutex::new(()));
registry.insert(canonical, Arc::downgrade(&lock));
Ok(lock)
}
pub(super) async fn guard(data_dir: &Path) -> Result<OwnedMutexGuard<()>> {
Ok(lock_for(data_dir).await?.lock_owned().await)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn aliases_share_a_lock_but_different_nodes_do_not() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let held = guard(first.path()).await.unwrap();
let alias = lock_for(&first.path().join(".")).await.unwrap();
assert!(alias.try_lock().is_err());
let independent = lock_for(second.path()).await.unwrap();
assert!(independent.try_lock().is_ok());
drop(held);
assert!(alias.try_lock().is_ok());
}
#[tokio::test]
async fn symlink_aliases_cannot_bypass_serialization() {
let parent = tempfile::tempdir().unwrap();
let actual = parent.path().join("node");
std::fs::create_dir(&actual).unwrap();
let alias = parent.path().join("alias");
std::os::unix::fs::symlink(&actual, &alias).unwrap();
let _held = guard(&actual).await.unwrap();
assert!(lock_for(&alias).await.unwrap().try_lock().is_err());
}
#[tokio::test]
async fn invalid_directory_fails_without_replacing_existing_data() {
let parent = tempfile::tempdir().unwrap();
let file = parent.path().join("node");
std::fs::write(&file, b"preserve").unwrap();
assert!(guard(&file).await.is_err());
assert_eq!(std::fs::read(file).unwrap(), b"preserve");
}
}
+66 -33
View File
@@ -223,6 +223,7 @@ pub async fn establish_from_master(
data_dir: &Path, data_dir: &Path,
master: &crate::seed::MasterSeed, master: &crate::seed::MasterSeed,
) -> Result<EcashSeed> { ) -> Result<EcashSeed> {
let _mutation = super::mutation::guard(data_dir).await?;
let derived = crate::seed::derive_cashu_mnemonic(master)?; let derived = crate::seed::derive_cashu_mnemonic(master)?;
if let Some(existing) = load_seed(data_dir).await? { if let Some(existing) = load_seed(data_dir).await? {
@@ -259,6 +260,7 @@ pub async fn establish_from_master(
/// silent default when derivation was possible; [`establish_from_master`] is /// silent default when derivation was possible; [`establish_from_master`] is
/// what a node with a master seed gets. /// what a node with a master seed gets.
pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> { pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
let _mutation = super::mutation::guard(data_dir).await?;
if let Some(existing) = load_seed(data_dir).await? { if let Some(existing) = load_seed(data_dir).await? {
return Ok(existing); return Ok(existing);
} }
@@ -294,6 +296,7 @@ pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
/// dangerous choice if the imported phrase turned out to be the one already /// dangerous choice if the imported phrase turned out to be the one already
/// in use. /// in use.
pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> { pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> {
let _mutation = super::mutation::guard(data_dir).await?;
let mnemonic: bip39::Mnemonic = words let mnemonic: bip39::Mnemonic = words
.split_whitespace() .split_whitespace()
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -330,24 +333,24 @@ pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Res
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Imported)) Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Imported))
} }
/// Move the current seed file aside, timestamped, before it is replaced. /// Durably copy the current seed before replacing it; keep the live seed on failure.
/// ///
/// Never deleted and never overwritten: this file may be the last copy of the /// Never deleted and never overwritten: this file may be the last copy of the
/// words a balance was minted under, and the whole point of the module is that /// words a balance was minted under, and the whole point of the module is that
/// such a thing is not casually destroyed. /// such a thing is not casually destroyed.
async fn archive_seed(data_dir: &Path) -> Result<()> { async fn archive_seed(data_dir: &Path) -> Result<()> {
let from = seed_path(data_dir); let from = seed_path(data_dir);
if !from.exists() { let content = fs::read(&from)
return Ok(()); .await
} .context("Could not read the previous ecash phrase for backup")?;
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json")); let to = data_dir.join(format!(
fs::rename(&from, &to).await.with_context(|| { "wallet/cashu_seed.replaced-{stamp}-{}.json",
format!( uuid::Uuid::new_v4()
"Could not archive the previous ecash phrase to {}", ));
to.display() persist_private_file(&to, &content)
) .await
})?; .context("Could not durably archive the previous ecash phrase")?;
warn!("Previous ecash phrase archived to {}", to.display()); warn!("Previous ecash phrase archived to {}", to.display());
Ok(()) Ok(())
} }
@@ -367,17 +370,7 @@ async fn write_seed(data_dir: &Path, mnemonic: &bip39::Mnemonic, source: SeedSou
}; };
let content = let content =
serde_json::to_string_pretty(&stored).context("Failed to serialize the ecash seed")?; serde_json::to_string_pretty(&stored).context("Failed to serialize the ecash seed")?;
fs::write(&path, content) persist_private_file(&path, content.as_bytes()).await?;
.await
.context("Failed to write the ecash seed")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.await
.context("Failed to restrict permissions on the ecash seed")?;
}
Ok(()) Ok(())
} }
@@ -420,46 +413,48 @@ pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) ->
} }
let content = let content =
serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?; serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?;
persist_counters(&path, content.as_bytes()).await?; persist_private_file(&path, content.as_bytes()).await?;
Ok(start) Ok(start)
} }
/// Never truncate the active reservation file. A reservation is not usable /// Never truncate the active reservation file. A reservation is not usable
/// until both its replacement file and directory entry have reached storage. /// until both its replacement file and directory entry have reached storage.
async fn persist_counters(path: &Path, content: &[u8]) -> Result<()> { async fn persist_private_file(path: &Path, content: &[u8]) -> Result<()> {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
struct PendingCounterFile(PathBuf); struct PendingPrivateFile(PathBuf);
impl Drop for PendingCounterFile { impl Drop for PendingPrivateFile {
fn drop(&mut self) { fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0); let _ = std::fs::remove_file(&self.0);
} }
} }
let parent = path.parent().context("Counter file has no directory")?; let parent = path
.parent()
.context("Private wallet file has no directory")?;
let temporary = let temporary =
PendingCounterFile(parent.join(format!(".cashu-counters-{}.tmp", uuid::Uuid::new_v4()))); PendingPrivateFile(parent.join(format!(".cashu-private-{}.tmp", uuid::Uuid::new_v4())));
let mut file = fs::OpenOptions::new() let mut file = fs::OpenOptions::new()
.write(true) .write(true)
.create_new(true) .create_new(true)
.mode(0o600) .mode(0o600)
.open(&temporary.0) .open(&temporary.0)
.await .await
.context("Could not create the ecash counter reservation")?; .context("Could not create the private ecash state")?;
file.write_all(content) file.write_all(content)
.await .await
.context("Could not write the ecash counter reservation")?; .context("Could not write the private ecash state")?;
file.sync_all() file.sync_all()
.await .await
.context("Could not flush the ecash counter reservation")?; .context("Could not flush the private ecash state")?;
drop(file); drop(file);
fs::rename(&temporary.0, path) fs::rename(&temporary.0, path)
.await .await
.context("Could not replace the ecash counter reservation")?; .context("Could not replace the private ecash state")?;
fs::File::open(parent) fs::File::open(parent)
.await? .await?
.sync_all() .sync_all()
.await .await
.context("Could not flush the ecash counter directory")?; .context("Could not flush the private ecash directory")?;
Ok(()) Ok(())
} }
@@ -909,4 +904,42 @@ mod tests {
assert!(source.next_outputs("01fc0ec0e59cd6fa", 1).await.is_err()); assert!(source.next_outputs("01fc0ec0e59cd6fa", 1).await.is_err());
assert!(!dir.path().join(COUNTER_FILE).exists()); assert!(!dir.path().join(COUNTER_FILE).exists());
} }
#[tokio::test]
async fn archiving_keeps_the_live_seed_and_never_overwrites_an_earlier_backup() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
establish_independent(dir.path()).await.unwrap();
let live = fs::read(seed_path(dir.path())).await.unwrap();
archive_seed(dir.path()).await.unwrap();
archive_seed(dir.path()).await.unwrap();
assert_eq!(fs::read(seed_path(dir.path())).await.unwrap(), live);
let backups: Vec<_> = std::fs::read_dir(dir.path().join("wallet"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.collect();
assert_eq!(backups.len(), 2);
for backup in backups {
assert_eq!(std::fs::read(&backup).unwrap(), live);
assert_eq!(
std::fs::metadata(backup).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[tokio::test]
async fn simultaneous_seed_establishment_keeps_one_identity() {
let dir = tempfile::tempdir().unwrap();
let (first, second) = tokio::join!(
establish_independent(dir.path()),
establish_independent(dir.path())
);
assert_eq!(first.unwrap().phrase(), second.unwrap().phrase());
}
} }
@@ -426,3 +426,37 @@ async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_mi
} }
} }
} }
#[tokio::test]
async fn simultaneous_receipts_and_revenue_writes_preserve_every_payment() {
let mint = Mint::start(0, None).await;
let wallet_dir = mint.wallet().await;
let mut tasks = tokio::task::JoinSet::new();
for exponent in 0..8 {
let amount = 1u64 << exponent;
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, amount)])
.serialize()
.unwrap();
let data_dir = wallet_dir.path().to_path_buf();
tasks.spawn(async move {
verify_and_receive_payment(&data_dir, &token, amount)
.await
.unwrap();
});
}
for index in 0..16 {
let data_dir = wallet_dir.path().to_path_buf();
tasks.spawn(async move {
record_streaming_revenue(&data_dir, 1, "fixture-service", &format!("peer-{index}"))
.await
.unwrap();
});
}
while let Some(result) = tasks.join_next().await {
result.unwrap();
}
let wallet = load_wallet(wallet_dir.path()).await.unwrap();
assert_eq!(wallet.balance(), 255);
assert_eq!(wallet.transactions.len(), 24);
assert_eq!(mint.requests.lock().unwrap().len(), 8);
}
+28
View File
@@ -142,3 +142,31 @@ whole-file replacement (not read-modify-write serialization), private permission
and retained targets on rename failure. Wallet tests:57passed. Full backend and retained targets on rename failure. Wallet tests:57passed. Full backend
qualification passes in `/tmp/archy-wallet-storage-tls-full-tests.log`. No live qualification passes in `/tmp/archy-wallet-storage-tls-full-tests.log`. No live
wallet was altered. Operation serialization and recovery journal remain open. wallet was altered. Operation serialization and recovery journal remain open.
### Serialized wallet mutations and seed durability
The wallet now serializes public mutations by canonical node data directory,
including network changes and seed establishment/import. Independent node
fixtures retain separate locks; nested send/swap paths use private implementations
under the outer lock. Streaming revenue records use the same boundary, and direct
wallet saves are restricted to the wallet module. This is in-process serialization,
not a cross-process transaction journal or a claim that an entire payment RPC is
recoverable.
Damaged network configuration now errors instead of silently choosing mainnet;
only an absent configuration retains the historical mainnet default. The previous
seed is durably copied to a unique private backup before atomic replacement,
rather than moved away before the replacement write succeeds.
Final isolated suite: **1,764 tests pass, zero failures, five existing skips**.
Log: /tmp/archy-wallet-mutation-seed-final-tests.log. Regressions include eight
simultaneous real HTTP/curve-signed fixture receipts plus sixteen history writes,
preserving255sats and24entries; canonical/symlink lock identity; damaged network
configuration; and simultaneous seed establishment/unique retained backups.
No live wallet data or additional real payments were used.
Still required: correlated purchase and mint-operation journal, recoverable
prepared outputs, quote/change handling and seller receipts, interrupted-operation
recovery and complete timed-playback integration. Higher-level Minibits claim and
purchase flows must pin their network/terms across their entire business operation;
serializing individual wallet calls alone does not provide that contract.
+21
View File
@@ -880,3 +880,24 @@ localhost replacement could alter external hostnames or paths. Exact authority
matching fixes these errors. The 22 focused launch/stable-URL/loader tests pass matching fixes these errors. The 22 focused launch/stable-URL/loader tests pass
after two new failures were reproduced. This is not claimed as the live gate after two new failures were reproduced. This is not claimed as the live gate
incident's cause. Production UI build passes (`/tmp/archy-https-runtime-ui-build.log`). This change is not yet deployed. incident's cause. Production UI build passes (`/tmp/archy-https-runtime-ui-build.log`). This change is not yet deployed.
## Wallet storage and Yaya TLS qualification
Commits `719e7238` and `9f0df2ac` pass the complete isolated backend suite:
1,757passed, zero failures, five existing skips. Wallet storage tests57pass;
permission repair tests6pass; first-boot9and rotation8cases pass. Live Yaya's
root-only leaf key was confirmed by gate permission-denied logs; changing only
its group/mode restored authenticated HTTPS iframe200. No cert bytes, nginx
configuration or app processes changed. Normal trust and the exact operator
URL/app remain open. See `docs/https-app-gate-followup-20261006.md`.
Per-wallet mutation serialization is now being qualified separately. It uses
canonical data-directory locks so independent fixture nodes cannot deadlock one
another, and private nested helpers avoid recursive locking. Wallet network
changes and streaming revenue writes participate. This remains uncommitted
and is not a durable transaction journal.
Final wallet serialization/network/seed qualification:1,764backend tests pass,
zero failures, five existing skips. This includes the concurrent receipt/history
and seed-backup tests. Source is ready for review; not deployed, and the durable
purchase journal remains open.
+9
View File
@@ -378,3 +378,12 @@ as a substitute for repairing the standard public-channel experience.
- User reports opening apps inside an iframe on an HTTPS node shows the app gate, while opening the same app in a separate tab works. Reproduce both modes with the same authenticated session before identifying a cause. - User reports opening apps inside an iframe on an HTTPS node shows the app gate, while opening the same app in a separate tab works. Reproduce both modes with the same authenticated session before identifying a cause.
- Trace generated launch origins, cookie attributes and scope, bootstrap redirects, iframe navigation and gate session exchange. Preserve authentication and public-management access restrictions; do not bypass the gate or expose credentials to embedded apps. - Trace generated launch origins, cookie attributes and scope, bootstrap redirects, iframe navigation and gate session exchange. Preserve authentication and public-management access restrictions; do not bypass the gate or expose credentials to embedded apps.
- Test HTTPS iframe and tab, HTTP LAN compatibility, desktop/mobile companion, reload, expired sessions, denied access and logout. Record actual-node evidence separately from fixtures. This remains open, not a confirmed diagnosis. - Test HTTPS iframe and tab, HTTP LAN compatibility, desktop/mobile companion, reload, expired sessions, denied access and logout. Record actual-node evidence separately from fixtures. This remains open, not a confirmed diagnosis.
### Task 17 progress
Yaya's app gate could not read its root-only TLS leaf key; logs confirmed
permission denied. Correcting only its group/mode restored authenticated HTTPS
iframe loading, while unauthenticated requests still return401. Source fixes
cover startup migration, hostname regeneration, first boot and explicit rotation;
tests pass. Exact operator hostname/app, trusted TLS and companion acceptance
remain open. See `docs/https-app-gate-followup-20261006.md`.