An interactive blockchain learning experiment, originally built for CS50 in 2021. Mine blocks, race two miners, send simulated coins, change the difficulty or halving interval, and tamper with a transaction to see integrity checks fail. Mining runs in your browser; an optional mining pool lets visitors compare verified work on a shared leaderboard.
Transactions are real legacy Bitcoin transactions with inputs, outputs, scripts and signatures, spent from a UTXO set, but this is a teaching model, not a Bitcoin wallet or network client. Coins have no monetary value. Each participant's BIP39 recovery phrase deterministically derives its demo key through BIP32; the phrase restores the key, not the simulated ledger. Never use these demo keys or phrases for real funds. The simulator chain is saved in this browser (localStorage, key proofofwork-chain): blocks, mempool, participants with their demo recovery phrases, open edits and the chain settings. A reload continues where you were, after checking the saved chain by the same rules as a live one; a save from another genesis, a damaged one or an unedited chain that no longer validates is dropped. Reset starts a new chain and replaces the save. Nothing leaves the browser. See demo wallet recovery for the recovery API and derivation details.
Use Node.js 22.22.2+, 24.15+ or 26+ and npm. The web app is React and Vite.
npm --prefix web ci
npm --prefix web run devChoose Start demo to mine the initial coins and distribute them. Create a transfer in New Transaction, then choose Mine Next Block. Block space is limited like Bitcoin's, scaled down: a block holds at most 1,600 bytes, about five transfers besides its header and coinbase. Each transfer pays a fee rate you choose (1, 5 or 20 satoshis per byte); miners take the highest fee per byte first, a transfer only after the pending one it spends from, and the rest waits for a later block. The Mempool card shows the next block's room and why each waiting transfer waits. Auto-mine repeats the experiment; Stop mining cancels the active attempt. Reset creates fresh wallets and a new genesis block while retaining experiment settings.
Open Experiment settings to change difficulty, the coin's display name, target block time or the halving interval. Changes affect future blocks, except that lowering the difficulty by hand starts a new chain after a warning, so a longer chain always took more work. Difficulty adapts every ten mined blocks. You choose difficulty in leading hexadecimal zeros, so each additional zero makes a successful hash about sixteen times less likely; every block stores it as Bitcoin's compact bits.
Choose Hash lab in the header to edit a message and nonce, compare fingerprints, and search for a hash with one to three leading zeros. The lab hashes a real 80-byte Bitcoin header in which the message stands in for the transactions. Search checks at most 50,000 candidates per run and can be stopped; editing an input cancels it. The lab uses independent temporary state and never changes the chain or wallets. Back to simulator or Escape closes it; reopening starts a fresh lab.
Choose Chain lab to experiment with three linked text blocks. Change Block 1's data: its stored hash stays unchanged, while the recomputed fingerprint reveals the edit. Later blocks can still pass their own checks while depending on an invalid earlier block. Re-mine each affected block in order to adopt the preceding stored hash and find a new nonce with one leading hex zero. Repairing one block leaves its successors untouched, so their links need attention next. Each search starts at zero, checks at most 50,000 candidates and can be stopped. Edits, reset and close cancel pending searches; only a successful search replaces a stored block. This isolated demonstration has no signatures, balances or network consensus and never changes the simulator. Choose Competing branches inside Chain lab to explore two valid histories. Competition starts tied; Rewrite history starts an altered toy payment two blocks behind. Allocate nominal hash power, mine one block or run up to twelve new blocks, and observe which history this local model selects. Only strictly more accumulated target work switches branches; a tie keeps the current selection. Stop, power changes, reset and leaving the experiment cancel unfinished work. Each block has the same target and contributes 16 expected-work units, regardless of observed attempts or extra hash zeros. Editing payment text does not forge a signature. Choose Prove a payment to check one transaction the way a light client does: with the block header and one sibling hash per Merkle tree level, not the whole block. Pick a block of 4, 16, 256 or 4,096 toy payments and a transaction; the view hashes the path level by level up to the root and compares it with the header's Merkle root, so 4,096 transactions need only 12 hashes. Edit the payment text or a sibling hash and the proof fails at a named level; Reset example restores it.
Open Missions for twelve guided experiments across the labs and workbench, from changing a fingerprint to fee packages, Merkle proofs and signed-payment tampering. Each step advances from the experiment itself; navigation alone cannot complete it. Progress stays in this browser. Opening or hiding missions within the page retains earlier missions’ active steps. An unfinished Merkle proof restarts when its experiment is left or guidance is hidden, because its local proof inputs are discarded. Reloading keeps completed missions and their rewards, while the unfinished mission restarts at its first step: the labs start fresh, and its evidence has to come from this visit even though the simulator chain is restored. Existing nine-mission progress unlocks the three appended missions without losing rewards or changing the saved-progress version. Free play remains available with missions collapsed.
Every hash attempt, in the simulator and in all labs, goes through one search module (web/src/lib/powSearch.js). In the browser it runs in a Web Worker, so the page stays responsive while miners race. Every block is an 80-byte Bitcoin header hashed with double SHA-256, so any Bitcoin tool can recompute it; the block details show those bytes. The first 64 bytes are absorbed into SHA-256 once (the midstate); each attempt only hashes the rest with the nonce, then hashes the digest again. Blocks commit to their transactions through a Merkle root, like Bitcoin's block header: change one transaction and the recomputed root, and with it the block hash, no longer match. Why the work stays in your browser, and when a server is used instead, is recorded in ADR 0001; the domain terms are in CONTEXT.md.
The leaderboard is shared by every visitor. You start with a generated name from Bitcoin's history, like Not-Satoshi-4; type your own in the glowing field at the top of the Next block card. Every block your browser mines carries that name in its coinbase, whichever simulated miner wins the race, and goes to the leaderboard. The coinbase also commits to the genesis hash, a recent Bitcoin block and your name, and the block header commits to the coinbase, so the work cannot be mined in advance, reused or credited to another name. The server re-checks the 80-byte header of every block; changed browser code cannot fake work. The smallest hash ranks highest, and Verify recomputes any entry in your browser. Faster hardware counts too: that is proof of work. On the Leaderboard tab your browser can also keep searching on its own, always for one more zero. Only the chosen name and each name's best block per round are stored; there are no accounts. Choose a name on the board to open its profile: its rank in the last 144 blocks and all time, and every round it has a block in, each with Verify. The profile totals and best hash cover all retained rounds, even when only the newest 200 are listed; each round also shows its rank. Your personal bests opens the current name directly. Finished rounds lists up to 20 recent Bitcoin rounds outside the 144-block window, with the best retained visible name, hash and block count. Totals and winners describe retained entries: weak entries can be evicted by the existing 20,000-row cap. Share best find uses the browser share sheet, with text-and-link copying as a fallback; cancellation posts nothing. Names are shared public labels, not accounts. The profile has its own link (#/pool/<name>, Copy link) to share. If the server is unavailable the simulator keeps working. See ADR 0003.
Public names pass a moderation check: the server folds a name (case, accents, ß, digit look-alikes, separators) and refuses it if it contains a blocked term or equals a reserved one; an operator can hide a name later. The blocked terms in server/data/blocked-names.txt come from the English and German lists of List of Dirty, Naughty, Obscene, and Otherwise Bad Words by the LDNOOBW contributors, licensed under CC BY 4.0; this project adds German umlaut spellings when it loads them.
The server lives in server/ (Node 24, no runtime dependencies, SQLite). deploy/lab/ runs it as a self-contained Compose stack with its own Cloudflare tunnel; a merge to master goes live within minutes after the server tests pass on the host; see deploy/lab/README.md.
npm --prefix web run check
npm --prefix web run deploy:check
npm --prefix web run test:browser
npm --prefix web run test:webkit
npm --prefix web run preview
npm --prefix server testThe tests cover real mainnet transactions and Bitcoin Core signature hashes, invalid transaction values and pending balances, double spends, tampering, the Merkle root against real Bitcoin blocks, block acceptance, the search module (midstate digests, weighted races, stop and limits), wallet initialization, concurrent starts, stopped halving attempts, reset, setting changes and demo distribution. Browser and server hashing are pinned to the same vectors in shared/share-vectors.json. The server tests cover forged, expired, duplicate and tampered shares, rate limits and CORS. test:browser needs Google Chrome. It starts and stops a local preview, exercises the complete demo/transfer/tamper/reset flow and checks five viewport widths. Set BASE_URL to check an already running site instead; screenshots go to the OS temporary directory (override with SCREENSHOT_DIR). test:webkit checks layout in WebKit, the engine of Safari on iPhone, which Chrome does not show (run npx playwright install webkit once); today: the chain band blocks stand apart on a phone and on a desktop.
The deployment check builds the public files and runs a Cloudflare dry run; it does not publish anything or establish live-domain acceptance.
Workers Static Assets serves only web/dist; these are plain files any static host could serve. The simulator needs no database, application server or visitor accounts. The leaderboard API is served separately at api.proofofwork.fyi from the lab stack in deploy/lab/. Optional analytics uses only a public PostHog project ingestion token. The pinned project Wrangler configuration is in web/wrangler.jsonc; use the project scripts rather than a global Wrangler installation. _headers supplies a same-origin content policy and basic response headers. The app loads no remote fonts or tracking scripts. Usage event requests go only to PostHog EU; visitors can opt out.
The default environment uses the separate Worker proofofwork-fyi-preview and has no custom domain. The explicitly selected production environment binds proofofwork.fyi to the Worker proofofwork-fyi in the configured account.
A merge to master that changes the site publishes it automatically: after the web checks pass, the deploy job in .github/workflows/web.yml runs deploy:production, waits until the domain serves the new index.html and runs the browser acceptance against https://proofofwork.fyi (PostHog mocked, pool requests refused). It needs two repository secrets: CLOUDFLARE_API_TOKEN, a token limited to this account and the proofofwork.fyi zone with Workers Scripts, Workers Routes and Workers Observability edit plus the read permissions Wrangler uses; and VITE_POSTHOG_PROJECT_TOKEN, the public ingestion token of the proofofwork.fyi EU project. The live acceptance requires usage sharing to be available, so a build without the token fails.
For a manual release, verify the authenticated account, existing Worker and domain/DNS bindings first. A browser login and Wrangler authentication are separate. Authenticate through a local OAuth flow or a locally supplied scoped token; never put a token in Git or chat.
# Authenticated account read, then explicit production release:
npm --prefix web exec -- wrangler whoami
npm --prefix web run deploy:productionAfter publication, verify HTTPS, response headers, asset loading and the Start demo → transfer → mine → tamper → reset flow on the actual domain. A successful upload or local dry run alone does not verify the live site.
web/src/lib/tx/: Bitcoin transactions, scripts, the UTXO rules and the transaction view of the UI.web/src/lib/blockchain.js: participants, spending, the Merkle root, 80-byte block headers, candidate preparation and block acceptance, integrity checks.web/src/lib/powSearch.jsandpowSearch.worker.js: the one nonce search (midstate, weighted race, Web Worker adapter).web/src/hooks/useBlockchain.js: simulation state, miner race orchestration, cancellation and demo setup.web/src/components/PoolPanel.jsx,web/src/hooks/useMinerIdentity.js,web/src/lib/poolProof.jsandweb/src/lib/poolApi.js: the miner name, block proofs and the leaderboard view.server/: the leaderboard server;deploy/lab/: its Compose stack;shared/: block format and proof vectors used by both sides.web/src/components/HashLab.jsxandweb/src/lib/hashLab.js: the isolated hashing experiment.web/src/components/: wallets, chain explorer, controls and detail dialogs.web/src/__tests__/: deterministic lifecycle and model regression tests.- Root
main.js,blockchain.js,config.js: historical terminal implementation.
Set VITE_POSTHOG_PROJECT_TOKEN in web/.env.production.local or the build environment, using the public ingestion token from the dedicated proofofwork.fyi EU project. See web/.env.example. The build rejects personal/secret key formats. A Vite variable is public client configuration: never put management credentials there. Without configuration, the app still works and usage sharing stays unavailable.
Usage sharing is on by default (opt-out). A first visit shows a notice with a filled OK and a plain Opt out beside it and a link to the privacy policy in web/public/privacy.html; the same switch stays under Usage sharing in the menu. Browser privacy signals (GPC, Do Not Track) keep it off. The privacy policy gives a contact email and no postal address (Mirko, 2026-11-03); a unit test rejects its [[…]] placeholders. The preference is the only analytics value saved locally. A random visit identifier stays in memory and is replaced after opt-out; person profiles are disabled. There is no replay, DOM/form/clipboard autocapture, analytics script loading, persisted request queue, retry or unload beacon. Opt-out aborts pending requests and prevents subsequent sends; it cannot undo requests already received by PostHog. Offline/failed sends are dropped and never block the simulation.
The small transport uses the documented Capture API, including the manual exception schema. CSP permits only the EU ingestion host in addition to this site's own resources. Every event has a generated event ID, app/schema identifier and client timestamp. Allowed operation fields are defined and tested in web/src/lib/telemetry.js:
| Events | Permitted operation data |
|---|---|
app_opened |
No extra operation fields |
demo_started, demo_completed, demo_failed, demo_cancelled |
Difficulty, resulting block count or a fixed failure code |
mining_started, mining_completed, mining_cancelled, mining_failed |
Difficulty, elapsed milliseconds, attempt/miner/transaction counts, block height or a fixed reason |
transaction_created, transaction_rejected |
Source, count, pending count or a fixed failure code |
difficulty_changed, settings_changed |
Previous/current difficulty, fixed setting name, numeric/boolean/enumerated value |
tamper_applied, chain_reset |
Block height or previous mined-block count |
$exception |
Fixed source/type, generic message and positions in known loaded app assets |
Keys, seed words, wallet/participant names, transaction references, arbitrary error messages, query strings and other free-text bodies are not included. Mining summaries are emitted at operation boundaries, never for individual hashes or progress renders.
The browser check intercepts PostHog requests; it never sends test data to a real dataset. CI supplies a synthetic public token and requires the default-on notice, the opt-out and the opt-in path. To run that path locally:
VITE_POSTHOG_PROJECT_TOKEN=phc_syntheticproject EXPECT_POSTHOG=1 npm --prefix web run test:browserThis produces a test build. Before release, rebuild through the normal production command with the real public project token. Actual acceptance also requires locating a uniquely generated synthetic event in the correct EU project's stored events; a successful capture response alone is insufficient. Authentication and that live evidence remain separate from mock coverage and CI.
The manually dispatched Production acceptance workflow checks the fixed public domain from a hosted runner. Supply the checked release source and four asset SHA-256 digests as release_manifest, and only the SHA-256 fingerprint of the public ingestion token as token_fingerprint. Browser checks and expected headers come from that declared release commit, so newer unpublished features do not change its acceptance criteria. The workflow checks normal TLS, published bytes/headers and the full simulator/hash-lab flow. Explicitly enabling its synthetic probe then sends nine anonymous usage/error events; its artifact contains the exact event IDs for separate stored-data verification. It has no deployment permissions, management credentials or automatic production trigger. Anonymous test events have no person profile; resolve the synthetic error issue after checking it, and do not delete unrelated project data.