Add share cards

This commit is contained in:
satoshi committed 2026-09-04 00:54:26 +03:00
1 parent ec42ce9b54
commit 4669bf10c0
51 files changed
+1193 -239

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Kamado Pool dashboard — Svelte 5 + Vite, talks to kamado-api over REST and WebSocket.",
"description": "Kamado Pool dashboard, Svelte 5 + Vite, talks to kamado-api over REST and WebSocket.",
"scripts": {
"dev": "vite",
"build": "vite build",
+8 -8
View File
@@ -17,7 +17,7 @@ export function formatHashrate(hs: number): string {
return `${v.toFixed(digits)} ${HASHRATE_UNITS[i]}`;
}
// Difficulty is a share-diff value — scale with k/M/G/T suffixes.
// Difficulty is a share-diff value, scale with k/M/G/T suffixes.
export function formatDifficulty(d: number): string {
if (!d || d <= 0 || !isFinite(d)) return "0";
const units = ["", "k", "M", "G", "T", "P"];
@@ -31,7 +31,7 @@ export function formatDifficulty(d: number): string {
return `${v.toFixed(digits)}${units[i]}`;
}
// Cumulative hashes — scale up to ZH / YH since pools accumulate fast.
// Cumulative hashes, scale up to ZH / YH since pools accumulate fast.
// Input is plain hashes (a diff-1 share is 2^32 hashes).
export function formatWork(hashes: number): string {
if (!hashes || hashes <= 0 || !isFinite(hashes)) return "0 H";
@@ -46,7 +46,7 @@ export function formatWork(hashes: number): string {
}
export function formatUptime(seconds: number): string {
if (!seconds || seconds < 0) return "—";
if (!seconds || seconds < 0) return "-";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
@@ -87,7 +87,7 @@ export function btcAddressOf(workername: string): string {
// Build the explorer base URL for the active network. If the user
// configured a custom mempool instance via StartOS, we trust that
// verbatim and don't append a /testnet4 / /signet path — the user's
// verbatim and don't append a /testnet4 / /signet path, the user's
// instance presumably runs on a single network already. Otherwise
// fall back to mempool.space with the appropriate network prefix.
export function explorerBaseFor(
@@ -105,8 +105,8 @@ export function explorerBaseFor(
// Resolve how a miner is connected from its ckpool serverurl[] index.
//
// The deployment declares the bind array via STRATUM_SERVERS (the StartOS
// wrapper renders one entry per bind it configures). When it hasn't — the
// dev compose stack, or an older wrapper — fall back to the historical
// wrapper renders one entry per bind it configures). When it hasn't, the
// dev compose stack, or an older wrapper, fall back to the historical
// layout, where index 0 is the plaintext bind and anything else is the
// loopback bind that stunnel forwards TLS traffic to.
export function connectionOf(
@@ -124,13 +124,13 @@ export function connectionOf(
// Detect common Bitcoin mining hardware from the stratum useragent
// string. This is a best-effort heuristic; unknown agents fall back
// to a stripped version of the raw string. Order matters — check
// to a stripped version of the raw string. Order matters, check
// more specific tokens before generic ones.
export function detectHardware(ua: string): string {
if (!ua) return "unknown";
const s = ua.toLowerCase();
const rules: Array<[RegExp, string]> = [
// Bitaxe and Nerd-family open-source ASICs. Order matters — check
// Bitaxe and Nerd-family open-source ASICs. Order matters, check
// narrower variants before the broader Nerd* / Bitaxe roots.
[/bitaxehex/, "Bitaxe Hex"],
[/bitaxe/, "Bitaxe"],
+32
View File
@@ -1,2 +1,34 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
// `qrcode` ships no types and has no @types package. Declare the surface we
// actually use, the donation QR in App.svelte and the proof QR on the share
// card, rather than leaving both call sites as implicit `any`.
declare module "qrcode" {
export type QRCodeErrorCorrectionLevel = "L" | "M" | "Q" | "H";
export interface QRCodeRenderOptions {
width?: number;
margin?: number;
scale?: number;
errorCorrectionLevel?: QRCodeErrorCorrectionLevel;
color?: { dark?: string; light?: string };
}
export function toCanvas(
canvas: HTMLCanvasElement,
text: string,
options?: QRCodeRenderOptions,
): Promise<void>;
export function toDataURL(
text: string,
options?: QRCodeRenderOptions,
): Promise<string>;
const QRCode: {
toCanvas: typeof toCanvas;
toDataURL: typeof toDataURL;
};
export default QRCode;
}
+4 -4
View File
@@ -69,7 +69,7 @@
} else if (feeLostSats > 0) {
success = `Boosted from ${from} to ${to} sat/vB. Revenue impact: -${feeLostSats.toLocaleString()} sats (${(feeLostSats / 1e8).toFixed(8)} BTC) per block mined.`;
} else {
success = `Boosted from ${from} to ${to} sat/vB. No revenue impact — the transaction was already in the template or the mempool fits in one block.`;
success = `Boosted from ${from} to ${to} sat/vB. No revenue impact, the transaction was already in the template or the mempool fits in one block.`;
}
txid = "";
feerateInput = "";
@@ -170,13 +170,13 @@
</p>
<p>
<strong>Revenue impact:</strong> The server inspects the current block template to find
the marginal transaction &mdash; the lowest fee-rate tx that would be displaced by the
the marginal transaction, the lowest fee-rate tx that would be displaced by the
boosted one. Its fee is the revenue you sacrifice per block mined, shown after each boost.
</p>
<p>
Your pool: {formatHashrate(poolHashrate)} /
{formatHashrate(networkHashrate)} network
({poolShare > 0 ? `${(poolShare * 100).toFixed(6)}%` : "—"} of blocks).
({poolShare > 0 ? `${(poolShare * 100).toFixed(6)}%` : "-"} of blocks).
</p>
</div>
</div>
@@ -202,7 +202,7 @@
</div>
<div class="field">
<label for="feerate-input">Target feerate (sat/vB) — max 2,000</label>
<label for="feerate-input">Target feerate (sat/vB), max 2,000</label>
<div class="feerate-row">
<input
id="feerate-input"
+6 -6
View File
@@ -70,7 +70,7 @@
return { c, cls: hVal === 0 ? "z-have" : "z-need" };
}
if (i === networkHexZeros) {
// Boundary char — must be <= target char
// Boundary char, must be <= target char
return { c, cls: hVal <= tVal ? "z-have" : "z-need" };
}
// Past the significant zone
@@ -87,7 +87,7 @@
});
});
// Count leading zero bits in the hash (not just hex zeros — count
// Count leading zero bits in the hash (not just hex zeros, count
// the actual zero bits in the first non-zero nibble too).
function leadingZeroBits(h: string): number {
let bits = 0;
@@ -159,7 +159,7 @@
// Within the zone that must be zero for a valid block
cls = hb === "0" ? "z-have" : "z-need";
} else {
// Past the required zero zone — doesn't matter
// Past the required zero zone, doesn't matter
cls = "z-rest";
}
out.push({ c: hb, cls });
@@ -233,7 +233,7 @@
<section class="card explainer">
<p>
To mine a block, you must find a hash whose <strong>numeric value</strong>
is less than the network's target. It's not just about leading zeros &mdash;
is less than the network's target. It's not just about leading zeros,
the entire hash must be smaller than the target. Think of it like a lottery:
you need to roll a number below a threshold, and the threshold gets lower as
difficulty rises.
@@ -244,7 +244,7 @@
The current network difficulty of <strong>{formatDifficulty(netDiff)}</strong>
requires a hash with at least <strong>{networkZeroBits}</strong> leading zero bits.
{#if diffRatio <= 1}
Your share meets the network target &mdash; this would be a valid block!
Your share meets the network target, this would be a valid block!
{:else}
The network target is <strong>{diffRatio.toFixed(1)}x</strong> harder than your
best share.
@@ -341,7 +341,7 @@
{#if networkZeroBits > shareZeroBits}
The gap of <strong>{networkZeroBits - shareZeroBits} bits</strong> means the target is roughly
<strong>2<sup>{networkZeroBits - shareZeroBits}</sup> &asymp; {Math.round(Math.pow(2, networkZeroBits - shareZeroBits)).toLocaleString()}x</strong>
harder &mdash; matching the {diffRatio.toFixed(1)}x difficulty ratio.
harder, matching the {diffRatio.toFixed(1)}x difficulty ratio.
{/if}
</p>
</section>
+1 -1
View File
@@ -8,7 +8,7 @@
// disconnect (the instance is freed) and on pool restart (in-memory
// state, not persisted). worker.best_diff is a separate persistent
// counter that survives both events, so we deliberately don't fall
// back to it — that fallback was the bug. Offline workers have no
// back to it, that fallback was the bug. Offline workers have no
// current session, so their session-best is 0.
const rows = $derived.by(() => {
const ws = snap.data?.workers ?? [];
+1 -1
View File
@@ -142,7 +142,7 @@
{/each}
</div>
<!-- Banner — above confetti, interactive -->
<!-- Banner, above confetti, interactive -->
<div class="banner-wrap">
<div class="banner">
<div class="party-row">
+58 -5
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { formatAgo, formatDifficulty, explorerBaseFor } from "../format";
import ShareCard from "./ShareCard.svelte";
import type { BlockRecord } from "../types";
const chainDisplayName: Record<string, string> = {
main: "mainnet",
@@ -14,6 +16,9 @@
return [...b].reverse();
});
// Block whose share card is open, if any.
let shareBlock: BlockRecord | null = $state(null);
const explorerBase = $derived(
explorerBaseFor(snap.data?.chain?.chain, snap.data?.mempool_base_url),
);
@@ -26,7 +31,7 @@
}
function minerLabel(miner: string | undefined): string {
if (!miner) return "—";
if (!miner) return "-";
// Show address.worker truncated
const dot = miner.indexOf(".");
if (dot < 0) {
@@ -57,6 +62,7 @@
<th class="num">Reward</th>
<th class="num">Winning share</th>
<th class="num">When</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -75,7 +81,7 @@
{:else if b.chain}
<span class="chain-current">{displayChain(b.chain)}</span>
{:else}
<span class="chain-unknown">—</span>
<span class="chain-unknown">-</span>
{/if}
</td>
<td class="hash-cell">
@@ -88,7 +94,7 @@
title={b.hash}
>{truncHash(b.hash)}</a>
{:else}
<span class="hash mono">&mdash;</span>
<span class="hash mono">-</span>
{/if}
</td>
<td class="num">
@@ -96,11 +102,25 @@
<span class="reward-num">{b.reward_btc.toFixed(4)}</span>
<span class="unit">BTC</span>
{:else}
<span class="reward-num">&mdash;</span>
<span class="reward-num">-</span>
{/if}
</td>
<td class="num">{b.share_diff ? formatDifficulty(b.share_diff) : "—"}</td>
<td class="num">{b.share_diff ? formatDifficulty(b.share_diff) : "-"}</td>
<td class="num">{formatAgo(new Date(b.found_at).getTime() / 1000)}</td>
<td class="share-cell">
<button
type="button"
class="share-block"
onclick={() => (shareBlock = b)}
title="Make a shareable card for this block"
aria-label="Share block {b.height}"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
</td>
</tr>
{/each}
</tbody>
@@ -109,7 +129,40 @@
{/if}
</section>
{#if shareBlock}
<ShareCard
data={{
kind: "block",
block: shareBlock,
chain: shareBlock.chain || currentChain || "main",
explorerBase,
}}
onclose={() => (shareBlock = null)}
/>
{/if}
<style>
.share-cell {
width: 1%;
white-space: nowrap;
}
.share-block {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.3em 0.5em;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.share-block:hover {
color: var(--accent);
border-color: var(--accent);
}
h2 {
margin: 0 0 1rem;
font-size: 1.05rem;
+3 -3
View File
@@ -3,7 +3,7 @@
import { formatAgo } from "../format";
// submit_gap > 0 means ckpool tried to submit at least one block that
// never got the "Solved and confirmed" follow-up — so either bitcoind
// never got the "Solved and confirmed" follow-up, so either bitcoind
// rejected the submission or the RPC dropped. These are persistent
// counters so the banner stays visible until the operator clears it
// by inspecting bitcoind's logs.
@@ -36,7 +36,7 @@
<span class="icon">!</span>
<div class="text">
<strong>Bitcoin Core is unreachable.</strong>
Stratum server has been stopped — miners will failover to backup pools.
Stratum server has been stopped, miners will failover to backup pools.
Service will resume automatically when Bitcoin Core recovers.
</div>
</div>
@@ -47,7 +47,7 @@
<div class="text">
<strong>Submit gap:</strong>
{submitGap} block{submitGap === 1 ? "" : "s"} attempted but not
confirmed by bitcoind. Investigate primary bitcoind logs —
confirmed by bitcoind. Investigate primary bitcoind logs,
submission may have been rejected or the RPC dropped.
</div>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@
};
// The "address" field on a ckpool stratum_instance is the SOURCE IP
// the connection arrived on (see connector.c:311 — it's set with
// the connection arrived on (see connector.c:311, it's set with
// inet_ntop). The miner's BTC payout address comes from the worker
// name (which is the stratum username, format <btc>[.label]) or
// from the joined worker_instance, which ckpool keys by full
+8 -8
View File
@@ -38,7 +38,7 @@
// Blocks remaining + ETA until the retarget. The predicted adjustment
// percent comes from the backend (measured between block timestamps
// from the epoch's first block), as does the epoch's observed mean
// block time — projecting the ETA at the pace the network is actually
// block time, projecting the ETA at the pace the network is actually
// keeping rather than a flat 600s, which is what mempool.space shows.
const retarget = $derived.by(() => {
if (!height) return { remaining: 2016, eta: "", progress: 0 };
@@ -53,7 +53,7 @@
const diffPct = $derived(data.next_difficulty_percent ?? 0);
const diffPctLabel = $derived(
diffPct === 0
? "—"
? "-"
: (diffPct > 0 ? "+" : "") + diffPct.toFixed(2) + "%",
);
@@ -74,7 +74,7 @@
return (best / work) * 100;
});
const luckLabel = $derived.by(() => {
if (!data.best_diff || !data.cumulative_shares) return "—";
if (!data.best_diff || !data.cumulative_shares) return "-";
if (luckPct >= 1000) return `${(luckPct / 100).toFixed(1)}x`;
return luckPct.toFixed(0) + "%";
});
@@ -92,7 +92,7 @@
prevHeight = h;
});
// New best share glow — derived from the persisted acked_best_diff.
// New best share glow, derived from the persisted acked_best_diff.
// Glows whenever best_diff > acked_best_diff. Survives restarts and
// works even if multiple new bests are found before the user opens the UI.
//
@@ -191,7 +191,7 @@
<span class="stat-dim">({latencyCount} blocks)</span>
</div>
{:else}
<div class="stat-value">—</div>
<div class="stat-value">-</div>
<div class="stat-sub">waiting for the next block</div>
{/if}
</div>
@@ -211,9 +211,9 @@
<!-- Row 2 -->
<div class="card height-card" class:new-block={heightFlash}>
<div class="stat-label">Block height</div>
<div class="stat-value">{height ? height.toLocaleString() : "—"}</div>
<div class="stat-value">{height ? height.toLocaleString() : "-"}</div>
<div class="stat-sub">
{data.chain?.chain === "main" ? "mainnet" : data.chain?.chain ?? "—"}
{data.chain?.chain === "main" ? "mainnet" : data.chain?.chain ?? "-"}
{#if data.peer_count > 0}
<span class="peers" title="{data.peers_in} inbound · {data.peers_out} outbound">&nbsp;· {data.peer_count} peers</span>
{/if}
@@ -223,7 +223,7 @@
<div class="card">
<div class="stat-label">Block reward</div>
<div class="stat-value" title="{data.next_block_reward_btc ? Math.round(data.next_block_reward_btc * 1e8).toLocaleString() + ' sats' : ''}">
{data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "—"}
{data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "-"}
<span class="unit">BTC</span>
</div>
<div class="stat-sub">subsidy + fees (next block)</div>
+12 -12
View File
@@ -92,7 +92,7 @@
// ── Formulas, as native MathML ──────────────────────────────────────
// Rendered through {@html}: these are static, authored here, and far
// more readable as markup strings than as ~200 lines of template. No
// library — browsers typeset MathML themselves, which keeps the
// library, browsers typeset MathML themselves, which keeps the
// dashboard dependency-free and works offline on a LAN.
const rot = (n: string) =>
`<msup><mi mathvariant="normal">ROTR</mi><mn>${n}</mn></msup>`;
@@ -547,12 +547,12 @@
<div class="panel">
<div class="lede">The coinbase transaction this pool built, hashed twice, gives its txid:</div>
<div class="hexline big glow">{step.txid}</div>
<div class="foot-note">internal byte order — the form used while hashing</div>
<div class="foot-note">internal byte order, the form used while hashing</div>
</div>
{:else if step.kind === "merkle-fold"}
<div class="panel">
<div class="lede">
Level {step.i} of {step.total} &mdash; join with the branch hash and hash the pair
Level {step.i} of {step.total}, join with the branch hash and hash the pair
</div>
<div class="fold">
<div class="fold-row"><span class="tag">current</span><span class="hexline">{step.left}</span></div>
@@ -579,7 +579,7 @@
<div class="panel wide">
<div class="lede">
80 header bytes, a <span class="sg-pad">0x80</span> marker, 39 zero bytes and the
<span class="sg-len">640-bit</span> length &mdash; exactly two 512-bit blocks
<span class="sg-len">640-bit</span> length, exactly two 512-bit blocks
</div>
{#each padBlocks as blk, bi}
<div class="msgblock">
@@ -597,7 +597,7 @@
<span class="lg"><i class="sw sw-len"></i> message length</span>
</div>
<div class="foot-note">
the <span class="sg-merkle">merkle root</span> straddles the boundary — SHA-256 takes
the <span class="sg-merkle">merkle root</span> straddles the boundary, SHA-256 takes
64 bytes at a time, indifferent to where fields begin and end
</div>
</div>
@@ -610,7 +610,7 @@
{/each}
</div>
<div class="foot-note">
the fractional parts of the square roots of the first eight primes &mdash; "nothing up
the fractional parts of the square roots of the first eight primes, "nothing up
my sleeve" numbers, chosen so nobody could have picked them to hide a weakness
</div>
</div>
@@ -672,7 +672,7 @@
{:else if step.kind === "add-h"}
<div class="panel">
<div class="lede">
64 rounds done &mdash; the result is <em>added</em> into the running state, chaining
64 rounds done, the result is <em>added</em> into the running state, chaining
this block into the next
</div>
<div class="regs">
@@ -681,7 +681,7 @@
{/each}
</div>
<div class="foot-note">
addition, not replacement — the Davies&ndash;Meyer construction, which is what makes
addition, not replacement, the Davies&ndash;Meyer construction, which is what makes
the compression one-way
</div>
</div>
@@ -699,7 +699,7 @@
{#each finalChars as ch}<span class:fz={ch.zero}>{ch.c}</span>{/each}
</div>
<div class="legend">
<span class="lg"><i class="sw sw-zeroes"></i> leading zeros &mdash; the visible sign of the work done</span>
<span class="lg"><i class="sw sw-zeroes"></i> leading zeros, the visible sign of the work done</span>
</div>
</div>
{:else if step.kind === "target"}
@@ -722,7 +722,7 @@
</div>
<div class="math small">{@html MATH_TARGET}</div>
<div class="foot-note">
the hash is below the share target — that is what made it a
the hash is below the share target, that is what made it a
{formatDifficulty(share.sdiff)} share. The network target sits another
<b>{targetGapX >= 1000 ? formatDifficulty(targetGapX) : targetGapX.toFixed(1)}×</b>
lower: clear that, and this exact header would have been block
@@ -784,7 +784,7 @@
to { opacity: 1; }
}
/* Drifting embers — pure decoration, cheap. */
/* Drifting embers, pure decoration, cheap. */
.embers {
position: absolute;
inset: 0;
@@ -905,7 +905,7 @@
display: flex;
/* `safe` matters: a flex item taller than its container, centred,
overflows equally in both directions and the top half becomes
unreachable — which hid register row `a` until the window was
unreachable, which hid register row `a` until the window was
tall enough. Safe alignment falls back to start on overflow.
The unprefixed value is declared first for older engines. */
align-items: center;
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts">
import { untrack } from "svelte";
import { formatDifficulty } from "../format";
import { renderShareCard, type CardData, type CardFormat } from "./shareCard";
let {
data,
onclose,
}: { data: CardData; onclose: () => void } = $props();
const card = untrack(() => data);
const isBlock = card.kind === "block";
// Story is the default (Instagram/X/WhatsApp status); square suits feed
// posts, where a 9:16 image gets cropped or letterboxed.
let format: CardFormat = $state("story");
let canvasEl: HTMLCanvasElement | undefined = $state();
let busy = $state(false);
let note = $state("");
$effect(() => {
const f = format;
const el = canvasEl;
if (el) void renderShareCard(el, card, f);
});
const slug = $derived(
card.kind === "block"
? `block-${card.block.height}`
: `best-share-${formatDifficulty(card.share.sdiff).replace(/\s+/g, "")}`,
);
const fileName = $derived(`kamado-${slug}-${format}.png`);
function toBlob(): Promise<Blob | null> {
return new Promise((resolve) => {
if (!canvasEl) return resolve(null);
canvasEl.toBlob((b) => resolve(b), "image/png");
});
}
async function download(): Promise<void> {
busy = true;
note = "";
const blob = await toBlob();
busy = false;
if (!blob) {
note = "Could not render the image.";
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
note = "Saved.";
}
async function copyImage(): Promise<void> {
busy = true;
note = "";
try {
const blob = await toBlob();
if (!blob) throw new Error("render failed");
// Clipboard image writes need a secure context; LAN dashboards are
// often plain HTTP, so fall back to the download path.
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
note = "Copied. Paste it straight into a post.";
} catch {
note = "Copying images needs HTTPS here, so use Save instead.";
} finally {
busy = false;
}
}
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") onclose();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="backdrop" role="dialog" aria-modal="true" aria-label="Share card">
<div class="sheet">
<header class="sheet-head">
<div>
<div class="sheet-title">{isBlock ? "Share your block" : "Share your best share"}</div>
<div class="sheet-sub">
{isBlock
? "The card names the chain and links the block, so anyone can look it up for themselves."
: "The card carries the raw header, so anyone who sees it can verify the work themselves."}
</div>
</div>
<button type="button" class="x" onclick={onclose} aria-label="Close">&#10005;</button>
</header>
<div class="preview-wrap">
<canvas bind:this={canvasEl} class="preview" class:square={format === "square"}></canvas>
</div>
<div class="controls">
<div class="seg">
<button type="button" class:on={format === "story"} onclick={() => (format = "story")}>
Story 9:16
</button>
<button type="button" class:on={format === "square"} onclick={() => (format = "square")}>
Square 1:1
</button>
</div>
<div class="acts">
<button type="button" class="ghost" onclick={copyImage} disabled={busy}>Copy image</button>
<button type="button" class="primary" onclick={download} disabled={busy}>Save PNG</button>
</div>
</div>
{#if note}
<div class="note">{note}</div>
{/if}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
align-items: center;
align-items: safe center;
justify-content: center;
justify-content: safe center;
padding: 1.2rem;
overflow-y: auto;
background: rgba(5, 7, 12, 0.82);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: fade 0.2s ease-out;
}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
.sheet {
width: 100%;
max-width: 560px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.1rem 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.sheet-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.sheet-title {
font-size: 1.05rem;
font-weight: 600;
}
.sheet-sub {
color: var(--fg-dim);
font-size: 0.85rem;
line-height: 1.5;
margin-top: 0.2rem;
}
.x {
font: inherit;
flex-shrink: 0;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.35em 0.55em;
cursor: pointer;
}
.x:hover {
color: var(--fg);
border-color: var(--accent);
}
.preview-wrap {
display: flex;
justify-content: center;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.8rem;
}
.preview {
display: block;
width: auto;
max-width: 100%;
max-height: 52vh;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
flex-wrap: wrap;
}
.seg {
display: flex;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.seg button {
font: inherit;
font-size: 0.82rem;
background: transparent;
color: var(--fg-dim);
border: none;
padding: 0.45em 0.9em;
cursor: pointer;
}
.seg button:not(:last-child) {
border-right: 1px solid var(--border);
}
.seg button.on {
background: var(--accent);
color: var(--bg);
font-weight: 600;
}
.acts {
display: flex;
gap: 0.5rem;
}
.acts button {
font: inherit;
font-size: 0.88rem;
border-radius: 8px;
padding: 0.5em 1.1em;
cursor: pointer;
border: 1px solid var(--border);
}
.acts .ghost {
background: transparent;
color: var(--fg-dim);
}
.acts .ghost:hover:not(:disabled) {
color: var(--fg);
border-color: var(--accent);
}
.acts .primary {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
font-weight: 600;
}
.acts .primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.acts button:disabled {
opacity: 0.55;
cursor: default;
}
.note {
font-size: 0.82rem;
color: var(--fg-dim);
}
@media (max-width: 480px) {
.preview {
max-height: 44vh;
}
.controls {
justify-content: center;
}
}
</style>
+53 -19
View File
@@ -3,6 +3,7 @@
import { snap } from "../stores/snapshot.svelte";
import { formatDifficulty, explorerBaseFor } from "../format";
import PowVerifyOverlay from "./PowVerifyOverlay.svelte";
import ShareCard from "./ShareCard.svelte";
import type { CheckResult } from "./PowVerifyOverlay.svelte";
import { revHex } from "./sha256";
@@ -11,7 +12,7 @@
// The all-time best share may predate ckpool patch 0008 (which logs
// header data). When the hashes differ, this page is showing the best
// share *since* the patch went live — disclose that.
// share *since* the patch went live, disclose that.
const isNewerThanAllTime = $derived(
!!pow && !!data.best_share_hash && pow.hash !== data.best_share_hash,
);
@@ -73,7 +74,7 @@
bytes: "68–71",
raw: h.slice(136, 144),
value: new Date(time * 1000).toLocaleString(),
sub: `unix ${time} — the miner may roll this forward slightly`,
sub: `unix ${time}, the miner may roll this forward slightly`,
},
{
key: "bits",
@@ -89,7 +90,7 @@
bytes: "76–79",
raw: h.slice(152, 160),
value: nonce.toLocaleString(),
sub: `0x${nonce.toString(16).padStart(8, "0")} — the value the miner iterated`,
sub: `0x${nonce.toString(16).padStart(8, "0")}, the value the miner iterated`,
},
];
});
@@ -113,6 +114,7 @@
// ── Verification: handed to the full-screen overlay ─────────────────
let showOverlay = $state(false);
let showShare = $state(false);
let checks: CheckResult[] = $state([]);
let verified = $state(false);
let finalHash = $state("");
@@ -171,7 +173,7 @@
const foundAt = $derived(pow ? new Date(pow.seen_at).toLocaleString() : "");
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape" && !showOverlay) selectBestShare();
if (ev.key === "Escape" && !showOverlay && !showShare) selectBestShare();
}
</script>
@@ -194,13 +196,13 @@
<div class="empty">
<p>
Header capture was not active when the current best share was found, and CKPool
discards job data once work moves on &mdash; so the block header of that share can
discards job data once work moves on, so the block header of that share can
no longer be reconstructed.
</p>
<p>
The pool now records the full header, coinbase, and merkle branches as shares come
in. The next accepted share will populate this page, which then tracks the best
share found since header capture was enabled &mdash; it does not wait for the
share found since header capture was enabled, it does not wait for the
all-time record to be beaten. Once a share does beat the all-time best, the two
converge and this page shows the overall best share.
</p>
@@ -230,14 +232,14 @@
</div>
<div class="card">
<div class="stat-label">Found By</div>
<div class="stat-value worker-name mono">{pow.workername || "—"}</div>
<div class="stat-value worker-name mono">{pow.workername || "-"}</div>
<div class="stat-sub">{foundAt}</div>
</div>
</section>
<!-- Block header visualization -->
<section class="card">
<h3>Block Header &mdash; 80 bytes</h3>
<h3>Block Header, 80 bytes</h3>
<p class="section-intro">
This is the block this share was building, drawn as it exists on the wire. The
header's six fields are serialized little-endian into 80 bytes; the
@@ -280,7 +282,7 @@
</div>
{/each}
<div class="block-txs">
+ transactions &mdash; not part of the header; committed by the
+ transactions, not part of the header; committed by the
<span class="seg-text-merkle">merkle root</span> above
</div>
</div>
@@ -289,7 +291,7 @@
<!-- Raw data & verification -->
<section class="card">
<h3>Raw Header &mdash; verify it yourself</h3>
<h3>Raw Header, verify it yourself</h3>
<p class="section-intro">
Hash these 160 hex characters with double SHA-256 and reverse the result: you get the
share hash. No trust in this dashboard required.
@@ -297,14 +299,14 @@
<div class="proof-note">
<strong>This is what turns a claim into proof.</strong>
A screenshot of a big difficulty number proves nothing &mdash; anyone can edit one, and
A screenshot of a big difficulty number proves nothing, anyone can edit one, and
no one can tell from a picture whether the work behind it was ever done. These 80 bytes
can't be faked that way. They are the exact preimage of the share hash, so anyone can
hash them and see for themselves both that the hash is genuine and that finding it took
the work claimed. The header also commits to the
<span class="seg-text-prev">previous block</span>, fixing when the work happened, and
through the <span class="seg-text-merkle">merkle root</span> to the coinbase
transaction, fixing who it pays &mdash; so the same proof can't be lifted from another
transaction, fixing who it pays, so the same proof can't be lifted from another
miner's share or replayed from a different block. Publish this, and the claim stands on
its own.
</div>
@@ -325,11 +327,18 @@
class:ok={allOk}
onclick={() => (showOverlay = true)}
>
{verified ? (allOk ? "Verified \u2713 — watch again" : "Verification failed — replay") : "Verify in this browser"}
{verified ? (allOk ? "Verified \u2713, watch again" : "Verification failed, replay") : "Verify in this browser"}
</button>
<button type="button" class="share-btn" onclick={() => (showShare = true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="15" height="15">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
Share card
</button>
<span class="verify-hint">
takes over the screen and replays the whole verification &mdash; merkle proof,
padding, every SHA-256 round &mdash; from the real computation, locally
takes over the screen and replays the whole verification, merkle proof,
padding, every SHA-256 round, from the real computation, locally
</span>
</div>
@@ -341,7 +350,7 @@
</div>
<div class="final-match" class:ok={finalHash === pow.hash} class:fail={finalHash !== pow.hash}>
{finalHash === pow.hash
? "= the recorded share hash — proof of work is genuine"
? "= the recorded share hash, proof of work is genuine"
: "\u2260 the recorded share hash!"}
</div>
<ul class="check-list">
@@ -374,7 +383,7 @@
<h3>Reproducing the Merkle Root</h3>
<p class="section-intro">
The header's merkle root commits to every transaction in the block, but reproducing
it doesn't require the full transaction list &mdash; only the coinbase transaction
it doesn't require the full transaction list, only the coinbase transaction
this pool built and one branch hash per tree level. Each branch hash cryptographically
summarizes the other transactions in its subtree, so the proof below is exactly as
strong as storing all of them. (CKPool discards the full template once work moves on,
@@ -395,7 +404,7 @@
<span class="legend-item">
<span class="swatch cb-swatch-{s.key}"></span>
<span class="legend-name">{s.name}</span>
<span class="legend-desc">— {s.desc}</span>
<span class="legend-desc">, {s.desc}</span>
</span>
{/each}
</div>
@@ -406,7 +415,7 @@
</div>
{#if branches.length === 0}
<div class="stat-sub">
None &mdash; the block template contained only the coinbase transaction, so the
None, the block template contained only the coinbase transaction, so the
merkle root is simply the coinbase txid.
</div>
{:else}
@@ -434,6 +443,13 @@
{/if}
</section>
{#if showShare && pow}
<ShareCard
data={{ kind: "share", share: pow, chain: data.chain?.chain ?? "main" }}
onclose={() => (showShare = false)}
/>
{/if}
{#if showOverlay && pow}
<PowVerifyOverlay
{pow}
@@ -780,6 +796,24 @@
.verify-btn.ok {
background: var(--good);
}
.share-btn {
font: inherit;
display: inline-flex;
align-items: center;
gap: 0.45em;
font-weight: 600;
color: var(--accent);
background: transparent;
border: 1px solid var(--accent-dim);
border-radius: 6px;
padding: 0.5em 1.1em;
cursor: pointer;
}
.share-btn:hover {
border-color: var(--accent);
background: var(--bg-hover);
text-shadow: 0 0 8px rgba(255, 122, 58, 0.4);
}
.verify-hint {
color: var(--fg-dim);
font-size: 0.8rem;
+1 -1
View File
@@ -21,7 +21,7 @@
// Detect new rejected shares to trigger shake animation.
// Skip the first snapshot (baseline), then fire on any increase
// — including from 0 after a pool restart. Declared BEFORE the
// including from 0 after a pool restart. Declared BEFORE the
// accepted effect so `reject` is already set when the accept
// guard reads it.
let prevRejected = $state(0);
+2 -2
View File
@@ -223,7 +223,7 @@
</section>
<style>
/* Page layout — identical to UserDetailPage / WorkerDetailPage */
/* Page layout, identical to UserDetailPage / WorkerDetailPage */
.page {
display: flex;
flex-direction: column;
@@ -259,7 +259,7 @@
line-height: 1.25;
}
/* Stat cards — matches .totals / .stats in other pages */
/* Stat cards, matches .totals / .stats in other pages */
.totals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+2 -2
View File
@@ -23,7 +23,7 @@
// Live stratum clients for those workers. ckpool's client.address
// is the SOURCE IP, never the BTC, so we cannot filter on it. The
// join key is workername — match the BTC bare or any "btc.label".
// join key is workername, match the BTC bare or any "btc.label".
const myClients = $derived.by<StratumClient[]>(() => {
if (!address) return [];
const prefix = address + ".";
@@ -81,7 +81,7 @@
idle: !!(c?.idle ?? w.idle),
});
}
// A live client without a worker_instance entry yet (rare —
// A live client without a worker_instance entry yet (rare,
// can happen briefly right after a new miner connects).
for (const c of myClients) {
const wname = c.workername || `${address}.unnamed`;
+2 -2
View File
@@ -4,7 +4,7 @@
// often served over plain HTTP on a LAN, so proof-of-work verification
// uses this rather than the platform digest. The optional hooks expose
// the real message schedule, the working registers after every
// compression round, and the chained state after each block — which is
// compression round, and the chained state after each block, which is
// what the verification animation renders. Nothing shown there is
// simulated; it is all this function's actual intermediate state.
@@ -19,7 +19,7 @@ const K = new Uint32Array([
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
/** The eight initial hash values, as hex — √2…√19 fractional parts. */
/** The eight initial hash values, as hex, √2…√19 fractional parts. */
export const INIT_H = [
"6a09e667", "bb67ae85", "3c6ef372", "a54ff53a", "510e527f", "9b05688c", "1f83d9ab", "5be0cd19",
];
+557
View File
@@ -0,0 +1,557 @@
// Renders the shareable cards to a canvas: the "best share" proof card and
// the celebratory "I found a block!" card.
//
// Kept out of the Svelte components so it can be driven headlessly and the
// output inspected. The layout measures its content first, then spreads the
// leftover height across the section gaps, so the same code fills a 9:16
// story and a 1:1 square without leaving voids or colliding.
import QRCode from "qrcode";
import { formatDifficulty } from "../format";
import type { BestSharePow, BlockRecord } from "../types";
export type CardFormat = "story" | "square";
export type CardData =
| { kind: "share"; share: BestSharePow; chain: string }
| { kind: "block"; block: BlockRecord; chain: string; explorerBase: string };
export const CARD_SIZES: Record<CardFormat, { w: number; h: number }> = {
story: { w: 1080, h: 1920 },
square: { w: 1080, h: 1080 },
};
const C = {
bg: "#080a10",
ember: "#ff7a3a",
fg: "#e6e9ef",
dim: "#8a92a6",
good: "#5ce0a8",
gold: "#ffc65c",
btc: "#f7931a",
panel: "rgba(255,255,255,0.045)",
stroke: "rgba(255,255,255,0.10)",
};
const MONO = '"JetBrains Mono","Fira Code",ui-monospace,SFMono-Regular,Menlo,monospace';
const SANS = '"Inter","Segoe UI",system-ui,-apple-system,sans-serif';
type Scale = {
margin: number; brand: number; hero: number; body: number; small: number;
eyebrow: number; mono: number; monoHeader: number; qr: number; gap: number;
};
const SCALES: Record<CardFormat, Scale> = {
story: {
margin: 88, brand: 44, hero: 170, body: 32, small: 26,
eyebrow: 26, mono: 33, monoHeader: 25, qr: 250, gap: 1,
},
square: {
margin: 64, brand: 34, hero: 104, body: 25, small: 21,
eyebrow: 21, mono: 24, monoHeader: 18, qr: 156, gap: 0.6,
},
};
/**
* Hash counts read as "66.66 EH hashes" if the SI symbol is kept, which
* says hash twice. Spell the magnitude instead.
*/
export function spellHashes(n: number): string {
const units: [number, string][] = [
[1e27, "ronnahashes"], [1e24, "yottahashes"], [1e21, "zettahashes"],
[1e18, "exahashes"], [1e15, "petahashes"], [1e12, "terahashes"],
[1e9, "gigahashes"], [1e6, "megahashes"], [1e3, "kilohashes"],
];
for (const [v, name] of units) {
if (n >= v) return `${(n / v).toFixed(2)} ${name}`;
}
return `${Math.round(n)} hashes`;
}
export function chainLabel(chain: string | undefined): string {
switch (chain) {
case "main": case "": case undefined: return "mainnet";
case "test": return "testnet3";
default: return chain;
}
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number, r: number,
): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/** Flame mark, drawn rather than relying on an emoji font. */
function drawFlame(ctx: CanvasRenderingContext2D, cx: number, cy: number, s: number): void {
ctx.save();
ctx.translate(cx, cy);
ctx.scale(s, s);
const g = ctx.createLinearGradient(0, -1, 0, 1);
g.addColorStop(0, "#ffd08a");
g.addColorStop(0.45, C.ember);
g.addColorStop(1, "#c0361a");
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(0, -1);
ctx.bezierCurveTo(0.62, -0.28, 0.78, 0.28, 0.36, 0.72);
ctx.bezierCurveTo(0.16, 0.94, -0.2, 0.98, -0.44, 0.78);
ctx.bezierCurveTo(-0.85, 0.42, -0.7, -0.2, -0.18, -0.52);
ctx.bezierCurveTo(-0.24, -0.16, -0.05, -0.02, 0.08, -0.16);
ctx.bezierCurveTo(0.22, -0.34, 0.1, -0.7, 0, -1);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// The official Bitcoin logo (Bitboy, public domain), as its two SVG paths
// on a 64x64 viewBox: the coin, then the ₿ knocked out of it.
const BTC_COIN =
"M63.04 39.741c-4.274 17.143-21.638 27.575-38.783 23.301C7.12 58.768-3.313 41.404.962 " +
"24.262 5.234 7.117 22.598-3.315 39.737.957c17.144 4.274 27.576 21.64 23.303 38.784z";
const BTC_SYMBOL =
"M46.11 27.441c.636-4.258-2.606-6.547-7.039-8.074l1.438-5.768-3.512-.875-1.4 5.616c-.923-.23-" +
"1.871-.447-2.813-.662l1.41-5.653-3.51-.875-1.439 5.766c-.764-.174-1.514-.346-2.242-.527l.004-" +
".018-4.842-1.209-.934 3.75s2.605.597 2.55.634c1.422.355 1.68 1.296 1.636 2.042l-1.638 6.571c." +
"098.025.225.061.365.117l-.37-.092-2.297 9.205c-.174.432-.615 1.08-1.609.834.035.051-2.552-.637-" +
"2.552-.637l-1.743 4.019 4.569 1.139c.85.213 1.683.436 2.503.646l-1.453 5.834 3.507.875 1.439-" +
"5.772c.958.26 1.888.5 2.798.726L27.7 50.83l3.511.875 1.453-5.823c5.987 1.133 10.489.676 12.384-" +
"4.739 1.527-4.36-.076-6.875-3.226-8.515 2.294-.529 4.022-2.038 4.483-5.155l.005-.032zm-8.023 " +
"11.249c-1.085 4.36-8.426 2.003-10.806 1.412l1.928-7.729c2.38.594 10.012 1.77 8.878 6.317zm1.086-" +
"11.312c-.99 3.966-7.1 1.951-9.082 1.457l1.748-7.01c1.982.494 8.365 1.416 7.334 5.553z";
/** The real Bitcoin logo, drawn from its official path data. */
function drawBitcoin(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void {
ctx.save();
ctx.translate(cx - r, cy - r);
ctx.scale((r * 2) / 64, (r * 2) / 64);
ctx.fillStyle = C.btc;
ctx.fill(new Path2D(BTC_COIN));
ctx.fillStyle = "#ffffff";
ctx.fill(new Path2D(BTC_SYMBOL));
ctx.restore();
}
/** Monospace is uniform width, so chunk by measured character. */
function monoChunks(ctx: CanvasRenderingContext2D, text: string, maxW: number): string[] {
const cw = ctx.measureText("0").width || 1;
const per = Math.max(8, Math.floor(maxW / cw));
const out: string[] = [];
for (let i = 0; i < text.length; i += per) out.push(text.slice(i, i + per));
return out;
}
function eyebrow(
ctx: CanvasRenderingContext2D, text: string, x: number, y: number,
size: number, color: string,
): void {
ctx.font = `600 ${size}px ${SANS}`;
ctx.fillStyle = color;
ctx.letterSpacing = `${size * 0.18}px`;
ctx.fillText(text.toUpperCase(), x, y);
ctx.letterSpacing = "0px";
}
function panelHeight(lineCount: number, font: number): number {
const lh = font * 1.5;
return lineCount * lh + font * 0.95 * 2 - (lh - font);
}
function hexPanel(
ctx: CanvasRenderingContext2D,
lines: string[], x: number, y: number, w: number,
font: number, opts: { fill: string; stroke: string; leadingZeros?: number },
): number {
const lh = font * 1.5;
const pad = font * 0.95;
const h = panelHeight(lines.length, font);
ctx.fillStyle = opts.fill;
ctx.strokeStyle = opts.stroke;
ctx.lineWidth = 2;
roundRect(ctx, x, y, w, h, 16);
ctx.fill();
ctx.stroke();
let ty = y + pad + font * 0.8;
let consumed = 0;
const zeros = opts.leadingZeros ?? 0;
for (const line of lines) {
const zeroPart = Math.max(0, Math.min(line.length, zeros - consumed));
if (zeroPart > 0) {
ctx.fillStyle = C.good;
ctx.shadowColor = "rgba(92,224,168,0.5)";
ctx.shadowBlur = 16;
ctx.fillText(line.slice(0, zeroPart), x + pad, ty);
ctx.shadowBlur = 0;
}
if (zeroPart < line.length) {
const off = zeroPart > 0 ? ctx.measureText(line.slice(0, zeroPart)).width : 0;
ctx.fillStyle = opts.leadingZeros !== undefined ? C.fg : "rgba(230,233,239,0.88)";
ctx.fillText(line.slice(zeroPart), x + pad + off, ty);
}
consumed += line.length;
ty += lh;
}
return h;
}
/** Rounded pill, used for the chain badge. */
function badge(
ctx: CanvasRenderingContext2D, text: string, x: number, y: number,
size: number, fg: string, bg: string, border: string,
): number {
ctx.font = `600 ${size}px ${SANS}`;
ctx.letterSpacing = `${size * 0.1}px`;
const tw = ctx.measureText(text.toUpperCase()).width;
const padX = size * 0.75;
const h = size * 2;
ctx.fillStyle = bg;
ctx.strokeStyle = border;
ctx.lineWidth = 2;
roundRect(ctx, x, y - h * 0.72, tw + padX * 2, h, h / 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = fg;
ctx.fillText(text.toUpperCase(), x + padX, y);
ctx.letterSpacing = "0px";
return tw + padX * 2;
}
function paintBackground(
ctx: CanvasRenderingContext2D, w: number, h: number, celebratory: boolean,
): void {
ctx.fillStyle = C.bg;
ctx.fillRect(0, 0, w, h);
const top = ctx.createRadialGradient(w * 0.5, -h * 0.08, 0, w * 0.5, -h * 0.08, h * 0.66);
top.addColorStop(0, celebratory ? "rgba(255,180,60,0.34)" : "rgba(255,110,40,0.30)");
top.addColorStop(1, "rgba(255,110,40,0)");
ctx.fillStyle = top;
ctx.fillRect(0, 0, w, h);
const low = ctx.createRadialGradient(w * 0.06, h, 0, w * 0.06, h, h * 0.5);
low.addColorStop(0, celebratory ? "rgba(255,140,30,0.22)" : "rgba(190,45,20,0.18)");
low.addColorStop(1, "rgba(190,45,20,0)");
ctx.fillStyle = low;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "rgba(40,64,48,0.055)";
const cell = 54;
for (let yy = 0; yy < h; yy += cell * 2) {
for (let xx = 0; xx < w; xx += cell * 2) {
ctx.fillRect(xx, yy, cell, cell);
ctx.fillRect(xx + cell, yy + cell, cell, cell);
}
}
}
/** Sparks radiating behind the headline on the block card. */
function drawSparks(ctx: CanvasRenderingContext2D, w: number, top: number, h: number): void {
ctx.save();
let seed = 20260903;
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
for (let i = 0; i < 46; i++) {
const x = rnd() * w;
const y = top + rnd() * h;
const r = 1.5 + rnd() * 3.5;
ctx.globalAlpha = 0.18 + rnd() * 0.5;
ctx.fillStyle = rnd() > 0.4 ? C.gold : C.ember;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.globalAlpha = 1;
}
export async function renderShareCard(
canvas: HTMLCanvasElement,
data: CardData,
format: CardFormat,
): Promise<void> {
const { w, h } = CARD_SIZES[format];
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return;
try {
await document.fonts.ready;
} catch {
/* fall back to whatever the stack resolves to */
}
const s = SCALES[format];
const M = s.margin;
const innerW = w - M * 2;
const isBlock = data.kind === "block";
paintBackground(ctx, w, h, isBlock);
ctx.textBaseline = "alphabetic";
ctx.textAlign = "left";
const footY = h - M * 0.75;
const ruleY = footY - s.body * 2.0;
const startY = M + s.brand * 0.9;
// ── QR payload: for a mined block the chain itself is the proof, so
// link the explorer; for a share, carry the header bytes.
const qrPayload = isBlock && data.block.hash
? `${data.explorerBase}/block/${data.block.hash}`
: data.kind === "share" ? data.share.header : "";
const qrCaption = isBlock
? ["See it on the chain", "anyone can look it up"]
: ["Scan for the header", "verify it yourself, no trust required"];
// QR block sits centred at the bottom, above the footer rule.
const qrSize = s.qr;
const qrCaptionH = s.body * 0.94 + s.small * 1.5;
const qrBlockH = qrSize + s.body * 0.9 + qrCaptionH;
// ── measure, then share the leftover height across the gaps
ctx.font = `500 ${s.mono}px ${MONO}`;
const hashText = isBlock ? (data.block.hash ?? "") : data.share.hash;
const hashLines = hashText ? monoChunks(ctx, hashText, innerW - s.mono * 1.9) : [];
const hashPanelH = hashLines.length ? panelHeight(hashLines.length, s.mono) : 0;
let hdrLines: string[] = [];
let hdrPanelH = 0;
if (data.kind === "share") {
ctx.font = `400 ${s.monoHeader}px ${MONO}`;
hdrLines = monoChunks(ctx, data.share.header, innerW - s.monoHeader * 2.2);
hdrPanelH = panelHeight(hdrLines.length, s.monoHeader);
}
const gapUnits = isBlock
? [70, 24, 56, 60, 26, 44, 40]
: [78, 24, 58, 74, 26, 46, 66, 26, 42, 40];
const gapTotal = gapUnits.reduce((a, b) => a + b, 0) * s.gap;
const gapX = data.kind === "share" && data.share.sdiff > 0
? data.share.netdiff / data.share.sdiff : 0;
const fixedH =
s.small * 1.5 +
s.hero * 0.78 +
(isBlock ? s.body * 2.6 : (gapX > 1 ? s.body * 1.45 : 0)) +
hashPanelH + hdrPanelH + qrBlockH;
const natural = fixedH + gapTotal;
const available = ruleY - s.body * 1.1 - startY;
// Spread the slack, but cap how far any one gap can grow. Without the
// cap a card with few sections (the block one) turns every gap into a
// ~80px canyon; with it, the remainder becomes balanced breathing room
// above the content and before the call to action.
const slack = Math.max(0, available - natural);
const extra = Math.min(slack / gapUnits.length, s.body * 1.7);
const leftover = slack - extra * gapUnits.length;
const topOffset = leftover * 0.45;
const G = (n: number) => n * s.gap + extra;
// ── brand lockup, with the real bitcoin logo balancing it
let y = startY;
drawFlame(ctx, M + s.brand * 0.42, y - s.brand * 0.42, s.brand * 0.62);
ctx.font = `700 ${s.brand}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.letterSpacing = "2px";
ctx.fillText("KAMADO POOL", M + s.brand * 1.08, y);
ctx.letterSpacing = "0px";
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText("solo bitcoin mining", M + s.brand * 1.12, y + s.small * 1.5);
drawBitcoin(ctx, w - M - s.brand * 0.6, y - s.brand * 0.16, s.brand * 0.6);
y += s.small * 1.5 + G(isBlock ? 70 : 78) + topOffset;
if (isBlock) {
const b = data.block;
drawSparks(ctx, w, y - s.hero * 0.9, s.hero * 1.5);
// headline
ctx.font = `800 ${s.hero * 0.46}px ${SANS}`;
const grad = ctx.createLinearGradient(M, y - s.hero * 0.3, M + innerW * 0.9, y);
grad.addColorStop(0, C.gold);
grad.addColorStop(1, C.ember);
ctx.fillStyle = grad;
ctx.shadowColor = "rgba(255,180,60,0.5)";
ctx.shadowBlur = 50;
ctx.fillText("I FOUND A BLOCK!", M, y);
ctx.shadowBlur = 0;
// block height as the hero figure
y += G(24) + s.hero * 0.78;
const hero = `#${b.height.toLocaleString()}`;
ctx.font = `700 ${s.hero}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.shadowColor = "rgba(255,180,60,0.4)";
ctx.shadowBlur = 50;
ctx.fillText(hero, M, y);
ctx.shadowBlur = 0;
// chain badge beside the height, so a testnet block is never mistaken
const heroW = ctx.measureText(hero).width;
const label = chainLabel(b.chain || data.chain);
const isMain = label === "mainnet";
badge(
ctx, label, M + heroW + s.body * 0.8, y - s.hero * 0.18, s.small,
isMain ? C.gold : "#7aa2f7",
isMain ? "rgba(255,198,92,0.12)" : "rgba(122,162,247,0.12)",
isMain ? "rgba(255,198,92,0.5)" : "rgba(122,162,247,0.5)",
);
// reward and the difficulty of the winning share
y += G(56);
ctx.font = `600 ${s.body * 1.25}px ${SANS}`;
ctx.fillStyle = C.gold;
const reward = b.reward_btc ? `${b.reward_btc.toFixed(8)} BTC` : "block reward";
ctx.fillText(reward, M, y);
const rw = ctx.measureText(reward).width;
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText("paid straight to the miner", M + rw + s.body * 0.6, y);
y += s.body * 1.35;
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
const solved = b.share_diff
? `solved with a ${formatDifficulty(b.share_diff)} share`
: "solved by this pool";
ctx.fillText(
`${solved} · ${new Date(b.found_at).toLocaleDateString(undefined, {
day: "numeric", month: "short", year: "numeric",
})}`,
M, y,
);
// block hash
y += G(60);
eyebrow(ctx, "Block hash", M, y, s.eyebrow * 0.92, C.dim);
y += G(26);
if (hashLines.length) {
ctx.font = `500 ${s.mono}px ${MONO}`;
const zeros = hashText.length - hashText.replace(/^0+/, "").length;
hexPanel(ctx, hashLines, M, y, innerW, s.mono, {
fill: C.panel, stroke: "rgba(255,198,92,0.28)", leadingZeros: zeros,
});
y += hashPanelH;
}
y += G(44);
} else {
const share = data.share;
eyebrow(ctx, "Best share", M, y, s.eyebrow, C.ember);
y += G(24) + s.hero * 0.78;
const heroText = formatDifficulty(share.sdiff);
ctx.font = `700 ${s.hero}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.shadowColor = "rgba(255,122,58,0.45)";
ctx.shadowBlur = 55;
ctx.fillText(heroText, M, y);
ctx.shadowBlur = 0;
const heroW = ctx.measureText(heroText).width;
ctx.font = `500 ${s.body * 1.12}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText("difficulty", M + heroW + s.body * 0.6, y);
y += G(58);
ctx.font = `400 ${s.body}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.fillText(`≈ ${spellHashes(share.sdiff * 2 ** 32)} to find`, M, y);
if (gapX > 1) {
y += s.body * 1.45;
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
const times = gapX >= 1000 ? formatDifficulty(gapX) : gapX.toFixed(1);
ctx.fillText(`a block needed ${times}× more`, M, y);
}
y += G(74);
eyebrow(ctx, "Block header hash", M, y, s.eyebrow * 0.92, C.dim);
y += G(26);
ctx.font = `500 ${s.mono}px ${MONO}`;
const zeros = hashText.length - hashText.replace(/^0+/, "").length;
hexPanel(ctx, hashLines, M, y, innerW, s.mono, {
fill: C.panel, stroke: C.stroke, leadingZeros: zeros,
});
y += hashPanelH;
y += G(46);
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText(
`block #${share.height.toLocaleString()} · ${chainLabel(data.chain)} · ` +
`${new Date(share.seen_at).toLocaleDateString(undefined, {
day: "numeric", month: "short", year: "numeric",
})}`,
M, y,
);
y += G(66);
eyebrow(ctx, "Proof · raw 80-byte header", M, y, s.eyebrow * 0.92, C.ember);
y += G(26);
ctx.font = `400 ${s.monoHeader}px ${MONO}`;
hexPanel(ctx, hdrLines, M, y, innerW, s.monoHeader, {
fill: "rgba(255,122,58,0.06)", stroke: "rgba(255,122,58,0.28)",
});
y += hdrPanelH;
y += G(42);
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText("SHA-256 it twice, reverse the bytes, and you get the hash above", M, y);
}
// ── footer, pinned
ctx.strokeStyle = C.stroke;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(M, ruleY);
ctx.lineTo(w - M, ruleY);
ctx.stroke();
drawFlame(ctx, M + s.small * 0.5, footY - s.small * 0.42, s.small * 0.72);
ctx.font = `600 ${s.body * 0.94}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.fillText("Run your own solo pool", M + s.small * 1.5, footY);
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.ember;
const cta = "Kamado Pool on StartOS";
ctx.fillText(cta, w - M - ctx.measureText(cta).width, footY);
// ── QR, centred above the footer
if (qrPayload) {
// Flow position: the slack distribution already sizes the gaps so this
// lands just above the footer. Clamping to the bottom instead would
// undo that and reopen a void above the code.
const qy = Math.min(
y + Math.max(s.small * 1.25, G(40)),
ruleY - s.body * 1.0 - qrBlockH,
);
const qx = (w - qrSize) / 2;
try {
const qc = document.createElement("canvas");
await QRCode.toCanvas(qc, qrPayload, {
width: qrSize, margin: 1, errorCorrectionLevel: "L",
color: { dark: "#0b0e14", light: "#e6e9ef" },
});
const pad = qrSize * 0.05;
ctx.fillStyle = C.fg;
roundRect(ctx, qx - pad, qy - pad, qrSize + pad * 2, qrSize + pad * 2, 14);
ctx.fill();
ctx.drawImage(qc, qx, qy, qrSize, qrSize);
ctx.textAlign = "center";
let cy = qy + qrSize + pad + s.body * 1.15;
ctx.font = `600 ${s.body * 0.94}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.fillText(qrCaption[0], w / 2, cy);
cy += s.small * 1.5;
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText(qrCaption[1], w / 2, cy);
ctx.textAlign = "left";
} catch {
/* the hash and header are printed above regardless */
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ let socket: WebSocket | null = null;
let retryMs = 1000;
export async function connect(): Promise<void> {
// Initial REST fetch — populates the first paint and tells us if
// Initial REST fetch, populates the first paint and tells us if
// the API is reachable at all before we commit to a WebSocket.
try {
const res = await fetch("/api/snapshot", { cache: "no-store" });
+6 -6
View File
@@ -61,7 +61,7 @@ export type StratumClient = {
lastshare: number;
starttime: number;
// Source IP, NOT the BTC payout address. ckpool sets this from
// inet_ntop(client_addr) in connector.c — see stratum_add_instance.
// inet_ntop(client_addr) in connector.c, see stratum_add_instance.
// The BTC address comes from `workername` (or the joined Worker.user).
address: string;
subscribed: boolean;
@@ -73,7 +73,7 @@ export type StratumClient = {
// Index into ckpool's serverurl[] array; identifies which stratum
// bind the client connected on. What each index *means* depends on
// how the deployment rendered ckpool.conf, so resolve it through
// Snapshot.stratum_servers rather than hardcoding — see
// Snapshot.stratum_servers rather than hardcoding, see
// connectionOf() in format.ts.
server: number;
bestdiff: number;
@@ -115,7 +115,7 @@ export type HashratePoint = {
// PoW reproduction data for the best share seen since ckpool patch 0008
// went live (logged by ckpool, ingested from the log by kamado-api).
// `header` is the raw 80-byte block header in hashing byte order —
// `header` is the raw 80-byte block header in hashing byte order,
// double-SHA256 it and reverse the digest to get `hash`. `coinbase` plus
// `merklebranches` reproduce the merkle root committed in the header.
export type BestSharePow = {
@@ -159,7 +159,7 @@ export type Snapshot = {
best_share_net_diff?: number;
// Full header/coinbase/merkle-branch record for the best share seen
// since ckpool patch 0008 was deployed. May describe a lower-diff
// share than best_diff when the all-time best predates the patch —
// share than best_diff when the all-time best predates the patch,
// compare hashes and disclose.
best_share_pow?: BestSharePow;
acked_best_diff: number;
@@ -178,9 +178,9 @@ export type Snapshot = {
mempool_base_url?: string;
// Describes ckpool's serverurl[] binds: entry N says what a client
// with `server === N` is connected over. Undefined when the
// deployment didn't declare them — see connectionOf() in format.ts.
// deployment didn't declare them, see connectionOf() in format.ts.
stratum_servers?: StratumServer[];
// Submission attempt tracking — count of "Possible block solve"
// Submission attempt tracking, count of "Possible block solve"
// log lines vs "Solved and confirmed" lines. A growing gap means
// submissions are being rejected by bitcoind.
block_submit_attempts: number;