diff --git a/.env.example b/.env.example
index 98d397e..7764448 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,9 @@ DATUM_ADMIN_PASSWORD=
# few seconds; 5s is a sane default.
DATUM_POLL_INTERVAL_MS=5000
+# Sovereign mempool API used for block height, network hashrate, and difficulty.
+MEMPOOL_API_URL=https://tx1138.com/api
+
# ---- Nostr auth ----
# Comma-separated bech32 npubs allowed to log in. Anything else is rejected
# at NIP-98 verification, before any session is issued.
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index e6876c7..3dd9b8e 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -15,6 +15,7 @@ const RawEnv = z.object({
DATUM_ADMIN_USER: z.string().default("admin"),
DATUM_ADMIN_PASSWORD: z.string().min(1),
DATUM_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000),
+ MEMPOOL_API_URL: z.string().url().default("https://tx1138.com/api"),
NOSTR_ALLOWED_NPUBS: z.string().min(1),
@@ -57,6 +58,9 @@ export const config = {
adminPassword: parsed.DATUM_ADMIN_PASSWORD,
pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS,
},
+ mempool: {
+ url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""),
+ },
nostr: {
allowedHexPubkeys: parseAllowedNpubs(parsed.NOSTR_ALLOWED_NPUBS),
},
diff --git a/apps/api/src/datum/poller.ts b/apps/api/src/datum/poller.ts
index d66cc9e..ad5c8b7 100644
--- a/apps/api/src/datum/poller.ts
+++ b/apps/api/src/datum/poller.ts
@@ -2,7 +2,7 @@ import { config } from "../config.js";
import { logger } from "../logger.js";
import { digestFetch } from "./digest.js";
import { parseClientsHtml, parseThreadsHtml } from "./parse.js";
-import type { CurrentJob, DatumSnapshot, PoolStat } from "./types.js";
+import type { CurrentJob, DatumSnapshot, NetworkStat, PoolStat } from "./types.js";
const DEFAULT_TIMEOUT_MS = 10_000;
@@ -35,11 +35,19 @@ const EMPTY_JOB: CurrentJob = {
timeInfo: "",
};
+const EMPTY_NETWORK: NetworkStat = {
+ blockHeight: 0,
+ hashrateEh: 0,
+ difficulty: 0,
+ source: "",
+};
+
let lastSnapshot: DatumSnapshot = {
ok: false,
fetchedAt: 0,
pool: EMPTY_POOL,
job: EMPTY_JOB,
+ network: EMPTY_NETWORK,
miners: [],
error: { code: "NOT_YET_POLLED", message: "Poller has not run yet" },
};
@@ -85,6 +93,37 @@ function isUmbrelShellHtml(html: string): boolean {
return /
\s*Umbrel\s*<\/title>/i.test(html) || / {
+ const timeout = abortableSignal(DEFAULT_TIMEOUT_MS);
+ try {
+ const [heightRes, hashrateRes] = await Promise.all([
+ fetch(`${config.mempool.url}/blocks/tip/height`, { signal: timeout.signal }),
+ fetch(`${config.mempool.url}/v1/mining/hashrate/3d`, { signal: timeout.signal }),
+ ]);
+ if (!heightRes.ok || !hashrateRes.ok) {
+ throw new Error(`mempool returned ${heightRes.status}/${hashrateRes.status}`);
+ }
+ const [heightText, hashrateJson] = await Promise.all([
+ heightRes.text(),
+ hashrateRes.json() as Promise
,
+ ]);
+ const blockHeight = Number(heightText);
+ return {
+ blockHeight: Number.isFinite(blockHeight) ? blockHeight : 0,
+ hashrateEh: (hashrateJson.currentHashrate ?? 0) / 1e18,
+ difficulty: hashrateJson.currentDifficulty ?? 0,
+ source: config.mempool.url,
+ };
+ } finally {
+ timeout.cancel();
+ }
+}
+
async function pollOnce(): Promise {
const fetchedAt = Date.now();
@@ -193,6 +232,12 @@ async function pollOnce(): Promise {
threads.reduce((s, t) => s + t.connections, 0) || miners.length;
const sharesAccepted = miners.reduce((s, m) => s + m.diffAcceptedCount, 0);
const sharesRejected = miners.reduce((s, m) => s + m.diffRejectedCount, 0);
+ let network = lastSnapshot.network.blockHeight > 0 ? lastSnapshot.network : EMPTY_NETWORK;
+ try {
+ network = await fetchNetworkStats();
+ } catch (err) {
+ logger.warn({ reason: formatErr(err), url: config.mempool.url }, "mempool_fetch_failed");
+ }
return {
ok: true,
@@ -207,7 +252,12 @@ async function pollOnce(): Promise {
sharesRejected,
connectionStatus: "ok",
},
- job: EMPTY_JOB,
+ job: {
+ ...EMPTY_JOB,
+ blockHeight: network.blockHeight,
+ difficulty: network.difficulty,
+ },
+ network,
miners,
};
} catch (err) {
diff --git a/apps/api/src/datum/types.ts b/apps/api/src/datum/types.ts
index 0047222..d4ab897 100644
--- a/apps/api/src/datum/types.ts
+++ b/apps/api/src/datum/types.ts
@@ -60,11 +60,19 @@ export type CurrentJob = {
timeInfo: string;
};
+export type NetworkStat = {
+ blockHeight: number;
+ hashrateEh: number;
+ difficulty: number;
+ source: string;
+};
+
export type DatumSnapshot = {
ok: boolean;
fetchedAt: number;
pool: PoolStat;
job: CurrentJob;
+ network: NetworkStat;
miners: MinerStat[];
error?: { code: string; message: string };
};
diff --git a/apps/web/src/components/LotteryWidget.vue b/apps/web/src/components/LotteryWidget.vue
index 70f9301..9fffa2c 100644
--- a/apps/web/src/components/LotteryWidget.vue
+++ b/apps/web/src/components/LotteryWidget.vue
@@ -2,18 +2,17 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import { LOTTERY_LINES } from "../strings";
-const props = defineProps<{ totalThs: number }>();
+const props = defineProps<{ totalThs: number; networkEh: number }>();
-// As of mid-2026, network hashrate is roughly ~720 EH/s. Hardcoded estimate;
-// good enough for self-deprecating jokes about cosmic odds.
-const NETWORK_EH_S = 720;
+const FALLBACK_NETWORK_EH_S = 720;
const SECONDS_PER_BLOCK = 600;
const SECONDS_PER_DAY = 86400;
+const activeNetworkEh = computed(() => props.networkEh > 0 ? props.networkEh : FALLBACK_NETWORK_EH_S);
const probPerBlock = computed(() => {
if (props.totalThs <= 0) return 0;
// Th/s vs EH/s = 1e6 conversion: 1 EH/s = 1e6 Th/s
- return props.totalThs / (NETWORK_EH_S * 1e6);
+ return props.totalThs / (activeNetworkEh.value * 1e6);
});
const probPerDay = computed(() => {
@@ -44,6 +43,9 @@ const currentLine = computed(() => {
solo lottery odds
+
+ calculated against {{ activeNetworkEh.toFixed(0) }} EH/s network hashrate
+
$
{{ currentLine }}
@@ -64,6 +66,9 @@ const currentLine = computed(() => {
flex-direction: column;
gap: 10px;
}
+.source {
+ font-size: 10px;
+}
.line {
font-size: 13px;
display: flex;
diff --git a/apps/web/src/components/MinerRace.vue b/apps/web/src/components/MinerRace.vue
index 9ca0b19..1bea5d8 100644
--- a/apps/web/src/components/MinerRace.vue
+++ b/apps/web/src/components/MinerRace.vue
@@ -1,8 +1,15 @@