Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
//! Shape-A paid-blobs negotiation ALPN (`archy/paid-blobs/1`) — the on-wire
|
||||
//! exchange that lets a downloader pay a seeder *before* fetching a gated blob
|
||||
//! (DHT distribution plan §1, "shape A"). Gated behind `iroh-swarm`.
|
||||
//!
|
||||
//! ## Why a side ALPN
|
||||
//! iroh-blobs carries the raw bytes; this tiny request/grant protocol rides a
|
||||
//! second ALPN on the *same* endpoint so a downloader can discover the price and
|
||||
//! deliver an ecash token first. The token opens a metered `streaming` session
|
||||
//! keyed by the downloader's endpoint id — exactly the session the blob-GET gate
|
||||
//! ([`super::paid`]) already checks. Same endpoint → same session → the GET is
|
||||
//! then served.
|
||||
//!
|
||||
//! ```text
|
||||
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: None }
|
||||
//! B ◀─────────────────────── A PaymentRequired { price, accepted_mints }
|
||||
//! B: auto_pay_token(...) ── builds a cashuA token (cross-mint aware)
|
||||
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: Some(t) }
|
||||
//! B ◀─────────────────────── A Granted (session now exists on A)
|
||||
//! B ──(iroh-blobs ALPN)─────▶ A GET H → served (gate sees the session)
|
||||
//! ```
|
||||
//!
|
||||
//! ## North star: origin always wins, releases stay free
|
||||
//! Negotiation is **best-effort and additive**. A peer that doesn't speak this
|
||||
//! ALPN, or any connect/protocol error, is treated as "proceed" — the blob-GET
|
||||
//! gate is the real enforcement, and a denied GET just falls back to origin.
|
||||
//! With the default [`PaymentPolicy::free`] a downloader never sends a token, so
|
||||
//! a seeder that prices a blob is simply skipped → origin. Only films (a future
|
||||
//! caller with a real budget) will actually pay.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use iroh::endpoint::Connection;
|
||||
use iroh::protocol::{AcceptError, ProtocolHandler};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId};
|
||||
use iroh_blobs::api::blobs::BlobStatus;
|
||||
use iroh_blobs::api::Store;
|
||||
use iroh_blobs::Hash;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::payment::PaymentPolicy;
|
||||
use crate::streaming::gate::{self, GateResult};
|
||||
|
||||
/// ALPN for the paid-blobs negotiation protocol.
|
||||
pub const PAID_ALPN: &[u8] = b"archy/paid-blobs/1";
|
||||
|
||||
/// The streaming service that meters swarm blob serving (same id as [`super::paid`]).
|
||||
const SERVICE_ID: &str = "content-download";
|
||||
|
||||
/// Cap on a single negotiation message (JSON). Requests/responses are tiny.
|
||||
const MAX_MSG: usize = 64 * 1024;
|
||||
|
||||
/// A downloader's ask for one content-addressed blob, optionally with payment.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct PaidRequest {
|
||||
/// BLAKE3 hex of the wanted blob.
|
||||
want: String,
|
||||
/// A `cashuA` token, present on the paying retry.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// The seeder's verdict.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
enum PaidResponse {
|
||||
/// Fetch away — free, or a paid session is now active for this peer.
|
||||
Granted,
|
||||
/// Payment needed before serving. The downloader may pay and retry.
|
||||
PaymentRequired {
|
||||
price_sats: u64,
|
||||
accepted_mints: Vec<String>,
|
||||
},
|
||||
/// Refused (bad request, insufficient/failed payment).
|
||||
Denied { reason: String },
|
||||
}
|
||||
|
||||
// ── Serve side ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Accept-side handler for [`PAID_ALPN`]. Registered on the provider's `Router`
|
||||
/// alongside the iroh-blobs protocol.
|
||||
#[derive(Clone)]
|
||||
pub struct PaidBlobsProtocol {
|
||||
data_dir: PathBuf,
|
||||
store: Store,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PaidBlobsProtocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PaidBlobsProtocol").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PaidBlobsProtocol {
|
||||
pub fn new(data_dir: PathBuf, store: Store) -> Self {
|
||||
Self { data_dir, store }
|
||||
}
|
||||
|
||||
/// Decide the verdict for a request from `peer`. Mirrors [`super::paid`]'s
|
||||
/// policy: free when the service is disabled (default) or the peer holds an
|
||||
/// active session; payment-required when metered and unpaid; fail-OPEN
|
||||
/// (Granted) on an internal gate error so a fault never blocks distribution.
|
||||
async fn decide(&self, peer: &str, req: &PaidRequest) -> PaidResponse {
|
||||
let size = self.blob_size(&req.want).await;
|
||||
match gate::check_gate(&self.data_dir, peer, SERVICE_ID, req.token.as_deref(), size).await {
|
||||
Ok(GateResult::ServiceUnavailable)
|
||||
| Ok(GateResult::Allowed { .. })
|
||||
| Ok(GateResult::PaidAndAllowed { .. }) => PaidResponse::Granted,
|
||||
Ok(GateResult::PaymentRequired {
|
||||
minimum_sats,
|
||||
pricing,
|
||||
..
|
||||
}) => PaidResponse::PaymentRequired {
|
||||
price_sats: minimum_sats,
|
||||
accepted_mints: pricing.accepted_mints,
|
||||
},
|
||||
Ok(GateResult::InsufficientPayment {
|
||||
provided_sats,
|
||||
minimum_sats,
|
||||
}) => PaidResponse::Denied {
|
||||
reason: format!("insufficient payment: {provided_sats} < {minimum_sats} sats"),
|
||||
},
|
||||
Ok(GateResult::PaymentFailed { reason }) => PaidResponse::Denied { reason },
|
||||
// Availability beats revenue: a gate fault serves free, matching the
|
||||
// blob-GET gate's fail-open behaviour.
|
||||
Err(e) => {
|
||||
tracing::warn!("paid-alpn: gate errored ({e}); granting free");
|
||||
PaidResponse::Granted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full size of a held blob (for metering); 0 if we don't hold it complete.
|
||||
async fn blob_size(&self, blake3_hex: &str) -> u64 {
|
||||
let Ok(raw) = hex::decode(blake3_hex) else {
|
||||
return 0;
|
||||
};
|
||||
let Ok(arr) = <[u8; 32]>::try_from(raw.as_slice()) else {
|
||||
return 0;
|
||||
};
|
||||
match self.store.blobs().status(Hash::from_bytes(arr)).await {
|
||||
Ok(BlobStatus::Complete { size }) => size,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolHandler for PaidBlobsProtocol {
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let peer = connection.remote_id().to_string();
|
||||
// One bi-stream per request (a paying downloader opens a second one).
|
||||
loop {
|
||||
let (mut send, mut recv) = match connection.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
// Connection closed by the peer — normal end of negotiation.
|
||||
Err(_) => break,
|
||||
};
|
||||
let buf = recv
|
||||
.read_to_end(MAX_MSG)
|
||||
.await
|
||||
.map_err(AcceptError::from_err)?;
|
||||
let response = match serde_json::from_slice::<PaidRequest>(&buf) {
|
||||
Ok(req) => self.decide(&peer, &req).await,
|
||||
Err(e) => PaidResponse::Denied {
|
||||
reason: format!("bad request: {e}"),
|
||||
},
|
||||
};
|
||||
let bytes = serde_json::to_vec(&response).map_err(AcceptError::from_err)?;
|
||||
send.write_all(&bytes)
|
||||
.await
|
||||
.map_err(AcceptError::from_err)?;
|
||||
send.finish().map_err(AcceptError::from_err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch side ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Negotiate access to `blake3_hex` from `peer` before fetching. Returns whether
|
||||
/// the caller should proceed to download from this peer.
|
||||
///
|
||||
/// Best-effort: any connect/protocol failure returns `true` (proceed — the
|
||||
/// blob-GET gate is the real enforcement, and a denied GET falls back to origin).
|
||||
/// Returns `false` only when the seeder explicitly requires a payment we won't or
|
||||
/// can't make under `policy`.
|
||||
pub async fn negotiate_access(
|
||||
endpoint: &Endpoint,
|
||||
data_dir: &Path,
|
||||
peer: EndpointId,
|
||||
blake3_hex: &str,
|
||||
policy: &PaymentPolicy,
|
||||
) -> bool {
|
||||
match negotiate_inner(endpoint, data_dir, peer, blake3_hex, policy).await {
|
||||
Ok(proceed) => proceed,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"paid-alpn: negotiation with {peer} failed ({e}) — proceeding (gate decides)"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn negotiate_inner(
|
||||
endpoint: &Endpoint,
|
||||
data_dir: &Path,
|
||||
peer: EndpointId,
|
||||
blake3_hex: &str,
|
||||
policy: &PaymentPolicy,
|
||||
) -> Result<bool> {
|
||||
let conn = endpoint.connect(EndpointAddr::new(peer), PAID_ALPN).await?;
|
||||
|
||||
// First ask with no token.
|
||||
let resp = exchange(
|
||||
&conn,
|
||||
&PaidRequest {
|
||||
want: blake3_hex.to_string(),
|
||||
token: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
match resp {
|
||||
PaidResponse::Granted => Ok(true),
|
||||
PaidResponse::Denied { .. } => Ok(false),
|
||||
PaidResponse::PaymentRequired {
|
||||
price_sats,
|
||||
accepted_mints,
|
||||
} => {
|
||||
// Build a token within budget (cross-mint aware); None ⇒ use origin.
|
||||
match super::payment::auto_pay_token(data_dir, policy, &accepted_mints, price_sats)
|
||||
.await?
|
||||
{
|
||||
None => Ok(false),
|
||||
Some(token) => {
|
||||
let resp2 = exchange(
|
||||
&conn,
|
||||
&PaidRequest {
|
||||
want: blake3_hex.to_string(),
|
||||
token: Some(token),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(matches!(resp2, PaidResponse::Granted))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One request/response round trip on a fresh bi-stream.
|
||||
async fn exchange(conn: &Connection, req: &PaidRequest) -> Result<PaidResponse> {
|
||||
let (mut send, mut recv) = conn.open_bi().await?;
|
||||
send.write_all(&serde_json::to_vec(req)?).await?;
|
||||
send.finish()?;
|
||||
let buf = recv.read_to_end(MAX_MSG).await?;
|
||||
Ok(serde_json::from_slice(&buf)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_round_trips_and_omits_absent_token() {
|
||||
let req = PaidRequest {
|
||||
want: "abcd".into(),
|
||||
token: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(
|
||||
!json.contains("token"),
|
||||
"absent token must be omitted: {json}"
|
||||
);
|
||||
let back: PaidRequest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.want, "abcd");
|
||||
assert!(back.token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_with_token_round_trips() {
|
||||
let req = PaidRequest {
|
||||
want: "ff".into(),
|
||||
token: Some("cashuAbc".into()),
|
||||
};
|
||||
let back: PaidRequest =
|
||||
serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
assert_eq!(back.token.as_deref(), Some("cashuAbc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_tagged_serialization() {
|
||||
let granted = serde_json::to_string(&PaidResponse::Granted).unwrap();
|
||||
assert_eq!(granted, r#"{"status":"granted"}"#);
|
||||
|
||||
let pr = serde_json::to_string(&PaidResponse::PaymentRequired {
|
||||
price_sats: 7,
|
||||
accepted_mints: vec!["https://m".into()],
|
||||
})
|
||||
.unwrap();
|
||||
let back: PaidResponse = serde_json::from_str(&pr).unwrap();
|
||||
match back {
|
||||
PaidResponse::PaymentRequired {
|
||||
price_sats,
|
||||
accepted_mints,
|
||||
} => {
|
||||
assert_eq!(price_sats, 7);
|
||||
assert_eq!(accepted_mints, vec!["https://m".to_string()]);
|
||||
}
|
||||
other => panic!("expected PaymentRequired, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user