diff options
Diffstat (limited to 'src/features/ark.js')
| -rw-r--r-- | src/features/ark.js | 127 |
1 files changed, 107 insertions, 20 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index b8e83a7..1b8c591 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -77,11 +77,15 @@ if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushAll(); }); } +// A read-only look at the stored state (stub wallets in tests have only loadArkState). +const peekArk = (wallet, url) => (wallet.peekArkState ? wallet.peekArkState(url) : wallet.loadArkState(url)); + // Wallet storage/key helpers for this feature, installed onto the core // wallet instance so a build without the feature ships none of it. export function installArkWallet(wallet) { if (wallet.loadArkState) return; // already installed const store = arkStore(); // IndexedDB-backed, memory-fronted; opens now + const peekOwnerOk = new WeakSet(); // stored states whose owner check passed Object.assign(wallet, { _arkStore: store, // ---- Ark support ------------------------------------------------------- @@ -133,6 +137,21 @@ export function installArkWallet(wallet) { return st; } catch { return null; } }, + // loadArkState for readers that only look (see ArkStore.peek): the stored + // object, no copy — never mutate it. Misfiled state goes through + // loadArkState's repair; each object's owner is decoded once. + peekArkState(url) { + try { + const st = store.peek(this._arkKey(url)); + if (!st) return null; + if (!peekOwnerOk.has(st)) { + const owner = this._arkVtxoOwner(st); + if (owner && st.serverPubkey && owner !== st.serverPubkey) return this.loadArkState(url); + peekOwnerOk.add(st); + } + return st; + } catch { return null; } + }, // Offer up pre-namespacing state, but only to the ASP that actually // cosigned it. Its owner is read from the state's own stamp, or failing // that decoded straight out of a vtxo — the vtxo wire format carries the @@ -491,6 +510,23 @@ export function arkFeature(ctx) { localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify(tip ? { info, tip, at: Date.now() } : { info, tip: (prev && prev.tip) || 0, at: (prev && prev.at) || Date.now() })); } catch {} } + // Whether Savings can fund a move into Spending at all: the server's board + // minimum (1,000 sats on coinos) plus the board transaction's chain fee. + // A Savings balance under that — 546 sats, say — must not be offered a + // move the board panel then refuses with "Needs at least 1,000 sats"; the + // way into Spending for it is a Lightning receive, which lands there + // directly. The minimum comes from the live manager or the last copy of + // the server's info seen, so the answer is right before connect too. + function savingsBoardMin() { + const info = (ark && ark.info) || ((cachedArkInfo((getArkConfig() || {}).ark) || {}).info) || null; + return info ? (info.minBoardAmountSat || 0) : 1000; + } + function savingsCanBoard() { + if (!arkAvailable()) return false; + const min = savingsBoardMin(); + if (min >= 2_100_000_000_000_000 || wallet.spendable < min) return false; // paused, or short + try { return previewBoardFunding(wallet, ark, min).fundingSat >= min; } catch { return false; } // the fee on top + } // the tip, live or estimated from the last one seen (a block per ten minutes) function arkTipNow(mgr) { if (mgr && mgr._tipH) return mgr._tipH; @@ -537,7 +573,7 @@ export function arkFeature(ctx) { function arkWanted() { const cfg = getArkConfig(); - const st = cfg && wallet.loadArkState(cfg.ark); + const st = cfg && peekArk(wallet, cfg.ark); return !!(st && ((st.vtxos || []).length || (st.movements || []).length || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); } @@ -646,6 +682,7 @@ export function arkFeature(ctx) { arkPersistFlushers.add(flushPersist); const mgr = new ArkManager({ account: wallet.account(), + lnOldestFirst: () => getCustody() !== 'custodial', storage: { load: () => wallet.loadArkState(cfg.ark), adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, cfg.ark), @@ -779,7 +816,7 @@ export function arkFeature(ctx) { // (send/exit) still requires the seed and is gated separately. const cfg = getArkConfig(); if (!cfg) return null; - return wallet.loadArkState(cfg.ark); + return peekArk(wallet, cfg.ark); } // Which receives actually arrived over Lightning? A payment to the user's @@ -929,7 +966,7 @@ export function arkFeature(ctx) { h('button', { class: 'btn-ghost', onClick: () => { ui.arkSend = null; ui.sendError = ''; render(); } }, t('back')), ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) - : a.amountSat > (arkBalance()?.spendableSat || 0) && wallet.spendable > 0 && !wallet.watchOnly + : a.amountSat > (arkBalance()?.spendableSat || 0) && savingsCanBoard() ? h('button', { class: 'btn-primary grow', onClick: () => { const need = a.amountSat; ui.arkSend = null; ui.sendError = ''; @@ -1015,10 +1052,29 @@ export function arkFeature(ctx) { return v; }; } - async function writeBgMirror(connections, offer, prefs) { + // The worker can now pay while this app is alive behind another one (a + // phone with Coinos in the background: the page is frozen or throttled, + // the worker is not). Its change coin and its spent coins then exist only + // in the mirror — so the mirror is never written over the worker's hand: + // one writer at a time (the lock the worker holds for a whole push), and + // whatever the worker did since we last looked is folded in first. + let bgAbsorbedAt = 0; // the worker stamp (rec.workerAt) this app has folded in + const withBgLock = (fn) => (typeof navigator !== 'undefined' && navigator.locks + ? navigator.locks.request('nwc-bg-work', fn) : fn()); + const bgBusy = (mgr) => !!(mgr && mgr._lnDriving && mgr._lnDriving.size); + async function writeBgMirror(connections, offer, prefs) { return withBgLock(() => writeBgMirrorLocked(connections, offer, prefs)); } + async function writeBgMirrorLocked(connections, offer, prefs) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; - const prev = await loadBg(wallet._cacheKey()); + let prev = await loadBg(wallet._cacheKey()); + if (prev && prev.v >= 3 && prev.mgr && (prev.workerAt || 0) > bgAbsorbedAt) { + // a payment of ours in flight holds the very records a merge replaces: + // leave the worker's state standing and write at the next call + if (bgBusy(ark)) return; + await mergeBgWorkerState(ark).catch((e) => console.warn('ark: worker merge failed', e && e.message)); + if ((prev.workerAt || 0) > bgAbsorbedAt) return; // not folded in: don't write over it + prev = await loadBg(wallet._cacheKey()); + } if (connections === undefined) connections = (prev && prev.connections) || []; if (offer === undefined) offer = (prev && prev.offer) || null; if (prefs === undefined) prefs = (prev && prev.prefs) || undefined; @@ -1031,7 +1087,7 @@ export function arkFeature(ctx) { offer, autowithdraw: await awMirror(prev), }); - if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; } // the worker's log survives rewrites + if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; if (prev.workerAt) rec.workerAt = prev.workerAt; } // the worker's log survives rewrites if (prefs) rec.prefs = prefs; // notification choices the worker honours await saveBg(wallet._cacheKey(), rec); await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {}); @@ -1051,6 +1107,7 @@ export function arkFeature(ctx) { } if (!rec.mgr) return; bootPhase('ark-merge'); + const stamp = rec.workerAt || 0; const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); @@ -1060,6 +1117,7 @@ export function arkFeature(ctx) { merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); + if (mgr === ark) bgAbsorbedAt = Math.max(bgAbsorbedAt, stamp); bootPhase('ark-merged'); await mgr.reconcile().catch(() => {}); bootPhase('ark-reconciled'); @@ -1952,11 +2010,16 @@ export function arkFeature(ctx) { } // The ask itself: switch and continue, move Savings over first, or not. + // When Savings can't fund a move either (under the board minimum, as a + // 546-sat Savings against coinos's 1,000 is), the door offered is the one + // that works: a Lightning receive, which lands in Spending directly. function arkLnFromSavingsView() { const p = ui.arkLnFromSavings; const have = arkBalance()?.spendableSat || 0; const need = p.amountSat || 0; const enough = !need || have >= need; + const canMove = savingsCanBoard(); + const close = () => { ui.arkLnFromSavings = null; ui.sendError = ''; ui.send = blankSend(); }; const proceed = () => { ui.arkLnFromSavings = null; ctx.setAccount('spending', 'right'); @@ -1964,20 +2027,27 @@ export function arkFeature(ctx) { if (startArkLnPay(p.invoice, p.meta)) return; ui.arkLnFromSavingsOk = false; // Spending can't carry it: the same offer the Spending side makes - if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return; + if (need && canMove && ctx.hook('arkOfferBoard', need)) return; ui.sendError = t('arkLnExceedsSpending', { need: fmtAmount(need) + ' ' + unitLabel(), have: fmtAmount(have) + ' ' + unitLabel() }); render(); }; + const moveFirst = () => { ui.arkLnFromSavings = null; ctx.hook('arkOfferBoard', need || 0); }; + const receiveInstead = () => { close(); ctx.setAccount('spending', 'right'); render(); }; + const sats = (n) => fmtAmount(n) + ' ' + unitLabel(); return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', { style: 'margin:0' }, t('arkLnFromSavingsTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('arkLnFromSavingsBody', { have: fmtAmount(have) + ' ' + unitLabel() })), - need ? h('div', { class: 'row between' }, h('span', { class: 'small muted' }, t('lnPayAmount')), h('span', { class: 'small' }, fmtAmount(need) + ' ' + unitLabel())) : null, - !enough ? h('div', { class: 'notice info small' }, t('arkLnFromSavingsShort')) : null, - h('button', { class: 'btn-primary btn-block', onClick: proceed }, enough ? t('arkLnUseSpending') : t('arkLnMoveFirst')), - enough && wallet.spendable > 1000 - ? h('button', { class: 'btn-block', onClick: () => { ui.arkLnFromSavings = null; ctx.hook('arkOfferBoard', need || 0); } }, t('arkLnMoveFirst')) + h('p', { class: 'small muted', style: 'margin:0' }, t('arkLnFromSavingsBody', { have: sats(have) })), + need ? h('div', { class: 'row between' }, h('span', { class: 'small muted' }, t('lnPayAmount')), h('span', { class: 'small' }, sats(need))) : null, + enough ? null + : canMove ? h('div', { class: 'notice info small' }, t('arkLnFromSavingsShort')) + : h('div', { class: 'notice info small' }, t('arkLnSavingsTooSmall', { have: sats(wallet.spendable || 0), min: sats(savingsBoardMin()) })), + enough ? h('button', { class: 'btn-primary btn-block', onClick: proceed }, t('arkLnUseSpending')) + : canMove ? h('button', { class: 'btn-primary btn-block', onClick: moveFirst }, t('arkLnMoveFirst')) + : h('button', { class: 'btn-primary btn-block', onClick: receiveInstead }, t('arkLnReceiveInstead')), + enough && canMove + ? h('button', { class: 'btn-block', onClick: moveFirst }, t('arkLnMoveFirst')) : null, - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkLnFromSavings = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('cancel'))); + h('button', { class: 'btn-ghost btn-block', onClick: () => { close(); render(); } }, t('cancel'))); } // shared by the ark and lightning zap flows (see zaps.js for the twin) @@ -3615,8 +3685,8 @@ export function arkFeature(ctx) { date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }); navigator.serviceWorker?.ready - .then((reg) => reg.showNotification('coinos', { body })) - .catch(() => { try { new Notification('coinos', { body }); } catch {} }); + .then((reg) => reg.showNotification(t('arkRenewWarnTitle'), { body })) + .catch(() => { try { new Notification(t('arkRenewWarnTitle'), { body }); } catch {} }); } catch {} } @@ -3665,7 +3735,7 @@ export function arkFeature(ctx) { const boardsPaused = () => ((ark && ark.info && ark.info.minBoardAmountSat) || 0) >= 2_100_000_000_000_000; function boardForm() { if (boardsPaused()) return h('div', { class: 'notice small' }, t('arkBoardPaused')); - const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0; + const minBoard = savingsBoardMin(); const canBoard = wallet.spendable >= minBoard; return h('div', { class: 'col', style: 'width:100%;gap:8px' }, h('div', { class: 'input-group' }, @@ -4034,6 +4104,7 @@ export function arkFeature(ctx) { return mgr.fetchBolt12(offerStr, amountSat); }, lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + arkSavingsCanBoard() { return savingsCanBoard(); }, spendingRenewingSat() { return renewingSat(); }, // Max for any Lightning amount form: spendable minus estimated fees. lnMaxSendSat() { return lnMaxSat(); }, @@ -4057,7 +4128,7 @@ export function arkFeature(ctx) { const short = Math.max(0, Math.ceil(needSat * 1.01) + 2 - have); let gross = short; try { for (let i = 0; i < 4; i++) gross = short + boardFee(gross, ark.info.boardFees); } catch {} - const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0; + const minBoard = savingsBoardMin(); // remembered info too: the offer can come before connect ctx.goHome(); ui.arkMoveOpen = true; ui.arkMoveDir = 'toSpending'; @@ -4078,6 +4149,8 @@ export function arkFeature(ctx) { return mgr.lnOutcome(paymentHash, timeoutMs); }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + // everything Spending holds, settled or not + arkHeldSat() { const b = arkBalance(); return b ? b.spendableSat + b.pendingSat + b.boardingSat : 0; }, arkReady() { return arkAvailable(); }, // A Lightning invoice for another feature to watch (point of sale): // the action id is the handle, the poller tells it when it settles. @@ -4116,6 +4189,18 @@ export function arkFeature(ctx) { // nwc.js calls this (with its connections) whenever the mirror should // refresh: on enable, on connection changes, after payments, on a timer. async arkBgWrite(connections, offer, prefs) { return writeBgMirror(connections, offer, prefs); }, + // Back on screen: what the worker paid while this app sat behind another + // one is folded in now, not at the next start. true when there was any. + async arkBgAbsorb() { + if (!ark || !ark.state) return false; + return withBgLock(async () => { + const rec = await loadBg(wallet._cacheKey()); + if (!rec || rec.v < 3 || !rec.mgr || (rec.workerAt || 0) <= bgAbsorbedAt || bgBusy(ark)) return false; + await mergeBgWorkerState(ark); + render(); + return true; + }); + }, async arkBgSpendableSat() { const rec = await loadBg(wallet._cacheKey()); return ((rec && rec.mgr && rec.mgr.vtxos) || []) @@ -4208,11 +4293,13 @@ export function arkFeature(ctx) { // paste. Own it: offer to board the shortfall from Savings when // there is one, else say plainly what's short. const need = (maybeLnInvoice(inv) || {}).amountSat || 0; - if (need && wallet.spendable > 1000 && ctx.hook('arkOfferBoard', need)) return true; + if (need && savingsCanBoard() && ctx.hook('arkOfferBoard', need)) return true; ui.sendError = t('arkLnExceedsSpending', { need: fmtAmount(need) + ' ' + unitLabel(), have: fmtAmount(arkBalance()?.spendableSat || 0) + ' ' + unitLabel(), - }); + }) + (wallet.spendable > 0 && !savingsCanBoard() // Savings has sats, just too few to move + ? ' ' + t('arkLnSavingsTooSmall', { have: fmtAmount(wallet.spendable) + ' ' + unitLabel(), min: fmtAmount(savingsBoardMin()) + ' ' + unitLabel() }) + : ''); render(); return true; } |
