diff options
Diffstat (limited to 'src/features/ark.js')
| -rw-r--r-- | src/features/ark.js | 188 |
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) { |
