diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 0335031454..c7bd442fc0 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -62,10 +62,32 @@ jobs: cp target/x86_64-unknown-linux-musl/release/rothschild bin/ cp target/x86_64-unknown-linux-musl/release/kaspa-wallet bin/ cp target/x86_64-unknown-linux-musl/release/stratum-bridge bin/ + # Copy bridge configuration file + cp bridge/config.yaml bin/ archive="bin/rusty-kaspa-${{ github.event.release.tag_name }}-linux-amd64.zip" zip -r "${archive}" ./bin/* echo "archive=${archive}" >> $GITHUB_ENV + - name: Install AppImage packaging deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y librsvg2-bin fuse libfuse2 + + - name: Build stratum-bridge AppImage (Linux) + id: appimage + if: runner.os == 'Linux' + continue-on-error: true + run: | + cd $GITHUB_WORKSPACE + bash bridge/appimage/build.sh "${{ github.event.release.tag_name }}" + + - name: Pack AppImage tarball (preserves executable bit on Linux extract) + if: runner.os == 'Linux' && steps.appimage.outcome == 'success' + run: | + cd "$GITHUB_WORKSPACE" + IMG="stratum-bridge-${{ github.event.release.tag_name }}-x86_64.AppImage" + tar -czf "${IMG}.tar.gz" "$IMG" + echo "appimage_tarball=${GITHUB_WORKSPACE}/${IMG}.tar.gz" >> $GITHUB_ENV + - name: Build on Windows if: runner.os == 'Windows' shell: bash @@ -79,6 +101,10 @@ jobs: cp target/release/rothschild.exe bin/ cp target/release/kaspa-wallet.exe bin/ cp target/release/stratum-bridge.exe bin/ + # Copy bridge configuration and batch files + cp bridge/config.yaml bin/ + cp bridge/Start-Kaspad.bat bin/ + cp bridge/Start-stratum-bridge-externalMode.bat bin/ archive="bin/rusty-kaspa-${{ github.event.release.tag_name }}-win64.zip" powershell "Compress-Archive bin/* \"${archive}\"" echo "archive=${archive}" >> $GITHUB_ENV @@ -95,6 +121,8 @@ jobs: cp target/release/rothschild bin/ cp target/release/kaspa-wallet bin/ cp target/release/stratum-bridge bin/ + # Copy bridge configuration file + cp bridge/config.yaml bin/ archive="bin/rusty-kaspa-${{ github.event.release.tag_name }}-osx.zip" zip -r "${archive}" ./bin/* echo "archive=${archive}" >> $GITHUB_ENV @@ -106,6 +134,15 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Ship only the tarball: raw .AppImage from browser/GitHub UI often loses +x; tar.gz preserves it on extract. + - name: Upload stratum-bridge AppImage release tarball + if: runner.os == 'Linux' && steps.appimage.outcome == 'success' + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.appimage_tarball }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-wasm: runs-on: ubuntu-latest name: Building WASM32 SDK diff --git a/.gitignore b/.gitignore index 96ea5b42c7..7ac1f4cd55 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ Servers.toml release package-sizes.js rustbridge_*.log +bridge/appimage/appimagetool-x86_64.AppImage diff --git a/bridge/Start-Kaspad.bat b/bridge/Start-Kaspad.bat new file mode 100644 index 0000000000..f0086917d8 --- /dev/null +++ b/bridge/Start-Kaspad.bat @@ -0,0 +1,26 @@ +@echo off +REM Kaspa Node Auto-Start Script (Portable Version) + +title Kaspa Node - Auto-Restart Script + +REM Change to the directory where this script is located +cd /d "%~dp0" + +:xxx +echo Starting Kaspa Node (kaspad.exe)... +echo Directory: %cd% +echo. + +echo To stop, press Ctrl+C and then 'Y' when prompted. +echo. + +REM Run kaspad from the same folder +kaspad.exe --utxoindex --rpclisten=127.0.0.1:16110 --rpclisten-borsh=127.0.0.1:17110 + +echo. +echo Kaspa Node process exited. Restarting in 5 seconds... +choice /C SR /N /T 5 /D R >nul +if errorlevel 2 goto xxx + +echo Stopping by user request. +goto :eof \ No newline at end of file diff --git a/bridge/Start-stratum-bridge-externalMode.bat b/bridge/Start-stratum-bridge-externalMode.bat new file mode 100644 index 0000000000..2466145b7a --- /dev/null +++ b/bridge/Start-stratum-bridge-externalMode.bat @@ -0,0 +1,11 @@ +@echo off + +REM Force script to run from its own directory +cd /d "%~dp0" + +echo Current directory: %cd% +echo. + +stratum-bridge.exe --config "config.yaml" --node-mode external --kaspad-address 127.0.0.1:16110 + +pause \ No newline at end of file diff --git a/bridge/appimage/AppRun b/bridge/appimage/AppRun new file mode 100644 index 0000000000..20ee8f385d --- /dev/null +++ b/bridge/appimage/AppRun @@ -0,0 +1,48 @@ +#!/bin/bash +# AppImage entry: run stratum-bridge with optional config in a writable location +# (AppImage mounts are read-only; config lives under XDG_CONFIG_HOME.) +set -euo pipefail + +# When opened from a file manager there is usually no TTY — reopen inside a terminal +# so users see logs and know the bridge is running. Set RKSTRATUM_NO_AUTO_TERMINAL=1 to skip. +# APPIMAGE is set by the AppImage runtime to the outer .AppImage path. +if [[ -z "${RKSTRATUM_NO_AUTO_TERMINAL:-}" ]] && ([[ -n "${DISPLAY:-}" ]] || [[ -n "${WAYLAND_DISPLAY:-}" ]]) && ! [[ -t 0 ]] && [[ -n "${APPIMAGE:-}" ]]; then + quote_cmd() { + printf '%q' "$1" + shift || true + for a in "$@"; do printf ' %q' "$a"; done + } + _cmd=$(quote_cmd "$APPIMAGE" "$@") + if command -v x-terminal-emulator >/dev/null 2>&1; then + exec x-terminal-emulator -e bash -lc "exec $_cmd" + elif command -v gnome-terminal >/dev/null 2>&1; then + exec gnome-terminal --title="RKStratum Bridge" -- bash -lc "exec $_cmd" + elif command -v konsole >/dev/null 2>&1; then + exec konsole -p tabtitle="RKStratum Bridge" -e bash -lc "exec $_cmd" + elif command -v xfce4-terminal >/dev/null 2>&1; then + exec xfce4-terminal --title="RKStratum Bridge" -e bash -lc "exec $_cmd" + elif command -v xterm >/dev/null 2>&1; then + exec xterm -title "RKStratum Bridge" -e bash -lc "exec $_cmd" + fi +fi + +HERE="$(dirname "$(readlink -f "${0}")")" +BIN="${HERE}/usr/bin/stratum-bridge" +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/stratum-bridge" +CONFIG_FILE="${CONFIG_DIR}/config.yaml" +mkdir -p "${CONFIG_DIR}" +# Do not inject --config if the user passed one (clap rejects duplicate --config). +inject_config=true +for a in "$@"; do + case "$a" in + --config | --config=*) + inject_config=false + break + ;; + esac +done +EXTRA_ARGS=() +if [[ "${inject_config}" == true ]] && [[ -f "${CONFIG_FILE}" ]]; then + EXTRA_ARGS+=(--config "${CONFIG_FILE}") +fi +exec "${BIN}" "${EXTRA_ARGS[@]}" "$@" diff --git a/bridge/appimage/build.sh b/bridge/appimage/build.sh new file mode 100644 index 0000000000..65d8c1239c --- /dev/null +++ b/bridge/appimage/build.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Build stratum-bridge AppImage from an existing musl release binary. +# Usage: from repo root, after `cargo build --bin stratum-bridge --release --target x86_64-unknown-linux-musl`: +# bash bridge/appimage/build.sh [version-label] +set -euo pipefail + +VERSION="${1:-dev}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +PACK_DIR="${SCRIPT_DIR}" +cd "$ROOT" + +BINARY="target/x86_64-unknown-linux-musl/release/stratum-bridge" +if [[ ! -f "$BINARY" ]]; then + echo "error: missing $BINARY — build stratum-bridge for x86_64-unknown-linux-musl first." >&2 + exit 1 +fi + +APPDIR="${PACK_DIR}/StratumBridge.AppDir" +rm -rf "$APPDIR" +mkdir -p "$APPDIR/usr/bin" +cp "$BINARY" "$APPDIR/usr/bin/stratum-bridge" +chmod +x "$APPDIR/usr/bin/stratum-bridge" + +cp "${PACK_DIR}/AppRun" "$APPDIR/AppRun" +chmod +x "$APPDIR/AppRun" + +mkdir -p "$APPDIR/usr/share/applications" +cp "${PACK_DIR}/stratum-bridge.desktop" "$APPDIR/usr/share/applications/stratum-bridge.desktop" +# appimagetool requires exactly one .desktop at the AppDir root (may be a symlink). +ln -sf "usr/share/applications/stratum-bridge.desktop" "${APPDIR}/stratum-bridge.desktop" + +ICON_DIR="$APPDIR/usr/share/icons/hicolor/256x256/apps" +mkdir -p "$ICON_DIR" +SVG="${ROOT}/bridge/static/assets/kaspa.svg" +BUNDLED_PNG="${PACK_DIR}/stratum-bridge.png" +# appimagetool requires Icon=name as name.png at the AppDir root (256x256 recommended). +if [[ -f "$SVG" ]] && command -v rsvg-convert >/dev/null 2>&1; then + rsvg-convert -w 256 -h 256 "$SVG" -o "${ICON_DIR}/stratum-bridge.png" +elif [[ -f "$BUNDLED_PNG" ]]; then + cp "$BUNDLED_PNG" "${ICON_DIR}/stratum-bridge.png" +elif [[ -f "$SVG" ]]; then + echo "error: rsvg-convert not found and no ${BUNDLED_PNG}; cannot produce stratum-bridge.png for AppImage." >&2 + exit 1 +else + echo "error: missing kaspa.svg and bundled stratum-bridge.png; cannot produce app icon." >&2 + exit 1 +fi +cp "${ICON_DIR}/stratum-bridge.png" "${APPDIR}/stratum-bridge.png" +cp "${ICON_DIR}/stratum-bridge.png" "${APPDIR}/.DirIcon" + +TOOL="${PACK_DIR}/appimagetool-x86_64.AppImage" +if [[ ! -x "$TOOL" ]]; then + echo "Downloading appimagetool..." + wget -qO "$TOOL" "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x "$TOOL" +fi + +export ARCH=x86_64 +# Allow appimagetool to run without FUSE (e.g. GitHub Actions, CI). +export APPIMAGE_EXTRACT_AND_RUN=1 +OUT="${ROOT}/stratum-bridge-${VERSION}-x86_64.AppImage" +rm -f "$OUT" +"$TOOL" "$APPDIR" "$OUT" +echo "Built: $OUT" diff --git a/bridge/appimage/stratum-bridge.desktop b/bridge/appimage/stratum-bridge.desktop new file mode 100644 index 0000000000..d8c2663267 --- /dev/null +++ b/bridge/appimage/stratum-bridge.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=RKStratum +Comment=Stratum bridge for Kaspa mining +Exec=stratum-bridge +Icon=stratum-bridge +Categories=Network; +Terminal=true diff --git a/bridge/appimage/stratum-bridge.png b/bridge/appimage/stratum-bridge.png new file mode 100644 index 0000000000..65287af86f Binary files /dev/null and b/bridge/appimage/stratum-bridge.png differ diff --git a/bridge/docs/README.md b/bridge/docs/README.md index 1a597d0613..18c07e53b9 100644 --- a/bridge/docs/README.md +++ b/bridge/docs/README.md @@ -13,7 +13,6 @@ The bridge can run against: - **External** node (you run `kaspad` yourself) - **In-process** node (the bridge starts `kaspad` in the same process) - ### Running from a release If you are running from GitHub Releases (without `cargo run`): @@ -36,6 +35,14 @@ Then run in external mode: ./stratum-bridge --config bridge/config.yaml --node-mode external ``` +**Linux AppImage (optional):** Releases ship `stratum-bridge--x86_64.AppImage.tar.gz` only (the AppImage inside +preserves `+x` after `tar -xzf ...` or your archive manager). Extract, then run the `.AppImage`. When launched from a desktop +(no terminal), `AppRun` tries to open a system terminal window so startup logs stay visible; set `RKSTRATUM_NO_AUTO_TERMINAL=1` to +disable that. The AppImage looks for `config.yaml` at `$XDG_CONFIG_HOME/stratum-bridge/config.yaml` (usually +`~/.config/stratum-bridge/config.yaml`) when that file exists; otherwise it uses built-in defaults. Extra CLI arguments are +forwarded to the bridge (an explicit `--config` skips that default). To build the AppImage locally after a musl `stratum-bridge` +release build: `bash bridge/appimage/build.sh `. + ### CLI Help For detailed command-line options: diff --git a/bridge/src/kaspaapi.rs b/bridge/src/kaspaapi.rs index e1d7db16b0..52575ea608 100644 --- a/bridge/src/kaspaapi.rs +++ b/bridge/src/kaspaapi.rs @@ -8,14 +8,16 @@ use kaspa_notify::{listener::ListenerId, scope::NewBlockTemplateScope}; use kaspa_rpc_core::notify::mode::NotificationMode; use kaspa_rpc_core::{ GetBlockDagInfoRequest, GetBlockTemplateRequest, GetConnectedPeerInfoRequest, GetCurrentBlockColorRequest, GetInfoRequest, - GetServerInfoRequest, Notification, RpcHash, RpcRawBlock, SubmitBlockRequest, SubmitBlockResponse, api::rpc::RpcApi, + GetServerInfoRequest, GetSinkBlueScoreRequest, Notification, RpcHash, RpcRawBlock, SubmitBlockRequest, SubmitBlockResponse, + api::rpc::RpcApi, }; use once_cell::sync::Lazy; use parking_lot::Mutex; +use serde::Serialize; use std::collections::{HashMap, VecDeque}; use std::str::FromStr; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::mpsc; use tokio::sync::watch; use tokio::time::sleep; @@ -24,6 +26,11 @@ use tracing::{debug, error, info, warn}; const STRATUM_COINBASE_TAG_BYTES: &[u8] = b"RK-Stratum"; const MAX_COINBASE_TAG_SUFFIX_LEN: usize = 64; +/// Mining-ready must hold continuously at least this long before binding stratum. From disk the node +/// can report parity + no IBD peer for a short window before P2P schedules IBD (race on cold/extra connect). +const MIN_MINING_READY_STABLE: Duration = Duration::from_secs(2); +const MINING_READY_STABLE_POLL: Duration = Duration::from_millis(400); + fn sanitize_coinbase_tag_suffix(suffix: &str) -> Option { let suffix = suffix.trim().trim_start_matches('/'); if suffix.is_empty() { @@ -113,11 +120,14 @@ static BLOCK_SUBMIT_GUARD: Lazy> = #[derive(Clone, Debug, Default)] pub struct NodeStatusSnapshot { pub last_updated: Option, + /// Wall clock ms since UNIX epoch when the snapshot was last refreshed (for dashboards). + pub last_updated_unix_ms: Option, pub is_connected: bool, pub is_synced: Option, pub network_id: Option, pub server_version: Option, pub virtual_daa_score: Option, + pub sink_blue_score: Option, pub block_count: Option, pub header_count: Option, pub difficulty: Option, @@ -128,6 +138,75 @@ pub struct NodeStatusSnapshot { pub static NODE_STATUS: Lazy> = Lazy::new(|| Mutex::new(NodeStatusSnapshot::default())); +/// JSON-friendly node snapshot for `/api/status` (camelCase matches prior dashboard conventions for nested objects). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeStatusApi { + pub is_connected: bool, + pub is_synced: Option, + pub network_id: Option, + pub network_display: Option, + pub server_version: Option, + pub virtual_daa_score: Option, + pub sink_blue_score: Option, + pub block_count: Option, + pub header_count: Option, + /// DAG difficulty from the node (RPC); distinct from Prometheus-estimated network difficulty on the dashboard. + pub difficulty: Option, + pub tip_hash: Option, + pub peers: Option, + pub mempool_size: Option, + pub last_updated_unix_ms: Option, +} + +/// Short network label for UI (same parsing idea as the `[NODE]` log line). +pub fn network_display_from_id(network_id: Option<&str>) -> Option { + let net = network_id?.trim(); + if net.is_empty() || net == "-" { + return None; + } + let mut network_type = None; + let mut suffix = None; + if let Some(pos) = net.find("network_type:") { + let s = &net[pos + "network_type:".len()..]; + let s = s.trim_start(); + network_type = s.split(&[',', '}'][..]).next().map(|v| v.trim()); + } + if let Some(pos) = net.find("suffix:") { + let s = &net[pos + "suffix:".len()..]; + let s = s.trim_start(); + let raw = s.split(&[',', '}'][..]).next().map(|v| v.trim()); + if raw != Some("None") { + suffix = raw; + } + } + Some(match (network_type, suffix) { + (Some(nt), Some(suf)) => format!("{}-{}", nt, suf), + (Some(nt), None) => nt.to_string(), + _ => net.to_string(), + }) +} + +pub fn node_status_for_api() -> NodeStatusApi { + let s = NODE_STATUS.lock(); + NodeStatusApi { + is_connected: s.is_connected, + is_synced: s.is_synced, + network_id: s.network_id.clone(), + network_display: network_display_from_id(s.network_id.as_deref()), + server_version: s.server_version.clone(), + virtual_daa_score: s.virtual_daa_score, + sink_blue_score: s.sink_blue_score, + block_count: s.block_count, + header_count: s.header_count, + difficulty: s.difficulty, + tip_hash: s.tip_hash.clone(), + peers: s.peers, + mempool_size: s.mempool_size, + last_updated_unix_ms: s.last_updated_unix_ms, + } +} + /// Kaspa API client wrapper using RPC client /// Both use gRPC under the hood, but through an RPC client wrapper abstraction pub struct KaspaApi { @@ -331,59 +410,96 @@ impl KaspaApi { } } - async fn start_node_status_thread(self: Arc) { - let mut interval = tokio::time::interval(Duration::from_secs(10)); - loop { - interval.tick().await; - - let connected = self.client.is_connected(); - - let server_info_fut = self.client.get_server_info_call(None, GetServerInfoRequest {}); - let dag_info_fut = self.client.get_block_dag_info_call(None, GetBlockDagInfoRequest {}); - let peers_fut = self.client.get_connected_peer_info_call(None, GetConnectedPeerInfoRequest {}); - let info_fut = self.client.get_info_call(None, GetInfoRequest {}); - - let (server_info, dag_info, peers_info, info_resp) = tokio::join!(server_info_fut, dag_info_fut, peers_fut, info_fut); + /// One RPC round-trip to refresh [`NODE_STATUS`] (console `[NODE]` line and `/api/status`). + /// The background poller runs every 10s; call this when mining-ready flips so the snapshot + /// matches [`is_node_synced_for_mining`] instead of lagging by up to one interval. + async fn refresh_node_status_snapshot(&self) { + let connected = self.client.is_connected(); + + let server_info_fut = self.client.get_server_info_call(None, GetServerInfoRequest {}); + let dag_info_fut = self.client.get_block_dag_info_call(None, GetBlockDagInfoRequest {}); + let peers_fut = self.client.get_connected_peer_info_call(None, GetConnectedPeerInfoRequest {}); + let info_fut = self.client.get_info_call(None, GetInfoRequest {}); + let sink_bs_fut = self.client.get_sink_blue_score_call(None, GetSinkBlueScoreRequest {}); + let sync_fut = self.client.get_sync_status(); + + let (server_info, dag_info, peers_info, info_resp, sink_bs_resp, sync_res) = + tokio::join!(server_info_fut, dag_info_fut, peers_fut, info_fut, sink_bs_fut, sync_fut); + + let mut snapshot = NODE_STATUS.lock(); + snapshot.last_updated = Some(Instant::now()); + snapshot.last_updated_unix_ms = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_millis() as u64); + snapshot.is_connected = connected; + + // Prefer `getSyncStatus` over `getServerInfo.is_synced`; clear "synced" while any peer is + // the P2P IBD peer, or while DAG bodies lag headers (`block_count != header_count`). + let mut synced = match sync_res { + Ok(v) => Some(v), + Err(_) => server_info.as_ref().ok().map(|s| s.is_synced), + }; + if let Ok(peers) = &peers_info + && synced == Some(true) + && peers.peer_info.iter().any(|p| p.is_ibd_peer) + { + synced = Some(false); + } + if let Ok(dag) = &dag_info + && synced == Some(true) + && dag.block_count != dag.header_count + { + synced = Some(false); + } + snapshot.is_synced = synced; - let mut snapshot = NODE_STATUS.lock(); - snapshot.last_updated = Some(std::time::Instant::now()); - snapshot.is_connected = connected; + if let Ok(server_info) = server_info { + snapshot.network_id = Some(format!("{:?}", server_info.network_id)); + snapshot.server_version = Some(server_info.server_version); + snapshot.virtual_daa_score = Some(server_info.virtual_daa_score); + } - if let Ok(server_info) = server_info { - snapshot.is_synced = Some(server_info.is_synced); - snapshot.network_id = Some(format!("{:?}", server_info.network_id)); - snapshot.server_version = Some(server_info.server_version); - snapshot.virtual_daa_score = Some(server_info.virtual_daa_score); + if let Ok(dag) = dag_info { + snapshot.block_count = Some(dag.block_count); + snapshot.header_count = Some(dag.header_count); + snapshot.difficulty = Some(dag.difficulty); + snapshot.tip_hash = dag.tip_hashes.first().map(|h| format!("{}", h)); + if snapshot.virtual_daa_score.is_none() { + snapshot.virtual_daa_score = Some(dag.virtual_daa_score); } - - if let Ok(dag) = dag_info { - snapshot.block_count = Some(dag.block_count); - snapshot.header_count = Some(dag.header_count); - snapshot.difficulty = Some(dag.difficulty); - snapshot.tip_hash = dag.tip_hashes.first().map(|h| format!("{}", h)); - if snapshot.virtual_daa_score.is_none() { - snapshot.virtual_daa_score = Some(dag.virtual_daa_score); - } - if snapshot.network_id.is_none() { - snapshot.network_id = Some(format!("{:?}", dag.network)); - } + if snapshot.network_id.is_none() { + snapshot.network_id = Some(format!("{:?}", dag.network)); } + } - if let Ok(peers) = peers_info { - snapshot.peers = Some(peers.peer_info.len()); - } + if let Ok(peers) = peers_info { + snapshot.peers = Some(peers.peer_info.len()); + } - if let Ok(info) = info_resp { - snapshot.mempool_size = Some(info.mempool_size); - if snapshot.server_version.is_none() { - snapshot.server_version = Some(info.server_version); - } + if let Ok(info) = info_resp { + snapshot.mempool_size = Some(info.mempool_size); + if snapshot.server_version.is_none() { + snapshot.server_version = Some(info.server_version); } } + + snapshot.sink_blue_score = sink_bs_resp.ok().map(|r| r.blue_score); + } + + async fn start_node_status_thread(self: Arc) { + let mut interval = tokio::time::interval(Duration::from_secs(10)); + loop { + interval.tick().await; + self.refresh_node_status_snapshot().await; + } } /// Submit a block pub async fn submit_block(&self, block: Block) -> Result { + if !self.is_node_synced_for_mining().await { + return Err(anyhow::anyhow!( + "refusing block submit: node not mining-ready (sync, P2P IBD, or DAG block/header count mismatch)" + )); + } + // Use kaspa_consensus_core::hashing::header::hash() for block hash calculation // In Kaspa, the block hash is the header hash (transactions are represented by hash_merkle_root in header) use kaspa_consensus_core::hashing::header; @@ -579,60 +695,114 @@ impl KaspaApi { result } - /// Wait for node to sync - async fn wait_for_sync(&self) -> Result<()> { - loop { - match self.client.get_sync_status().await { - Ok(is_synced) => { - if is_synced { - break; - } - } - Err(e) => { - debug!("failed to get sync status: {}, retrying...", e); - } - } + /// Mining-safe sync: node's `getSyncStatus` (sink recent + not in transitional IBD), no active + /// P2P IBD peer (`getConnectedPeerInfo`: `is_ibd_peer`), and `getBlockDagInfo` **block/header + /// parity** (`block_count == header_count`). Headers can run ahead of bodies during catch-up; the + /// dashboard `blk=a/b` line reflects the same counts. + pub async fn is_node_synced_for_mining(&self) -> bool { + if !self.client.get_sync_status().await.unwrap_or(false) { + return false; + } - sleep(Duration::from_secs(10)).await; + let peers_fut = self.client.get_connected_peer_info_call(None, GetConnectedPeerInfoRequest {}); + let dag_fut = self.client.get_block_dag_info_call(None, GetBlockDagInfoRequest {}); + let (peers_res, dag_res) = tokio::join!(peers_fut, dag_fut); + + let ibd_peer_active = match &peers_res { + Ok(resp) => resp.peer_info.iter().any(|p| p.is_ibd_peer), + Err(e) => { + debug!("getConnectedPeerInfo failed while checking P2P IBD; ignoring IBD-peer gate: {}", e); + false + } + }; + if ibd_peer_active { + return false; } - Ok(()) + match &dag_res { + Ok(dag) => dag.block_count == dag.header_count, + Err(e) => { + debug!("getBlockDagInfo failed while checking block/header parity; not mining-ready: {}", e); + false + } + } } - pub async fn wait_for_sync_with_shutdown(&self, mut shutdown_rx: watch::Receiver) -> Result<()> { - debug!("checking kaspad sync state"); + /// Wait until [`is_node_synced_for_mining`] stays true for [`MIN_MINING_READY_STABLE`]. If + /// `shutdown_rx` is set, returns `false` when shutdown is requested; otherwise only returns `true`. + async fn wait_until_mining_ready_stable(&self, mut shutdown_rx: Option<&mut watch::Receiver>) -> bool { + let mut stable_since: Option = None; + // So the first "not synced" path can warn without waiting 10s from process start. + let mut last_slow_warn = Instant::now() - Duration::from_secs(30); loop { - let sync_fut = self.client.get_sync_status(); - let sync_res = tokio::select! { - _ = shutdown_rx.wait_for(|v| *v) => { - return Err(anyhow::anyhow!("shutdown requested")); + if let Some(rx) = shutdown_rx.as_mut() + && *rx.borrow() + { + return false; + } + + let ready_fut = self.is_node_synced_for_mining(); + let ready = match shutdown_rx.as_mut() { + Some(rx) => { + tokio::select! { + _ = rx.wait_for(|v| *v) => return false, + r = ready_fut => r, + } } - res = sync_fut => res, + None => ready_fut.await, }; - match sync_res { - Ok(is_synced) => { - if is_synced { - debug!("kaspad synced, starting server"); - break; + let now = Instant::now(); + if ready { + match stable_since { + None => stable_since = Some(now), + Some(t0) if now.duration_since(t0) >= MIN_MINING_READY_STABLE => { + self.refresh_node_status_snapshot().await; + return true; } + Some(_) => {} } - Err(e) => { - warn!("failed to get sync status: {}, retrying...", e); + } else { + if stable_since.take().is_some() { + warn!( + "{} {}", + LogColors::api("[API]"), + LogColors::label( + "Mining-ready dropped before stability window elapsed; continuing to wait (avoids opening stratum right before P2P IBD)" + ) + ); + } + if now.duration_since(last_slow_warn) >= Duration::from_secs(10) { + warn!("Kaspa is not synced (or P2P IBD still active), waiting before starting bridge"); + last_slow_warn = now; } } - warn!("Kaspa is not synced, waiting for sync before starting bridge"); - - tokio::select! { - _ = shutdown_rx.wait_for(|v| *v) => { - return Err(anyhow::anyhow!("shutdown requested")); + match shutdown_rx.as_mut() { + Some(rx) => { + tokio::select! { + _ = rx.wait_for(|v| *v) => return false, + _ = sleep(MINING_READY_STABLE_POLL) => {} + } } - _ = sleep(Duration::from_secs(10)) => {} + None => sleep(MINING_READY_STABLE_POLL).await, } } + } + + /// Block until the node reports fully synced. Logs at WARN on each wait cycle (same message as startup). + async fn wait_for_sync(&self) -> Result<()> { + self.wait_until_mining_ready_stable(None).await; + Ok(()) + } + pub async fn wait_for_sync_with_shutdown(&self, mut shutdown_rx: watch::Receiver) -> Result<()> { + debug!("checking kaspad sync state"); + if !self.wait_until_mining_ready_stable(Some(&mut shutdown_rx)).await { + return Err(anyhow::anyhow!("shutdown requested")); + } + debug!("kaspad mining-ready (stable window passed), starting stratum"); Ok(()) } @@ -643,6 +813,12 @@ impl KaspaApi { /// Get block template for a client pub async fn get_block_template(&self, wallet_addr: &str, _remote_app: &str, _canxium_addr: &str) -> Result { + if !self.is_node_synced_for_mining().await { + return Err(anyhow::anyhow!( + "refusing block template: node not mining-ready (sync, P2P IBD, or DAG block/header count mismatch)" + )); + } + // Retry up to 3 times if we get "Odd number of digits" error // This error can occur if the block template has malformed hash fields let max_retries = 3; @@ -772,9 +948,43 @@ impl KaspaApi { Ok(resp.blue) } + /// Block until mining-ready or shutdown. No extra stability window here: [`wait_for_sync_with_shutdown`] + /// in `main` already enforces [`MIN_MINING_READY_STABLE`]; repeating it would delay template jobs ~2s after + /// TCP accepts miners on each outer-loop re-entry. + async fn block_until_synced_or_shutdown(api: Arc, shutdown_rx: &mut watch::Receiver) -> bool { + loop { + if *shutdown_rx.borrow() { + return false; + } + + let ready_fut = api.is_node_synced_for_mining(); + let ready = tokio::select! { + _ = shutdown_rx.wait_for(|v| *v) => { + return false; + } + r = ready_fut => r, + }; + + if ready { + return true; + } + warn!("Kaspa is not synced (or P2P IBD still active), waiting for sync before starting bridge"); + + tokio::select! { + _ = shutdown_rx.wait_for(|v| *v) => { + return false; + } + _ = sleep(Duration::from_secs(10)) => {} + } + } + } + /// Start listening for block template notifications /// Uses RegisterForNewBlockTemplateNotifications with ticker fallback /// This provides immediate notifications when new blocks are available, with polling as fallback + /// + /// **Sync safety:** templates are only dispatched while the node is mining-ready (same as + /// [`is_node_synced_for_mining`]). If sync is lost or P2P IBD resumes, we stop calling the callback. pub async fn start_block_template_listener(self: Arc, block_wait_time: Duration, mut block_cb: F) -> Result<()> where F: FnMut() + Send + 'static, @@ -783,60 +993,68 @@ impl KaspaApi { let api_clone = Arc::clone(&self); tokio::spawn(async move { - let mut restart_channel = true; - let mut ticker = tokio::time::interval(block_wait_time); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - // Check sync state and reconnect if needed - if let Err(e) = api_clone.wait_for_sync().await { - error!("error checking kaspad sync state, attempting reconnect: {}", e); - // Note: gRPC client handles reconnection automatically, but we log it - // In Go, reconnect() is called explicitly, but Rust gRPC handles it - tokio::time::sleep(Duration::from_secs(5)).await; - restart_channel = true; - } + let mut log_sync_resume = true; + + 'outer: loop { + let _ = api_clone.wait_for_sync().await; - // Re-register for notifications if needed - if restart_channel { - // In Go, RegisterForNewBlockTemplateNotifications is called here when restartChannel is true - // In Rust, we already subscribed in new(), and the notification channel persists - // If the connection is lost, the gRPC client handles reconnection automatically - // The notification subscription should be maintained by the gRPC client - // If notifications stop working, we'll fall back to ticker polling - restart_channel = false; + if std::mem::take(&mut log_sync_resume) { + info!( + "{} {}", + LogColors::api("[API]"), + LogColors::label("Node fully synced — distributing block templates to stratum miners") + ); } - // Wait for either notification or ticker timeout - tokio::select! { - // Notification received - notification_result = rx.recv() => { - match notification_result { - Some(Notification::NewBlockTemplate(_)) => { - // Drain any additional notifications - while rx.try_recv().is_ok() {} - - // Call callback - block_cb(); - - // Reset ticker - ticker = tokio::time::interval(block_wait_time); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut ticker = tokio::time::interval(block_wait_time); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + 'inner: loop { + tokio::select! { + notification_result = rx.recv() => { + match notification_result { + Some(Notification::NewBlockTemplate(_)) => { + while rx.try_recv().is_ok() {} + } + Some(_) => continue, + None => { + warn!("Block template notification channel closed"); + break 'outer; + } } - Some(_) => { - // Other notification types - ignore + + if !api_clone.is_node_synced_for_mining().await { + warn!( + "{} {}", + LogColors::api("[API]"), + LogColors::label( + "Node left fully-synced state; pausing stratum jobs until sync completes (IBD / catch-up)" + ) + ); + log_sync_resume = true; + break 'inner; } - None => { - // Channel closed - exit loop - warn!("Block template notification channel closed"); - break; + + block_cb(); + ticker = tokio::time::interval(block_wait_time); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + } + _ = ticker.tick() => { + if !api_clone.is_node_synced_for_mining().await { + warn!( + "{} {}", + LogColors::api("[API]"), + LogColors::label( + "Node left fully-synced state; pausing stratum jobs until sync completes (IBD / catch-up)" + ) + ); + log_sync_resume = true; + break 'inner; } + + block_cb(); } } - // Ticker timeout - manually check for new blocks - _ = ticker.tick() => { - block_cb(); - } } } }); @@ -857,48 +1075,86 @@ impl KaspaApi { let api_clone = Arc::clone(&self); tokio::spawn(async move { - let mut restart_channel = true; - let mut ticker = tokio::time::interval(block_wait_time); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut log_sync_resume = true; - loop { - if *shutdown_rx.borrow() { + 'outer: loop { + if !KaspaApi::block_until_synced_or_shutdown(Arc::clone(&api_clone), &mut shutdown_rx).await { break; } - if let Err(e) = api_clone.wait_for_sync().await { - error!("error checking kaspad sync state, attempting reconnect: {}", e); - tokio::time::sleep(Duration::from_secs(5)).await; - restart_channel = true; + if std::mem::take(&mut log_sync_resume) { + info!( + "{} {}", + LogColors::api("[API]"), + LogColors::label("Node fully synced — distributing block templates to stratum miners") + ); } - if restart_channel { - restart_channel = false; - } + let mut ticker = tokio::time::interval(block_wait_time); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - tokio::select! { - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - break; - } + 'inner: loop { + if *shutdown_rx.borrow() { + break 'outer; } - notification_result = rx.recv() => { - match notification_result { - Some(Notification::NewBlockTemplate(_)) => { - while rx.try_recv().is_ok() {} - block_cb(); - ticker = tokio::time::interval(block_wait_time); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break 'outer; + } + } + notification_result = rx.recv() => { + match notification_result { + Some(Notification::NewBlockTemplate(_)) => { + while rx.try_recv().is_ok() {} + } + Some(_) => continue, + None => { + warn!("Block template notification channel closed"); + break 'outer; + } + } + + if *shutdown_rx.borrow() { + break 'outer; } - Some(_) => {} - None => { - warn!("Block template notification channel closed"); - break; + + if !api_clone.is_node_synced_for_mining().await { + warn!( + "{} {}", + LogColors::api("[API]"), + LogColors::label( + "Node left fully-synced state; pausing stratum jobs until sync completes (IBD / catch-up)" + ) + ); + log_sync_resume = true; + break 'inner; } + + block_cb(); + ticker = tokio::time::interval(block_wait_time); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + } + _ = ticker.tick() => { + if *shutdown_rx.borrow() { + break 'outer; + } + + if !api_clone.is_node_synced_for_mining().await { + warn!( + "{} {}", + LogColors::api("[API]"), + LogColors::label( + "Node left fully-synced state; pausing stratum jobs until sync completes (IBD / catch-up)" + ) + ); + log_sync_resume = true; + break 'inner; + } + + block_cb(); } - } - _ = ticker.tick() => { - block_cb(); } } } @@ -945,4 +1201,8 @@ impl KaspaApiTrait for KaspaApi { .await .map_err(|e| Box::new(std::io::Error::other(e.to_string())) as Box) } + + async fn is_node_synced_for_mining(&self) -> bool { + KaspaApi::is_node_synced_for_mining(self).await + } } diff --git a/bridge/src/share_handler.rs b/bridge/src/share_handler.rs index 7c17183988..90ad0eb1b3 100644 --- a/bridge/src/share_handler.rs +++ b/bridge/src/share_handler.rs @@ -1434,28 +1434,7 @@ impl ShareHandler { let tip_short = if tip.len() > 28 { format!("{}...{}", &tip[..16], &tip[tip.len() - 8..]) } else { tip.to_string() }; - let net_short = { - let mut network_type = None; - let mut suffix = None; - if let Some(pos) = net.find("network_type:") { - let s = &net[pos + "network_type:".len()..]; - let s = s.trim_start(); - network_type = s.split(&[',', '}'][..]).next().map(|v| v.trim()); - } - if let Some(pos) = net.find("suffix:") { - let s = &net[pos + "suffix:".len()..]; - let s = s.trim_start(); - let raw = s.split(&[',', '}'][..]).next().map(|v| v.trim()); - if raw != Some("None") { - suffix = raw; - } - } - match (network_type, suffix) { - (Some(nt), Some(suf)) => format!("{}-{}", nt, suf), - (Some(nt), None) => nt.to_string(), - _ => net.to_string(), - } - }; + let net_short = crate::kaspaapi::network_display_from_id(Some(net)).unwrap_or_else(|| net.to_string()); out.push(format!( "[NODE] {}|{} | n={} | v={} | p={} | vd={} | blk={}/{} | d={} | mp={} | tip={}", @@ -1678,6 +1657,9 @@ pub trait KaspaApiTrait: Send + Sync { ) -> Result, Box>; async fn get_current_block_color(&self, block_hash: &str) -> Result>; + + /// `true` only when the node reports fully synced for mining (`getSyncStatus`: sink recent + not in transitional IBD). + async fn is_node_synced_for_mining(&self) -> bool; } pub struct WorkerContext<'a> { diff --git a/bridge/src/stratum_server.rs b/bridge/src/stratum_server.rs index 40d1a64cf0..49c8d135fc 100644 --- a/bridge/src/stratum_server.rs +++ b/bridge/src/stratum_server.rs @@ -257,6 +257,7 @@ async fn listen_and_serve_impl( tokio::spawn(async move { let mut interval = tokio::time::interval(config.block_wait_time); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut was_synced = true; loop { if let Some(ref mut rx) = shutdown_rx_poll { tokio::select! { @@ -266,11 +267,31 @@ async fn listen_and_serve_impl( } } _ = interval.tick() => { + if !kaspa_api_poll.is_node_synced_for_mining().await { + if was_synced { + warn!( + "Kaspa is not mining-ready — pausing template polling until sync, P2P IBD, and block/header parity match" + ); + was_synced = false; + } + continue; + } + was_synced = true; client_handler_poll.new_block_available(Arc::clone(&kaspa_api_poll)).await; } } } else { interval.tick().await; + if !kaspa_api_poll.is_node_synced_for_mining().await { + if was_synced { + warn!( + "Kaspa is not mining-ready — pausing template polling until sync, P2P IBD, and block/header parity match" + ); + was_synced = false; + } + continue; + } + was_synced = true; client_handler_poll.new_block_available(Arc::clone(&kaspa_api_poll)).await; } }