Recover incoming settlement and persist immutable purchase journals
This commit is contained in:
@@ -88,6 +88,10 @@ pub struct WalletState {
|
||||
#[serde(default)]
|
||||
pub mint_url: String,
|
||||
|
||||
/// Durable receive commit ownership; never prune with transaction history.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub(super) receive_commits: std::collections::BTreeMap<String, String>,
|
||||
|
||||
// ── Legacy compatibility ──
|
||||
// Old wallet format had a `tokens` field. If present during deserialization,
|
||||
// we migrate to proofs. This field is never written.
|
||||
@@ -239,7 +243,7 @@ pub enum EcashNetwork {
|
||||
}
|
||||
|
||||
impl EcashNetwork {
|
||||
fn wallet_file(&self) -> &'static str {
|
||||
pub(super) fn wallet_file(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Mainnet => WALLET_FILE,
|
||||
Self::Testnet => "wallet/ecash.testnet.json",
|
||||
@@ -367,13 +371,11 @@ async fn write_file_atomically(path: &Path, content: &str) -> Result<()> {
|
||||
.await
|
||||
.context("Failed to flush wallet file")?;
|
||||
drop(file);
|
||||
fs::rename(&tmp.0, path)
|
||||
.await
|
||||
.context("Failed to replace wallet file")?;
|
||||
fs::File::open(parent)
|
||||
.await?
|
||||
// Complete the commit without yielding: async filesystem work must not
|
||||
// rename an old purse after cancellation releases the mutation guard.
|
||||
std::fs::rename(&tmp.0, path).context("Failed to replace wallet file")?;
|
||||
std::fs::File::open(parent)?
|
||||
.sync_all()
|
||||
.await
|
||||
.context("Failed to flush wallet directory")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -856,7 +858,9 @@ pub async fn send_token_recoverable(
|
||||
// Establish recovery support before reserving or spending inputs.
|
||||
// Newly derived outputs must not already exist at the mint.
|
||||
let existing = client.restore_prepared_swap(&prepared).await.map_err(|_| {
|
||||
anyhow::anyhow!("The mint could not verify payment recovery support; no funds spent")
|
||||
anyhow::anyhow!(
|
||||
"The mint could not verify payment recovery support; no funds spent"
|
||||
)
|
||||
})?;
|
||||
anyhow::ensure!(
|
||||
existing.is_none(),
|
||||
@@ -1366,6 +1370,150 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Settle one caller-owned incoming token without losing an ambiguous mint
|
||||
/// response. Persist and reuse operation_id/context_hash for the same purchase.
|
||||
/// This is not a delivery receipt, refund authorization, or purchase protocol.
|
||||
pub async fn receive_token_recoverable(
|
||||
data_dir: &Path,
|
||||
operation_id: &str,
|
||||
network: EcashNetwork,
|
||||
mint_url: &str,
|
||||
token_str: &str,
|
||||
minimum_sats: u64,
|
||||
context_hash: &str,
|
||||
) -> Result<u64> {
|
||||
use super::receive_journal::{canonical_mint, Binding, Journal, Phase};
|
||||
use sha2::{Digest, Sha256};
|
||||
let held = super::mutation::guard(data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
load_network(data_dir).await? == network,
|
||||
"Switch back to the settlement's original network"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
token_str.len() <= 512 * 1024,
|
||||
"Incoming token exceeds settlement size limit"
|
||||
);
|
||||
let binding = Binding {
|
||||
id: operation_id.into(),
|
||||
network,
|
||||
mint_url: canonical_mint(mint_url)?,
|
||||
token_hash: hex::encode(Sha256::digest(token_str.as_bytes())),
|
||||
context_hash: context_hash.into(),
|
||||
minimum_sats,
|
||||
};
|
||||
binding.validate()?;
|
||||
let journal = Journal::new(&held);
|
||||
let previous = journal.load(operation_id).await?;
|
||||
let recovering = previous.is_some();
|
||||
let record = if let Some(record) = previous {
|
||||
anyhow::ensure!(
|
||||
record.binding == binding,
|
||||
"Settlement operation terms changed; do not redeem again"
|
||||
);
|
||||
record
|
||||
} else {
|
||||
let token = CashuToken::deserialize(token_str)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid incoming settlement token"))?;
|
||||
anyhow::ensure!(
|
||||
token.unit.as_deref().unwrap_or("sat") == "sat" && token.token.len() == 1,
|
||||
"Settlement requires one sat-denominated mint"
|
||||
);
|
||||
let entry = &token.token[0];
|
||||
anyhow::ensure!(
|
||||
canonical_mint(&entry.mint)? == binding.mint_url,
|
||||
"Incoming token mint does not match settlement terms"
|
||||
);
|
||||
let amount = entry
|
||||
.proofs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
|
||||
.context("Incoming amount overflow")?;
|
||||
anyhow::ensure!(
|
||||
amount >= minimum_sats
|
||||
&& entry
|
||||
.proofs
|
||||
.iter()
|
||||
.all(|proof| proof.amount.is_power_of_two()
|
||||
&& !proof.secret.is_empty()
|
||||
&& proof.c_as_pubkey().is_ok()),
|
||||
"Invalid incoming settlement proofs or amount"
|
||||
);
|
||||
journal
|
||||
.ensure_unclaimed(&binding.mint_url, &entry.proofs, Some(operation_id))
|
||||
.await?;
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
anyhow::ensure!(accepted.mints.iter().any(|mint| canonical_mint(mint).ok().as_deref() == Some(binding.mint_url.as_str())), "Settlement mint is not accepted");
|
||||
let wallet = load_wallet(data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
!entry
|
||||
.proofs
|
||||
.iter()
|
||||
.any(|proof| wallet.proofs.iter().any(|stored| !stored.spent
|
||||
&& canonical_mint(&stored.mint_url).ok().as_deref()
|
||||
== Some(binding.mint_url.as_str())
|
||||
&& stored.proof.secret == proof.secret)),
|
||||
"Incoming settlement overlaps existing wallet funds"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!wallet
|
||||
.receive_commits
|
||||
.contains_key(&format!("received:{operation_id}")),
|
||||
"Settlement marker exists without its recovery record; manual recovery required"
|
||||
);
|
||||
let client = mint_client(data_dir, &binding.mint_url).await?;
|
||||
let prepared = client.prepare_swap_at_least(&entry.proofs, &amount_to_denominations(amount), minimum_sats).await
|
||||
.map_err(|_| anyhow::anyhow!("Could not prepare a recoverable settlement covering the agreed net price; no proofs redeemed"))?;
|
||||
let existing = client.restore_prepared_swap(&prepared).await.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"The mint could not verify settlement recovery support; no proofs redeemed"
|
||||
)
|
||||
})?;
|
||||
anyhow::ensure!(
|
||||
existing.is_none(),
|
||||
"New settlement outputs already exist; no proofs redeemed"
|
||||
);
|
||||
journal.prepare(binding.clone(), prepared).await?
|
||||
};
|
||||
if !matches!(record.phase, Phase::Prepared) {
|
||||
return journal.commit_wallet(&binding).await;
|
||||
}
|
||||
let client = MintClient::new(&binding.mint_url)?;
|
||||
let restored = if recovering {
|
||||
client
|
||||
.restore_prepared_swap(&record.request)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Could not recover this settlement yet; retain the original operation"
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result = if let Some(result) = restored {
|
||||
result
|
||||
} else {
|
||||
if recovering {
|
||||
let states = client.check_state(record.request.inputs()).await
|
||||
.map_err(|_| anyhow::anyhow!("Could not verify incoming settlement inputs; do not redeem or refund again"))?;
|
||||
anyhow::ensure!(
|
||||
states.iter().all(|state| state.state == "UNSPENT"),
|
||||
"Incoming settlement remains pending at the mint; do not redeem or refund again"
|
||||
);
|
||||
}
|
||||
client
|
||||
.execute_prepared_swap(&record.request)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"The mint did not confirm settlement; retry this same operation to recover it"
|
||||
)
|
||||
})?
|
||||
};
|
||||
journal.record_result(&binding, result.new_proofs).await?;
|
||||
journal.commit_wallet(&binding).await
|
||||
}
|
||||
|
||||
/// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones.
|
||||
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
let _mutation = super::mutation::guard(data_dir).await?;
|
||||
@@ -1375,6 +1523,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
}
|
||||
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
for entry in &token.token {
|
||||
super::receive_journal::Journal::new(&_mutation)
|
||||
.ensure_unclaimed(&entry.mint, &entry.proofs, None)
|
||||
.await?;
|
||||
}
|
||||
let total_amount = token.total_amount();
|
||||
|
||||
if total_amount == 0 {
|
||||
@@ -1530,6 +1683,9 @@ pub async fn verify_and_receive_payment(
|
||||
[entry] => entry,
|
||||
_ => anyhow::bail!("Use a single-mint token for this payment"),
|
||||
};
|
||||
super::receive_journal::Journal::new(&_mutation)
|
||||
.ensure_unclaimed(&entry.mint, &entry.proofs, None)
|
||||
.await?;
|
||||
let total = entry
|
||||
.proofs
|
||||
.iter()
|
||||
@@ -1616,6 +1772,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?;
|
||||
super::receive_journal::Journal::new(&_mutation)
|
||||
.ensure_restore_allowed(load_network(data_dir).await?, mint_url)
|
||||
.await?;
|
||||
let outgoing = super::send_journal::Journal::new(&_mutation)
|
||||
.restore_exclusions(load_network(data_dir).await?, mint_url)
|
||||
.await?;
|
||||
|
||||
@@ -12,3 +12,4 @@ mod mutation;
|
||||
pub mod nut13;
|
||||
pub mod profits;
|
||||
mod send_journal;
|
||||
mod receive_journal;
|
||||
|
||||
@@ -729,16 +729,30 @@ async fn recoverable_send_rejects_broken_recovery_before_reserving_or_spending()
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
let error = send_token_recoverable(
|
||||
root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context,
|
||||
).await.unwrap_err();
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
4,
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("recovery support"));
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
// Once the mint responds correctly the same unspent operation can proceed.
|
||||
*mint.restore_reply.lock().unwrap() = None;
|
||||
assert!(send_token_recoverable(
|
||||
root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context,
|
||||
).await.is_ok());
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
4,
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.is_ok());
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
|
||||
}
|
||||
@@ -1016,3 +1030,547 @@ async fn incomplete_duplicate_or_unknown_restoration_never_completes_a_swap() {
|
||||
}
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recoverable_receive_lost_reply_claims_inputs_and_recovers_once() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let root = mint.wallet().await;
|
||||
let incoming = CashuToken::new(&mint.url, vec![proof(V2, 8), proof(ACTIVE, 4)]);
|
||||
let token = incoming.serialize_v4().unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
mint.lose_swap_reply
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
12,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||
assert!(restore_from_seed(root.path(), &mint.url)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("pending receipts"));
|
||||
for duplicate in [
|
||||
token.clone(),
|
||||
incoming.serialize().unwrap(),
|
||||
CashuToken::new(&mint.url, vec![proof(ACTIVE, 4), proof(ACTIVE, 2)])
|
||||
.serialize()
|
||||
.unwrap(),
|
||||
] {
|
||||
let other = uuid::Uuid::new_v4().to_string();
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&other,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&duplicate,
|
||||
1,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("another settlement"));
|
||||
}
|
||||
assert!(receive_token(root.path(), &token)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("another settlement"));
|
||||
*mint.restore_reply.lock().unwrap() = Some(json!({"outputs":[],"signatures":[]}));
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
12,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("pending at the mint"));
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
*mint.restore_reply.lock().unwrap() = None;
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
12,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
12
|
||||
);
|
||||
let wallet = load_wallet(root.path()).await.unwrap();
|
||||
assert_eq!(wallet.balance(), 12);
|
||||
assert_eq!(wallet.transactions.len(), 1);
|
||||
assert_eq!(wallet.transactions[0].id, format!("received:{id}"));
|
||||
assert_eq!(wallet.receive_commits.len(), 1);
|
||||
for output in &wallet.proofs {
|
||||
assert_eq!(
|
||||
output.proof.c,
|
||||
signed_point(bdhke::hash_to_curve(output.proof.secret.as_bytes()).unwrap())
|
||||
);
|
||||
assert!(!incoming.token[0]
|
||||
.proofs
|
||||
.iter()
|
||||
.any(|input| input.secret == output.proof.secret));
|
||||
}
|
||||
let before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
12,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
12
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
for (network, price, terms) in [
|
||||
(EcashNetwork::Testnet, 12, context.clone()),
|
||||
(EcashNetwork::Mainnet, 11, context.clone()),
|
||||
(EcashNetwork::Mainnet, 12, "cd".repeat(32)),
|
||||
] {
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
network,
|
||||
&mint.url,
|
||||
&token,
|
||||
price,
|
||||
&terms
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
// Completed receipts remain valid without re-crediting a pruned wallet.
|
||||
std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap();
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
12,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
12
|
||||
);
|
||||
assert!(!root.path().join("wallet/ecash.json").exists());
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&incoming.serialize().unwrap(),
|
||||
12,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recoverable_receive_recovery_support_fees_and_terms_fail_before_spend() {
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let root = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
*mint.restore_reply.lock().unwrap() = Some(json!({}));
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
7,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("recovery support"));
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert!(!root.path().join("wallet/receive-operations").exists());
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||
let mut foreign = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||
foreign.unit = Some("usd".into());
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&foreign.serialize().unwrap(),
|
||||
7,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
foreign.unit = Some("sat".into());
|
||||
foreign.token.push(foreign.token[0].clone());
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&foreign.serialize().unwrap(),
|
||||
7,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
*mint.restore_reply.lock().unwrap() = None;
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&format!("{}/", mint.url),
|
||||
&token,
|
||||
7,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 7);
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn rewrite_receive_phase(root: &std::path::Path, id: &str, phase: Value) {
|
||||
use sha2::{Digest, Sha256};
|
||||
let path = root.join(format!("wallet/receive-operations/{id}.json"));
|
||||
let mut envelope: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
let mut record: Value = serde_json::from_str(envelope["payload"].as_str().unwrap()).unwrap();
|
||||
record["phase"] = phase;
|
||||
let payload = serde_json::to_string(&record).unwrap();
|
||||
envelope["checksum"] = json!(hex::encode(Sha256::digest(payload.as_bytes())));
|
||||
envelope["payload"] = json!(payload);
|
||||
std::fs::write(path, serde_json::to_vec(&envelope).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recoverable_receive_commit_boundaries_survive_history_pruning_without_recredit() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mint = Mint::start(0, None).await;
|
||||
let root = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
let mut wallet = load_wallet(root.path()).await.unwrap();
|
||||
let proofs: Vec<_> = wallet.proofs.iter().map(|p| p.proof.clone()).collect();
|
||||
let committing =
|
||||
json!({"Committing":{"proofs":proofs,"before":hex::encode(Sha256::digest(b"missing"))}});
|
||||
let journal_path = root
|
||||
.path()
|
||||
.join(format!("wallet/receive-operations/{id}.json"));
|
||||
let committed_record = std::fs::read(&journal_path).unwrap();
|
||||
// Crash after purse save but before phase save; unrelated history pruning
|
||||
// preserves the durable marker and must not restore already-spent outputs.
|
||||
rewrite_receive_phase(root.path(), &id, committing.clone());
|
||||
wallet.transactions.clear();
|
||||
wallet.proofs.clear();
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let purse = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||
purse
|
||||
);
|
||||
// Old writers dropping the marker cause a recovery hold, never a guessed credit.
|
||||
rewrite_receive_phase(root.path(), &id, committing.clone());
|
||||
wallet.receive_commits.clear();
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("manual recovery"));
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||
// Crash before the purse save: exact absent pre-image authorizes first commit.
|
||||
std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap();
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
// Completed receipt survives a missing purse without rebuilding its proofs.
|
||||
std::fs::write(journal_path, committed_record).unwrap();
|
||||
std::fs::remove_file(root.path().join("wallet/ecash.json")).unwrap();
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
assert!(!root.path().join("wallet/ecash.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recoverable_receive_corrupt_claims_and_write_failures_preserve_funds() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let root = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
// A directory at the target blocks the private journal replacement before POST.
|
||||
let path = root
|
||||
.path()
|
||||
.join(format!("wallet/receive-operations/{id}.json"));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
std::fs::remove_dir(&path).unwrap();
|
||||
mint.lose_swap_reply
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(path.parent().unwrap())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
}
|
||||
std::fs::write(&path, b"damaged").unwrap();
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(receive_token(root.path(), &token).await.is_err());
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
std::fs::write(&path, saved).unwrap();
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recoverable_receive_unspent_retry_reuses_exact_request_and_waits_for_verified_state() {
|
||||
let mint = Mint::start(0, Some(503)).await;
|
||||
let root = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
// Legacy paid-content redemption must respect claims even while mint inputs
|
||||
// remain unspent; otherwise it could steal the pending operation's proofs.
|
||||
assert!(verify_and_receive_payment(root.path(), &token, 8)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("another settlement"));
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
let original = mint.requests.lock().unwrap()[0].clone();
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||
// An empty state response must not authorize a second POST.
|
||||
*mint.state_reply.lock().unwrap() = Some(json!({"states":[]}));
|
||||
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
*mint.state_reply.lock().unwrap() = None;
|
||||
assert_eq!(
|
||||
receive_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
&token,
|
||||
8,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
let requests = mint.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1], original);
|
||||
drop(requests);
|
||||
let wallet = load_wallet(root.path()).await.unwrap();
|
||||
assert_eq!(wallet.balance(), 8);
|
||||
assert_eq!(wallet.transactions.len(), 1);
|
||||
assert_eq!(wallet.receive_commits.len(), 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Private incoming-proof claims and recoverable settlement. Incoming bearer
|
||||
//! proofs never become spendable locally: only fresh, saved swap outputs do.
|
||||
use super::{
|
||||
cashu::Proof, ecash::EcashNetwork, mint_client::PreparedSwap, mutation::WalletMutation,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
use tokio::{
|
||||
fs,
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
};
|
||||
const MAX_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
pub(super) fn canonical_mint(value: &str) -> Result<String> {
|
||||
let url = reqwest::Url::parse(value).context("Invalid settlement mint")?;
|
||||
anyhow::ensure!(
|
||||
matches!(url.scheme(), "http" | "https")
|
||||
&& url.host_str().is_some()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none(),
|
||||
"Invalid settlement mint URL"
|
||||
);
|
||||
Ok(url.to_string().trim_end_matches('/').to_owned())
|
||||
}
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(bytes))
|
||||
}
|
||||
fn is_hash(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Binding {
|
||||
pub id: String,
|
||||
pub network: EcashNetwork,
|
||||
pub mint_url: String,
|
||||
pub token_hash: String,
|
||||
pub context_hash: String,
|
||||
pub minimum_sats: u64,
|
||||
}
|
||||
impl Binding {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
uuid::Uuid::parse_str(&self.id)
|
||||
.ok()
|
||||
.is_some_and(|id| id.to_string() == self.id),
|
||||
"Invalid settlement identifier"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
self.minimum_sats > 0 && is_hash(&self.token_hash) && is_hash(&self.context_hash),
|
||||
"Invalid settlement terms"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
canonical_mint(&self.mint_url)? == self.mint_url,
|
||||
"Settlement mint is not canonical"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn history_id(&self) -> String {
|
||||
format!("received:{}", self.id)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub(super) enum Phase {
|
||||
Prepared,
|
||||
Result(Vec<Proof>),
|
||||
Committing {
|
||||
proofs: Vec<Proof>,
|
||||
before: String,
|
||||
},
|
||||
Committed {
|
||||
amount_sats: u64,
|
||||
commitment: String,
|
||||
},
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Record {
|
||||
pub binding: Binding,
|
||||
pub request: PreparedSwap,
|
||||
pub phase: Phase,
|
||||
}
|
||||
impl std::fmt::Debug for Record {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ReceiveJournalRecord")
|
||||
.field("id", &self.binding.id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Envelope {
|
||||
version: u8,
|
||||
payload: String,
|
||||
checksum: String,
|
||||
}
|
||||
pub(super) struct Journal<'a> {
|
||||
guard: &'a WalletMutation,
|
||||
}
|
||||
impl<'a> Journal<'a> {
|
||||
pub fn new(guard: &'a WalletMutation) -> Self {
|
||||
Self { guard }
|
||||
}
|
||||
fn directory(&self) -> PathBuf {
|
||||
self.guard.data_dir.join("wallet/receive-operations")
|
||||
}
|
||||
fn path(&self, id: &str) -> Result<PathBuf> {
|
||||
let uuid = uuid::Uuid::parse_str(id).context("Invalid settlement identifier")?;
|
||||
anyhow::ensure!(
|
||||
uuid.to_string() == id,
|
||||
"Settlement identifier is not canonical"
|
||||
);
|
||||
Ok(self.directory().join(format!("{uuid}.json")))
|
||||
}
|
||||
fn validate(record: &Record) -> Result<()> {
|
||||
record.binding.validate()?;
|
||||
record.request.validate_for_mint(&record.binding.mint_url)?;
|
||||
anyhow::ensure!(
|
||||
record.request.covers_payment(record.binding.minimum_sats),
|
||||
"Settlement does not cover the agreed price"
|
||||
);
|
||||
match &record.phase {
|
||||
Phase::Prepared => (),
|
||||
Phase::Result(proofs) => {
|
||||
Self::validate_result(record, proofs)?;
|
||||
}
|
||||
Phase::Committing { proofs, before } => {
|
||||
Self::validate_result(record, proofs)?;
|
||||
anyhow::ensure!(is_hash(before), "Invalid settlement purse boundary");
|
||||
}
|
||||
Phase::Committed {
|
||||
amount_sats,
|
||||
commitment,
|
||||
} => {
|
||||
anyhow::ensure!(
|
||||
*amount_sats >= record.binding.minimum_sats
|
||||
&& record.request.covers_payment(*amount_sats)
|
||||
&& (amount_sats
|
||||
.checked_add(1)
|
||||
.is_none_or(|next| !record.request.covers_payment(next)))
|
||||
&& is_hash(commitment),
|
||||
"Invalid committed settlement"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_result(record: &Record, proofs: &[Proof]) -> Result<u64> {
|
||||
record.request.validate_result_proofs(proofs)?;
|
||||
let amount = proofs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
|
||||
.context("Settlement amount overflow")?;
|
||||
anyhow::ensure!(
|
||||
amount >= record.binding.minimum_sats,
|
||||
"Settlement does not cover the agreed price"
|
||||
);
|
||||
Ok(amount)
|
||||
}
|
||||
pub async fn load(&self, id: &str) -> Result<Option<Record>> {
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
|
||||
let file = match options.open(self.path(id)?).await {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e).context("Could not read settlement recovery"),
|
||||
};
|
||||
anyhow::ensure!(
|
||||
file.metadata().await?.is_file(),
|
||||
"Settlement record is not a regular file"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).await?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() as u64 <= MAX_BYTES,
|
||||
"Settlement recovery exceeds its size limit"
|
||||
);
|
||||
let envelope: Envelope = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| anyhow::anyhow!("Settlement recovery is damaged; do not redeem again"))?;
|
||||
anyhow::ensure!(
|
||||
envelope.version == 1 && envelope.checksum == digest(envelope.payload.as_bytes()),
|
||||
"Settlement recovery checksum/version failed"
|
||||
);
|
||||
let record: Record = serde_json::from_str(&envelope.payload)
|
||||
.map_err(|_| anyhow::anyhow!("Settlement recovery contents are damaged"))?;
|
||||
anyhow::ensure!(record.binding.id == id, "Settlement identity mismatch");
|
||||
Self::validate(&record)?;
|
||||
Ok(Some(record))
|
||||
}
|
||||
async fn records(&self) -> Result<Vec<Record>> {
|
||||
let mut directory = match fs::read_dir(self.directory()).await {
|
||||
Ok(directory) => directory,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(e) => return Err(e).context("Cannot inspect incoming settlement claims"),
|
||||
};
|
||||
let mut records = Vec::new();
|
||||
while let Some(entry) = directory.next_entry().await? {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_str().context("Invalid settlement filename")?;
|
||||
if name
|
||||
.strip_prefix('.')
|
||||
.and_then(|name| name.strip_suffix(".tmp"))
|
||||
.is_some_and(|id| uuid::Uuid::parse_str(id).is_ok())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let id = name
|
||||
.strip_suffix(".json")
|
||||
.context("Unexpected settlement recovery entry")?;
|
||||
records.push(
|
||||
self.load(id)
|
||||
.await?
|
||||
.context("Settlement recovery disappeared")?,
|
||||
);
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
/// Claims are based on proof secrets, not token serialization/keyset aliases.
|
||||
/// A partial overlap must not be treated as a new payment or a refund.
|
||||
pub async fn ensure_unclaimed(
|
||||
&self,
|
||||
mint_url: &str,
|
||||
inputs: &[Proof],
|
||||
owner: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mint = canonical_mint(mint_url)?;
|
||||
let secrets: HashSet<_> = inputs
|
||||
.iter()
|
||||
.map(|proof| digest(proof.secret.as_bytes()))
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
secrets.len() == inputs.len() && !inputs.is_empty(),
|
||||
"Duplicate or missing settlement inputs"
|
||||
);
|
||||
for record in self.records().await? {
|
||||
if record.binding.mint_url != mint || owner == Some(record.binding.id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
anyhow::ensure!(!record.request.inputs().iter().any(|proof| secrets.contains(&digest(proof.secret.as_bytes()))), "These incoming proofs already belong to another settlement; resume its original operation");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn ensure_restore_allowed(
|
||||
&self,
|
||||
network: EcashNetwork,
|
||||
mint_url: &str,
|
||||
) -> Result<()> {
|
||||
let mint = canonical_mint(mint_url)?;
|
||||
for record in self.records().await? {
|
||||
if record.binding.network == network && record.binding.mint_url == mint {
|
||||
anyhow::ensure!(
|
||||
matches!(record.phase, Phase::Committed { .. }),
|
||||
"Recover pending receipts before restoring this mint from the backup phrase"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn prepare(&self, binding: Binding, request: PreparedSwap) -> Result<Record> {
|
||||
binding.validate()?;
|
||||
if let Some(previous) = self.load(&binding.id).await? {
|
||||
anyhow::ensure!(
|
||||
previous.binding == binding,
|
||||
"Settlement operation terms changed"
|
||||
);
|
||||
return Ok(previous);
|
||||
}
|
||||
self.ensure_unclaimed(&binding.mint_url, request.inputs(), Some(&binding.id))
|
||||
.await?;
|
||||
let record = Record {
|
||||
binding,
|
||||
request,
|
||||
phase: Phase::Prepared,
|
||||
};
|
||||
Self::validate(&record)?;
|
||||
self.write(&record).await?;
|
||||
Ok(record)
|
||||
}
|
||||
async fn bound(&self, binding: &Binding) -> Result<Record> {
|
||||
let record = self
|
||||
.load(&binding.id)
|
||||
.await?
|
||||
.context("Settlement recovery is missing")?;
|
||||
anyhow::ensure!(
|
||||
&record.binding == binding,
|
||||
"Settlement operation terms changed"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
super::ecash::load_network(&self.guard.data_dir).await? == binding.network,
|
||||
"Switch back to the settlement's original network"
|
||||
);
|
||||
Ok(record)
|
||||
}
|
||||
pub async fn record_result(&self, binding: &Binding, proofs: Vec<Proof>) -> Result<()> {
|
||||
let mut record = self.bound(binding).await?;
|
||||
Self::validate_result(&record, &proofs)?;
|
||||
match &record.phase {
|
||||
Phase::Prepared => record.phase = Phase::Result(proofs),
|
||||
Phase::Result(saved) | Phase::Committing { proofs: saved, .. } => {
|
||||
anyhow::ensure!(
|
||||
serde_json::to_vec(saved)? == serde_json::to_vec(&proofs)?,
|
||||
"Settlement already has a different result"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Phase::Committed { .. } => anyhow::bail!("Settlement already committed"),
|
||||
}
|
||||
self.write(&record).await
|
||||
}
|
||||
async fn purse_snapshot(&self, network: EcashNetwork) -> Result<String> {
|
||||
// Hash exact on-disk bytes, not a reserialized WalletState. Absence and
|
||||
// empty file are deliberately different (empty fails wallet loading).
|
||||
match fs::read(self.guard.data_dir.join(network.wallet_file())).await {
|
||||
Ok(bytes) => {
|
||||
let mut tagged = b"existing:".to_vec();
|
||||
tagged.extend(bytes);
|
||||
Ok(digest(&tagged))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(digest(b"missing")),
|
||||
Err(e) => Err(e).context("Cannot establish settlement purse boundary"),
|
||||
}
|
||||
}
|
||||
pub async fn commit_wallet(&self, binding: &Binding) -> Result<u64> {
|
||||
use super::ecash::{load_wallet, save_wallet, TransactionType};
|
||||
let mut record = self.bound(binding).await?;
|
||||
if let Phase::Committed { amount_sats, .. } = record.phase {
|
||||
return Ok(amount_sats);
|
||||
}
|
||||
let mut wallet = load_wallet(&self.guard.data_dir).await?;
|
||||
let (proofs, before) = match record.phase.clone() {
|
||||
Phase::Prepared => anyhow::bail!("Settlement result is not durable yet"),
|
||||
Phase::Result(proofs) => {
|
||||
let before = self.purse_snapshot(binding.network).await?;
|
||||
record.phase = Phase::Committing {
|
||||
proofs: proofs.clone(),
|
||||
before: before.clone(),
|
||||
};
|
||||
self.write(&record).await?;
|
||||
(proofs, before)
|
||||
}
|
||||
Phase::Committing { proofs, before } => (proofs, before),
|
||||
Phase::Committed { .. } => unreachable!(),
|
||||
};
|
||||
let amount = Self::validate_result(&record, &proofs)?;
|
||||
let commitment = digest(&serde_json::to_vec(&(binding, amount, &proofs))?);
|
||||
let history_id = binding.history_id();
|
||||
if let Some(marker) = wallet.receive_commits.get(&history_id) {
|
||||
anyhow::ensure!(
|
||||
is_hash(marker) && *marker == commitment,
|
||||
"Settlement purse marker does not match; manual recovery required"
|
||||
);
|
||||
} else {
|
||||
anyhow::ensure!(
|
||||
self.purse_snapshot(binding.network).await? == before,
|
||||
"Settlement purse changed without its commit marker; manual recovery required"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!wallet.transactions.iter().any(|tx| tx.id == history_id),
|
||||
"Settlement history exists without its commit marker; manual recovery required"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!proofs
|
||||
.iter()
|
||||
.any(|proof| wallet.proofs.iter().any(|stored| canonical_mint(
|
||||
&stored.mint_url
|
||||
)
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(binding.mint_url.as_str())
|
||||
&& stored.proof.secret == proof.secret)),
|
||||
"Settlement output exists without its commit marker; manual recovery required"
|
||||
);
|
||||
wallet.add_proofs(&binding.mint_url, proofs);
|
||||
wallet.record_tx(
|
||||
TransactionType::Receive,
|
||||
amount,
|
||||
"Received ecash",
|
||||
&binding.mint_url,
|
||||
"",
|
||||
);
|
||||
wallet
|
||||
.transactions
|
||||
.last_mut()
|
||||
.context("Could not record settlement history")?
|
||||
.id = history_id.clone();
|
||||
wallet
|
||||
.receive_commits
|
||||
.insert(history_id, commitment.clone());
|
||||
save_wallet(&self.guard.data_dir, &wallet).await?;
|
||||
}
|
||||
record.phase = Phase::Committed {
|
||||
amount_sats: amount,
|
||||
commitment,
|
||||
};
|
||||
self.write(&record).await?;
|
||||
Ok(amount)
|
||||
}
|
||||
async fn write(&self, record: &Record) -> Result<()> {
|
||||
Self::validate(record)?;
|
||||
let payload = serde_json::to_string(record)?;
|
||||
let bytes = serde_json::to_vec(&Envelope {
|
||||
version: 1,
|
||||
checksum: digest(payload.as_bytes()),
|
||||
payload,
|
||||
})?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() as u64 <= MAX_BYTES,
|
||||
"Settlement recovery exceeds its size limit"
|
||||
);
|
||||
let parent = self.directory();
|
||||
fs::create_dir_all(&parent).await?;
|
||||
anyhow::ensure!(
|
||||
fs::symlink_metadata(&parent).await?.is_dir(),
|
||||
"Settlement directory is not a regular directory"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).await?;
|
||||
}
|
||||
struct Temporary(PathBuf);
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
let temporary = Temporary(parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())));
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
let mut file = options.open(&temporary.0).await?;
|
||||
file.write_all(&bytes).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
// No asynchronous commit may outlive the wallet mutation guard.
|
||||
std::fs::rename(&temporary.0, self.path(&record.binding.id)?)?;
|
||||
for directory in [
|
||||
parent,
|
||||
self.guard.data_dir.join("wallet"),
|
||||
self.guard.data_dir.clone(),
|
||||
] {
|
||||
std::fs::File::open(directory)?.sync_all()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -979,14 +979,16 @@ impl<'a> Journal<'a> {
|
||||
file.write_all(&bytes).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
fs::rename(&temporary.0, &path).await?;
|
||||
// Keep the commit synchronous under the mutation guard: cancellation
|
||||
// cannot leave a rename queued after a newer wallet mutation starts.
|
||||
std::fs::rename(&temporary.0, &path)?;
|
||||
// Persist every new directory entry down from the existing node root.
|
||||
for directory in [
|
||||
parent.to_path_buf(),
|
||||
self.guard.data_dir.join("wallet"),
|
||||
self.guard.data_dir.clone(),
|
||||
] {
|
||||
fs::File::open(directory).await?.sync_all().await?;
|
||||
std::fs::File::open(directory)?.sync_all()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user