feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony

- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
  fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
  decoupled-update wiring; Fedimint Client core app in catalog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-17 19:21:07 -04:00
co-authored by Claude Opus 4.8
parent c10f2ac22e
commit bd567cd165
34 changed files with 2677 additions and 68 deletions
+279
View File
@@ -66,6 +66,21 @@ impl ApiHandler {
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning)
// or X-Onchain-Address (on-chain) — both authorize the download if this
// node issued+settled them, and both resolve against the same shared
// entitlement store keyed by the token string (#46).
let invoice_hash = headers
.get("x-invoice-hash")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.or_else(|| {
headers
.get("x-onchain-address")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
// Extract federation peer DID from X-Federation-DID header
let peer_did = headers
.get("x-federation-did")
@@ -82,6 +97,7 @@ impl ApiHandler {
&config.data_dir,
content_id,
payment_token.as_deref(),
invoice_hash.as_deref(),
peer_did.as_deref(),
range,
)
@@ -140,6 +156,261 @@ impl ApiHandler {
}
}
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
/// Records a pending entitlement keyed by the invoice's payment hash.
pub(super) async fn handle_content_invoice(
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/invoice"))
.unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid content ID"),
));
}
let catalog = content_server::load_catalog(&self.config.data_dir)
.await
.unwrap_or_default();
let item = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => i,
None => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
))
}
};
let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
_ => {
// Not a paid item — no invoice to issue.
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
));
}
};
let memo = format!("Archipelago peer file {content_id}");
match self
.rpc_handler
.create_invoice(price_sats as i64, &memo)
.await
{
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
let body = serde_json::json!({
"bolt11": bolt11,
"payment_hash": payment_hash,
"price_sats": price_sats,
});
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
Ok(_) => Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"application/json",
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
)),
Err(e) => {
let body = serde_json::json!({
"error": format!("Could not create invoice: {e}")
});
Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
}
}
/// Seller side (#46): report whether a previously-issued invoice has settled.
/// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the
/// entitlement is marked paid so the buyer can then download the file.
pub(super) async fn handle_content_invoice_status(
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
let rest = path.strip_prefix("/content/").unwrap_or("");
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
Some((id, hash)) => (id, hash),
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
))
}
};
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
));
}
// The hash must be one we issued for exactly this content item.
match crate::content_invoice::lookup(payment_hash).await {
Some((cid, _)) if cid == content_id => {}
_ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
))
}
}
// Already paid? Otherwise ask our LND and persist the result.
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
crate::content_invoice::mark_paid(payment_hash).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
/// pending entitlement keyed by the address; price doubles as expected amount.
pub(super) async fn handle_content_onchain(
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/onchain"))
.unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid content ID"),
));
}
let catalog = content_server::load_catalog(&self.config.data_dir)
.await
.unwrap_or_default();
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => match &i.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
_ => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
))
}
},
None => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
))
}
};
match self.rpc_handler.new_onchain_address().await {
Ok(address) if !address.is_empty() => {
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
let body = serde_json::json!({
"address": address,
"amount_sats": price_sats,
});
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
_ => {
let body = serde_json::json!({
"error": "Could not generate an on-chain address (is the wallet ready?)"
});
Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
}
}
/// Seller side (#46): report whether an on-chain payment to a previously-
/// issued address has arrived (>= price, >= 1 conf). Path:
/// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid.
pub(super) async fn handle_content_onchain_status(
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
let rest = path.strip_prefix("/content/").unwrap_or("");
let (content_id, address) = match rest.split_once("/onchain-status/") {
Some((id, addr)) => (id, addr),
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
))
}
};
if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
));
}
// The address must be one we issued for exactly this content item.
let price = match crate::content_invoice::lookup(address).await {
Some((cid, price)) if cid == content_id => price,
_ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown address"}"#),
))
}
};
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
crate::content_invoice::mark_paid(address).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
pub(super) async fn handle_content_preview(
path: &str,
@@ -190,6 +461,14 @@ impl ApiHandler {
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder()
.status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
.header("Content-Type", "text/plain")
.header("X-Content-Preview", "unavailable")
.body(hyper::Body::from(
"Preview unavailable for this media (needs re-encoding)",
))
.unwrap()),
Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
+35 -2
View File
@@ -44,6 +44,11 @@ pub struct ApiHandler {
session_store: SessionStore,
/// Broadcast channel for relaying companion app input to remote browsers.
input_relay_tx: broadcast::Sender<String>,
/// Reverse broadcast channel: the kiosk browser publishes "open this URL
/// externally" requests here, and the companion (phone) socket forwards them
/// to the phone's default browser. Lets "open in external browser" apps —
/// which the kiosk can't usefully open itself — launch on the controller.
external_open_tx: broadcast::Sender<String>,
/// Content-addressed blob store for attachments shared over mesh/federation.
blob_store: Arc<BlobStore>,
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
@@ -71,6 +76,7 @@ impl ApiHandler {
.await?,
);
let (input_relay_tx, _) = broadcast::channel(64);
let (external_open_tx, _) = broadcast::channel(16);
// Derive a blob-store capability key from the node's Ed25519 signing
// key. SHA-256 domain-separated so rotating the identity rotates
@@ -100,6 +106,7 @@ impl ApiHandler {
metrics_store,
session_store,
input_relay_tx,
external_open_tx,
blob_store,
self_pubkey_hex,
})
@@ -356,7 +363,12 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_input(req, self.input_relay_tx.clone()).await;
return Self::handle_remote_input(
req,
self.input_relay_tx.clone(),
self.external_open_tx.subscribe(),
)
.await;
}
// Remote relay WebSocket — browser receives companion input events
@@ -365,7 +377,12 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_relay(req, self.input_relay_tx.subscribe()).await;
return Self::handle_remote_relay(
req,
self.input_relay_tx.subscribe(),
self.external_open_tx.clone(),
)
.await;
}
// Convert body to bytes for non-WS routes
@@ -480,6 +497,22 @@ impl ApiHandler {
Self::handle_content_preview(p, &self.config).await
}
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
self.handle_content_invoice(p).await
}
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
self.handle_content_invoice_status(p).await
}
// On-chain peer-file sale (#46): issue address / poll for payment
(Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => {
self.handle_content_onchain_status(p).await
}
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
self.handle_content_onchain(p).await
}
// Content serving — peers access shared content over Tor (no session auth)
(Method::GET, p) if p.starts_with("/content/") => {
Self::handle_content_request(p, &headers, &self.config).await
@@ -19,6 +19,8 @@ impl ApiHandler {
signature: Option<String>,
#[serde(default)]
encrypted: bool,
#[serde(default)]
msg_id: Option<String>,
}
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
from_pubkey: None,
@@ -26,6 +28,7 @@ impl ApiHandler {
message: None,
signature: None,
encrypted: false,
msg_id: None,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref())
{
@@ -152,7 +155,13 @@ impl ApiHandler {
let clean_from = sanitize_html(from);
let clean_msg = sanitize_html(&plaintext);
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await;
node_msg::store_received(
&clean_from,
&clean_msg,
clean_name.as_deref(),
incoming.msg_id.as_deref(),
)
.await;
}
Ok(build_response(
StatusCode::OK,
@@ -211,6 +211,7 @@ impl ApiHandler {
pub(super) async fn handle_remote_input(
req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>,
mut external_open_rx: broadcast::Receiver<String>,
) -> Result<Response<hyper::Body>> {
// Extract optional player ID from query string: /ws/remote-input?p=1
let player_id: Option<u8> = req
@@ -266,6 +267,19 @@ impl ApiHandler {
break;
}
}
// Forward kiosk "open this URL externally" requests down to
// the companion so the link opens in the phone's browser.
ext = external_open_rx.recv() => {
match ext {
Ok(text) => {
if tx.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => {}
}
}
msg = rx.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
@@ -11,9 +11,16 @@ use super::ApiHandler;
impl ApiHandler {
/// WebSocket endpoint for browser clients to receive relayed companion input.
/// The browser's remote-relay.ts dispatches these as DOM keyboard/mouse events.
///
/// The kiosk also uses this socket in the *reverse* direction: when an "open
/// in external browser" app is launched, the kiosk can't usefully open it
/// itself, so it sends `{"t":"o","url":"https://…"}` here. We validate the
/// URL and publish it on `external_open_tx`, which the companion (phone)
/// socket forwards so the link opens in the phone's default browser.
pub(super) async fn handle_remote_relay(
req: Request<hyper::Body>,
mut relay_rx: broadcast::Receiver<String>,
external_open_tx: broadcast::Sender<String>,
) -> Result<Response<hyper::Body>> {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@@ -63,10 +70,20 @@ impl ApiHandler {
Err(broadcast::error::RecvError::Closed) => break,
}
}
// Handle client-side messages (pong, close)
// Handle client-side messages (pong, close, open-url requests)
client_msg = rx.next() => {
match client_msg {
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Ping(_))) => {}
Some(Ok(Message::Text(text))) => {
// The only kiosk→server message we accept is an
// external-open request: {"t":"o","url":"https://…"}.
if let Some(url) = parse_open_url(&text) {
debug!("Relaying external-open to companion: {}", url);
let _ = external_open_tx.send(
format!(r#"{{"t":"o","url":{}}}"#, json_string(&url))
);
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
@@ -81,3 +98,29 @@ impl ApiHandler {
Ok(response)
}
}
/// Parse a kiosk `{"t":"o","url":"…"}` external-open request, returning the URL
/// only if it's a well-formed http(s) URL. Anything else (other message tags,
/// non-http schemes like `javascript:`/`file:`, malformed JSON) is rejected so a
/// compromised kiosk page can't push arbitrary URIs to the phone.
fn parse_open_url(text: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
if v.get("t").and_then(|t| t.as_str()) != Some("o") {
return None;
}
let url = v.get("url").and_then(|u| u.as_str())?.trim();
if url.len() > 2048 {
return None;
}
let lower = url.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
Some(url.to_string())
} else {
None
}
}
/// Serialize a string as a JSON string literal (with surrounding quotes).
fn json_string(s: &str) -> String {
serde_json::Value::String(s.to_string()).to_string()
}