Integrate recoverable native purchases, registered rentals and explicit payment consent

This commit is contained in:
archipelago
2026-10-06 22:44:06 -04:00
parent e4eae71314
commit 49703d7e88
63 changed files with 8028 additions and 134 deletions
+72 -1
View File
@@ -450,16 +450,26 @@ pub(super) struct Outcome {
#[derive(Clone, Serialize, Deserialize)]
pub(super) enum Phase {
Prepared,
Cancelled,
Result(Outcome),
Committed(Outcome),
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(super) enum DispatchState {
#[default]
Unknown,
NotDispatched,
Started,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Record {
pub binding: Binding,
pub request: Request,
pub phase: Phase,
#[serde(default)]
pub dispatch: DispatchState,
}
impl std::fmt::Debug for Record {
@@ -528,6 +538,9 @@ impl<'a> Journal<'a> {
{
continue;
}
if matches!(record.phase, Phase::Cancelled) {
continue;
}
let Phase::Committed(outcome) = record.phase else {
anyhow::bail!(
"Recover pending payments before restoring this mint from the backup phrase"
@@ -634,6 +647,7 @@ impl<'a> Journal<'a> {
use super::ecash::TransactionType;
let record = self.bound_record(binding).await?;
let (outcome, committed) = match &record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
Phase::Result(outcome) => (outcome, false),
Phase::Committed(outcome) => (outcome, true),
@@ -867,7 +881,7 @@ impl<'a> Journal<'a> {
Self::validate_binding(&record.binding)?;
Self::validate_request(&record.binding, &record.request)?;
match &record.phase {
Phase::Prepared => (),
Phase::Prepared | Phase::Cancelled => (),
Phase::Result(outcome) | Phase::Committed(outcome) => {
Self::validate_outcome(&record, outcome)?
}
@@ -895,6 +909,7 @@ impl<'a> Journal<'a> {
binding,
request,
phase: Phase::Prepared,
dispatch: DispatchState::NotDispatched,
};
self.write(&record).await?;
Ok(record)
@@ -911,6 +926,7 @@ impl<'a> Journal<'a> {
);
Self::validate_outcome(&record, &outcome)?;
match &record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => record.phase = Phase::Result(outcome),
Phase::Result(previous) | Phase::Committed(previous) => {
anyhow::ensure!(
@@ -935,6 +951,7 @@ impl<'a> Journal<'a> {
"Payment operation terms changed"
);
record.phase = match record.phase {
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
Phase::Result(outcome) | Phase::Committed(outcome) => Phase::Committed(outcome),
};
@@ -942,6 +959,60 @@ impl<'a> Journal<'a> {
Ok(record)
}
/// Must finish durably immediately before every fresh mint POST, under the
/// same wallet mutation guard as cancellation and input reservation.
pub async fn mark_dispatched(&self, binding: &Binding) -> Result<()> {
let mut record = self.bound_record(binding).await?;
anyhow::ensure!(
matches!(record.phase, Phase::Prepared),
"Payment cannot be dispatched in this phase"
);
record.dispatch = DispatchState::Started;
self.write(&record).await
}
/// A terminal local seal precedes release. No mint state query can prove an
/// ambiguous old POST will not finish later, so such swaps are never released.
pub async fn cancel_unspent(&self, binding: &Binding) -> Result<()> {
let mut record = self.bound_record(binding).await?;
if !matches!(record.phase, Phase::Cancelled) {
anyhow::ensure!(
matches!(record.phase, Phase::Prepared),
"A prepared token/result cannot be cancelled as unspent"
);
anyhow::ensure!(
matches!(record.request, Request::Exact { .. })
|| record.dispatch == DispatchState::NotDispatched,
"Mint dispatch is possible; recover original results instead of cancelling"
);
record.phase = Phase::Cancelled;
self.write(&record).await?;
}
let mut wallet = super::ecash::load_wallet(&self.guard.data_dir).await?;
let mut changed = false;
for stored in &mut wallet.proofs {
if stored.reserved_by.as_deref() != Some(binding.id.as_str()) {
continue;
}
anyhow::ensure!(
Self::inputs(&record.request)
.iter()
.any(|input| input.secret == stored.proof.secret
&& input.amount == stored.proof.amount
&& input.id == stored.proof.id
&& input.c == stored.proof.c),
"Cancellation reservation differs from original inputs"
);
anyhow::ensure!(!stored.spent, "Cancellation cannot restore spent inputs");
stored.reserved = false;
stored.reserved_by = None;
changed = true;
}
if changed {
super::ecash::save_wallet(&self.guard.data_dir, &wallet).await?;
}
Ok(())
}
async fn write(&self, record: &Record) -> Result<()> {
let payload = serde_json::to_string(record)?;
let checksum = hex::encode(Sha256::digest(payload.as_bytes()));