git / moonbite-network / templates/start.html · raw

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0B0D12">
<title>Mine your first block — MoonBite</title>
<meta name="description" content="No signup, no purchase, no wallet to install. An address is already waiting. Press start and a real block reward of {{ consensus.current_reward_coins|int }} MBITE is on the line.">
<link rel="icon" type="image/svg+xml" href="/static/moonbite-logo.svg">
<link rel="stylesheet" href="/static/fonts/moonbite-fonts.css">
<style>
/* Doc 04: three roles. A heavy grotesque for display, a quiet sans for
   reading, a monospace for anything that is a number.

   Archivo Black and Inter are self-hosted from /static/fonts (latin subset,
   OFL-licensed). They are deliberately not loaded from Google Fonts: /privacy
   states the site loads nothing that phones home, and a webfont CDN sends
   every visitor's IP to Google on every page load. Every figure that is
   money, a hash, an address or a block height is monospace with tabular
   figures — amounts that shift width as they update look untrustworthy. */
:root{
  --font-display:'Archivo Black','Helvetica Neue','Arial Black',sans-serif;
  --font-body:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
  --font-data:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
h1,h2,.big,.reveal .big,.balance-amount,.elapsed,.mort-big{
  font-family:var(--font-display); font-weight:400; letter-spacing:-.02em;
}
.num,.mono,.addr,.cert .amt,.cl .v,.tot .v,.mcell .v,.odds .v,
.loss .v,.count .v,.zero-band .v,.total .v,.vault-v,td.n,th.n{
  font-family:var(--font-data); font-variant-numeric:tabular-nums;
}

* { margin:0; padding:0; box-sizing:border-box; }
body { background:#0B0D12; color:#F5F2EA;
  font-family:var(--font-body);
  overflow-x:hidden; -webkit-font-smoothing:antialiased; min-height:100svh; }
::selection { background:#D9A441; color:#0B0D12; }
a,button { -webkit-tap-highlight-color:transparent; font-family:inherit; }
#sky { position:fixed; inset:0; z-index:0; }
.wrap { position:relative; z-index:1; max-width:520px; margin:0 auto; padding:0 18px 60px; }

.kicker { letter-spacing:.3em; font-size:.64rem; color:#D9A441; text-transform:uppercase;
  font-weight:800; }
.num { font-variant-numeric:tabular-nums; }
.hidden { display:none !important; }
h1 { font-size:clamp(2rem,8vw,3rem); font-weight:800; line-height:1.05;
  letter-spacing:-.02em; margin:12px 0 10px; }
.sub { color:#8A8580; font-size:.98rem; line-height:1.6; max-width:420px; margin:0 auto; }
.step { text-align:center; padding-top:5vh; }

.btn { display:inline-flex; align-items:center; justify-content:center; gap:9px; border:none;
  border-radius:10px; padding:17px 30px; min-height:52px; font-weight:800; font-size:1rem;
  cursor:pointer; text-decoration:none; transition:transform .15s ease; }
.btn:active { transform:scale(.97); }
.btn-gold { background:#D9A441; color:#0B0D12; box-shadow:0 12px 38px rgba(217,164,65,.3); }
.btn-gold:disabled { opacity:.45; box-shadow:none; }
.btn-ghost { background:transparent; color:#F5F2EA; border:1px solid rgba(245,242,234,.22); }
.actions { display:flex; gap:11px; justify-content:center; flex-wrap:wrap; margin-top:22px; }

/* ---------------------------------------------------------- ADDRESS card */
.acard { background:#161A22; border:1px solid rgba(217,164,65,.22); border-radius:20px;
  padding:20px 18px; margin-top:26px; text-align:left;
  box-shadow:0 20px 60px rgba(0,0,0,.45); }
.acard .lbl { font-size:.6rem; letter-spacing:.22em; color:#8A8580; text-transform:uppercase;
  font-weight:800; display:flex; align-items:center; gap:8px; }
.acard .lbl .dot { width:6px; height:6px; border-radius:50%; background:#2E9E6B; }
.addr { font-family:ui-monospace,Menlo,Consolas,monospace; font-size:.82rem; color:#F5F2EA;
  word-break:break-all; line-height:1.65; margin-top:11px; }
.acopy { margin-top:13px; background:transparent; border:1px solid rgba(217,164,65,.4);
  color:#D9A441; border-radius:10px; padding:11px 16px; min-height:44px; font-weight:800;
  font-size:.75rem; letter-spacing:.1em; cursor:pointer; width:100%; }
.anote { color:#8A8580; font-size:.78rem; line-height:1.6; margin-top:12px; }

/* prize strip */
.prize { display:flex; align-items:center; justify-content:center; gap:14px; margin-top:24px;
  padding:16px 18px; border-radius:20px;
  background:linear-gradient(135deg, rgba(217,164,65,.14), rgba(184,134,59,.06));
  border:1px solid rgba(217,164,65,.3); }
.prize .v { font-size:clamp(1.7rem,7vw,2.3rem); font-weight:800; color:#D9A441; line-height:1; }
.prize .k { font-size:.62rem; letter-spacing:.18em; color:#8A8580; text-transform:uppercase;
  font-weight:800; margin-top:5px; text-align:left; }
.prize .sep { width:1px; align-self:stretch; background:rgba(217,164,65,.25); }

/* -------------------------------------------------------- MINING screen */
.hunt-wrap { width:min(56vw,220px); aspect-ratio:1; margin:2vh auto 0; position:relative; }
#hunt { width:100%; height:100%; display:block; }
.elapsed { font-size:clamp(2.6rem,13vw,4rem); font-weight:800; color:#F5F2EA; line-height:1;
  letter-spacing:-.02em; margin-top:6px; }
.elapsed-k { font-size:.6rem; letter-spacing:.26em; color:#8A8580; text-transform:uppercase;
  font-weight:800; margin-top:9px; }
.odds { background:#161A22; border:1px solid rgba(200,64,47,.28); border-radius:20px;
  padding:18px 16px; margin-top:22px; }
.odds .k { font-size:.6rem; letter-spacing:.2em; color:#8A8580; text-transform:uppercase;
  font-weight:800; }
.odds .v { font-size:clamp(1.15rem,5.2vw,1.6rem); font-weight:800; color:#C8402F;
  margin-top:10px; line-height:1.1; letter-spacing:-.01em; }
.odds .vsub { font-size:.8rem; color:#F5F2EA; margin-top:6px; font-weight:600; }
.odds .n { font-size:.76rem; color:#8A8580; margin-top:9px; line-height:1.55; }
.mgrid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:9px; margin-top:14px; }
.mcell { background:#161A22; border:1px solid rgba(245,242,234,.08); border-radius:10px;
  padding:13px 8px; }
.mcell .v { font-size:clamp(.95rem,3.8vw,1.2rem); font-weight:800; color:#D9A441; line-height:1.1; }
.mcell .k { font-size:.52rem; letter-spacing:.14em; color:#8A8580; text-transform:uppercase;
  margin-top:5px; font-weight:700; }
.whisper { color:#8A8580; font-size:.86rem; line-height:1.65; margin-top:20px; min-height:3.3em;
  transition:opacity .45s; padding:0 4px; }
.stopwrap { margin-top:18px; }
.stopbtn { background:none; border:none; color:#8A8580; font-size:.76rem; letter-spacing:.14em;
  text-transform:uppercase; font-weight:700; cursor:pointer; padding:12px 18px; min-height:44px; }

/* ---------------------------------------------------------- DONE screen */
.dcard { background:linear-gradient(160deg,#161A22,#0B0D12); border:1px solid rgba(217,164,65,.35);
  border-radius:20px; padding:26px 20px; margin-top:24px;
  box-shadow:0 24px 70px rgba(0,0,0,.5); }
.dcard .amt { font-size:clamp(2.4rem,11vw,3.4rem); font-weight:800; color:#D9A441; line-height:1; }
.dcard .meta { color:#8A8580; font-size:.86rem; margin-top:10px; }
.badgerow { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:16px; }
.bpill { border:1px solid rgba(217,164,65,.5); background:rgba(217,164,65,.1); color:#D9A441;
  border-radius:999px; padding:7px 13px; font-size:.75rem; font-weight:800; }
.nextrow { display:flex; flex-direction:column; gap:10px; margin-top:22px; }
.nextcard { display:flex; align-items:center; gap:14px; text-decoration:none; text-align:left;
  background:#161A22; border:1px solid rgba(217,164,65,.2); border-radius:10px; padding:16px 16px; }
.nextcard .ic { font-size:1.5rem; }
.nextcard b { color:#F5F2EA; font-size:.95rem; display:block; }
.nextcard span { color:#8A8580; font-size:.79rem; }
.nextcard .ar { margin-left:auto; color:#D9A441; font-weight:800; }

.toast { position:fixed; left:50%; bottom:26px; transform:translateX(-50%) translateY(16px);
  background:#2E9E6B; color:#04160E; font-weight:800; padding:12px 22px; border-radius:10px;
  opacity:0; transition:all .3s; z-index:60; pointer-events:none; font-size:.88rem; }
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }

/* first-block ceremony (self-contained mirror of site.css) */
.mb-first-block-overlay { position:fixed; inset:0; z-index:999; display:flex; align-items:center;
  justify-content:center; background:rgba(5,8,20,.93); }
.mb-confetti { position:absolute; inset:0; pointer-events:none; }
.mb-first-block-card { position:relative; max-width:400px; width:calc(100% - 36px); text-align:center;
  padding:34px 26px; border-radius:20px; background:linear-gradient(180deg,#161A22,#0B0D12);
  border:1px solid rgba(217,164,65,.42); box-shadow:0 0 70px rgba(217,164,65,.16);
  animation:mbFbPop .55s cubic-bezier(.2,1.6,.4,1); }
@keyframes mbFbPop { from{transform:scale(.72);opacity:0} to{transform:scale(1);opacity:1} }
.mb-fb-kicker { letter-spacing:.25em; font-size:.72rem; color:#2E9E6B; font-weight:800; }
.mb-fb-amount { font-size:2.9rem; font-weight:800; margin:10px 0 4px; color:#D9A441; }
.mb-fb-line { color:rgba(245,242,234,.75); font-size:.93rem; margin-top:4px; }
.mb-fb-addr { font-family:monospace; font-size:.82rem; opacity:.6; }
.mb-fb-badges { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin:16px 0 6px; }
.mb-fb-badge { padding:6px 12px; border-radius:999px; font-size:.78rem; font-weight:800;
  border:1px solid rgba(217,164,65,.5); background:rgba(217,164,65,.1); color:#D9A441; }
.mb-fb-quote { font-style:italic; color:rgba(245,242,234,.6); font-size:.89rem; margin:12px 0 18px; }
.mb-fb-actions { display:flex; gap:10px; justify-content:center; flex-wrap:wrap; }
.mb-fb-btn { padding:13px 20px; min-height:44px; border-radius:10px; cursor:pointer; font-weight:800;
  border:1px solid rgba(217,164,65,.6); color:#0B0D12; background:#D9A441; font-family:inherit; }
.mb-fb-btn.ghost { background:transparent; color:#F5F2EA; border-color:rgba(245,242,234,.3); }
.mb-badge-toast { position:fixed; left:50%; bottom:40px; transform:translateX(-50%);
  background:#D9A441; color:#0B0D12; font-weight:800; padding:12px 22px; border-radius:10px; z-index:1000; }
@media (prefers-reduced-motion: reduce){ .mb-first-block-card { animation:none; } }
/* Doc 17: visible keyboard focus on every interactive element. */
a:focus-visible, button:focus-visible, input:focus-visible,
select:focus-visible, [tabindex]:focus-visible {
  outline:2px solid #D9A441; outline-offset:3px; border-radius:6px;
}
</style>
</head>
<body>
<canvas id="sky"></canvas>
{% include 'partials/nav.html' %}

<div class="wrap">

  <!-- ========================================================= 1. READY -->
  <section class="step" id="sReady">
    <div class="kicker">STEP ONE OF ONE</div>
    <h1>Mine your<br>first block.</h1>
    <p class="sub">No signup, no purchase, nothing to install. An address is already
      waiting for you below. Press start and it goes to work.</p>

    <div class="prize">
      <div>
        <div class="v num">{{ consensus.current_reward_coins|int }}</div>
        <div class="k">MBITE at stake</div>
      </div>
      <div class="sep"></div>
      <div>
        <div class="v num">~{{ consensus.block_time_min }}<span style="font-size:.55em;">min</span></div>
        <div class="k">Average hunt</div>
      </div>
    </div>

    <div class="acard">
      <div class="lbl"><span class="dot"></span>YOUR MINING ADDRESS</div>
      <div class="addr" id="addr">generating…</div>
      <button class="acopy" id="btnCopy">COPY ADDRESS</button>
      <p class="anote">Stored in this browser only — we never see it. Any block you find
        pays here, and this address is the only claim to it.</p>
    </div>

    <div class="actions">
      <button class="btn btn-gold" id="btnStart" disabled>START MINING</button>
    </div>
    <p class="sub" style="margin-top:14px; font-size:.78rem;">
      RandomX is genuine proof-of-work. The difficulty is the point.</p>
  </section>

  <!-- ======================================================== 2. MINING -->
  <section class="step hidden" id="sMining">
    <div class="hunt-wrap"><canvas id="hunt"></canvas></div>
    <div class="kicker" style="color:#2E9E6B;">● HUNTING FOR A BLOCK</div>
    <div class="elapsed num" id="mElapsed">0:00</div>
    <div class="elapsed-k">TIME SPENT SEARCHING</div>

    <div class="odds">
      <div class="k">WHAT YOUR NODE IS UP AGAINST</div>
      <div class="v num" id="oOdds">—</div>
      <div class="vsub">hashes, on average, for one winning block</div>
      <div class="n">There is no progress bar for mining. Every attempt is independent —
        the next one is as likely as the first. That is what makes it fair.</div>
    </div>

    <div class="mgrid">
      <div class="mcell"><div class="v num" id="mReward">—</div><div class="k">Reward</div></div>
      <div class="mcell"><div class="v num" id="mDiff">—</div><div class="k">Difficulty</div></div>
      <div class="mcell"><div class="v num" id="mHeight">—</div><div class="k">Height</div></div>
    </div>

    <p class="whisper" id="whisper">Your node is filling memory with a randomly generated
      program and running it. That is what makes an ASIC pointless here.</p>

    <div class="stopwrap"><button class="stopbtn" id="btnStop">Stop mining</button></div>
    <p class="sub" style="margin-top:8px; font-size:.75rem;">Keep this tab open — the search
      continues whether or not you watch.</p>
  </section>

  <!-- ========================================================== 3. DONE -->
  <section class="step hidden" id="sDone">
    <div class="kicker">YOU ARE A MINER NOW</div>
    <h1>Block found.</h1>
    <div class="dcard">
      <div class="amt num" id="dAmt">+{{ consensus.current_reward_coins|int }} MBITE</div>
      <div class="meta" id="dMeta">—</div>
      <div class="badgerow" id="dBadges"></div>
    </div>
    <div class="nextrow">
      <a class="nextcard" href="/world-cup">
        <div class="ic"></div>
        <div><b>Claim your country</b><span>Put your flag on the world board</span></div>
        <div class="ar">→</div>
      </a>
      <a class="nextcard" href="/calculator">
        <div class="ic"></div>
        <div><b>What it's worth being early</b><span>Run the numbers on your machine</span></div>
        <div class="ar">→</div>
      </a>
      <a class="nextcard" href="#" id="againCard">
        <div class="ic"></div>
        <div><b>Mine another block</b><span>Same address, same odds</span></div>
        <div class="ar">→</div>
      </a>
    </div>
  </section>
</div>

{% include 'partials/footer.html' %}
<div class="toast" id="toast"></div>

<script src="/static/moonbite-badges.js"></script>
<script nonce="{{ csp_nonce }}">
(function () {
  'use strict';
  var CONS = {
    reward: {{ consensus.current_reward_coins }},
    blockMin: {{ consensus.block_time_min }}
  };
  var addr = null, poller = null, ticker = null, started = 0, busy = false, baseHeight = null;
  /* A freshly generated address has provably mined nothing, so its baseline is
     zero. Sampling it on the first poll would race a fast node that already
     found the block and swallow the ceremony. */
  var baseBlocks = 0, mining = false;

  function $(id){ return document.getElementById(id); }
function shortAddr(a){
    /* Doc 18: first and last six characters are always visible, so a miner can
       verify an address by eye. Never shorten past that. */
    if (!a) return '';
    return a.length > 14 ? a.slice(0,6) + '…' + a.slice(-6) : a;
  }
  function fmt(n){ return Math.round(n).toLocaleString('en-US'); }
  /* Doc 11: block heights are never thousands-separated — only amounts are. */
  function ht(n){ return String(Math.round(n||0)); }
  /* clipboard.writeText rejects on insecure contexts, denied permission and
     some browsers; an uncaught rejection surfaces as a console error. */
  function copyText(s){
    try {
      if (navigator.clipboard && navigator.clipboard.writeText)
        return navigator.clipboard.writeText(s).catch(function(){});
    } catch(e){}
    return null;
  }

  function show(id){ ['sReady','sMining','sDone'].forEach(function(s){
    $(s).classList.toggle('hidden', s !== id); }); }
  function toast(m){ var t=$('toast'); t.textContent=m; t.classList.add('show');
    setTimeout(function(){ t.classList.remove('show'); }, 2400); }
  function clock(sec){
    sec = Math.max(0, Math.round(sec));
    var m = Math.floor(sec/60), s = sec%60;
    if (m < 60) return m + ':' + (s<10?'0':'') + s;
    return Math.floor(m/60) + ':' + (m%60<10?'0':'') + (m%60) + ':' + (s<10?'0':'') + s;
  }

  /* ------------------------------------------------------------- sky */
  var sky=$('sky'), sg=sky.getContext('2d'), W,H,stars=[];
  var DPR=Math.min(window.devicePixelRatio||1,2);
  var reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  function resize(){
    W=innerWidth; H=innerHeight;
    sky.width=W*DPR; sky.height=H*DPR; sky.style.width=W+'px'; sky.style.height=H+'px';
    sg.setTransform(DPR,0,0,DPR,0,0); stars=[];
    for(var i=0,n=Math.min(150,(W*H/9500)|0); i<n; i++)
      stars.push({x:Math.random()*W,y:Math.random()*H,r:Math.random()*1.1+.25,tw:Math.random()*6.28});
  }
  addEventListener('resize',resize); resize();
  var st=0;
  (function loop(){
    st += reduced?0:(mining?.05:.008);
    sg.fillStyle='#0B0D12'; sg.fillRect(0,0,W,H);
    for(var i=0;i<stars.length;i++){ var s=stars[i];
      sg.globalAlpha=.16+.34*Math.abs(Math.sin(st+s.tw));
      sg.fillStyle = mining ? '#D9A441' : '#F5F2EA';
      sg.beginPath(); sg.arc(s.x,s.y,s.r,0,6.2832); sg.fill(); }
    sg.globalAlpha=1; requestAnimationFrame(loop);
  })();

  /* ------------------------------------------------- the hunting mark */
  /* Renamed from ht: it shadowed the ht() height formatter declared above,
     so by the time the miner polled, ht was a number and ht(height) threw —
     which aborted the poll handler before it could ever detect a found
     block, leaving the page hunting forever. */
  var hunt=$('hunt'), hg=hunt.getContext('2d'), huntT=0;
  if (!CanvasRenderingContext2D.prototype.roundRect) {
    CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
      r=Math.min(r,w/2,h/2); this.moveTo(x+r,y);
      this.arcTo(x+w,y,x+w,y+h,r); this.arcTo(x+w,y+h,x,y+h,r);
      this.arcTo(x,y+h,x,y,r); this.arcTo(x,y,x+w,y,r); this.closePath(); return this; };
  }
  (function drawHunt(){
    huntT += reduced?0:.03;
    var S=hunt.clientWidth;
    if(S>0 && hunt.width!==S*DPR){ hunt.width=S*DPR; hunt.height=S*DPR; }
    hg.setTransform(DPR,0,0,DPR,0,0); hg.clearRect(0,0,S,S);
    var c=S/2, r=S*.29;
    /* searching sweep — a radar, not a progress bar: it never fills */
    var sweepA = huntT % 6.2832;
    var grad = hg.createConicGradient ? null : null;
    hg.save();
    hg.strokeStyle='rgba(217,164,65,.75)'; hg.lineWidth=Math.max(2,S*.014); hg.lineCap='round';
    hg.beginPath(); hg.arc(c,c,S*.44, sweepA, sweepA+1.0); hg.stroke();
    hg.strokeStyle='rgba(245,242,234,.07)'; hg.lineWidth=Math.max(2,S*.014);
    hg.beginPath(); hg.arc(c,c,S*.44,0,6.2832); hg.stroke();
    hg.restore();
    /* glow pulse */
    var pulse = .5+.5*Math.sin(huntT*1.6);
    var gr=hg.createRadialGradient(c,c,r*.5,c,c,r*1.9);
    gr.addColorStop(0,'rgba(217,164,65,'+(.10+.10*pulse)+')');
    gr.addColorStop(1,'rgba(217,164,65,0)');
    hg.fillStyle=gr; hg.beginPath(); hg.arc(c,c,r*1.9,0,6.2832); hg.fill();
    /* the mark */
    var bg=hg.createRadialGradient(c-r*.35,c-r*.35,r*.1,c,c,r);
    bg.addColorStop(0,'#FFFFFF'); bg.addColorStop(.6,'#F5F2EA'); bg.addColorStop(1,'#CFC9BB');
    hg.fillStyle=bg; hg.beginPath(); hg.arc(c,c,r,0,6.2832); hg.fill();
    hg.fillStyle='#0B0D12';
    hg.beginPath(); hg.arc(c+r*.55,c-r*.75,r*.78,0,6.2832); hg.fill();
    var bh=Math.max(3,r*.13);
    hg.fillStyle='#D9A441';
    hg.beginPath(); hg.roundRect(c-r*2.9/2,c-r*.33-bh/2,r*2.9,bh,bh/2); hg.fill();
    hg.beginPath(); hg.roundRect(c-r*2.7/2,c+r*.22-bh/2,r*2.7,bh,bh/2); hg.fill();
    requestAnimationFrame(drawHunt);
  })();

  /* ------------------------------------------------------- whispers */
  var WHISPERS = [
    'Your node is filling memory with a randomly generated program and running it. That is what makes an ASIC pointless here.',
    'Nobody can pay to skip this queue. The only way past it is electricity and time.',
    'Every hash is an independent lottery ticket. Two minutes in, the next one is still as likely as the first.',
    'A block found now pays ' + CONS.reward + ' MBITE. After the next halving it pays half that — forever.',
    'This is the same work securing the chain. You are not simulating mining; you are mining.'
  ];
  var wi=0;
  setInterval(function(){
    if(!mining) return;
    var el=$('whisper'); el.style.opacity=0;
    setTimeout(function(){ wi=(wi+1)%WHISPERS.length; el.textContent=WHISPERS[wi]; el.style.opacity=1; },450);
  }, 9000);

  /* -------------------------------------------------------- address */
  fetch('/api/wallet/new').then(function(r){return r.json();}).then(function(j){
    if(!j.address) throw 0;
    addr=j.address; $('addr').textContent=addr; $('btnStart').disabled=false;
    try {
      localStorage.setItem('mb_start_address', addr);
      var saved=JSON.parse(sessionStorage.getItem('addresses')||'[]');
      saved.unshift({address:addr}); sessionStorage.setItem('addresses',JSON.stringify(saved));
    } catch(e){}
  }).catch(function(){ $('addr').textContent='Could not reach the node — reload to retry.'; });

  $('btnCopy').addEventListener('click', function(){
    if(addr) copyText(addr);
    toast('Address copied');
  });

  /* --------------------------------------------------------- mining */
  /* Attribution comes from the chain-derived leaderboard, not the server's
     aggregate counter: several people may mine this node at once, and the
     aggregate would credit everyone with everyone else's blocks. */
  function myBlocks(d){
    var lb=d.leaderboard||[];
    for(var i=0;i<lb.length;i++) if(lb[i].address===addr) return lb[i].blocks||0;
    return 0;
  }

  function poll(){
    if(busy) return; busy=true;
    fetch('/api/mining/status?include=leaderboard').then(function(r){return r.json();})
      .then(function(d){
        busy=false;
        if(!d || d.status==='error') return;

        /* Each field is written independently. A single throw here used to
           abort the whole handler before the completion check ever ran, and
           the bare catch below hid it — the page then hunted forever while the
           node had already mined the block. */
        try {
          if(d.difficulty){
            $('mDiff').textContent = fmt(d.difficulty);
            $('oOdds').textContent = fmt(d.difficulty*4294967296);
          }
          if(d.current_height!=null) $('mHeight').textContent = ht(d.current_height);
          $('mReward').textContent = (d.next_block_reward_coins||CONS.reward);
        } catch(e){ if(window.console) console.warn('stat render failed', e); }

        var mine = 0;
        try { mine = myBlocks(d); } catch(e){ mine = 0; }
        if(mine>baseBlocks){ finish(mine-baseBlocks, d.current_height); return; }

        /* The leaderboard is the primary signal but needs the coinbase
           indexed, so it can lag behind the block. Only this address's own
           balance is checked as a backstop — a global signal like the chain
           height or the node's blocks_mined would credit this miner for
           somebody else's block. */
        if(d.status!=='mining' && !d.mining){
          confirmEarned(d.current_height);
        }
      }).catch(function(e){
        busy=false;
        if(window.console) console.warn('mining poll failed', e);
      });
  }

  /* The job has stopped. Decide between "found one" and "nothing yet" by
     asking what this address actually holds, rather than trusting a signal
     that could belong to another miner. */
  function confirmEarned(height){
    if(!addr){ stop(false); return; }
    fetch('/api/address/'+encodeURIComponent(addr)+'/balance')
      .then(function(r){ return r.json(); })
      .then(function(b){
        if(b && b.status==='success' && (b.balance_units||0) > 0){ finish(1, height); }
        else { stop(false); }
      })
      .catch(function(){ stop(false); });
  }

  function finish(delta, height){
    mining=false;
    clearInterval(poller); clearInterval(ticker);
    fetch('/api/mining/stop').catch(function(){});
    var reward = CONS.reward * delta;
    $('dAmt').textContent = '+' + reward + ' MBITE';
    $('dMeta').textContent = 'Block #' + ht(height) + ' · paid to ' +
      shortAddr(addr);
    if(window.MoonBiteBadges){
      try { MoonBiteBadges.recordBlocks(delta, height, addr); } catch(e){}
      try {
        var prof=JSON.parse(localStorage.getItem('mb_miner_profiles')||'{}')[addr];
        if(prof && prof.badges){
          var NAMES={first_bite:' First Bite',genesis:' Genesis Miner',pioneer:'Pioneer',
            pre_halving:'Pre-Halving',ten:'Ten Bites',hundred:' Hundred Bites',
            week_streak:' Seven Moons'};
          $('dBadges').innerHTML = prof.badges.map(function(b){
            return '<span class="bpill">'+(NAMES[b]||b)+'</span>'; }).join('');
        }
      } catch(e){}
    }
    show('sDone');
  }

  function stop(found){
    mining=false;
    if(poller){ clearInterval(poller); poller=null; }
    if(ticker){ clearInterval(ticker); ticker=null; }
    fetch('/api/mining/stop').catch(function(){});
    if(!found){ show('sReady'); $('btnStart').disabled=false;
      $('btnStart').innerHTML='START MINING'; }
  }

  function begin(){
    if(!addr) return;
    var b=$('btnStart'); b.disabled=true; b.textContent='STARTING…';
    fetch('/api/mining/start',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({blocks:1,address:addr})})
      .then(function(r){return r.json();}).then(function(d){
        if(d.status!=='mining') throw new Error(d.message||'start failed');
        mining=true; started=Date.now(); baseBlocks=0; baseHeight=null;
        show('sMining'); poll();
        poller=setInterval(poll,2000);
        /* elapsed ticks locally every second — the one number measured here */
        ticker=setInterval(function(){
          if(mining) $('mElapsed').textContent=clock((Date.now()-started)/1000);
        },1000);
      }).catch(function(){
        b.disabled=false; b.innerHTML='START MINING';
        toast('Could not start mining. Try again.');
      });
  }

  $('btnStart').addEventListener('click', begin);
  $('btnStop').addEventListener('click', function(){ stop(false); });
  $('againCard').addEventListener('click', function(e){ e.preventDefault(); begin(); });
})();
</script>
</body>
</html>