/* 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?'−':'+'}${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');
})();