From 96dfed1ec59bf12a41d558fb7b52cf3a7658925d Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 16:50:34 -0400 Subject: [PATCH] Validate mint proof states and protect outgoing sends during seed restore --- core/archipelago/src/wallet/ecash.rs | 4 + core/archipelago/src/wallet/mint_client.rs | 32 ++++- core/archipelago/src/wallet/payment_tests.rs | 66 ++++++++++ core/archipelago/src/wallet/send_journal.rs | 126 ++++++++++++++++++- docs/paid-content-recovery-followup.md | 18 +++ 5 files changed, 244 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 9c26308a..9933587e 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -1485,6 +1485,9 @@ pub struct RestoreOutcome { /// time to press this button is when something already looks wrong. pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result { let _mutation = super::mutation::guard(data_dir).await?; + let outgoing = super::send_journal::Journal::new(&_mutation) + .restore_exclusions(load_network(data_dir).await?, mint_url) + .await?; let recovery = RecoverySource::load(data_dir).await?.ok_or_else(|| { anyhow::anyhow!( "This wallet has no backup phrase yet, so there is nothing to restore from. \ @@ -1507,6 +1510,7 @@ pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result bool { + self.outputs + .iter() + .try_fold(0u64, |sum, output| sum.checked_add(output.amount)) + .is_some_and(|total| total >= amount) + } + pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> { anyhow::ensure!( self.mint_url == mint_url, @@ -878,11 +885,19 @@ impl MintClient { }) .collect::>>()?; + anyhow::ensure!( + ys.iter().collect::>().len() == ys.len(), + "Cannot check duplicate proof identifiers" + ); + if ys.is_empty() { + return Ok(Vec::new()); + } + let url = format!("{}/v1/checkstate", self.url); let res = self .client .post(&url) - .json(&serde_json::json!({ "Ys": ys })) + .json(&serde_json::json!({ "Ys": &ys })) .send() .await .context("Failed to check proof state")?; @@ -899,6 +914,21 @@ impl MintClient { serde_json::from_value(body.get("states").cloned().unwrap_or(serde_json::json!([]))) .context("Failed to parse proof states")?; + anyhow::ensure!( + states.len() == ys.len(), + "Mint returned an incomplete proof-state response" + ); + for (state, expected) in states.iter().zip(&ys) { + anyhow::ensure!( + state.y.eq_ignore_ascii_case(expected), + "Mint returned a mismatched proof-state identifier" + ); + anyhow::ensure!( + matches!(state.state.as_str(), "UNSPENT" | "PENDING" | "SPENT"), + "Mint returned an unknown proof state" + ); + } + Ok(states) } diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs index 87b55376..6c06c741 100644 --- a/core/archipelago/src/wallet/payment_tests.rs +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -22,6 +22,7 @@ struct Mint { failure: Arc, lose_swap_reply: Arc, restore_reply: Arc>>, + state_reply: Arc>>, } impl Drop for Mint { fn drop(&mut self) { @@ -62,6 +63,8 @@ impl Mint { let lose_reply = lose_swap_reply.clone(); let restore_reply = Arc::new(Mutex::new(None::)); let restore_override = restore_reply.clone(); + let state_reply = Arc::new(Mutex::new(None::)); + let state_override = state_reply.clone(); let service = make_service_fn(move |_| { let seen = seen.clone(); let rejection = rejection.clone(); @@ -69,6 +72,7 @@ impl Mint { let issued = issued.clone(); let lose_reply = lose_reply.clone(); let restore_override = restore_override.clone(); + let state_override = state_override.clone(); async move { Ok::<_, Infallible>(service_fn(move |req: Request| { let seen = seen.clone(); @@ -77,6 +81,7 @@ impl Mint { let issued = issued.clone(); let lose_reply = lose_reply.clone(); let restore_override = restore_override.clone(); + let state_override = state_override.clone(); async move { let mut status = 200; let body = match req.uri().path() { @@ -146,6 +151,14 @@ impl Mint { } } } + "/v1/checkstate" => { + let body: Value = serde_json::from_slice( + &hyper::body::to_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + let overridden = state_override.lock().unwrap().clone(); + overridden.unwrap_or_else(|| json!({"states": body["Ys"].as_array().unwrap().iter().map(|y| json!({"Y":y,"state":"UNSPENT"})).collect::>()})) + } "/v1/restore" => { let body: Value = serde_json::from_slice( &hyper::body::to_bytes(req.into_body()).await.unwrap(), @@ -201,6 +214,7 @@ impl Mint { failure, lose_swap_reply, restore_reply, + state_reply, } } async fn wallet(&self) -> tempfile::TempDir { @@ -556,6 +570,51 @@ async fn prepared_swap_recovers_a_lost_reply_without_a_second_spend() { assert!(!format!("{:?}", reconstructed).contains(&restored.new_proofs[0].secret)); } +#[tokio::test] +async fn proof_state_checks_reject_foreign_duplicate_missing_and_unknown_states() { + let mint = Mint::start(0, None).await; + let client = MintClient::new(&mint.url).unwrap(); + let proofs = vec![proof(ACTIVE, 4), proof(ACTIVE, 8)]; + let states = client.check_state(&proofs).await.unwrap(); + assert_eq!(states.len(), 2); + let valid: Vec<_> = states + .iter() + .map(|state| json!({"Y":state.y,"state":state.state})) + .collect(); + let mut duplicate = valid.clone(); + duplicate[1] = duplicate[0].clone(); + let mut foreign = valid.clone(); + foreign[0]["Y"] = json!("00".repeat(33)); + let mut unknown = valid.clone(); + unknown[0]["state"] = json!("UNKNOWN"); + let mut reversed = valid.clone(); + reversed.reverse(); + for response in [ + json!({}), + json!({"states":[valid[0].clone()]}), + json!({"states":duplicate}), + json!({"states":foreign}), + json!({"states":unknown}), + json!({"states":reversed}), + ] { + *mint.state_reply.lock().unwrap() = Some(response); + assert!(client.check_state(&proofs).await.is_err()); + } + let mut mixed = valid; + mixed[0]["Y"] = json!(states[0].y.to_uppercase()); + mixed[0]["state"] = json!("PENDING"); + mixed[1]["state"] = json!("SPENT"); + *mint.state_reply.lock().unwrap() = Some(json!({"states":mixed})); + let result = client.check_state(&proofs).await.unwrap(); + assert_eq!(result[0].state, "PENDING"); + assert_eq!(result[1].state, "SPENT"); + assert!(client + .check_state(&[proofs[0].clone(), proofs[0].clone()]) + .await + .is_err()); + assert!(client.check_state(&[]).await.unwrap().is_empty()); +} + #[tokio::test] async fn journal_recovers_lost_swap_reply_and_commits_change_once() { use crate::wallet::{ @@ -584,6 +643,13 @@ async fn journal_recovers_lost_swap_reply_and_commits_change_once() { .prepare_swap_at_least(&[input], &[4, 4], 4) .await .unwrap(); + let mut impossible = binding.clone(); + impossible.id = uuid::Uuid::new_v4().to_string(); + impossible.amount_sats = 16; + assert!(journal + .prepare(impossible, SendRequest::Swap(prepared.clone())) + .await + .is_err()); journal .prepare(binding.clone(), SendRequest::Swap(prepared.clone())) .await diff --git a/core/archipelago/src/wallet/send_journal.rs b/core/archipelago/src/wallet/send_journal.rs index ee3c5815..9086671f 100644 --- a/core/archipelago/src/wallet/send_journal.rs +++ b/core/archipelago/src/wallet/send_journal.rs @@ -152,6 +152,66 @@ mod tests { ); } + #[tokio::test] + async fn seed_restore_blocks_pending_payments_and_excludes_committed_outgoing_tokens() { + let root = tempfile::tempdir().unwrap(); + let held = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&held); + let (binding, request, outcome) = fixture(); + assert!(journal + .restore_exclusions(binding.network, &binding.mint_url) + .await + .unwrap() + .is_empty()); + fund_fixture(root.path(), &binding, &request).await; + journal.prepare(binding.clone(), request).await.unwrap(); + assert!(journal + .restore_exclusions(binding.network, &binding.mint_url) + .await + .is_err()); + assert!(journal + .restore_exclusions(EcashNetwork::Testnet, &binding.mint_url) + .await + .unwrap() + .is_empty()); + assert!(journal + .restore_exclusions(binding.network, "https://other.example") + .await + .unwrap() + .is_empty()); + journal.reserve_wallet(&binding).await.unwrap(); + journal.record_result(&binding, outcome).await.unwrap(); + assert!(journal + .restore_exclusions(binding.network, &binding.mint_url) + .await + .is_err()); + journal.commit_wallet(&binding).await.unwrap(); + let excluded = journal + .restore_exclusions(binding.network, &binding.mint_url) + .await + .unwrap(); + assert_eq!(excluded.len(), 1); + assert!(excluded.contains("private-journal-fixture")); + // Journal exclusions survive removal/pruning of the legacy purse. + fs::remove_file(root.path().join("wallet/ecash.json")) + .await + .unwrap(); + assert_eq!( + journal + .restore_exclusions(binding.network, &binding.mint_url) + .await + .unwrap(), + excluded + ); + fs::write(journal.path(&binding.id).unwrap(), b"damaged") + .await + .unwrap(); + assert!(journal + .restore_exclusions(binding.network, &binding.mint_url) + .await + .is_err()); + } + #[tokio::test] async fn network_switch_cannot_redirect_a_pending_payment_commit() { let root = tempfile::tempdir().unwrap(); @@ -427,6 +487,64 @@ impl<'a> Journal<'a> { Self { guard } } + /// Seed restoration must not re-credit an outgoing token which its recipient + /// has not redeemed yet. Resolve ambiguous operations before scanning. + pub async fn restore_exclusions( + &self, + network: EcashNetwork, + mint_url: &str, + ) -> Result> { + let mut excluded = std::collections::HashSet::new(); + let mut entries = + match fs::read_dir(self.guard.data_dir.join("wallet/send-operations")).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(excluded), + Err(error) => { + return Err(error) + .context("Cannot inspect payment recovery before restoring the wallet") + } + }; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name = name.to_str().context("Invalid payment recovery filename")?; + // A temporary write cannot have authorized a remote operation. + if let Some(id) = name + .strip_prefix('.') + .and_then(|name| name.strip_suffix(".tmp")) + { + if uuid::Uuid::parse_str(id).is_ok() { + continue; + } + } + let id = name + .strip_suffix(".json") + .context("Unexpected payment recovery entry")?; + let record = self + .load(id) + .await? + .context("Payment recovery record disappeared")?; + if record.binding.network != network + || record.binding.mint_url.trim_end_matches('/') != mint_url.trim_end_matches('/') + { + continue; + } + let Phase::Committed(outcome) = record.phase else { + anyhow::bail!( + "Recover pending payments before restoring this mint from the backup phrase" + ); + }; + let token = super::cashu::CashuToken::deserialize(&outcome.token)?; + excluded.extend( + token + .token + .into_iter() + .flat_map(|entry| entry.proofs) + .map(|proof| proof.secret), + ); + } + Ok(excluded) + } + async fn bound_record(&self, binding: &Binding) -> Result { let record = self .load(&binding.id) @@ -648,7 +766,13 @@ impl<'a> Journal<'a> { proof.c_as_pubkey()?; } } - Request::Swap(prepared) => prepared.validate_for_mint(&binding.mint_url)?, + Request::Swap(prepared) => { + prepared.validate_for_mint(&binding.mint_url)?; + anyhow::ensure!( + prepared.covers_payment(binding.amount_sats), + "Prepared outputs cannot cover the payment amount" + ); + } } Ok(()) } diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index b1840386..5cf33c1a 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -254,3 +254,21 @@ These methods are not yet wired into the purchase RPC or a remote-operation executor. Seller receipts, delivery capabilities, ambiguous refunds, melt/change and seed-restore interaction remain open. A passing commit primitive is not full paid-content recovery acceptance. + +### Seed-restore interaction qualified + +NUT-07 responses now require one correctly identified state per requested proof +in protocol order, with only defined states accepted. Duplicate requests, foreign +or duplicated response identifiers, omissions, reordered entries and unknown +states fail closed. Hexadecimal case differences remain accepted. + +Seed restore inspects the private send journal before contacting the mint: it +blocks unresolved operations for the selected network/mint and excludes committed +outgoing token secrets even after legacy purse pruning/removal. Damaged journal +records block restoration rather than disappearing from the decision. Other +network/mint operations do not block an unrelated valid restore. Prepared swap +requests also must cover the bound payment amount before being recorded. + +Full isolated qualification:1,779passed, zero failures, five existing skips, +`/tmp/archy-journal-restore-boundary-tests.log`. These source changes remain +undeployed. The higher-level purchase/receipt executor remains open.