diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index 1b541e7a..4b7057ff 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -358,6 +358,16 @@ pub fn is_truncated_v2_keyset_id(id: &str) -> bool { id.len() == 16 && id.starts_with("01") && hex::decode(id).is_ok() } +/// Match a compact token's ID against the full ID already bound to a specific +/// proof/operation. This is not discovery of an unknown mint keyset. +pub(super) fn matches_stored_keyset_id(wire: &str, stored: &str) -> bool { + wire.eq_ignore_ascii_case(stored) + || (is_truncated_v2_keyset_id(wire) + && stored.len() == 66 + && hex::decode(stored).is_ok() + && stored[..16].eq_ignore_ascii_case(wire)) +} + /// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec /// default) and falling back to other alphabets some implementations use. fn decode_token_base64(payload: &str) -> Result, base64::DecodeError> { diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 9933587e..3737f351 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -801,6 +801,128 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> send_token_at_locked(data_dir, mint_url, amount_sats).await } +/// Prepare one immutable caller-owned payment. Callers must persist and reuse +/// the operation ID and context hash; a fresh ID is a different payment. +/// This does not authorize delivery or replace the seller's settlement receipt. +pub async fn send_token_recoverable( + data_dir: &Path, + operation_id: &str, + network: EcashNetwork, + mint_url: &str, + amount_sats: u64, + context_hash: &str, +) -> Result { + use super::send_journal::{Binding, Journal, Outcome, Phase, Request}; + let held = super::mutation::guard(data_dir).await?; + anyhow::ensure!( + load_network(data_dir).await? == network, + "Switch back to the payment's original network before recovering it" + ); + let binding = Binding { + id: operation_id.into(), + network, + mint_url: mint_url.into(), + amount_sats, + context_hash: context_hash.into(), + }; + let journal = Journal::new(&held); + let previous = journal.load(operation_id).await?; + let recovering = previous.is_some(); + let record = if let Some(record) = previous { + anyhow::ensure!( + record.binding == binding, + "Payment operation terms changed; no new spend allowed" + ); + record + } else { + anyhow::ensure!(amount_sats > 0, "Payment amount must be positive"); + let wallet = load_wallet(data_dir).await?; + let (indices, excess) = wallet + .select_proofs(&binding.mint_url, amount_sats) + .context("Insufficient spendable balance for this payment")?; + let proofs: Vec<_> = indices + .iter() + .map(|&index| wallet.proofs[index].proof.clone()) + .collect(); + let request = if excess == 0 { + Request::Exact { proofs } + } else { + let mut denominations = amount_to_denominations(amount_sats); + denominations.extend(amount_to_denominations(excess)); + let prepared = mint_client(data_dir, &binding.mint_url) + .await? + .prepare_swap_at_least(&proofs, &denominations, amount_sats) + .await?; + Request::Swap(prepared) + }; + journal.prepare(binding.clone(), request).await? + }; + if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) { + return journal.commit_wallet(&binding).await; + } + journal.reserve_wallet(&binding).await?; + let (send, change) = match &record.request { + Request::Exact { proofs } => (proofs.clone(), vec![]), + Request::Swap(prepared) => { + // All recovery material is already durable. Do not derive new + // outputs or release reservations after an ambiguous response. + let client = MintClient::new(&binding.mint_url)?; + let restored = if recovering { + client.restore_prepared_swap(prepared).await.map_err(|_| { + anyhow::anyhow!("Could not recover this payment yet; its funds remain reserved") + })? + } else { + None + }; + let result = if let Some(restored) = restored { + restored + } else { + if recovering { + let states = client.check_state(prepared.inputs()).await.map_err(|_| { + anyhow::anyhow!( + "Could not verify this payment's original inputs; do not pay again" + ) + })?; + anyhow::ensure!( + states.iter().all(|state| state.state == "UNSPENT"), + "This payment is still pending at the mint; do not pay again" + ); + } + client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!( + "The mint did not confirm this payment; retry this same operation to recover it"))? + }; + let mut needed = amount_to_denominations(amount_sats); + let mut send = Vec::new(); + let mut change = Vec::new(); + for proof in result.new_proofs { + if let Some(index) = needed.iter().position(|amount| *amount == proof.amount) { + needed.remove(index); + send.push(proof); + } else { + change.push(proof); + } + } + anyhow::ensure!( + needed.is_empty(), + "Recovered payment is incomplete; do not pay again" + ); + (send, change) + } + }; + let token = CashuToken::new(&binding.mint_url, send); + let encoded = token.serialize_v4().or_else(|_| token.serialize())?; + journal + .record_result( + &binding, + Outcome { + token: encoded, + change, + }, + ) + .await?; + journal.commit_wallet(&binding).await +} + async fn send_token_at_locked(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result { let mut wallet = load_wallet(data_dir).await?; let mint_url = mint_url.to_string(); @@ -1611,11 +1733,9 @@ pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result Result<()> { anyhow::ensure!( - self.mint_url == mint_url, + self.mint_url.trim_end_matches('/') == mint_url.trim_end_matches('/'), "Prepared swap belongs to a different mint" ); anyhow::ensure!( @@ -173,7 +173,7 @@ impl PreparedSwap { .collect::>()?; for proof in proofs { anyhow::ensure!( - proof.id == self.keyset.id + super::cashu::matches_stored_keyset_id(&proof.id, &self.keyset.id) && expected.remove(proof.secret.as_str()) == Some(proof.amount), "Payment result does not match prepared outputs" ); @@ -1009,11 +1009,40 @@ impl MintClient { ); } - Ok(echoed - .into_iter() - .map(|o| o.b_prime) - .zip(signatures) - .collect()) + let mut requested = std::collections::HashMap::new(); + for output in outputs { + let point = output + .b_prime + .parse::() + .context("Invalid restoration output point")? + .to_string(); + anyhow::ensure!( + requested.insert(point, output).is_none(), + "Duplicate restoration request output" + ); + } + let mut restored = Vec::new(); + for (output, signature) in echoed.into_iter().zip(signatures) { + let point = output + .b_prime + .parse::() + .context("Invalid restored output point")? + .to_string(); + let original = requested + .remove(&point) + .context("Unknown or duplicate restored output")?; + anyhow::ensure!( + super::cashu::matches_stored_keyset_id(&output.id, &original.id) + && super::cashu::matches_stored_keyset_id(&signature.id, &original.id) + && signature.amount.is_power_of_two() + && (original.amount == 0 || original.amount == signature.amount) + && (output.amount == 0 || output.amount == signature.amount), + "Restored output metadata changed" + ); + signature.c_prime_as_pubkey()?; + restored.push((point, signature)); + } + Ok(restored) } /// Receive a CashuToken by swapping its proofs for fresh ones. diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs index 6c06c741..589448b4 100644 --- a/core/archipelago/src/wallet/payment_tests.rs +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -89,14 +89,15 @@ impl Mint { {"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee}, {"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee} ]}), - "/v1/keys" => { + path if path == "/v1/keys" || path.starts_with("/v1/keys/") => { let public = PublicKey::from_secret_key(&Secp256k1::new(), &signing_key()) .to_string(); let keys: serde_json::Map = (0..16) .map(|i| ((1u64 << i).to_string(), json!(public))) .collect(); - json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]}) + let id = path.strip_prefix("/v1/keys/").unwrap_or(ACTIVE); + json!({"keysets":[{"id": id,"unit":"sat","keys":keys}]}) } "/v1/swap" => { let body: Value = serde_json::from_slice( @@ -157,7 +158,10 @@ impl Mint { ) .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::>()})) + overridden.unwrap_or_else(|| { + let spent_points: std::collections::HashSet<_> = spent.lock().unwrap().iter().map(|secret| hex::encode(bdhke::hash_to_curve(secret.as_bytes()).unwrap().serialize())).collect(); + json!({"states": body["Ys"].as_array().unwrap().iter().map(|y| json!({"Y":y,"state":if spent_points.contains(y.as_str().unwrap()) {"SPENT"} else {"UNSPENT"}})).collect::>()}) + }) } "/v1/restore" => { let body: Value = serde_json::from_slice( @@ -713,6 +717,215 @@ async fn journal_recovers_lost_swap_reply_and_commits_change_once() { ); } +#[tokio::test] +async fn recoverable_send_reuses_original_operation_after_ambiguous_mint_response() { + let mint = Mint::start(0, None).await; + let root = tempfile::tempdir().unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); + save_wallet(root.path(), &wallet).await.unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let context = "ab".repeat(32); + mint.lose_swap_reply + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(send_token_recoverable( + root.path(), + &id, + EcashNetwork::Mainnet, + &mint.url, + 4, + &context + ) + .await + .is_err()); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); + assert_eq!(mint.requests.lock().unwrap().len(), 1); + // A missing restore response is not permission to swap spent inputs again. + *mint.restore_reply.lock().unwrap() = Some(json!({"outputs":[],"signatures":[]})); + assert!(send_token_recoverable( + root.path(), + &id, + EcashNetwork::Mainnet, + &mint.url, + 4, + &context + ) + .await + .is_err()); + assert_eq!(mint.requests.lock().unwrap().len(), 1); + *mint.restore_reply.lock().unwrap() = None; + let token = send_token_recoverable( + root.path(), + &id, + EcashNetwork::Mainnet, + &mint.url, + 4, + &context, + ) + .await + .unwrap(); + assert_eq!(CashuToken::deserialize(&token).unwrap().total_amount(), 4); + let purse = std::fs::read(root.path().join("wallet/ecash.json")).unwrap(); + assert_eq!( + send_token_recoverable( + root.path(), + &id, + EcashNetwork::Mainnet, + &mint.url, + 4, + &context + ) + .await + .unwrap(), + token + ); + assert!(send_token_recoverable( + root.path(), + &id, + EcashNetwork::Mainnet, + &mint.url, + 8, + &context + ) + .await + .is_err()); + assert!(send_token_recoverable( + root.path(), + &id, + EcashNetwork::Mainnet, + &mint.url, + 4, + &"cd".repeat(32) + ) + .await + .is_err()); + assert_eq!( + std::fs::read(root.path().join("wallet/ecash.json")).unwrap(), + purse + ); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4); + assert_eq!( + load_wallet(root.path()).await.unwrap().transactions.len(), + 1 + ); + assert_eq!(mint.requests.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn recoverable_exact_send_supports_compact_v2_and_keeps_full_wallet_id() { + let root = tempfile::tempdir().unwrap(); + let mint = "https://unused-mint.invalid/"; + let mut wallet = WalletState::default(); + wallet.mint_url = mint.into(); + wallet.add_proofs(mint, vec![proof(V2, 8)]); + save_wallet(root.path(), &wallet).await.unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let context = "ab".repeat(32); + let token = send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context) + .await + .unwrap(); + assert_eq!( + CashuToken::deserialize(&token).unwrap().token[0].proofs[0].id, + &V2[..16] + ); + assert_eq!( + send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context) + .await + .unwrap(), + token + ); + let wallet = load_wallet(root.path()).await.unwrap(); + assert_eq!(wallet.balance(), 0); + assert_eq!(wallet.proofs[0].proof.id, V2); + assert_eq!(wallet.transactions.len(), 1); +} + +#[tokio::test] +async fn seed_restore_refuses_to_credit_when_counter_persistence_fails() { + let mint = Mint::start(0, None).await; + let root = tempfile::tempdir().unwrap(); + crate::wallet::nut13::establish_independent(root.path()) + .await + .unwrap(); + let client = MintClient::new(&mint.url).unwrap().with_recovery( + crate::wallet::nut13::RecoverySource::load(root.path()) + .await + .unwrap(), + ); + let prepared = client + .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) + .await + .unwrap(); + client.execute_prepared_swap(&prepared).await.unwrap(); + let counter = root.path().join("wallet/cashu_counters.json"); + std::fs::rename(&counter, root.path().join("counter-fixture-backup")).unwrap(); + std::fs::create_dir(&counter).unwrap(); + let error = restore_from_seed(root.path(), &mint.url).await.unwrap_err(); + assert!(error.to_string().contains("derivation position")); + assert!(counter.is_dir()); + assert!(!root.path().join("wallet/ecash.json").exists()); + std::fs::remove_dir(&counter).unwrap(); + std::fs::rename(root.path().join("counter-fixture-backup"), &counter).unwrap(); + assert_eq!( + restore_from_seed(root.path(), &mint.url) + .await + .unwrap() + .recovered_sats, + 8 + ); + assert_eq!( + restore_from_seed(root.path(), &mint.url) + .await + .unwrap() + .recovered_sats, + 0 + ); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); + assert_eq!(mint.requests.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn seed_restore_matches_points_and_rejects_foreign_or_duplicated_metadata() { + let mint = Mint::start(0, None).await; + let client = MintClient::new(&mint.url).unwrap(); + let prepared = client + .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) + .await + .unwrap(); + client.execute_prepared_swap(&prepared).await.unwrap(); + let encoded = serde_json::to_value(&prepared).unwrap(); + let outputs: Vec = + serde_json::from_value(encoded["outputs"].clone()).unwrap(); + let restored = client.restore(&outputs).await.unwrap(); + assert_eq!(restored.len(), 2); + assert!(restored + .iter() + .all(|(point, _)| outputs.iter().any(|output| &output.b_prime == point))); + let output = encoded["outputs"][0].clone(); + let signature = json!({"id":ACTIVE,"amount":4,"C_":signed_point(output["B_"].as_str().unwrap().parse().unwrap())}); + // A seed scan legitimately receives only the outputs the mint signed. + *mint.restore_reply.lock().unwrap() = + Some(json!({"outputs":[output.clone()],"signatures":[signature.clone()]})); + assert_eq!(client.restore(&outputs).await.unwrap().len(), 1); + let mut foreign = output.clone(); + foreign["B_"] = + json!(PublicKey::from_secret_key(&Secp256k1::new(), &signing_key()).to_string()); + let mut wrong_keyset = signature.clone(); + wrong_keyset["id"] = json!(V2); + let mut wrong_amount = signature.clone(); + wrong_amount["amount"] = json!(8); + for response in [ + json!({"outputs":[output.clone(),output.clone()],"signatures":[signature.clone(),signature.clone()]}), + json!({"outputs":[foreign],"signatures":[signature.clone()]}), + json!({"outputs":[output.clone()],"signatures":[wrong_keyset]}), + json!({"outputs":[output],"signatures":[wrong_amount]}), + ] { + *mint.restore_reply.lock().unwrap() = Some(response); + assert!(client.restore(&outputs).await.is_err()); + } +} + #[tokio::test] async fn damaged_or_wrong_mint_preparation_fails_before_spending() { let mint = Mint::start(0, None).await; diff --git a/core/archipelago/src/wallet/send_journal.rs b/core/archipelago/src/wallet/send_journal.rs index 9086671f..33f0e5f0 100644 --- a/core/archipelago/src/wallet/send_journal.rs +++ b/core/archipelago/src/wallet/send_journal.rs @@ -802,10 +802,24 @@ impl<'a> Journal<'a> { match &record.request { Request::Exact { proofs: expected } => { anyhow::ensure!( - outcome.change.is_empty() - && serde_json::to_value(proofs)? == serde_json::to_value(expected)?, + outcome.change.is_empty() && proofs.len() == expected.len(), "Exact payment result changed its proofs" ); + let mut expected: std::collections::HashMap<_, _> = expected + .iter() + .map(|proof| (proof.secret.as_str(), proof)) + .collect(); + for proof in proofs { + let original = expected + .remove(proof.secret.as_str()) + .context("Exact payment result has an unknown or duplicate proof")?; + anyhow::ensure!( + proof.amount == original.amount + && super::cashu::matches_stored_keyset_id(&proof.id, &original.id) + && proof.c_as_pubkey()? == original.c_as_pubkey()?, + "Exact payment result changed its proofs" + ); + } } Request::Swap(prepared) => { let mut all = proofs.clone(); diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index 5cf33c1a..4c41dcab 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -272,3 +272,20 @@ 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. + +### Recoverable send executor qualified locally + +The caller-owned operation now reserves inputs before remote spending, restores +the original saved swap after an ambiguous response, and commits its original +token/change/history once. Changed payment terms reject reuse. A spent input with +no recoverable output blocks another swap. Exact sends support compact v2 wire +keyset IDs while retaining the full wallet ID. Seed restoration canonicalizes +curve points, rejects foreign or duplicate metadata, and requires durable counter +advancement before crediting recovered funds. + +The first test run exposed compact-ID comparison and uppercase restore-point +matching defects; both were fixed. The final isolated run passed1,783tests with +zero failures and five existing skips: +`/tmp/archy-recoverable-send-executor-final-tests.log`. +No real payment or deployment was performed. Purchase RPC integration, durable +seller settlement/receipt recovery and the end-to-end acceptance remain open. diff --git a/docs/post-1.9.0-work-backlog.md b/docs/post-1.9.0-work-backlog.md index a2d43c61..d090fdfe 100644 --- a/docs/post-1.9.0-work-backlog.md +++ b/docs/post-1.9.0-work-backlog.md @@ -388,13 +388,14 @@ 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`. -## 18. Firewall and tunnel UI/settings — latest addition, last in sequence +## 18. Firewall and tunnel UI/settings Status: handover read and acknowledged on6October; implementation pending. The private handover and acknowledgement live in the separate mining review checkout. Do not commit its deployment addresses or operational details here. -- Keep this task at the end, after the previously deferred MeshCore work. +- Keep this task after the previously deferred MeshCore work. The subsequent + connection UX additions below follow all other tasks and their acceptance. - Network → Local network: make the Firewall Active row clickable and add a bottom-anchored Firewall & tunnels button. Both open one central settings screen; app pages may link to it, but are not the primary configuration UI. @@ -419,3 +420,36 @@ checkout. Do not commit its deployment addresses or operational details here. as requested, while retaining repository regression and release requirements. - Review and integrate once through ngit, preserve the already integrated mining work, and mirror accepted commits to Gitea. No release gate is waived. + +## Deferred expansion of tasks 3/6: connection UX and final functional review + +Operator sequencing on6October: finish all other tasks, related deployments, +tests, app deployments and UAT first. Then perform this expansion and the final +functional/UX review. These are additions to existing groups, not closed work. + +- Distinguish peer relationships from federation membership in labels, actions + and removal confirmations, including nodes with both relationships. Remove only + the selected relationship; describe exactly what changed. +- Anchor removal buttons at card bottoms. Show an immediate per-action spinner, + prevent duplicate submissions, and show an accurate completion/error toast. +- Measure and reduce removal latency. Verify whether an authenticated existing + FIPS connection is preferred; implement that priority where supported, with + bounded fallback and no weakening of identity or authorization checks. +- Hide rejected, expired and otherwise resolved Nostr requests from actionable + lists. Prevent relay replay or stale refresh results from resurrecting them; + retain any history needed for diagnostics separately. +- Give incoming peer requests useful notifications linking directly to the + correct Federation/Peers request and its accept/reject actions. +- Update relationship, request, availability and operation states automatically + across screens without routine manual sync. Handle reconnect, missed events, + out-of-order replies and failed refreshes without invented success states. +- When a request changes the 3D map layout, rotate its node to the front, top + centre and clearly expose the request action. Cover multiple requests, + completion, user camera interaction, reduced motion and mobile viewports. +- Review complete desktop/mobile connection flows after the other work passes: + put useful node capabilities and actions first, reduce unnecessary navigation + and scrolling, and assess direct links to permitted peer Cloud files. Preserve + the design system, trust boundaries and meaningful loading/error feedback. +- Qualify real reciprocal removal/requests, offline and reconnect behavior, + duplicate/replayed events, automatic UI convergence, notifications/deep links, + map positioning and responsive card geometry before claiming acceptance.