Validate mint proof states and protect outgoing sends during seed restore

This commit is contained in:
archipelago
2026-10-06 16:50:34 -04:00
parent 3211852acd
commit 96dfed1ec5
5 changed files with 244 additions and 2 deletions
+4
View File
@@ -1485,6 +1485,9 @@ pub struct RestoreOutcome {
/// time to press this button is when something already looks wrong.
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
let _mutation = super::mutation::guard(data_dir).await?;
let outgoing = super::send_journal::Journal::new(&_mutation)
.restore_exclusions(load_network(data_dir).await?, mint_url)
.await?;
let recovery = RecoverySource::load(data_dir).await?.ok_or_else(|| {
anyhow::anyhow!(
"This wallet has no backup phrase yet, so there is nothing to restore from. \
@@ -1507,6 +1510,7 @@ pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<Restor
.proofs
.iter()
.map(|p| p.proof.secret.clone())
.chain(outgoing)
.collect();
let mut outcome = RestoreOutcome::default();
+31 -1
View File
@@ -87,6 +87,13 @@ impl PreparedSwap {
&self.inputs
}
pub(super) fn covers_payment(&self, amount: u64) -> bool {
self.outputs
.iter()
.try_fold(0u64, |sum, output| sum.checked_add(output.amount))
.is_some_and(|total| total >= amount)
}
pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> {
anyhow::ensure!(
self.mint_url == mint_url,
@@ -878,11 +885,19 @@ impl MintClient {
})
.collect::<Result<Vec<_>>>()?;
anyhow::ensure!(
ys.iter().collect::<std::collections::HashSet<_>>().len() == ys.len(),
"Cannot check duplicate proof identifiers"
);
if ys.is_empty() {
return Ok(Vec::new());
}
let url = format!("{}/v1/checkstate", self.url);
let res = self
.client
.post(&url)
.json(&serde_json::json!({ "Ys": ys }))
.json(&serde_json::json!({ "Ys": &ys }))
.send()
.await
.context("Failed to check proof state")?;
@@ -899,6 +914,21 @@ impl MintClient {
serde_json::from_value(body.get("states").cloned().unwrap_or(serde_json::json!([])))
.context("Failed to parse proof states")?;
anyhow::ensure!(
states.len() == ys.len(),
"Mint returned an incomplete proof-state response"
);
for (state, expected) in states.iter().zip(&ys) {
anyhow::ensure!(
state.y.eq_ignore_ascii_case(expected),
"Mint returned a mismatched proof-state identifier"
);
anyhow::ensure!(
matches!(state.state.as_str(), "UNSPENT" | "PENDING" | "SPENT"),
"Mint returned an unknown proof state"
);
}
Ok(states)
}
@@ -22,6 +22,7 @@ struct Mint {
failure: Arc<std::sync::atomic::AtomicU16>,
lose_swap_reply: Arc<std::sync::atomic::AtomicBool>,
restore_reply: Arc<Mutex<Option<Value>>>,
state_reply: Arc<Mutex<Option<Value>>>,
}
impl Drop for Mint {
fn drop(&mut self) {
@@ -62,6 +63,8 @@ impl Mint {
let lose_reply = lose_swap_reply.clone();
let restore_reply = Arc::new(Mutex::new(None::<Value>));
let restore_override = restore_reply.clone();
let state_reply = Arc::new(Mutex::new(None::<Value>));
let state_override = state_reply.clone();
let service = make_service_fn(move |_| {
let seen = seen.clone();
let rejection = rejection.clone();
@@ -69,6 +72,7 @@ impl Mint {
let issued = issued.clone();
let lose_reply = lose_reply.clone();
let restore_override = restore_override.clone();
let state_override = state_override.clone();
async move {
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
let seen = seen.clone();
@@ -77,6 +81,7 @@ impl Mint {
let issued = issued.clone();
let lose_reply = lose_reply.clone();
let restore_override = restore_override.clone();
let state_override = state_override.clone();
async move {
let mut status = 200;
let body = match req.uri().path() {
@@ -146,6 +151,14 @@ impl Mint {
}
}
}
"/v1/checkstate" => {
let body: Value = serde_json::from_slice(
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
)
.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<_>>()}))
}
"/v1/restore" => {
let body: Value = serde_json::from_slice(
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
@@ -201,6 +214,7 @@ impl Mint {
failure,
lose_swap_reply,
restore_reply,
state_reply,
}
}
async fn wallet(&self) -> tempfile::TempDir {
@@ -556,6 +570,51 @@ async fn prepared_swap_recovers_a_lost_reply_without_a_second_spend() {
assert!(!format!("{:?}", reconstructed).contains(&restored.new_proofs[0].secret));
}
#[tokio::test]
async fn proof_state_checks_reject_foreign_duplicate_missing_and_unknown_states() {
let mint = Mint::start(0, None).await;
let client = MintClient::new(&mint.url).unwrap();
let proofs = vec![proof(ACTIVE, 4), proof(ACTIVE, 8)];
let states = client.check_state(&proofs).await.unwrap();
assert_eq!(states.len(), 2);
let valid: Vec<_> = states
.iter()
.map(|state| json!({"Y":state.y,"state":state.state}))
.collect();
let mut duplicate = valid.clone();
duplicate[1] = duplicate[0].clone();
let mut foreign = valid.clone();
foreign[0]["Y"] = json!("00".repeat(33));
let mut unknown = valid.clone();
unknown[0]["state"] = json!("UNKNOWN");
let mut reversed = valid.clone();
reversed.reverse();
for response in [
json!({}),
json!({"states":[valid[0].clone()]}),
json!({"states":duplicate}),
json!({"states":foreign}),
json!({"states":unknown}),
json!({"states":reversed}),
] {
*mint.state_reply.lock().unwrap() = Some(response);
assert!(client.check_state(&proofs).await.is_err());
}
let mut mixed = valid;
mixed[0]["Y"] = json!(states[0].y.to_uppercase());
mixed[0]["state"] = json!("PENDING");
mixed[1]["state"] = json!("SPENT");
*mint.state_reply.lock().unwrap() = Some(json!({"states":mixed}));
let result = client.check_state(&proofs).await.unwrap();
assert_eq!(result[0].state, "PENDING");
assert_eq!(result[1].state, "SPENT");
assert!(client
.check_state(&[proofs[0].clone(), proofs[0].clone()])
.await
.is_err());
assert!(client.check_state(&[]).await.unwrap().is_empty());
}
#[tokio::test]
async fn journal_recovers_lost_swap_reply_and_commits_change_once() {
use crate::wallet::{
@@ -584,6 +643,13 @@ async fn journal_recovers_lost_swap_reply_and_commits_change_once() {
.prepare_swap_at_least(&[input], &[4, 4], 4)
.await
.unwrap();
let mut impossible = binding.clone();
impossible.id = uuid::Uuid::new_v4().to_string();
impossible.amount_sats = 16;
assert!(journal
.prepare(impossible, SendRequest::Swap(prepared.clone()))
.await
.is_err());
journal
.prepare(binding.clone(), SendRequest::Swap(prepared.clone()))
.await
+125 -1
View File
@@ -152,6 +152,66 @@ mod tests {
);
}
#[tokio::test]
async fn seed_restore_blocks_pending_payments_and_excludes_committed_outgoing_tokens() {
let root = tempfile::tempdir().unwrap();
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
let (binding, request, outcome) = fixture();
assert!(journal
.restore_exclusions(binding.network, &binding.mint_url)
.await
.unwrap()
.is_empty());
fund_fixture(root.path(), &binding, &request).await;
journal.prepare(binding.clone(), request).await.unwrap();
assert!(journal
.restore_exclusions(binding.network, &binding.mint_url)
.await
.is_err());
assert!(journal
.restore_exclusions(EcashNetwork::Testnet, &binding.mint_url)
.await
.unwrap()
.is_empty());
assert!(journal
.restore_exclusions(binding.network, "https://other.example")
.await
.unwrap()
.is_empty());
journal.reserve_wallet(&binding).await.unwrap();
journal.record_result(&binding, outcome).await.unwrap();
assert!(journal
.restore_exclusions(binding.network, &binding.mint_url)
.await
.is_err());
journal.commit_wallet(&binding).await.unwrap();
let excluded = journal
.restore_exclusions(binding.network, &binding.mint_url)
.await
.unwrap();
assert_eq!(excluded.len(), 1);
assert!(excluded.contains("private-journal-fixture"));
// Journal exclusions survive removal/pruning of the legacy purse.
fs::remove_file(root.path().join("wallet/ecash.json"))
.await
.unwrap();
assert_eq!(
journal
.restore_exclusions(binding.network, &binding.mint_url)
.await
.unwrap(),
excluded
);
fs::write(journal.path(&binding.id).unwrap(), b"damaged")
.await
.unwrap();
assert!(journal
.restore_exclusions(binding.network, &binding.mint_url)
.await
.is_err());
}
#[tokio::test]
async fn network_switch_cannot_redirect_a_pending_payment_commit() {
let root = tempfile::tempdir().unwrap();
@@ -427,6 +487,64 @@ impl<'a> Journal<'a> {
Self { guard }
}
/// Seed restoration must not re-credit an outgoing token which its recipient
/// has not redeemed yet. Resolve ambiguous operations before scanning.
pub async fn restore_exclusions(
&self,
network: EcashNetwork,
mint_url: &str,
) -> Result<std::collections::HashSet<String>> {
let mut excluded = std::collections::HashSet::new();
let mut entries =
match fs::read_dir(self.guard.data_dir.join("wallet/send-operations")).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(excluded),
Err(error) => {
return Err(error)
.context("Cannot inspect payment recovery before restoring the wallet")
}
};
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let name = name.to_str().context("Invalid payment recovery filename")?;
// A temporary write cannot have authorized a remote operation.
if let Some(id) = name
.strip_prefix('.')
.and_then(|name| name.strip_suffix(".tmp"))
{
if uuid::Uuid::parse_str(id).is_ok() {
continue;
}
}
let id = name
.strip_suffix(".json")
.context("Unexpected payment recovery entry")?;
let record = self
.load(id)
.await?
.context("Payment recovery record disappeared")?;
if record.binding.network != network
|| record.binding.mint_url.trim_end_matches('/') != mint_url.trim_end_matches('/')
{
continue;
}
let Phase::Committed(outcome) = record.phase else {
anyhow::bail!(
"Recover pending payments before restoring this mint from the backup phrase"
);
};
let token = super::cashu::CashuToken::deserialize(&outcome.token)?;
excluded.extend(
token
.token
.into_iter()
.flat_map(|entry| entry.proofs)
.map(|proof| proof.secret),
);
}
Ok(excluded)
}
async fn bound_record(&self, binding: &Binding) -> Result<Record> {
let record = self
.load(&binding.id)
@@ -648,7 +766,13 @@ impl<'a> Journal<'a> {
proof.c_as_pubkey()?;
}
}
Request::Swap(prepared) => prepared.validate_for_mint(&binding.mint_url)?,
Request::Swap(prepared) => {
prepared.validate_for_mint(&binding.mint_url)?;
anyhow::ensure!(
prepared.covers_payment(binding.amount_sats),
"Prepared outputs cannot cover the payment amount"
);
}
}
Ok(())
}