aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/ark.js')
-rw-r--r--src/features/ark.js127
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;
}