556 lines
22 KiB
Rust
556 lines
22 KiB
Rust
//! 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";
|
|
|
|
/// Shared HTTP-Basic password between the fmcd container and this bridge. The
|
|
/// fedimint-clientd manifest generates it via `generated_secrets: [fmcd-password]`
|
|
/// and injects it through `secret_env`; the bridge reads the same file in
|
|
/// `from_node`. (Generation lives in `container::secrets`, not here — it's a
|
|
/// generic, manifest-declared concern, not fedimint-specific.)
|
|
const FMCD_PASSWORD_SECRET: &str = "fmcd-password";
|
|
|
|
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(())
|
|
}
|
|
|
|
/// Local Fedimint transaction log. fmcd has no per-node history API, so we
|
|
/// record each redeem/spend ourselves and merge it with the Cashu history in
|
|
/// `wallet.ecash-history` — otherwise a Fedimint receive shows nowhere.
|
|
const FEDIMINT_TX_FILE: &str = "wallet/fedimint_transactions.json";
|
|
|
|
/// Load the local Fedimint transaction log (newest entries last). Empty on any
|
|
/// error — history is best-effort and must never block a wallet operation.
|
|
pub async fn load_fedimint_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
|
|
match fs::read_to_string(data_dir.join(FEDIMINT_TX_FILE)).await {
|
|
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Append a Fedimint transaction to the local log so it appears in unified
|
|
/// history with meaningful data. Best-effort: write failures are logged, not
|
|
/// propagated, so they never fail the redeem/spend that produced the funds.
|
|
pub async fn record_fedimint_tx(
|
|
data_dir: &Path,
|
|
tx_type: crate::wallet::ecash::TransactionType,
|
|
amount_sats: u64,
|
|
federation_id: &str,
|
|
description: &str,
|
|
) {
|
|
let mut txs = load_fedimint_txs(data_dir).await;
|
|
txs.push(crate::wallet::ecash::EcashTransaction {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
tx_type,
|
|
amount_sats,
|
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
description: description.to_string(),
|
|
// Cashu uses mint_url; for Fedimint we record the federation id in `peer`
|
|
// so the UI can show which federation the funds moved through.
|
|
mint_url: String::new(),
|
|
peer: federation_id.to_string(),
|
|
kind: "fedimint".to_string(),
|
|
});
|
|
// Cap the log so it can't grow unbounded.
|
|
let len = txs.len();
|
|
if len > 500 {
|
|
txs.drain(0..len - 500);
|
|
}
|
|
if let Err(e) = fs::create_dir_all(data_dir.join("wallet")).await {
|
|
tracing::warn!("fedimint tx log: could not create wallet dir: {e}");
|
|
return;
|
|
}
|
|
match serde_json::to_string_pretty(&txs) {
|
|
Ok(content) => {
|
|
if let Err(e) = fs::write(data_dir.join(FEDIMINT_TX_FILE), content).await {
|
|
tracing::warn!("fedimint tx log: write failed: {e}");
|
|
}
|
|
}
|
|
Err(e) => tracing::warn!("fedimint tx log: serialize failed: {e}"),
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
};
|
|
|
|
// Fast path: if fmcd already reports a joined federation, do NOT re-issue the
|
|
// POST /v2/admin/join. That call re-syncs federation config against the
|
|
// guardians and adds seconds of latency — and ensure_default_federation runs
|
|
// on every wallet.fedimint-list / spend / reissue, so the join was being paid
|
|
// on each balance refresh (the "mints take ages to load" report). The cheap
|
|
// GET /v2/admin/info is enough to confirm membership; just reconcile the local
|
|
// registry against the live joined set and return.
|
|
let joined = client.joined_federation_ids().await;
|
|
if !joined.is_empty() {
|
|
let mut reg = load_registry(data_dir).await?;
|
|
let mut changed = false;
|
|
for id in joined {
|
|
if !reg.federations.iter().any(|f| f.federation_id == id) {
|
|
reg.federations.push(JoinedFederation {
|
|
federation_id: id,
|
|
name: Some("Archipelago Federation".to_string()),
|
|
});
|
|
changed = true;
|
|
}
|
|
}
|
|
if changed {
|
|
save_registry(data_dir, ®).await?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Cold start only: nothing joined yet, so join the default federation once.
|
|
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: Some("Archipelago Federation".to_string()),
|
|
});
|
|
save_registry(data_dir, ®).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Spend `amount_sats` of Fedimint ecash from whichever joined federation can
|
|
/// cover it, returning the serialized notes (the X-Payment-Token a buyer hands
|
|
/// the seller) and the federation id that minted them. Federations are tried in
|
|
/// registry order (default first); only one with sufficient balance is used so
|
|
/// the resulting notes redeem cleanly on the other side. Errors clearly when no
|
|
/// federation is joined or none has the balance — the caller falls back to (or
|
|
/// from) the Cashu path.
|
|
pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String, String)> {
|
|
if amount_sats == 0 {
|
|
anyhow::bail!("payment amount must be greater than zero");
|
|
}
|
|
let _ = ensure_default_federation(data_dir).await;
|
|
let client = FedimintClient::from_node(data_dir).await?;
|
|
|
|
// Same union-of-sources approach as reissue_into_any: the persisted registry
|
|
// and what fmcd actually reports joined can drift, so consider both.
|
|
let mut fed_ids: Vec<String> = Vec::new();
|
|
if let Ok(reg) = load_registry(data_dir).await {
|
|
for f in reg.federations {
|
|
if !fed_ids.contains(&f.federation_id) {
|
|
fed_ids.push(f.federation_id);
|
|
}
|
|
}
|
|
}
|
|
for id in client.joined_federation_ids().await {
|
|
if !fed_ids.contains(&id) {
|
|
fed_ids.push(id);
|
|
}
|
|
}
|
|
if fed_ids.is_empty() {
|
|
anyhow::bail!("No Fedimint federation joined to spend from");
|
|
}
|
|
|
|
let mut last_err = None;
|
|
for fed_id in &fed_ids {
|
|
// Skip federations that can't cover the amount so we don't mint a
|
|
// partial/failed spend and leave dangling reserved notes.
|
|
match client.federation_balance_sats(fed_id).await {
|
|
Ok(bal) if bal >= amount_sats => {}
|
|
Ok(_) => continue,
|
|
Err(e) => {
|
|
last_err = Some(e);
|
|
continue;
|
|
}
|
|
}
|
|
match client.spend(fed_id, amount_sats).await {
|
|
Ok(notes) => {
|
|
record_fedimint_tx(
|
|
data_dir,
|
|
crate::wallet::ecash::TransactionType::Send,
|
|
amount_sats,
|
|
fed_id,
|
|
"Sent Fedimint ecash",
|
|
)
|
|
.await;
|
|
return Ok((notes, fed_id.clone()));
|
|
}
|
|
Err(e) => last_err = Some(e),
|
|
}
|
|
}
|
|
Err(last_err
|
|
.map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}"))
|
|
.unwrap_or_else(|| {
|
|
anyhow::anyhow!("No joined Fedimint federation has {amount_sats} sats available")
|
|
}))
|
|
}
|
|
|
|
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
|
|
/// per-federation, but a token only validates against the federation that
|
|
/// minted it, so we try each joined federation (default first) and return the
|
|
/// first that accepts the notes, along with its id. Errors clearly when the
|
|
/// fmcd sidecar isn't installed or no federation is joined — the Cashu path is
|
|
/// handled separately by the caller.
|
|
pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, String)> {
|
|
// Make sure at least the default federation is tracked before we try.
|
|
let _ = ensure_default_federation(data_dir).await;
|
|
|
|
let client = FedimintClient::from_node(data_dir).await?;
|
|
|
|
// Build the set of federations to try: the locally-persisted registry PLUS
|
|
// every federation the fmcd sidecar actually reports joined. The two can
|
|
// drift (a federation joined directly, before tracking, or a registry that
|
|
// wasn't written), and a note only validates against the federation that
|
|
// minted it — so we must try EVERY connected federation before giving up,
|
|
// or a perfectly valid token is wrongly reported as failed.
|
|
let mut fed_ids: Vec<String> = Vec::new();
|
|
if let Ok(reg) = load_registry(data_dir).await {
|
|
for f in reg.federations {
|
|
if !fed_ids.contains(&f.federation_id) {
|
|
fed_ids.push(f.federation_id);
|
|
}
|
|
}
|
|
}
|
|
for id in client.joined_federation_ids().await {
|
|
if !fed_ids.contains(&id) {
|
|
fed_ids.push(id);
|
|
}
|
|
}
|
|
if fed_ids.is_empty() {
|
|
anyhow::bail!("No Fedimint federation joined to redeem these notes into");
|
|
}
|
|
|
|
let mut already_redeemed = false;
|
|
let mut last_err = None;
|
|
for fed_id in &fed_ids {
|
|
match client.reissue(fed_id, notes).await {
|
|
Ok(sats) => {
|
|
// Record the receive so it appears in unified ecash history.
|
|
record_fedimint_tx(
|
|
data_dir,
|
|
crate::wallet::ecash::TransactionType::Receive,
|
|
sats,
|
|
fed_id,
|
|
"Received Fedimint ecash",
|
|
)
|
|
.await;
|
|
return Ok((sats, fed_id.clone()));
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_string().to_ascii_lowercase();
|
|
// fmcd reports already-claimed notes as "We already reissued
|
|
// these notes" (or "already spent"). That means the funds were
|
|
// already redeemed INTO this node's wallet — they're safe, just
|
|
// not new — so surface that clearly instead of a raw 500.
|
|
if msg.contains("already reissued") || msg.contains("already spent") {
|
|
already_redeemed = true;
|
|
}
|
|
last_err = Some(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if already_redeemed {
|
|
anyhow::bail!(
|
|
"This ecash was already redeemed into your wallet — the notes are \
|
|
already claimed, so no new balance was added. Check your Fedimint balance."
|
|
);
|
|
}
|
|
Err(last_err
|
|
.map(|e| {
|
|
anyhow::anyhow!(
|
|
"These notes didn't match any of your {} connected Fedimint \
|
|
federation(s). You may need to join the federation that issued \
|
|
them first. (last error: {e})",
|
|
fed_ids.len()
|
|
)
|
|
})
|
|
.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
|
|
}
|
|
|
|
/// 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,
|
|
_ => {
|
|
// The shared secret the fmcd container also reads (manifest
|
|
// secret_env: fmcd-password, resolved from <data_dir>/secrets).
|
|
// Legacy <data_dir>/fmcd/password kept as a fallback.
|
|
let shared = data_dir.join("secrets").join(FMCD_PASSWORD_SECRET);
|
|
let legacy = data_dir.join("fmcd").join("password");
|
|
let mut found = None;
|
|
for candidate in [shared, legacy] {
|
|
if let Ok(s) = fs::read_to_string(&candidate).await {
|
|
let s = s.trim().to_string();
|
|
if !s.is_empty() {
|
|
found = Some(s);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
found.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
|
|
}
|
|
|
|
/// Every federation id the fmcd sidecar currently reports joined, read from
|
|
/// `/v2/admin/info` (the authoritative live set — the locally-persisted
|
|
/// registry can drift from it). Returns an empty vec on any error so callers
|
|
/// can fall back to the registry rather than fail outright.
|
|
pub async fn joined_federation_ids(&self) -> Vec<String> {
|
|
match self.info().await {
|
|
Ok(info) => info
|
|
.as_object()
|
|
.map(|m| m.keys().cloned().collect())
|
|
.unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// `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)
|
|
}
|