diff --git a/Cargo.lock b/Cargo.lock index d3e3a08..4f8aece 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2237,6 +2237,7 @@ dependencies = [ "chrono", "fedimint-core 0.8.0-beta.2", "serde", + "serde_json", ] [[package]] diff --git a/flake.lock b/flake.lock index f9935fa..fe3548f 100644 --- a/flake.lock +++ b/flake.lock @@ -168,11 +168,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1771848320, - "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "lastModified": 1782467914, + "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 99d405b..23a10e7 100644 --- a/flake.nix +++ b/flake.nix @@ -13,6 +13,13 @@ let pkgs = import nixpkgs { inherit system; + overlays = [ + (final: prev: { + nodePackages = { + bash-language-server = final.bash-language-server; + }; + }) + ]; }; flakeboxLib = flakebox.lib.mkLib pkgs { }; lib = pkgs.lib; @@ -33,7 +40,7 @@ wasm-pack trunk nodejs - nodePackages.tailwindcss + tailwindcss ]; }; targets = (pkgs.lib.getAttrs @@ -130,7 +137,7 @@ wasm-pack nodejs binaryen - nodePackages.tailwindcss + tailwindcss ]; FMO_API_SERVER = api; @@ -157,7 +164,7 @@ # Get the npm dependencies hash # To update: nix build .#fmo_frontend_react_default --impure # and use the hash from the error message - npmDepsHash = "sha256-d04Zjrg1mOhnO7FgG6rvDSh0ovt+z7PqIE8FCSG2Czk="; + npmDepsHash = "sha256-j+iWBBnZQLHcFx65dkyET3nKjT3W7WQQDI722xVyJNg="; in rec { fmo_frontend_react = api: pkgs.buildNpmPackage { diff --git a/fmo_api_types/Cargo.toml b/fmo_api_types/Cargo.toml index ce83cc5..beca5ef 100644 --- a/fmo_api_types/Cargo.toml +++ b/fmo_api_types/Cargo.toml @@ -8,3 +8,4 @@ bitcoin = { version = "0.32.5", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] } fedimint-core = { workspace = true } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/fmo_api_types/src/lib.rs b/fmo_api_types/src/lib.rs index b4c6d17..a71eaf7 100644 --- a/fmo_api_types/src/lib.rs +++ b/fmo_api_types/src/lib.rs @@ -1,4 +1,5 @@ use bitcoin::address::NetworkUnchecked; +use chrono::{DateTime, Utc}; use fedimint_core::config::FederationId; use fedimint_core::Amount; use serde::{Deserialize, Serialize}; @@ -40,10 +41,70 @@ pub struct FederationUtxo { pub amount: Amount, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FederationUtxosResponse { + pub observed: Vec, + pub guardian_claims: Vec, + pub disagreements: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuardianUtxoClaim { + pub guardian_id: u16, + pub status: GuardianUtxoClaimStatus, + pub utxos: Vec, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianUtxoClaimStatus { + Unavailable, + Ok, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuardianClaimedUtxo { + pub out_point: bitcoin::OutPoint, + pub amount: Amount, + pub state: GuardianClaimedUtxoState, + #[serde(skip_serializing_if = "Option::is_none")] + pub onchain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuardianClaimedUtxoOnchain { + pub script_pubkey: String, + pub address: Option, + pub amount: Amount, + pub confirmed: bool, + pub block_height: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianClaimedUtxoState { + Spendable, + UnsignedPegOut, + UnsignedChange, + UnconfirmedPegOut, + UnconfirmedChange, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuardianUtxoDisagreement { + pub out_point: bitcoin::OutPoint, + pub description: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GuardianHealth { pub avg_uptime: f32, pub avg_latency: f32, + pub software_version: Option, pub latest: Option, } @@ -63,6 +124,60 @@ pub enum FederationHealth { Offline, } +/// Subset of a gateway's registration info suitable for public API responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayInfo { + /// Gateway's public key (hex-encoded) + pub gateway_id: String, + /// LN node public key (hex-encoded) + pub node_pub_key: String, + pub lightning_alias: String, + /// URL of the gateway's public API + pub api_endpoint: String, + /// Whether the federation has vetted this gateway + pub vetted: bool, + /// Full raw announcement, useful for forwards-compatible client usage + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + /// First time this gateway was seen by the observer + #[serde(skip_serializing_if = "Option::is_none")] + pub first_seen: Option>, + /// Most recent time this gateway was seen by the observer + #[serde(skip_serializing_if = "Option::is_none")] + pub last_seen: Option>, + /// Real LN activity metrics over the last 7 days + #[serde(skip_serializing_if = "Option::is_none")] + pub activity_7d: Option, + /// Real LN activity metrics over the requested API window + #[serde(skip_serializing_if = "Option::is_none")] + pub activity_window: Option, + /// Uptime metrics computed from periodic gateway snapshots over the + /// requested window + #[serde(skip_serializing_if = "Option::is_none")] + pub uptime_window: Option, + /// The window label used for `activity_window` and `uptime_window`, e.g. + /// `7d` + #[serde(skip_serializing_if = "Option::is_none")] + pub metrics_window: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayActivityMetrics { + pub fund_count: u64, + pub settle_count: u64, + pub cancel_count: u64, + pub total_volume_msat: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayUptimeMetrics { + pub sample_count: u64, + pub seen_samples: u64, + pub online_minutes: u64, + pub offline_minutes: u64, + pub uptime_pct: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NoncesRequest { pub nonces: Vec, diff --git a/fmo_frontend/src/components/federation/guardians.rs b/fmo_frontend/src/components/federation/guardians.rs index ecd6e66..a7be8b5 100644 --- a/fmo_frontend/src/components/federation/guardians.rs +++ b/fmo_frontend/src/components/federation/guardians.rs @@ -46,6 +46,9 @@ pub fn Guardians(federation_id: FederationId, guardians: Vec) -> impl let health = health.get(&PeerId::from(guardian_idx as u16)).expect("Guardian exists").clone(); let mut badges = vec![]; + let software_version = health + .software_version + .unwrap_or_else(|| "Version unknown".to_owned()); if let Some(latest) = health.latest { badges.push(view! { @@ -77,6 +80,11 @@ pub fn Guardians(federation_id: FederationId, guardians: Vec) -> impl }.into_view()); } + badges.push(view! { + + {software_version} + + }.into_view()); view! { {badges} }.into_any() } diff --git a/fmo_frontend/src/components/federation/stars_selector.rs b/fmo_frontend/src/components/federation/stars_selector.rs new file mode 100644 index 0000000..711f481 --- /dev/null +++ b/fmo_frontend/src/components/federation/stars_selector.rs @@ -0,0 +1,50 @@ +use leptos::prelude::*; + +#[component] +pub fn StarsSelector(default_value: u8, selected_stars: WriteSignal) -> impl IntoView { + selected_stars.set(default_value); + + let (selected, set_selected) = signal(default_value); + let (hover, set_hover) = signal(None); + + let star = move |star_idx: u8, fill: bool, border: bool| { + view! { + + } + }; + + view! { +
+ { move || { + let selected = selected.get(); + let hover = hover.get(); + (1..=5).map(|idx| star(idx, selected >= idx, hover.is_some_and(|h| h >= idx))).collect::>() + }} +
+ } +} diff --git a/fmo_frontend_react/package-lock.json b/fmo_frontend_react/package-lock.json index fed30f3..513e81d 100644 --- a/fmo_frontend_react/package-lock.json +++ b/fmo_frontend_react/package-lock.json @@ -2553,13 +2553,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -2654,9 +2657,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { diff --git a/fmo_frontend_react/src/App.tsx b/fmo_frontend_react/src/App.tsx index de29c8c..2ebd4a9 100644 --- a/fmo_frontend_react/src/App.tsx +++ b/fmo_frontend_react/src/App.tsx @@ -3,6 +3,7 @@ import { NavBar } from './components/NavBar'; import { Home } from './pages/Home'; import { Nostr } from './pages/Nostr'; import { FederationDetail } from './pages/FederationDetail'; +import { FederationGateways } from './pages/FederationGateways'; import { useTheme } from './hooks/useTheme'; function App() { @@ -16,6 +17,7 @@ function App() { } /> } /> } /> + } /> Page not found} /> diff --git a/fmo_frontend_react/src/components/GatewayWarningPage.tsx b/fmo_frontend_react/src/components/GatewayWarningPage.tsx new file mode 100644 index 0000000..54115ea --- /dev/null +++ b/fmo_frontend_react/src/components/GatewayWarningPage.tsx @@ -0,0 +1,42 @@ +export type GatewayWarningLevel = 'info' | 'warning' | 'error'; + +export interface GatewayWarningState { + level: GatewayWarningLevel; + title: string; + message: string; + detail?: string; +} + +interface GatewayWarningPageProps { + warning: GatewayWarningState; + className?: string; +} + +function levelClasses(level: GatewayWarningLevel): string { + switch (level) { + case 'info': + return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200'; + case 'error': + return 'border-red-200 bg-red-50 text-red-900 dark:border-red-700 dark:bg-red-900/30 dark:text-red-200'; + default: + return 'border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-200'; + } +} + +export function GatewayWarningPage({ warning, className = '' }: GatewayWarningPageProps) { + return ( +
+

{warning.title}

+

{warning.message}

+ {warning.detail && ( +

+ {warning.detail} +

+ )} +
+ ); +} diff --git a/fmo_frontend_react/src/pages/FederationDetail.tsx b/fmo_frontend_react/src/pages/FederationDetail.tsx index 6ce2763..4c77796 100644 --- a/fmo_frontend_react/src/pages/FederationDetail.tsx +++ b/fmo_frontend_react/src/pages/FederationDetail.tsx @@ -2,11 +2,19 @@ import { useEffect, useState, useMemo, lazy, Suspense } from 'react'; import { useParams, Link } from 'react-router-dom'; import { QRCodeSVG } from 'qrcode.react'; import { api } from '../services/api'; -import type { FederationSummary } from '../types/api'; +import type { + FederationSummary, + FederationUtxo, + GuardianUtxoClaim, + GuardianUtxoDisagreement, +} from '../types/api'; import { Badge } from '../components/Badge'; import { Alert } from '../components/Alert'; import { Copyable } from '../components/Copyable'; +const MSATS_PER_BTC = 100_000_000_000; +const MAX_CLAIMED_UTXOS_PER_GUARDIAN = 5; + // Lazy load the chart component for code splitting const TransactionChart = lazy(() => import('../components/TransactionChart').then(module => ({ default: module.TransactionChart }))); @@ -24,6 +32,7 @@ interface Guardian { interface GuardianHealth { avg_uptime: number; avg_latency: number; + software_version: string | null; latest: { block_height: number; block_outdated: boolean; @@ -40,12 +49,6 @@ interface FederationConfig { rawConfig: Record; // Store raw config for display } -interface UTXO { - out_point: string; - amount: number; // millisats - address: string; -} - interface HistogramEntry { date: string; volume: number; @@ -60,7 +63,9 @@ export function FederationDetail() { const { id } = useParams<{ id: string }>(); const [federation, setFederation] = useState(null); const [config, setConfig] = useState(null); - const [utxos, setUtxos] = useState([]); + const [utxos, setUtxos] = useState([]); + const [guardianUtxoClaims, setGuardianUtxoClaims] = useState([]); + const [utxoDisagreements, setUtxoDisagreements] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState<'activity' | 'utxos' | 'config'>('activity'); @@ -241,12 +246,10 @@ export function FederationDetail() { const fetchUTXOs = async (federationId: string) => { setUtxosLoading(true); try { - const BASE_URL = import.meta.env.VITE_FMO_API_BASE_URL || 'https://observer.fedimint.org/api'; - const response = await fetch(`${BASE_URL}/federations/${federationId}/utxos`); - if (response.ok) { - const data = await response.json(); - setUtxos(data); - } + const data = await api.getFederationUtxos(federationId); + setUtxos(data.observed); + setGuardianUtxoClaims(data.guardian_claims); + setUtxoDisagreements(data.disagreements); } catch (err) { console.error('Failed to fetch UTXOs:', err); } finally { @@ -359,6 +362,7 @@ export function FederationDetail() { const block = health?.latest ? health.latest.block_height - 1 : 0; const sessionOutdated = health?.latest?.session_outdated || false; const blockOutdated = health?.latest?.block_outdated || false; + const softwareVersion = health?.software_version || 'Version unknown'; return (
@@ -378,6 +382,9 @@ export function FederationDetail() { {isOnline ? 'Online' : 'Offline'} + + {softwareVersion} + {isOnline && ( <>
+ {id && ( +
+ + Gateway Details + +
+ )} {federation?.invite && hasOnlineGuardian && (
Invite Link
@@ -674,9 +691,179 @@ export function FederationDetail() { message="The UTXO view is reconstructed from a combination of the public federation log and on-chain transactions, hence unconfirmed change UTXOs may be missing." /> +
+
+
+ Guardian Claims +
+
+ {guardianUtxoClaims.filter((claim) => claim.status === 'ok').length}/{guardianUtxoClaims.length} +
+
+ guardians returned wallet summaries +
+
+
+
+ Claimed UTXOs +
+
+ {guardianUtxoClaims.reduce((sum, claim) => sum + claim.utxos.length, 0)} +
+
+ across all guardian responses +
+
+
+
+ Disagreements +
+
+ {utxoDisagreements.length} +
+
+ observer vs guardian claim checks +
+
+
+ + {guardianUtxoClaims.length > 0 && ( +
+
+ Guardian Wallet Claims +
+
+ {guardianUtxoClaims.map((claim) => { + const claimedTotalMsats = claim.utxos.reduce((sum, utxo) => sum + utxo.amount, 0); + const stateCounts = summarizeGuardianClaimStates(claim); + const visibleUtxos = claim.utxos.slice(0, MAX_CLAIMED_UTXOS_PER_GUARDIAN); + const hiddenUtxoCount = claim.utxos.length - visibleUtxos.length; + + return ( +
+
+
+
+
+ Guardian {claim.guardian_id} +
+ + {claim.status} + +
+ + {claim.error && ( +
+ {claim.error} +
+ )} + + {stateCounts.length > 0 && ( +
+ {stateCounts.map(([state, count]) => ( + + {formatUtxoState(state)}: {count} + + ))} +
+ )} + + {visibleUtxos.length > 0 && ( +
+ {visibleUtxos.map((utxo) => ( +
+
+ + {utxo.out_point} + + {utxo.onchain?.address && ( + + {utxo.onchain.address} + + )} + {utxo.onchain && !utxo.onchain.address && ( +
+ script {utxo.onchain.script_pubkey} +
+ )} + {utxo.resolution_error && ( +
+ {utxo.resolution_error} +
+ )} +
+
+ {formatUtxoState(utxo.state)} + {utxo.onchain && ( + {utxo.onchain.confirmed ? `Block ${utxo.onchain.block_height ?? 'confirmed'}` : 'Unconfirmed'} + )} + + {formatMsatsAsBtc(utxo.amount)} + +
+
+ ))} + {hiddenUtxoCount > 0 && ( +
+ +{hiddenUtxoCount} more claimed UTXOs +
+ )} +
+ )} +
+ +
+
+ {claim.utxos.length} UTXOs +
+
+ {formatMsatsAsBtc(claimedTotalMsats)} +
+
+
+
+ ); + })} +
+
+ )} + + {utxoDisagreements.length > 0 && ( +
+
+ UTXO Disagreements +
+
+ {utxoDisagreements.map((disagreement, index) => ( +
+
+ {disagreement.out_point} +
+
+ {disagreement.description} +
+
+ ))} +
+
+ )} +
- UTXOs ({utxos.length} total) + Observed UTXOs ({utxos.length} total)
{utxosLoading ? ( @@ -694,7 +881,7 @@ export function FederationDetail() {
Amount - {(utxo.amount / 100000000000).toFixed(8)} BTC + {formatMsatsAsBtc(utxo.amount)}
@@ -731,6 +927,35 @@ export function FederationDetail() { ); } +function formatMsatsAsBtc(msats: number): string { + return `${(msats / MSATS_PER_BTC).toFixed(8)} BTC`; +} + +function mempoolTxUrl(outPoint: string): string { + const [txid] = outPoint.split(':'); + return `https://mempool.space/tx/${txid}`; +} + +function mempoolAddressUrl(address: string): string { + return `https://mempool.space/address/${address}`; +} + +function formatUtxoState(state: string): string { + return state + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +function summarizeGuardianClaimStates(claim: GuardianUtxoClaim): Array<[string, number]> { + const counts = claim.utxos.reduce>((acc, utxo) => { + acc[utxo.state] = (acc[utxo.state] || 0) + 1; + return acc; + }, {}); + + return Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)); +} + async function fetchFederationConfig(federationId: string, inviteCode: string): Promise { const BASE_URL = import.meta.env.VITE_FMO_API_BASE_URL || 'https://observer.fedimint.org/api'; diff --git a/fmo_frontend_react/src/pages/FederationGateways.tsx b/fmo_frontend_react/src/pages/FederationGateways.tsx new file mode 100644 index 0000000..ca49f85 --- /dev/null +++ b/fmo_frontend_react/src/pages/FederationGateways.tsx @@ -0,0 +1,890 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../services/api'; +import type { FederationSummary, GatewayInfo, GatewayWindow } from '../types/api'; +import { GatewayWarningPage, type GatewayWarningState } from '../components/GatewayWarningPage'; + +type GatewayStatus = 'online' | 'degraded' | 'offline' | 'unknown'; +type UptimeStripStatus = 'online' | 'degraded' | 'offline' | 'unknown'; + +interface GatewayWithStatus extends GatewayInfo { + firstSeenDate: Date | null; + lastSeenDate: Date | null; + status: GatewayStatus; + minutesSinceLastSeen: number | null; + inferredOfflineMinutes: number; + estimatedOfflineMinutes: number; + estimatedOnlineMinutes: number; + estimatedUnknownMinutes: number; + estimatedUptimePct: number; + coveragePct: number; + realActivityScore: number | null; + fundCountWindow: number; + settleCountWindow: number; + cancelCountWindow: number; + totalVolumeMsatWindow: number; +} + +function parseTimestamp(value?: string): Date | null { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function getGatewayStatus(lastSeen: Date | null): GatewayStatus { + if (!lastSeen) return 'unknown'; + const minutes = (Date.now() - lastSeen.getTime()) / (1000 * 60); + if (minutes <= 10) return 'online'; + if (minutes <= 30) return 'degraded'; + return 'offline'; +} + +function formatDateTime(date: Date | null): string { + if (!date) return 'N/A'; + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatRelative(date: Date | null): string { + if (!date) return 'Never seen'; + + const diffMs = Date.now() - date.getTime(); + const minutes = Math.floor(diffMs / (1000 * 60)); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function shortId(value: string): string { + if (value.length <= 16) return value; + return `${value.slice(0, 8)}...${value.slice(-8)}`; +} + +function formatDuration(minutes: number): string { + const safe = Math.max(0, Math.floor(minutes)); + const days = Math.floor(safe / (60 * 24)); + const hours = Math.floor((safe % (60 * 24)) / 60); + const mins = safe % 60; + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +function formatCompactDuration(minutes: number): string { + const safe = Math.max(0, Math.round(minutes)); + if (safe >= 60 * 24) return `${Math.round(safe / (60 * 24))}d`; + if (safe >= 60) return `${Math.round(safe / 60)}h`; + if (safe === 0) return '0m'; + return `${safe}m`; +} + +function formatMsats(msat: number): string { + const sats = msat / 1000; + if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`; + if (sats >= 100_000) return `${(sats / 100_000).toFixed(1)}M sats`; + if (sats >= 1_000) return `${(sats / 1000).toFixed(1)}k sats`; + return `${Math.round(sats).toLocaleString()} sats`; +} + +function statusClasses(status: GatewayStatus): string { + switch (status) { + case 'online': + return 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'; + case 'degraded': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300'; + case 'offline': + return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'; + default: + return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + } +} + +function getUptimeStripClass(status: UptimeStripStatus): string { + switch (status) { + case 'online': + return 'bg-green-500 dark:bg-green-400'; + case 'degraded': + return 'bg-yellow-500 dark:bg-yellow-400'; + case 'offline': + return 'bg-red-500 dark:bg-red-400'; + default: + return 'bg-gray-300 dark:bg-gray-600'; + } +} + +function buildUptimeStrip(gateway: GatewayWithStatus, windowMinutes: number): UptimeStripStatus[] { + const segments = 30; + + if (gateway.status === 'unknown' || windowMinutes <= 0) { + return Array.from({ length: segments }, () => 'unknown'); + } + + const strip: UptimeStripStatus[] = Array.from({ length: segments }, () => 'unknown'); + const unknownMinutes = Math.max(0, Math.min(windowMinutes, gateway.estimatedUnknownMinutes)); + const unknownSegments = Math.max( + 0, + Math.min(segments, Math.round((unknownMinutes / Math.max(1, windowMinutes)) * segments)), + ); + const observedSegments = Math.max(0, segments - unknownSegments); + const observedStart = unknownSegments; + + for (let idx = observedStart; idx < segments; idx += 1) { + strip[idx] = 'online'; + } + + if (observedSegments === 0) { + return strip; + } + + const sampledMinutes = Math.max( + 1, + gateway.estimatedOnlineMinutes + gateway.estimatedOfflineMinutes, + ); + const offlineMinutes = Math.max(0, gateway.estimatedOfflineMinutes); + const offlineSegments = Math.max( + 0, + Math.min(observedSegments, Math.round((offlineMinutes / sampledMinutes) * observedSegments)), + ); + const offlineStatus: UptimeStripStatus = gateway.status === 'degraded' ? 'degraded' : 'offline'; + + for (let idx = segments - 1; idx >= segments - offlineSegments; idx -= 1) { + if (idx >= 0) strip[idx] = offlineStatus; + } + + if (gateway.status === 'degraded') { + strip[segments - 1] = 'degraded'; + } else if (gateway.status === 'offline') { + strip[segments - 1] = 'offline'; + } else { + strip[segments - 1] = 'online'; + } + + return strip; +} + +function getUptimeBucketLabel( + bucketIndex: number, + totalBuckets: number, + windowMinutes: number, +): string { + const minutesPerBucket = windowMinutes / totalBuckets; + const newestEndMinutes = (totalBuckets - bucketIndex) * minutesPerBucket; + const newestStartMinutes = (totalBuckets - bucketIndex - 1) * minutesPerBucket; + + const start = formatCompactDuration(newestStartMinutes); + const end = formatCompactDuration(newestEndMinutes); + return `${start}–${end} ago`; +} + +function mergeGatewayData(observedGateways: GatewayInfo[], liveGateways: GatewayInfo[]): GatewayInfo[] { + if (observedGateways.length === 0) return liveGateways; + if (liveGateways.length === 0) return observedGateways; + + const observedById = new Map(observedGateways.map((gateway) => [gateway.gateway_id, gateway] as const)); + const liveIds = new Set(liveGateways.map((gateway) => gateway.gateway_id)); + + const merged = liveGateways.map((liveGateway) => { + const observedGateway = observedById.get(liveGateway.gateway_id); + if (!observedGateway) return liveGateway; + + return { + ...liveGateway, + lightning_alias: liveGateway.lightning_alias || observedGateway.lightning_alias, + api_endpoint: liveGateway.api_endpoint || observedGateway.api_endpoint, + node_pub_key: liveGateway.node_pub_key || observedGateway.node_pub_key, + vetted: liveGateway.vetted || observedGateway.vetted, + raw: liveGateway.raw ?? observedGateway.raw, + first_seen: observedGateway.first_seen ?? liveGateway.first_seen, + last_seen: observedGateway.last_seen ?? liveGateway.last_seen, + activity_7d: observedGateway.activity_7d ?? liveGateway.activity_7d, + activity_window: observedGateway.activity_window ?? liveGateway.activity_window, + uptime_window: observedGateway.uptime_window ?? liveGateway.uptime_window, + metrics_window: observedGateway.metrics_window ?? liveGateway.metrics_window, + }; + }); + + for (const observedGateway of observedGateways) { + if (!liveIds.has(observedGateway.gateway_id)) { + merged.push(observedGateway); + } + } + + return merged; +} + +interface GatewaySelection { + gateways: GatewayInfo[]; + warning: GatewayWarningState | null; +} + +function selectGatewayData( + observedGateways: GatewayInfo[], + liveGateways: GatewayInfo[], + observedError: string | null, + liveError: string | null, + hasInvite: boolean, +): GatewaySelection { + if (liveGateways.length > 0) { + if (observedGateways.length === 0) { + return { + gateways: liveGateways, + warning: { + level: 'info', + title: 'Live Gateway Data Only', + message: 'Showing gateways from invite-based live discovery.', + detail: 'Observed gateway history is unavailable on this backend.', + }, + }; + } + + return { + gateways: mergeGatewayData(observedGateways, liveGateways), + warning: { + level: 'info', + title: 'Merged Gateway Sources', + message: 'Combined observed history with live invite-based gateway metadata.', + detail: 'Live data provides the latest registry details, while observed data keeps status and activity context.', + }, + }; + } + + if (observedGateways.length > 0) { + if (liveError) { + return { + gateways: observedGateways, + warning: { + level: 'warning', + title: 'Live Lookup Failed', + message: 'Showing observed gateway data from the backend.', + detail: `Live invite lookup error: ${liveError}`, + }, + }; + } + + if (hasInvite) { + return { + gateways: observedGateways, + warning: { + level: 'warning', + title: 'Live Lookup Returned No Gateways', + message: 'Showing observed gateway data from the backend.', + detail: 'Invite-based lookup returned an empty gateway list.', + }, + }; + } + + return { gateways: observedGateways, warning: null }; + } + + const reason = observedError ?? 'No gateway data available on the configured API backend.'; + if (liveError) { + return { + gateways: [], + warning: { + level: 'error', + title: 'Gateway Data Unavailable', + message: 'Could not load gateway data from backend or invite-based live lookup.', + detail: `Backend: ${reason}. Live: ${liveError}`, + }, + }; + } + + if (hasInvite) { + return { + gateways: [], + warning: { + level: 'warning', + title: 'No Gateways Returned', + message: 'Both backend and invite-based lookup returned no gateway records.', + detail: 'This can happen for new federations or backends with incomplete gateway ingestion.', + }, + }; + } + + return { + gateways: [], + warning: { + level: 'warning', + title: 'Gateway Data Unavailable', + message: 'The configured backend did not return any gateway data.', + detail: reason, + }, + }; +} + +export function FederationGateways() { + const { id } = useParams<{ id: string }>(); + const [federation, setFederation] = useState(null); + const [gateways, setGateways] = useState([]); + const [loading, setLoading] = useState(true); + const [windowLoading, setWindowLoading] = useState(false); + const [error, setError] = useState(null); + const [gatewayWarning, setGatewayWarning] = useState(null); + const [timeWindow, setTimeWindow] = useState('7d'); + const hasLoadedOnce = useRef(false); + const requestSeq = useRef(0); + const federationCache = useRef>(new Map()); + const liveGatewayCache = useRef>(new Map()); + + useEffect(() => { + if (!id) return; + let cancelled = false; + const currentRequest = ++requestSeq.current; + + if (!hasLoadedOnce.current) { + setLoading(true); + } else { + setWindowLoading(true); + } + setError(null); + setGatewayWarning(null); + + (async () => { + try { + let fed: FederationSummary | null | undefined = federationCache.current.get(id); + if (fed === undefined) { + const federations = await api.getFederations(); + fed = federations.find((item) => item.id === id) || null; + federationCache.current.set(id, fed); + } + if (cancelled || currentRequest !== requestSeq.current) return; + + setFederation(fed ?? null); + + let observedGateways: GatewayInfo[] = []; + let observedError: string | null = null; + try { + observedGateways = await api.getFederationGateways(id, timeWindow); + } catch (observedErr: unknown) { + observedError = + observedErr instanceof Error + ? observedErr.message + : `Failed to fetch gateways for federation ${id}`; + } + if (cancelled || currentRequest !== requestSeq.current) return; + + let liveGateways: GatewayInfo[] = []; + let liveError: string | null = null; + if (fed?.invite) { + const cachedLive = liveGatewayCache.current.get(fed.invite); + if (cachedLive) { + liveGateways = cachedLive.gateways; + liveError = cachedLive.error; + } else { + try { + liveGateways = await api.getFederationGatewaysByInvite(fed.invite); + } catch (liveErr: unknown) { + liveError = + liveErr instanceof Error + ? liveErr.message + : 'Invite-based gateway lookup failed.'; + } + liveGatewayCache.current.set(fed.invite, { + gateways: liveGateways, + error: liveError, + }); + } + } + if (cancelled || currentRequest !== requestSeq.current) return; + + const selection = selectGatewayData( + observedGateways, + liveGateways, + observedError, + liveError, + Boolean(fed?.invite), + ); + setGateways(selection.gateways); + setGatewayWarning(selection.warning); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load gateways'; + if (!hasLoadedOnce.current) { + setError(message); + } else { + setGatewayWarning({ + level: 'warning', + title: 'Refresh Failed', + message: 'Failed to refresh the selected time window.', + detail: message, + }); + } + } finally { + if (!cancelled && currentRequest === requestSeq.current) { + if (!hasLoadedOnce.current) { + setLoading(false); + hasLoadedOnce.current = true; + } + setWindowLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [id, timeWindow]); + + const windowMinutes = useMemo(() => { + switch (timeWindow) { + case '1h': + return 60; + case '24h': + return 24 * 60; + case '7d': + return 7 * 24 * 60; + case '30d': + return 30 * 24 * 60; + case '90d': + default: + return 90 * 24 * 60; + } + }, [timeWindow]); + + const rows = useMemo(() => { + return gateways + .map((gateway) => { + const firstSeenDate = parseTimestamp(gateway.first_seen); + const lastSeenDate = parseTimestamp(gateway.last_seen); + const status = getGatewayStatus(lastSeenDate); + const minutesSinceLastSeen = lastSeenDate + ? (Date.now() - lastSeenDate.getTime()) / (1000 * 60) + : null; + const observedUptime = gateway.uptime_window; + const hasObservedSamples = Boolean(observedUptime && observedUptime.sample_count > 0); + const rawObservedOnlineMinutes = hasObservedSamples ? (observedUptime?.online_minutes ?? 0) : 0; + const rawObservedOfflineMinutes = hasObservedSamples ? (observedUptime?.offline_minutes ?? 0) : 0; + const rawObservedTotalMinutes = rawObservedOnlineMinutes + rawObservedOfflineMinutes; + const clampScale = rawObservedTotalMinutes > windowMinutes + ? windowMinutes / rawObservedTotalMinutes + : 1; + const estimatedOnlineMinutes = rawObservedOnlineMinutes * clampScale; + const observedOfflineMinutes = rawObservedOfflineMinutes * clampScale; + const sampledMinutes = estimatedOnlineMinutes + observedOfflineMinutes; + const baseUnknownMinutes = Math.max(0, windowMinutes - sampledMinutes); + const inferredOfflineFromRecency = status === 'offline' && minutesSinceLastSeen !== null + ? Math.max(0, Math.min(baseUnknownMinutes, minutesSinceLastSeen)) + : 0; + const estimatedOfflineMinutes = observedOfflineMinutes + inferredOfflineFromRecency; + const estimatedUnknownMinutes = Math.max(0, baseUnknownMinutes - inferredOfflineFromRecency); + const estimatedUptimePct = sampledMinutes > 0 + ? (estimatedOnlineMinutes / sampledMinutes) * 100 + : 0; + const coveragePct = windowMinutes > 0 + ? (sampledMinutes / windowMinutes) * 100 + : 0; + const activityWindow = gateway.activity_window ?? gateway.activity_7d; + const fundCountWindow = activityWindow?.fund_count ?? 0; + const settleCountWindow = activityWindow?.settle_count ?? 0; + const cancelCountWindow = activityWindow?.cancel_count ?? 0; + const totalVolumeMsatWindow = activityWindow?.total_volume_msat ?? 0; + const hasRealActivity = Boolean(activityWindow); + const realActivityScore = hasRealActivity + ? Math.max( + 0, + Math.round( + (fundCountWindow * 1.0) + + (settleCountWindow * 3.0) + + (0.5 * Math.log1p(totalVolumeMsatWindow / 1_000_000)) + - (cancelCountWindow * 1.5), + ), + ) + : null; + + return { + ...gateway, + firstSeenDate, + lastSeenDate, + status, + minutesSinceLastSeen, + inferredOfflineMinutes: inferredOfflineFromRecency, + estimatedOfflineMinutes, + estimatedOnlineMinutes, + estimatedUnknownMinutes, + estimatedUptimePct, + coveragePct, + realActivityScore, + fundCountWindow, + settleCountWindow, + cancelCountWindow, + totalVolumeMsatWindow, + }; + }) + .sort((a, b) => { + const left = a.lastSeenDate?.getTime() ?? 0; + const right = b.lastSeenDate?.getTime() ?? 0; + return right - left; + }); + }, [gateways, windowMinutes]); + + const totals = useMemo(() => { + const total = rows.length; + const online = rows.filter((row) => row.status === 'online').length; + const degraded = rows.filter((row) => row.status === 'degraded').length; + const offline = rows.filter((row) => row.status === 'offline').length; + const vetted = rows.filter((row) => row.vetted).length; + + return { total, online, degraded, offline, vetted }; + }, [rows]); + + const avgUptime = useMemo(() => { + const observedRows = rows.filter((row) => row.coveragePct > 0); + if (observedRows.length === 0) return 0; + const total = observedRows.reduce((sum, row) => sum + row.estimatedUptimePct, 0); + return total / observedRows.length; + }, [rows]); + + const avgCoverage = useMemo(() => { + if (rows.length === 0) return 0; + const total = rows.reduce((sum, row) => sum + row.coveragePct, 0); + return total / rows.length; + }, [rows]); + + const uptimeStrips = useMemo(() => { + return [...rows] + .sort((a, b) => { + if (b.estimatedUptimePct !== a.estimatedUptimePct) { + return b.estimatedUptimePct - a.estimatedUptimePct; + } + return (b.realActivityScore ?? 0) - (a.realActivityScore ?? 0); + }) + .map((gateway) => ({ + gateway, + strip: buildUptimeStrip(gateway, windowMinutes), + })); + }, [rows, windowMinutes]); + + const mostActive = useMemo(() => { + return rows + .filter((row) => row.realActivityScore !== null) + .sort((a, b) => (b.realActivityScore ?? 0) - (a.realActivityScore ?? 0)) + .slice(0, 5); + }, [rows]); + + const hasRealActivityData = useMemo( + () => rows.some((row) => row.realActivityScore !== null), + [rows], + ); + + if (loading) { + return ( +
+
Loading gateways...
+
+ ); + } + + if (error) { + return ( +
+
Error: {error}
+
+ ); + } + + const federationName = federation?.name || 'Federation'; + const metricsWindow = (rows.find((row) => row.metrics_window)?.metrics_window ?? timeWindow) + .toUpperCase(); + + return ( +
+
+ + ← Back to Federation Details + +
+ +

+ {federationName} Gateways +

+
+

+ Gateway registry view with latest-seen freshness and federation LN gateway metadata. +

+
+ {windowLoading && ( + Updating... + )} + {(['24h', '7d', '30d', '90d'] as GatewayWindow[]).map((window) => ( + + ))} +
+
+ + {gatewayWarning && ( + + )} + +
+
+
Total Gateways
+
{totals.total}
+
+
+
Online
+
{totals.online}
+
+
+
Degraded
+
{totals.degraded}
+
+
+
Offline
+
{totals.offline}
+
+
+
Vetted
+
{totals.vetted}
+
+
+
Avg Uptime (Observed)
+
{avgUptime.toFixed(1)}%
+
+ Window: {timeWindow.toUpperCase()} · Coverage: {avgCoverage.toFixed(1)}% +
+
+
+ +
+
+

+ Gateway Uptime ({timeWindow.toUpperCase()} Window) +

+

+ 30-bucket availability strip over the selected window (newest at right). +

+
oldest ← newest
+
+ {uptimeStrips.length === 0 && ( +
No gateways discovered yet.
+ )} + {uptimeStrips.map(({ gateway, strip }) => ( +
+
+ + {gateway.lightning_alias || shortId(gateway.gateway_id)} + + + {gateway.estimatedUptimePct.toFixed(1)}% + +
+
+ {strip.map((status, idx) => ( +
+ ))} +
+
+ ))} + {uptimeStrips.length > 0 && ( +
+
Online
+
Degraded
+
Offline
+
Unknown
+
+ )} +
+
+ +
+

+ Most Active Gateways (Real {metricsWindow}) +

+

+ Ranked by real {metricsWindow} contract activity (fund/settle/cancel + volume). +

+ {!hasRealActivityData && ( +

+ No gateway contract events were found in the backend {metricsWindow} activity window. +

+ )} +
+ {mostActive.map((gateway) => ( +
+
+ + {gateway.lightning_alias || shortId(gateway.gateway_id)} + + + {(gateway.realActivityScore ?? 0).toLocaleString()} + +
+
+ {metricsWindow}: fund {gateway.fundCountWindow} · settle {gateway.settleCountWindow} · cancel {gateway.cancelCountWindow} +
+
+
0 + ? Math.max( + 8, + ((gateway.realActivityScore ?? 0) / (mostActive[0].realActivityScore ?? 1)) * 100, + ) + : 8 + }%`, + }} + /> +
+
+ ))} +
+
+
+ +
+
+ Gateway Details +

+ Complete list of gateways discovered for this federation. +

+
+ + + + + + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + + {rows.map((gateway) => ( + + + + + + + + + + + + + + ))} + +
GatewayStatusUptime %OnlineOfflineUnknownActivityVettedFirst SeenLast SeenAPI Endpoint
+ No gateways available for this federation yet. +
+
+ {gateway.lightning_alias || 'Unnamed Gateway'} +
+
+ {shortId(gateway.gateway_id)} +
+
+ Node: {shortId(gateway.node_pub_key)} +
+
+ + {gateway.status} + +
+ {formatRelative(gateway.lastSeenDate)} +
+
+ {gateway.estimatedUptimePct.toFixed(1)}% + + {formatDuration(gateway.estimatedOnlineMinutes)} + + {formatDuration(gateway.estimatedOfflineMinutes)} + + {formatDuration(gateway.estimatedUnknownMinutes)} + + {gateway.realActivityScore !== null ? ( + <> +
+ {gateway.realActivityScore.toLocaleString()} +
+
+ {metricsWindow} F:{gateway.fundCountWindow} S:{gateway.settleCountWindow} C:{gateway.cancelCountWindow}
+ Vol: {formatMsats(gateway.totalVolumeMsatWindow)} +
+ + ) : ( +
+ N/A (no real {metricsWindow} data) +
+ )} +
+ + {gateway.vetted ? 'Yes' : 'No'} + + + {formatDateTime(gateway.firstSeenDate)} + + {formatDateTime(gateway.lastSeenDate)} + + + {gateway.api_endpoint} + + {gateway.raw && ( +
+ + Raw announcement + +
+                        {JSON.stringify(gateway.raw, null, 2)}
+                      
+
+ )} +
+
+
+ ); +} diff --git a/fmo_frontend_react/src/services/api.ts b/fmo_frontend_react/src/services/api.ts index 40c180a..f73048c 100644 --- a/fmo_frontend_react/src/services/api.ts +++ b/fmo_frontend_react/src/services/api.ts @@ -1,4 +1,10 @@ -import type { FedimintTotals, FederationSummary } from '../types/api'; +import type { + FedimintTotals, + FederationSummary, + FederationUtxosResponse, + GatewayInfo, + GatewayWindow, +} from '../types/api'; const BASE_URL = import.meta.env.VITE_FMO_API_BASE_URL || 'https://observer.fedimint.org/api'; @@ -51,7 +57,7 @@ export const api = { return response.json(); }, - async getFederationUtxos(id: string): Promise { + async getFederationUtxos(id: string): Promise { const response = await fetch(`${BASE_URL}/federations/${id}/utxos`); if (!response.ok) { throw new Error(`Failed to fetch UTXOs for federation ${id}`); @@ -74,4 +80,22 @@ export const api = { } return response.json(); }, + + async getFederationGateways(id: string, window?: GatewayWindow): Promise { + const query = window ? `?window=${encodeURIComponent(window)}` : ''; + const response = await fetch(`${BASE_URL}/federations/${id}/gateways${query}`); + if (!response.ok) { + throw new Error(`Failed to fetch gateways for federation ${id} (${response.status})`); + } + return response.json(); + }, + + async getFederationGatewaysByInvite(inviteCode: string): Promise { + const encodedInvite = encodeURIComponent(inviteCode); + const response = await fetch(`${BASE_URL}/config/${encodedInvite}/gateways`); + if (!response.ok) { + throw new Error(`Failed to fetch gateways by invite (${response.status})`); + } + return response.json(); + }, }; diff --git a/fmo_frontend_react/src/types/api.ts b/fmo_frontend_react/src/types/api.ts index ba76ffd..305c82a 100644 --- a/fmo_frontend_react/src/types/api.ts +++ b/fmo_frontend_react/src/types/api.ts @@ -30,9 +30,83 @@ export interface FederationUtxo { amount: number; } +export interface FederationUtxosResponse { + observed: FederationUtxo[]; + guardian_claims: GuardianUtxoClaim[]; + disagreements: GuardianUtxoDisagreement[]; +} + +export interface GuardianUtxoClaim { + guardian_id: number; + status: 'unavailable' | 'ok' | 'error'; + utxos: GuardianClaimedUtxo[]; + error: string | null; +} + +export interface GuardianClaimedUtxo { + out_point: string; + amount: number; + state: GuardianClaimedUtxoState; + onchain?: GuardianClaimedUtxoOnchain; + resolution_error?: string; +} + +export interface GuardianClaimedUtxoOnchain { + script_pubkey: string; + address: string | null; + amount: number; + confirmed: boolean; + block_height: number | null; +} + +export type GuardianClaimedUtxoState = + | 'spendable' + | 'unsigned_peg_out' + | 'unsigned_change' + | 'unconfirmed_peg_out' + | 'unconfirmed_change'; + +export interface GuardianUtxoDisagreement { + out_point: string; + description: string; +} + +export interface GatewayInfo { + gateway_id: string; + node_pub_key: string; + lightning_alias: string; + api_endpoint: string; + vetted: boolean; + raw?: Record; + first_seen?: string; + last_seen?: string; + activity_7d?: GatewayActivityMetrics; + activity_window?: GatewayActivityMetrics; + uptime_window?: GatewayUptimeMetrics; + metrics_window?: GatewayWindow; +} + +export interface GatewayActivityMetrics { + fund_count: number; + settle_count: number; + cancel_count: number; + total_volume_msat: number; +} + +export interface GatewayUptimeMetrics { + sample_count: number; + seen_samples: number; + online_minutes: number; + offline_minutes: number; + uptime_pct: number; +} + +export type GatewayWindow = '1h' | '24h' | '7d' | '30d' | '90d'; + export interface GuardianHealth { avg_uptime: number; avg_latency: number; + software_version: string | null; latest: GuardianHealthLatest | null; } diff --git a/fmo_server/schema/v10.sql b/fmo_server/schema/v10.sql new file mode 100644 index 0000000..045cc9b --- /dev/null +++ b/fmo_server/schema/v10.sql @@ -0,0 +1,11 @@ +BEGIN; + +INSERT INTO + schema_version (version) +VALUES + (10); + +ALTER TABLE guardian_health + ADD COLUMN IF NOT EXISTS software_version TEXT; + +COMMIT; diff --git a/fmo_server/schema/v9.sql b/fmo_server/schema/v9.sql new file mode 100644 index 0000000..a6b3dfa --- /dev/null +++ b/fmo_server/schema/v9.sql @@ -0,0 +1,35 @@ +BEGIN; + +INSERT INTO + schema_version (version) +VALUES + (9); + +CREATE TABLE IF NOT EXISTS gateways ( + federation_id BYTEA NOT NULL REFERENCES federations (federation_id), + gateway_id TEXT NOT NULL, + node_pub_key TEXT NOT NULL, + api_endpoint TEXT NOT NULL, + lightning_alias TEXT NOT NULL, + vetted BOOLEAN NOT NULL DEFAULT FALSE, + raw JSONB NOT NULL, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (federation_id, gateway_id) +); + +CREATE INDEX IF NOT EXISTS gateways_federation_id ON gateways (federation_id); +CREATE INDEX IF NOT EXISTS gateways_node_pub_key ON gateways (node_pub_key); + +CREATE TABLE IF NOT EXISTS gateway_poll_snapshots ( + federation_id BYTEA NOT NULL REFERENCES federations (federation_id), + gateway_id TEXT NOT NULL, + poll_time TIMESTAMPTZ NOT NULL, + is_seen BOOLEAN NOT NULL, + PRIMARY KEY (federation_id, gateway_id, poll_time) +); + +CREATE INDEX IF NOT EXISTS gateway_poll_snapshots_fed_time + ON gateway_poll_snapshots (federation_id, poll_time); + +COMMIT; diff --git a/fmo_server/src/config/mod.rs b/fmo_server/src/config/mod.rs index 4f577b0..27d2265 100644 --- a/fmo_server/src/config/mod.rs +++ b/fmo_server/src/config/mod.rs @@ -6,6 +6,7 @@ use axum::routing::get; use axum::{Json, Router}; use fedimint_core::config::{FederationId, JsonClientConfig}; use fedimint_core::invite_code::InviteCode; +use fmo_api_types::GatewayInfo; use reqwest::Method; use tower_http::cors::{Any, CorsLayer}; use tracing::warn; @@ -14,6 +15,7 @@ use crate::config::id::fetch_federation_id; use crate::config::meta::fetch_federation_meta; use crate::config::modules::fetch_federation_module_kinds; use crate::error::Result; +use crate::federation::gateways::fetch_gateways_for_config; use crate::util::config_to_json; use crate::AppState; @@ -28,6 +30,7 @@ pub mod modules; pub fn get_config_routes() -> Router { let router = Router::new() .route("/:invite", get(fetch_federation_config)) + .route("/:invite/gateways", get(fetch_federation_gateways)) .route("/:invite/meta", get(fetch_federation_meta)) .route("/:invite/id", get(fetch_federation_id)) .route("/:invite/module_kinds", get(fetch_federation_module_kinds)); @@ -56,6 +59,16 @@ pub async fn fetch_federation_config( .into()) } +pub async fn fetch_federation_gateways( + Path(invite): Path, +) -> Result>> { + let config = fedimint_api_client::api::net::Connector::default() + .download_from_invite_code(&invite) + .await?; + let gateways = fetch_gateways_for_config(&config).await?; + Ok(gateways.into()) +} + #[derive(Default, Debug, Clone)] pub struct FederationConfigCache { federations: Arc>>, diff --git a/fmo_server/src/federation/gateways.rs b/fmo_server/src/federation/gateways.rs new file mode 100644 index 0000000..5771b37 --- /dev/null +++ b/fmo_server/src/federation/gateways.rs @@ -0,0 +1,623 @@ +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::{bail, Context}; +use axum::extract::{Path, Query, State}; +use axum::Json; +use chrono::{DateTime, Utc}; +use fedimint_api_client::api::{DynGlobalApi, FederationApiExt}; +use fedimint_core::config::{ClientConfig, FederationId}; +use fedimint_core::core::ModuleInstanceId; +use fedimint_core::encoding::Encodable; +use fedimint_core::module::ApiRequestErased; +use fedimint_ln_common::federation_endpoint_constants::LIST_GATEWAYS_ENDPOINT; +use fedimint_ln_common::LightningGatewayAnnouncement; +use fmo_api_types::{GatewayActivityMetrics, GatewayInfo, GatewayUptimeMetrics}; +use futures::future::join_all; +use serde::Deserialize; +use tracing::{info, warn}; + +use crate::federation::observer::FederationObserver; +use crate::util::query; + +const GATEWAY_POLL_INTERVAL_MINUTES: u64 = 5; +const GATEWAY_SNAPSHOT_RETENTION_DAYS: i64 = 90; +const GATEWAY_PRUNE_INTERVAL_HOURS: i64 = 6; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum GatewayMetricsWindow { + H1, + H24, + D7, + D30, + D90, +} + +impl GatewayMetricsWindow { + fn parse(value: Option<&str>) -> anyhow::Result { + match value.unwrap_or("7d") { + "1h" => Ok(Self::H1), + "24h" => Ok(Self::H24), + "7d" => Ok(Self::D7), + "30d" => Ok(Self::D30), + "90d" => Ok(Self::D90), + invalid => bail!( + "Invalid gateways window '{invalid}'. Supported values: 1h, 24h, 7d, 30d, 90d" + ), + } + } + + fn label(self) -> &'static str { + match self { + Self::H1 => "1h", + Self::H24 => "24h", + Self::D7 => "7d", + Self::D30 => "30d", + Self::D90 => "90d", + } + } + + fn duration(self) -> chrono::Duration { + match self { + Self::H1 => chrono::Duration::hours(1), + Self::H24 => chrono::Duration::hours(24), + Self::D7 => chrono::Duration::days(7), + Self::D30 => chrono::Duration::days(30), + Self::D90 => chrono::Duration::days(90), + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct GetFederationGatewaysParams { + window: Option, +} + +pub(crate) async fn fetch_gateways_for_config( + config: &ClientConfig, +) -> anyhow::Result> { + let api = DynGlobalApi::from_endpoints( + config + .global + .api_endpoints + .iter() + .map(|(&peer_id, peer_url)| (peer_id, peer_url.url.clone())), + &None, + ) + .await?; + + let ln_instance_id = config + .modules + .iter() + .find_map(|(&instance_id, module)| (module.kind.as_str() == "ln").then_some(instance_id)) + .context("No LN module found in federation config")?; + + let peer_ids: Vec = + config.global.api_endpoints.keys().copied().collect(); + let mut merged: HashMap = HashMap::new(); + let peer_results = join_all(peer_ids.iter().copied().map(|peer_id| { + let api = api.clone(); + async move { + let result: anyhow::Result> = api + .with_module(ln_instance_id) + .request_single_peer( + LIST_GATEWAYS_ENDPOINT.to_owned(), + ApiRequestErased::default(), + peer_id, + ) + .await + .map_err(anyhow::Error::from) + .and_then(|v| serde_json::from_value(v).map_err(anyhow::Error::from)); + (peer_id, result) + } + })) + .await; + + for (peer_id, result) in peer_results { + match result { + Ok(gateways) => { + for gw in gateways { + merged.entry(gw.info.gateway_id.to_string()).or_insert(gw); + } + } + Err(e) => { + warn!( + "Failed to fetch live gateways from peer {}: {:?}", + peer_id, e + ); + } + } + } + + merged + .into_values() + .map(|gw| { + let raw = serde_json::to_value(&gw)?; + Ok(GatewayInfo { + gateway_id: gw.info.gateway_id.to_string(), + node_pub_key: gw.info.node_pub_key.to_string(), + lightning_alias: gw.info.lightning_alias, + api_endpoint: gw.info.api.to_string(), + vetted: gw.vetted, + raw: Some(raw), + first_seen: None, + last_seen: None, + activity_7d: None, + activity_window: None, + uptime_window: None, + metrics_window: None, + }) + }) + .collect() +} + +impl FederationObserver { + /// Background task: poll the LN module on this federation for currently + /// registered gateways and persist them. Runs in a loop until cancelled. + pub async fn monitor_gateways( + &self, + federation_id: FederationId, + config: ClientConfig, + ) -> anyhow::Result<()> { + const POLL_INTERVAL: Duration = Duration::from_secs(GATEWAY_POLL_INTERVAL_MINUTES * 60); + + let api = DynGlobalApi::from_endpoints( + config + .global + .api_endpoints + .iter() + .map(|(&peer_id, peer_url)| (peer_id, peer_url.url.clone())), + &None, + ) + .await?; + + let ln_instance_id = config + .modules + .iter() + .find_map(|(&instance_id, module)| { + (module.kind.as_str() == "ln").then_some(instance_id) + }) + .context("No LN module found in federation config")?; + + let peer_ids: Vec = + config.global.api_endpoints.keys().copied().collect(); + + let mut interval = tokio::time::interval(POLL_INTERVAL); + loop { + interval.tick().await; + if let Err(e) = + Self::fetch_and_store_gateways(self, federation_id, &api, ln_instance_id, &peer_ids) + .await + { + warn!( + "Failed to fetch gateways for federation {}: {:?}", + federation_id, e + ); + } + } + } + + async fn fetch_and_store_gateways( + &self, + federation_id: FederationId, + api: &DynGlobalApi, + ln_instance_id: ModuleInstanceId, + peer_ids: &[fedimint_core::PeerId], + ) -> anyhow::Result<()> { + // Query all peers and merge by gateway_id — each guardian has their own + // registry so we take the union across all peers. + let mut merged: HashMap = HashMap::new(); + let mut successful_peer_queries: u32 = 0; + let peer_results = join_all(peer_ids.iter().copied().map(|peer_id| async move { + let result: anyhow::Result> = api + .with_module(ln_instance_id) + .request_single_peer( + LIST_GATEWAYS_ENDPOINT.to_owned(), + ApiRequestErased::default(), + peer_id, + ) + .await + .map_err(anyhow::Error::from) + .and_then(|v| serde_json::from_value(v).map_err(anyhow::Error::from)); + (peer_id, result) + })) + .await; + + for (peer_id, result) in peer_results { + match result { + Ok(gateways) => { + successful_peer_queries += 1; + for gw in gateways { + merged.entry(gw.info.gateway_id.to_string()).or_insert(gw); + } + } + Err(e) => { + warn!( + "Failed to fetch gateways from peer {} for {}: {:?}", + peer_id, federation_id, e + ); + } + } + } + + if successful_peer_queries == 0 { + bail!( + "No successful gateway registry responses from any federation peer for {}", + federation_id + ); + } + + let mut conn = self.connection().await?; + let dbtx = conn.transaction().await?; + let now = chrono::Utc::now(); + let federation_id_bytes = federation_id.consensus_encode_to_vec(); + + let mut gateway_ids = Vec::with_capacity(merged.len()); + let mut node_pub_keys = Vec::with_capacity(merged.len()); + let mut api_endpoints = Vec::with_capacity(merged.len()); + let mut lightning_aliases = Vec::with_capacity(merged.len()); + let mut vetted_flags = Vec::with_capacity(merged.len()); + let mut raw_announcements = Vec::with_capacity(merged.len()); + + for (gateway_id, gw) in &merged { + gateway_ids.push(gateway_id.clone()); + node_pub_keys.push(gw.info.node_pub_key.to_string()); + api_endpoints.push(gw.info.api.to_string()); + lightning_aliases.push(gw.info.lightning_alias.clone()); + vetted_flags.push(gw.vetted); + raw_announcements.push(serde_json::to_string(gw)?); + } + + if !gateway_ids.is_empty() { + dbtx.execute( + "INSERT INTO gateways + (federation_id, gateway_id, node_pub_key, api_endpoint, + lightning_alias, vetted, raw, first_seen, last_seen) + SELECT + $1, + gw.gateway_id, + gw.node_pub_key, + gw.api_endpoint, + gw.lightning_alias, + gw.vetted, + gw.raw_json::jsonb, + $2, + $2 + FROM UNNEST( + $3::text[], + $4::text[], + $5::text[], + $6::text[], + $7::boolean[], + $8::text[] + ) AS gw(gateway_id, node_pub_key, api_endpoint, lightning_alias, vetted, raw_json) + ON CONFLICT (federation_id, gateway_id) DO UPDATE + SET node_pub_key = EXCLUDED.node_pub_key, + api_endpoint = EXCLUDED.api_endpoint, + lightning_alias = EXCLUDED.lightning_alias, + vetted = EXCLUDED.vetted, + raw = EXCLUDED.raw, + last_seen = EXCLUDED.last_seen", + &[ + &federation_id_bytes, + &now, + &gateway_ids, + &node_pub_keys, + &api_endpoints, + &lightning_aliases, + &vetted_flags, + &raw_announcements, + ], + ) + .await?; + } + + dbtx.execute( + "WITH current_gateway_ids AS ( + SELECT UNNEST($3::text[]) AS gateway_id + ), + all_gateway_ids AS ( + SELECT gateway_id, TRUE AS is_seen + FROM current_gateway_ids + UNION + SELECT g.gateway_id, FALSE AS is_seen + FROM gateways g + WHERE g.federation_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM current_gateway_ids c + WHERE c.gateway_id = g.gateway_id + ) + ) + INSERT INTO gateway_poll_snapshots + (federation_id, gateway_id, poll_time, is_seen) + SELECT + $1, + a.gateway_id, + $2, + a.is_seen + FROM all_gateway_ids a + ON CONFLICT DO NOTHING", + &[&federation_id_bytes, &now, &gateway_ids], + ) + .await?; + + let prune_interval_secs = GATEWAY_PRUNE_INTERVAL_HOURS * 60 * 60; + let should_prune = now.timestamp().rem_euclid(prune_interval_secs) + < (GATEWAY_POLL_INTERVAL_MINUTES as i64 * 60); + let deleted_snapshots = if should_prune { + let retention_cutoff = now - chrono::Duration::days(GATEWAY_SNAPSHOT_RETENTION_DAYS); + dbtx.execute( + "DELETE FROM gateway_poll_snapshots + WHERE federation_id = $1 + AND poll_time < $2", + &[&federation_id_bytes, &retention_cutoff], + ) + .await? + } else { + 0 + }; + dbtx.commit().await?; + + info!( + "Stored {} seen gateway(s), persisted poll snapshots for federation {}, deleted {} old snapshots", + merged.len(), federation_id, deleted_snapshots + ); + Ok(()) + } + + async fn list_federation_gateways( + &self, + federation_id: FederationId, + window: GatewayMetricsWindow, + ) -> anyhow::Result> { + #[derive(postgres_from_row::FromRow)] + struct GatewayRow { + gateway_id: String, + node_pub_key: String, + lightning_alias: String, + api_endpoint: String, + vetted: bool, + raw: serde_json::Value, + first_seen: chrono::DateTime, + last_seen: chrono::DateTime, + } + + #[derive(postgres_from_row::FromRow)] + struct GatewayActivityRow { + gateway_key: String, + fund_count: i64, + settle_count: i64, + cancel_count: i64, + total_volume_msat: i64, + } + + #[derive(postgres_from_row::FromRow)] + struct GatewayUptimeRow { + gateway_id: String, + seen_samples: i64, + total_samples: i64, + } + + let conn = self.connection().await?; + let federation_id_bytes = federation_id.consensus_encode_to_vec(); + let window_start_utc: DateTime = Utc::now() - window.duration(); + let window_start_naive = window_start_utc.naive_utc(); + let metrics_window = window.label().to_owned(); + + let rows = query::( + &conn, + "SELECT gateway_id, node_pub_key, lightning_alias, api_endpoint, vetted, raw, first_seen, last_seen + FROM gateways + WHERE federation_id = $1 + ORDER BY last_seen DESC", + &[&federation_id_bytes], + ) + .await?; + + let activity_rows = query::( + &conn, + "WITH tx_window AS ( + SELECT t.federation_id, t.txid + FROM transactions t + JOIN session_times st + ON st.federation_id = t.federation_id + AND st.session_index = t.session_index + WHERE t.federation_id = $1 + AND st.estimated_session_timestamp >= $2 + ), + window_ln_outputs AS ( + SELECT + o.federation_id, + o.txid, + o.out_index, + o.ln_contract_id, + o.ln_contract_interaction_kind, + COALESCE(o.amount_msat, 0)::bigint AS amount_msat + FROM transaction_outputs o + JOIN tx_window tw + ON tw.federation_id = o.federation_id + AND tw.txid = o.txid + WHERE o.federation_id = $1 + AND o.kind = 'ln' + AND o.ln_contract_id IS NOT NULL + ), + window_ln_inputs AS ( + SELECT + i.federation_id, + i.txid, + i.ln_contract_id + FROM transaction_inputs i + JOIN tx_window tw + ON tw.federation_id = i.federation_id + AND tw.txid = i.txid + WHERE i.federation_id = $1 + AND i.kind = 'ln' + AND i.ln_contract_id IS NOT NULL + ), + contract_map AS ( + SELECT DISTINCT ON (wlo.federation_id, wlo.ln_contract_id) + wlo.federation_id, + wlo.ln_contract_id, + COALESCE( + d.details #>> '{V0,Contract,contract,Outgoing,gateway_key}', + d.details #>> '{V0,Contract,contract,Incoming,gateway_key}' + ) AS gateway_key + FROM window_ln_outputs wlo + JOIN transaction_output_details d + ON d.federation_id = wlo.federation_id + AND d.txid = wlo.txid + AND d.out_index = wlo.out_index + WHERE wlo.ln_contract_interaction_kind = 'fund' + ORDER BY wlo.federation_id, wlo.ln_contract_id, wlo.txid, wlo.out_index + ), + events AS ( + SELECT + cm.gateway_key, + 1::bigint AS fund_count, + 0::bigint AS settle_count, + 0::bigint AS cancel_count, + wlo.amount_msat AS volume_msat + FROM window_ln_outputs wlo + JOIN contract_map cm + ON cm.federation_id = wlo.federation_id + AND cm.ln_contract_id = wlo.ln_contract_id + WHERE wlo.ln_contract_interaction_kind = 'fund' + UNION ALL + SELECT + cm.gateway_key, + 0::bigint, + 1::bigint, + 0::bigint, + 0::bigint + FROM window_ln_inputs wli + JOIN contract_map cm + ON cm.federation_id = wli.federation_id + AND cm.ln_contract_id = wli.ln_contract_id + UNION ALL + SELECT + cm.gateway_key, + 0::bigint, + 0::bigint, + 1::bigint, + 0::bigint + FROM window_ln_outputs wlo + JOIN contract_map cm + ON cm.federation_id = wlo.federation_id + AND cm.ln_contract_id = wlo.ln_contract_id + WHERE wlo.ln_contract_interaction_kind = 'cancel' + ) + SELECT + gateway_key, + SUM(fund_count)::bigint AS fund_count, + SUM(settle_count)::bigint AS settle_count, + SUM(cancel_count)::bigint AS cancel_count, + SUM(volume_msat)::bigint AS total_volume_msat + FROM events + WHERE gateway_key IS NOT NULL + GROUP BY gateway_key", + &[&federation_id_bytes, &window_start_naive], + ) + .await?; + + let activity_by_gateway_key: HashMap = activity_rows + .into_iter() + .map(|row| { + ( + row.gateway_key, + GatewayActivityMetrics { + fund_count: row.fund_count.max(0) as u64, + settle_count: row.settle_count.max(0) as u64, + cancel_count: row.cancel_count.max(0) as u64, + total_volume_msat: row.total_volume_msat.max(0) as u64, + }, + ) + }) + .collect(); + + let uptime_rows = query::( + &conn, + "SELECT + gateway_id, + COUNT(*) FILTER (WHERE is_seen)::bigint AS seen_samples, + COUNT(*)::bigint AS total_samples + FROM gateway_poll_snapshots + WHERE federation_id = $1 + AND poll_time >= $2 + GROUP BY gateway_id", + &[&federation_id_bytes, &window_start_utc], + ) + .await?; + + let uptime_by_gateway_id: HashMap = uptime_rows + .into_iter() + .map(|row| { + let seen_samples = row.seen_samples.max(0) as u64; + let total_samples = row.total_samples.max(0) as u64; + let online_minutes = seen_samples.saturating_mul(GATEWAY_POLL_INTERVAL_MINUTES); + let offline_minutes = total_samples + .saturating_sub(seen_samples) + .saturating_mul(GATEWAY_POLL_INTERVAL_MINUTES); + let uptime_pct = if total_samples > 0 { + (seen_samples as f64 / total_samples as f64) * 100.0 + } else { + 0.0 + }; + ( + row.gateway_id, + GatewayUptimeMetrics { + sample_count: total_samples, + seen_samples, + online_minutes, + offline_minutes, + uptime_pct, + }, + ) + }) + .collect(); + + Ok(rows + .into_iter() + .map(|r| { + let activity_window = r + .raw + .pointer("/info/gateway_redeem_key") + .and_then(|v| v.as_str()) + .and_then(|gateway_key| activity_by_gateway_key.get(gateway_key).cloned()); + let uptime_window = uptime_by_gateway_id.get(&r.gateway_id).cloned(); + + GatewayInfo { + activity_7d: if window == GatewayMetricsWindow::D7 { + activity_window.clone() + } else { + None + }, + activity_window, + uptime_window, + metrics_window: Some(metrics_window.clone()), + gateway_id: r.gateway_id, + node_pub_key: r.node_pub_key, + lightning_alias: r.lightning_alias, + api_endpoint: r.api_endpoint, + vetted: r.vetted, + raw: Some(r.raw), + first_seen: Some(r.first_seen), + last_seen: Some(r.last_seen), + } + }) + .collect()) + } +} + +pub(super) async fn get_federation_gateways( + Path(federation_id): Path, + Query(params): Query, + State(state): State, +) -> crate::error::Result>> { + let window = GatewayMetricsWindow::parse(params.window.as_deref())?; + Ok(state + .federation_observer + .list_federation_gateways(federation_id, window) + .await? + .into()) +} diff --git a/fmo_server/src/federation/guardians.rs b/fmo_server/src/federation/guardians.rs index dd2f159..cacac5e 100644 --- a/fmo_server/src/federation/guardians.rs +++ b/fmo_server/src/federation/guardians.rs @@ -86,7 +86,14 @@ impl FederationObserver { }); let api_latency = start_time.elapsed(); - (peer_id, status, block_height, api_latency) + let software_version = api + .fedimintd_version(peer_id) + .await + .ok() + .map(|version| version.trim().to_owned()) + .filter(|version| !version.is_empty()); + + (peer_id, status, software_version, block_height, api_latency) } })) .await; @@ -94,9 +101,19 @@ impl FederationObserver { let mut conn = self.connection().await?; let dbtx = conn.transaction().await?; let timestamp = chrono::Utc::now().naive_utc(); - for (peer_id, status, block_height, api_latency) in peer_status_responses { + for (peer_id, status, software_version, block_height, api_latency) in + peer_status_responses + { dbtx.execute( - "INSERT INTO guardian_health VALUES ($1, $2, $3, $4, $5, $6)", + "INSERT INTO guardian_health ( + federation_id, + time, + guardian_id, + status, + block_height, + latency_ms, + software_version + ) VALUES ($1, $2, $3, $4, $5, $6, $7)", &[ &federation_id.consensus_encode_to_vec(), ×tamp, @@ -104,6 +121,7 @@ impl FederationObserver { &status.map(|s| serde_json::to_value(s).expect("Can be serialized")), &block_height.map(|bh| bh as i32), &(api_latency.as_millis() as i32), + &software_version, ], ) .await?; @@ -128,6 +146,7 @@ impl FederationObserver { latest.guardian_id, latest.block_height, (latest.status -> 'federation' ->> 'session_count')::integer AS session_count, + latest.software_version, last30d.uptime, last30d.latency_ms FROM guardian_health latest @@ -179,6 +198,7 @@ impl FederationObserver { let health = GuardianHealth { avg_uptime: row.uptime, avg_latency: row.latency_ms, + software_version: row.software_version, latest, }; @@ -230,7 +250,11 @@ impl FederationObserver { // Special case single guardian federations to not show them as degraded if federation.guardians == 1 { - return Ok((federation_id, FederationHealth::Online)); + if federation.online_guardians >= 1 { + return Ok((federation_id, FederationHealth::Online)); + } else { + return Ok((federation_id, FederationHealth::Offline)); + } } let threshold = NumPeers::from(federation.guardians as usize).threshold(); @@ -254,6 +278,7 @@ struct GuardianHealthRow { guardian_id: i32, block_height: Option, session_count: Option, + software_version: Option, uptime: f32, latency_ms: f32, } diff --git a/fmo_server/src/federation/mod.rs b/fmo_server/src/federation/mod.rs index e54e72b..9f58f1f 100644 --- a/fmo_server/src/federation/mod.rs +++ b/fmo_server/src/federation/mod.rs @@ -1,4 +1,5 @@ pub mod db; +pub(crate) mod gateways; mod guardians; mod meta; pub(crate) mod nostr; @@ -6,19 +7,27 @@ pub mod observer; mod session; mod transaction; +use std::collections::{HashMap, HashSet}; + use anyhow::Context; use axum::extract::{Path, State}; use axum::routing::{get, post, put}; use axum::{Json, Router}; use axum_auth::AuthBearer; +use bitcoin::OutPoint; use fedimint_core::config::{ClientConfig, FederationId, JsonClientConfig}; use fedimint_core::core::ModuleInstanceId; use fedimint_core::invite_code::InviteCode; use fedimint_core::module::registry::ModuleDecoderRegistry; -use fmo_api_types::{FederationSummary, FedimintTotals, NonceSpendInfo, NoncesRequest}; +use fmo_api_types::{ + FederationSummary, FederationUtxo, FederationUtxosResponse, FedimintTotals, + GuardianClaimedUtxo, GuardianUtxoClaim, GuardianUtxoClaimStatus, GuardianUtxoDisagreement, + NonceSpendInfo, NoncesRequest, +}; use serde::Deserialize; use serde_json::json; +use crate::federation::gateways::get_federation_gateways; use crate::federation::guardians::get_federation_health; use crate::federation::meta::get_federation_meta; use crate::federation::session::{count_sessions, list_sessions}; @@ -55,6 +64,7 @@ pub fn get_federations_routes() -> Router { "/:federation_id/transactions/histogram", get(transaction_histogram), ) + .route("/:federation_id/gateways", get(get_federation_gateways)) .route("/:federation_id/utxos", get(get_federation_utxos)) .route("/:federation_id/sessions", get(list_sessions)) .route("/:federation_id/sessions/count", get(count_sessions)) @@ -130,12 +140,194 @@ async fn get_federation_overview( async fn get_federation_utxos( Path(federation_id): Path, State(state): State, -) -> crate::error::Result>> { +) -> crate::error::Result> { let utxos = state .federation_observer .federation_utxos(federation_id) .await?; - Ok(utxos.into()) + let mut guardian_claims = state + .federation_observer + .guardian_utxo_claims(federation_id) + .await?; + state + .federation_observer + .enrich_guardian_claims_onchain(&mut guardian_claims) + .await; + let disagreements = guardian_utxo_disagreements(&utxos, &guardian_claims); + Ok(FederationUtxosResponse { + observed: utxos, + guardian_claims, + disagreements, + } + .into()) +} + +fn guardian_utxo_disagreements( + observed: &[FederationUtxo], + guardian_claims: &[GuardianUtxoClaim], +) -> Vec { + let observed_by_outpoint = observed + .iter() + .map(|utxo| (utxo.out_point, utxo)) + .collect::>(); + let successful_claims = guardian_claims + .iter() + .filter(|claim| matches!(claim.status, GuardianUtxoClaimStatus::Ok)) + .collect::>(); + + if successful_claims.is_empty() { + return if guardian_claims.is_empty() { + Vec::new() + } else { + vec![GuardianUtxoDisagreement { + out_point: OutPoint::null(), + description: "no guardian wallet summaries could be fetched".to_owned(), + }] + }; + } + + let claimed_by_outpoint = successful_claims + .iter() + .flat_map(|claim| { + claim + .utxos + .iter() + .map(|utxo| (utxo.out_point, (claim.guardian_id, utxo))) + }) + .fold( + HashMap::>::new(), + |mut acc, (out_point, claim)| { + acc.entry(out_point).or_default().push(claim); + acc + }, + ); + + let mut disagreements = Vec::new(); + + for claim in &successful_claims { + for utxo in &claim.utxos { + if let Some(onchain) = &utxo.onchain { + if onchain.amount != utxo.amount { + disagreements.push(GuardianUtxoDisagreement { + out_point: utxo.out_point, + description: format!( + "guardian {} reports {} msat, but resolved Bitcoin output has {} msat", + claim.guardian_id, utxo.amount.msats, onchain.amount.msats + ), + }); + } + } else if let Some(error) = &utxo.resolution_error { + disagreements.push(GuardianUtxoDisagreement { + out_point: utxo.out_point, + description: format!( + "could not resolve guardian {} claimed outpoint from Bitcoin data: {error}", + claim.guardian_id + ), + }); + } + } + } + + for observed_utxo in observed { + let Some(claims) = claimed_by_outpoint.get(&observed_utxo.out_point) else { + disagreements.push(GuardianUtxoDisagreement { + out_point: observed_utxo.out_point, + description: "observer has UTXO but no successful guardian claims it".to_owned(), + }); + continue; + }; + + let mismatched_guardians = claims + .iter() + .filter(|(_, claim)| claim.amount != observed_utxo.amount) + .map(|(guardian_id, claim)| { + format!("guardian {guardian_id} reports {} msat", claim.amount.msats) + }) + .collect::>(); + + if !mismatched_guardians.is_empty() { + disagreements.push(GuardianUtxoDisagreement { + out_point: observed_utxo.out_point, + description: format!( + "observer reports {} msat, but {}", + observed_utxo.amount.msats, + mismatched_guardians.join(", ") + ), + }); + } + + let observed_address = observed_utxo.address.clone().assume_checked().to_string(); + let mismatched_addresses = claims + .iter() + .filter_map(|(guardian_id, claim)| { + claim + .onchain + .as_ref() + .and_then(|onchain| onchain.address.as_ref()) + .filter(|address| *address != &observed_address) + .map(|address| format!("guardian {guardian_id} resolves to address {address}")) + }) + .collect::>(); + + if !mismatched_addresses.is_empty() { + disagreements.push(GuardianUtxoDisagreement { + out_point: observed_utxo.out_point, + description: format!( + "observer reconstructs address {}, but {}", + observed_address, + mismatched_addresses.join(", ") + ), + }); + } + } + + for (out_point, claims) in &claimed_by_outpoint { + if !observed_by_outpoint.contains_key(out_point) { + let guardian_ids = claims + .iter() + .map(|(guardian_id, _)| guardian_id.to_string()) + .collect::>() + .join(", "); + let onchain_hint = claims + .iter() + .find_map(|(_, claim)| claim.onchain.as_ref()) + .map(|onchain| { + format!( + "; resolved script_pubkey: {}; address: {}", + onchain.script_pubkey, + onchain.address.as_deref().unwrap_or("non-standard") + ) + }) + .unwrap_or_default(); + disagreements.push(GuardianUtxoDisagreement { + out_point: *out_point, + description: format!( + "guardian wallet summary claims UTXO, but observer reconstruction does not; guardians: {guardian_ids}{onchain_hint}" + ), + }); + } + } + + for claim in successful_claims { + let guardian_outpoints = claim + .utxos + .iter() + .map(|utxo| utxo.out_point) + .collect::>(); + for out_point in claimed_by_outpoint.keys() { + if !guardian_outpoints.contains(out_point) { + disagreements.push(GuardianUtxoDisagreement { + out_point: *out_point, + description: format!( + "guardian {} did not claim UTXO claimed by another successful guardian", + claim.guardian_id + ), + }); + } + } + } + + disagreements } async fn get_federation_totals( diff --git a/fmo_server/src/federation/observer.rs b/fmo_server/src/federation/observer.rs index ae982f6..1b70015 100644 --- a/fmo_server/src/federation/observer.rs +++ b/fmo_server/src/federation/observer.rs @@ -1,18 +1,21 @@ +use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::time::{Duration, SystemTime}; -use anyhow::{bail, ensure}; +use anyhow::{bail, ensure, Context}; use bitcoin::hashes::Hash; +use bitcoin::hex::DisplayHex as _; use bitcoin::{Address, OutPoint, Txid}; use chrono::{DateTime, NaiveDate}; use deadpool_postgres::{GenericClient, Runtime, Transaction}; use fedimint_api_client::api::net::Connector; -use fedimint_api_client::api::DynGlobalApi; +use fedimint_api_client::api::{DynGlobalApi, FederationApiExt}; use fedimint_core::config::{ClientConfig, FederationId}; use fedimint_core::core::DynModuleConsensusItem; use fedimint_core::encoding::Encodable; use fedimint_core::epoch::ConsensusItem; use fedimint_core::invite_code::InviteCode; +use fedimint_core::module::ApiRequestErased; use fedimint_core::session_outcome::SessionOutcome; use fedimint_core::task::TaskGroup; use fedimint_core::util::backoff_util::background_backoff; @@ -23,10 +26,14 @@ use fedimint_ln_common::{ LightningConsensusItem, LightningInput, LightningOutput, LightningOutputV0, }; use fedimint_mint_common::{MintConsensusItem, MintInput, MintOutput}; -use fedimint_wallet_common::{WalletConsensusItem, WalletInput, WalletOutput, WalletOutputV0}; +use fedimint_wallet_common::endpoint_constants::WALLET_SUMMARY_ENDPOINT; +use fedimint_wallet_common::{ + TxOutputSummary, WalletConsensusItem, WalletInput, WalletOutput, WalletOutputV0, WalletSummary, +}; use fmo_api_types::{ FederationActivity, FederationHealth, FederationSummary, FederationUtxo, FedimintTotals, - NonceSpendInfo, + GuardianClaimedUtxo, GuardianClaimedUtxoOnchain, GuardianClaimedUtxoState, GuardianUtxoClaim, + GuardianUtxoClaimStatus, NonceSpendInfo, }; use futures::future::join_all; use futures::StreamExt; @@ -114,7 +121,7 @@ impl FederationObserver { let slf = self.clone(); let federation_id = federation.federation_id; - let config = federation.config; + let config = federation.config.clone(); self.task_group.spawn_cancellable( format!("Health Monitor for {}", federation_id), async move { @@ -129,6 +136,24 @@ impl FederationObserver { } .instrument(info_span!("health", fed = %federation_id.to_prefix())), ); + + let slf = self.clone(); + let federation_id = federation.federation_id; + let config = federation.config; + self.task_group.spawn_cancellable( + format!("Gateway Monitor for {}", federation_id), + async move { + loop { + let e = slf + .monitor_gateways(federation_id, config.clone()) + .await + .expect_err("gateway monitor task exited unexpectedly"); + error!("Gateway Monitor errored, restarting in 30s: {e}"); + tokio::time::sleep(Duration::from_secs(30)).await; + } + } + .instrument(info_span!("gateways", fed = %federation_id.to_prefix())), + ); } async fn setup_schema(&self) -> anyhow::Result<()> { @@ -191,6 +216,8 @@ impl FederationObserver { "/schema/v8.sql", FederationObserver::backfill_reprocess_all_sessions ), + migration!("/schema/v9.sql"), + migration!("/schema/v10.sql"), ]; for (index, migration) in migrations.iter().enumerate() { @@ -1157,11 +1184,8 @@ impl FederationObserver { .await?; } WalletConsensusItem::PegOutSignature(peg_out_sig) => { - let peg_out_txid = peg_out_sig.txid.to_string(); - let peg_out_txid_encoded = - fedimint_core::TransactionId::from_str(peg_out_txid.as_str()) - .expect("Invalid on chain txid") - .consensus_encode_to_vec(); + let peg_out_txid = peg_out_sig.txid; + let peg_out_txid_encoded = peg_out_txid.to_byte_array().to_vec(); dbtx.execute( "INSERT INTO wallet_withdrawal_transactions VALUES ($1, $2) ON CONFLICT DO NOTHING", @@ -1210,7 +1234,7 @@ impl FederationObserver { // at this point, the transaction reached threshold and should broadcast - let esplora_txid = esplora_client::Txid::from_str(peg_out_txid.as_str()) + let esplora_txid = esplora_client::Txid::from_str(&peg_out_txid.to_string()) .expect("Couldn't create esplora txid"); let builder = esplora_client::Builder::new(mempool_url); @@ -1232,11 +1256,7 @@ impl FederationObserver { .expect("Reached usize::MAX retries"); for input in fetched_tx.input { - let prev_out_txid = fedimint_core::TransactionId::from_str( - input.previous_output.txid.to_string().as_str(), - ) - .expect("Invalid txid") - .consensus_encode_to_vec(); + let prev_out_txid = input.previous_output.txid.to_byte_array().to_vec(); dbtx.execute( "INSERT INTO wallet_withdrawal_transaction_inputs VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", @@ -1388,6 +1408,208 @@ impl FederationObserver { }).collect() } + pub async fn guardian_utxo_claims( + &self, + federation_id: FederationId, + ) -> anyhow::Result> { + let federation = self + .get_federation(federation_id) + .await? + .context("Federation not observed")?; + let config = federation.config; + + let Some(wallet_module_id) = config + .modules + .iter() + .find_map(|(id, module)| (module.kind.as_str() == "wallet").then_some(*id)) + else { + return Ok(config + .global + .api_endpoints + .keys() + .map(|peer_id| GuardianUtxoClaim { + guardian_id: peer_id.to_usize() as u16, + status: GuardianUtxoClaimStatus::Unavailable, + utxos: Vec::new(), + error: Some("federation config has no wallet module".to_owned()), + }) + .collect()); + }; + + let api = DynGlobalApi::from_endpoints( + config + .global + .api_endpoints + .iter() + .map(|(peer_id, peer_url)| (*peer_id, peer_url.url.clone())), + &None, + ) + .await?; + + let module_api = api.with_module(wallet_module_id); + Ok(join_all(config.global.api_endpoints.keys().map(|peer_id| { + let module_api = module_api.clone(); + let peer_id = *peer_id; + async move { + match module_api + .request_single_peer::( + WALLET_SUMMARY_ENDPOINT.to_owned(), + ApiRequestErased::default(), + peer_id, + ) + .await + { + Ok(summary) => GuardianUtxoClaim { + guardian_id: peer_id.to_usize() as u16, + status: GuardianUtxoClaimStatus::Ok, + utxos: wallet_summary_claimed_utxos(summary), + error: None, + }, + Err(error) => GuardianUtxoClaim { + guardian_id: peer_id.to_usize() as u16, + status: GuardianUtxoClaimStatus::Error, + utxos: Vec::new(), + error: Some(error.to_string()), + }, + } + } + })) + .await) + } + + pub async fn enrich_guardian_claims_onchain(&self, guardian_claims: &mut [GuardianUtxoClaim]) { + let outpoints = guardian_claims + .iter() + .filter(|claim| matches!(claim.status, GuardianUtxoClaimStatus::Ok)) + .flat_map(|claim| claim.utxos.iter().map(|utxo| utxo.out_point)) + .collect::>(); + + if outpoints.is_empty() { + return; + } + + let resolutions = self.resolve_onchain_outpoints(outpoints).await; + for claim in guardian_claims { + for utxo in &mut claim.utxos { + if let Some(resolution) = resolutions.get(&utxo.out_point) { + match resolution { + Ok(onchain) => { + utxo.onchain = Some(onchain.clone()); + utxo.resolution_error = None; + } + Err(error) => { + utxo.onchain = None; + utxo.resolution_error = Some(error.clone()); + } + } + } + } + } + } + + async fn resolve_onchain_outpoints( + &self, + outpoints: HashSet, + ) -> HashMap> { + let mut outpoints_by_txid = HashMap::>::new(); + for outpoint in outpoints { + outpoints_by_txid + .entry(outpoint.txid) + .or_default() + .push(outpoint); + } + + let client = match esplora_client::Builder::new(&self.mempool_url).build_async() { + Ok(client) => client, + Err(error) => { + return outpoints_by_txid + .into_values() + .flatten() + .into_iter() + .map(|outpoint| { + ( + outpoint, + Err(format!("failed to create Esplora client: {error}")), + ) + }) + .collect(); + } + }; + + join_all(outpoints_by_txid.into_iter().map(|(txid, outpoints)| { + let client = client.clone(); + async move { + let tx = match client.get_tx_no_opt(&txid).await { + Ok(tx) => tx, + Err(error) => { + return outpoints + .into_iter() + .map(|outpoint| { + ( + outpoint, + Err(format!("failed to fetch transaction: {error}")), + ) + }) + .collect::>(); + } + }; + + let status = match client.get_tx_status(&txid).await { + Ok(status) => status, + Err(error) => { + return outpoints + .into_iter() + .map(|outpoint| { + ( + outpoint, + Err(format!("failed to fetch transaction status: {error}")), + ) + }) + .collect::>(); + } + }; + + outpoints + .into_iter() + .map(|outpoint| { + let result = tx + .output + .get(outpoint.vout as usize) + .ok_or_else(|| { + format!("transaction does not have vout {}", outpoint.vout) + }) + .map(|output| { + let address = Address::from_script( + &output.script_pubkey, + bitcoin::Network::Bitcoin, + ) + .ok() + .map(|address| address.to_string()); + + GuardianClaimedUtxoOnchain { + script_pubkey: output + .script_pubkey + .as_bytes() + .as_hex() + .to_string(), + address, + amount: Amount::from_sats(output.value.to_sat()), + confirmed: status.confirmed, + block_height: status.block_height, + } + }); + + (outpoint, result) + }) + .collect::>() + } + })) + .await + .into_iter() + .flatten() + .collect() + } + pub async fn totals(&self) -> anyhow::Result { #[derive(Debug, FromRow)] struct FedimintTotalsResult { @@ -1582,6 +1804,50 @@ impl FederationObserver { } } +fn wallet_summary_claimed_utxos(summary: WalletSummary) -> Vec { + let mut utxos = Vec::new(); + append_claimed_utxos( + &mut utxos, + summary.spendable_utxos, + GuardianClaimedUtxoState::Spendable, + ); + append_claimed_utxos( + &mut utxos, + summary.unsigned_peg_out_txos, + GuardianClaimedUtxoState::UnsignedPegOut, + ); + append_claimed_utxos( + &mut utxos, + summary.unsigned_change_utxos, + GuardianClaimedUtxoState::UnsignedChange, + ); + append_claimed_utxos( + &mut utxos, + summary.unconfirmed_peg_out_txos, + GuardianClaimedUtxoState::UnconfirmedPegOut, + ); + append_claimed_utxos( + &mut utxos, + summary.unconfirmed_change_utxos, + GuardianClaimedUtxoState::UnconfirmedChange, + ); + utxos +} + +fn append_claimed_utxos( + utxos: &mut Vec, + txos: Vec, + state: GuardianClaimedUtxoState, +) { + utxos.extend(txos.into_iter().map(|txo| GuardianClaimedUtxo { + out_point: txo.outpoint, + amount: Amount::from_sats(txo.amount.to_sat()), + state, + onchain: None, + resolution_error: None, + })); +} + fn last_n_day_iter(now: NaiveDate, days: u32) -> impl Iterator { (0..days) .rev() diff --git a/fmo_server/src/federation/transaction.rs b/fmo_server/src/federation/transaction.rs index 30f3077..ca64344 100644 --- a/fmo_server/src/federation/transaction.rs +++ b/fmo_server/src/federation/transaction.rs @@ -196,7 +196,7 @@ impl FederationObserver { FROM transactions t JOIN session_times st ON t.session_index = st.session_index AND t.federation_id = st.federation_id - JOIN + LEFT JOIN (SELECT federation_id, txid, SUM(amount_msat) AS total_input_amount diff --git a/justfile b/justfile index b26197b..059d0ad 100644 --- a/justfile +++ b/justfile @@ -96,7 +96,7 @@ clippy_package PACKAGE *ARGS="--locked": clippy *ARGS="--locked": just clippy_package fmo_server {{ARGS}} - RUSTFLAGS="$RUSTFLAGS --cfg getrandom_backend=\"wasm_js\"" just clippy_package fmo_frontend --target wasm32-unknown-unknown {{ARGS}} + RUSTFLAGS="${RUSTFLAGS:-} --cfg getrandom_backend=\"wasm_js\"" just clippy_package fmo_frontend --target wasm32-unknown-unknown {{ARGS}} # run `cargo clippy --fix` on everything clippy_fix-package PACKAGE *ARGS="--locked --offline":