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');})();