Prepare and recover Cashu swaps from exact persisted request material

This commit is contained in:
archipelago
2026-10-06 15:58:22 -04:00
parent 9c95b8732f
commit a4ede20204
3 changed files with 382 additions and 31 deletions
+199 -27
View File
@@ -57,6 +57,31 @@ pub struct SwapResult {
pub new_proofs: Vec<Proof>, pub new_proofs: Vec<Proof>,
} }
/// Exact private request material to persist before a remote swap. Debug
/// deliberately omits bearer secrets and blinding factors.
#[derive(Clone, Serialize, Deserialize)]
pub struct PreparedSwap {
mint_url: String,
inputs: Vec<Proof>,
keyset: MintKeyset,
outputs: Vec<BlindedMessageRequest>,
blinding: Vec<PreparedBlinding>,
}
#[derive(Clone, Serialize, Deserialize)]
struct PreparedBlinding {
secret: Vec<u8>,
factor: [u8; 32],
amount: u64,
}
impl std::fmt::Debug for PreparedSwap {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedSwap")
.field("input_count", &self.inputs.len())
.field("output_count", &self.outputs.len())
.finish_non_exhaustive()
}
}
/// Result of a mint operation. /// Result of a mint operation.
pub struct MintResult { pub struct MintResult {
pub proofs: Vec<Proof>, pub proofs: Vec<Proof>,
@@ -537,6 +562,19 @@ impl MintClient {
target_amounts: &[u64], target_amounts: &[u64],
minimum: u64, minimum: u64,
) -> Result<SwapResult> { ) -> Result<SwapResult> {
let prepared = self
.prepare_swap_at_least(inputs, target_amounts, minimum)
.await?;
self.execute_prepared_swap(&prepared).await
}
/// Read mint metadata and derive outputs, but do not consume any input.
pub async fn prepare_swap_at_least(
&self,
inputs: &[Proof],
target_amounts: &[u64],
minimum: u64,
) -> Result<PreparedSwap> {
// V4 tokens carry short keyset IDs. Every swap path (including paid // V4 tokens carry short keyset IDs. Every swap path (including paid
// files and streams) must expand these, not only wallet imports. // files and streams) must expand these, not only wallet imports.
let resolved = self.resolve_truncated_keyset_ids(inputs).await?; let resolved = self.resolve_truncated_keyset_ids(inputs).await?;
@@ -599,13 +637,102 @@ impl MintClient {
let (blinded_messages, blinding_data) = let (blinded_messages, blinding_data) =
self.blinded_outputs(&keyset.id, target_amounts).await?; self.blinded_outputs(&keyset.id, target_amounts).await?;
let prepared = PreparedSwap {
mint_url: self.url.clone(),
inputs: inputs.to_vec(),
keyset,
outputs: blinded_messages,
blinding: blinding_data
.into_iter()
.map(|(secret, factor, amount)| PreparedBlinding {
secret,
factor: factor.secret_bytes(),
amount,
})
.collect(),
};
self.validate_prepared_swap(&prepared)?;
Ok(prepared)
}
fn validate_prepared_swap(&self, prepared: &PreparedSwap) -> Result<()> {
anyhow::ensure!(
prepared.mint_url == self.url,
"Prepared swap belongs to a different mint"
);
anyhow::ensure!(
!prepared.inputs.is_empty()
&& !prepared.outputs.is_empty()
&& prepared.outputs.len() == prepared.blinding.len(),
"Invalid prepared swap structure"
);
let commitments: std::collections::HashSet<_> = prepared
.outputs
.iter()
.map(|output| output.b_prime.as_str())
.collect();
anyhow::ensure!(
commitments.len() == prepared.outputs.len(),
"Prepared swap contains duplicate outputs"
);
let input_secrets: std::collections::HashSet<_> = prepared
.inputs
.iter()
.map(|proof| proof.secret.as_str())
.collect();
anyhow::ensure!(
input_secrets.len() == prepared.inputs.len(),
"Prepared swap contains duplicate inputs"
);
anyhow::ensure!(
prepared.keyset.unit == "sat",
"Prepared swap is not denominated in sats"
);
let input_total = prepared
.inputs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Prepared input amount overflow")?;
let output_total = prepared
.outputs
.iter()
.try_fold(0u64, |sum, output| sum.checked_add(output.amount))
.context("Prepared output amount overflow")?;
anyhow::ensure!(
output_total <= input_total,
"Prepared swap output value exceeds its inputs"
);
for (output, secret) in prepared.outputs.iter().zip(&prepared.blinding) {
anyhow::ensure!(
output.id == prepared.keyset.id
&& output.amount == secret.amount
&& output.amount.is_power_of_two(),
"Prepared swap output metadata changed"
);
let factor = secp256k1::SecretKey::from_slice(&secret.factor)
.context("Invalid prepared blinding factor")?;
let blinded = bdhke::blind_message(&secret.secret, &factor)?;
anyhow::ensure!(
output.b_prime == hex::encode(blinded.b_prime.serialize()),
"Prepared swap output commitment changed"
);
std::str::from_utf8(&secret.secret).context("Invalid prepared secret encoding")?;
prepared.keyset.key_for_amount(output.amount)?;
}
Ok(())
}
/// Execute only an already prepared request. Callers implementing recovery
/// must save it before invoking this method, and retain it on any error.
pub async fn execute_prepared_swap(&self, prepared: &PreparedSwap) -> Result<SwapResult> {
self.validate_prepared_swap(prepared)?;
let url = format!("{}/v1/swap", self.url); let url = format!("{}/v1/swap", self.url);
let res = self let res = self
.client .client
.post(&url) .post(&url)
.json(&serde_json::json!({ .json(&serde_json::json!({
"inputs": inputs, "inputs": prepared.inputs,
"outputs": blinded_messages, "outputs": prepared.outputs,
})) }))
.send() .send()
.await .await
@@ -625,39 +752,84 @@ impl MintClient {
) )
.context("Failed to parse swap signatures")?; .context("Failed to parse swap signatures")?;
if signatures.len() != blinding_data.len() { self.unblind_prepared_swap(prepared, &signatures)
anyhow::bail!( }
"Swap returned {} signatures, expected {}",
signatures.len(), fn unblind_prepared_swap(
blinding_data.len() &self,
prepared: &PreparedSwap,
signatures: &[BlindSignature],
) -> Result<SwapResult> {
anyhow::ensure!(
signatures.len() == prepared.blinding.len(),
"Swap returned an incomplete signature set; preserve the prepared operation"
);
let mut new_proofs = Vec::with_capacity(signatures.len());
for (sig, secret) in signatures.iter().zip(&prepared.blinding) {
anyhow::ensure!(
sig.amount == secret.amount && sig.id == prepared.keyset.id,
"Mint returned a swap signature for an unexpected amount or keyset"
); );
} let factor = secp256k1::SecretKey::from_slice(&secret.factor)?;
let c = bdhke::unblind_signature(
let mut new_proofs = Vec::new(); &sig.c_prime_as_pubkey()?,
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) { &factor,
if sig.amount != *amount || sig.id != keyset.id { &prepared.keyset.key_for_amount(secret.amount)?,
anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset"); )?;
}
let c_prime = sig.c_prime_as_pubkey()?;
let mint_key = keyset.key_for_amount(*amount)?;
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
new_proofs.push(Proof { new_proofs.push(Proof {
amount: *amount, amount: secret.amount,
id: keyset.id.clone(), id: prepared.keyset.id.clone(),
secret: String::from_utf8_lossy(secret).to_string(), secret: std::str::from_utf8(&secret.secret)?.to_owned(),
c: hex::encode(c.serialize()), c: hex::encode(c.serialize()),
}); });
} }
debug!(
"Swapped {} inputs for {} new proofs",
inputs.len(),
new_proofs.len()
);
Ok(SwapResult { new_proofs }) Ok(SwapResult { new_proofs })
} }
/// Recover signatures for the exact persisted outputs after a lost reply.
/// No result is not proof of failure; callers must retain the reservation.
pub async fn restore_prepared_swap(
&self,
prepared: &PreparedSwap,
) -> Result<Option<SwapResult>> {
self.validate_prepared_swap(prepared)?;
let returned = self.restore(&prepared.outputs).await?;
if returned.is_empty() {
return Ok(None);
}
let mut by_output = std::collections::HashMap::new();
for (commitment, signature) in returned {
// Hex case is not part of the curve-point identity.
let commitment = hex::encode(
commitment
.parse::<secp256k1::PublicKey>()
.context("Mint restored an invalid output commitment")?
.serialize(),
);
anyhow::ensure!(
prepared
.outputs
.iter()
.any(|output| output.b_prime == commitment),
"Mint restored an unknown output"
);
anyhow::ensure!(
by_output.insert(commitment, signature).is_none(),
"Mint restored duplicate outputs"
);
}
let ordered: Vec<_> = prepared
.outputs
.iter()
.map(|output| {
by_output
.remove(&output.b_prime)
.context("Mint restored only part of the prepared swap")
})
.collect::<Result<_>>()?;
Ok(Some(self.unblind_prepared_swap(prepared, &ordered)?))
}
// ── Check state (NUT-07) ── // ── Check state (NUT-07) ──
/// Check whether proofs are spent, unspent, or pending. /// Check whether proofs are spent, unspent, or pending.
+160 -4
View File
@@ -20,6 +20,8 @@ struct Mint {
requests: Arc<Mutex<Vec<Value>>>, requests: Arc<Mutex<Vec<Value>>>,
task: tokio::task::JoinHandle<()>, task: tokio::task::JoinHandle<()>,
failure: Arc<std::sync::atomic::AtomicU16>, failure: Arc<std::sync::atomic::AtomicU16>,
lose_swap_reply: Arc<std::sync::atomic::AtomicBool>,
restore_reply: Arc<Mutex<Option<Value>>>,
} }
impl Drop for Mint { impl Drop for Mint {
fn drop(&mut self) { fn drop(&mut self) {
@@ -55,15 +57,26 @@ impl Mint {
let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0))); let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0)));
let rejection = failure.clone(); let rejection = failure.clone();
let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new())); let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
let issued = Arc::new(Mutex::new(std::collections::HashMap::<String, Value>::new()));
let lose_swap_reply = Arc::new(std::sync::atomic::AtomicBool::new(false));
let lose_reply = lose_swap_reply.clone();
let restore_reply = Arc::new(Mutex::new(None::<Value>));
let restore_override = restore_reply.clone();
let service = make_service_fn(move |_| { let service = make_service_fn(move |_| {
let seen = seen.clone(); let seen = seen.clone();
let rejection = rejection.clone(); let rejection = rejection.clone();
let spent = spent.clone(); let spent = spent.clone();
let issued = issued.clone();
let lose_reply = lose_reply.clone();
let restore_override = restore_override.clone();
async move { async move {
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| { Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
let seen = seen.clone(); let seen = seen.clone();
let rejection = rejection.clone(); let rejection = rejection.clone();
let spent = spent.clone(); let spent = spent.clone();
let issued = issued.clone();
let lose_reply = lose_reply.clone();
let restore_override = restore_override.clone();
async move { async move {
let mut status = 200; let mut status = 200;
let body = match req.uri().path() { let body = match req.uri().path() {
@@ -119,10 +132,46 @@ impl Mint {
.unwrap() .unwrap()
.insert(p["secret"].as_str().unwrap().into()); .insert(p["secret"].as_str().unwrap().into());
} }
json!({"signatures":outputs.iter().map(|o| json!({ let signatures: Vec<_> = outputs.iter().map(|o| {
"amount":o["amount"],"id":ACTIVE, let signature = json!({"amount":o["amount"],"id":ACTIVE,
"C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap()) "C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())});
})).collect::<Vec<_>>()}) issued.lock().unwrap().insert(o["B_"].as_str().unwrap().into(), signature.clone());
signature
}).collect();
if lose_reply.load(std::sync::atomic::Ordering::SeqCst) {
status = 500;
json!({"detail":"Fixture lost the reply after consuming inputs"})
} else {
json!({"signatures":signatures})
}
}
}
"/v1/restore" => {
let body: Value = serde_json::from_slice(
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
)
.unwrap();
let override_reply = restore_override.lock().unwrap().clone();
if let Some(reply) = override_reply {
reply
} else {
let issued = issued.lock().unwrap();
let mut outputs = Vec::new();
let mut signatures = Vec::new();
for output in body["outputs"].as_array().unwrap().iter().rev() {
if let Some(signature) =
issued.get(output["B_"].as_str().unwrap())
{
let mut echoed = output.clone();
echoed["B_"] = json!(output["B_"]
.as_str()
.unwrap()
.to_uppercase());
outputs.push(echoed);
signatures.push(signature.clone());
}
}
json!({"outputs":outputs,"signatures":signatures})
} }
} }
_ => { _ => {
@@ -150,6 +199,8 @@ impl Mint {
requests, requests,
task, task,
failure, failure,
lose_swap_reply,
restore_reply,
} }
} }
async fn wallet(&self) -> tempfile::TempDir { async fn wallet(&self) -> tempfile::TempDir {
@@ -460,3 +511,108 @@ async fn simultaneous_receipts_and_revenue_writes_preserve_every_payment() {
assert_eq!(wallet.transactions.len(), 24); assert_eq!(wallet.transactions.len(), 24);
assert_eq!(mint.requests.lock().unwrap().len(), 8); assert_eq!(mint.requests.lock().unwrap().len(), 8);
} }
#[tokio::test]
async fn prepared_swap_recovers_a_lost_reply_without_a_second_spend() {
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();
assert!(
mint.requests.lock().unwrap().is_empty(),
"Preparation must not consume inputs"
);
assert!(client
.restore_prepared_swap(&prepared)
.await
.unwrap()
.is_none());
let stored = serde_json::to_vec(&prepared).unwrap();
mint.lose_swap_reply
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(client.execute_prepared_swap(&prepared).await.is_err());
let reconstructed: crate::wallet::mint_client::PreparedSwap =
serde_json::from_slice(&stored).unwrap();
let restarted = MintClient::new(&mint.url).unwrap();
let restored = restarted
.restore_prepared_swap(&reconstructed)
.await
.unwrap()
.unwrap();
assert_eq!(restored.new_proofs.iter().map(|p| p.amount).sum::<u64>(), 8);
for proof in &restored.new_proofs {
assert_eq!(
proof.c,
signed_point(bdhke::hash_to_curve(proof.secret.as_bytes()).unwrap())
);
}
assert_eq!(mint.requests.lock().unwrap().len(), 1);
assert_eq!(
serde_json::to_value(&reconstructed).unwrap(),
serde_json::from_slice::<Value>(&stored).unwrap()
);
assert!(!format!("{:?}", reconstructed).contains(&restored.new_proofs[0].secret));
}
#[tokio::test]
async fn damaged_or_wrong_mint_preparation_fails_before_spending() {
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();
for field in ["mint_url", "outputs", "blinding", "duplicate"] {
let mut data = serde_json::to_value(&prepared).unwrap();
match field {
"mint_url" => data["mint_url"] = json!("https://different.invalid"),
"outputs" => data["outputs"][0]["amount"] = json!(2),
"duplicate" => {
data["outputs"][1] = data["outputs"][0].clone();
data["blinding"][1] = data["blinding"][0].clone();
}
_ => data["blinding"][0]["secret"] = json!([1, 2, 3]),
}
let corrupted = serde_json::from_value(data).unwrap();
assert!(client.execute_prepared_swap(&corrupted).await.is_err());
}
assert!(mint.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn incomplete_duplicate_or_unknown_restoration_never_completes_a_swap() {
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();
let data = serde_json::to_value(&prepared).unwrap();
let output = data["outputs"][0].clone();
let signature = json!({"amount":4,"id":ACTIVE,
"C_":signed_point(output["B_"].as_str().unwrap().parse().unwrap())});
let mut unknown = output.clone();
unknown["B_"] =
json!(PublicKey::from_secret_key(&Secp256k1::new(), &signing_key()).to_string());
let second = data["outputs"][1].clone();
let second_signature = json!({"amount":4,"id":ACTIVE,
"C_":signed_point(second["B_"].as_str().unwrap().parse().unwrap())});
let mut wrong_amount = signature.clone();
wrong_amount["amount"] = json!(2);
let mut wrong_keyset = signature.clone();
wrong_keyset["id"] = json!(V2);
for reply in [
json!({"outputs":[output.clone(),second.clone()],"signatures":[wrong_amount,second_signature.clone()]}),
json!({"outputs":[output.clone(),second],"signatures":[wrong_keyset,second_signature]}),
json!({"outputs":[output.clone()],"signatures":[signature.clone()]}),
json!({"outputs":[output.clone(),output.clone()],"signatures":[signature.clone(),signature.clone()]}),
json!({"outputs":[unknown],"signatures":[signature.clone()]}),
json!({"outputs":[output],"signatures":[]}),
] {
*mint.restore_reply.lock().unwrap() = Some(reply);
assert!(client.restore_prepared_swap(&prepared).await.is_err());
}
assert!(mint.requests.lock().unwrap().is_empty());
}
+23
View File
@@ -170,3 +170,26 @@ prepared outputs, quote/change handling and seller receipts, interrupted-operati
recovery and complete timed-playback integration. Higher-level Minibits claim and recovery and complete timed-playback integration. Higher-level Minibits claim and
purchase flows must pin their network/terms across their entire business operation; purchase flows must pin their network/terms across their entire business operation;
serializing individual wallet calls alone does not provide that contract. serializing individual wallet calls alone does not provide that contract.
### Recoverable prepared Cashu swaps
MintClient now separates preparation from execution. Prepared requests contain
the exact outputs and private unblinding material, can survive serialization, and
validate their mint, commitments, values and keyset before execution. Debug output
omits bearer secrets. Recovery uses the original outputs, matches returned curve
points independently of response ordering/hex case, and rejects partial,
duplicate, unknown or mismatched signatures. An empty restore response remains
uncertain; it is never interpreted as permission to spend again.
Real HTTP/curve fixtures simulate a mint consuming inputs and returning500instead
of its successful response. A reconstructed client recovers the original valid
proofs without a second swap. Negative tests cover changed preparation and
malformed recovery. The first run failed an overly strict test comparing JSON
bytes with unordered map keys; the corrected test compares semantic contents.
Original log: /tmp/archy-prepared-swap-tests.log. Final complete isolated backend
suite: **1,767 pass, zero failures, five existing skips**, in
/tmp/archy-prepared-swap-final-full-tests.log.
This introduces the request/recovery primitive. Existing swap callers still
execute immediately; durable wallet reservations and the correlated purchase
journal are not wired yet. No live money, wallet state or app deployment changed.