/* 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 now let totalUnits = 0; // spendable + mining rewards still maturing let _balInit = false; // guards first balance read from logging a fake receive let 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) => `
${i+1}${w}
`).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 '
'; if(k === 'del') return ''; return ``; }).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) => ``).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 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 `
`+ `
${sent?'Sent':'Received'}
${sub}
`+ `
${sent?'−':'+'}${fmt(it.amountUnits)} ${unitLabel()}
`; } function renderActivity(){ const items = getActivity(), home = $('#activity'), full = $('#activityFull'); if(!items.length){ if(home) home.innerHTML = '
No transactions yet.
Receive MBITE to get started.
'; if(full) full.innerHTML = '
No transactions yet.
Your sends and receives will appear here.
'; 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})$/, '$1$2$3'); 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 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) => `
${i+1}${w}
`).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) => `
${i+1}${w}
`).join(''); go('s-reveal'); } function revealDone(){ $('#revealGrid').innerHTML = ''; go('s-settings'); } // clear words from DOM async 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'); })();