git / moonbite-network / static/wallet-flagship.js · raw

/* MoonBite flagship wallet — app logic. * External module (CSP: script-src 'self'); no inline handlers. Reuses the * audited crypto modules for all key operations. */import { generatePhrase, validateMnemonic } from './moonbite-phrase.js?v=20261017a';import { deriveFromSeedPhrase, isValidAddress, packSecret, unpackSecret } from './moonbite-address.js?v=20261016e';import { encryptSeed, decryptSeed, isNewFormat, encryptSeedWithPrf, decryptSeedWithPrf } from './moonbite-crypto.js?v=20261017a';import { buildSignedTransaction, buildSweepTransaction, planSpend, maxSendable, feeFor, DUST_UNITS } from './moonbite-tx.js?v=20261016e';import { qrMatrix } from './vendor/qr.js?v=20261017a';import { createPinGuard } from './moonbite-pin-guard.js?v=20261017a';const $ = s => document.querySelector(s);const LS = 'mbf_seed_enc';const UNIT = 1e8;// Fees are priced by transaction size (moonbite-tx.js feeFor): a normal send// pays 0.00001 MBITE. spendUtxos caches this address's spendable coins (public// chain data) so the fee shown before sending is the fee that is paid.let spendUtxos = [];// Display currency: the native coin or its smallest indivisible unit. There is// no fiat option because MBITE has no market price (price_feed.py returns// "unpriced") — showing a currency value would be fabricated.let displayUnit = 'mbite';try{ displayUnit = localStorage.getItem('mbf_display_unit') || 'mbite'; }catch(e){}function unitLabel(){ return displayUnit === 'units' ? 'units' : 'MBITE'; }function toBaseUnits(inputVal){  const n = parseFloat(inputVal) || 0;  return displayUnit === 'units' ? Math.round(n) : Math.round(n * UNIT);}/* ---------- theme (dark / light / system) ---------- */let themePref = 'dark';try{ themePref = localStorage.getItem('mbf_theme') || 'dark'; }catch(e){}const _mqLight = window.matchMedia('(prefers-color-scheme: light)');function resolvedTheme(){ return themePref === 'system' ? (_mqLight.matches ? 'light' : 'dark') : themePref; }function applyTheme(){ document.documentElement.setAttribute('data-theme', resolvedTheme()); updateThemeSeg(); }function updateThemeSeg(){  document.querySelectorAll('#themeSeg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.arg === themePref));}function setTheme(t){  themePref = ['dark','light','system'].includes(t) ? t : 'dark';  try{ localStorage.setItem('mbf_theme', themePref); }catch(e){}  applyTheme();}try{ _mqLight.addEventListener('change', () => { if(themePref === 'system') applyTheme(); }); }catch(e){}let seedPhrase = null;      // in-memory only, after create/unlock. The wallet secret:                            // the phrase, or phrase + extra password packed (moonbite-address.js)let _createPhrase = null;   // plain words while creating, so the password can be (re)applied/* ---------- PIN guard (M8): backoff after 3 failures, wipe after 10) ---------- * One counter shared by unlock, reveal-phrase and change-PIN, since they all * verify the same PIN against the same encrypted blob. */const BIO_LS = 'mbf_bio';// After "Move to a new phrase": the previous phrase (encrypted under the same// PIN), its address, and the outpoints already swept from it. Kept only until// every coin on the old address has moved, so maturing rewards aren't stranded.const OLD_LS = 'mbf_old_seed_enc', OLD_ADDR = 'mbf_old_addr', OLD_SPENT = 'mbf_old_spent';const pinGuard = createPinGuard({  storageKey: 'mbf_pin_guard',  wipeKeys: [LS, BIO_LS, OLD_LS],  onWipe: () => { seedPhrase = null; wallet = null; },});let _guardTimer = null;/* If the guard is blocking, show the wait message (with a live countdown) and * return true so the caller skips the decrypt. */function guardBlocked(errSel, dotsSel){  const st = pinGuard.check();  if(st.allowed){ return false; }  const paint = () => {    const cur = pinGuard.check();    if(cur.allowed){ clearInterval(_guardTimer); _guardTimer = null; $(errSel).textContent = ''; return; }    $(errSel).textContent = pinGuard.message(cur);  };  paint(); dots($(dotsSel), 0);  clearInterval(_guardTimer); _guardTimer = setInterval(paint, 1000);  return true;}/* Record a failure, show the right message, and route to restore on wipe. */function guardFail(errSel, dotsSel){  const r = pinGuard.fail();  if(r.wiped){ clearInterval(_guardTimer); showWiped(); return; }  if(r.waitMs > 0){ guardBlocked(errSel, dotsSel); return; }  $(errSel).textContent = pinGuard.message(r);  setTimeout(() => dots($(dotsSel), 0), 300);}function showWiped(){  go('s-import');  const err = $('#importErr');  if(err) err.textContent = pinGuard.message({ wiped: true });}let wallet = null;          // { address }let balanceUnits = 0;   // spendable nowlet totalUnits = 0;     // spendable + mining rewards still maturinglet _balInit = false;       // guards first balance read from logging a fake receivelet seedSource = 'create';  // 'create' | 'import' (for PIN back button)let pinBuf = '', pinStage = 'first', firstPin = '';/* ---------- navigation ---------- */const TAB_SCREENS = ['s-home', 's-activity', 's-settings'];function go(id){  document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));  const el = $('#'+id); el.classList.add('active'); el.scrollTop = 0;  updateNav(id);  if(id === 's-settings') fillSettings();  if(id === 's-activity'){ renderActivity(); refreshConfirmations(); }}function updateNav(id){  const bar = $('#tabbar'); const show = TAB_SCREENS.includes(id);  bar.hidden = !show;  bar.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.arg === id));}function toast(msg){  $('#toastMsg').textContent = msg; const t = $('#toast');  t.classList.add('show'); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('show'), 1800);}/* ---------- onboarding ---------- */async function startCreate(){  const phrase = await generatePhrase();  seedPhrase = phrase; _createPhrase = phrase; seedSource = 'create';  ['#createPass', '#createPass2'].forEach(id => { const el = $(id); if(el) el.value = ''; });  const adv = $('#createAdv'); if(adv) adv.open = false;  const ce = $('#createErr'); if(ce) ce.textContent = '';  const words = phrase.split(/\s+/);  $('#seedGrid').innerHTML = words.map((w,i) => `<div class="word"><i>${i+1}</i><b>${w}</b></div>`).join('');  go('s-create');}async function doImport(){  const val = $('#importInput').value.trim().replace(/\s+/g,' ');  const err = $('#importErr'); err.textContent = '';  if(!(await validateMnemonic(val))){ err.textContent = 'That is not a valid 12-word recovery phrase.'; return; }  const pass = ($('#importPass') || {}).value || '';  seedPhrase = packSecret(val, pass); seedSource = 'import'; startPinSet();  const ip = $('#importPass'); if(ip) ip.value = '';}/* Create: optional extra password, checked twice, folded into the secret. */function createContinue(){  const p1 = ($('#createPass') || {}).value || '', p2 = ($('#createPass2') || {}).value || '';  const err = $('#createErr'); if(err) err.textContent = '';  if(p1 !== p2){ if(err) err.textContent = 'The two passwords don’t match.'; return; }  if(!_createPhrase) return;  seedPhrase = packSecret(_createPhrase, p1);  startPinSet();}function pinBack(){ go(seedSource === 'create' ? 's-create' : 's-import'); }/* ---------- PIN pads ---------- */function buildPad(el, onKey){  const keys = ['1','2','3','4','5','6','7','8','9','','0','del'];  el.innerHTML = keys.map(k => {    if(k === '') return '<div class="key ghost"></div>';    if(k === 'del') return '<button class="key" data-k="del"><svg class="icon"><use href="#i-del"/></svg></button>';    return `<button class="key" data-k="${k}">${k}</button>`;  }).join('');  el.querySelectorAll('.key[data-k]').forEach(b => b.addEventListener('click', () => onKey(b.dataset.k)));}function dots(el, n){ el.innerHTML = Array.from({length:6}, (_,i) => `<i class="${i<n?'on':''}"></i>`).join(''); }function startPinSet(){  pinBuf = ''; firstPin = ''; pinStage = 'first';  $('#pinSetTitle').textContent = 'Create a 6-digit PIN';  $('#pinSetHint').textContent = 'It encrypts your phrase on this device.';  $('#pinErr').textContent = ''; dots($('#setDots'), 0);  buildPad($('#setPad'), onSetKey);  go('s-pin-set');}function onSetKey(k){  if(k === 'del'){ pinBuf = pinBuf.slice(0,-1); dots($('#setDots'), pinBuf.length); return; }  if(pinBuf.length >= 6) return;  pinBuf += k; dots($('#setDots'), pinBuf.length);  if(pinBuf.length < 6) return;  if(pinStage === 'first'){    firstPin = pinBuf; pinBuf = ''; pinStage = 'confirm';    setTimeout(() => { $('#pinSetTitle').textContent = 'Confirm your PIN';      $('#pinSetHint').textContent = 'Enter it once more.'; dots($('#setDots'), 0); }, 160);  } else {    if(pinBuf !== firstPin){      $('#pinErr').textContent = 'PINs didn’t match — try again.';      pinBuf = ''; firstPin = ''; pinStage = 'first';      setTimeout(() => { $('#pinSetTitle').textContent = 'Create a 6-digit PIN';        $('#pinSetHint').textContent = 'It encrypts your phrase on this device.'; dots($('#setDots'), 0); }, 160);      return;    }    finishSetup(firstPin);  }}async function finishSetup(pin){  const env = await encryptSeed(seedPhrase, pin);  localStorage.setItem(LS, env);  pinGuard.reset();  await deriveWallet(); enterHome();}/* ---------- unlock ---------- */function startUnlock(){  pinBuf = ''; $('#unlockErr').textContent = ''; dots($('#unlockDots'), 0);  buildPad($('#unlockPad'), onUnlockKey); go('s-unlock');  guardBlocked('#unlockErr', '#unlockDots');   // show any pending wait up front  // offer biometric only when it's set up and the device can actually do it  const btn = $('#bioUnlockBtn');  if(btn){ btn.hidden = true;    if(bioEnabled()) bioSupported().then(ok => { btn.hidden = !ok; }); }}async function onUnlockKey(k){  if(k === 'del'){ pinBuf = pinBuf.slice(0,-1); dots($('#unlockDots'), pinBuf.length); return; }  if(pinBuf.length >= 6) return;  pinBuf += k; dots($('#unlockDots'), pinBuf.length);  if(pinBuf.length < 6) return;  const pin = pinBuf; pinBuf = '';  if(guardBlocked('#unlockErr', '#unlockDots')) return;  const seed = await decryptSeed(localStorage.getItem(LS), pin);  if(!seed){ guardFail('#unlockErr', '#unlockDots'); return; }  pinGuard.reset(); $('#unlockErr').textContent = '';  seedPhrase = seed; await deriveWallet(); enterHome();}/* ---------- wallet ---------- */async function deriveWallet(){ const d = await deriveFromSeedPhrase(seedPhrase); wallet = { address: d.address }; }function realAddress(){ return (wallet && wallet.address && isValidAddress(wallet.address)) ? wallet.address : null; }function enterHome(){ go('s-home'); renderHome(); renderActivity(); refreshBalance(); refreshConfirmations(); }/* ---------- biometric unlock (WebAuthn PRF, fully on-device) ---------- * Touch ID / Face ID / Windows Hello gate the release of a per-credential * secret (the PRF output). We wrap the seed under a key derived from it, so a * successful biometric check — and nothing else, no server — decrypts the * wallet. The PIN envelope stays as the always-available fallback. */// A fixed per-app salt for the PRF evaluation; the secret is the authenticator's,// this only namespaces it. Same value on enable and on unlock.const BIO_PRF_SALT = new Uint8Array([  0x6d,0x6f,0x6f,0x6e,0x62,0x69,0x74,0x65,0x2d,0x77,0x61,0x6c,0x6c,0x65,0x74,0x2d,  0x62,0x69,0x6f,0x2d,0x70,0x72,0x66,0x2d,0x76,0x31,0x00,0x00,0x00,0x00,0x00,0x01]);function _b64e(buf){ let s=''; const b=new Uint8Array(buf); for(const x of b) s+=String.fromCharCode(x); return btoa(s); }function _b64d(s){ const t=atob(s); const o=new Uint8Array(t.length); for(let i=0;i<t.length;i++)o[i]=t.charCodeAt(i); return o; }function bioStored(){ try{ return JSON.parse(localStorage.getItem(BIO_LS)||'null'); }catch(e){ return null; } }function bioEnabled(){ return !!bioStored(); }async function bioSupported(){  try{    if(!window.PublicKeyCredential || !navigator.credentials || !PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) return false;    return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();  }catch(e){ return false; }}async function bioGetSecret(credIdBuf){  const asrt = await navigator.credentials.get({ publicKey:{    challenge: crypto.getRandomValues(new Uint8Array(32)),    allowCredentials:[{ id: credIdBuf, type:'public-key' }],    userVerification:'required', timeout:60000,    extensions:{ prf:{ eval:{ first: BIO_PRF_SALT } } }  }});  const r = asrt.getClientExtensionResults().prf;  return (r && r.results && r.results.first) ? r.results.first : null;}async function enableBiometric(seed){  const cred = await navigator.credentials.create({ publicKey:{    rp:{ id: location.hostname, name:'MoonBite Wallet' },    user:{ id: crypto.getRandomValues(new Uint8Array(16)), name:'moonbite-wallet', displayName:'MoonBite Wallet' },    challenge: crypto.getRandomValues(new Uint8Array(32)),    pubKeyCredParams:[{type:'public-key',alg:-7},{type:'public-key',alg:-257}],    authenticatorSelection:{ authenticatorAttachment:'platform', userVerification:'required', residentKey:'required' },    timeout:60000, extensions:{ prf:{} }  }});  if(!cred) throw new Error('cancelled');  const ext = cred.getClientExtensionResults();  if(!ext.prf || !ext.prf.enabled) throw new Error('unsupported');  const secret = await bioGetSecret(cred.rawId);  if(!secret) throw new Error('unsupported');  const seedEnc = await encryptSeedWithPrf(seed, secret);  localStorage.setItem(BIO_LS, JSON.stringify({ credId: _b64e(cred.rawId), seedEnc }));}function disableBiometric(){ try{ localStorage.removeItem(BIO_LS); }catch(e){} }async function bioUnlock(){  const st = bioStored(); if(!st) return;  try{    const secret = await bioGetSecret(_b64d(st.credId));    if(!secret){ $('#unlockErr').textContent = 'Biometric unlock failed — use your PIN'; return; }    const seed = await decryptSeedWithPrf(st.seedEnc, secret);    if(!seed){ $('#unlockErr').textContent = 'Biometric unlock failed — use your PIN'; return; }    pinGuard.reset();    seedPhrase = seed; await deriveWallet(); enterHome();  }catch(e){ /* user cancelled the prompt — silently fall back to the PIN pad */ }}async function onBioToggle(){  if(bioEnabled()){ disableBiometric(); toast('Biometric unlock turned off'); fillSettings(); return; }  if(!seedPhrase){ toast('Unlock your wallet first'); return; }  if(!(await bioSupported())){ toast('This device has no biometric unlock'); return; }  try{ await enableBiometric(seedPhrase); toast('Biometric unlock is on'); fillSettings(); }  catch(e){ toast(e && e.message==='unsupported'      ? "This device can't store a secure biometric key"      : 'Biometric setup was cancelled'); }}function fmt(units){  return displayUnit === 'units'    ? Math.round(units).toLocaleString('en-US')    : (units/UNIT).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:8});}function renderHome(){  $('#balNum').textContent = fmt(totalUnits);  const u = document.querySelector('.balance .unit'); if(u) u.textContent = unitLabel();  $('#balSpend').textContent = fmt(balanceUnits) + ' ' + unitLabel();  const m = $('#balMaturing');  if(m){    const maturing = totalUnits - balanceUnits;    m.textContent = maturing > 0 ? ' · Maturing ' + fmt(maturing) + ' ' + unitLabel() : '';  }  $('#sendAvail').textContent = fmt(balanceUnits) + ' ' + unitLabel();  const au = document.querySelector('.amt-unit'); if(au) au.textContent = unitLabel();  const addr = realAddress();  document.querySelectorAll('[data-bind="address"]').forEach(e => {    e.textContent = addr ? addr.slice(0, 9) + '…' + addr.slice(-6) : '';  });}async function refreshBalance(){  const a = realAddress(); if(!a) return;  const prev = totalUnits;  try{    const r = await fetch('/api/chain/address/' + encodeURIComponent(a));    const j = await r.json();    // balance_units is spendable only; mining rewards younger than 100 blocks    // are reported separately and can't be sent yet.    balanceUnits = (j.balance_units ?? 0) || 0;    spendUtxos = Array.isArray(j.utxos) ? j.utxos.filter(u => Number.isSafeInteger(u.value) && u.value > 0) : [];    totalUnits = (j.total_units ?? balanceUnits) || 0;  }catch(e){ renderHome(); return; }  // offline: keep last known, don't mislog  // A net increase since the last successful read is money that arrived. This  // is the honest history model for a stock node with no address index: the log  // reflects what this device has actually observed on-chain.  if(_balInit && totalUnits > prev){    logActivity({ type:'receive', amountUnits: totalUnits - prev, status:'confirmed' });  }  _balInit = true;  renderHome(); renderActivity();  refreshOldBanner();}/* ---------- activity ledger (local, per wallet — no address index on chain) ---------- */function actKey(){ const a = realAddress(); return a ? 'mbf_act_' + a : null; }function getActivity(){ try{ return JSON.parse(localStorage.getItem(actKey()) || '[]'); }catch(e){ return []; } }function saveActivity(a){ const k = actKey(); if(k) localStorage.setItem(k, JSON.stringify(a.slice(0, 200))); }function logActivity(entry){  const a = getActivity();  a.unshift(Object.assign({ ts: Math.floor(Date.now()/1000) }, entry));  saveActivity(a); renderActivity();}function fmtWhen(ts){  const d = new Date(ts*1000);  return d.toLocaleDateString(undefined, {month:'short', day:'numeric'}) + ' · ' +         d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'});}function txRow(it){  const sent = it.type === 'send';  const sub = fmtWhen(it.ts) + (it.status ? '  ·  ' + it.status : '') +              (it.txid ? '  ·  ' + it.txid.slice(0,8) + '…' : '');  return `<div class="tx ${sent?'out':'in'}"><div class="ic"><svg class="icon"><use href="#${sent?'i-send':'i-recv'}"/></svg></div>`+    `<div class="meta"><div class="t">${sent?'Sent':'Received'}</div><div class="s">${sub}</div></div>`+    `<div class="amt">${sent?'−':'+'}${fmt(it.amountUnits)} ${unitLabel()}</div></div>`;}function renderActivity(){  const items = getActivity(), home = $('#activity'), full = $('#activityFull');  if(!items.length){    if(home) home.innerHTML = '<div class="empty">No transactions yet.<br>Receive MBITE to get started.</div>';    if(full) full.innerHTML = '<div class="empty">No transactions yet.<br>Your sends and receives will appear here.</div>';    return;  }  if(home) home.innerHTML = items.slice(0,4).map(txRow).join('');  if(full) full.innerHTML = items.map(txRow).join('');}async function refreshConfirmations(){  const a = getActivity(); let changed = false;  for(const it of a){    if(it.type === 'send' && it.status === 'pending' && it.txid){      try{        const r = await fetch('/api/explorer/tx/' + it.txid);        if(r.ok){          const d = await r.json(); const tx = d && d.transaction;          if(tx && (tx.status === 'confirmed' || tx.block_height != null)){ it.status = 'confirmed'; changed = true; }        }      }catch(e){ /* offline: leave pending */ }    }  }  if(changed){ saveActivity(a); renderActivity(); }}/* ---------- receive ---------- */function openReceive(){  const a = realAddress(); if(!a){ toast('Unlock your wallet first'); return; }  $('#recvAddr').innerHTML = a.replace(/^(moon1)(.*)(.{6})$/, '<span class="hl">$1</span>$2<span class="hl">$3</span>');  renderQR(a, $('#qrFrame'));  go('s-receive');}async function copyAddr(){  const a = realAddress(); if(!a) return;  try{ await navigator.clipboard.writeText(a); toast('Address copied'); }  catch(e){ toast('Copy failed'); }}function renderQR(text, frame){  try{    const m = qrMatrix(text), n = m.length, S = 190, cell = S/n, NS = 'http://www.w3.org/2000/svg';    const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', `0 0 ${S} ${S}`);    const bg = document.createElementNS(NS, 'rect'); bg.setAttribute('width', S); bg.setAttribute('height', S); bg.setAttribute('fill', '#F5F2EA'); svg.appendChild(bg);    let d = '';    for(let y=0;y<n;y++) for(let x=0;x<n;x++) if(m[y][x]) d += `M${(x*cell).toFixed(2)} ${(y*cell).toFixed(2)}h${cell.toFixed(2)}v${cell.toFixed(2)}h${(-cell).toFixed(2)}z`;    const p = document.createElementNS(NS, 'path'); p.setAttribute('d', d); p.setAttribute('fill', '#0A0C11'); svg.appendChild(p);    frame.innerHTML = ''; frame.appendChild(svg);  }catch(e){    frame.innerHTML = '<div style="color:#20170A;font-size:12px;padding:14px;text-align:center;line-height:1.5">Copy the address below</div>';  }}/* ---------- send ---------- */function onSendInput(){  const amountUnits = toBaseUnits($('#sendAmt').value);  const to = $('#sendTo').value.trim();  $('#sendReview').style.display = amountUnits > 0 ? 'block' : 'none';  $('#rvAmt').textContent = fmt(amountUnits) + ' ' + unitLabel();  const plan = amountUnits > 0 ? planSpend(spendUtxos, amountUnits) : null;  const feeUnits = plan ? plan.fee : feeFor(1, 2);  $('#rvFee').textContent = fmt(feeUnits) + ' ' + unitLabel();  $('#rvTot').textContent = fmt(amountUnits + feeUnits) + ' ' + unitLabel();  $('#sendBtn').disabled = !(amountUnits > 0 && to.length > 6);  $('#sendErr').textContent = '';}function sendMax(){  const maxUnits = maxSendable(spendUtxos);  $('#sendAmt').value = displayUnit === 'units'    ? String(maxUnits)    : (maxUnits > 0 ? +(maxUnits/UNIT).toFixed(8) : '0');  onSendInput();}async function doSend(){  const to = $('#sendTo').value.trim(), err = $('#sendErr');  err.textContent = '';  if(!isValidAddress(to)){ err.textContent = 'That doesn’t look like a valid MoonBite address.'; return; }  const amountUnits = toBaseUnits($('#sendAmt').value);  if(amountUnits <= 0){ err.textContent = 'Enter an amount to send.'; return; }  if(amountUnits < DUST_UNITS){ err.textContent = 'That amount is below the network minimum (' + fmt(DUST_UNITS) + ' ' + unitLabel() + ').'; return; }  if(!planSpend(spendUtxos, amountUnits)){ err.textContent = 'Not enough MBITE for that amount plus the fee.'; return; }  const a = realAddress();  if(!a || !seedPhrase){ err.textContent = 'Unlock your wallet first.'; return; }  const btn = $('#sendBtn'), label = btn.textContent;  btn.disabled = true; btn.textContent = 'Sending…';  try{    // Real spend: fetch this wallet's UTXOs, sign ON DEVICE with the audited    // builder, and broadcast the finished hex. The seed never leaves the page.    const r = await fetch('/api/chain/address/' + encodeURIComponent(a));    const j = await r.json();    const utxos = j.utxos || [];    const built = await buildSignedTransaction({ seedPhrase, toAddress: to, amountUnits, utxos });    const feeUnits = built.fee;    const br = await fetch('/api/chain/broadcast', {      method: 'POST', headers: { 'Content-Type': 'application/json' },      body: JSON.stringify({ rawtx: built.rawHex }),    });    const bj = await br.json();    if(!br.ok || bj.status !== 'success') throw new Error(bj.message || 'The network rejected this transaction.');    const txid = built.txid || bj.txid;    logActivity({ type: 'send', amountUnits, txid, status: 'pending' });    // let the presentation layer show a receipt; carries display strings only    document.dispatchEvent(new CustomEvent('mb:sent', { detail: {      to, txid, amountText: fmt(amountUnits), feeText: fmt(feeUnits), unit: unitLabel() } }));    $('#sendAmt').value = ''; $('#sendTo').value = ''; onSendInput();    toast('Sent'); go('s-home'); setTimeout(refreshBalance, 1500);  }catch(e){    err.textContent = (e && e.message) ? e.message : 'Send failed. Please try again.';  }finally{    btn.disabled = false; btn.textContent = label;  }}/* ---------- settings ---------- */function fillSettings(){ const a = realAddress(); $('#setAddr').textContent = a || '—';  const ps = $('#passState'); if(ps) ps.textContent = (seedPhrase && unpackSecret(seedPhrase).passphrase) ? 'On' : 'Off';  updateUnitSeg(); updateThemeSeg(); updateBioRow(); }function updateBioRow(){  const row = $('#bioRow'), state = $('#bioState'); if(!row) return;  const on = bioEnabled();  if(state) state.textContent = on ? 'On' : 'Off';  row.classList.toggle('on', on);  // hide the row entirely on a device that can't do platform biometrics  bioSupported().then(ok => { row.hidden = !ok && !on; });}function lockWallet(){  seedPhrase = null; _cpSeed = null; _cpPin = null; _mv = null;  const mg = $('#moveGrid'); if(mg) mg.innerHTML = '';  const g = $('#revealGrid'); if(g) g.innerHTML = '';   // never leave words on screen  startUnlock();}/* ---------- move to a new recovery phrase ---------- * For a phrase that may have leaked. Order matters, so no step can strand * coins: (1) PIN check, (2) new phrase shown + 3 words checked from paper, * (3) BOTH phrases saved encrypted under the PIN and the wallet switched to * the new one, (4) every spendable coin swept old -> new in one tx. If (4) * fails, or rewards are still maturing, the old phrase stays (encrypted) and * the home banner finishes the job later. Phrases live in memory only for * the duration of the flow. */let _mv = null;   // { mode:'rotate'|'leftover', oldSeed, newSeed, pin, check:[i,i,i], buf }function lsGet(k){ try{ return localStorage.getItem(k); }catch(e){ return null; } }function lsSet(k, v){ localStorage.setItem(k, v); }function lsDel(k){ try{ localStorage.removeItem(k); }catch(e){} }function oldSpent(){ try{ return new Set(JSON.parse(lsGet(OLD_SPENT) || '[]')); }catch(e){ return new Set(); } }function clearOld(){ [OLD_LS, OLD_ADDR, OLD_SPENT].forEach(lsDel); }async function readAddr(a){  const r = await fetch('/api/chain/address/' + encodeURIComponent(a));  const j = await r.json();  if(!r.ok || j.status !== 'success') throw new Error(j.message || 'Could not read the chain. Try again.');  return j;}/* Spendable coins on the old address that no earlier sweep already spent * (a broadcast sweep's inputs stay in the UTXO set until it confirms). */function oldState(j){  const spent = oldSpent();  const free = (j.utxos || []).filter(u => !spent.has(u.txid + ':' + u.vout));  const freeUnits = free.reduce((s, u) => s + u.value, 0);  const pending = (j.utxos || []).length - free.length;  const firstMature = (j.immature_utxos || []).reduce((m, u) => (m === null || u.matures_at_height < m) ? u.matures_at_height : m, null);  return { free, freeUnits, pending, maturing: j.immature_units || 0, firstMature, height: j.height };}async function sweepOld(oldSeed, oldAddr, toAddr){  const st = oldState(await readAddr(oldAddr));  let moved = 0, txid = null;  if(maxSendable(st.free.slice().sort((a, b) => b.value - a.value).slice(0, 500)) >= DUST_UNITS){    const built = await buildSweepTransaction({ seedPhrase: oldSeed, toAddress: toAddr, utxos: st.free });    const br = await fetch('/api/chain/broadcast', { method: 'POST', headers: { 'Content-Type': 'application/json' },      body: JSON.stringify({ rawtx: built.rawHex }) });    const bj = await br.json();    if(!br.ok || bj.status !== 'success') throw new Error(bj.message || 'The network rejected the move.');    const spent = oldSpent();    st.free.slice().sort((a, b) => b.value - a.value).slice(0, built.inputs).forEach(u => spent.add(u.txid + ':' + u.vout));    lsSet(OLD_SPENT, JSON.stringify([...spent]));    moved = built.amountUnits; txid = built.txid || bj.txid;    st.left = built.remaining;  }  st.moved = moved; st.txid = txid;  return st;}function moveIntro(){  const blocked = !!lsGet(OLD_LS);  $('#moveFee').textContent = fmt(feeFor(Math.max(1, spendUtxos.length), 1));  $('#moveSpend').textContent = fmt(Math.max(0, balanceUnits)) + ' ' + unitLabel();  const mat = totalUnits - balanceUnits;  $('#moveMatRow').hidden = $('#moveMatNote').hidden = !(mat > 0);  $('#moveMat').textContent = fmt(mat) + ' ' + unitLabel();  $('#moveBlocked').hidden = !blocked;  $('#moveStartBtn').disabled = blocked || !seedPhrase;  go('s-move');}function movePin(mode){  _mv = { mode, buf: '' };  $('#movePinTitle').textContent = 'Enter your PIN';  $('#movePinHint').textContent = mode === 'rotate' ? 'Your PIN will also protect the new phrase.' : 'To move the coins left on your old phrase.';  $('#movePinErr').textContent = ''; dots($('#movePinDots'), 0);  buildPad($('#movePinPad'), onMovePinKey); go('s-move-pin');  guardBlocked('#movePinErr', '#movePinDots');}function moveStart(){ if(lsGet(OLD_LS) || !seedPhrase) return; movePin('rotate'); }function moveLeftoverStart(){ if(lsGet(OLD_LS) && seedPhrase) movePin('leftover'); }function moveCancel(){ _mv = null; $('#moveGrid').innerHTML = ''; go(seedPhrase ? 's-settings' : 's-welcome'); }async function onMovePinKey(k){  if(!_mv) return;  if(k === 'del'){ _mv.buf = _mv.buf.slice(0,-1); dots($('#movePinDots'), _mv.buf.length); return; }  if(_mv.buf.length >= 6) return;  _mv.buf += k; dots($('#movePinDots'), _mv.buf.length);  if(_mv.buf.length < 6) return;  const pin = _mv.buf; _mv.buf = '';  if(guardBlocked('#movePinErr', '#movePinDots')) return;  const seed = await decryptSeed(lsGet(_mv.mode === 'rotate' ? LS : OLD_LS), pin);  if(!seed){ guardFail('#movePinErr', '#movePinDots'); return; }  pinGuard.reset(); $('#movePinErr').textContent = '';  _mv.pin = pin; _mv.oldSeed = seed;  if(_mv.mode === 'leftover'){ await runLeftover(); return; }  _mv.newSeed = await generatePhrase();  if(_mv.newSeed === seed){ moveCancel(); toast('Try again'); return; }   // never reuse  $('#moveGrid').innerHTML = _mv.newSeed.split(/\s+/).map((w,i) => `<div class="word"><i>${i+1}</i><b>${w}</b></div>`).join('');  $('#moveWrap').classList.add('veiled');  const ack = $('#moveAck'); ack.classList.remove('on'); ack.classList.add('off'); ack.setAttribute('aria-checked', 'false');  $('#moveToCheckBtn').disabled = true;  go('s-move-new');}function moveReveal(){ $('#moveWrap').classList.remove('veiled'); $('#moveAck').classList.remove('off'); }function moveAckToggle(){  const ack = $('#moveAck'); if(ack.classList.contains('off')) return;  const on = !ack.classList.contains('on');  ack.classList.toggle('on', on); ack.setAttribute('aria-checked', String(on));  $('#moveToCheckBtn').disabled = !on;}function moveToCheck(){  if(!_mv || !_mv.newSeed) return;  const idx = new Set(); const r = new Uint32Array(1);  while(idx.size < 3){ crypto.getRandomValues(r); idx.add(r[0] % 12); }  _mv.check = [...idx].sort((a, b) => a - b);  _mv.check.forEach((w, i) => { $('#mvL' + i).textContent = 'Word #' + (w + 1); $('#mvW' + i).value = ''; });  $('#moveCheckErr').textContent = '';  ['#mvPass', '#mvPass2'].forEach(id => { const el = $(id); if(el) el.value = ''; });  const adv = $('#moveAdv'); if(adv) adv.open = false;  $('#moveGrid').innerHTML = '';   // words off screen once they're on paper  go('s-move-check');}async function moveExecute(){  if(!_mv || !_mv.newSeed || !_mv.oldSeed) return;  const words = _mv.newSeed.split(/\s+/);  const ok = _mv.check.every((w, i) => $('#mvW' + i).value.trim().toLowerCase() === words[w]);  if(!ok){ $('#moveCheckErr').textContent = 'Those words don’t match. Check your paper and try again.'; return; }  const mp1 = ($('#mvPass') || {}).value || '', mp2 = ($('#mvPass2') || {}).value || '';  if(mp1 !== mp2){ $('#moveCheckErr').textContent = 'The two passwords don’t match.'; return; }  const btn = $('#moveExecBtn'), label = btn.textContent;  btn.disabled = true; btn.textContent = 'Moving…';  const oldAddr = realAddress();  const { oldSeed, pin } = _mv;  const newSeed = packSecret(_mv.newSeed, mp1);   // the new wallet's secret  let doneMsg;  try{    const newAddr = (await deriveFromSeedPhrase(newSeed)).address;    // (3) persist both phrases BEFORE any coin moves, then switch the wallet.    const newEnv = await encryptSeed(newSeed, pin), oldEnv = await encryptSeed(oldSeed, pin);    lsSet(OLD_LS, oldEnv); lsSet(OLD_ADDR, oldAddr); lsDel(OLD_SPENT);    lsSet(LS, newEnv);    disableBiometric();   // its envelope holds the old phrase    const oldAct = lsGet('mbf_act_' + oldAddr);    if(oldAct) lsSet('mbf_act_' + newAddr, oldAct);    seedPhrase = newSeed; wallet = { address: newAddr };    balanceUnits = 0; totalUnits = 0; spendUtxos = []; _balInit = false;    // (4) sweep.    try{      const st = await sweepOld(oldSeed, oldAddr, newAddr);      const parts = [];      parts.push(st.moved > 0 ? fmt(st.moved) + ' ' + unitLabel() + ' is on its way to your new address.' : 'There were no spendable coins to move.');      if(st.maturing > 0) parts.push(fmt(st.maturing) + ' ' + unitLabel() + ' in mining rewards is still maturing on the old address; the home screen will offer to move it once it unlocks.');      if(st.left) parts.push('Some coins are left; use “Move now” on the home screen.');      // An old address that held nothing needs no follow-up. Otherwise keep the      // old phrase until the banner sees the address confirmed empty, so a sweep      // the network drops can still be redone.      if(st.moved === 0 && st.maturing === 0 && st.free.length === 0 && st.pending === 0) clearOld();      doneMsg = parts.join(' ');    }catch(e){      doneMsg = 'Moving the coins didn’t go through yet (' + ((e && e.message) || 'network error') +        '). Your coins are safe on the old address; tap “Move now” on the home screen to try again.';    }    $('#moveDoneTitle').textContent = 'Your new phrase is active';    $('#moveDoneMsg').textContent = doneMsg;    $('#moveNewAddr').textContent = newAddr;    _mv = null;    go('s-move-done');    setTimeout(refreshBalance, 1500);  }catch(e){    $('#moveCheckErr').textContent = (e && e.message) ? e.message : 'Something went wrong. Nothing was moved.';  }finally{    btn.disabled = false; btn.textContent = label;  }}async function runLeftover(){  const oldAddr = lsGet(OLD_ADDR), to = realAddress(), oldSeed = _mv.oldSeed;  _mv = null;  go('s-home');  if(!oldAddr || !to) return;  try{    const st = await sweepOld(oldSeed, oldAddr, to);    toast(st.moved > 0 ? 'Moved ' + fmt(st.moved) + ' ' + unitLabel() : 'Nothing to move yet');  }catch(e){ toast((e && e.message) || 'Move failed'); }  setTimeout(refreshBalance, 1500);}/* Home banner for coins still sitting on the previous phrase. Clears the old * phrase from the device once its address is fully empty. */async function refreshOldBanner(){  const b = $('#oldBanner'); if(!b) return;  const oldAddr = lsGet(OLD_ADDR);  if(!oldAddr || !lsGet(OLD_LS)){ b.hidden = true; return; }  let st; try{ st = oldState(await readAddr(oldAddr)); }catch(e){ return; }  const btn = $('#oldBannerBtn'), txt = $('#oldBannerTxt');  if(st.freeUnits === 0 && st.maturing === 0 && st.pending === 0){ clearOld(); b.hidden = true; return; }  btn.hidden = true;  if(maxSendable(st.free) >= DUST_UNITS){    txt.textContent = fmt(st.freeUnits) + ' ' + unitLabel() + ' is still on your old phrase.';    btn.hidden = false;  }else if(st.maturing > 0){    const left = st.firstMature != null && st.height != null ? st.firstMature - st.height : null;    txt.textContent = fmt(st.maturing) + ' ' + unitLabel() + ' is maturing on your old phrase' +      (left > 0 ? '; the first unlocks in ' + left + ' block' + (left === 1 ? '' : 's') + '.' : '.');  }else{    txt.textContent = 'Moving coins from your old phrase… waiting for the network to confirm.';  }  b.hidden = false;}/* ---------- sign out: forget this wallet on this device ---------- * Lock keeps the encrypted phrase, so the next visit asks for the PIN. Sign out * erases it (plus the biometric key, PIN-attempt counter and this address's * local activity log), so the next visit starts at create / restore. Gated on * an explicit "I have my recovery phrase" tick. */function signOutStart(){  const ack = $('#signOutAck');  ack.classList.remove('on'); ack.setAttribute('aria-checked', 'false');  $('#signOutBtn').disabled = true;  go('s-signout');}function signOutAck(){  const ack = $('#signOutAck'), on = !ack.classList.contains('on');  ack.classList.toggle('on', on); ack.setAttribute('aria-checked', String(on));  $('#signOutBtn').disabled = !on;}function signOutConfirm(){  if(!$('#signOutAck').classList.contains('on')) return;  const act = actKey();  const oldAddr = lsGet(OLD_ADDR);  for(const k of [LS, BIO_LS, 'mbf_pin_guard', act, OLD_LS, OLD_ADDR, OLD_SPENT, oldAddr && 'mbf_act_' + oldAddr]){    if(k){ try{ localStorage.removeItem(k); }catch(e){} }  }  seedPhrase = null; _cpSeed = null; wallet = null;  balanceUnits = 0; totalUnits = 0; spendUtxos = []; _balInit = false;  const g = $('#revealGrid'); if(g) g.innerHTML = '';  go('s-welcome'); toast('Signed out');}/* ---------- inactivity auto-lock ---------- * 10 minutes without input clears the in-memory phrase and shows the lock * screen. Only arms while a wallet is unlocked and an encrypted blob exists * (so a brand-new, not-yet-PIN'd setup is not interrupted). */const IDLE_LOCK_MS = 10 * 60 * 1000;let _idleTimer = null;function idleLock(){  _idleTimer = null;  if(!seedPhrase) return;  let env = null; try{ env = localStorage.getItem(LS); }catch(e){}  if(!env) return;  lockWallet(); toast('Locked after inactivity');}function touchIdle(){  clearTimeout(_idleTimer);  _idleTimer = setTimeout(idleLock, IDLE_LOCK_MS);}['pointerdown','keydown','touchstart','input','scroll'].forEach(ev =>  document.addEventListener(ev, touchIdle, { passive: true }));document.addEventListener('visibilitychange', () => { if(document.visibilityState === 'visible') touchIdle(); });touchIdle();/* ---------- balance refresh: once a minute while unlocked and on screen ---------- * New blocks land about every 2 minutes, so a 1-minute poll keeps the balance * and maturing figure current without hammering the node's UTXO scan. */const BALANCE_POLL_MS = 60 * 1000;setInterval(() => {  if(seedPhrase && document.visibilityState === 'visible') refreshBalance();}, BALANCE_POLL_MS);function updateUnitSeg(){  document.querySelectorAll('#unitSeg .seg-btn').forEach(b =>    b.classList.toggle('active', b.dataset.arg === displayUnit));}function setUnit(u){  displayUnit = (u === 'units') ? 'units' : 'mbite';  try{ localStorage.setItem('mbf_display_unit', displayUnit); }catch(e){}  updateUnitSeg(); renderHome(); renderActivity(); onSendInput();}let revealBuf = '';function revealStart(){  revealBuf = ''; $('#revealErr').textContent = ''; dots($('#revealDots'), 0);  buildPad($('#revealPad'), onRevealKey); go('s-reveal-pin');  guardBlocked('#revealErr', '#revealDots');}async function onRevealKey(k){  if(k === 'del'){ revealBuf = revealBuf.slice(0,-1); dots($('#revealDots'), revealBuf.length); return; }  if(revealBuf.length >= 6) return;  revealBuf += k; dots($('#revealDots'), revealBuf.length);  if(revealBuf.length < 6) return;  const pin = revealBuf; revealBuf = '';  if(guardBlocked('#revealErr', '#revealDots')) return;  const seed = await decryptSeed(localStorage.getItem(LS), pin);  if(!seed){ guardFail('#revealErr', '#revealDots'); return; }  pinGuard.reset(); $('#revealErr').textContent = '';  const u = unpackSecret(seed);  const words = u.phrase.split(/\s+/);  $('#revealPassNote').hidden = !u.passphrase;  $('#revealGrid').innerHTML = words.map((w,i) => `<div class="word"><i>${i+1}</i><b>${w}</b></div>`).join('');  go('s-reveal');}function revealDone(){ $('#revealGrid').innerHTML = ''; go('s-settings'); } // clear words from DOMasync function revealCopy(){  const words = [...document.querySelectorAll('#revealGrid .word b')].map(b => b.textContent);  if(!words.length) return;  try{ await navigator.clipboard.writeText(words.join(' ')); toast('Recovery phrase copied'); }  catch(e){ toast('Copy failed'); }}/* ---------- change PIN ---------- */let _cpSeed = null, _cpPin = null, _cpBuf = '', _cpFirst = '', _cpStage = 'first';function changePinStart(){  _cpSeed = null; _cpBuf = ''; $('#cpCurErr').textContent = ''; dots($('#cpCurDots'), 0);  buildPad($('#cpCurPad'), onCpCurKey); go('s-changepin-current');  guardBlocked('#cpCurErr', '#cpCurDots');}async function onCpCurKey(k){  if(k === 'del'){ _cpBuf = _cpBuf.slice(0,-1); dots($('#cpCurDots'), _cpBuf.length); return; }  if(_cpBuf.length >= 6) return;  _cpBuf += k; dots($('#cpCurDots'), _cpBuf.length);  if(_cpBuf.length < 6) return;  const pin = _cpBuf; _cpBuf = '';  if(guardBlocked('#cpCurErr', '#cpCurDots')) return;  const seed = await decryptSeed(localStorage.getItem(LS), pin);  if(!seed){ guardFail('#cpCurErr', '#cpCurDots'); return; }  pinGuard.reset(); $('#cpCurErr').textContent = '';  _cpSeed = seed; _cpPin = pin; startCpNew();}function startCpNew(){  _cpBuf = ''; _cpFirst = ''; _cpStage = 'first';  $('#cpNewTitle').textContent = 'Choose a new PIN'; $('#cpNewHint').textContent = '6 digits.';  $('#cpNewErr').textContent = ''; dots($('#cpNewDots'), 0);  buildPad($('#cpNewPad'), onCpNewKey); go('s-changepin-new');}function onCpNewKey(k){  if(k === 'del'){ _cpBuf = _cpBuf.slice(0,-1); dots($('#cpNewDots'), _cpBuf.length); return; }  if(_cpBuf.length >= 6) return;  _cpBuf += k; dots($('#cpNewDots'), _cpBuf.length);  if(_cpBuf.length < 6) return;  if(_cpStage === 'first'){    _cpFirst = _cpBuf; _cpBuf = ''; _cpStage = 'confirm';    setTimeout(() => { $('#cpNewTitle').textContent = 'Confirm new PIN';      $('#cpNewHint').textContent = 'Enter it once more.'; dots($('#cpNewDots'), 0); }, 160);  } else {    if(_cpBuf !== _cpFirst){      $('#cpNewErr').textContent = 'PINs didn’t match — try again.';      _cpBuf = ''; _cpFirst = ''; _cpStage = 'first';      setTimeout(() => { $('#cpNewTitle').textContent = 'Choose a new PIN';        $('#cpNewHint').textContent = '6 digits.'; dots($('#cpNewDots'), 0); }, 160);      return;    }    finishChangePin(_cpFirst);  }}async function finishChangePin(newPin){  const env = await encryptSeed(_cpSeed, newPin);  // A previous phrase still waiting to be emptied must follow the new PIN too.  const oldEnv = lsGet(OLD_LS);  const oldSeed = oldEnv && _cpPin ? await decryptSeed(oldEnv, _cpPin) : null;  if(oldSeed) lsSet(OLD_LS, await encryptSeed(oldSeed, newPin));  localStorage.setItem(LS, env);  _cpSeed = null; _cpPin = null; _cpBuf = ''; _cpFirst = '';  toast('PIN changed'); go('s-settings');}/* ---------- event delegation (CSP-safe: no inline handlers) ---------- */const ACTIONS = { go: (a) => go(a), tab: (a) => go(a), startCreate, doImport, toPin: createContinue, pinBack,  openReceive, copyAddr, refreshBalance, sendMax, doSend,  lockWallet, signOutStart, signOutAck, signOutConfirm, revealStart,  moveIntro, moveStart, moveLeftoverStart, moveCancel, moveReveal, moveAckToggle, moveToCheck, moveExecute, revealDone, revealCopy, setUnit, setTheme, changePinStart,  bioUnlock, bioToggle: onBioToggle };document.addEventListener('click', e => {  const el = e.target.closest('[data-act]'); if(!el) return;  const fn = ACTIONS[el.dataset.act]; if(fn) fn(el.dataset.arg);});$('#sendAmt').addEventListener('input', onSendInput);$('#sendTo').addEventListener('input', onSendInput);/* ---------- boot ---------- */(function boot(){  applyTheme();  const env = localStorage.getItem(LS);  if(env && isNewFormat(env)) startUnlock(); else go('s-welcome');})();