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.
|
/// 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> {
|
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
|
||||||
let _mutation = super::mutation::guard(data_dir).await?;
|
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(|| {
|
let recovery = RecoverySource::load(data_dir).await?.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"This wallet has no backup phrase yet, so there is nothing to restore from. \
|
"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
|
.proofs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.proof.secret.clone())
|
.map(|p| p.proof.secret.clone())
|
||||||
|
.chain(outgoing)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut outcome = RestoreOutcome::default();
|
let mut outcome = RestoreOutcome::default();
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ impl PreparedSwap {
|
|||||||
&self.inputs
|
&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<()> {
|
pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
self.mint_url == mint_url,
|
self.mint_url == mint_url,
|
||||||
@@ -878,11 +885,19 @@ impl MintClient {
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.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 url = format!("{}/v1/checkstate", self.url);
|
||||||
let res = self
|
let res = self
|
||||||
.client
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.json(&serde_json::json!({ "Ys": ys }))
|
.json(&serde_json::json!({ "Ys": &ys }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("Failed to check proof state")?;
|
.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!([])))
|
serde_json::from_value(body.get("states").cloned().unwrap_or(serde_json::json!([])))
|
||||||
.context("Failed to parse proof states")?;
|
.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)
|
Ok(states)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct Mint {
|
|||||||
failure: Arc<std::sync::atomic::AtomicU16>,
|
failure: Arc<std::sync::atomic::AtomicU16>,
|
||||||
lose_swap_reply: Arc<std::sync::atomic::AtomicBool>,
|
lose_swap_reply: Arc<std::sync::atomic::AtomicBool>,
|
||||||
restore_reply: Arc<Mutex<Option<Value>>>,
|
restore_reply: Arc<Mutex<Option<Value>>>,
|
||||||
|
state_reply: Arc<Mutex<Option<Value>>>,
|
||||||
}
|
}
|
||||||
impl Drop for Mint {
|
impl Drop for Mint {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
@@ -62,6 +63,8 @@ impl Mint {
|
|||||||
let lose_reply = lose_swap_reply.clone();
|
let lose_reply = lose_swap_reply.clone();
|
||||||
let restore_reply = Arc::new(Mutex::new(None::<Value>));
|
let restore_reply = Arc::new(Mutex::new(None::<Value>));
|
||||||
let restore_override = restore_reply.clone();
|
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 service = make_service_fn(move |_| {
|
||||||
let seen = seen.clone();
|
let seen = seen.clone();
|
||||||
let rejection = rejection.clone();
|
let rejection = rejection.clone();
|
||||||
@@ -69,6 +72,7 @@ impl Mint {
|
|||||||
let issued = issued.clone();
|
let issued = issued.clone();
|
||||||
let lose_reply = lose_reply.clone();
|
let lose_reply = lose_reply.clone();
|
||||||
let restore_override = restore_override.clone();
|
let restore_override = restore_override.clone();
|
||||||
|
let state_override = state_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();
|
||||||
@@ -77,6 +81,7 @@ impl Mint {
|
|||||||
let issued = issued.clone();
|
let issued = issued.clone();
|
||||||
let lose_reply = lose_reply.clone();
|
let lose_reply = lose_reply.clone();
|
||||||
let restore_override = restore_override.clone();
|
let restore_override = restore_override.clone();
|
||||||
|
let state_override = state_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() {
|
||||||
@@ -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" => {
|
"/v1/restore" => {
|
||||||
let body: Value = serde_json::from_slice(
|
let body: Value = serde_json::from_slice(
|
||||||
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
||||||
@@ -201,6 +214,7 @@ impl Mint {
|
|||||||
failure,
|
failure,
|
||||||
lose_swap_reply,
|
lose_swap_reply,
|
||||||
restore_reply,
|
restore_reply,
|
||||||
|
state_reply,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async fn wallet(&self) -> tempfile::TempDir {
|
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));
|
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]
|
#[tokio::test]
|
||||||
async fn journal_recovers_lost_swap_reply_and_commits_change_once() {
|
async fn journal_recovers_lost_swap_reply_and_commits_change_once() {
|
||||||
use crate::wallet::{
|
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)
|
.prepare_swap_at_least(&[input], &[4, 4], 4)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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
|
journal
|
||||||
.prepare(binding.clone(), SendRequest::Swap(prepared.clone()))
|
.prepare(binding.clone(), SendRequest::Swap(prepared.clone()))
|
||||||
.await
|
.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]
|
#[tokio::test]
|
||||||
async fn network_switch_cannot_redirect_a_pending_payment_commit() {
|
async fn network_switch_cannot_redirect_a_pending_payment_commit() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
@@ -427,6 +487,64 @@ impl<'a> Journal<'a> {
|
|||||||
Self { guard }
|
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> {
|
async fn bound_record(&self, binding: &Binding) -> Result<Record> {
|
||||||
let record = self
|
let record = self
|
||||||
.load(&binding.id)
|
.load(&binding.id)
|
||||||
@@ -648,7 +766,13 @@ impl<'a> Journal<'a> {
|
|||||||
proof.c_as_pubkey()?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
executor. Seller receipts, delivery capabilities, ambiguous refunds, melt/change
|
||||||
and seed-restore interaction remain open. A passing commit primitive is not full
|
and seed-restore interaction remain open. A passing commit primitive is not full
|
||||||
paid-content recovery acceptance.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user