Prepare and recover Cashu swaps from exact persisted request material
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user