From 47a20c23d3d08325817085954ecbb2dd1c01a2f5 Mon Sep 17 00:00:00 2001 From: hulxv Date: Wed, 3 Jun 2026 14:26:09 +0300 Subject: [PATCH] refactor: display all makers without filters --- .github/workflows/docker-build.yml | 14 +- src/server.rs | 38 ++++-- src/state.rs | 84 ++++++++++-- src/sync.rs | 26 ++-- tests/e2e.rs | 38 +++--- tests/http.rs | 127 ++++++++++++------ web/src/App.jsx | 209 ++++++++++++++++++++--------- 7 files changed, 373 insertions(+), 163 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 7d5d1e5..afbda5f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -70,20 +70,20 @@ jobs: sleep 1 done - # /api/health: status=ok, offer_count=0 on a fresh container. + # /api/health: status=ok, maker_count=0 on a fresh container. health=$(curl -fsS http://127.0.0.1:3000/api/health) echo "Health: $health" - if ! echo "$health" | jq -e '.status == "ok" and .offer_count == 0' > /dev/null; then + if ! echo "$health" | jq -e '.status == "ok" and .maker_count == 0 and .with_offer == 0' > /dev/null; then echo "Health assertion failed" docker logs marketd-test || true exit 1 fi - # /api/offers: empty array on a fresh container. - offers=$(curl -fsS http://127.0.0.1:3000/api/offers) - echo "Offers: $offers" - if ! echo "$offers" | jq -e 'type == "array" and length == 0' > /dev/null; then - echo "Offers assertion failed" + # /api/makers: empty array on a fresh container. + makers=$(curl -fsS http://127.0.0.1:3000/api/makers) + echo "Makers: $makers" + if ! echo "$makers" | jq -e 'type == "array" and length == 0' > /dev/null; then + echo "Makers assertion failed" docker logs marketd-test || true exit 1 fi diff --git a/src/server.rs b/src/server.rs index 7ac4b75..380a94e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,38 +1,60 @@ -use axum::{Router, extract::State, http::StatusCode, response::Json, routing::get}; +use axum::{ + Router, + extract::{Query, State}, + http::StatusCode, + response::Json, + routing::get, +}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tower_http::{ cors::CorsLayer, services::{ServeDir, ServeFile}, }; -use crate::state::SharedStore; +use crate::state::{ApiMaker, ApiMakerState, SharedStore}; pub fn router(store: SharedStore, static_dir: String) -> Router { let index = format!("{static_dir}/index.html"); let spa = ServeDir::new(&static_dir).not_found_service(ServeFile::new(index)); Router::new() - .route("/api/offers", get(get_offers)) + .route("/api/makers", get(get_makers)) .route("/api/health", get(get_health)) .with_state(store) .layer(CorsLayer::permissive()) .fallback_service(spa) } -async fn get_offers(State(store): State) -> Result, StatusCode> { - let offers = store +#[derive(Serialize, Deserialize)] +struct MakerQueryParams { + state: Option, +} + +async fn get_makers( + State(store): State, + Query(params): Query, +) -> Result, StatusCode> { + let makers = store .read() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .offers + .makers .clone(); - Ok(Json(json!(offers))) + let makers = makers + .iter() + .filter(|m| params.state.as_ref().is_none_or(|state| state == &m.state)) + .collect::>(); + + Ok(Json(json!(makers))) } async fn get_health(State(store): State) -> Json { let s = store.read().unwrap(); + let with_offer = s.makers.iter().filter(|m| m.offer.is_some()).count(); Json(json!({ "status": "ok", - "offer_count": s.offers.len(), + "maker_count": s.makers.len(), + "with_offer": with_offer, "last_sync": s.last_sync, })) } diff --git a/src/state.rs b/src/state.rs index 1a66942..b7e1798 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,8 @@ -use coinswap::{protocol::common_messages::Offer, taker::offers::MakerAddress}; -use serde::Serialize; +use coinswap::{ + protocol::common_messages::Offer, + taker::offers::{MakerOfferCandidate, MakerProtocol, MakerState}, +}; +use serde::{Deserialize, Serialize}; use std::sync::{Arc, RwLock}; #[derive(Serialize, Clone, Debug)] @@ -17,10 +20,10 @@ pub struct ApiFidelityBond { pub cert_sig: String, } +/// Offer payload — populated only when the taker has successfully fetched +/// the maker's advertisement. #[derive(Serialize, Clone, Debug)] pub struct ApiOffer { - pub address: String, - pub timestamp: u64, pub base_fee: u64, pub amount_relative_fee_pct: f64, pub time_relative_fee_pct: f64, @@ -33,15 +36,11 @@ pub struct ApiOffer { } impl ApiOffer { - pub fn from_coinswap(offer: &Offer, address: &MakerAddress, timestamp: u64) -> Self { + pub fn from_coinswap(offer: &Offer) -> Self { let bond = &offer.fidelity.bond; let outpoint = bond.outpoint(); - let txid = outpoint.txid.to_string(); - let vout = outpoint.vout; Self { - address: address.to_string(), - timestamp, base_fee: offer.base_fee, amount_relative_fee_pct: offer.amount_relative_fee_pct, time_relative_fee_pct: offer.time_relative_fee_pct, @@ -52,7 +51,10 @@ impl ApiOffer { tweakable_point: offer.tweakable_point.to_string(), fidelity_bond: ApiFidelityBond { amount: bond.amount.to_sat(), - outpoint: ApiOutpoint { txid, vout }, + outpoint: ApiOutpoint { + txid: outpoint.txid.to_string(), + vout: outpoint.vout, + }, lock_time: bond.lock_time.to_consensus_u32(), cert_hash: offer.fidelity.cert_hash.to_string(), cert_sig: hex::encode(offer.fidelity.cert_sig.serialize_der().as_ref()), @@ -61,14 +63,68 @@ impl ApiOffer { } } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ApiMakerState { + Good, + Unresponsive { retries: u8 }, + Bad, +} + +impl From<&MakerState> for ApiMakerState { + fn from(s: &MakerState) -> Self { + match s { + MakerState::Good => Self::Good, + MakerState::Unresponsive { retries } => Self::Unresponsive { retries: *retries }, + MakerState::Bad => Self::Bad, + } + } +} + +fn protocol_label(p: &MakerProtocol) -> &'static str { + match p { + MakerProtocol::Legacy => "legacy", + MakerProtocol::Taproot => "taproot", + MakerProtocol::Unified => "unified", + } +} + +/// A maker known to the taker's offerbook — *whether or not* we have a +/// current offer for it. Bad and unresponsive makers come through here too, +/// with `offer: None`. +#[derive(Serialize, Clone, Debug)] +pub struct ApiMaker { + pub address: String, + pub state: ApiMakerState, + pub protocol: Option<&'static str>, + pub timestamp: u64, + pub last_offer_update_ts: Option, + pub next_offer_check_ts: Option, + pub offer: Option, +} + +impl ApiMaker { + pub fn from_candidate(candidate: &MakerOfferCandidate, timestamp: u64) -> Self { + Self { + address: candidate.address.to_string(), + state: (&candidate.state).into(), + protocol: candidate.protocol.as_ref().map(protocol_label), + timestamp, + last_offer_update_ts: candidate.last_offer_update_ts, + next_offer_check_ts: candidate.next_offer_check_ts, + offer: candidate.offer.as_ref().map(ApiOffer::from_coinswap), + } + } +} + #[derive(Default, Clone)] -pub struct OfferStore { - pub offers: Vec, +pub struct MakerStore { + pub makers: Vec, pub last_sync: Option, } -pub type SharedStore = Arc>; +pub type SharedStore = Arc>; pub fn new_store() -> SharedStore { - Arc::new(RwLock::new(OfferStore::default())) + Arc::new(RwLock::new(MakerStore::default())) } diff --git a/src/sync.rs b/src/sync.rs index 32006e9..f1d8dc2 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -51,7 +51,7 @@ pub fn build_taker_config(cfg: &Config) -> TakerInitConfig { } pub fn sync_loop(init_config: TakerInitConfig, sync_interval_secs: u64, store: state::SharedStore) { - use coinswap::taker::{Taker, offers::MakerState}; + use coinswap::taker::Taker; if let Some(rpc) = init_config.rpc_config.as_ref() { wait_for_tcp(&rpc.url, "Bitcoin RPC"); @@ -97,25 +97,27 @@ pub fn sync_loop(init_config: TakerInitConfig, sync_interval_secs: u64, store: s .unwrap_or_default() .as_secs(); - let offers: Vec = book + // Include every maker the offerbook tracks — Good, Unresponsive, Bad. + // The API consumer decides what to display; we don't filter here. + let makers: Vec = book .all_makers() - .into_iter() - .filter(|m| matches!(m.state, MakerState::Good)) - .filter_map(|m| { - m.offer - .as_ref() - .map(|o| state::ApiOffer::from_coinswap(o, &m.address, timestamp)) - }) + .iter() + .map(|m| state::ApiMaker::from_candidate(m, timestamp)) .collect(); - let count = offers.len(); + let count = makers.len(); + let with_offer = makers.iter().filter(|m| m.offer.is_some()).count(); { let mut s = store.write().unwrap(); - s.offers = offers; + s.makers = makers; s.last_sync = Some(timestamp); } - tracing::info!(count, "Sync done, sleeping {sync_interval_secs}s"); + tracing::info!( + count, + with_offer, + "Sync done, sleeping {sync_interval_secs}s" + ); std::thread::sleep(Duration::from_secs(sync_interval_secs)); } } diff --git a/tests/e2e.rs b/tests/e2e.rs index fb9ea10..e50a42e 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -13,7 +13,7 @@ //! Spawns a funded, fidelity-bonded `MakerServer` in-process, lets it //! broadcast its offer to the nostr relay, runs marketd's `sync_loop` //! against the same relay, and asserts the offer reaches -//! `GET /api/offers` with the right fields. +//! `GET /api/makers` with the right fields. //! //! Both tests require `BITCOIND_EXE` and assume a nostr-rs-relay listening //! on `ws://127.0.0.1:8000` (the docker entrypoint provides this). @@ -91,15 +91,16 @@ fn taker_init_and_http_layer_against_regtest() { assert_eq!(resp.status(), 200); let body: Value = resp.into_json().expect("decode /api/health"); assert_eq!(body["status"], "ok"); - assert_eq!(body["offer_count"], 0); + assert_eq!(body["maker_count"], 0); + assert_eq!(body["with_offer"], 0); let resp = guard .agent - .get(&guard.url("/api/offers")) + .get(&guard.url("/api/makers")) .call() - .expect("GET /api/offers"); + .expect("GET /api/makers"); assert_eq!(resp.status(), 200); - let offers: Value = resp.into_json().expect("decode /api/offers"); + let offers: Value = resp.into_json().expect("decode /api/makers"); assert_eq!(offers.as_array().expect("array").len(), 0); } @@ -234,30 +235,32 @@ fn maker_offer_flows_through_to_api_offers() { // ───────── HTTP layer ───────── let guard = common::MarketdServerGuard::start(store.clone()); - // Poll /api/offers until non-empty. + // Poll /api/makers until a maker with an offer appears. let offer_deadline = Instant::now() + Duration::from_secs(120); - let offer = loop { + let maker_json = loop { assert!( Instant::now() < offer_deadline, - "marketd /api/offers stayed empty for 120s" + "marketd /api/makers never saw a maker with an offer (120s)" ); let resp = guard .agent - .get(&guard.url("/api/offers")) + .get(&guard.url("/api/makers")) .call() - .expect("GET /api/offers"); - let body: Value = resp.into_json().expect("decode /api/offers"); - let arr = body.as_array().expect("offers JSON is array"); - if let Some(first) = arr.first() { - break first.clone(); + .expect("GET /api/makers"); + let body: Value = resp.into_json().expect("decode /api/makers"); + let arr = body.as_array().expect("makers JSON is array"); + if let Some(m) = arr.iter().find(|m| !m["offer"].is_null()) { + break m.clone(); } thread::sleep(Duration::from_secs(2)); }; - tracing::info!(?offer, "marketd saw an offer"); + tracing::info!(?maker_json, "marketd saw a maker with an offer"); - assert_eq!(offer["address"], format!("127.0.0.1:{maker_net_port}")); + assert_eq!(maker_json["address"], format!("127.0.0.1:{maker_net_port}")); + assert_eq!(maker_json["state"]["kind"], "good"); + let offer = &maker_json["offer"]; assert_eq!(offer["base_fee"], 1000); assert_eq!(offer["min_size"], 10_000); assert_eq!(offer["required_confirms"], 1); @@ -281,7 +284,8 @@ fn maker_offer_flows_through_to_api_offers() { .expect("GET /api/health"); let body: Value = resp.into_json().expect("decode /api/health"); assert_eq!(body["status"], "ok"); - assert!(body["offer_count"].as_u64().unwrap() >= 1); + assert!(body["maker_count"].as_u64().unwrap() >= 1); + assert!(body["with_offer"].as_u64().unwrap() >= 1); assert!(body["last_sync"].is_number()); // ───────── Teardown ───────── diff --git a/tests/http.rs b/tests/http.rs index e92d3f0..90766c9 100644 --- a/tests/http.rs +++ b/tests/http.rs @@ -7,11 +7,11 @@ mod common; -use marketd::state::{ApiFidelityBond, ApiOffer, ApiOutpoint, new_store}; +use marketd::state::{ApiFidelityBond, ApiMaker, ApiMakerState, ApiOffer, ApiOutpoint, new_store}; use serde_json::Value; #[test] -fn health_and_offers_empty_store() { +fn health_and_makers_empty_store() { let store = new_store(); let guard = common::MarketdServerGuard::start(store); @@ -23,45 +23,73 @@ fn health_and_offers_empty_store() { assert_eq!(resp.status(), 200); let body: Value = resp.into_json().expect("health JSON"); assert_eq!(body["status"], "ok"); - assert_eq!(body["offer_count"], 0); + assert_eq!(body["maker_count"], 0); + assert_eq!(body["with_offer"], 0); assert!(body["last_sync"].is_null()); let resp = guard .agent - .get(&guard.url("/api/offers")) + .get(&guard.url("/api/makers")) .call() - .expect("GET /api/offers"); + .expect("GET /api/makers"); assert_eq!(resp.status(), 200); - let offers: Value = resp.into_json().expect("offers JSON"); - assert_eq!(offers.as_array().expect("array").len(), 0); + let makers: Value = resp.into_json().expect("makers JSON"); + assert_eq!(makers.as_array().expect("array").len(), 0); } #[test] -fn offers_returns_seeded_data() { +fn makers_returns_seeded_data() { let store = new_store(); { let mut s = store.write().unwrap(); - s.offers.push(ApiOffer { - address: "test.onion:6102".into(), + // A "Good" maker with a populated offer. + s.makers.push(ApiMaker { + address: "127.0.0.1:6102".into(), + state: ApiMakerState::Good, + protocol: Some("taproot"), timestamp: 1_234_567_890, - base_fee: 1000, - amount_relative_fee_pct: 0.5, - time_relative_fee_pct: 0.1, - min_size: 10_000, - max_size: 100_000_000, - required_confirms: 1, - minimum_locktime: 144, - tweakable_point: "02abcd".into(), - fidelity_bond: ApiFidelityBond { - amount: 5_000_000, - outpoint: ApiOutpoint { - txid: "deadbeef".into(), - vout: 0, + last_offer_update_ts: Some(1_234_567_890), + next_offer_check_ts: Some(1_234_567_950), + offer: Some(ApiOffer { + base_fee: 1000, + amount_relative_fee_pct: 0.5, + time_relative_fee_pct: 0.1, + min_size: 10_000, + max_size: 100_000_000, + required_confirms: 1, + minimum_locktime: 144, + tweakable_point: "02abcd".into(), + fidelity_bond: ApiFidelityBond { + amount: 5_000_000, + outpoint: ApiOutpoint { + txid: "deadbeef".into(), + vout: 0, + }, + lock_time: 800_000, + cert_hash: "feedface".into(), + cert_sig: "303d".into(), }, - lock_time: 800_000, - cert_hash: "feedface".into(), - cert_sig: "303d".into(), - }, + }), + }); + // An unresponsive maker — no offer payload, but still appears. + s.makers.push(ApiMaker { + address: "127.0.0.1:7777".into(), + state: ApiMakerState::Unresponsive { retries: 3 }, + protocol: None, + timestamp: 1_234_567_890, + last_offer_update_ts: None, + next_offer_check_ts: Some(1_234_568_000), + offer: None, + }); + // A bad maker with no offer at all. + s.makers.push(ApiMaker { + address: "127.0.0.1:8888".into(), + state: ApiMakerState::Bad, + protocol: None, + timestamp: 1_234_567_890, + last_offer_update_ts: None, + next_offer_check_ts: None, + offer: None, }); s.last_sync = Some(1_700_000_000); } @@ -70,20 +98,40 @@ fn offers_returns_seeded_data() { let resp = guard .agent - .get(&guard.url("/api/offers")) + .get(&guard.url("/api/makers")) .call() - .expect("GET /api/offers"); + .expect("GET /api/makers"); assert_eq!(resp.status(), 200); - let offers: Value = resp.into_json().expect("offers JSON"); - let arr = offers.as_array().expect("array"); - assert_eq!(arr.len(), 1); + let makers: Value = resp.into_json().expect("makers JSON"); + let arr = makers.as_array().expect("array"); + assert_eq!(arr.len(), 3, "bad and unresponsive makers must be returned"); + + // Good maker + let good = &arr[0]; + assert_eq!(good["address"], "127.0.0.1:6102"); + assert_eq!(good["state"]["kind"], "good"); + assert_eq!(good["protocol"], "taproot"); + assert_eq!(good["offer"]["base_fee"], 1000); + assert_eq!(good["offer"]["fidelity_bond"]["amount"], 5_000_000); + assert_eq!( + good["offer"]["fidelity_bond"]["outpoint"]["txid"], + "deadbeef" + ); + + // Unresponsive maker — kind=unresponsive with a retries count. + let unresp = &arr[1]; + assert_eq!(unresp["state"]["kind"], "unresponsive"); + assert_eq!(unresp["state"]["retries"], 3); + assert!( + unresp["offer"].is_null(), + "offer must be null for unresponsive maker" + ); + assert!(unresp["protocol"].is_null()); - let o = &arr[0]; - assert_eq!(o["address"], "test.onion:6102"); - assert_eq!(o["base_fee"], 1000); - assert_eq!(o["fidelity_bond"]["amount"], 5_000_000); - assert_eq!(o["fidelity_bond"]["outpoint"]["txid"], "deadbeef"); - assert_eq!(o["fidelity_bond"]["outpoint"]["vout"], 0); + // Bad maker — same shape, kind=bad. + let bad = &arr[2]; + assert_eq!(bad["state"]["kind"], "bad"); + assert!(bad["offer"].is_null()); let resp = guard .agent @@ -91,6 +139,7 @@ fn offers_returns_seeded_data() { .call() .expect("GET /api/health"); let body: Value = resp.into_json().expect("health JSON"); - assert_eq!(body["offer_count"], 1); + assert_eq!(body["maker_count"], 3); + assert_eq!(body["with_offer"], 1); assert_eq!(body["last_sync"], 1_700_000_000); } diff --git a/web/src/App.jsx b/web/src/App.jsx index 8ee842d..d65c299 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -2,8 +2,36 @@ import { useState, useEffect } from "react"; const POLL_INTERVAL_MS = 60_000; +function stateLabel(state) { + if (!state) return "—"; + switch (state.kind) { + case "good": + return "Good"; + case "unresponsive": + return `Unresponsive (${state.retries})`; + case "bad": + return "Bad"; + default: + return state.kind; + } +} + +function stateClass(state) { + if (!state) return "text-gray-400"; + switch (state.kind) { + case "good": + return "text-green-400"; + case "unresponsive": + return "text-yellow-400"; + case "bad": + return "text-red-400"; + default: + return "text-gray-400"; + } +} + function App() { - const [offers, setOffers] = useState([]); + const [makers, setMakers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); @@ -11,31 +39,31 @@ function App() { const EXPLORER_BASE = import.meta.env.VITE_EXPLORER_BASE || "https://mempool.space/signet"; - const fetchOffers = () => { - fetch("/api/offers") + const fetchMakers = () => { + fetch("/api/makers") .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => { - setOffers(data); + setMakers(data); setError(null); if (data.length > 0) { - const latest = Math.max(...data.map((o) => o.timestamp * 1000)); + const latest = Math.max(...data.map((m) => m.timestamp * 1000)); setLastUpdated(new Date(latest)); } setLoading(false); }) .catch((err) => { - console.error("Error loading offers:", err); + console.error("Error loading makers:", err); setError(err.message); setLoading(false); }); }; useEffect(() => { - fetchOffers(); - const id = setInterval(fetchOffers, POLL_INTERVAL_MS); + fetchMakers(); + const id = setInterval(fetchMakers, POLL_INTERVAL_MS); return () => clearInterval(id); }, []); @@ -52,6 +80,8 @@ function App() { ); } + const withOffer = makers.filter((m) => m.offer).length; + return (
@@ -60,13 +90,14 @@ function App() { Coinswap Market

- Live market offers with competitive rates + All makers tracked by the daemon — including bad and unresponsive + ones.

{error && (
- Failed to load offers: {error} + Failed to load makers: {error}
)} @@ -77,6 +108,8 @@ function App() { {[ { label: "Address", desc: "Maker Address" }, + { label: "State", desc: "Connection Status" }, + { label: "Protocol", desc: "Legacy / Taproot" }, { label: "Base Fee", desc: "Fixed Fee (sat)" }, { label: "Amount", desc: "Volume Fee" }, { label: "Time", desc: "Time Fee" }, @@ -99,67 +132,111 @@ function App() { - {offers.length === 0 ? ( + {makers.length === 0 ? ( - No offers available. The daemon may still be syncing. + No makers known yet. The daemon may still be syncing. ) : ( - offers.map((offer) => ( - - -
- {offer.address} -
- - - - {offer.base_fee.toLocaleString()} - - - - - {(offer.amount_relative_fee_pct * 100).toFixed(2)}% - - - - - {(offer.time_relative_fee_pct * 100).toFixed(2)}% - - - - - {offer.min_size.toLocaleString()} - - - - - {offer.max_size.toLocaleString()} - - - -
- {offer.fidelity_bond.amount.toLocaleString()} sat -
- - {offer.fidelity_bond.outpoint.txid} - - - - )) + makers.map((m) => { + const offer = m.offer; + const bond = offer?.fidelity_bond; + const key = bond + ? `${bond.outpoint.txid}:${bond.outpoint.vout}` + : m.address; + return ( + + +
+ {m.address} +
+ + + + {stateLabel(m.state)} + + + + {m.protocol ?? "—"} + + + {offer ? ( + + {offer.base_fee.toLocaleString()} + + ) : ( + — + )} + + + {offer ? ( + + {(offer.amount_relative_fee_pct * 100).toFixed(2)} + % + + ) : ( + — + )} + + + {offer ? ( + + {(offer.time_relative_fee_pct * 100).toFixed(2)}% + + ) : ( + — + )} + + + {offer ? ( + + {offer.min_size.toLocaleString()} + + ) : ( + — + )} + + + {offer ? ( + + {offer.max_size.toLocaleString()} + + ) : ( + — + )} + + + {bond ? ( + <> +
+ {bond.amount.toLocaleString()} sat +
+ + {bond.outpoint.txid} + + + ) : ( + — + )} + + + ); + }) )} @@ -168,8 +245,8 @@ function App() {

- Showing {offers.length} active offer{offers.length !== 1 ? "s" : ""}{" "} - •{" "} + Showing {makers.length} maker{makers.length !== 1 ? "s" : ""} ( + {withOffer} with offer{withOffer !== 1 ? "s" : ""}) •{" "} {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : "Waiting for first sync..."}