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
+369 -9
View File
@@ -39,7 +39,7 @@ fn validate_id(id: &str) -> Result<()> {
);
Ok(())
}
fn canonical_mint(value: &str) -> Result<String> {
pub(crate) fn canonical_mint(value: &str) -> Result<String> {
let url = reqwest::Url::parse(value).context("Invalid purchase mint")?;
anyhow::ensure!(
matches!(url.scheme(), "http" | "https")
@@ -228,6 +228,8 @@ impl PreparedToken {
pub(crate) enum BuyerPhase {
Intent,
AcceptanceSaved,
CancellationPending,
Cancelled,
TokenPrepared,
ReceiptSaved,
Delivered,
@@ -245,6 +247,8 @@ impl BuyerRecord {
pub fn public_status(&self) -> serde_json::Value {
let (state, settlement_confirmed, delivered) = match self.phase {
BuyerPhase::Intent => ("intent", false, false),
BuyerPhase::CancellationPending => ("cancellation_pending_seller", false, false),
BuyerPhase::Cancelled => ("cancelled_unspent", false, false),
BuyerPhase::AcceptanceSaved => ("accepted_payment_unconfirmed", false, false),
BuyerPhase::TokenPrepared => ("token_prepared_settlement_unconfirmed", false, false),
BuyerPhase::ReceiptSaved => ("settled_delivery_pending", true, false),
@@ -260,8 +264,8 @@ impl BuyerRecord {
"settlement_confirmed": settlement_confirmed,
"amount_received": self.receipt().map(|receipt| receipt.amount_received),
"delivered": delivered,
"recovery_required": !delivered,
"can_start_new_payment": false,
"recovery_required": !delivered && self.phase != BuyerPhase::Cancelled,
"can_start_new_payment": self.phase == BuyerPhase::Cancelled,
})
}
@@ -284,6 +288,8 @@ impl BuyerRecord {
}
anyhow::ensure!(
match self.phase {
BuyerPhase::CancellationPending | BuyerPhase::Cancelled =>
self.token.is_none() && self.receipt.is_none(),
BuyerPhase::Intent =>
self.acceptance.is_none() && self.token.is_none() && self.receipt.is_none(),
BuyerPhase::AcceptanceSaved =>
@@ -302,6 +308,7 @@ impl BuyerRecord {
#[serde(deny_unknown_fields)]
pub(crate) enum SellerPhase {
Intent,
Cancelled,
Settled { amount_received: u64 },
ReceiptSaved(Receipt),
}
@@ -315,6 +322,10 @@ pub(crate) struct SellerRecord {
}
impl SellerRecord {
pub fn acceptance(&self) -> Result<Acceptance> {
anyhow::ensure!(
!matches!(self.phase, SellerPhase::Cancelled),
"Seller cancelled this operation"
);
Ok(Acceptance {
contract_hash: self.contract.context_hash()?,
accepted_at: self.accepted_at,
@@ -322,15 +333,22 @@ impl SellerRecord {
}
fn validate(&self) -> Result<()> {
self.contract.validate()?;
self.acceptance()?.validate(&self.contract)?;
if !matches!(self.phase, SellerPhase::Cancelled) {
self.acceptance()?.validate(&self.contract)?;
}
if let Some(token_hash) = &self.token_hash {
anyhow::ensure!(valid_hash(token_hash), "Invalid seller token hash");
}
anyhow::ensure!(
matches!(self.phase, SellerPhase::Intent) || self.token_hash.is_some(),
matches!(self.phase, SellerPhase::Intent | SellerPhase::Cancelled)
|| self.token_hash.is_some(),
"Seller token was not durably bound"
);
match &self.phase {
SellerPhase::Cancelled => anyhow::ensure!(
self.token_hash.is_none(),
"Cancelled seller already has a token"
),
SellerPhase::Intent => (),
SellerPhase::Settled { amount_received } => {
anyhow::ensure!(
@@ -355,6 +373,13 @@ struct Envelope {
/// Exclusive journal access across tasks and processes. Hold this only while
/// changing local purchase state; release it before transport/wallet calls.
/// A later caller reopens and revalidates the immutable contract before advancing.
#[derive(Serialize, Deserialize)]
struct RetiredOffer {
id: String,
buyer_did: String,
offer_sha256: String,
}
pub(crate) struct Journal {
directory: PathBuf,
_lock: std::fs::File,
@@ -423,7 +448,16 @@ impl Journal {
fn path(&self, role: &str, id: &str) -> Result<PathBuf> {
validate_id(id)?;
anyhow::ensure!(
matches!(role, "buyer" | "seller"),
matches!(
role,
"buyer"
| "seller"
| "protocol-offer"
| "offer-retired"
| "envelope-buyer"
| "envelope-seller"
| "plan-buyer"
),
"Invalid purchase journal role"
);
Ok(self.directory.join(format!("{role}-{id}.json")))
@@ -511,6 +545,280 @@ impl Journal {
.sync_all()?;
Ok(())
}
/// Caller sealed the wallet first under its mutation guard. This phase
/// still blocks replacement until authenticated seller acknowledgement.
pub async fn begin_cancellation(&self, contract: &Contract) -> Result<()> {
let mut record = self.bound_buyer(contract).await?;
anyhow::ensure!(
matches!(
record.phase,
BuyerPhase::Intent
| BuyerPhase::AcceptanceSaved
| BuyerPhase::CancellationPending
| BuyerPhase::Cancelled
),
"Funded purchase cannot cancel as unspent"
);
if record.phase == BuyerPhase::Cancelled {
return Ok(());
}
record.phase = BuyerPhase::CancellationPending;
record.validate()?;
self.write("buyer", &contract.id, &record).await
}
pub async fn finish_cancellation(
&self,
contract: &Contract,
verified_seller: &str,
) -> Result<()> {
anyhow::ensure!(
verified_seller == contract.seller_did,
"Cancellation acknowledgement seller changed"
);
let mut record = self.bound_buyer(contract).await?;
anyhow::ensure!(
matches!(
record.phase,
BuyerPhase::CancellationPending | BuyerPhase::Cancelled
),
"Cancellation was not sealed locally"
);
record.phase = BuyerPhase::Cancelled;
record.validate()?;
self.write("buyer", &contract.id, &record).await
}
/// Runs under the same journal flock as accept/token binding. A token bound
/// before this lock wins and prevents cancellation, even before settlement.
pub async fn cancel_seller(&self, contract: &Contract) -> Result<()> {
let mut record = if let Some(record) = self.seller(&contract.id).await? {
anyhow::ensure!(
record.contract == *contract,
"Seller cancellation terms changed"
);
record
} else {
SellerRecord {
contract: contract.clone(),
accepted_at: 0,
token_hash: None,
phase: SellerPhase::Cancelled,
}
};
anyhow::ensure!(
record.token_hash.is_none()
&& matches!(record.phase, SellerPhase::Intent | SellerPhase::Cancelled),
"Seller already received this payment; recover settlement"
);
record.phase = SellerPhase::Cancelled;
record.validate()?;
self.write("seller", &contract.id, &record).await
}
// Add these methods inside content_purchase::Journal; extend path role allowlist
// with "protocol-offer" | "envelope-buyer" | "envelope-seller" | "plan-buyer".
// The existing same flock/checksum/private permissions/synchronous commit apply.
pub async fn protocol_offer(
&self,
id: &str,
) -> Result<Option<crate::content_purchase_protocol::Offer>> {
let value: Option<crate::content_purchase_protocol::Offer> =
self.read("protocol-offer", id).await?;
if let Some(offer) = &value {
anyhow::ensure!(offer.id == id, "Offer identifier changed");
offer.validate()?;
}
Ok(value)
}
pub async fn save_protocol_offer(
&self,
offer: &crate::content_purchase_protocol::Offer,
) -> Result<()> {
offer.validate()?;
let retired: Option<RetiredOffer> = self.read("offer-retired", &offer.id).await?;
anyhow::ensure!(
retired.is_none(),
"Original offer expired without acceptance; recover cancellation before replacing it"
);
if let Some(old) = self.protocol_offer(&offer.id).await? {
anyhow::ensure!(old == *offer, "Original offer changed");
return Ok(());
}
self.retire_unaccepted_offers(chrono::Utc::now().timestamp())
.await?;
// Bound unaffiliated authenticated peers' quote storage. Existing IDs
// replay above without consuming another slot; no accepted liability GC.
let mut entries = fs::read_dir(&self.directory).await?;
let mut total = 0usize;
let mut buyer = 0usize;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Some(id) = name
.strip_prefix("protocol-offer-")
.and_then(|v| v.strip_suffix(".json"))
else {
continue;
};
// Accepted obligations are retained, but do not consume the quota
// for new, never-accepted quotes.
if self.seller(id).await?.is_some() {
continue;
}
total += 1;
anyhow::ensure!(
total < 4096,
"Purchase offer storage limit reached; existing operations remain recoverable"
);
if self
.protocol_offer(id)
.await?
.is_some_and(|value| value.buyer_did == offer.buyer_did)
{
buyer += 1;
anyhow::ensure!(
buyer < 128,
"Buyer offer storage limit reached; recover an existing operation"
);
}
}
self.write("protocol-offer", &offer.id, offer).await
}
/// Retire only provably unaccepted quotes under the same journal flock as
/// acceptance/cancellation. The immutable commitment survives forever;
/// absence of history is never interpreted as permission to pay again.
pub async fn retire_unaccepted_offers(&self, now: i64) -> Result<()> {
let mut entries = fs::read_dir(&self.directory).await?;
let mut bytes = 0u64;
while let Some(entry) = entries.next_entry().await? {
if entry
.file_name()
.to_string_lossy()
.starts_with("offer-retired-")
{
bytes = bytes
.checked_add(entry.metadata().await?.len())
.context("Offer retirement size overflow")?;
}
}
let mut entries = fs::read_dir(&self.directory).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let Some(id) = name
.to_str()
.and_then(|name| name.strip_prefix("protocol-offer-"))
.and_then(|name| name.strip_suffix(".json"))
else {
continue;
};
let Some(offer) = self.protocol_offer(id).await? else {
continue;
};
if offer.expires_at > now
|| self.seller(id).await?.is_some()
|| self.protocol_envelope("seller", id).await?.is_some()
{
continue;
}
let commitment = hash(&serde_json::to_vec(&offer)?);
let previous: Option<RetiredOffer> = self.read("offer-retired", id).await?;
if let Some(previous) = previous {
anyhow::ensure!(
previous.id == id
&& previous.buyer_did == offer.buyer_did
&& previous.offer_sha256 == commitment,
"Retired offer binding changed"
);
} else {
// Bound compact terminal metadata separately from accepted liability.
anyhow::ensure!(bytes < 64 * 1024 * 1024, "Quote retirement storage needs maintenance; existing purchases remain recoverable");
self.write(
"offer-retired",
id,
&RetiredOffer {
id: id.into(),
buyer_did: offer.buyer_did,
offer_sha256: commitment,
},
)
.await?;
bytes = bytes
.checked_add(std::fs::metadata(self.path("offer-retired", id)?)?.len())
.context("Retirement size overflow")?;
}
// Synchronous commit point: cancellation cannot leave an asynchronous
// deletion running after this flock is released.
std::fs::remove_file(self.path("protocol-offer", id)?)?;
std::fs::File::open(&self.directory)?.sync_all()?;
}
Ok(())
}
pub async fn retired_offer_matches(
&self,
offer: &crate::content_purchase_protocol::Offer,
) -> Result<bool> {
let value: Option<RetiredOffer> = self.read("offer-retired", &offer.id).await?;
let commitment = hash(&serde_json::to_vec(offer)?);
Ok(value.is_some_and(|value| {
value.id == offer.id
&& value.buyer_did == offer.buyer_did
&& value.offer_sha256 == commitment
}))
}
pub async fn protocol_envelope(
&self,
role: &str,
id: &str,
) -> Result<Option<crate::content_purchase_protocol::Envelope>> {
let role = match role {
"buyer" => "envelope-buyer",
"seller" => "envelope-seller",
_ => anyhow::bail!("Invalid envelope role"),
};
let value: Option<crate::content_purchase_protocol::Envelope> = self.read(role, id).await?;
if let Some(value) = &value {
anyhow::ensure!(value.contract()?.id == id, "Envelope identifier changed");
}
Ok(value)
}
pub async fn save_protocol_envelope(
&self,
role: &str,
value: &crate::content_purchase_protocol::Envelope,
) -> Result<()> {
let contract = value.contract()?;
if let Some(old) = self.protocol_envelope(role, &contract.id).await? {
anyhow::ensure!(old == *value, "Original payment shape changed");
return Ok(());
}
let role = match role {
"buyer" => "envelope-buyer",
"seller" => "envelope-seller",
_ => anyhow::bail!("Invalid envelope role"),
};
self.write(role, &contract.id, value).await
}
pub async fn buyer_plan(
&self,
id: &str,
) -> Result<Option<crate::wallet::purchase_plan::PreparedPayment>> {
self.read("plan-buyer", id).await
}
pub async fn save_buyer_plan(
&self,
id: &str,
plan: &crate::wallet::purchase_plan::PreparedPayment,
) -> Result<()> {
if let Some(old) = self.buyer_plan(id).await? {
anyhow::ensure!(
serde_json::to_value(&old)? == serde_json::to_value(plan)?,
"Original wallet plan changed"
);
return Ok(());
}
self.write("plan-buyer", id, plan).await
}
pub async fn buyer(&self, id: &str) -> Result<Option<BuyerRecord>> {
let result: Option<BuyerRecord> = self.read("buyer", id).await?;
if let Some(record) = &result {
@@ -527,6 +835,44 @@ impl Journal {
}
Ok(result)
}
/// Caller holds the wallet mutation guard. Keep accepted seller liabilities
/// redeemable until settlement or authenticated cancellation is durable.
pub(crate) async fn ensure_seller_policy_change(
&self,
network: EcashNetwork,
accepted_mints: Option<&[String]>,
) -> Result<()> {
let mut entries = fs::read_dir(&self.directory).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let Some(id) = name
.to_str()
.and_then(|v| v.strip_prefix("seller-"))
.and_then(|v| v.strip_suffix(".json"))
else {
continue;
};
let record = self
.seller(id)
.await?
.context("Seller liability disappeared")?;
if !matches!(record.phase, SellerPhase::Intent) {
continue;
}
anyhow::ensure!(
record.contract.network == network,
"A pending accepted sale requires its original wallet network"
);
if let Some(mints) = accepted_mints {
anyhow::ensure!(
mints.iter().any(|mint| canonical_mint(mint).ok().as_deref()
== Some(record.contract.mint_url.as_str())),
"A pending accepted sale requires its original accepted mint"
);
}
}
Ok(())
}
/// Discover existing node-owned intent after browser storage loss. The
/// journal lock makes this lookup and prepare_buyer's duplicate guard one
/// serialized decision; caller-supplied fresh UUIDs cannot bypass it.
@@ -584,9 +930,10 @@ impl Journal {
)
.await?;
anyhow::ensure!(
pending
.iter()
.all(|record| record.phase == BuyerPhase::Delivered),
pending.iter().all(|record| matches!(
record.phase,
BuyerPhase::Delivered | BuyerPhase::Cancelled
)),
"An existing purchase must be recovered before a new operation is created"
);
contract.validate_new_at(now)?;
@@ -607,6 +954,10 @@ impl Journal {
&record.contract == contract,
"Seller purchase terms changed"
);
anyhow::ensure!(
!matches!(record.phase, SellerPhase::Cancelled),
"Seller cancelled this operation"
);
return Ok(record);
}
contract.validate_new_at(now)?;
@@ -651,6 +1002,13 @@ impl Journal {
"Acceptance is from another seller"
);
let mut record = self.bound_buyer(contract).await?;
anyhow::ensure!(
!matches!(
record.phase,
BuyerPhase::CancellationPending | BuyerPhase::Cancelled
),
"Buyer cancellation is sealed"
);
acceptance.validate(contract)?;
if let Some(previous) = &record.acceptance {
anyhow::ensure!(previous == acceptance, "Seller acceptance changed");
@@ -718,6 +1076,7 @@ impl Journal {
) -> Result<SellerRecord> {
let mut record = self.bound_seller(contract).await?;
match &record.phase {
SellerPhase::Cancelled => anyhow::bail!("Seller cancelled this operation"),
SellerPhase::Intent => record.phase = SellerPhase::Settled { amount_received },
SellerPhase::Settled {
amount_received: saved,
@@ -744,6 +1103,7 @@ impl Journal {
pub async fn issue_receipt(&self, contract: &Contract) -> Result<Receipt> {
let mut record = self.bound_seller(contract).await?;
let amount_received = match &record.phase {
SellerPhase::Cancelled => anyhow::bail!("Seller cancelled this operation"),
SellerPhase::Intent => anyhow::bail!("Seller settlement is not durable"),
SellerPhase::Settled { amount_received } => *amount_received,
SellerPhase::ReceiptSaved(receipt) => return Ok(receipt.clone()),