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(())
}
+18
View File
@@ -254,3 +254,21 @@ These methods are not yet wired into the purchase RPC or a remote-operation
executor. Seller receipts, delivery capabilities, ambiguous refunds, melt/change
and seed-restore interaction remain open. A passing commit primitive is not full
paid-content recovery acceptance.
### Seed-restore interaction qualified
NUT-07 responses now require one correctly identified state per requested proof
in protocol order, with only defined states accepted. Duplicate requests, foreign
or duplicated response identifiers, omissions, reordered entries and unknown
states fail closed. Hexadecimal case differences remain accepted.
Seed restore inspects the private send journal before contacting the mint: it
blocks unresolved operations for the selected network/mint and excludes committed
outgoing token secrets even after legacy purse pruning/removal. Damaged journal
records block restoration rather than disappearing from the decision. Other
network/mint operations do not block an unrelated valid restore. Prepared swap
requests also must cover the bound payment amount before being recorded.
Full isolated qualification:1,779passed, zero failures, five existing skips,
`/tmp/archy-journal-restore-boundary-tests.log`. These source changes remain
undeployed. The higher-level purchase/receipt executor remains open.