Add share cards
This commit is contained in:
51 files changed
+1193
-239
No files matched your search
+1
-1
@@ -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
@@ -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"],
|
||||
|
||||
Vendored
+32
@@ -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;
|
||||
}
|
||||
@@ -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 — 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"
|
||||
|
||||
@@ -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 —
|
||||
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 — 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> ≈ {Math.round(Math.pow(2, networkZeroBits - shareZeroBits)).toLocaleString()}x</strong>
|
||||
harder — matching the {diffRatio.toFixed(1)}x difficulty ratio.
|
||||
harder, matching the {diffRatio.toFixed(1)}x difficulty ratio.
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">—</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">—</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,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"> · {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>
|
||||
|
||||
@@ -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} — 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 — 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 — "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 — 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–Meyer construction, which is what makes
|
||||
addition, not replacement, the Davies–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 — 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;
|
||||
|
||||
@@ -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">✕</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>
|
||||
@@ -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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — merkle proof,
|
||||
padding, every SHA-256 round — 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 — 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 — 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user