fix: allow explicit renewal of expired rentals

This commit is contained in:
archipelago
2026-10-10 09:55:43 -04:00
parent 2dc6121eea
commit a4df61000c
9 changed files with 359 additions and 10 deletions
+42
View File
@@ -144,6 +144,8 @@ impl RpcHandler {
struct RentalParams {
#[serde(default)]
retry_preparation: bool,
#[serde(default)]
renew_expired: bool,
seller_did: String,
content_id: String,
expected_sha256: String,
@@ -209,6 +211,46 @@ impl RpcHandler {
price_sats: params.expected_price_sats,
viewing_seconds: params.expected_viewing_seconds,
};
if params.renew_expired {
let journal = Journal::open(&self.config.data_dir).await?;
let matching = journal
.find_buyers(&buyer, transport.seller_did(), &params.content_id)
.await?;
let settled: Vec<_> = matching
.into_iter()
.filter(|record| record.phase == crate::content_purchase::BuyerPhase::ReceiptSaved)
.collect();
anyhow::ensure!(
settled.len() <= 1,
"Multiple original rentals require recovery"
);
if let Some(record) = settled.into_iter().next() {
let capability = record
.receipt()
.context("Original rental receipt is missing")?
.capability
.clone();
let original_duration = journal
.protocol_envelope("buyer", &record.contract.id)
.await?
.context("Original rental terms are missing")?
.offer
.viewing_seconds
.context("Original purchase is not a timed rental")?;
let (started, expires) = transport
.expired_rental_window(&record.contract, &capability, original_duration)
.await?;
journal
.record_rental_expiry(
&record.contract,
started,
expires,
original_duration,
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0),
)
.await?;
}
}
match caller::purchase_bound(
&self.config.data_dir,
&buyer,
+77
View File
@@ -1167,6 +1167,40 @@ impl Journal {
}
Ok(record)
}
/// Retire a settled timed-rental receipt only after the authenticated
/// seller has reported its immutable viewing window as expired. This is a
/// lifecycle transition, not evidence that a permanent copy was cached:
/// it merely permits a later, explicitly approved rental operation for the
/// same registered title. Incomplete and still-active purchases must remain
/// recoverable under their original UUID.
pub async fn record_rental_expiry(
&self,
contract: &Contract,
started_at: u64,
expires_at: u64,
viewing_seconds: u64,
now: u64,
) -> Result<BuyerRecord> {
let mut record = self.bound_buyer(contract).await?;
anyhow::ensure!(
contract.content_id.starts_with("registered_")
&& record.phase == BuyerPhase::ReceiptSaved
&& record.receipt.is_some(),
"Only a settled timed rental can be retired"
);
anyhow::ensure!(
viewing_seconds > 0
&& started_at > 0
&& started_at.checked_add(viewing_seconds) == Some(expires_at)
&& now >= expires_at,
"Original rental viewing period has not ended"
);
record.phase = BuyerPhase::Delivered;
record.validate()?;
self.write("buyer", &contract.id, &record).await?;
Ok(record)
}
}
#[cfg(test)]
@@ -1365,6 +1399,49 @@ mod tests {
assert!(journal.buyer(&expired.id).await.unwrap().is_none());
}
#[tokio::test]
async fn only_an_expired_settled_rental_can_be_retired_for_another_period() {
let root = tempfile::tempdir().unwrap();
let mut rental = contract();
rental.content_id = "registered_film-1".into();
let journal = Journal::open(root.path()).await.unwrap();
journal.prepare_buyer(&rental, 1100).await.unwrap();
let seller = journal.prepare_seller(&rental, 1100).await.unwrap();
journal
.record_acceptance(&rental, &seller.acceptance().unwrap(), &rental.seller_did)
.await
.unwrap();
let encoded = token(&rental, "rental-expiry");
journal.record_token(&rental, &encoded).await.unwrap();
journal
.record_incoming_token(&rental, &encoded)
.await
.unwrap();
journal
.record_settlement(&rental, rental.minimum_net_sats)
.await
.unwrap();
let receipt = journal.issue_receipt(&rental).await.unwrap();
journal.record_receipt(&rental, &receipt).await.unwrap();
assert!(journal
.record_rental_expiry(&rental, 2000, 5600, 3600, 5599)
.await
.is_err());
assert_eq!(
journal.buyer(&rental.id).await.unwrap().unwrap().phase,
BuyerPhase::ReceiptSaved
);
let retired = journal
.record_rental_expiry(&rental, 2000, 5600, 3600, 5600)
.await
.unwrap();
assert_eq!(retired.phase, BuyerPhase::Delivered);
let mut next = rental.clone();
next.id = uuid::Uuid::new_v4().to_string();
journal.prepare_buyer(&next, 1500).await.unwrap();
}
#[tokio::test]
async fn damaged_records_and_nonregular_targets_are_preserved() {
let root = tempfile::tempdir().unwrap();
let contract = contract();
@@ -206,7 +206,10 @@ pub(crate) async fn purchase_bound(
.await?;
let unresolved: Vec<_> = matching
.into_iter()
.filter(|record| record.phase != BuyerPhase::Cancelled)
.filter(|record| {
record.phase != BuyerPhase::Cancelled
&& !(expected.is_some() && record.phase == BuyerPhase::Delivered)
})
.collect();
anyhow::ensure!(
unresolved.len() <= 1,
@@ -39,6 +39,60 @@ impl FipsPurchaseTransport {
self.retry_preparation = retry;
self
}
/// Ask the authenticated seller for the original rental window without
/// starting or extending it. The capability never crosses into the app.
pub async fn expired_rental_window(
&self,
contract: &crate::content_purchase::Contract,
capability: &str,
viewing_seconds: u64,
) -> Result<(u64, u64)> {
let route = format!(
"/content/{}/rental/{}/prepare",
contract.content_id, contract.id
);
let body = serde_json::json!({"capability":capability,"ready_id":null,"retry":false});
let (mut response, _) =
crate::fips::dial::PeerRequest::new(Some(&self.fips_npub), &self.onion, &route)
.require_fips()
.single_delivery()
.timeout(Duration::from_secs(20))
.send_rental_control_json(&self.data_dir, &self.seller_did, &body)
.await?;
anyhow::ensure!(
response.status().is_success(),
"Original rental status is unavailable"
);
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
anyhow::ensure!(
bytes
.len()
.checked_add(chunk.len())
.is_some_and(|n| n <= 16 * 1024),
"Rental status response is too large"
);
bytes.extend_from_slice(&chunk);
}
let value: serde_json::Value = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
value["state"] == "expired",
"Original rental viewing period is still available"
);
let started = value["started_at"]
.as_u64()
.context("Missing rental start")?;
let expires = value["expires_at"]
.as_u64()
.context("Missing rental expiry")?;
anyhow::ensure!(
value["viewing_seconds"].as_u64() == Some(viewing_seconds)
&& started.checked_add(viewing_seconds) == Some(expires),
"Seller changed the original rental window"
);
Ok((started, expires))
}
async fn post<B: Serialize + Sync, R: DeserializeOwned>(
&self,
route: &str,