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();