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.js188
1 files changed, 158 insertions, 30 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 739dd5e..1b8c591 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -8,7 +8,7 @@ import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
-import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
+import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, sameDelivery, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -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 = '';
@@ -995,10 +1032,49 @@ export function arkFeature(ctx) {
// connections/offer belong to the nwc feature; when it isn't the caller
// (an auto-withdraw setting changed, say) carry forward what's mirrored
// rather than blanking someone's wallet connections.
- async function writeBgMirror(connections, offer, prefs) {
+ // The mirror carries hundreds of pre-derived keys (BG_KEY_LOOKAHEAD) and
+ // is rewritten after every state change: derive each index once per
+ // account, not once per write.
+ const bgKeyMemo = new WeakMap(); // account node -> Map('chain:i' -> hex)
+ function bgKeyFor(account) {
+ let m = bgKeyMemo.get(account);
+ if (!m) { m = new Map(); bgKeyMemo.set(account, m); }
+ const chains = new Map();
+ return (chain, i) => {
+ const k = chain + ':' + i;
+ let v = m.get(k);
+ if (!v) {
+ let node = chains.get(chain);
+ if (!node) { node = account.deriveChild(chain); chains.set(chain, node); }
+ v = hex.encode(node.deriveChild(i).privateKey);
+ m.set(k, v);
+ }
+ return v;
+ };
+ }
+ // 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;
@@ -1006,12 +1082,12 @@ export function arkFeature(ctx) {
ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired },
mgrState: ark.state,
custody: getCustody(),
- keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey),
+ keyFor: bgKeyFor(ark.account),
connections,
offer,
autowithdraw: await awMirror(prev),
});
- if (prev) rec.spends = prev.spends; // 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(() => {});
@@ -1031,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]));
@@ -1040,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');
@@ -1499,9 +1577,12 @@ export function arkFeature(ctx) {
h('span', { class: g.claimed ? 'tag conf' : 'tag' },
g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag')));
})(),
- (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(),
- m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null),
- h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))),
+ (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })()),
+ // the status tag sits by the time, not the name: in the narrow wallet
+ // column the title row has room for the label and the chip, not a
+ // third thing — the chip was breaking mid-name to make space
+ h('div', { class: 'row gap6 small faint' }, timeAgo(m.ts / 1000),
+ m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null)),
h('div', { style: 'text-align:right' },
// in fiat this is what the payment was worth when it was made
h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg' }, fmtAmount(m.amountSat, m.ts / 1000)))
@@ -1670,6 +1751,7 @@ export function arkFeature(ctx) {
})(),
m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null,
m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null,
+ m.type === 'receive' ? coinListRows(t('arkVtxoId'), m.vtxoIds) : null,
// the coins this payment consumed — a send can gather up to 24 inputs
coinListRows(t('arkVtxoInputs'), m.inputIds),
// An offboard's fee is two different pockets — the miners' cut for the
@@ -1718,7 +1800,7 @@ export function arkFeature(ctx) {
const g = giftForMovement(m);
const open = g && !g.claimed && !g.revoked;
const chips = [
- open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}`, t('giftCopyLinkAgain')) : null,
+ open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}${g.note ? '#m=' + encodeURIComponent(g.note) : ''}`, t('giftCopyLinkAgain')) : null,
m.preimage ? copyBtn(m.preimage, t('copyPreimage')) : null,
m.txid ? copyBtn(m.txid, t('copyTxid')) : null,
m.txid ? h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool')) : null,
@@ -1928,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');
@@ -1940,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)
@@ -3167,7 +3261,7 @@ export function arkFeature(ctx) {
// Fund a fresh bearer gift identity. Shared by the gifts feature's hook and
// the zap flow's locked-gift fallback; `lockedTo` records who a locked gift
// was for (the sender can still revoke either kind while unclaimed).
- async function createArkGift(amountSat, lockedTo = null) {
+ async function createArkGift(amountSat, lockedTo = null, note = null) {
const mgr = await connectArk();
const secret = crypto.getRandomValues(new Uint8Array(32));
const secretHex = hex.encode(secret);
@@ -3177,7 +3271,7 @@ export function arkFeature(ctx) {
const action = mgr.state.actions.find((a) => a.id === actionId);
if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed'));
const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id;
- arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}) });
+ arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}), ...(note ? { note } : {}) });
mgr._save();
return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address };
}
@@ -3591,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 {}
}
@@ -3641,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' },
@@ -4010,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(); },
@@ -4033,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';
@@ -4054,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.
@@ -4092,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) || [])
@@ -4184,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;
}
@@ -4289,7 +4400,22 @@ export function arkFeature(ctx) {
for (const m of s.movements || []) for (const id of m.inputIds || []) accounted.add(id);
const out = [];
const seenCoin = new Set();
+ // One payment delivered as several coins (a gift held in two, a
+ // dust-padded split) is one row: receives from the same mailbox
+ // message (`batch`; older rows: the same delivery instant) fold.
+ const folded = new Map();
for (const m of moves) {
+ if (m.type === 'receive' && sameDelivery(out[out.length - 1], m)) {
+ const last = out[out.length - 1];
+ if (!folded.has(last.id)) {
+ const f = { ...last, vtxoIds: [last.vtxoId], vtxoId: null };
+ out[out.length - 1] = f; folded.set(f.id, f);
+ }
+ const f = folded.get(last.id);
+ f.amountSat += m.amountSat;
+ f.vtxoIds.push(m.vtxoId);
+ continue;
+ }
if (m.type !== 'reconcile') { out.push(m); continue; }
if (m.vtxoId && (seenCoin.has(m.vtxoId) || accounted.has(m.vtxoId))) continue;
if (m.vtxoId) seenCoin.add(m.vtxoId);
@@ -4302,6 +4428,7 @@ export function arkFeature(ctx) {
out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] });
}
}
+ this._histFolded = folded; // a folded row's detail shows the whole payment
return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) })));
},
historyDetail() {
@@ -4321,7 +4448,8 @@ export function arkFeature(ctx) {
if (done) return arkMoveDetailView(done);
if (a && !['done', 'failed'].includes(a.step)) return arkMoveDetailView(lnPendingMove(a));
}
- const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
+ if (s) this.historyEntries(); // (memoized) keeps the folded rows current
+ const m = s ? (this._histFolded && this._histFolded.get(ui.arkMoveDetail)) || (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
if (m) return arkMoveDetailView(m);
ui.arkMoveDetail = null;
return null;
@@ -4472,7 +4600,7 @@ export function arkFeature(ctx) {
setNetwork(g.net);
return true;
},
- arkGiftCreate(amountSat) { return createArkGift(amountSat); },
+ arkGiftCreate(amountSat, note) { return createArkGift(amountSat, null, note); },
// Claim-side status: claimable (with the live amount), taken, wrongnet, or
// unknown (not visible here — wrong server or not yet delivered).
async arkGiftStatus(code) {