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:
co-authored by
Claude Opus 4.8
parent
c10f2ac22e
commit
bd567cd165
@@ -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, ®).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)
|
||||
}
|
||||
@@ -4,5 +4,6 @@
|
||||
pub mod bdhke;
|
||||
pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod mint_client;
|
||||
pub mod profits;
|
||||
|
||||
Reference in New Issue
Block a user