Files
archy/core/archipelago/src/wallet/receive_journal.rs
T

457 lines
17 KiB
Rust

//! 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(())
}
}