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
@@ -0,0 +1,264 @@
//! Local-only deterministic payment planning. No swap POST or reservation here.
//! Serialize this object only into the private buyer journal, never onto the wire.
use super::{
cashu::{KeysetInfo, Proof},
ecash,
mint_client::MintClient,
mutation,
purchase_fee_plan::{FeePlan, KeysetPlan},
send_journal::{Binding, Journal, Request},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::Path};
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct PreparedPayment {
pub fee_plan: FeePlan,
/// Includes any buyer funding-swap input fee, distinct from seller redemption.
pub wallet_debit_sats: u64,
request: Request,
}
fn exact_shape(proofs: &[Proof], keysets: &[KeysetInfo]) -> Result<Vec<KeysetPlan>> {
let mut groups: BTreeMap<String, KeysetPlan> = BTreeMap::new();
for proof in proofs {
let matches: Vec<_> = keysets
.iter()
.filter(|keyset| super::cashu::matches_stored_keyset_id(&proof.id, &keyset.id))
.collect();
anyhow::ensure!(matches.len() == 1, "Missing or ambiguous payment keyset");
let keyset = matches[0];
anyhow::ensure!(keyset.unit == "sat", "Payment keyset has another unit");
groups
.entry(keyset.id.to_ascii_lowercase())
.or_insert_with(|| KeysetPlan {
keyset_id: keyset.id.to_ascii_lowercase(),
denominations: vec![],
input_fee_ppk: keyset.input_fee_ppk,
})
.denominations
.push(proof.amount);
}
Ok(groups.into_values().collect())
}
/// Quote at most 1024 gross amounts. Failure is explicit; never silently increase
/// the user-approved debit limit or substitute another mint/network.
pub(crate) async fn prepare(
data_dir: &Path,
mint: &str,
expected_network: ecash::EcashNetwork,
seller_net_sats: u64,
max_wallet_debit: u64,
) -> Result<PreparedPayment> {
anyhow::ensure!(
seller_net_sats > 0 && max_wallet_debit >= seller_net_sats,
"Invalid payment budget"
);
let _held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == expected_network,
"Offer uses another wallet network; no intent was created"
);
let wallet = ecash::load_wallet(data_dir).await?;
anyhow::ensure!(
wallet.select_proofs(mint, seller_net_sats).is_some(),
"No spendable balance at the seller's mint; no intent was created"
);
let client =
MintClient::new(mint)?.with_recovery(super::nut13::RecoverySource::load(data_dir).await?);
let keysets = client.get_keysets().await?;
let active = client.get_active_sat_keyset().await?;
let active_fee: Vec<_> = keysets
.iter()
.filter(|keyset| keyset.id == active.id && keyset.active && keyset.unit == "sat")
.collect();
anyhow::ensure!(
active_fee.len() == 1,
"Active payment keyset is not uniquely bound"
);
for additional in 0..1024u64 {
let gross = seller_net_sats
.checked_add(additional)
.context("Payment amount overflow")?;
if gross > max_wallet_debit {
break;
}
let Some((indices, excess)) = wallet.select_proofs(mint, gross) else {
break;
};
let proofs: Vec<_> = indices
.iter()
.map(|&index| wallet.proofs[index].proof.clone())
.collect();
let input_shape = exact_shape(&proofs, &keysets)?;
let input_ppk = input_shape
.iter()
.try_fold(0u64, |total, group| {
total.checked_add(
group
.input_fee_ppk
.checked_mul(group.denominations.len() as u64)?,
)
})
.context("Funding fee overflow")?;
let quoted_funding_fee = input_ppk.checked_add(999).context("Funding fee overflow")? / 1000;
if excess > 0
&& (quoted_funding_fee > excess
|| gross
.checked_add(quoted_funding_fee)
.is_none_or(|debit| debit > max_wallet_debit))
{
continue;
}
let shape = if excess == 0 {
input_shape
} else {
vec![KeysetPlan {
keyset_id: active.id.to_ascii_lowercase(),
denominations: super::cashu::amount_to_denominations(gross),
input_fee_ppk: active_fee[0].input_fee_ppk,
}]
};
let ppk = shape
.iter()
.try_fold(0u64, |total, group| {
total.checked_add(
group
.input_fee_ppk
.checked_mul(group.denominations.len() as u64)?,
)
})
.context("Quoted fee overflow")?;
let fee = ppk.checked_add(999).context("Quoted fee overflow")? / 1000;
if gross <= fee || gross - fee < seller_net_sats {
continue;
}
let fee_plan = FeePlan::from_shape(mint, shape)?;
if fee_plan.net_sats < seller_net_sats {
continue;
}
let (request, funding_fee) = if excess == 0 {
(Request::Exact { proofs }, 0)
} else {
let mut amounts = super::cashu::amount_to_denominations(gross);
amounts.extend(super::cashu::amount_to_denominations(excess));
let prepared = client
.prepare_swap_at_least(&proofs, &amounts, gross)
.await?;
anyhow::ensure!(
prepared.payment_keyset_id() == active.id,
"Mint rotated while planning; refresh the offer"
);
let fee = prepared.input_fee_sats()?;
anyhow::ensure!(
fee == quoted_funding_fee,
"Mint funding fee changed while planning; refresh before acceptance"
);
anyhow::ensure!(
client.restore_prepared_swap(&prepared).await?.is_none(),
"Planned outputs already exist; recover the previous operation"
);
(Request::Swap(prepared), fee)
};
let debit = gross
.checked_add(funding_fee)
.context("Payment debit overflow")?;
anyhow::ensure!(
debit <= max_wallet_debit,
"Funding fee exceeds the approved debit limit"
);
return Ok(PreparedPayment {
fee_plan,
wallet_debit_sats: debit,
request,
});
}
anyhow::bail!("No bounded payment plan covers the seller price within the approved debit limit")
}
/// Must precede authenticated seller acceptance. This durably pins the exact
/// inputs/outputs without reserving or spending; stale inputs later reject.
pub(crate) async fn persist(
data_dir: &Path,
contract: &crate::content_purchase::Contract,
plan: &PreparedPayment,
) -> Result<()> {
contract.validate()?;
anyhow::ensure!(
plan.fee_plan.mint_url == contract.mint_url
&& plan.fee_plan.gross_sats == contract.gross_token_sats
&& plan.fee_plan.net_sats >= contract.minimum_net_sats,
"Wallet plan changed purchase amounts"
);
let held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == contract.network,
"Purchase wallet network changed"
);
let binding = Binding {
id: contract.id.clone(),
network: contract.network,
mint_url: contract.mint_url.clone(),
amount_sats: contract.gross_token_sats,
context_hash: contract.context_hash()?,
};
let journal = Journal::new(&held);
if let Some(existing) = journal.load(&contract.id).await? {
anyhow::ensure!(
existing.binding == binding
&& serde_json::to_value(&existing.request)? == serde_json::to_value(&plan.request)?,
"Original wallet preparation changed"
);
}
if journal.load(&contract.id).await?.is_none() {
let buyer = crate::content_purchase::Journal::open(data_dir)
.await?
.buyer(&contract.id)
.await?
.context("Buyer intent is missing")?;
anyhow::ensure!(buyer.phase == crate::content_purchase::BuyerPhase::Intent,
"Accepted operation lost its wallet journal; no new preparation or cancellation is allowed");
}
journal.prepare(binding, plan.request.clone()).await?;
Ok(())
}
/// Seal before contacting seller. Repeating after a lost seller reply is safe.
pub(crate) async fn cancel_unspent(
data_dir: &Path,
contract: &crate::content_purchase::Contract,
plan: &PreparedPayment,
) -> Result<()> {
persist(data_dir, contract, plan).await?;
let held = mutation::guard(data_dir).await?;
let binding = Binding {
id: contract.id.clone(),
network: contract.network,
mint_url: contract.mint_url.clone(),
amount_sats: contract.gross_token_sats,
context_hash: contract.context_hash()?,
};
Journal::new(&held).cancel_unspent(&binding).await
}
/// Publish the buyer intent while holding the wallet network mutation guard, so
/// a concurrent network switch cannot strand a newly published wrong-network row.
pub(crate) async fn persist_intent(
data_dir: &Path,
envelope: &crate::content_purchase_protocol::Envelope,
plan: &PreparedPayment,
) -> Result<()> {
let contract = envelope.contract()?;
anyhow::ensure!(plan.fee_plan == envelope.fee_plan, "Buyer fee plan changed");
let _held = mutation::guard(data_dir).await?;
anyhow::ensure!(
ecash::load_network(data_dir).await? == contract.network,
"Offer uses another wallet network; no intent was created"
);
let journal = crate::content_purchase::Journal::open(data_dir).await?;
journal.save_buyer_plan(&contract.id, plan).await?;
journal.save_protocol_envelope("buyer", envelope).await?;
journal
.prepare_buyer(&contract, chrono::Utc::now().timestamp())
.await?;
Ok(())
}