Make saved Cashu sends recoverable and record deferred connection UX scope

This commit is contained in:
archipelago
2026-10-06 17:19:30 -04:00
parent af85d2be53
commit 4228ce443c
7 changed files with 456 additions and 19 deletions
+10
View File
@@ -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<Vec<u8>, base64::DecodeError> {
+125 -5
View File
@@ -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<String> {
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<String> {
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<Restor
// have found coins beyond where the counter file thought we were —
// reusing those would mint proofs that collide with existing ones.
if let Some(highest) = highest_seen {
if let Err(e) =
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1).await
{
warn!("Could not advance the NUT-13 counter after restore: {e:#}");
}
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1)
.await
.context("Could not preserve the restored wallet derivation position")?;
}
}
+36 -7
View File
@@ -96,7 +96,7 @@ impl PreparedSwap {
pub(super) fn validate_for_mint(&self, mint_url: &str) -> 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::<Result<_>>()?;
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::<secp256k1::PublicKey>()
.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::<secp256k1::PublicKey>()
.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.
+216 -3
View File
@@ -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<String, Value> = (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::<Vec<_>>()}))
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::<Vec<_>>()})
})
}
"/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<crate::wallet::cashu::BlindedMessageRequest> =
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;
+16 -2
View File
@@ -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();
+17
View File
@@ -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.
+36 -2
View File
@@ -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.