From b984b2a6984b65562976c10db86711148a172214 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 07:03:11 -0400 Subject: [PATCH] Check durable file payments against their actual payment method --- core/archipelago/src/api/handler/content.rs | 3 +- core/archipelago/src/content_invoice.rs | 85 ++++++++++++++++++++- core/archipelago/src/content_server.rs | 29 ++++++- 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index abd4125f..be4ae295 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -470,11 +470,12 @@ impl ApiHandler { match self.rpc_handler.new_onchain_address().await { Ok(address) if !address.is_empty() => { - crate::content_invoice::record_pending( + crate::content_invoice::record_pending_method( &self.config.data_dir, &address, content_id, price_sats, + crate::content_invoice::PaymentMethod::Onchain, ) .await?; let body = serde_json::json!({ diff --git a/core/archipelago/src/content_invoice.rs b/core/archipelago/src/content_invoice.rs index 265df871..a4eb3744 100644 --- a/core/archipelago/src/content_invoice.rs +++ b/core/archipelago/src/content_invoice.rs @@ -10,11 +10,44 @@ use tokio::{fs, io::AsyncWriteExt, sync::Mutex}; static WRITES: Mutex<()> = Mutex::const_new(()); +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PaymentMethod { + Lightning, + Onchain, +} + +impl PaymentMethod { + pub fn as_str(self) -> &'static str { + match self { + Self::Lightning => "lightning", + Self::Onchain => "onchain", + } + } +} + #[derive(Clone, Serialize, Deserialize)] struct Entitlement { content_id: String, price_sats: u64, paid: bool, + #[serde(default)] + method: Option, +} + +impl Entitlement { + fn payment_method(&self, token: &str) -> PaymentMethod { + self.method.unwrap_or_else(|| { + if token + .parse::>() + .is_ok() + { + PaymentMethod::Onchain + } else { + PaymentMethod::Lightning + } + }) + } } fn path(data_dir: &Path, token: &str) -> PathBuf { @@ -61,11 +94,30 @@ pub async fn record_pending( token: &str, content_id: &str, price_sats: u64, +) -> Result<()> { + record_pending_method( + data_dir, + token, + content_id, + price_sats, + PaymentMethod::Lightning, + ) + .await +} + +pub async fn record_pending_method( + data_dir: &Path, + token: &str, + content_id: &str, + price_sats: u64, + method: PaymentMethod, ) -> Result<()> { let _lock = WRITES.lock().await; if let Some(existing) = read(data_dir, token).await? { anyhow::ensure!( - existing.content_id == content_id && existing.price_sats == price_sats, + existing.content_id == content_id + && existing.price_sats == price_sats + && existing.payment_method(token) == method, "Payment entitlement mismatch" ); return Ok(()); @@ -77,6 +129,7 @@ pub async fn record_pending( content_id: content_id.into(), price_sats, paid: false, + method: Some(method), }, ) .await @@ -106,6 +159,16 @@ pub async fn is_paid_for(data_dir: &Path, token: &str, content_id: &str) -> bool .unwrap_or(false) } +/// Read the method from the seller's durable record, never a buyer header. +pub async fn paid_method_for( + data_dir: &Path, + token: &str, + content_id: &str, +) -> Option { + let entry = read(data_dir, token).await.ok().flatten()?; + (entry.paid && entry.content_id == content_id).then(|| entry.payment_method(token)) +} + #[cfg(test)] mod tests { use super::*; @@ -134,6 +197,26 @@ mod tests { assert!(!is_paid_for(other.path(), "hash", "file").await); assert!(mark_paid(dir.path(), "unknown").await.is_err()); } + #[tokio::test] + async fn payment_method_survives_reload_and_legacy_addresses_remain_onchain() { + let dir = tempfile::tempdir().unwrap(); + record_pending_method(dir.path(), "new", "file", 1, PaymentMethod::Onchain) + .await + .unwrap(); + assert!(paid_method_for(dir.path(), "new", "file").await.is_none()); + mark_paid(dir.path(), "new").await.unwrap(); + assert!(paid_method_for(dir.path(), "new", "file").await == Some(PaymentMethod::Onchain)); + assert!(record_pending(dir.path(), "new", "file", 1).await.is_err()); + assert!(paid_method_for(dir.path(), "new", "other").await.is_none()); + let address = "1BoatSLRHtKNngkdXEeobR76b53LETtpyT"; + let legacy = br#"{"content_id":"file","price_sats":1,"paid":true}"#; + fs::write(path(dir.path(), address), legacy).await.unwrap(); + assert!(paid_method_for(dir.path(), address, "file").await == Some(PaymentMethod::Onchain)); + let hash = "a".repeat(64); + fs::write(path(dir.path(), &hash), legacy).await.unwrap(); + assert!(paid_method_for(dir.path(), &hash, "file").await == Some(PaymentMethod::Lightning)); + } + #[tokio::test] async fn corrupt_or_unwritable_records_fail_closed() { let dir = tempfile::tempdir().unwrap(); diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index cbc4aeb8..acf8c7a2 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -436,10 +436,8 @@ where } if !authorized { if let Some(hash) = invoice_hash { - if method_accepted(&item.access, "lightning") - && crate::content_invoice::is_paid_for(data_dir, hash, id).await - { - authorized = true; + if let Some(method) = crate::content_invoice::paid_method_for(data_dir, hash, id).await { + authorized = method_accepted(&item.access, method.as_str()); } } } @@ -1316,6 +1314,29 @@ mod paid_read_order_tests { ); } + #[tokio::test] + async fn durable_payment_uses_its_recorded_method_for_seller_acceptance() { + use crate::content_invoice::{record_pending_method, mark_paid, PaymentMethod}; + for (paid_with, accepted, expected) in [ + (PaymentMethod::Onchain, "onchain", true), + (PaymentMethod::Onchain, "lightning", false), + (PaymentMethod::Lightning, "lightning", true), + (PaymentMethod::Lightning, "onchain", false), + ] { + let dir = fixture(b"paid bytes").await; + let mut catalog = load_catalog(dir.path()).await.unwrap(); + catalog.items[0].access = AccessControl::Paid { price_sats: 10, accepted: vec![accepted.into()] }; + save_catalog(dir.path(), &catalog).await.unwrap(); + record_pending_method(dir.path(), "receipt", "paid", 10, paid_with).await.unwrap(); + mark_paid(dir.path(), "receipt").await.unwrap(); + let result = serve_content_with(dir.path(), "paid", None, Some("receipt"), None, None, false, + |path, range, mime| prepare_content(dir.path(), path, range, mime), + |_, _| async { panic!("must not redeem another payment") }).await.unwrap(); + if expected { assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"paid bytes")); } + else { assert!(matches!(result, ServeResult::PaymentRequired(10))); } + } + } + #[tokio::test] async fn payment_denial_never_returns_prepared_content() { let dir = fixture(b"secret").await;