Validate mint proof states and protect outgoing sends during seed restore
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user