diff --git a/src/posts/bitcoin/diagrams/FlagRateMempool.astro b/src/posts/bitcoin/diagrams/FlagRateMempool.astro deleted file mode 100644 index 178701a..0000000 --- a/src/posts/bitcoin/diagrams/FlagRateMempool.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import { FlagRateMempool } from "./FlagRateMempool"; ---- - - diff --git a/src/posts/bitcoin/diagrams/FlagRateMempool.tsx b/src/posts/bitcoin/diagrams/FlagRateMempool.tsx deleted file mode 100644 index 972abeb..0000000 --- a/src/posts/bitcoin/diagrams/FlagRateMempool.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { useId, useMemo, useRef, useState } from "react"; -import { - bandLine, - bandPath, - bandY, - bestLag, - blocksOfDay, - DAYS, - flagShare, - formatDate, - FRAME, - pearson, - percentile, - quarterTicks, - RESPONSE_LAG, - xAt, -} from "../lib/flag-rate"; - -// Both ceilings come from the data with a little headroom — a fixed one -// silently flat-tops the tallest spike, which is the one column that matters. -const FEE_MAX = Math.ceil(Math.max(...DAYS.map((d) => d.feeRate)) / 20) * 20; -const FLAG_MAX = Math.ceil(Math.max(...DAYS.map(flagShare)) * 20) / 20; - -export function FlagRateMempool() { - const gradientId = useId(); - const svgRef = useRef(null); - const [index, setIndex] = useState(() => defaultIndex()); - const [held, setHeld] = useState(false); - - const fees = useMemo(() => DAYS.map((d) => d.feeRate), []); - const flags = useMemo(() => DAYS.map(flagShare), []); - - const feePath = useMemo(() => bandPath(fees, FEE_MAX, true, "log"), [fees]); - const feeLine = useMemo(() => bandLine(fees, FEE_MAX, true, "log"), [fees]); - const flagPath = useMemo(() => bandPath(flags, FLAG_MAX, false), [flags]); - const flagLine = useMemo(() => bandLine(flags, FLAG_MAX, false), [flags]); - - const cospikes = useMemo(() => { - const feeCut = percentile(fees, 0.9); - const flagCut = percentile(flags, 0.9); - return DAYS.map((_, i) => i).filter( - (i) => fees[i] >= feeCut && flags[i] >= flagCut, - ); - }, [fees, flags]); - - const r0 = useMemo(() => pearson(fees, flags, 0), [fees, flags]); - const lag = useMemo(() => bestLag(fees, flags), [fees, flags]); - const rLag = useMemo(() => pearson(fees, flags, lag), [fees, flags, lag]); - - const day = DAYS[index]; - const share = flagShare(day); - const ribbon = useMemo(() => blocksOfDay(index), [index]); - const ticks = useMemo(() => quarterTicks(), []); - - const title = "var(--stage-title,var(--color-ink-50))"; - const body = "var(--stage-body,var(--color-ink-300))"; - const accent = "var(--hero-accent,var(--accent,var(--color-signal-500)))"; - - const indexFromEvent = (clientX: number) => { - const svg = svgRef.current; - if (!svg) return; - const { left, width } = svg.getBoundingClientRect(); - if (width === 0) return; - const t = Math.min(1, Math.max(0, (clientX - left) / width)); - setIndex(Math.round(t * (DAYS.length - 1))); - }; - - const cursorX = xAt(index); - - return ( -
-
- flag rate vs mempool congestion - {formatDate(day.date)} -
- -
- - - - -
- - { - event.currentTarget.setPointerCapture(event.pointerId); - setHeld(true); - indexFromEvent(event.clientX); - }} - onPointerMove={(event) => { - if (held || event.pointerType === "mouse") - indexFromEvent(event.clientX); - }} - onPointerUp={(event) => { - event.currentTarget.releasePointerCapture(event.pointerId); - setHeld(false); - }} - onKeyDown={(event) => { - const step = event.shiftKey ? 7 : 1; - const next = - event.key === "ArrowLeft" - ? index - step - : event.key === "ArrowRight" - ? index + step - : null; - if (next === null) return; - event.preventDefault(); - setIndex(Math.min(DAYS.length - 1, Math.max(0, next))); - }} - > - - - - - - - - - - - - {/* Where both series sit in their own top decile on the same day — the - question the chart is asking, marked rather than left to the eye. */} - {cospikes.map((i) => ( - - ))} - - - - - - - - - {ticks.map((tick) => ( - - - FRAME.width - 20 - ? "end" - : "middle" - } - fill="currentColor" - opacity={0.55} - className="font-mono" - style={{ fontSize: 9 }} - > - {tick.label} - - - ))} - - - ▲ public fee rate · {FEE_MAX} sat/vB · log - - - ▼ private inclusion · {Math.round(FLAG_MAX * 100)}% - - - - - - - - {/* Per-block, for the day under the cursor: every block that day, in - order, accent if it carried a flagged transaction. */} -
-
- blocks · {day.date} - - {day.flagged} flagged - -
-
- {ribbon.map((flagged, i) => ( - - ))} -
-
- -

- pseudo data · flag response lagged {RESPONSE_LAG}d by construction -

-
- ); -} - -/** Open on the sharpest co-spike, so a still screenshot shows the point. */ -function defaultIndex(): number { - let best = 0; - let bestScore = -Infinity; - DAYS.forEach((day, i) => { - const score = day.feeRate * (day.flagged / day.blocks); - if (score > bestScore) { - bestScore = score; - best = i; - } - }); - return best; -} - -function Readout({ - label, - value, - unit, - title, -}: { - label: string; - value: string; - unit: string; - title: string; -}) { - return ( -
-
- {label} -
-
- {value} -
-
- {unit} -
-
- ); -} - -export default FlagRateMempool; diff --git a/src/posts/bitcoin/diagrams/FlaggedBlocks.astro b/src/posts/bitcoin/diagrams/FlaggedBlocks.astro deleted file mode 100644 index 0a13003..0000000 --- a/src/posts/bitcoin/diagrams/FlaggedBlocks.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -import { FlaggedBlocks } from "./FlaggedBlocks"; ---- - -{ - /* client:visible — same call as the timeline: the chart only means anything - once it is on screen, and it sits well below the fold. */ -} - diff --git a/src/posts/bitcoin/diagrams/FlaggedBlocks.tsx b/src/posts/bitcoin/diagrams/FlaggedBlocks.tsx deleted file mode 100644 index 55658dc..0000000 --- a/src/posts/bitcoin/diagrams/FlaggedBlocks.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { - axisMax, - axisTicks, - flaggedBlocks, - FLAGGED_MONTHS, - formatMonth, - formatPercent, - monthShare, - POOLS, - poolShare, - segments, - yearTicks, -} from "../lib/flagged-blocks"; - -const PLOT_HEIGHT = 168; -const accent = "var(--accent,var(--color-signal-500))"; - -/** - * The numbers this draws are placeholder — see the header of - * lib/flagged-blocks.ts. The caption under the chart says so on the page too, - * and both notes come out together when real counts land. - */ -export function FlaggedBlocks() { - const rows = FLAGGED_MONTHS; - const max = axisMax(rows); - const ticks = axisTicks(max); - const years = yearTicks(rows); - const plotRef = useRef(null); - const [active, setActive] = useState(rows.length - 1); - const [grown, setGrown] = useState(false); - - useEffect(() => { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setGrown(true); - return; - } - const frame = requestAnimationFrame(() => setGrown(true)); - return () => cancelAnimationFrame(frame); - }, []); - - const indexFromEvent = (clientX: number) => { - const plot = plotRef.current; - if (!plot) return; - const { left, width } = plot.getBoundingClientRect(); - if (width === 0) return; - const ratio = (clientX - left) / width; - const index = Math.floor(ratio * rows.length); - setActive(Math.min(rows.length - 1, Math.max(0, index))); - }; - - const row = rows[active]; - const flagged = flaggedBlocks(row); - const share = monthShare(row); - - return ( -
-
- - blocks with ≥1 flagged tx - - - monthly · {formatMonth(rows[0].month)} —{" "} - {formatMonth(rows[rows.length - 1].month)} - -
- -
-
-
- {formatPercent(share)} -
-
- {flagged.toLocaleString()} of {row.blocks.toLocaleString()} blocks -
-
-
- {formatMonth(row.month)} -
-
- -
- - -
indexFromEvent(event.clientX)} - onPointerMove={(event) => indexFromEvent(event.clientX)} - onKeyDown={(event) => { - const next = - event.key === "ArrowLeft" - ? active - 1 - : event.key === "ArrowRight" - ? active + 1 - : null; - if (next === null) return; - event.preventDefault(); - setActive(Math.min(rows.length - 1, Math.max(0, next))); - }} - > - {ticks.map((tick) => ( - - -
- - -
-
- -
- one block counts once, under the pool that mined it, however many - flagged txs it holds. sample data — not measured. -
-
- ); -} - -export default FlaggedBlocks; diff --git a/src/posts/bitcoin/diagrams/PoolFlagHeatmap.astro b/src/posts/bitcoin/diagrams/PoolFlagHeatmap.astro deleted file mode 100644 index 4a770cd..0000000 --- a/src/posts/bitcoin/diagrams/PoolFlagHeatmap.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import { PoolFlagHeatmap } from "./PoolFlagHeatmap"; ---- - - diff --git a/src/posts/bitcoin/diagrams/PoolFlagHeatmap.tsx b/src/posts/bitcoin/diagrams/PoolFlagHeatmap.tsx deleted file mode 100644 index 9a87659..0000000 --- a/src/posts/bitcoin/diagrams/PoolFlagHeatmap.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useMemo, useState } from "react"; -import { FLAG_ROWS } from "../lib/pool-flag-sample"; -import { - buildHeatmap, - formatBlocks, - formatMonth, - formatRate, - shade, - type Cell, - type Series, -} from "../lib/pool-flag-rate"; - -interface Hover { - pool: string; - cell: Cell; -} - -export function PoolFlagHeatmap() { - const heatmap = useMemo(() => buildHeatmap(FLAG_ROWS), []); - const [hover, setHover] = useState(null); - - const { months, years, pools, network, maxRate } = heatmap; - const columns = `repeat(${months.length}, minmax(0, 1fr))`; - const span = `${formatMonth(months[0])} — ${formatMonth(months[months.length - 1])}`; - - return ( -
-
- flagged blocks / blocks won - {span} -
- - - -
-
setHover(null)} - > - {pools.map((series, row) => ( - - ))} - -
- - - -
-
- {years.map((year) => ( -
- {year.year} -
- ))} -
-
-
-
- - -
- ); -} - -interface RowProps { - series: Series; - columns: string; - maxRate: number; - row: number; - hover: Hover | null; - onHover: (hover: Hover | null) => void; -} - -function Row({ series, columns, maxRate, row, hover, onHover }: RowProps) { - const active = hover?.pool === series.pool; - - return ( - <> -
- {series.pool} -
- -
- {series.cells.map((cell, column) => { - const lit = !hover || hover.cell.month === cell?.month; - return ( -
cell && onHover({ pool: series.pool, cell })} - style={{ - background: cell - ? shade(cell.rate, maxRate) - : "var(--color-ink-900)", - boxShadow: cell - ? undefined - : "inset 0 0 0 1px var(--color-ink-800)", - opacity: lit ? 1 : 0.28, - animationDelay: `${column * 6 + row * 14}ms`, - }} - /> - ); - })} -
- -
- {formatRate(series.rate)} -
- - ); -} - -function Readout({ - hover, - network, - span, -}: { - hover: Hover | null; - network: Series; - span: string; -}) { - const pool = hover ? hover.pool : "all pools"; - const when = hover ? formatMonth(hover.cell.month) : span; - const won = hover ? hover.cell.blocksWon : network.blocksWon; - const flagged = hover ? hover.cell.flaggedBlocks : network.flaggedBlocks; - const rate = hover ? hover.cell.rate : network.rate; - - return ( -
-
-
- {pool} · {when} -
-
- {formatRate(rate)} -
-
-
-
{formatBlocks(flagged)} flagged
-
{formatBlocks(won)} won
-
-
- ); -} - -function Legend({ maxRate }: { maxRate: number }) { - const steps = [0, 0.25, 0.5, 0.75, 1]; - - return ( -
-
- 0% -
- {steps.map((step) => ( -
- ))} -
- {formatRate(maxRate)} -
- one cell = one pool-month · hollow = no blocks won -
- ); -} diff --git a/src/posts/bitcoin/index.mdx b/src/posts/bitcoin/index.mdx index 12f3750..70a90b5 100644 --- a/src/posts/bitcoin/index.mdx +++ b/src/posts/bitcoin/index.mdx @@ -11,9 +11,6 @@ stageTone: "dark" import AcceleratorPools from "./diagrams/AcceleratorPools.astro"; import BitcoinTimeline from "./diagrams/BitcoinTimeline.astro"; -import FlagRateMempool from "./diagrams/FlagRateMempool.astro"; -import FlaggedBlocks from "./diagrams/FlaggedBlocks.astro"; -import PoolFlagHeatmap from "./diagrams/PoolFlagHeatmap.astro"; import { OffchainRevenue } from "./diagrams/OffchainRevenue"; @@ -52,16 +49,6 @@ There are more offchain revenues, private business deals, un published data, so [same charts, but with addition of assumed offchain revenue] -## Flag rate vs mempool congestion - - - - - - - -The numbers here are placeholder data. - ## What a pool earns off the book diff --git a/src/posts/bitcoin/lib/flag-rate.ts b/src/posts/bitcoin/lib/flag-rate.ts deleted file mode 100644 index d7a507b..0000000 --- a/src/posts/bitcoin/lib/flag-rate.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * PSEUDO DATA — every number below is synthesised, not measured. Swap - * `buildDays` for a real loader (mempool.space fee history + whatever marks a - * block as carrying privately-relayed transactions) and the widget is - * unchanged. The shape is what a real loader has to produce. - */ - -export interface DayBucket { - /** ISO date, UTC. */ - date: string; - /** Blocks mined that day. */ - blocks: number; - /** Blocks that carried at least one flagged (privately included) tx. */ - flagged: number; - /** Median public fee rate, sat/vB. */ - feeRate: number; -} - -export const FLAG_START = "2023-01-01"; -const DAYS_COUNT = 731; - -// Deterministic: the island renders on the server and again in the browser, -// and a Math.random() series would differ between the two. -function mulberry32(seed: number) { - let a = seed >>> 0; - return () => { - a = (a + 0x6d2b79f5) >>> 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -interface Episode { - /** Day index the congestion starts. */ - at: number; - /** Days it lasts. */ - len: number; - /** Peak median fee rate, sat/vB. */ - peak: number; -} - -const EPISODES: Episode[] = [ - { at: 118, len: 22, peak: 78 }, - { at: 131, len: 16, peak: 168 }, - { at: 199, len: 12, peak: 62 }, - { at: 320, len: 34, peak: 246 }, - { at: 412, len: 14, peak: 71 }, - { at: 468, len: 20, peak: 132 }, - { at: 475, len: 9, peak: 301 }, - { at: 560, len: 18, peak: 96 }, - { at: 651, len: 26, peak: 154 }, -]; - -/** How many days private inclusion trails the public spike. */ -export const RESPONSE_LAG = 2; - -function episodeLift(index: number): number { - let lift = 0; - for (const episode of EPISODES) { - const t = (index - episode.at) / episode.len; - if (t < 0 || t > 1) continue; - // Fast in, slow out — congestion arrives quicker than it drains. - const shape = t < 0.28 ? t / 0.28 : Math.pow(1 - (t - 0.28) / 0.72, 1.7); - lift += episode.peak * shape; - } - return lift; -} - -function buildDays(): DayBucket[] { - const random = mulberry32(0x5eed_1337); - const start = Date.UTC(2023, 0, 1); - - const feeRates: number[] = []; - for (let i = 0; i < DAYS_COUNT; i += 1) { - const base = 9 + 3.5 * Math.sin(i / 47) + 2 * Math.sin(i / 11); - const noise = (random() - 0.45) * 6; - feeRates.push(Math.max(1.4, base + noise + episodeLift(i))); - } - - return feeRates.map((feeRate, i) => { - // Private inclusion answers the *earlier* public spike, not today's — the - // whole point of the chart is that the two curves are offset, not glued. - const driver = feeRates[Math.max(0, i - RESPONSE_LAG)]; - const pressure = Math.min(1, Math.log10(1 + driver / 9) / 1.35); - const share = Math.min( - 0.62, - Math.max(0.01, 0.055 + 0.44 * pressure + (random() - 0.5) * 0.045), - ); - const blocks = 132 + Math.round(random() * 22); - return { - date: new Date(start + i * 86_400_000).toISOString().slice(0, 10), - blocks, - flagged: Math.round(blocks * share), - feeRate: Math.round(feeRate * 10) / 10, - }; - }); -} - -export const DAYS: DayBucket[] = buildDays(); - -export function flagShare(day: DayBucket): number { - return day.flagged / day.blocks; -} - -/** - * One day's blocks, flagged or not. Runs rather than a coin flip per block: - * a pool that just won a private-order-flow deal wins several blocks in a - * row, so the real ribbon clusters. - */ -export function blocksOfDay(index: number): boolean[] { - const day = DAYS[index]; - const random = mulberry32(0xb10c + index * 2654435761); - const out: boolean[] = []; - let remaining = day.flagged; - while (out.length < day.blocks) { - const left = day.blocks - out.length; - const gapRoom = left - remaining; - if (remaining <= 0) { - out.push(false); - continue; - } - if (gapRoom <= 0) { - out.push(true); - remaining -= 1; - continue; - } - const gap = 1 + Math.floor(random() * Math.min(gapRoom, 6)); - for (let i = 0; i < gap; i += 1) out.push(false); - const run = 1 + Math.floor(random() * Math.min(remaining, 4)); - for (let i = 0; i < run; i += 1) out.push(true); - remaining -= run; - } - return out.slice(0, day.blocks); -} - -export function percentile(values: number[], p: number): number { - const sorted = [...values].sort((a, b) => a - b); - const at = (sorted.length - 1) * p; - const low = Math.floor(at); - const high = Math.ceil(at); - return sorted[low] + (sorted[high] - sorted[low]) * (at - low); -} - -export function pearson(a: number[], b: number[], lag = 0): number { - const x = lag > 0 ? a.slice(0, a.length - lag) : a; - const y = lag > 0 ? b.slice(lag) : b; - const n = Math.min(x.length, y.length); - const mx = x.reduce((s, v) => s + v, 0) / n; - const my = y.reduce((s, v) => s + v, 0) / n; - let num = 0; - let dx = 0; - let dy = 0; - for (let i = 0; i < n; i += 1) { - const a0 = x[i] - mx; - const b0 = y[i] - my; - num += a0 * b0; - dx += a0 * a0; - dy += b0 * b0; - } - return num / Math.sqrt(dx * dy); -} - -/** The lag, in days, at which the two series line up best. */ -export function bestLag(a: number[], b: number[], maxLag = 10): number { - let best = 0; - let bestR = -Infinity; - for (let lag = 0; lag <= maxLag; lag += 1) { - const r = pearson(a, b, lag); - if (r > bestR) { - bestR = r; - best = lag; - } - } - return best; -} - -export interface Frame { - width: number; - /** Where the two mirrored bands meet. */ - axisY: number; - /** Tallest either band gets. */ - extent: number; -} - -export const FRAME: Frame = { width: 720, axisY: 108, extent: 96 }; - -export function xAt(index: number, count = DAYS.length): number { - return (index / (count - 1)) * FRAME.width; -} - -export type Scale = "linear" | "log"; - -/** - * Fee rate spans 1.4 → 350 sat/vB, so a linear band pins two years of - * ordinary days flat against the axis and shows only the spikes. Log keeps - * the quiet weeks readable — and the band is labelled "log", because a log - * band's shape is not the growth's shape. - */ -function norm(value: number, max: number, scale: Scale): number { - const clamped = Math.min(max, Math.max(0, value)); - if (scale === "linear") return clamped / max; - return Math.log10(1 + clamped) / Math.log10(1 + max); -} - -/** - * A mirrored band: `up` draws above the axis, otherwise below. Each band is - * normalised against its own max — sat/vB and a percentage have nothing to - * say to each other on one scale. - */ -export function bandLine( - values: number[], - max: number, - up: boolean, - scale: Scale = "linear", -): string { - return values - .map( - (v, i) => - `${i === 0 ? "M" : "L"}${xAt(i).toFixed(2)} ${bandY(v, max, up, scale).toFixed(2)}`, - ) - .join(" "); -} - -/** The same line closed down to the axis, for the fill only — stroking this - would paint the baseline and the left edge as if they were data. */ -export function bandPath( - values: number[], - max: number, - up: boolean, - scale: Scale = "linear", -): string { - return `${bandLine(values, max, up, scale)} L${FRAME.width} ${FRAME.axisY} L0 ${FRAME.axisY} Z`; -} - -export function bandY( - value: number, - max: number, - up: boolean, - scale: Scale = "linear", -): number { - const { axisY, extent } = FRAME; - const h = norm(value, max, scale) * extent; - return up ? axisY - h : axisY + h; -} - -/** First day of each month that starts a quarter, for the axis ticks. */ -export function quarterTicks(): { index: number; label: string }[] { - const out: { index: number; label: string }[] = []; - DAYS.forEach((day, index) => { - const [year, month, dayOfMonth] = day.date.split("-"); - if (dayOfMonth !== "01") return; - if (!["01", "04", "07", "10"].includes(month)) return; - const quarter = Math.floor(Number(month) / 3) + 1; - out.push({ index, label: `${year.slice(2)}·Q${quarter}` }); - }); - return out; -} - -export function formatDate(iso: string): string { - const [year, month, day] = iso.split("-"); - const names = [ - "jan", - "feb", - "mar", - "apr", - "may", - "jun", - "jul", - "aug", - "sep", - "oct", - "nov", - "dec", - ]; - return `${day} ${names[Number(month) - 1]} ${year}`; -} diff --git a/src/posts/bitcoin/lib/flagged-blocks.test.ts b/src/posts/bitcoin/lib/flagged-blocks.test.ts deleted file mode 100644 index dde3273..0000000 --- a/src/posts/bitcoin/lib/flagged-blocks.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { - axisMax, - axisTicks, - flaggedBlocks, - FLAGGED_MONTHS, - formatMonth, - monthShare, - POOLS, - segments, - yearTicks, - type FlaggedMonth, -} from "./flagged-blocks.ts"; - -const row: FlaggedMonth = { - month: "2023-01", - blocks: 1000, - flagged: { foundry: 100, antpool: 50, viabtc: 30, f2pool: 20, other: 10 }, -}; - -test("monthShare counts every pool once against the month's blocks", () => { - assert.equal(flaggedBlocks(row), 210); - assert.equal(monthShare(row), 21); -}); - -test("segments stack without gaps and end at the month's share", () => { - const stacked = segments(row); - assert.equal(stacked.length, POOLS.length); - assert.equal(stacked[0].base, 0); - stacked.forEach((segment, index) => { - if (index === 0) return; - const previous = stacked[index - 1]; - assert.equal(segment.base, previous.base + previous.height); - }); - const last = stacked[stacked.length - 1]; - assert.equal(last.base + last.height, monthShare(row)); -}); - -test("a month with no blocks reads as zero rather than NaN", () => { - const empty: FlaggedMonth = { ...row, blocks: 0 }; - assert.equal(monthShare(empty), 0); - assert.equal(segments(empty)[0].height, 0); -}); - -test("the axis clears the tallest month", () => { - const max = axisMax(FLAGGED_MONTHS); - const tallest = Math.max(...FLAGGED_MONTHS.map(monthShare)); - assert.ok(max >= tallest); - assert.equal(max % 10, 0); - assert.equal(axisTicks(max)[0], 0); -}); - -test("one x label per year, the first one on the opening month", () => { - const years = yearTicks(FLAGGED_MONTHS); - assert.equal(years[0].index, 0); - assert.deepEqual( - years.map((tick) => tick.year), - ["2023", "2024", "2025", "2026"], - ); -}); - -test("formatMonth reads as a month, not an ISO string", () => { - assert.equal(formatMonth("2023-01"), "jan 2023"); - assert.equal(formatMonth("2026-07"), "jul 2026"); -}); diff --git a/src/posts/bitcoin/lib/flagged-blocks.ts b/src/posts/bitcoin/lib/flagged-blocks.ts deleted file mode 100644 index 08e50f0..0000000 --- a/src/posts/bitcoin/lib/flagged-blocks.ts +++ /dev/null @@ -1,541 +0,0 @@ -/** - * PLACEHOLDER DATA — every number in FLAGGED_MONTHS below is generated, not - * measured. Replace the array with real per-month counts; nothing else in this - * file or in FlaggedBlocks.tsx needs to change, as long as the shape holds: - * one row per month, `blocks` = every block mined that month, `flagged` = - * blocks holding at least one flagged tx, split by the pool that mined them. - * A block is counted once, under one pool, however many flagged txs it holds. - */ - -export type PoolId = "foundry" | "antpool" | "viabtc" | "f2pool" | "other"; - -export interface FlaggedMonth { - /** YYYY-MM */ - month: string; - blocks: number; - flagged: Record; -} - -export interface Pool { - id: PoolId; - label: string; - /** Slot in the validated categorical palette — see global.css. */ - color: string; -} - -/** Stack order, floor first. Fixed: a pool keeps its color as the mix moves. */ -export const POOLS: Pool[] = [ - { id: "foundry", label: "Foundry USA", color: "var(--pool-1)" }, - { id: "antpool", label: "AntPool", color: "var(--pool-2)" }, - { id: "viabtc", label: "ViaBTC", color: "var(--pool-3)" }, - { id: "f2pool", label: "F2Pool", color: "var(--pool-4)" }, - { id: "other", label: "Other pools", color: "var(--pool-5)" }, -]; - -export const FLAGGED_MONTHS: FlaggedMonth[] = [ - { - month: "2023-01", - blocks: 4428, - flagged: { foundry: 114, antpool: 94, viabtc: 57, f2pool: 59, other: 74 }, - }, - { - month: "2023-02", - blocks: 4132, - flagged: { foundry: 125, antpool: 106, viabtc: 50, f2pool: 64, other: 68 }, - }, - { - month: "2023-03", - blocks: 4446, - flagged: { foundry: 155, antpool: 126, viabtc: 74, f2pool: 71, other: 91 }, - }, - { - month: "2023-04", - blocks: 4419, - flagged: { foundry: 160, antpool: 137, viabtc: 85, f2pool: 89, other: 108 }, - }, - { - month: "2023-05", - blocks: 4470, - flagged: { foundry: 184, antpool: 159, viabtc: 92, f2pool: 93, other: 101 }, - }, - { - month: "2023-06", - blocks: 4319, - flagged: { foundry: 167, antpool: 143, viabtc: 82, f2pool: 85, other: 112 }, - }, - { - month: "2023-07", - blocks: 4536, - flagged: { - foundry: 197, - antpool: 166, - viabtc: 92, - f2pool: 105, - other: 119, - }, - }, - { - month: "2023-08", - blocks: 4506, - flagged: { - foundry: 210, - antpool: 156, - viabtc: 88, - f2pool: 108, - other: 104, - }, - }, - { - month: "2023-09", - blocks: 4394, - flagged: { - foundry: 189, - antpool: 161, - viabtc: 98, - f2pool: 101, - other: 105, - }, - }, - { - month: "2023-10", - blocks: 4474, - flagged: { - foundry: 222, - antpool: 154, - viabtc: 91, - f2pool: 110, - other: 169, - }, - }, - { - month: "2023-11", - blocks: 4293, - flagged: { foundry: 214, antpool: 167, viabtc: 99, f2pool: 84, other: 111 }, - }, - { - month: "2023-12", - blocks: 4494, - flagged: { - foundry: 235, - antpool: 197, - viabtc: 111, - f2pool: 102, - other: 161, - }, - }, - { - month: "2024-01", - blocks: 4355, - flagged: { - foundry: 282, - antpool: 189, - viabtc: 122, - f2pool: 105, - other: 140, - }, - }, - { - month: "2024-02", - blocks: 4160, - flagged: { - foundry: 243, - antpool: 174, - viabtc: 105, - f2pool: 115, - other: 179, - }, - }, - { - month: "2024-03", - blocks: 4518, - flagged: { - foundry: 316, - antpool: 222, - viabtc: 138, - f2pool: 139, - other: 142, - }, - }, - { - month: "2024-04", - blocks: 4196, - flagged: { - foundry: 311, - antpool: 189, - viabtc: 116, - f2pool: 129, - other: 152, - }, - }, - { - month: "2024-05", - blocks: 4389, - flagged: { - foundry: 331, - antpool: 221, - viabtc: 153, - f2pool: 116, - other: 168, - }, - }, - { - month: "2024-06", - blocks: 4376, - flagged: { - foundry: 335, - antpool: 192, - viabtc: 134, - f2pool: 126, - other: 159, - }, - }, - { - month: "2024-07", - blocks: 4564, - flagged: { - foundry: 331, - antpool: 224, - viabtc: 141, - f2pool: 128, - other: 129, - }, - }, - { - month: "2024-08", - blocks: 4554, - flagged: { - foundry: 350, - antpool: 226, - viabtc: 139, - f2pool: 116, - other: 230, - }, - }, - { - month: "2024-09", - blocks: 4393, - flagged: { - foundry: 350, - antpool: 216, - viabtc: 162, - f2pool: 116, - other: 193, - }, - }, - { - month: "2024-10", - blocks: 4483, - flagged: { - foundry: 337, - antpool: 227, - viabtc: 141, - f2pool: 140, - other: 215, - }, - }, - { - month: "2024-11", - blocks: 4402, - flagged: { - foundry: 329, - antpool: 221, - viabtc: 136, - f2pool: 122, - other: 215, - }, - }, - { - month: "2024-12", - blocks: 4558, - flagged: { - foundry: 355, - antpool: 241, - viabtc: 153, - f2pool: 122, - other: 207, - }, - }, - { - month: "2025-01", - blocks: 4480, - flagged: { - foundry: 411, - antpool: 230, - viabtc: 161, - f2pool: 121, - other: 220, - }, - }, - { - month: "2025-02", - blocks: 3957, - flagged: { - foundry: 358, - antpool: 220, - viabtc: 151, - f2pool: 120, - other: 226, - }, - }, - { - month: "2025-03", - blocks: 4434, - flagged: { - foundry: 395, - antpool: 234, - viabtc: 143, - f2pool: 127, - other: 164, - }, - }, - { - month: "2025-04", - blocks: 4377, - flagged: { - foundry: 388, - antpool: 211, - viabtc: 158, - f2pool: 128, - other: 222, - }, - }, - { - month: "2025-05", - blocks: 4369, - flagged: { - foundry: 460, - antpool: 241, - viabtc: 186, - f2pool: 142, - other: 211, - }, - }, - { - month: "2025-06", - blocks: 4429, - flagged: { - foundry: 476, - antpool: 285, - viabtc: 203, - f2pool: 140, - other: 185, - }, - }, - { - month: "2025-07", - blocks: 4579, - flagged: { - foundry: 415, - antpool: 250, - viabtc: 194, - f2pool: 133, - other: 185, - }, - }, - { - month: "2025-08", - blocks: 4534, - flagged: { - foundry: 439, - antpool: 239, - viabtc: 207, - f2pool: 121, - other: 230, - }, - }, - { - month: "2025-09", - blocks: 4421, - flagged: { - foundry: 521, - antpool: 244, - viabtc: 208, - f2pool: 139, - other: 227, - }, - }, - { - month: "2025-10", - blocks: 4394, - flagged: { - foundry: 489, - antpool: 240, - viabtc: 198, - f2pool: 131, - other: 189, - }, - }, - { - month: "2025-11", - blocks: 4228, - flagged: { - foundry: 443, - antpool: 254, - viabtc: 217, - f2pool: 117, - other: 260, - }, - }, - { - month: "2025-12", - blocks: 4484, - flagged: { - foundry: 524, - antpool: 242, - viabtc: 200, - f2pool: 124, - other: 224, - }, - }, - { - month: "2026-01", - blocks: 4374, - flagged: { - foundry: 502, - antpool: 270, - viabtc: 187, - f2pool: 112, - other: 188, - }, - }, - { - month: "2026-02", - blocks: 4036, - flagged: { - foundry: 473, - antpool: 248, - viabtc: 203, - f2pool: 134, - other: 294, - }, - }, - { - month: "2026-03", - blocks: 4397, - flagged: { - foundry: 465, - antpool: 238, - viabtc: 219, - f2pool: 119, - other: 258, - }, - }, - { - month: "2026-04", - blocks: 4317, - flagged: { - foundry: 484, - antpool: 249, - viabtc: 186, - f2pool: 111, - other: 276, - }, - }, - { - month: "2026-05", - blocks: 4582, - flagged: { - foundry: 612, - antpool: 307, - viabtc: 240, - f2pool: 138, - other: 330, - }, - }, - { - month: "2026-06", - blocks: 4351, - flagged: { - foundry: 602, - antpool: 279, - viabtc: 214, - f2pool: 121, - other: 231, - }, - }, - { - month: "2026-07", - blocks: 4457, - flagged: { - foundry: 520, - antpool: 260, - viabtc: 201, - f2pool: 118, - other: 303, - }, - }, -]; - -export function flaggedBlocks(row: FlaggedMonth): number { - return POOLS.reduce((sum, pool) => sum + (row.flagged[pool.id] ?? 0), 0); -} - -/** Percent of the month's blocks that held a flagged tx, 0–100. */ -export function monthShare(row: FlaggedMonth): number { - return row.blocks === 0 ? 0 : (flaggedBlocks(row) / row.blocks) * 100; -} - -/** One pool's contribution to that share, in the same percentage points. */ -export function poolShare(row: FlaggedMonth, pool: PoolId): number { - return row.blocks === 0 ? 0 : ((row.flagged[pool] ?? 0) / row.blocks) * 100; -} - -/** Cumulative floor and height, in percentage points, for each stack segment. */ -export function segments( - row: FlaggedMonth, -): { pool: Pool; base: number; height: number }[] { - let base = 0; - return POOLS.map((pool) => { - const height = poolShare(row, pool.id); - const segment = { pool, base, height }; - base += height; - return segment; - }); -} - -/** Axis ceiling: the next 10-point step above the tallest month. */ -export function axisMax(rows: FlaggedMonth[]): number { - const tallest = rows.reduce((max, row) => Math.max(max, monthShare(row)), 0); - return Math.max(10, Math.ceil(tallest / 10) * 10); -} - -export function axisTicks(max: number): number[] { - const step = max > 50 ? 20 : 10; - const ticks: number[] = []; - for (let value = 0; value <= max; value += step) ticks.push(value); - return ticks; -} - -/** Index of every January in the series — the only x labels the axis carries. */ -export function yearTicks( - rows: FlaggedMonth[], -): { index: number; year: string }[] { - return rows.flatMap((row, index) => { - const [year, month] = row.month.split("-"); - return month === "01" || index === 0 ? [{ index, year }] : []; - }); -} - -const MONTH_NAMES = [ - "jan", - "feb", - "mar", - "apr", - "may", - "jun", - "jul", - "aug", - "sep", - "oct", - "nov", - "dec", -]; - -export function formatMonth(month: string): string { - const [year, index] = month.split("-"); - return `${MONTH_NAMES[Number(index) - 1]} ${year}`; -} - -export function formatPercent(value: number, digits = 1): string { - return `${value.toFixed(digits)}%`; -} diff --git a/src/posts/bitcoin/lib/pool-flag-rate.ts b/src/posts/bitcoin/lib/pool-flag-rate.ts deleted file mode 100644 index 62c5a31..0000000 --- a/src/posts/bitcoin/lib/pool-flag-rate.ts +++ /dev/null @@ -1,155 +0,0 @@ -export interface FlagRow { - pool: string; - month: string; - blocksWon: number; - flaggedBlocks: number; -} - -export interface Cell { - month: string; - blocksWon: number; - flaggedBlocks: number; - rate: number; -} - -export interface Series { - pool: string; - cells: (Cell | null)[]; - blocksWon: number; - flaggedBlocks: number; - rate: number; -} - -export interface YearSpan { - year: string; - start: number; - span: number; -} - -export interface Heatmap { - months: string[]; - years: YearSpan[]; - pools: Series[]; - network: Series; - maxRate: number; -} - -const MONTH_NAMES = [ - "jan", - "feb", - "mar", - "apr", - "may", - "jun", - "jul", - "aug", - "sep", - "oct", - "nov", - "dec", -]; - -function toCell(month: string, won: number, flagged: number): Cell { - return { - month, - blocksWon: won, - flaggedBlocks: flagged, - rate: won === 0 ? 0 : flagged / won, - }; -} - -function summarize(pool: string, cells: (Cell | null)[]): Series { - let blocksWon = 0; - let flaggedBlocks = 0; - for (const cell of cells) { - if (!cell) continue; - blocksWon += cell.blocksWon; - flaggedBlocks += cell.flaggedBlocks; - } - return { - pool, - cells, - blocksWon, - flaggedBlocks, - rate: blocksWon === 0 ? 0 : flaggedBlocks / blocksWon, - }; -} - -export function buildHeatmap(rows: FlagRow[]): Heatmap { - const months = [...new Set(rows.map((row) => row.month))].sort(); - const index = new Map(months.map((month, i) => [month, i])); - - const byPool = new Map(); - const networkCells: (Cell | null)[] = months.map(() => null); - const networkWon = months.map(() => 0); - const networkFlagged = months.map(() => 0); - - for (const row of rows) { - const column = index.get(row.month); - if (column === undefined) continue; - let cells = byPool.get(row.pool); - if (!cells) { - cells = months.map(() => null); - byPool.set(row.pool, cells); - } - cells[column] = toCell(row.month, row.blocksWon, row.flaggedBlocks); - networkWon[column] += row.blocksWon; - networkFlagged[column] += row.flaggedBlocks; - } - - for (const [column, month] of months.entries()) { - if (networkWon[column] === 0) continue; - networkCells[column] = toCell( - month, - networkWon[column], - networkFlagged[column], - ); - } - - const pools = [...byPool.entries()] - .map(([pool, cells]) => summarize(pool, cells)) - .sort((a, b) => b.blocksWon - a.blocksWon); - - const years: YearSpan[] = []; - for (const [column, month] of months.entries()) { - const year = month.slice(0, 4); - const last = years[years.length - 1]; - if (last && last.year === year) last.span += 1; - else years.push({ year, start: column, span: 1 }); - } - - let maxRate = 0; - for (const series of pools) { - for (const cell of series.cells) { - if (cell && cell.rate > maxRate) maxRate = cell.rate; - } - } - - return { - months, - years, - pools, - network: summarize("network", networkCells), - maxRate: maxRate || 1, - }; -} - -/** Cell fill: one hue, surface → signal, gamma-eased so the low end stays legible. */ -export function shade(rate: number, maxRate: number): string { - const t = Math.min(1, Math.max(0, rate / maxRate)) ** 0.78; - const mix = (5 + 90 * t).toFixed(1); - return `color-mix(in oklab, var(--color-signal-500) ${mix}%, var(--color-ink-800))`; -} - -export function formatMonth(month: string): string { - const [year, index] = month.split("-"); - return `${MONTH_NAMES[Number(index) - 1]} ${year}`; -} - -export function formatRate(rate: number): string { - return `${(rate * 100).toFixed(1)}%`; -} - -export function formatBlocks(count: number): string { - return count.toLocaleString("en-US"); -} diff --git a/src/posts/bitcoin/lib/pool-flag-sample.ts b/src/posts/bitcoin/lib/pool-flag-sample.ts deleted file mode 100644 index bbc99aa..0000000 --- a/src/posts/bitcoin/lib/pool-flag-sample.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { FlagRow } from "./pool-flag-rate"; - -/* PSEUDO DATA — not measured, generated. Replace this whole file's default - export with the real rows; nothing else reads the generator. */ - -interface PoolSpec { - name: string; - share: number; - base: number; - drift: number; - from?: string; - until?: string; -} - -const POOL_SPECS: PoolSpec[] = [ - { name: "foundry usa", share: 0.29, base: 0.16, drift: 0.13 }, - { name: "antpool", share: 0.21, base: 0.05, drift: 0.03 }, - { name: "viabtc", share: 0.11, base: 0.04, drift: 0.02 }, - { name: "f2pool", share: 0.1, base: 0.14, drift: -0.11 }, - { name: "binance pool", share: 0.07, base: 0.03, drift: 0.01 }, - { name: "mara pool", share: 0.05, base: 0.21, drift: 0.14 }, - { name: "luxor", share: 0.04, base: 0.15, drift: 0.07 }, - { name: "spiderpool", share: 0.05, base: 0.02, drift: 0.01, from: "2023-10" }, - { name: "braiins", share: 0.03, base: 0.01, drift: 0.0 }, - { - name: "sbi crypto", - share: 0.02, - base: 0.09, - drift: 0.02, - until: "2025-08", - }, - { name: "other", share: 0.06, base: 0.03, drift: 0.02 }, -]; - -const FIRST_MONTH = "2023-01"; -const LAST_MONTH = "2026-06"; -const BLOCKS_PER_MONTH = 4380; - -function mulberry32(seed: number) { - let a = seed; - return () => { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -function monthRange(first: string, last: string): string[] { - const months: string[] = []; - let [year, month] = first.split("-").map(Number); - const [lastYear, lastMonth] = last.split("-").map(Number); - while (year < lastYear || (year === lastYear && month <= lastMonth)) { - months.push(`${year}-${String(month).padStart(2, "0")}`); - month += 1; - if (month > 12) { - month = 1; - year += 1; - } - } - return months; -} - -function generate(): FlagRow[] { - const random = mulberry32(0x9c1f37); - const months = monthRange(FIRST_MONTH, LAST_MONTH); - const rows: FlagRow[] = []; - - for (const spec of POOL_SPECS) { - for (const [index, month] of months.entries()) { - if (spec.from && month < spec.from) continue; - if (spec.until && month > spec.until) continue; - - const t = index / (months.length - 1); - const share = spec.share * (0.78 + random() * 0.44); - const blocksWon = Math.round(BLOCKS_PER_MONTH * share); - if (blocksWon === 0) continue; - - // An S-curve, not a straight line: policy changes land over a quarter or - // two rather than one month. - const ramp = 1 / (1 + Math.exp(-(t - 0.45) * 7)); - const rate = Math.min( - 0.94, - Math.max(0, spec.base + spec.drift * ramp + (random() - 0.5) * 0.045), - ); - rows.push({ - pool: spec.name, - month, - blocksWon, - flaggedBlocks: Math.round(blocksWon * rate), - }); - } - } - - return rows; -} - -export const FLAG_ROWS: FlagRow[] = generate();