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) || /<div\s+id=["']root["']/i.test(html); } +type MempoolHashrate = { + currentHashrate?: number; + currentDifficulty?: number; +}; + +async function fetchNetworkStats(): Promise<NetworkStat> { + 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<MempoolHashrate>, + ]); + 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<DatumSnapshot> { const fetchedAt = Date.now(); @@ -193,6 +232,12 @@ async function pollOnce(): Promise<DatumSnapshot> { 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<DatumSnapshot> { 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(() => { <template> <section class="lottery panel"> <div class="label">solo lottery odds</div> + <div class="source muted"> + calculated against {{ activeNetworkEh.toFixed(0) }} EH/s network hashrate + </div> <div class="line glow-amber"> <span class="prefix">$</span> <span class="text">{{ currentLine }}</span> @@ -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 @@ <script setup lang="ts"> import { computed } from "vue"; import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types"; +import { useRotatingCopy } from "../composables/useRotatingCopy"; +import { RACE_SCALE_NOTES, RACE_TITLES } from "../copy"; const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>(); +const raceTitle = useRotatingCopy(RACE_TITLES); +const scaleNote = useRotatingCopy(RACE_SCALE_NOTES, 7600); +const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0); +const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0); +const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0); const rows = computed(() => { const miners = props.snapshot?.miners ?? []; @@ -43,6 +50,28 @@ const rows = computed(() => { ]; }); +const cosmicRows = computed(() => { + if (!networkEh.value || !totalThs.value) return []; + const totalNetworkThs = networkEh.value * 1_000_000; + const largePoolThs = totalNetworkThs * 0.25; + return [ + { + key: "large-pool", + name: "Large pool-ish bully", + value: `${(largePoolThs / 1_000_000).toFixed(0)} EH/s`, + width: 78, + joke: "illustrative 25% chunk of the monster", + }, + { + key: "network", + name: "Total Bitcoin network", + value: `${networkEh.value.toFixed(0)} EH/s`, + width: 100, + joke: `${networkMultiple.value.toExponential(2)}x your shelf. rude but factual.`, + }, + ]; +}); + function minerKey(m: MinerStat): string { return m.nickname || m.authUsername || m.remoteHost; } @@ -60,12 +89,13 @@ function imageFor(m: MinerStat): string { <header> <div> <div class="label">hashrate race</div> - <h2 class="glow-cyan">Tiny machines, deeply unfair track</h2> + <h2 class="glow-cyan">{{ raceTitle }}</h2> </div> - <span class="muted">ranked by current TH/s</span> + <span class="muted">{{ scaleNote }}</span> </header> <div class="track"> + <div class="section-label">Actual miners</div> <div v-for="row in rows" :key="row.key" class="lane"> <div class="rank">#{{ row.place }}</div> <img :src="row.image" :alt="row.miner.model" /> @@ -81,6 +111,25 @@ function imageFor(m: MinerStat): string { </div> </div> </div> + + <div v-if="cosmicRows.length" class="section-label cosmic-label"> + Big pools / total network · separate scale so the kids remain visible + </div> + <div v-for="row in cosmicRows" :key="row.key" class="lane cosmic"> + <div class="rank">∞</div> + <div class="network-icon">₿</div> + <div class="runner"> + <div class="meta"> + <strong>{{ row.name }}</strong> + <span>{{ row.value }} · {{ row.joke }}</span> + </div> + <div class="bar cosmic-bar"> + <span :style="{ width: `${row.width}%` }"> + <i>NOPE</i> + </span> + </div> + </div> + </div> </div> </section> </template> @@ -108,6 +157,17 @@ h2 { flex-direction: column; gap: 12px; } +.section-label { + color: var(--neon-amber); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.14em; + border-top: 1px solid var(--line); + padding-top: 10px; +} +.cosmic-label { + color: var(--neon-red); +} .lane { display: grid; grid-template-columns: 46px 86px 1fr; @@ -124,6 +184,16 @@ img { height: 58px; object-fit: contain; } +.network-icon { + width: 58px; + height: 58px; + display: grid; + place-items: center; + border: 1px solid var(--neon-red); + color: var(--neon-amber); + font-size: 28px; + box-shadow: 0 0 14px rgba(255, 79, 120, 0.24); +} .runner { min-width: 0; } @@ -158,6 +228,12 @@ img { background: #475569; box-shadow: none; } +.cosmic .bar span { + background: linear-gradient(90deg, #ff4f78, #ffd84a); +} +.cosmic .meta strong { + color: var(--neon-red); +} .bar i { padding-right: 6px; color: #07090f; diff --git a/apps/web/src/components/PoolHero.vue b/apps/web/src/components/PoolHero.vue index 93a69bb..365b599 100644 --- a/apps/web/src/components/PoolHero.vue +++ b/apps/web/src/components/PoolHero.vue @@ -1,14 +1,27 @@ <script setup lang="ts"> import { computed } from "vue"; import type { DatumSnapshot } from "../types"; +import { useRotatingCopy } from "../composables/useRotatingCopy"; +import { BLOCK_LABELS, HASHRATE_LABELS, NETWORK_LABELS } from "../copy"; const props = defineProps<{ snapshot: DatumSnapshot | null }>(); const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0); +const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0); +const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0); +const networkRoast = computed(() => { + if (!networkMultiple.value) return "waiting for the grown-up numbers"; + if (networkMultiple.value > 20_000_000) return "the network is a freight train; this shelf is a polite cough."; + if (networkMultiple.value > 1_000_000) return "big miners brought warehouses; you brought character."; + return "we are briefly visible on the cosmic spreadsheet."; +}); const subscribed = computed(() => props.snapshot?.pool.totalSubscriptions ?? 0); const sharesAccepted = computed(() => props.snapshot?.pool.sharesAccepted ?? 0); const sharesRejected = computed(() => props.snapshot?.pool.sharesRejected ?? 0); const blockHeight = computed(() => props.snapshot?.job.blockHeight ?? 0); +const hashrateLabel = useRotatingCopy(HASHRATE_LABELS); +const blockLabel = useRotatingCopy(BLOCK_LABELS, 7200); +const networkLabel = useRotatingCopy(NETWORK_LABELS, 7800); const ageS = computed(() => { const t = props.snapshot?.fetchedAt; if (!t) return null; @@ -19,17 +32,26 @@ const ageS = computed(() => { <template> <section class="hero panel"> <div class="left"> - <div class="label">total hashrate · all 4 little soldiers</div> + <div class="label">{{ hashrateLabel }}</div> <div class="number glow-cyan"> {{ totalThs.toFixed(2) }} <span class="unit">Th/s</span> </div> + <div class="network-roast muted">{{ networkRoast }}</div> </div> <div class="right"> <div class="cell"> - <div class="label">block height</div> + <div class="label">{{ blockLabel }}</div> <div class="value">{{ blockHeight > 0 ? blockHeight.toLocaleString() : "—" }}</div> </div> + <div class="cell"> + <div class="label">{{ networkLabel }}</div> + <div class="value glow-amber">{{ networkEh > 0 ? `${networkEh.toFixed(0)} EH/s` : "—" }}</div> + </div> + <div class="cell"> + <div class="label">network is bigger by</div> + <div class="value glow-red">{{ networkMultiple > 0 ? `${networkMultiple.toExponential(2)}x` : "—" }}</div> + </div> <div class="cell"> <div class="label">subscribed</div> <div class="value">{{ subscribed }}</div> @@ -67,9 +89,14 @@ const ageS = computed(() => { font-size: 18px; color: var(--fg-2); } +.network-roast { + margin-top: 10px; + font-size: 11px; + line-height: 1.4; +} .right { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(7, 1fr); gap: 12px; } .cell .value { diff --git a/apps/web/src/components/Shameboard.vue b/apps/web/src/components/Shameboard.vue index 5760eda..791b9e4 100644 --- a/apps/web/src/components/Shameboard.vue +++ b/apps/web/src/components/Shameboard.vue @@ -12,6 +12,8 @@ const SUBSIDY_SATS = 312_500_000; const miners = computed(() => props.snapshot?.miners ?? []); const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0); +const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0); +const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0); const totalWatts = computed(() => miners.value.reduce((sum, m) => sum + m.watts, 0)); const rewardSats = computed(() => props.snapshot?.job.blockValueSats || SUBSIDY_SATS); const difficulty = computed(() => props.snapshot?.job.difficulty || 0); @@ -212,6 +214,11 @@ function pct(n: number, digits = 4): string { <strong>{{ compact(totalWatts, 0) }} W</strong> <small>{{ compact(heatBtu, 0) }} BTU/hr · {{ compact(poolEfficiency, 4) }} TH/W</small> </div> + <div> + <span class="label">network bullying</span> + <strong class="glow-red">{{ networkMultiple > 0 ? `${networkMultiple.toExponential(2)}x` : "collecting" }}</strong> + <small>{{ networkEh > 0 ? `${compact(networkEh, 0)} EH/s globally. easy version: the planet brought a warehouse.` : "tx1138 mempool warming up" }}</small> + </div> </div> <div class="leader-strip"> @@ -294,7 +301,7 @@ h2 { letter-spacing: 0.08em; } .summary { - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(6, 1fr); } .summary > div, .leader-strip > div { diff --git a/apps/web/src/composables/useRotatingCopy.ts b/apps/web/src/composables/useRotatingCopy.ts new file mode 100644 index 0000000..bfc7b50 --- /dev/null +++ b/apps/web/src/composables/useRotatingCopy.ts @@ -0,0 +1,18 @@ +import { computed, onMounted, onUnmounted, ref } from "vue"; + +export function useRotatingCopy(lines: readonly string[], intervalMs = 6500) { + const index = ref(0); + let timer: number | null = null; + + onMounted(() => { + timer = window.setInterval(() => { + index.value = (index.value + 1) % lines.length; + }, intervalMs); + }); + + onUnmounted(() => { + if (timer !== null) window.clearInterval(timer); + }); + + return computed(() => lines[index.value] ?? lines[0] ?? ""); +} diff --git a/apps/web/src/copy.ts b/apps/web/src/copy.ts new file mode 100644 index 0000000..894937d --- /dev/null +++ b/apps/web/src/copy.ts @@ -0,0 +1,114 @@ +export const HASHRATE_LABELS = [ + "total hashrate · all actual miners", + "tiny thunder department", + "hashing cupboard output", + "combined cope velocity", + "someone's getting warmed tonight", + "space heaters with receipts", + "four boards versus a planet", + "domestic sha-256 turbulence", + "the little engines that almost certainly won't", + "thermal optimism index", + "proof-of-warmth aggregate", + "hashrate, allegedly", + "tiny chips doing tax fraud to physics", + "the room is warmer than the odds", + "solo lottery horsepower", + "sats-per-delusion engine", + "garage-grade entropy cannon", + "pleb furnace output", + "all miners, no excuses", + "bitcoin-adjacent radiator speed", +] as const; + +export const BLOCK_LABELS = [ + "current block height", + "where bitcoin is right now", + "latest block on the wall", + "chain height, for the adults", + "block number the kids are chasing", + "global scoreboard tick", + "bitcoin's current floor", + "the moving target", + "height of the mountain", + "mempool says we're here", + "sovereign block receipt", + "latest confirmed step", + "chain altitude", + "block ladder position", + "what the node believes", + "today's impossible number", + "where the grown-up miners are", + "the target keeps walking", + "bitcoin's odometer", + "public humiliation height", +] as const; + +export const NETWORK_LABELS = [ + "network hashrate", + "the actual boss fight", + "everyone else, unfortunately", + "global mining industrial complex", + "the dragon outside", + "planetary sha-256 weather", + "big pool gravity", + "the reason this is funny", + "industrial-scale bullying", + "total network furnace", + "the adults with warehouses", + "hashrate landlord class", + "bitcoin's meat grinder", + "global chip argument", + "big miner misery wall", + "the mountain of nope", + "everyone versus your shelf", + "network-sized emotional damage", + "the pool hall full of monsters", + "the thing Boomer Heater fears", +] as const; + +export const RACE_TITLES = [ + "Tiny machines, deeply unfair track", + "Actual miners, spiritually overconfident", + "The cupboard grand prix", + "Drag race at walking speed", + "The hashrate kindergarten derby", + "Domestic miners doing their little laps", + "Four warm rectangles and a dream", + "Race day for the statistically doomed", + "The space-heater invitational", + "Actual miners, actual delusion", + "The tiny ASIC humiliation circuit", + "Tour de not-finding-a-block", + "Hashrate horse race, if horses were firmware", + "A sprint measured in geological disappointment", + "The nobody-is-retiring 500", + "Proof-of-work nursery slopes", + "The little rigs are trying, allegedly", + "Solo mining track day", + "The warm boys championship", + "Someone's getting warmed tonight", +] as const; + +export const RACE_SCALE_NOTES = [ + "actual miners · scaled up to not shame the children", + "scaled up so the children remain visible", + "visual mercy scale enabled", + "not to scale, because cruelty has limits", + "tiny miner zoom mode", + "chart wearing reading glasses", + "magnified for emotional support", + "scaled up like their confidence", + "warehouse miners cropped out for decency", + "child-safe hashrate scale", + "actual miners, padded for dignity", + "zoomed in before the network laughs", + "big pools excluded from the yardstick", + "scale adjusted to preserve morale", + "the children get a booster seat", + "mercifully not linear", + "visual participation trophy mode", + "shown larger than nature intended", + "small rig cinema mode", + "the graph lies kindly", +] as const; diff --git a/apps/web/src/strings.ts b/apps/web/src/strings.ts index 346c2f2..babb176 100644 --- a/apps/web/src/strings.ts +++ b/apps/web/src/strings.ts @@ -32,6 +32,110 @@ export const LOTTERY_LINES = [ `solo mining: where hope goes to become server logs.`, () => `this is not financial advice. It is barely electrical advice.`, + () => + `the fiat system is shaking, but not because of this. This is mostly fan noise.`, + () => + `somewhere a central banker felt nothing at all.`, + () => + `if this finds a block, check the logs, the stars, and whoever touched the thermostat.`, + () => + `the orange pill was supposed to be freedom, not watching a desk fan beg for destiny.`, + () => + `your odds are non-zero, which is how all terrible ideas get funding.`, + () => + `the network difficulty looked at this rig and filed a noise complaint.`, + () => + `proof of work? More like proof of warm.`, + () => + `fiat dies slowly. This block attempt dies every ten minutes.`, + () => + `the hash is strong. The probability is in witness protection.`, + () => + `the IMF has not yet issued a statement about your 0.000000whatever percent chance.`, + () => + `solo mining is just buying a lottery ticket that also heats the hallway.`, + () => + `the block reward remains technically available, like buried treasure on Mars.`, + () => + `every rejected share is a tiny central bank meeting with worse catering.`, + () => + `some call it sovereign mining. Others call it an elaborate way to run a heater.`, + () => + `the machines are awake. The odds are legally dead.`, + () => + `if math had a face, it would be avoiding eye contact.`, + () => + `hash harder, little toaster. The empire will not dismantle itself.`, + () => + `Bitcoin is permissionless, including permission to embarrass yourself electrically.`, + () => + `your miners are attacking fiat with the force of a damp receipt.`, + () => + `the conspiracy is real: the big pools have more ASICs than your shelf.`, + () => + `this is how monetary revolution sounds when it has a 40mm fan.`, + () => + `the Cantillon effect remains undefeated by the cupboard.`, + () => + `the nonce search continues. So does the comedy.`, + () => + `somewhere, Satoshi is either proud or changing the Wi-Fi password.`, + () => + `the chance is small enough to qualify as abstract art.`, + () => + `mining a block today would be less prediction, more paranormal incident.`, + () => + `these rigs are not decentralizing power. They are decentralizing warmth.`, + () => + `the mempool sees you. It is trying not to laugh.`, + () => + `fiat printers go brrr. Your fan goes whrrr. Guess who has better margins.`, + () => + `today's block odds have been forwarded to the Ministry of Delusion.`, + () => + `the network hashrate is a dragon. You brought teaspoons.`, + () => + `this is sovereign self-custody of disappointment.`, + () => + `if a block appears, assume divine intervention or a rounding bug.`, + () => + `monetary debasement is tragic. This rig's odds are slapstick.`, + () => + `your ASICs are yelling at SHA-256 through a letterbox.`, + () => + `the pools have warehouses. You have vibes and a power strip.`, + () => + `a beautiful act of rebellion, if rebellion paid in almost nothing.`, + () => + `the block subsidy is safe from you for now.`, + () => + `hashing like this is how you whisper at a volcano.`, + () => + `the odds are dark, but at least the room isn't cold.`, + () => + `fiat friends buy heaters. You bought statistically complicated heaters.`, + () => + `the nonce lottery has reviewed your application and placed it under a very large pile.`, + () => + `this is not a get-rich-quick scheme. It is barely a get-warm-slowly scheme.`, + () => + `the global banking cartel remains untroubled by this particular fan curve.`, + () => + `somebody said "be your own bank" and now the shelf is making whale noises.`, + () => + `the blockchain is immutable. So is the humiliation.`, + () => + `your chance is smaller than a politician's apology and twice as expensive to heat.`, + () => + `the rig believes. The spreadsheet has concerns.`, + () => + `mining solo is what happens when optimism discovers electricity.`, + () => + `the odds are so low they qualify as a privacy feature.`, + () => + `keep stacking sats. These machines are mostly stacking anecdotes.`, + () => + `the revolution will be decentralized, but apparently not profitable today.`, ]; export const BLOCK_CELEBRATION_LINES = [ diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 7e7bbfd..e03b7c8 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -49,11 +49,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/views/DashboardView.vue b/apps/web/src/views/DashboardView.vue index 61be112..e385949 100644 --- a/apps/web/src/views/DashboardView.vue +++ b/apps/web/src/views/DashboardView.vue @@ -14,6 +14,7 @@ onMounted(() => stats.start()); onUnmounted(() => stats.stop()); const totalThs = computed(() => stats.snapshot?.pool.combinedHashrateThs ?? 0); +const networkEh = computed(() => stats.snapshot?.network.hashrateEh ?? 0); const miners = computed(() => stats.snapshot?.miners ?? []); const errorMsg = computed(() => stats.error); const upstreamErr = computed(() => stats.snapshot?.error); @@ -84,7 +85,7 @@ function minerKey(m: { nickname: string; authUsername: string; remoteHost: strin </section> <div class="grid-row"> - <LotteryWidget :total-ths="totalThs" /> + <LotteryWidget :total-ths="totalThs" :network-eh="networkEh" /> <ShareTicker :snapshot="stats.snapshot" /> </div> diff --git a/docker-compose.yml b/docker-compose.yml index f7001b3..9d93035 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: DATUM_ADMIN_USER: "${DATUM_ADMIN_USER:-admin}" DATUM_ADMIN_PASSWORD: "${DATUM_ADMIN_PASSWORD?must be set}" DATUM_POLL_INTERVAL_MS: "${DATUM_POLL_INTERVAL_MS:-5000}" + MEMPOOL_API_URL: "${MEMPOOL_API_URL:-https://tx1138.com/api}" NOSTR_ALLOWED_NPUBS: "${NOSTR_ALLOWED_NPUBS?must be set}" JWT_SECRET: "${JWT_SECRET?must be set}" JWT_TTL_SECONDS: "${JWT_TTL_SECONDS:-86400}"