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
@@ -0,0 +1,285 @@
//! Thin HTTP bridge to the `fedimint-clientd` sidecar container.
//!
//! Keeps the heavy, fast-moving Fedimint client SDK OUT of this binary: the
//! `fedimint-clientd` daemon (in `apps/fedimint-clientd`) holds the federation
//! clients and ecash notes; we just speak its REST API (`/v2/*`, Bearer auth),
//! mirroring how [`super::mint_client::MintClient`] speaks the Cashu NUT API.
//!
//! See `docs/dual-ecash-design.md`. Endpoint/JSON shapes target fedimint-clientd
//! v0.3.x and must be pinned to the vendored image tag.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::debug;
const CLIENTD_TIMEOUT_SECS: u64 = 15;
const CLIENTD_HEAVY_TIMEOUT_SECS: u64 = 60;
/// Default host port the `fedimint-clientd` container is mapped to (its own
/// default 8080 collides with LND REST, so the manifest maps it to 8178).
const DEFAULT_CLIENTD_URL: &str = "http://127.0.0.1:8178";
/// Federation joined out-of-the-box on every node. The fmcd container also
/// auto-joins this at boot (`FMCD_INVITE_CODE` in the manifest); keep in sync.
///
/// The preferred default federation (guardian on .116, iroh transport).
/// Validated: fmcd 0.8.2 joins it (federation_id 2debd071…73b76884). iroh does
/// NAT traversal, so it's reachable fleet-wide — the right fleet default.
/// CAVEAT: iroh is experimental and the connection can be flaky (esp. NAT
/// hairpin when fmcd runs on .116 itself reaching .116's own WAN IP); validate
/// reliability from a separate node. ensure_default_federation is best-effort.
/// See docs/dual-ecash-design.md.
pub const DEFAULT_FEDERATION_INVITE: &str = "fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp";
/// One joined federation, persisted locally so the list survives clientd being
/// temporarily down. Balances are always read live from clientd.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinedFederation {
pub federation_id: String,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct FederationRegistry {
pub federations: Vec<JoinedFederation>,
}
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
let path = data_dir.join(REGISTRY_FILE);
if !path.exists() {
return Ok(FederationRegistry::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read fedimint federation registry")?;
Ok(serde_json::from_str(&content).unwrap_or_default())
}
pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let content = serde_json::to_string_pretty(reg).context("Failed to serialize registry")?;
fs::write(data_dir.join(REGISTRY_FILE), content)
.await
.context("Failed to write fedimint federation registry")?;
Ok(())
}
/// Idempotently ensure the node has joined the default federation and that it
/// is tracked in the local registry. Best-effort: silently no-ops if clientd
/// isn't installed/running yet. Joining is idempotent on the clientd side.
pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
let client = match FedimintClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Ok(()), // clientd not configured yet
};
let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await {
Ok(id) => id,
Err(e) => {
debug!("default federation autojoin skipped: {e}");
return Ok(());
}
};
let mut reg = load_registry(data_dir).await?;
if !reg.federations.iter().any(|f| f.federation_id == federation_id) {
reg.federations.push(JoinedFederation {
federation_id,
name: None,
});
save_registry(data_dir, &reg).await?;
}
Ok(())
}
/// HTTP client for a `fedimint-clientd` instance.
pub struct FedimintClient {
base_url: String,
password: String,
client: reqwest::Client,
}
impl FedimintClient {
pub fn new(base_url: &str, password: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(CLIENTD_HEAVY_TIMEOUT_SECS))
.build()
.context("Failed to build HTTP client for fedimint-clientd")?;
Ok(Self::with_client(base_url, password, client))
}
pub fn with_client(base_url: &str, password: &str, client: reqwest::Client) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
password: password.to_string(),
client,
}
}
/// Resolve URL + password from env / node secret, with sane defaults.
/// URL: `FEDIMINT_CLIENTD_URL` else the default mapped port.
/// Password: `FEDIMINT_CLIENTD_PASSWORD` else `<data_dir>/fedimint-clientd/password`.
pub async fn from_node(data_dir: &Path) -> Result<Self> {
let base_url =
std::env::var("FMCD_URL").unwrap_or_else(|_| DEFAULT_CLIENTD_URL.to_string());
let password = match std::env::var("FMCD_PASSWORD") {
Ok(p) if !p.is_empty() => p,
_ => {
let secret = data_dir.join("fmcd").join("password");
fs::read_to_string(&secret)
.await
.map(|s| s.trim().to_string())
.context(
"Fedimint client not configured (no FMCD_PASSWORD and no \
fmcd/password secret). Install the Fedimint client app.",
)?
}
};
Self::new(&base_url, &password)
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
// fmcd uses HTTP Basic auth with a fixed username `fmcd`.
req.basic_auth("fmcd", Some(&self.password))
}
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.post(&url))
.json(&body)
.send()
.await
.with_context(|| format!("fedimint-clientd POST {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn get(&self, path: &str) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.get(&url))
.timeout(std::time::Duration::from_secs(CLIENTD_TIMEOUT_SECS))
.send()
.await
.with_context(|| format!("fedimint-clientd GET {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("fedimint-clientd {path} returned {status}: {text}");
}
if text.is_empty() {
return Ok(serde_json::json!({}));
}
serde_json::from_str(&text)
.with_context(|| format!("fedimint-clientd {path} returned non-JSON: {text}"))
}
/// `GET /v2/admin/info` — per-federation holdings keyed by federationId.
pub async fn info(&self) -> Result<serde_json::Value> {
self.get("/v2/admin/info").await
}
/// `POST /v2/admin/join` — join a federation by invite code; returns its federationId.
pub async fn join(&self, invite_code: &str) -> Result<String> {
let res = self
.post(
"/v2/admin/join",
serde_json::json!({ "inviteCode": invite_code, "useManualSecret": false }),
)
.await?;
let id = res
.get("thisFederationId")
.or_else(|| res.get("federationId"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
match id {
Some(id) => {
debug!("joined fedimint federation {id}");
Ok(id)
}
// Older/newer clientd may return the full info map; fall back to info().
None => self.latest_federation_id().await,
}
}
/// Total balance across all joined federations, in sats.
pub async fn total_balance_sats(&self) -> Result<u64> {
let info = self.info().await?;
Ok(sum_msat(&info) / 1000)
}
/// Balance of one federation in sats (0 if unknown).
pub async fn federation_balance_sats(&self, federation_id: &str) -> Result<u64> {
let info = self.info().await?;
let msat = info
.get(federation_id)
.and_then(federation_msat)
.unwrap_or(0);
Ok(msat / 1000)
}
/// `POST /v2/mint/spend` — prepare notes to send (ecash), in msat. Returns serialized notes.
pub async fn spend(&self, federation_id: &str, amount_sats: u64) -> Result<String> {
let res = self
.post(
"/v2/mint/spend",
serde_json::json!({
"federationId": federation_id,
"amountMsat": amount_sats * 1000,
"allowOverpay": true,
"timeout": 3600,
"includeInvite": false,
}),
)
.await?;
res.get("notes")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("fedimint spend: no notes in response"))
}
/// `POST /v2/mint/reissue` — redeem received notes; returns reissued sats.
pub async fn reissue(&self, federation_id: &str, notes: &str) -> Result<u64> {
let res = self
.post(
"/v2/mint/reissue",
serde_json::json!({ "federationId": federation_id, "notes": notes }),
)
.await?;
let msat = res
.get("amountMsat")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("fedimint reissue: no amountMsat in response"))?;
Ok(msat / 1000)
}
async fn latest_federation_id(&self) -> Result<String> {
let info = self.info().await?;
info.as_object()
.and_then(|m| m.keys().next_back().cloned())
.ok_or_else(|| anyhow::anyhow!("joined federation but clientd reported none"))
}
}
fn federation_msat(entry: &serde_json::Value) -> Option<u64> {
entry
.get("totalAmountMsat")
.or_else(|| entry.get("totalMsat"))
.and_then(|v| v.as_u64())
}
fn sum_msat(info: &serde_json::Value) -> u64 {
info.as_object()
.map(|m| m.values().filter_map(federation_msat).sum())
.unwrap_or(0)
}
+1
View File
@@ -4,5 +4,6 @@
pub mod bdhke;
pub mod cashu;
pub mod ecash;
pub mod fedimint_client;
pub mod mint_client;
pub mod profits;