// Ark feature — off-chain payments via an ASP (Second's bark/captaind // protocol, spoken natively by ../ark). Receive address + boarding, instant // sends, history entries, refresh/consolidation, server settings. 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 } from '../ark/manager.js'; import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee, p2trAddress } from '../ark/board.js'; import { maybeBolt11, maybeLnInvoice, lnSendFee } from '../ark/lightning.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes, vtxoBytesFromStr, vtxoBytesToHex } from '../ark/proto.js'; import { arkStore } from '../ark/store.js'; import { signedExitTxs, exitTxVsizes, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js'; import { utxoId } from '../wallet.js'; import { getNetwork, setNetwork, getArkProviderId, getArkConfig, } from '../api.js'; import { t } from '../i18n.js'; import { resolveBip353, parsePaymentName, parseBip21 as parseBip21Uri } from '../bip353.js'; import { parseZapTarget, fetchPayParams, requestInvoice } from '../lnurl.js'; import { shortAddr, shortTxid, timeAgo, fmtSats, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js'; // t?ark1… bech32m — an Ark address for this or another ASP. export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); } // Use the funding transaction's actual input selection and P2TR output size // for both Max and the fee preview. Mining fees are paid from Savings; only // the ASP service fee comes out of the amount deposited into Spending. export function previewBoardFunding(wallet, ark, amountSat = 0, sendMax = false) { const feeRate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; const hrp = { bitcoin: 'bc', regtest: 'bcrt' }[ark?.info?.network] || 'tb'; const dest = ark ? p2trAddress(ark._key(0).pubkey.slice(1), hrp) : wallet.freshChange().address; const draft = wallet.buildTx({ recipients: [{ address: dest, amount: amountSat }], feeRate, sendMax, noSort: true }); const fundingSat = draft.outputs[0].amount; const serviceFeeSat = boardFee(fundingSat, ark?.info?.boardFees); return { fundingSat, chainFeeSat: draft.fee, serviceFeeSat, feeSat: draft.fee + serviceFeeSat, netSat: fundingSat - serviceFeeSat }; } // Ark movement types that actually touch the chain — each is a real bitcoin // tx with a txid, so it wears the Bitcoin mark, not the Ark one: board pulls // coins in from on-chain, offboard and exit push them back out. const ONCHAIN_ARK = new Set(['board', 'offboard', 'exit']); // Every live ark manager registers its debounced persist here so a pending // write is never lost: flushed synchronously when the page hides, and on a // wallet switch (see stopArk). const arkPersistFlushers = new Set(); if (typeof document !== 'undefined') { const flushAll = () => { for (const f of [...arkPersistFlushers]) { try { f(); } catch {} } }; addEventListener('pagehide', flushAll); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushAll(); }); } // 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 Object.assign(wallet, { _arkStore: store, // ---- Ark support ------------------------------------------------------- // Persisted ArkManager state (vtxos, in-flight action checkpoints, movement // history — no secrets: vtxo keys are re-derived from the seed). // Ark state is per WALLET *and* per ASP: vtxos are cosigned by one // specific server, so showing another server's coins (or resuming its // in-flight board) is meaningless and alarming. Switching providers must // therefore switch state, and switching back must restore it. _arkNs(url) { // short, stable id for an ASP endpoint let h = 5381; for (const c of String(url || '')) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; return h.toString(36); }, _arkKey(url) { return this._cacheKey() + ':ark:' + this._arkNs(url); }, _arkLegacyKey() { return this._cacheKey() + ':ark'; }, // Who really cosigned this state's coins? A vtxo names its server in its // own bytes, which is cryptographic fact — unlike the stamp, which is // just something we wrote and could have written wrongly. _arkVtxoOwner(st) { const v = (st?.vtxos || []).find((x) => x.bytes); if (!v) return null; try { return decodeVtxo(vtxoBytesFromStr(v.bytes)).serverPubkey; } catch { return null; } }, // Park state under the server that actually owns it, until the URL for // that server is selected and can adopt it. _arkHoldKey(pubkey) { return this._cacheKey() + ':ark:pk:' + String(pubkey).slice(0, 16); }, loadArkState(url) { // NB deliberately no fallback to the legacy key: the UI renders from // this before any server is contacted, so returning another ASP's state // here is exactly how you get a phantom balance. Pre-namespacing state // is claimed via adoptArkState() once we know who cosigned it. try { const st = store.get(this._arkKey(url)); if (!st) return null; // Self-repair: an earlier build could stamp state with whichever // server happened to be connected, filing one ASP's coins under // another. The vtxos themselves settle it — if they disagree with the // stamp, this state is misfiled. Move it to its real owner and show // nothing here. const owner = this._arkVtxoOwner(st); if (owner && st.serverPubkey && owner !== st.serverPubkey) { st.serverPubkey = owner; store.set(this._arkHoldKey(owner), st); store.remove(this._arkKey(url)); return null; } 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 // server pubkey, so this needs no network call and cannot guess wrong. adoptArkState(serverPubkey, url) { try { // state parked by the self-repair above belongs to exactly one server const held = store.get(this._arkHoldKey(serverPubkey)); if (held) { this.saveArkState(held, url); store.remove(this._arkHoldKey(serverPubkey)); return held; } const st = store.get(this._arkLegacyKey()); if (!st) return null; let owner = st.serverPubkey || null; if (!owner) { const v = (st.vtxos || []).find((x) => x.bytes); if (v) { try { owner = decodeVtxo(vtxoBytesFromStr(v.bytes)).serverPubkey; } catch {} } } if (owner && owner !== serverPubkey) return null; // belongs to another ASP if (!owner) return null; // can't prove ownership: leave it st.serverPubkey = owner; this.saveArkState(st, url); store.remove(this._arkLegacyKey()); // now safely namespaced return st; } catch { return null; } }, saveArkState(state, url) { store.set(this._arkKey(url), state); }, }); } // A slim projection of ark state for the sync snapshot — the sync relay caps // events at 64 KB and the full state (deep-genesis vtxo bytes + done-action // hex) blew past it, so publishes silently failed and nothing synced. Another // device needs, per vtxo: id/amount/state (+ bytes ONLY for spendable/pending, // which it might spend/exit); the movement log (capped) for ark history; and // done board/offboard/exit actions (stripped of hex) so on-chain rows still // label. In-flight actions and their signing material stay device-local. export function slimArkForSync(s) { if (!s) return null; const HEAVY = ['bytes', 'destBytes', 'changeBytes', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos']; return { v: s.v, serverPubkey: s.serverPubkey, mailboxCheckpoint: s.mailboxCheckpoint, nextKeyIndex: s.nextKeyIndex, receiveAckTs: s.receiveAckTs, // Spent stubs and done actions are pure history and grow forever — cap // them (newest kept) so the sync event's size is bounded for life. vtxos: (() => { // On the wire the bytes stay HEX for now: a device still on a build // that only reads hex would otherwise fail to decode every coin merged // from this one. Flip to base64 once builds before 2026-09-10 are gone. const live = (s.vtxos || []).filter((v) => v.state !== 'spent') .map((v) => (v.bytes ? { ...v, bytes: vtxoBytesToHex(v.bytes) } : v)); const stubs = (s.vtxos || []).filter((v) => v.state === 'spent').slice(-200) .map((v) => ({ id: v.id, amountSat: v.amountSat, state: 'spent', keyIndex: v.keyIndex, expiryHeight: v.expiryHeight })); return [...live, ...stubs]; })(), actions: (() => { const done = (s.actions || []).filter((a) => a.step === 'done'); const core = done.filter((a) => ['board', 'offboard', 'exit'].includes(a.type)) .slice(-50) .map((a) => { const c = { ...a }; for (const k of HEAVY) delete c[k]; return c; }); // Spends travel as SLIVERS — id, net amount, and which coins they // consumed. That last field is what lets ANOTHER device recognize a // vanished coin as this send rather than painting a red "spent on // another device" row beside the synced Sent movement (one spend was // showing twice, at input magnitude the second time). const spends = done.filter((a) => ['send', 'ln-pay'].includes(a.type)) .slice(-100) .map((a) => ({ id: a.id, type: a.type, step: 'done', amountSat: a.amountSat, inputIds: (a.parts || []).map((p) => p.inputId).concat(a.inputId ? [a.inputId] : []).filter(Boolean), })); return [...core, ...spends]; })(), movements: (s.movements || []).slice(-200).map((m, i, arr) => // Older rows only need to render a history line; the bolt11 (~400 chars // each) and preimage are what pushed snapshots past relay size limits. (arr.length - i <= 50 ? m : (({ invoice, preimage, ...rest }) => rest)(m))), gifts: s.gifts, }; } // Merge two ark states so devices can't clobber each other through the sync // snapshot: additive union of vtxos (a device keeps its own full copy, gains // ones it lacks), actions/movements/gifts union by id, counters take the max // (so two devices never reuse a key index). export function mergeArkStates(a, b) { if (!a) return b; if (!b) return a; // never fuse state from two different ASPs if (a.serverPubkey && b.serverPubkey && a.serverPubkey !== b.serverPubkey) return a; const out = { ...a }; // ADDITIVE union only: bring in vtxos this device is missing (the whole // point — board/change coins that live only where they were created), but // NEVER let a remote snapshot change the state of a vtxo we already track. // Spend detection is each device's own job via reconcile() against the // server (the authority), so a stale-or-wrong "spent" on one device can't // propagate and permanently poison the others. A newly-merged-in vtxo that // was actually spent elsewhere gets caught by the reconcile on next connect // / send-intent, exactly like any other drift. const vtxos = new Map((a.vtxos || []).map((v) => [v.id, { ...v }])); for (const rv of b.vtxos || []) if (!vtxos.has(rv.id)) vtxos.set(rv.id, { ...rv }); out.vtxos = [...vtxos.values()]; const unionById = (x = [], y = []) => { const ids = new Set(x.map((i) => i.id)); return [...x, ...y.filter((i) => !ids.has(i.id))]; }; out.actions = unionById(a.actions, b.actions); // Union keeps the local copy of a movement forever — but a movement can be // ENRICHED after other devices synced it (the cooney rescue stamped // inputIds/unlockHash/fee onto its renewal movement post-hoc, and those // fields are what lets history exclude the matching "spent on another // device" rows). Adopt fields the local copy lacks; never overwrite ones // it has. const bM = new Map((b.movements || []).map((m) => [m.id, m])); const enriched = (a.movements || []).map((lm) => { const rm = bM.get(lm.id); if (!rm) return lm; const take = {}; if (rm.inputIds != null && lm.inputIds == null) take.inputIds = rm.inputIds; if (rm.unlockHash != null && lm.unlockHash == null) take.unlockHash = rm.unlockHash; // a fee learned late (the rescue computed it from inputs − outputs) // upgrades a recorded zero — the row only renders when the fee is real if ((rm.feeSat || 0) > 0 && !(lm.feeSat > 0)) take.feeSat = rm.feeSat; if (rm.chainFeeSat != null && lm.chainFeeSat == null) take.chainFeeSat = rm.chainFeeSat; if (rm.serviceFeeSat != null && lm.serviceFeeSat == null) take.serviceFeeSat = rm.serviceFeeSat; // an LN-origin tag learned on another device (matched against the // registrar's receipts there) rides the snapshot to this one if (rm.viaLn && !lm.viaLn) { take.viaLn = true; if (rm.lnHash) take.lnHash = rm.lnHash; } return Object.keys(take).length ? { ...lm, ...take } : lm; }); // Two devices (or the push and the poll, pre-guard) can each record the // SAME lightning payment under different movement ids — same preimage is // the same payment, keep the earliest telling of it. const seenPay = new Set(); out.movements = unionById(enriched, b.movements) .sort((m, n) => (m.ts || 0) - (n.ts || 0)) .filter((m) => { if (!m.type || !m.type.startsWith('ln-') || m.status !== 'complete') return true; const key = m.type + ':' + (m.paymentHash || m.preimage || m.invoice || m.id); if (seenPay.has(key)) return false; seenPay.add(key); return true; }); const gifts = new Map((a.gifts || []).map((g) => [g.id, { ...g }])); for (const rg of b.gifts || []) { const lg = gifts.get(rg.id); if (!lg) gifts.set(rg.id, { ...rg }); else { lg.claimed = lg.claimed || rg.claimed; lg.revoked = lg.revoked || rg.revoked; } } out.gifts = [...gifts.values()]; // Targeted spend knowledge beats waiting for a reconcile: a completed // movement or done action that names its consumed inputs is direct // evidence those coins are gone. A device waking cold used to merge in // another device's renewal/send OUTPUT while still counting the consumed // inputs as spendable — the balance visibly ticked high, then dropped // when the reconcile caught up. (This is narrow, evidence-based state — // not the blanket remote-state adoption the rule above forbids.) const consumed = new Set(); for (const x of out.actions || []) { if (x.step !== 'done') continue; for (const id of x.inputIds || []) consumed.add(id); for (const part of x.parts || []) if (part.inputId) consumed.add(part.inputId); } for (const m of out.movements || []) { if (m.status !== 'complete') continue; for (const id of m.inputIds || []) consumed.add(id); } for (const v of out.vtxos) if (v.state === 'spendable' && consumed.has(v.id)) v.state = 'spent'; // The checkpoint is READING PROGRESS, not state: adopting a remote one // skips every message this device never processed — balance appears (the // vtxo unions in) but its receive movement and celebration never happen. // Keep the local checkpoint; re-read messages dedupe harmlessly. out.mailboxCheckpoint = a.mailboxCheckpoint || 0; out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1); out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0); out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0); out.serverPubkey = a.serverPubkey || b.serverPubkey || null; return out; } // Apply sync to a connected manager without restarting payment ceremonies. // Drivers retain references across awaits, so preserve the state, coin and // action objects they already hold while adding/enriching the remote records. export function adoptArkSnapshot(manager, snapshot) { if (!snapshot || !manager.state) return false; const state = manager.state; const merged = mergeArkStates(state, snapshot); if (JSON.stringify(merged) === JSON.stringify(state)) return false; for (const field of ['vtxos', 'actions', 'movements', 'gifts']) { const existing = new Map((state[field] || []).map(item => [item.id, item])); merged[field] = (merged[field] || []).map(item => { const current = existing.get(item.id); return current ? Object.assign(current, item) : item; }); } Object.assign(state, merged); manager._save(); return true; } export function arkFeature(ctx) { const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx; installArkWallet(wallet); // ark state storage lives outside the core wallet // Ride the encrypted sync snapshot so ark funds follow the seed across // devices. Board/change vtxo bytes exist only where they were created — a // second device can't otherwise see them. All devices share ONE replaceable // event slot (same seed -> same nostr key), so a naive publish can clobber // another device's coins. Anti-entropy fixes that: on receiving a snapshot, // union it with local; if it was missing coins we know (or brought coins we // didn't), re-publish the superset. The slot converges to the union no // matter who publishes when. wallet.registerCacheExtension({ domain: 'ark', // published as its own sync slot — see splitSnapshotDomains mergeAlways: true, // load() is a commutative merge — apply older snapshots too syncOnly: true, // ark keeps its own store; the local cache blob needn't carry a copy save: () => { const cfg = getArkConfig(); const s = cfg && wallet.loadArkState(cfg.ark); // tag the snapshot with the ASP it belongs to (see load()) return s ? { arkState: slimArkForSync(s), arkServer: wallet._arkNs(cfg.ark) } : {}; }, load: (d) => { if (!d.arkState) return; const cfg = getArkConfig(); if (!cfg) return; if (d.arkServer && d.arkServer !== wallet._arkNs(cfg.ark)) return; // A snapshot from an older build carries no tag, so the tag alone can't // be trusted — ask the vtxos who cosigned them. If they belong to a // different ASP (or we can't yet prove they belong to this one), park // the snapshot under its real owner instead of merging it in. Merging // blind is exactly how another server's balance appears here. const owner = d.arkState.serverPubkey || wallet._arkVtxoOwner(d.arkState); const mine = (ark && ark.info && ark.info.serverPubkey) || (wallet.loadArkState(cfg.ark) || {}).serverPubkey || null; if (owner && owner !== mine) { try { const key = wallet._arkHoldKey(owner); wallet._arkStore.set(key, mergeArkStates(wallet._arkStore.get(key), { ...d.arkState, serverPubkey: owner })); } catch {} // `mine` is null on a FRESH restore (no local state, not yet // connected), so even our own snapshot lands in the hold. Connecting // is what resolves it: the manager learns the server's pubkey and // adopts the held state if it matches — a seed imported on a new // device would otherwise show no ark balance until some manual poke. if (!mine) setTimeout(() => maybeInitArk(), 0); return; } const local = wallet.loadArkState(cfg.ark); const merged = mergeArkStates(local, d.arkState); // Every boot replays the sync slot, and on a converged slot the union // brings nothing — not worth rewriting ~1 MB of state or re-adopting // it into the manager (profiled at ~200ms per replay, twice per boot). // Bytes decide: unchanged means it serializes to what's already stored. let changed = true; try { changed = JSON.stringify(merged) !== JSON.stringify(local); } catch {} if (changed) wallet.saveArkState(merged, cfg.ark); const mergedN = (merged.vtxos || []).length; const remoteN = (d.arkState.vtxos || []).length; // We know vtxos this snapshot lacked — push our superset back up so the // sender (and the shared slot) learns them. Guarded by mergedN>remoteN so // it can't loop once every device has converged to the union. if (mergedN > remoteN) { try { wallet.saveCache(); } catch {} } // maybeInitArk deliberately does nothing when connected. Update that // manager directly or the newly synced change/history stays invisible // until reload, even though storage already contains the right balance. if (ark) { if (changed) adoptArkSnapshot(ark, merged); } else if (mergedN) setTimeout(() => maybeInitArk(), 0); }, }); // One manager per open wallet; null when Ark is off in Settings, watch-only, // or not yet dialed (lazy: fresh wallets connect on first use). let ark = null; let arkTimer = null; let arkConnectPromise = null; let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch function stopArk() { // land any debounced ark write BEFORE bumping the gen (which would make the // old manager's flusher a no-op), then drop the stale flushers for (const f of [...arkPersistFlushers]) { try { f(); } catch {} } arkPersistFlushers.clear(); arkInitGen++; if (arkTimer) clearInterval(arkTimer); arkTimer = null; if (ark) ark.stopMailboxStream(); stopNwcFunding(); for (const timer of arkLnTimers.values()) clearInterval(timer); arkLnTimers.clear(); clearTimeout(lnTagT); lnTagSeen = -1; ark = null; arkConnectPromise = null; // The renewal warning describes the coins of the wallet we're leaving. // Switching accounts used to carry it onto the next one — with the old // wallet's amount and deadline, over the new wallet's balance — and the // 30-minute throttle then kept it there. Both reset with the manager; // the next connect's first tick re-decides for the wallet now open. arkRenewWarn = null; arkAutoRefreshAt = 0; } function arkAvailable() { return !!getArkConfig() && !wallet.watchOnly && !!wallet.account; } function arkWanted() { const cfg = getArkConfig(); const st = cfg && wallet.loadArkState(cfg.ark); return !!(st && ((st.vtxos || []).length || (st.movements || []).length || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); } function initArk() { stopArk(); ui.arkError = ''; lastAutoInit = Date.now(); // The ark store is IndexedDB — asynchronous to OPEN, synchronous to read // once it has. A boot that beats it open reads no state at all: no // Spending balance, no Spending history, and nothing here that looks // worth connecting for. Wait for it and run again, so what the store // holds is on screen the moment it can be. const store = wallet._arkStore; if (store && !store.ready) { store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {}); return; } if (arkAvailable() && arkWanted()) connectArk().catch(() => {}); } // Automatic re-inits (a synced snapshot arriving) must not restart a // connection that is already in flight, or retry in a tight loop when the // server is unreachable: each one calls stopArk() first, so the settings // card's "connecting" indicator flickers on every snapshot. User-driven // calls to initArk() stay immediate. let lastAutoInit = 0; function maybeInitArk() { if (ark || arkConnectPromise) return; if (Date.now() - lastAutoInit < 20000) return; initArk(); } // Operator console rescue for a round participation stranded server-side // 'unclaimed' (its action lost client-side): arkRescue('', // ['', ...]) from devtools, ids supplied from the server's DB. if (typeof globalThis !== 'undefined') { globalThis.arkRescue = async (unlockHash, inputIds) => { const mgr = await connectArk(); return mgr.rescueParticipation(unlockHash, inputIds); }; } function connectArk() { if (ark) return Promise.resolve(ark); if (arkConnectPromise) return arkConnectPromise; if (!arkAvailable()) return Promise.reject(new Error(t('arkNotConnected'))); const cfg = getArkConfig(); const gen = arkInitGen; // Persisting ark state is EXPENSIVE — a JSON.parse of the stored state, a // merge over every vtxo + movement, a JSON.stringify, a synchronous // localStorage write, then saveCache(). The manager fires _save() on every // mutation, and a boot reconcile mutates dozens of times in a burst — // that's the main-thread stall that made a mouse drag lurch right after a // refresh. The in-memory state is always current and every live READ goes // to it (arkStateNow returns ark.state); only the disk copy has to catch // up, so coalesce the writes: idle → write now, mid-burst → one write when // it quiets (capped so a steady stream still lands). Flushed on hide. let persistPending = null, persistT = 0, persistLastAt = 0, persistFirstAt = 0; const flushPersist = () => { clearTimeout(persistT); persistT = 0; persistFirstAt = 0; if (!persistPending || gen !== arkInitGen) { persistPending = null; return; } const s = persistPending; persistPending = null; persistLastAt = Date.now(); wallet.saveArkState(mergeArkStates(s, wallet.loadArkState(cfg.ark)), cfg.ark); try { wallet.saveCache(); } catch {} }; arkPersistFlushers.add(flushPersist); const mgr = new ArkManager({ account: wallet.account(), storage: { load: () => wallet.loadArkState(cfg.ark), adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, cfg.ark), // merge-on-save: the MANAGER is authoritative for the state of the // vtxos it tracks (its reconcile/spends must persist), so its state // is the first arg and wins; storage only contributes vtxos the // manager hasn't loaded yet (a snapshot merged in from another device // between init and the next reinit). saveCache() then carries ark // state into the snapshot -> debounced nostr publish. save: (s) => { persistPending = s; const now = Date.now(); if (!persistFirstAt) persistFirstAt = now; // a lone change after a quiet spell writes immediately; a burst // coalesces into one write ~200ms after it settles, but never waits // more than ~800ms so a steady stream still lands if (now - persistLastAt > 1000 && !persistT) { flushPersist(); return; } if (now - persistFirstAt > 800) { flushPersist(); return; } clearTimeout(persistT); persistT = setTimeout(flushPersist, 200); }, }, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), // the wallet's own tx table as a second witness for confirmations — // its data source is user-configurable and often healthier than the // ark preset's esplora from this device statusHint: (txid2) => { const tx = (wallet.txs || []).find((t2) => t2.txid === txid2); return tx && tx.confirmed ? { confirmed: true, block_height: tx.blockHeight || NaN } : null; }, onUpdate: (m) => { maybeAutoSelectSpending(); scheduleLnTag(m); render(); }, }); ui.arkError = ''; arkConnectPromise = mgr.init().then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; // Sync may have arrived while init awaited the server handshake. adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark)); // Boards that died before funding (a failed tx build, a closed tab) // used to linger at 'created' forever — and an in-flight-looking // action blocks auto-renewal. Anything unfunded after an hour is a // stub, not a plan; the id carries its birth time. { let swept = false; for (const a of mgr.state.actions || []) { if (a.type !== 'board' || a.fundingTxid || ['done', 'failed'].includes(a.step)) continue; const ts = Number((a.id || '').split('-')[1]) || 0; if (ts && Date.now() - ts > 3600_000) { a.step = 'failed'; a.error = a.error || 'never funded'; swept = true; } } if (swept) mgr._save(); } announceArkAddress(mgr); // ark zaps: tell nostr where our mailbox lives startNwcFunding(mgr); // NWC bridge: honor funding requests within the allowance // Publish this device's ark state to its per-device sync slot on connect. // Cross-device sync only re-published on a state CHANGE (a send/board), so // an idle device never shared its coins — another device would never see // them. Publishing on connect guarantees every device's ark balance // reaches the relay (and gets merged) without needing a transaction first. if (mgr.state && (mgr.state.vtxos || []).length) { try { wallet.saveCache(); } catch {} } // A failed mailbox read must not starve the action drives: boards and // refreshes progress on chain state alone, so push them even when the // sync itself errored. const tick = () => mgr.sync().catch(() => mgr.resumePending().catch(() => {})) .then(() => driveExits(mgr)).catch(() => {}) .then(() => { if (ark === mgr) return maybeAutoRefresh(mgr); }).catch(() => {}) .then(() => { if (ark === mgr) return maybeAutoWithdraw(mgr); }).catch(() => {}); tick(); // Reconcile once on connect: a vtxo synced in from another device (or // one this device held while a spend happened elsewhere) is checked // against the server, so a stale spendable is caught here rather than // only at send time. mgr.reconcile().catch(() => {}); mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. mgr.startMailboxStream(); scheduleLnTag(mgr); // receives that landed while this device was closed arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); render(); return mgr; }).catch((e) => { if (gen === arkInitGen) { ui.arkError = e.message; render(); } throw e; }).finally(() => { arkConnectPromise = null; }); render(); return arkConnectPromise; } // The current ark state for READ-ONLY rendering: the live manager if it's // connected, else the persisted state straight from storage. Lets the // balance and history icons paint immediately on refresh instead of // flickering (empty → populated) while the manager connects asynchronously. function arkStateNow() { if (ark && ark.state) return ark.state; // Read persisted state whenever Ark is configured for this network — even // watch-only (seed not loaded this session): the balance/history should // show read-only, just like the on-chain balance does. Acting on it // (send/exit) still requires the seed and is gated separately. const cfg = getArkConfig(); if (!cfg) return null; return wallet.loadArkState(cfg.ark); } // Which receives actually arrived over Lightning? A payment to the user's // name@coinos.io address settles on the registrar's node and lands here as // an ordinary arkoor from its float wallet — the mailbox protocol carries // no memo, so the movement alone can't name the rail the sender used. The // registrar keeps a receipt per LN settlement it forwarded; matching those // against receive movements (exact amount, delivery within a couple of // minutes) marks the row viaLn so it wears the ⚡ the sender expects. // Best-effort and cosmetic: a rare amount+time collision only mislabels an // icon. The receipt's payment hash is stamped on the matched movement so a // receipt never claims a second row on later passes. let lnTagBusy = false, lnTagT = 0, lnTagSeen = -1; async function tagLnReceives(mgr) { if (lnTagBusy) return; // nothing a receipt could match (the registrar keeps them 7 days) — // don't spend a signed fetch on every boot of a quiet wallet if (!(mgr.state.movements || []).some((m) => m.type === 'receive' && m.status === 'complete' && !m.viaLn && (m.ts || 0) > Date.now() - 7 * 86400_000)) return; lnTagBusy = true; try { const receipts = await Promise.resolve(ctx.hook('namesLnReceipts')).catch(() => null); if (!receipts || !receipts.length || ark !== mgr || !mgr.state) return; const moves = (mgr.state.movements || []) .filter((m) => m.type === 'receive' && m.status === 'complete'); const used = new Set(moves.filter((m) => m.lnHash).map((m) => m.lnHash)); let changed = false; for (const r of receipts) { if (r.hash && used.has(r.hash)) continue; let best = null; for (const m of moves) { if (m.viaLn || m.amountSat !== r.sat) continue; const dt = Math.abs((m.ts || 0) - r.ts); if (dt > 180_000) continue; if (!best || dt < Math.abs((best.ts || 0) - r.ts)) best = m; } if (!best) continue; best.viaLn = true; if (r.hash) { best.lnHash = r.hash; used.add(r.hash); } changed = true; } if (changed) mgr._save(); } finally { lnTagBusy = false; } } // Fires off onUpdate, so gate on the thing that actually warrants a fetch: // the count of untagged receives changed (a new receive landed, or a // snapshot brought some in). Debounced so a catch-up burst asks once. function scheduleLnTag(mgr) { const n = (mgr.state.movements || []).reduce((c, m) => c + (m.type === 'receive' && m.status === 'complete' && !m.viaLn ? 1 : 0), 0); if (n === lnTagSeen) return; lnTagSeen = n; clearTimeout(lnTagT); lnTagT = setTimeout(() => { if (ark === mgr) tagLnReceives(mgr).catch(() => {}); }, 2000); } // A wallet that opens onto "Savings: 0" while its money sits in Spending // is answering the wrong question — a stale device-global selection (or // the default) shouldn't hide the balance. One shot per wallet open; any // manual switch afterwards is respected. let _autoSelected = false; function maybeAutoSelectSpending() { if (_autoSelected || wallet.watchOnly) return; // The user beat us to it: they've already scrolled/tapped/flicked to an // account this session. The "open onto Spending when Savings is empty" // convenience only applies to the UNTOUCHED open — arriving late (when // the ark balance finally syncs, a second after a refresh) and yanking // the card away from the one they just chose is the exact opposite of // convenient. Consume the one-shot so it can't fire behind them. if (ui.accountUserChosen) { _autoSelected = true; return; } const b = arkBalance(); if (!b || b.spendableSat + b.pendingSat <= 0) return; _autoSelected = true; if ((wallet.spendable || 0) === 0 && ctx.getAccount() === 'savings') { ui.navAnimSkip = true; // a correction at open paints, never slides ctx.setAccount('spending'); // renders synchronously; the flip is decided here ui.navAnimSkip = false; // don't let the one-shot eat the next real navigation } } function arkBalance() { const s = arkStateNow(); if (!s) return null; const sum = (st) => (s.vtxos || []).filter((v) => v.state === st && !v.expiryRejected).reduce((n, v) => n + v.amountSat, 0); const boardingSat = (s.actions || []) .filter((a) => a.type === 'board' && a.fundingTxid && !['done', 'failed'].includes(a.step)) .reduce((n, a) => n + (a.amountSat - a.feeSat), 0); return { spendableSat: sum('spendable'), pendingSat: sum('pending'), boardingSat }; } // An ark address in the send form signals a send is coming: verify our // spendable vtxos against the server now, so a stale one (same seed active // elsewhere, restored state) is dropped before coin selection instead of // failing at cosign time. Throttled — this fires from the render path. let arkReconciledAt = 0; function maybeReconcile() { if (Date.now() - arkReconciledAt < 30_000) return; arkReconciledAt = Date.now(); connectArk().then((mgr) => mgr.reconcile()).catch(() => {}); } function arkSendReview() { if (ui.arkSent) { return h( 'div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', onClick: () => { ui.arkSent = null; ui.send = blankSend(); render(); }, }, h('div', { class: 'check-badge' }, '✓'), h('h2', { style: 'margin:0' }, t('arkSentTitle')), h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkSent.amountSat) + ' ' + unitLabel()), h('div', { class: 'small muted' }, t('tapToProceed')) ); } const a = ui.arkSend; const row = (k, v) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' }, v)); return h( 'div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, t('arkSendTitle')), row(t('lnPayAmount'), fmtAmount(a.amountSat) + ' ' + unitLabel()), row(t('arkPayTo'), shortAddr(a.address, 14)), row(t('networkFee'), t('arkNoFee')), ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, // An amount Spending can't cover isn't a dead end while Savings can: // the Send button becomes the door to the board panel, prefilled with // what this payment needs — instead of letting the send run into an // "insufficient ark balance" wall. h('div', { class: 'row gap6' }, 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 ? h('button', { class: 'btn-primary grow', onClick: () => { const need = a.amountSat; ui.arkSend = null; ui.sendError = ''; ctx.hook('arkOfferBoard', need); } }, t('zapBoardBtn')) : h('button', { class: 'btn-primary grow', onClick: doArkSend }, t('arkSendBtn')) ) ); } async function doArkSend() { const a = ui.arkSend; ui.busy = true; ui.sendError = ''; render(); try { const mgr = await connectArk(); await mgr.send(a.address, a.amountSat); ui.arkSent = { amountSat: a.amountSat }; ui.arkSend = null; } catch (e) { ui.sendError = e.message; } ui.busy = false; render(); } // ---- lightning via the ASP (captaind pays/holds invoices natively) ------- // One poll timer per in-flight lightning action; cleared on settle. const arkLnTimers = new Map(); function stopArkLnPoll(id) { const timer = arkLnTimers.get(id); if (timer) { clearInterval(timer); arkLnTimers.delete(id); } } function pollArkLn(id, onSettle) { stopArkLnPoll(id); // One held call brings the settlement the moment the server has it; // the interval below is the fallback pace if that call breaks. if (ark) { ark.awaitLnPay(id).then((a) => { if (a && ['done', 'failed'].includes(a.step) && arkLnTimers.has(id)) { stopArkLnPoll(id); onSettle(a); } render(); }).catch(() => {}); } arkLnTimers.set(id, setInterval(async () => { if (!ark) return; const a = await ark.driveLn(id).catch(() => null); if (a && ['done', 'failed'].includes(a.step)) { stopArkLnPoll(id); onSettle(a); } render(); }, 2500)); } // ---- background NWC mirror --------------------------------------------- // The service worker answers wallet-connect requests while the app is // closed, spending the SAME ark balance the app does. The mirror is the // full manager state plus the chain-3 keys for its live coins and a window // of upcoming change indices (the worker cannot derive). nwc.js decides // WHEN to mirror (background answering on) and supplies the connections. // 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) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; const 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; const rec = buildBg({ ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }, mgrState: ark.state, keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey), connections, offer, autowithdraw: await awMirror(prev), }); if (prev) rec.spends = prev.spends; // 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(() => {}); } // On connect, fold whatever the worker did while we were closed back into // the live state: its spent flips and change vtxos merge in (the additive // merge never resurrects anything reconcile() disproves), its key // allocations advance ours, and its in-flight ln actions become resumable. async function mergeBgWorkerState(mgr) { const rec = await loadBg(wallet._cacheKey()); if (!rec) return; absorbBgAutoWithdraw(rec); if (rec.v < 3) { await sweepLegacyPouch(rec).catch((e) => console.warn('ark: pouch sweep failed', e.message)); return; } if (!rec.mgr) return; 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])); for (const v of merged.vtxos) { if (workerState.get(v.id) === 'spent') v.state = 'spent'; } merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); await mgr.reconcile().catch(() => {}); await mgr.resumePending().catch(() => {}); } // A leftover chain-6 pouch from before whole-balance background spending: // sweep its coins home using the keys stored in the record itself, then // let the next mirror write replace it. async function sweepLegacyPouch(rec) { if (!(rec.vtxos || []).some((v) => (v.state || 'spendable') === 'spendable')) return; const cfg = getArkConfig(); const mgr = await connectArk(); const shim = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(rec.keys[String(i)] || rec.keys['0']) }) }) }; let state = { v: 1, serverPubkey: rec.ark?.serverPubkey || null, mailboxCheckpoint: 0, nextKeyIndex: rec.nextKeyIndex || 20, vtxos: (rec.mgr?.vtxos || rec.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })), actions: [], movements: [], }; const pm = await new ArkManager({ account: shim, storage: { load: () => state, save: (s2) => { state = s2; } }, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), }).init(); const dest = mgr.address(); for (let guard = 0; guard < 30; guard++) { const v = (pm.state.vtxos || []).find((x) => x.state === 'spendable'); if (!v) break; await pm.send(dest, v.amountSat); } await saveBg(wallet._cacheKey(), { ...rec, vtxos: [], mgr: state, sweptAt: Date.now() }); } // Pay a bolt11 with no UI at all — used by the NWC wallet service. Same // routing as the interactive path: the cheaper bridge first, the ASP's own // lightning-send as fallback. Resolves { preimage, feeSat, amountSat }. async function payInvoiceHeadless(invoice, { maxAmountSat } = {}) { const dec = maybeBolt11(invoice); if (!dec) throw new Error('not a bolt11 invoice'); if (!dec.amountSat) throw new Error('zero-amount invoices are not supported'); if (maxAmountSat && dec.amountSat > maxAmountSat) { throw new Error(`amount ${dec.amountSat} exceeds the limit of ${maxAmountSat} sat`); } const mgr = await connectArk(); // The sync merge is additive, so a vtxo spent on another device can sit // in local state looking spendable until someone checks. The interactive // send path reconciles on intent; this headless path must too, or an NWC // payment picks the stale coin and the ASP rejects it ("state: spent"). await mgr.reconcile().catch(() => {}); // Up to three attempts: each failure refunds in full, and the next try // brings one extra sat of routing budget for liquidity that shifted // between the quote and the send. let last = null; for (let attempt = 0; attempt < 3; attempt++) { const routingFeeSat = attempt === 0 ? undefined : (last?.routingFeeSat ?? 0) + 1; const id = await mgr.payLnInvoice(invoice, { routingFeeSat }); await mgr.awaitLnPay(id).catch(() => {}); for (let i = 0; i < 60; i++) { const a = mgr.lnAction(id); if (!a || ['done', 'failed'].includes(a.step)) break; await new Promise((r) => setTimeout(r, 2000)); await mgr.driveLn(id).catch(() => {}); } const a = mgr.lnAction(id); if (a && a.step === 'done') return { preimage: a.preimage, feeSat: a.feeSat, amountSat: a.amountSat }; last = a; if (a && a.step === 'failed' && !lnRetryable(a)) break; if (!a || a.step !== 'failed') break; // still pending after 2 min — don't double-pay } throw new Error(last?.error || last?.lastError || 'payment did not complete'); } // A bolt11 lands in Send (via the swaps feature's delegation hook, or // directly when swaps is absent): take over when the ark balance can // plausibly cover it, else decline so the Boltz path handles it. function startArkLnPay(invoice, meta) { const dec = maybeLnInvoice(invoice); if (!dec || !arkAvailable() || wallet.watchOnly) return false; const s = arkStateNow(); // Coverage by SUM: multi-input pays gather up to 24 coins, so judging by // the single largest coin declined invoices the wallet could pay — a // tester holding 204+29 sats pasted a ~220-sat invoice and nothing // happened at all. Fees are the quote's business, not this gate's. const sum = (state) => (s ? (s.vtxos || []) : []).filter((v) => v.state === state).reduce((n, v) => n + v.amountSat, 0); // pending funds count for the take-over decision: an in-flight payment or // revocation frees them in seconds if (sum('spendable') < (dec.amountSat || 1) && sum('pending') < (dec.amountSat || 1)) return false; // Will this pay itself (an armed budget that covers it)? Known before the // quote — from the invoice amount and the stored budget — so the view can // go straight to a paying screen and never flash the review card first. const armedBudget = meta && meta.autopay && meta.budgetKey && ctx.lnBudget ? ctx.lnBudget(meta.budgetKey) : 0; const autopaying = !!(meta && meta.autopay && dec.amountSat != null && armedBudget > 0 && dec.amountSat <= armedBudget); ui.arkLnPay = { invoice, meta: meta || null, amountSat: dec.amountSat, amount: '', feeSat: null, status: 'quote', autopaying }; ui.sendError = ''; render(); // An auto-pay recipient (a budget the user set) pays as soon as the quote // lands — no tap. Guarded to the budget and to a single fire; anything // off-script (over budget, no amount, a quote error) falls back to the // normal review with the Pay button. const maybeAutopay = () => { const p2 = ui.arkLnPay; if (!p2 || p2.invoice !== invoice || p2._autopaid) return; if (p2.status !== 'ready' || !p2.meta || !p2.meta.autopay || p2.amountSat == null) return; const budget = p2.meta.budgetKey && ctx.lnBudget ? ctx.lnBudget(p2.meta.budgetKey) : 0; if (!(budget > 0 && p2.amountSat <= budget)) return; p2._autopaid = true; doArkLnPay(); }; quoteArkLnPay(invoice, meta).then(maybeAutopay).catch(() => { // The quote is a nicety — a dead esplora or quote service must not // wedge the form at an eternal spinner (it did: "Getting a quote…" // above a Failed-to-fetch, Pay unreachable). Price it like the outage // fallback and let Pay proceed; the payment path quotes again with // its own fallbacks and locks only what the route actually needs. const p2 = ui.arkLnPay; if (!p2 || p2.invoice !== invoice) return; p2.feeSat = p2.amountSat ? Math.max(3, Math.ceil(p2.amountSat / 1000)) : null; p2.status = 'ready'; ui.sendError = ''; render(); maybeAutopay(); }); return true; } // Quote (or re-quote) an in-form lightning pay: smallest vtxo that covers // amount + its expiry-based fee. Funds locked by a settling action retry // themselves free; genuinely insufficient funds hand off to Boltz. async function quoteArkLnPay(invoice, meta) { const mgr = await connectArk(); const p = ui.arkLnPay; if (!p || p.invoice !== invoice) return; if (!p.amountSat) { p.status = 'ready'; render(); return; } const tip = await mgr.chain.tipHeight(); // what the lightning network itself will charge (0 for coinos-to-coinos) const routing = await mgr.lnRouteFee(invoice, p.amountSat); if (!ui.arkLnPay || ui.arkLnPay !== p || p.invoice !== invoice) return; p.routingFeeSat = routing; const spendables = mgr.vtxos().filter((v) => v.state === 'spendable'); let fee = null; for (const v of [...spendables].sort((a, b) => a.amountSat - b.amountSat)) { const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, [v], tip) + routing; if (v.amountSat >= p.amountSat + f) { fee = f; break; } } if (fee == null) { // no single coin covers it — the pay path gathers several, so quote for // the same largest-first set it will pick const picked = []; let sum = 0; for (const v of [...spendables].sort((a, b) => b.amountSat - a.amountSat)) { picked.push(v); sum += v.amountSat; const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, picked, tip) + routing; if (sum >= p.amountSat + f) { fee = f; break; } } } if (fee == null) { const pendingCover = mgr.vtxos().some((v) => v.state === 'pending' && v.amountSat >= p.amountSat); if (pendingCover) { p.status = 'fundsPending'; render(); mgr.resumePending().catch(() => {}); // nudge whatever holds them setTimeout(() => { if (ui.arkLnPay === p) quoteArkLnPay(invoice, meta).catch(() => {}); }, 4000); return; } // can't cover once fees are in ui.arkLnPay = null; ui.sendError = t('arkLnPayFailed'); render(); return; } p.feeSat = fee; p.status = 'ready'; render(); } async function doArkLnPay() { const p = ui.arkLnPay; const sats = p.amountSat || ctx.parseAmount(p.amount, ctx.getUnit()); if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; } ui.busy = true; ui.sendError = ''; render(); try { const mgr = await connectArk(); // Routing conditions can shift between quote and send: a failed // (and fully refunded) attempt re-tries with one extra sat of routing // budget before giving up — askrene fees are exact, liquidity isn't. let tries = 0; const attempt = async (routingFeeSat) => { const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats, routingFeeSat }); const a = mgr.lnAction(id); if (['done', 'failed'].includes(a.step)) settle(a); else { p.actionId = id; p.status = 'paying'; pollArkLn(id, settle); } }; const settle = (a) => { if (a.step === 'done') { ui.arkLnPaid = { amountSat: a.amountSat, meta: p.meta, autopaid: !!(p._autopaid || p.autopaying) }; if (p.meta && p.meta.pk) noteZap('inv:' + p.invoice, p.meta.pk); // the user opted this recipient into auto-pay: remember the chosen // budget so future fixed charges from them clear without a tap if (p.setBudget && p.meta && p.meta.budgetKey && ctx.setLnBudget) ctx.setLnBudget(p.meta.budgetKey, p.setBudget); ui.arkLnPay = null; } else if (ui.arkLnPay) { if (tries < 2 && lnRetryable(a)) { tries += 1; p.retrying = tries; // the spinner narrates instead of stonewalling render(); attempt((a.routingFeeSat ?? 0) + 1).catch((e) => { ui.arkLnPay.status = 'ready'; ui.sendError = e.message; render(); }); return; } ui.arkLnPay.status = 'ready'; ui.sendError = a.error === 'payment failed' ? t('arkLnRefunded') : (a.error || t('arkLnPayFailed')); } render(); }; await attempt(p.routingFeeSat); } catch (e) { ui.sendError = e.message; } ui.busy = false; render(); } // A failed lightning pay is worth re-trying with a bumped routing budget // unless the error says the attempt itself was ill-formed. function lnRetryable(a) { return !a?.error || !/already|invalid|expired|unpayable|insufficient|consolidate|no amount|exceeds/i.test(a.error); } // The most a Lightning pay can carry: the spendable sum over the 24-input // cap, minus the schedule fee and the conservative routing reserve. The // real quote at pay time confirms or trims — Max leaves a few sats on the // table rather than promising ones the fees will eat. function lnMaxSat() { const s = arkStateNow(); const coins = (s ? (s.vtxos || []) : []).filter((v) => v.state === 'spendable') .sort((a2, b2) => b2.amountSat - a2.amountSat).slice(0, 24); const sum = coins.reduce((n, v) => n + v.amountSat, 0); if (!sum) return 0; const tip = (ark && ark._tipH) || 0; const fees = (ark && ark.info && ark.info.lnSendFees) || {}; let amt = sum; for (let i = 0; i < 3; i++) { const f = lnSendFee(amt, fees, coins, tip) + Math.max(3, Math.ceil(amt / 1000)); amt = sum - f; } return Math.max(0, amt); } const amountFill = (sat) => (ctx.getUnit() === 'sats' ? String(sat) : (sat / 1e8).toFixed(8)); // Auto-pay progress + success as ONE structurally identical card, so the // paying → paid step is a class flip (spinner ring settles into the green // check, amount and recipient never move) instead of a text/layout swap. No // "Paying" wording at all — the badge animation carries the state. const autopayCard = (paid, meta, amountSat) => h('div', { class: 'card col autopay-card' + (paid ? ' paid' : ''), style: 'align-items:center;text-align:center;gap:14px;padding:48px 20px' + (paid ? ';cursor:pointer' : ''), onClick: paid ? () => { ui.arkLnPaid = null; ui.send = blankSend(); render(); } : undefined, }, h('div', { class: 'check-badge autopay-badge' }, '⚡'), meta && meta.name ? h('div', { class: 'small muted' }, meta.name) : null, h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(amountSat) + ' ' + unitLabel()), h('div', { class: 'small muted autopay-foot' }, paid ? t('tapToProceed') : '')); function arkLnPayView() { const u = ' ' + unitLabel(); if (ui.arkLnPaid) { const zap = ui.arkLnPaid.meta; if (ui.arkLnPaid.autopaid) return autopayCard(true, zap, ui.arkLnPaid.amountSat); return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', onClick: () => { ui.arkLnPaid = null; ui.send = blankSend(); render(); }, }, h('div', { class: 'check-badge' }, '⚡'), h('h2', { style: 'margin:0' }, zap && zap.social ? t('arkZapSentTitle') : t('arkLnPaidTitle')), zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null, h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkLnPaid.amountSat) + u), h('div', { class: 'small muted' }, t('tapToProceed'))); } const p = ui.arkLnPay; if (!p) return null; const zap = p.meta; // Auto-pay in flight: the progress half of the same card (see autopayCard). if (p.autopaying) return autopayCard(false, p.meta, p.amountSat); const row = (label, sats, bold) => h('div', { class: 'row between' + (bold ? '' : ''), style: bold ? 'font-weight:600' : '' }, h('span', { class: bold ? '' : 'small muted' }, label), h('span', {}, fmtAmount(sats) + u)); const total = (p.amountSat || 0) + (p.feeSat || 0); return h('div', { class: 'col', style: 'gap:12px' }, h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { style: 'margin:0' }, '⚡ ' + (zap && zap.social ? t('lnZapReviewTitle') : t('lnPayTitle'))), h('div', { class: 'small muted' }, t('arkLnPayVia')), zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null, zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null, p.status === 'quote' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnQuoting'))) : null, p.status === 'fundsPending' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkLnFundsPending'))) : null, p.amountSat == null && p.status !== 'quote' ? h('div', { class: 'input-group' }, h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: p.amount, onInput: (e) => { p.amount = e.target.value; } }), h('button', { type: 'button', onClick: () => { p.amount = amountFill(lnMaxSat()); render(); } }, t('max')), h('div', { style: 'display:flex;align-items:center' }, unitTag())) : null, p.amountSat != null ? row(t('lnPayAmount'), p.amountSat) : null, p.feeSat != null && p.feeSat > 0 ? row(t('arkLnFee'), p.feeSat) : null, p.amountSat != null && p.feeSat != null ? [h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), row(t('lnPayTotal'), total, true)] : null), ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, // Offer to skip this confirmation next time: pick a budget and future // fixed charges from this recipient up to it auto-pay without this // screen. The chosen amount is stored on a successful pay (see settle). (zap && zap.budgetKey && zap.fixedSat && !zap.autopay && p.status === 'ready' && !(ctx.lnBudget && ctx.lnBudget(zap.budgetKey) > 0)) ? h('div', { class: 'col', style: 'gap:6px' }, h('span', { class: 'small muted' }, t('lnAutopaySetLabel', { name: zap.name || zap.address || '' })), h('div', { class: 'row gap6', style: 'flex-wrap:wrap' }, ...[1000, 5000, 10000, 100000].map((amt) => h('button', { type: 'button', class: (p.setBudget === amt ? 'btn-primary' : 'btn-ghost') + ' btn-sm', onClick: () => { p.setBudget = p.setBudget === amt ? 0 : amt; render(); } }, fmtAmount(amt) + ' ' + unitLabel())))) : null, p.status === 'paying' ? h('div', { class: 'card row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, p.retrying ? t('arkLnRetrying', { n: p.retrying + 1, of: 3 }) : t('arkLnPaying'))) : h('button', { class: 'btn-primary btn-block', disabled: !!ui.busy || p.status !== 'ready', onClick: doArkLnPay }, ui.busy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')), p.status !== 'paying' ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkLnPay = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')) : null); } async function doArkLnInvoice() { const sats = parseInt((ui.arkLnRecvAmt || '').trim(), 10); if (!sats) return; ui.arkBusy = 'lninvoice'; ui.arkError = ''; render(); try { const mgr = await connectArk(); const a = await mgr.createLnInvoice(sats); ui.arkLnRecvId = a.id; pollArkLn(a.id, () => render()); // 'done' -> celebration via unseenReceives } catch (e) { ui.arkError = e.message; } ui.arkBusy = null; render(); } // Who a zap went to, keyed by what the movement records (the ark address // for direct zaps, the invoice for Lightning ones) — enough to put a face // on history rows without touching the manager's movement schema. const zapNotes = () => wallet.loadFeatureState('zapnotes', {}); function noteZap(key, pk) { const s = zapNotes(); s[key] = pk; const keys = Object.keys(s); if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) delete s[k]; wallet.saveFeatureState('zapnotes', s); } const zapNoteFor = (m) => m.type === 'send' && m.to ? zapNotes()['to:' + m.to] : m.type === 'ln-send' && m.invoice ? zapNotes()['inv:' + m.invoice] : null; function arkHistoryItem(m) { const failed = m.status === 'failed'; const incoming = !['send', 'offboard', 'exit', 'ln-send', 'refresh'].includes(m.type); const label = failed ? t('arkLnPayFailed') : m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory') : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : m.type === 'refresh' ? t('arkRenewedHistory') : t('sent'); // A spent input proves neither a payment amount nor its date: it can // contain change, an offboard, or a renewal this device has not synced. // Show the missing history explicitly until its records arrive. if (m.type === 'reconcile') { return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkReconDetail = { amountSat: m.amountSat, ts: m.ts, vtxoIds: [...(m.vtxoIds || [])] }; render(); }, }, h('div', { class: 'ico' }, '↻'), h('div', { class: 'grow' }, h('div', {}, t('arkHistoryIncomplete')), h('div', { class: 'small faint' }, t('arkDetailsNotSynced'))), h('div', { style: 'text-align:right' }, h('div', { class: 'small muted' }, fmtAmount(m.amountSat, m.ts / 1000) + ' ' + unitLabel()))); } // a renewal moves nothing anywhere — its history amount is what it COST if (m.type === 'refresh') { return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, h('div', { class: 'ico' }, '↻'), h('div', { class: 'grow' }, h('div', {}, label), h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), h('div', { style: 'text-align:right' }, m.feeSat > 0 ? h('div', { class: 'amount-neg' }, '-' + fmtAmount(m.feeSat)) : h('div', { class: 'small faint' }, t('arkDepthFree')))); } return h( 'div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, // The circle's glyph names the RAIL the money actually rode: ⚡ for // Lightning (including a receive the registrar forwarded from an LN // settlement — viaLn, matched against its receipts), the Bitcoin mark // for the on-chain movements (board pulls coins in from chain; // offboard/exit push them back out — each is a real tx with a txid), // and the Ark mark for pure off-chain hops. // Direction is carried by the tint + signed amount, no text chip. m.type.startsWith('ln-') || m.viaLn ? h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, '⚡') : ONCHAIN_ARK.has(m.type) ? h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: BITCOIN_ICON(20) }) : h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: ARK_MARK(15) }), h('div', { class: 'grow' }, h('div', { class: 'row gap6', style: 'align-items:center' }, (() => { const g = giftForMovement(m); if (!g) return label; return h('span', {}, '🎁 ' + t('giftHistoryTitle'), ' ', 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) : null), h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), 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' }, (failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat, m.ts / 1000))) ); } const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now(); // Only link a tx that is KNOWN to be out there: the claim, a confirmed // hop, or the finished chain. Guessing from lastError made the link // flicker in time with the retry loop. const exitWatchTxid = (a) => a.claimTxid || (a.step !== 'chain' ? (a.txids || [])[(a.txids || []).length - 1] : a.hopsDone > 0 ? (a.txids || [])[Math.min(a.hopsDone, (a.txids || []).length - 1)] : null); function arkExitActionItem(a) { return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkExitDetail = a.id; render(); } }, h('div', { class: 'ico out', html: ARK_MARK(15) }), h('div', { class: 'grow' }, h('div', { class: 'row gap6', style: 'align-items:center' }, t('arkExitingHistory'), h('span', { class: 'tag pending' }, t('arkExitPendingTag'))), h('div', { class: 'small faint' }, timeAgo(exitStartedTs(a) / 1000))), h('div', { style: 'text-align:right' }, h('div', { class: 'amount-neg' }, '-' + fmtAmount(a.amountSat)))); } function arkExitActionDetail(a) { const exitDelta = (ark && ark.info && ark.info.vtxoExitDelta) || 144; const blocks = a.step === 'chain' ? ((a.txids || []).length - (a.hopsDone || 0)) + exitDelta + 1 : a.step === 'timelock' ? Math.max(0, a.claimableAt - (a.tipSeen || 0)) + 1 : 1; const mins = blocks * 10; const eta = getNetwork() === 'regtest' ? t('arkExitSoon') : mins >= 2880 ? `${Math.round(mins / 1440)} days` : mins >= 120 ? `${Math.round(mins / 60)} hours` : `${mins} minutes`; const watch = exitWatchTxid(a); const url = watch ? wallet.api.explorerTx(watch) : null; const cancellable = a.actionable && !(a.hopsDone > 0) && a.step === 'chain'; const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' }, h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v)); return h('div', { class: 'card col', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { html: ARK_ICON(18) }), h('h3', { style: 'margin:0' }, t('arkExitingHistory')), h('span', { class: 'tag pending' }, t('arkExitPendingTag'))), h('div', { class: 'amount-neg', style: 'font-size:20px' }, '-' + fmtAmount(a.amountSat) + ' ' + unitLabel()), row(t('dateLabel'), new Date(exitStartedTs(a)).toLocaleString()), h('div', { class: 'small muted' }, t('arkExitSummary', { n: fmtSats(a.amountSat), eta })), a.lastError ? h('div', { class: a.actionable ? 'small err' : 'small faint' }, a.actionable ? a.lastError : t('arkExitRetrying')) : null, url ? h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener' }, t('arkExitViewTx')) : null, cancellable ? h('button', { class: 'linklike small', onClick: () => { a.step = 'failed'; const v = ark && ark._vtxo(a.vtxoId); if (v && v.state === 'pending') v.state = 'spendable'; if (ark) ark._save(); ui.arkExitDetail = null; render(); } }, t('arkExitCancel')) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.arkExitDetail = null; }) }, t('back'))); } // One label, N monospace coin ids — the send detail's consumed inputs and // the reconcile detail's vanished coins share the shape. const coinListRows = (label, ids) => !ids || !ids.length ? null : h('div', { class: 'col', style: 'gap:4px' }, ...ids.map((id, i) => h('div', { class: 'row between', style: 'gap:12px' }, h('span', { class: 'small muted', style: 'flex-shrink:0' }, i === 0 ? label : ''), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, shortTxid(id))))); // Detail for a folded reconcile row: the hard facts only — when this wallet // learned of it, the original coin total, and which coins were consumed. function arkReconDetailView(d) { const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' }, h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v)); return h('div', { class: 'card col', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { html: ARK_ICON(18) }), h('h3', { style: 'margin:0' }, t('arkHistoryIncomplete'))), h('div', { class: 'small muted' }, t('arkHistoryIncompleteHelp')), row(t('arkOriginalCoins'), fmtAmount(d.amountSat) + ' ' + unitLabel()), row(t('arkDetectedAt'), new Date(d.ts).toLocaleString()), coinListRows((d.vtxoIds || []).length > 1 ? t('arkVtxoInputs') : t('arkVtxoId'), d.vtxoIds), h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.arkReconDetail = null; }) }, t('back'))); } function arkMoveDetailView(m) { const failed = m.status === 'failed'; const incoming = m.type === 'refresh' ? false : !['send', 'offboard', 'exit', 'ln-send'].includes(m.type); const label = failed ? t('arkLnPayFailed') : m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory') : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : m.type === 'refresh' ? t('arkRenewedHistory') : t('sent'); const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' }, h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v)); const url = m.txid ? wallet.api.explorerTx(m.txid) : null; return h( 'div', { class: 'card col', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, // the rail the user chose is the one to show: a payment SENT over // Lightning stays Lightning here, however it settled underneath; an // on-chain move (board/offboard/exit) wears the Bitcoin mark m.type.startsWith('ln-') || m.viaLn ? h('span', { style: 'font-size:18px' }, '⚡') : ONCHAIN_ARK.has(m.type) ? h('span', { html: BITCOIN_ICON(18) }) : h('span', { html: ARK_ICON(18) }), h('h3', { style: 'margin:0' }, label), m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status) : null), m.type === 'refresh' ? h('div', { class: m.feeSat > 0 ? 'amount-neg' : 'small faint', style: 'font-size:20px' }, m.feeSat > 0 ? '-' + fmtAmount(m.feeSat) + ' ' + unitLabel() : t('arkDepthFree')) : h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg', style: 'font-size:20px' }, (failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat, m.ts / 1000) + ' ' + unitLabel()), row(t('dateLabel'), new Date(m.ts).toLocaleString()), ctx.worthLine ? ctx.worthLine(m.amountSat, m.ts / 1000, row) : null, (() => { const pk = zapNoteFor(m); const chip = pk ? ctx.hook('profileChip', pk) : null; return chip ? h('div', { class: 'row between', style: 'gap:12px;align-items:center' }, h('span', { class: 'small muted' }, t('recipient')), chip) : null; })(), m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null, m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : 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 // on-chain weight and the ASP's service charge (base + lifetime ppm) — // shown apart so neither reads as the other being overpriced. m.chainFeeSat != null && m.serviceFeeSat != null ? h('div', { class: 'col', style: 'gap:10px' }, row(t('arkFeeChain'), fmtAmount(m.chainFeeSat) + ' ' + unitLabel()), row(t('arkFeeService'), fmtAmount(m.serviceFeeSat) + ' ' + unitLabel())) : m.detail ? row(t('detailsLabel'), m.detail) : null, // proof of payment: the preimage is what a merchant asks for (its copy // button rides the grouped small-button row below with the others) m.preimage ? row(t('preimageLabel'), shortTxid(m.preimage)) : null, // A send that funded a bearer gift: show its fate, and while unclaimed // the sweep-back — the copy-link lives in the grouped small-button row // below, next to the other copy actions. (() => { const g = giftForMovement(m); if (!g) return null; refreshArkGiftRecords(); const open = !g.claimed && !g.revoked; return h('div', { class: 'col', style: 'gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', {}, '🎁 ' + t('giftHistoryTitle')), h('span', { class: g.claimed ? 'tag conf' : 'tag' }, g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag'))), open ? (ui.busy && ui.revokeId === g.id ? h('button', { class: 'btn-block btn-danger', disabled: true }, h('span', { class: 'spinner sm' })) : h('button', { class: 'btn-block btn-danger', onClick: async () => { ui.revokeId = g.id; ui.busy = true; render(); try { await doArkGiftRevoke(g.id); toast(t('giftArkRevoked')); } catch (e) { toast(e.message); } ui.busy = false; ui.revokeId = null; render(); } }, t('giftRevoke'))) : null); })(), m.txid ? h('div', { class: 'col', style: 'gap:6px' }, h('div', { class: 'small muted' }, t('transactionId')), h('div', { class: 'addr-box', style: 'width:100%' }, m.txid)) : null, // All the small copy/view actions travel TOGETHER in one wrapping row at // their natural width — not each stretched full-width and split apart by // the block buttons. The block actions (Revoke above, Back below) own // the full width; these are secondary and read as a cluster. (() => { 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, 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, m.to ? copyBtn(m.to, t('copyAddress')) : null, ].filter(Boolean); return chips.length ? h('div', { class: 'row gap6 wrap' }, ...chips) : null; })(), h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.arkMoveDetail = null; }) }, t('back')) ); } async function doArkBoard() { const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); if (!sats) return; ui.arkBusy = 'board'; ui.arkError = ''; render(); let unfunded = null; // the action id, until the funding tx is on the wire try { const { actionId, fundingAddress, feeSat } = await ark.startBoard(sats); unfunded = actionId; const feeRate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; const draft = wallet.buildTx({ recipients: [{ address: fundingAddress, amount: sats }], feeRate, noSort: true }); const hexTx = wallet.sign(draft.tx); const txid = await wallet.broadcast(hexTx); unfunded = null; // money moved — this action must live on whatever happens next // Like the main send flow: reflect the spend locally and let the // poll/watcher reconcile. A scan here would race the explorer's indexing // and could resurrect the just-spent coin. wallet.applySentTx(draft.tx); await ark.completeBoard(actionId, txid); ui.arkBoardAmt = ''; ui.arkBoarded = { txid, netSat: sats - feeSat }; } catch (e) { // A board that never funded is not in flight. Leaving the stub at // 'created' looked like an in-flight action forever — which silently // blocked auto-renewal (it refuses to start a round beside one). if (unfunded && ark && ark.state) { const a = (ark.state.actions || []).find((x) => x.id === unfunded); if (a && !a.fundingTxid) { a.step = 'failed'; a.error = e.message; try { ark._save(); } catch {} } } ui.arkError = e.message; } ui.arkBusy = null; render(); } // ---- ark zaps (draft protocol, NIP-61-shaped) ---------------------------- // Lightning zaps (NIP-57) are built around LNURL + bolt11; ark needs // neither. Mirroring nutzaps (NIP-61): the recipient announces their ark // address as a replaceable event, the sender pays with a plain instant // arkoor send and publishes a receipt referencing the delivered vtxo. // Kinds are provisional until a NIP lands. const ARK_INFO_KIND = 10037; // replaceable: ["ark",
], ["network", ] const ARK_ZAP_KIND = 9737; // receipt: ["p", pk], ["e", note?], ["amount", sats], ["vtxo", id], ["network", net] // npub decode without importing the nostr stack (keeps ark-only builds lean) function npubToHex(s) { try { const { prefix, words } = bech32.decode(String(s || '').trim(), 200); if (prefix !== 'npub') return null; return hex.encode(new Uint8Array(bech32.fromWords(words))); } catch { return null; } } // Tell the world where our ark mailbox lives (once per address, and only // when the sync/nostr feature is present in this build). function announceArkAddress(mgr) { if (!wallet.nostrPublish) return; const addr = mgr.address(); if (mgr._zapAnnounced === addr) return; mgr._zapAnnounced = addr; wallet.nostrPublish({ kind: ARK_INFO_KIND, tags: [['ark', addr], ['network', getNetwork()]] }).catch(() => {}); } // Resolve an npub to a same-network ark address via their announcement. // Paying a PERSON (an npub): resolve every way they can be paid and take // the best — ark over lightning over on-chain. // 1. their ark address: the kind-10037 advert, or an ark= instruction in // BIP-353 (their coinos name via the registrar, or their nip05 name's // own DNS record — a bare domain nip05 is the BIP-353 "_" user) // 2. lightning: profile lud16/lud06, via the zap flow // 3. an on-chain address from the BIP-353 record const NAMES_REGISTRAR = 'https://names.coinos.io'; function startNpubPay(pk, npub, eventId = null, autoSat = 0) { const z = (ui.arkZap = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat }); ui.zap = null; // a stale Lightning-zap card must not resurface behind ours ui.sendError = ''; render(); const live = () => ui.arkZap === z; // The tap already put a pulsing chip on the note being zapped: whichever // way this goes, say so, so the chip settles instead of pulsing until it // times out. (A handover to the Lightning flow doesn't report — that flow // is still carrying the same zap, and reports for itself.) const bail = () => { if (!z.autoSat) return; // a form zap: the card keeps the screen and its own status ctx.hook('zapSettled', z.eventId, false); z.autoSat = 0; ctx.showSend(); }; // an instant zap: resolution succeeded — pay the default amount now, no // form, and report by toast; failures fall back to the classic form const auto = async (label) => { try { await performArkZap(z, z.autoSat); if (ui.arkZap === z) ui.arkZap = null; ctx.hook('zapSettled', z.eventId, true, z.autoSat); toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() })); render(); } catch (e) { bail(); ui.sendError = e.message; render(); } }; (async () => { connectArk().catch(() => {}); // 1a. ark advert const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' })); if (!live()) return; if (adv.status === 'ready') { Object.assign(z, adv); if (z.autoSat) return auto(); render(); return; } if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(); render(); return; } // 1b. BIP-353 with an ark instruction; remember on-chain as last resort const uris = []; try { const j = await (await fetch(`${NAMES_REGISTRAR}/pubkey/${pk}`)).json(); if (j && j.uri) uris.push(j.uri); } catch {} const profile = wallet.nostrProfile ? await wallet.nostrProfile(pk).catch(() => null) : null; if (!live()) return; if (profile && typeof profile.nip05 === 'string' && profile.nip05.trim()) { const nip05 = profile.nip05.trim().toLowerCase(); const parsed = parsePaymentName(nip05.includes('@') ? nip05 : '_@' + nip05); if (parsed) { try { const uri = await resolveBip353(parsed.name, parsed.domain); if (uri) uris.push(uri); } catch {} } } if (!live()) return; let onchain = null; for (const uri of uris) { const dec = parseBip21Uri(uri); if (!dec) continue; const arkAddr = dec.params && dec.params.ark; if (arkAddr && isArkAddress(arkAddr)) { Object.assign(z, { status: 'ready', address: arkAddr }); if (z.autoSat) return auto(); render(); return; } if (dec.onchain && !onchain) onchain = dec.onchain; } // 2. lightning via the zap flow (connect first — canLnPay needs it) if (profile && (profile.lud16 || profile.lud06)) { await connectArk().catch(() => {}); if (!live()) return; if (ctx.hook('canLnZap')) { ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; } } // 3. on-chain fallback if (onchain) { ui.arkZap = null; ui.send.recipients[0].address = onchain; bail(); render(); return; } if (live()) { z.status = 'noark'; bail(); render(); } })().catch((e) => { if (live()) { z.status = 'noark'; bail(); ui.sendError = e.message; render(); } }); } // shared by the ark and lightning zap flows (see zaps.js for the twin) function zapSkeleton(pk, fallback) { return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, '⚡ ' + t('zapTitle')), (pk && ctx.hook('profileChip', pk, 'lg')) || h('div', { class: 'small muted break' }, fallback || ''), h('div', { class: 'row gap6', style: 'align-items:center;padding:6px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('zapFinding'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkZap = null; ui.zap = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back'))); } async function lookupArkZapTarget(pk) { const events = await wallet.nostrFetch({ kinds: [ARK_INFO_KIND], authors: [pk] }, 6000); const ev = (events || []).sort((a, b) => b.created_at - a.created_at)[0]; if (!ev) return { status: 'noark' }; const addr = (ev.tags.find((t) => t[0] === 'ark') || [])[1]; const net = (ev.tags.find((t) => t[0] === 'network') || [])[1]; if (!addr) return { status: 'noark' }; if (net && net !== getNetwork()) return { status: 'wrongnet', net }; return { status: 'ready', address: addr }; } // The zap itself, shared by the form and the one-tap default-amount path. async function performArkZap(z, sats) { const mgr = await connectArk(); const actionId = await mgr.send(z.address, sats); 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; noteZap('to:' + z.address, z.pk); // the receipt is best-effort: the sats are already delivered via mailbox await wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], }).catch(() => {}); } async function doArkZap() { const z = ui.arkZap; const sats = ctx.parseAmount(z.amount, ctx.getUnit()); if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; } ui.busy = true; ui.sendError = ''; render(); try { await performArkZap(z, sats); ctx.hook('zapSettled', z.eventId, true, sats); // the tally shows it now, not when the receipt lands ui.arkZapped = { amountSat: sats, npub: z.npub }; ui.arkZap = null; } catch (e) { ui.sendError = e.message; } ui.busy = false; render(); } // No ark address published: the zap leaves anyway, as an ark gift locked to // their nostr key. The sats move into a bearer vtxo now; the public link is // safe to put in the receipt because only the recipient's key — or the // claim code we DM them — opens it. Unclaimed, it stays revocable from the // gift card like any other ark gift. async function doArkZapGift() { const z = ui.arkZap; const sats = ctx.parseAmount(z.amount, ctx.getUnit()); if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; } if (sats > (arkBalance()?.spendableSat || 0)) { ui.sendError = t('giftExceedsBalance'); render(); return; } ui.busy = true; ui.sendError = ''; render(); try { const g = await createArkGift(sats, z.pk); const locked = ctx.hook('lockArkGift', g.code, sats, z.pk); if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build noteZap('to:' + g.address, z.pk); const done = (ui.arkZapped = { amountSat: sats, npub: z.npub, gift: { url: locked.url, claimCode: locked.claimCode, dm: 'sending' } }); ui.arkZap = null; // best-effort receipt; carries the locked link so the recipient can // discover the gift from the note even if the DM never lands await wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url]], }).catch(() => {}); const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); if (wallet.sendNostrDM) { wallet.sendNostrDM(z.pk, dmText) .then((ok) => { done.gift.dm = ok ? 'sent' : 'failed'; render(); }) .catch(() => { done.gift.dm = 'failed'; render(); }); } else done.gift.dm = 'failed'; } catch (e) { ui.sendError = e.message; } ui.busy = false; render(); } function arkZapView() { // While resolving, show the SHAPE of the card that's coming — same // title, same recipient chip, a spinner where the form will be. The // screen fills in rather than being replaced. if (ui.arkZap && ui.arkZap.status === 'lookup') return zapSkeleton(ui.arkZap.pk, ui.arkZap.npub); if (ui.arkZapped) { const gift = ui.arkZapped.gift; const finish = () => { ui.arkZapped = null; ui.send = blankSend(); render(); }; if (!gift) { return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', onClick: finish, }, h('div', { class: 'check-badge' }, '⚡'), h('h2', { style: 'margin:0' }, t('arkZapSentTitle')), h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), h('div', { class: 'small muted' }, t('tapToProceed'))); } // A locked-gift zap has aftercare: the DM's fate, and the link + claim // code to pass along by hand when the DM couldn't be delivered. return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px;padding:28px 16px' }, h('div', { class: 'check-badge' }, '🎁'), h('h2', { style: 'margin:0' }, t('arkZapGiftSentTitle')), h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), gift.dm === 'sending' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending'))) : gift.dm === 'sent' ? h('div', { class: 'small', style: 'color:var(--green)' }, t('giftDmSent')) : h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')), h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, gift.url), h('div', { class: 'row gap6 wrap', style: 'justify-content:center' }, copyBtn(gift.url, t('copyLink')), gift.dm !== 'sent' ? copyBtn(gift.claimCode, t('giftCopyCode')) : null), h('button', { class: 'btn-primary btn-block', onClick: finish }, t('done'))); } const z = ui.arkZap; if (!z) return null; const spendable = arkBalance()?.spendableSat || 0; // No ark address found — but a zap can still leave as an ark gift locked // to their nostr key, when this build carries the gifts feature. const giftOk = z.status === 'noark' && !wallet.watchOnly && spendable >= 1 && !!ctx.hook('canLockGift'); const amountInputs = (hint) => h('div', { class: 'col gap6' }, h('div', { class: 'input-group' }, h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount, onInput: (e) => { z.amount = e.target.value; } }), h('button', { type: 'button', onClick: () => { z.amount = amountFill(lnMaxSat()); render(); } }, t('max')), h('div', { style: 'display:flex;align-items:center' }, unitTag())), h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment, onInput: (e) => { z.comment = e.target.value; } }), h('div', { class: 'small faint' }, hint)); return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, '⚡ ' + t('zapTitle')), ctx.hook('profileChip', z.pk, 'lg') || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.npub), z.status === 'lookup' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkZapLookup'))) : null, z.status === 'noark' && !giftOk ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null, giftOk ? h('div', { class: 'notice info' }, t('arkZapNoArkGift')) : null, giftOk ? amountInputs(t('arkZapGiftHint')) : null, giftOk ? (ui.busy ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary btn-block', onClick: doArkZapGift }, '🎁 ' + t('arkZapGiftBtn'))) : null, z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, // No Ark address, but they may still take a Lightning zap — hand off to // the zaps feature (present alongside swaps). (z.status === 'noark' || z.status === 'wrongnet') && ctx.hook('canLnZap') ? h('button', { class: (giftOk ? '' : 'btn-primary ') + 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback')) : null, z.status === 'ready' && spendable < 330 ? h('div', { class: 'notice info' }, t('zapNoBalance')) : z.status === 'ready' ? amountInputs(t('arkZapHint')) : null, ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: () => { ui.arkZap = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')), z.status === 'ready' && spendable >= 330 ? (ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary grow', onClick: doArkZap }, t('arkZapBtn'))) : null)); } // ---- auto-withdraw -------------------------------------------------------- // Forward Spending onward once it crosses a threshold. Same destinations the // send form takes — an ark address, a payment name, a lightning address, an // npub, or an on-chain address — resolved through the same paths, just // without a human present. Deliberately conservative: one transfer at a // time, never while another action is in flight, and a failure backs off // instead of retrying in a loop. const awState = () => wallet.loadFeatureState('autowithdraw', {}); const awSave = (v) => wallet.saveFeatureState('autowithdraw', v); // What kind of destination is this, and can we pay it at all? Returns // { kind, address } or null. Resolution that needs the network (BIP-353, // LNURL, npub profiles) happens at send time, not here. function awClassify(dest) { const d = (dest || '').trim(); if (!d) return null; if (isArkAddress(d)) return { kind: 'ark', address: d }; if (parsePaymentName(d)) return { kind: 'name', address: d }; if (npubToHex(d)) return { kind: 'npub', address: d }; if (/^lnurl1[ac-hj-np-z02-9]+$/i.test(d)) return { kind: 'lnurl', address: d }; try { btc.Address(wallet.netCfg.net).decode(d); return { kind: 'onchain', address: d }; } catch {} return null; } // Turn any destination into something payable right now. async function awResolve(dest) { const c = awClassify(dest); if (!c) throw new Error(t('awBadDest')); if (c.kind === 'ark' || c.kind === 'onchain') return c; // a payment name may carry an ark instruction (free) or fall back to LNURL if (c.kind === 'name') { const p = parsePaymentName(c.address); const uri = await resolveBip353(p.name, p.domain).catch(() => null); const dec = uri ? parseBip21Uri(uri) : null; const arkAddr = dec && dec.params && dec.params.ark; if (arkAddr && isArkAddress(arkAddr)) return { kind: 'ark', address: arkAddr }; if (dec && dec.onchain) return { kind: 'onchain', address: dec.onchain }; return { kind: 'lnaddr', address: c.address }; } if (c.kind === 'npub') { const pk = npubToHex(c.address); const adv = await lookupArkZapTarget(pk).catch(() => null); if (adv && adv.status === 'ready') return { kind: 'ark', address: adv.address }; const profile = wallet.nostrProfile ? await wallet.nostrProfile(pk).catch(() => null) : null; if (profile && profile.lud16) return { kind: 'lnaddr', address: profile.lud16 }; throw new Error(t('awBadDest')); } return { kind: c.kind === 'lnurl' ? 'lnurl' : 'lnaddr', address: c.address }; } async function awPay(target, amountSat) { const mgr = await connectArk(); if (target.kind === 'ark') { await mgr.send(target.address, amountSat); return; } if (target.kind === 'onchain') { const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(target.address)); // Offboarding moves whole vtxos, so carve out the exact amount first // when we're not sending everything. const spendables = () => mgr.state.vtxos.filter((v) => v.state === 'spendable'); const total = spendables().reduce((n, v) => n + v.amountSat, 0); let ids; if (amountSat < total) { let exact = spendables().find((v) => v.amountSat === amountSat); if (!exact) { const before = new Set(spendables().map((v) => v.id)); await mgr.send(mgr.address(), amountSat); for (let i = 0; i < 30 && !exact; i++) { await new Promise((r) => setTimeout(r, 2000)); await mgr.sync().catch(() => {}); exact = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id)); } if (!exact) throw new Error(t('arkSplitTimeout')); } ids = [exact.id]; } await mgr.startOffboard(spk, target.address, ids); return; } // lightning: ask their server for an invoice, pay it over ark const zt = parseZapTarget(target.address); if (!zt || !zt.url) throw new Error(t('awBadDest')); const params = await fetchPayParams(zt.url); const msat = amountSat * 1000; if (msat < params.minSendable || msat > params.maxSendable) throw new Error(t('awOutOfRange')); const invoice = await requestInvoice(params, { amountMsat: msat, lnurlBech32: zt.lnurlBech32 }); await mgr.payLnInvoice(invoice, { amountSat }); } // What the service worker gets: the setting plus a destination it can pay // without a relay. Resolution can fail (DNS down, profile missing) — then we // keep whatever we mirrored last rather than dropping the feature. async function awMirror(prev) { const st = awState(); if (!st.on || !st.dest) return null; const prevAw = prev && prev.autowithdraw; let target = prevAw && prevAw.dest === st.dest ? prevAw.target : null; try { const r = await awResolve(st.dest); target = { kind: r.kind, address: r.address }; if (r.kind === 'lnaddr' || r.kind === 'lnurl') { const zt = parseZapTarget(r.address); if (zt && zt.url) target.url = zt.url; else target = null; } else if (r.kind === 'onchain') { target.spkHex = hex.encode(btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(r.address))); } } catch {} if (!target) return null; return { on: true, dest: st.dest, target, threshold: Number(st.threshold) || 0, keep: Math.max(0, Number(st.keep) || 0), failedAt: st.failedAt || 0, }; } // What the worker did while we were closed becomes the app's state again. function absorbBgAutoWithdraw(rec) { const bg = rec && rec.autowithdraw; if (!bg) return; const st = awState(); if (!st.on) return; if ((bg.lastAt || 0) > (st.lastAt || 0)) awSave({ ...st, lastAt: bg.lastAt, lastSat: bg.lastSat, failedAt: 0, error: '' }); else if ((bg.failedAt || 0) > (st.failedAt || 0)) awSave({ ...st, failedAt: bg.failedAt, error: bg.error || '' }); } let awBusy = false; async function maybeAutoWithdraw(mgr) { const st = awState(); if (!st.on || !st.dest || wallet.watchOnly || awBusy) return; if (Date.now() - (st.failedAt || 0) < 15 * 60_000) return; // backing off if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; const spendable = (mgr.state.vtxos || []) .filter((v) => v.state === 'spendable').reduce((n, v) => n + v.amountSat, 0); const threshold = Number(st.threshold) || 0; const keep = Math.max(0, Number(st.keep) || 0); if (!threshold || spendable < threshold) return; const amount = spendable - keep; if (amount < 330) return; awBusy = true; try { const target = await awResolve(st.dest); await awPay(target, amount); const s2 = awState(); awSave({ ...s2, lastAt: Date.now(), lastSat: amount, failedAt: 0, error: '' }); toast(t('awSent', { n: fmtAmount(amount) + ' ' + unitLabel() })); render(); } catch (e) { const s2 = awState(); awSave({ ...s2, failedAt: Date.now(), error: e.message || String(e) }); console.warn('auto-withdraw failed:', e.message); } finally { awBusy = false; } } // The exit-cost tradeoff, surfaced only when it's real: every off-chain // hop a coin accumulates makes its trustless exit one on-chain transaction // more expensive; a renewal flattens the history back to a single hop. // Most users never need to think about this — the card only appears once // coins run deep enough for the difference to matter. const EXIT_DEPTH_ADVISORY = 4; // Pricing the advisory is EC-heavy: it decodes every spendable coin (for its // genesis depth) and builds the exit transactions (estimateExitFeeSat). Doing // that synchronously in the render path was the cold cost behind a carousel // drag stutter right after a refresh. So the render only READS the cached // answer, and the recompute runs in idle time — never competing with input. let _depthKey = null, _depthComputing = false; function computeDepthAdvisory() { if (!ark || !ark.state) return; const spend = (ark.state.vtxos || []).filter((v) => v.state === 'spendable'); const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); const key = feeRate + '|' + spend.map((v) => v.id).join(','); if (key === _depthKey || _depthComputing) return; // already priced this exact set _depthComputing = true; const run = () => { try { let depth = 0; for (const v of spend) { try { depth = Math.max(depth, ark._decoded(v).genesis.length); } catch {} } let show = false, exitFee = 0; if (spend.length && depth >= EXIT_DEPTH_ADVISORY) { try { exitFee = estimateExitFeeSat(ark); } catch {} const afterFee = Math.ceil(530 * feeRate); // one coin, one hop post-renewal show = afterFee < exitFee; // renewing has to actually help } _depthKey = key; const cached = wallet.loadFeatureState('arkDepth', null); if (!cached || cached.show !== show || cached.exitFee !== exitFee) { wallet.saveFeatureState('arkDepth', { show, exitFee }); render(); } } finally { _depthComputing = false; } }; if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 2000 }); else setTimeout(run, 200); } function exitDepthNotice() { if (!arkAvailable() || wallet.watchOnly) return null; // Render is cheap: paint the last computed answer immediately (so the line // doesn't pop in and shove layout), and kick an idle recompute if the coin // set changed. The heavy EC never runs in the render/interaction path. if (ark && ark.state) computeDepthAdvisory(); const cached = wallet.loadFeatureState('arkDepth', null); return cached && cached.show ? t('arkDepthNotice', { fee: fmtSats(cached.exitFee) }) : null; } // The screen behind that notice's Manage link: every spendable coin with // its expiry and arkoor depth, what an uncooperative exit costs today, and // what a renewal costs and buys. function arkCoinsPage() { const mgr = ark; const spend = ((mgr && mgr.state && mgr.state.vtxos) || []).filter((v) => v.state === 'spendable'); const tip = (mgr && mgr._tipH) || 0; const back = () => { ui.arkCoinsPage = null; ui.arkCoinsSel = null; render(); }; if (!spend.length) { ui.arkCoinsPage = null; return null; } // nothing left to manage const totalSat = spend.reduce((n, v) => n + v.amountSat, 0); let exitFee = 0; try { exitFee = estimateExitFeeSat(mgr); } catch {} const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); const afterFee = Math.ceil(530 * feeRate); // relative and terse — "14d" / "13h" beside the hourglass — a date says // less than a countdown, and a coin past its height says so plainly // instead of the shifty "0h" (the server still honors it; the wallet's // next renewal round sweeps it up). const expiresOf = (v) => { if (!v.expiryHeight || !tip) return '—'; const blocks = v.expiryHeight - tip; if (blocks <= 0) return t('arkCoinsExpired'); return blocks >= 144 ? Math.round(blocks / 144) + 'd' : Math.max(1, Math.round(blocks / 6)) + 'h'; }; // what pulling THIS coin on-chain alone would cost: every hop's fee // child plus the claim, at today's rate (the whole-balance figure below // is the same sum over all coins) const exitFeeOf = (v) => { try { let f = Math.ceil(150 * feeRate); for (const vsize of exitTxVsizes(mgr._decoded(v), mgr.serverPub)) f += Math.ceil((vsize + 130) * feeRate); return f; } catch { return null; } }; // The server prices a renewal by how much lifetime each coin still // carries (its ppm bracket) — waiting IS cheaper, and inside the final // bracket it's free, where the wallet renews on its own anyway. const table = ((mgr.info || {}).refreshFees || {}).ppmExpiryTable || []; const roundMins = Math.max(1, Math.round(((mgr.info || {}).roundIntervalSecs || 120) / 60)); const roundEvery = roundMins === 1 ? t('arkCoinsRoundEveryOne') : t('arkCoinsRoundEvery', { n: roundMins }); const ppmFor = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0; // A coin's SHARE of the renewal fee, fractional: the server totals the // ppm across every coin in the round and rounds up once (refreshFee), so // a 1-sat coin costs a few thousandths of a sat, not the 1 sat a // per-coin ceiling used to print — nine such chips summing past the // real total made it look as if the page were skipping them. const feeNowOf = (v) => (v.amountSat * ppmFor((v.expiryHeight || 0) - tip)) / 1_000_000; const renewChip = (share) => share <= 0 ? t('arkDepthFree') : share < 1 ? t('arkCoinsChipRenewTiny') : fmtAmount(Math.round(share)) + ' ' + unitLabel(); const span = (blocks) => { if (blocks >= 144) { const d = Math.round(blocks / 144); return d === 1 ? t('arkCoinsSpanDay') : t('arkCoinsSpanDays', { n: d }); } const hs = Math.max(1, Math.round(blocks / 6)); return hs === 1 ? t('arkCoinsSpanHour') : t('arkCoinsSpanHours', { n: hs }); }; const schedule = [...table].sort((a, b) => b.thresholdBlocks - a.thresholdBlocks).map((e, i, arr) => ({ label: e.ppm === 0 ? t('arkCoinsSchedFinal', { span: span(arr[i - 1] ? arr[i - 1].thresholdBlocks : 144) }) : i === 0 ? t('arkCoinsSchedOver', { span: span(e.thresholdBlocks) }) : t('arkCoinsSchedBetween', { a: span(e.thresholdBlocks), b: span(arr[i - 1].thresholdBlocks) }), cost: e.ppm === 0 ? t('arkDepthFree') : (e.ppm / 10000).toLocaleString(undefined, { maximumFractionDigits: 3 }) + '%', free: e.ppm === 0, })); // Which coins ride the renewal: all by default (the one-button behavior), // any subset by unticking. Selection is per-visit; ids that vanished // (spent meanwhile) drop out silently. const ids = new Set(spend.map((v) => v.id)); if (!(ui.arkCoinsSel instanceof Set)) ui.arkCoinsSel = new Set(ids); for (const id of ui.arkCoinsSel) if (!ids.has(id)) ui.arkCoinsSel.delete(id); const sel = ui.arkCoinsSel; const selCoins = spend.filter((v) => sel.has(v.id)); let selFee = 0; try { selFee = mgr.refreshFee(selCoins, tip); } catch {} const renew = () => { if (!selCoins.length) return; // fire and let the round machinery carry it — rounds can be an hour out mgr.refresh(selCoins.map((v) => v.id)).catch((e) => toast(e.message)); toast(t('arkDepthRenewed')); ui.arkCoinsSel = null; back(); }; const tick = (checked, onChange, title) => h('input', { type: 'checkbox', checked, title, style: 'flex:0 0 auto;margin:0', onChange, }); return h('div', { class: 'col', style: 'gap:16px' }, h('div', { class: 'card col', style: 'gap:8px' }, h('h3', { style: 'margin:0' }, t('arkCoinsTitle')), h('p', { class: 'small muted', style: 'margin:0' }, spend.length === 1 ? t('arkCoinsIntroOne', { total: fmtAmount(totalSat) + ' ' + unitLabel() }) : t('arkCoinsIntro', { n: spend.length, total: fmtAmount(totalSat) + ' ' + unitLabel() })), // One coin per row, two lines: the amount (with its renewal price on // the right — the number this page's action spends), then a faint // meta line. Five flexed columns fit a laptop but wrapped into // confetti on a phone; labels riding inline with each value need no // header row and no horizontal budget at all. `.coin` brings the // on-chain coin-control look — hairlines between rows, big accent // checkboxes — so the two coin pages read as siblings. // One coin per row in a headed table — the labels live in the header // once instead of on every row as chips. It scrolls sideways on a // narrow screen (the wrapper, not the page) rather than wrapping. // A tap anywhere on a row toggles it. h('div', { class: 'coin-scroll' }, h('table', { class: 'coin-table' }, h('thead', {}, h('tr', {}, h('th', {}, tick(sel.size === ids.size, (e) => { ui.arkCoinsSel = e.target.checked ? new Set(ids) : new Set(); render(); }, t('arkCoinsSelectAll'))), h('th', { class: 'num' }, t('arkCoinsColAmount')), h('th', { class: 'num', title: t('arkCoinsChipExpTitle') }, t('arkCoinsColExpiry')), h('th', { class: 'num', title: t('arkCoinsChipExitTitle') }, t('arkCoinsColExit')), h('th', { class: 'num', title: t('arkCoinsChipRenewTitle') }, t('arkCoinsColRenew')))), h('tbody', {}, ...spend.map((v) => { const f = feeNowOf(v); const xf = exitFeeOf(v); const due = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0); const toggle = () => { sel.has(v.id) ? sel.delete(v.id) : sel.add(v.id); render(); }; return h('tr', { class: 'clickable', onClick: (e) => { if (e.target.tagName !== 'INPUT') toggle(); }, }, h('td', {}, tick(sel.has(v.id), toggle)), h('td', { class: 'num' }, fmtAmount(v.amountSat), h('span', { class: 'small faint' }, ' ' + unitLabel())), h('td', { class: 'num' + (due ? ' warn' : '') }, expiresOf(v)), h('td', { class: 'num' }, xf == null ? '—' : fmtAmount(xf), xf == null ? null : h('span', { class: 'small faint' }, ' ' + unitLabel())), h('td', { class: 'num' }, renewChip(f))); }))))), h('div', { class: 'card col', style: 'gap:8px' }, h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) }))), h('div', { class: 'card col', style: 'gap:8px' }, h('h4', { style: 'margin:0' }, t('arkCoinsRenewTitle')), // the round cadence comes from the server (it has been an hour and // is two minutes as of September 2026) — never a number baked in here h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsRenewDesc', { every: roundEvery })), schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' }, ...schedule.map((r) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, r.label), h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null, h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || !selCoins.length, onClick: renew }, !selCoins.length ? t('arkCoinsRenewNone') : selCoins.length < spend.length ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtAmount(selFee) + ' ' + unitLabel() : t('arkDepthFree') }) : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtAmount(selFee) + ' ' + unitLabel() }) : t('arkDepthRenewBtn'))), h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); } function autoWithdrawCard() { if (!arkAvailable() || wallet.watchOnly) return null; const st = awState(); const draft = ui.awDraft || (ui.awDraft = { dest: st.dest || '', threshold: String(st.threshold || ''), keep: String(st.keep || '') }); const save = (on) => { const dest = (draft.dest || '').trim(); const threshold = parseInt(String(draft.threshold).replace(/[^0-9]/g, ''), 10) || 0; const keep = parseInt(String(draft.keep).replace(/[^0-9]/g, ''), 10) || 0; if (on) { if (!awClassify(dest)) { ui.awError = t('awBadDest'); render(); return; } if (threshold < 1000) { ui.awError = t('awLowThreshold'); render(); return; } if (keep >= threshold) { ui.awError = t('awKeepTooHigh'); render(); return; } } ui.awError = ''; awSave({ ...st, on, dest, threshold, keep, failedAt: 0, error: '' }); writeBgMirror().catch(() => {}); // the worker needs the new destination toast(on ? t('awOn') : t('awOff')); render(); }; return h('div', { class: 'card col' }, h('h3', {}, t('awTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('awDesc')), h('label', { class: 'field' }, h('span', { class: 'lab' }, t('awDest')), h('input', { type: 'text', class: 'mono-input', placeholder: t('awDestPh'), autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: draft.dest, onInput: (e) => { draft.dest = e.target.value; }, })), h('div', { class: 'row gap6' }, h('label', { class: 'field grow' }, h('span', { class: 'lab' }, t('awThreshold')), h('input', { type: 'number', inputmode: 'numeric', min: '0', placeholder: '100000', value: draft.threshold, onInput: (e) => { draft.threshold = e.target.value; }, })), h('label', { class: 'field grow' }, h('span', { class: 'lab' }, t('awKeep')), h('input', { type: 'number', inputmode: 'numeric', min: '0', placeholder: '0', value: draft.keep, onInput: (e) => { draft.keep = e.target.value; }, }))), ui.awError ? h('div', { class: 'notice err small' }, ui.awError) : null, st.on && st.error ? h('div', { class: 'notice err small' }, t('awLastError', { why: st.error })) : null, st.lastAt ? h('div', { class: 'small faint' }, t('awLast', { n: fmtAmount(st.lastSat || 0) + ' ' + unitLabel(), when: new Date(st.lastAt).toLocaleString() })) : null, st.on ? h('div', { class: 'row gap6' }, h('button', { class: 'grow', onClick: () => save(true) }, t('save')), h('button', { class: 'btn-ghost', onClick: () => save(false) }, t('awTurnOff'))) : h('button', { class: 'btn-primary btn-block', onClick: () => save(true) }, t('awTurnOn'))); } // ---- NWC bridge funding (PoC) -------------------------------------------- // An external NWC wallet service (e.g. coinos) can bridge ark -> lightning: // a nostr client asks IT to pay_invoice, it asks US (kind 23196, funding // request) to cover the amount over ark, we auto-pay within a user-set // allowance and ack (kind 23197). Custody window = seconds in flight. // PoC config in localStorage 'btc-wallet-arknwc': { bridgePk, budgetSat }. const NWC_FUND_KIND = 23196; const NWC_FUND_ACK_KIND = 23197; let nwcUnsub = null; let nwcSpent = 0; function stopNwcFunding() { if (nwcUnsub) { try { nwcUnsub(); } catch {} nwcUnsub = null; } } function startNwcFunding(mgr) { if (nwcUnsub || !wallet.nostrSubscribe || !wallet.nostrPubkey || !wallet.nostrPubkey()) return; let cfg = null; try { cfg = JSON.parse(localStorage.getItem('btc-wallet-arknwc') || 'null'); } catch {} if (!cfg || !cfg.bridgePk || !(cfg.budgetSat > 0)) return; const seen = new Set(); nwcUnsub = wallet.nostrSubscribe( { kinds: [NWC_FUND_KIND], authors: [cfg.bridgePk], '#p': [wallet.nostrPubkey()] }, async (ev) => { if (seen.has(ev.id)) return; seen.add(ev.id); let req; try { req = JSON.parse(ev.content); } catch { return; } const sats = Math.round(req.amountSat || 0); if (!sats || !req.address || nwcSpent + sats > cfg.budgetSat) return; // over allowance: bridge times out try { const actionId = await mgr.send(req.address, sats); const action = mgr.state.actions.find((a) => a.id === actionId); if (!action || action.step === 'failed') return; nwcSpent += sats; await wallet.nostrPublish({ kind: NWC_FUND_ACK_KIND, tags: [['p', cfg.bridgePk], ['e', ev.id]], content: JSON.stringify({ id: req.id }), }); } catch {} }); } // ---- unilateral exit (trustless: no server cooperation) ------------------ const addrScript = (address) => btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address)); // Smallest confirmed, unreserved coin that can fund a CPFP bump. function pickFeeCoin(minSat) { return wallet.utxos .filter((u) => u.confirmed && !wallet.isReserved(utxoId(u)) && u.value >= minSat) .sort((a, b) => a.value - b.value)[0] || null; } // What a unilateral exit will need from Savings, BEFORE it starts: every // chain hop is a zero-fee tx bumped by a ~130 vB CPFP child, plus the // final claim. Priced at today's feerate — the driver reprices per hop. // Sizes only (exitTxVsizes): building the real signed chain costs several // secp256k1 multiplies per hop, and across 27 coins of depth ~13 that was // 5+ seconds of main-thread work on every boot (idle callback or not). // Fee = size × rate needs none of it. Still memoized on the coin set + // feerate, since the advisory asks on every wallet render. let _exitFeeMemo = { key: null, val: 0 }; function estimateExitFeeSat(mgr) { const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); const coins = mgr.vtxos().filter((x) => x.state === 'spendable'); const key = feeRate + '|' + coins.map((v) => v.id).join(','); if (_exitFeeMemo.key === key) return _exitFeeMemo.val; let total = 0; for (const v of coins) { try { for (const vsize of exitTxVsizes(mgr._decoded(v), mgr.serverPub)) total += Math.ceil((vsize + 130) * feeRate); total += Math.ceil(150 * feeRate); // the claim tx } catch {} } _exitFeeMemo = { key, val: total }; return total; } async function doArkExit() { ui.arkBusy = 'exit'; ui.arkError = ''; render(); try { const mgr = await connectArk(); const spendables = mgr.vtxos().filter((v) => v.state === 'spendable'); if (!spendables.length) throw new Error(t('arkNotConnected')); // Fees are paid hop by hop from Savings as the exit unrolls — an exit // that can't fund its hops just stalls at step zero. Refuse to start // one Savings can't carry, with the number on the table. const feeEst = estimateExitFeeSat(mgr); if ((wallet.spendable || 0) < feeEst) { throw new Error(t('arkExitFeeShort', { need: fmtSats(feeEst), have: fmtSats(wallet.spendable || 0) })); } for (const v of spendables) mgr.startExit(v.id); toast(t('arkExitStarted', { n: spendables.length })); driveExits(mgr).catch(() => {}); } catch (e) { ui.arkError = e.message; } ui.arkBusy = null; render(); } async function driveExits(mgr) { const open = mgr.state.actions.filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)); for (const a of open) { try { await driveExit(mgr, a); if (a.lastError) { delete a.lastError; delete a.actionable; mgr._save(); } // resolved } catch (e) { a.lastError = e.message; a.actionable = !!e.actionable; // user must act (e.g. fund fees) vs. plain retry mgr._save(); } } } // One tick of an exit's state machine: broadcast the next unconfirmed hop // (with its fee child) as a package, then wait out the CSV, then claim. async function driveExit(mgr, action) { const rec = mgr._vtxo(action.vtxoId); const decoded = mgr._decoded(rec); if (action.step === 'chain') { const txs = signedExitTxs(decoded, mgr.serverPub); let lastConfirmedHeight = 0; let hopsDone = 0; for (const txi of txs) { const st = await mgr.chain.getTxStatus(txi.txid); if (st?.confirmed) { lastConfirmedHeight = st.block_height; hopsDone++; if (action.hopsDone !== hopsDone) { action.hopsDone = hopsDone; mgr._save(); } continue; } // /tx/:txid/status answers {confirmed:false} even for UNKNOWN txids // (electrs + mempool.space) — only /tx/:txid 404s definitively if (await mgr.chain.getTxHex(txi.txid)) return; // in mempool — wait // unknown to the chain: submit this hop + CPFP child as a package const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); // the child pays for the whole package (the parent is zero-fee) const feeSat = Math.ceil((txi.vsize + 130) * feeRate); // child ≈ 130 vB const coin = pickFeeCoin(Math.max(294, feeSat - txi.anchorValue + 294)); if (!coin) { // No on-chain coin for the fee child. Try the hop bare — a node // that relays zero-fee txs (regtest with minrelaytxfee=0) needs no // bump. If it refuses, this is a PRECONDITION the user must fix, // not a transient to retry behind a vague message. try { await submitPackage(mgr.esploraUrl, [txi.hex]); mgr._save(); return; } catch { const e = new Error(t('arkExitNoFeeCoin', { need: fmtSats(Math.max(294, feeSat)) })); e.actionable = true; throw e; } } const changeAddr = wallet.freshChange().address; const child = buildBumpChild({ parentTxidInternal: txi.txidInternal, anchorVout: txi.anchorVout, anchorValue: txi.anchorValue, coin: { txid: coin.txid, vout: coin.vout, value: coin.value, pubkey: wallet.derive(coin.chain, coin.index).pubkey, privkey: wallet.node(coin.chain, coin.index).privateKey, }, changeScript: addrScript(changeAddr), feeSat, }); await submitPackage(mgr.esploraUrl, [txi.hex, child.hex]); // reflect the spent fee coin locally so nothing double-spends it wallet.utxos = wallet.utxos.filter((u) => !(u.txid === coin.txid && u.vout === coin.vout)); const ce = wallet.addrMap.get(changeAddr); if (ce) wallet.utxos.push({ txid: child.txid, vout: 0, value: child.changeSat, address: changeAddr, chain: ce.chain, index: ce.index, confirmed: false }); wallet._recomputeBalanceFromUtxos(); wallet.saveCache(); action.bumpTxid = child.txid; mgr._save(); return; // one hop per tick (TRUC: single unconfirmed parent) } // whole chain confirmed — start the CSV clock from the vtxo tx's height action.claimableAt = lastConfirmedHeight + decoded.exitDelta; action.step = 'timelock'; mgr._save(); } if (action.step === 'timelock') { const tip = await mgr.chain.tipHeight(); if (action.tipSeen !== tip) { action.tipSeen = tip; mgr._save(); } // for the blocks-left display if (tip < action.claimableAt) return; const keys = mgr._keyForVtxo(rec); const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); const claim = buildExitClaim({ vtxo: decoded, keys, serverPub: mgr.serverPub, destScript: addrScript(wallet.freshReceive().address), feeRate, }); await mgr.chain.broadcastTx(claim.hex); rec.state = 'spent'; action.claimTxid = claim.txid; action.step = 'claiming'; mgr._movement({ type: 'exit', amountSat: claim.amountSat, status: 'complete', txid: claim.txid, detail: `fee ${claim.feeSat} sat` }); mgr._save(); wallet.scan().catch(() => {}); } if (action.step === 'claiming') { const st = await mgr.chain.getTxStatus(action.claimTxid); if (!st?.confirmed) return; action.step = 'done'; mgr._save(); } } // ---- ark gifts: bearer-key vtxos ---------------------------------------- // Ark can't presign a bearer spend (every arkoor needs a live server cosign), // so an ark gift is a vtxo sent to an ephemeral ark identity whose seed IS // the link. The claimer rebuilds the identity from the code, reads the // gift's mailbox for the vtxo, and sweeps it to their own ark address — // instant and free. The sender keeps the secret too, which is what makes // revoke possible (sweep it back before it's claimed; first cosign wins). const AG_MAGIC = 0x11; const AG_NET = { mainnet: 0, testnet: 1, signet: 2, mutinynet: 3, regtest: 4 }; const AG_NET_BY = Object.fromEntries(Object.entries(AG_NET).map(([k, v]) => [v, k])); function encodeArkGiftCode(net, amountSat, secret) { const b = new Uint8Array(42); b[0] = AG_MAGIC; b[1] = AG_NET[net] ?? 0; new DataView(b.buffer).setBigUint64(2, BigInt(amountSat), true); b.set(secret, 10); return base32nopad.encode(b); } function decodeArkGiftCode(code) { try { const b = base32nopad.decode(String(code || '').toUpperCase()); if (b.length !== 42 || b[0] !== AG_MAGIC || !(b[1] in AG_NET_BY)) return null; return { net: AG_NET_BY[b[1]], amountSat: Number(new DataView(b.buffer, b.byteOffset).getBigUint64(2, true)), secretHex: hex.encode(b.slice(10)), }; } catch { return null; } } // A manager over the gift identity. State persists per device (keyed by the // secret's hash) so a claim interrupted mid-sweep resumes checkpointed. async function giftManager(secretHex) { const cfg = getArkConfig(); if (!cfg) throw new Error(t('arkNotConnected')); const key = 'btc-wallet-arkgift:' + hex.encode(sha256(hex.decode(secretHex))).slice(0, 24); const mgr = new ArkManager({ account: HDKey.fromMasterSeed(hex.decode(secretHex)), storage: { load: () => { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }, save: (s) => { try { localStorage.setItem(key, JSON.stringify(s)); } catch {} }, }, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), }); await mgr.init(); return mgr; } // The gift vtxo's key/outpoint for direct status checks (receive key 0). const giftKey0 = (secretHex) => { const n = HDKey.fromMasterSeed(hex.decode(secretHex)).deriveChild(3).deriveChild(0); return n.privateKey; }; const pointRawFromId = (id) => { const [txid, vout] = id.split(':'); const raw = new Uint8Array(36); raw.set(hex.decode(txid).reverse(), 0); new DataView(raw.buffer).setUint32(32, Number(vout), true); return raw; }; function arkGiftRecords() { if (!ark || !ark.state) return []; return (ark.state.gifts = ark.state.gifts || []); } // A gift's record id IS the destination vtxo of the send that funded it, so // the history row and detail view can recognise their own gift sends. const giftForMovement = (m) => (m && m.type === 'send' && m.vtxoId ? arkGiftRecords().find((g) => g.id === m.vtxoId) : null); // Lazily mark records whose vtxo the server reports spent (claimed — or our // own revoke). Throttled: this is called from the gift card's render path. let arkGiftsCheckedAt = 0; function refreshArkGiftRecords() { if (!ark || Date.now() - arkGiftsCheckedAt < 30_000) return; arkGiftsCheckedAt = Date.now(); const open = arkGiftRecords().filter((g) => !g.revoked && !g.claimed); Promise.all(open.map(async (g) => { try { const st = await getVtxoStatus(ark.arkUrl, pointRawFromId(g.id), giftKey0(g.secretHex)); if (st === VTXO_STATE_SPENT) { g.claimed = true; return true; } } catch {} return false; })).then((flags) => { if (flags.some(Boolean)) ark._save(); }); } // 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) { const mgr = await connectArk(); const secret = crypto.getRandomValues(new Uint8Array(32)); const secretHex = hex.encode(secret); const gm = await giftManager(secretHex); const address = gm.address(); const actionId = await mgr.send(address, amountSat); 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 } : {}) }); mgr._save(); return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address }; } // Sweep the gift identity's balance to `destAddress`. Shared by claim // (recipient's address) and revoke (sender's own address). async function sweepArkGift(code, destAddress) { const g = decodeArkGiftCode(code); if (!g) throw new Error('not an ark gift'); if (g.net !== getNetwork()) throw new Error(t('arkGiftWrongNet', { net: g.net })); const gm = await giftManager(g.secretHex); await gm.sync(); await gm.reconcile().catch(() => {}); const amount = gm.balance().spendableSat; if (!amount) throw Object.assign(new Error(t('giftTakenTitle')), { giftTaken: true }); await gm.send(destAddress, amount); return amount; } // Offboard the whole ark balance back into this wallet's own on-chain // receive address — the mirror image of boarding. // Offboard `amountSat` (or everything when omitted). An offboard moves // whole vtxos — no change — so a partial amount first mints an exact-amount // vtxo via a self-send (regtest-verified: the mailbox delivers self-sends), // then exits just that one. The number the user typed is the number that // leaves Spending; fees come off what arrives in Savings. // Send-to-on-chain from Spending: amount in, exit out — an offboard whose // destination is whatever address was pasted. function arkOffboardSendView() { const o = ui.arkOffboardSend; const spendable = arkBalance()?.spendableSat || 0; return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, t('arkExitSendTitle')), h('div', { class: 'small muted break' }, o.address), h('div', { class: 'small faint' }, t('arkExitSendNote')), h('div', { class: 'input-group' }, h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount, onInput: (e) => { o.amount = e.target.value; } }), h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))), ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost grow', onClick: () => { ui.arkOffboardSend = null; ui.arkError = ''; ui.send = blankSend(); render(); } }, t('back')), ui.arkBusy === 'offboard' ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' })) : h('button', { class: 'btn-primary grow', onClick: () => { const sats = parseInt(o.amount, 10) || spendable; if (!sats || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; } doArkOffboard(sats >= spendable ? 0 : sats, o.address); } }, t('send'))), // This send IS the cooperative exit. The trustless fallback only // appears when cooperation actually failed — and it states, before // anything is confirmed, exactly what it does and what the mining // fees from Savings will be. (() => { if (!ui.arkError || ui.arkBusy === 'offboard' || !ark) return null; let feeEst = 0; try { feeEst = estimateExitFeeSat(ark); } catch {} const balance = arkBalance()?.spendableSat || 0; // A trustless exit that costs more than it moves isn't an option, // it's a trap — say so, and point at the road that actually fits a // small balance: Lightning carries no mining fees at all. if (feeEst >= balance) { return h('div', { class: 'col', style: 'gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:10px' }, h('div', { class: 'small muted' }, t('arkUniUneconomical', { fee: fmtSats(feeEst), n: fmtSats(balance) }))); } const savings = wallet.spendable || 0; const short = savings < feeEst; return h('div', { class: 'col', style: 'gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:10px' }, h('div', { class: 'small muted' }, t('arkUniFallbackBody', { fee: fmtSats(feeEst) })), short ? h('div', { class: 'small err' }, t('arkUniFallbackShort', { need: fmtSats(feeEst), have: fmtSats(savings) })) : null, h('button', { class: 'btn-block', disabled: !!ui.arkBusy || short, onClick: () => { ui.arkOffboardSend = null; ui.arkError = ''; ui.send = blankSend(); doArkExit(); ui.tab = 'history'; // the exit lives as a pending record there render(); }, }, t('arkUniFallbackBtn', { fee: fmtSats(feeEst) }))); })()); } async function doArkOffboard(amountSat, destAddress = null) { ui.arkBusy = 'offboard'; ui.arkError = ''; render(); try { const mgr = await connectArk(); const spendables = () => mgr.state.vtxos.filter((v) => v.state === 'spendable'); const total = spendables().reduce((n, v) => n + v.amountSat, 0); let ids; // undefined = all spendable vtxos if (amountSat && amountSat < total) { let target = spendables().find((v) => v.amountSat === amountSat); if (!target) { const before = new Set(spendables().map((v) => v.id)); await mgr.send(mgr.address(), amountSat); for (let i = 0; i < 30 && !target; i++) { await new Promise((r) => setTimeout(r, 2000)); await mgr.sync().catch(() => {}); target = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id)); } if (!target) throw new Error(t('arkSplitTimeout')); } ids = [target.id]; } const address = destAddress || wallet.freshReceive().address; const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address)); const action = await mgr.startOffboard(spk, address, ids); ui.arkOffboardAmt = ''; ui.arkMoveOpen = false; ui.arkOffboardSend = null; ui.arkOffboarded = { txid: action.txid, netSat: action.netSat, feeSat: action.feeSat }; wallet.scan().catch(() => {}); // surface the incoming pending tx promptly } catch (e) { ui.arkError = e.message; } ui.arkBusy = null; render(); } // VTXO upkeep, invisibly: a send needs a single vtxo covering the amount // (fragmentation breaks coin selection) and every vtxo expires at // expiryHeight (unrenewed, the ASP can eventually sweep it). Rather than // exposing a refresh button, consolidate-and-renew automatically when a // vtxo is inside the renewal window or fragmentation builds up. The round // fee this spends is the cost of keeping the money spendable at all. // The ASP's refresh fee brackets reward renewing LATE — under 96 blocks of // remaining life a renewal is free, and this ASP keeps honoring (and freely // rescuing) a coin even past its expiry, so aiming inside the free window // risks nothing. ~16 hours of margin on mainnet. const renewWindow = () => (getNetwork() === 'regtest' ? 24 : 96); // How close to expiry the SERVER's own backstop renewal is aimed. The // window above only helps a wallet someone opens inside it; this one is a // participation registered far in advance that the ASP runs on its own, // whether or not anybody is looking. It sits well inside renewWindow so an // app that IS open still renews first (and the untouched backstop is // dropped), and far enough from expiry — 36 blocks, six hourly rounds — for // the round to actually run in time. Both are in the free fee bracket. const backstopWindow = () => (getNetwork() === 'regtest' ? 6 : 36); let arkAutoRefreshAt = 0; let arkBackstopAt = 0; // Set when the balance is too small to renew itself (total minus round fee // under the server's 330-sat output minimum) AND a vtxo is inside the // renewal window: { deadlineMs, sat }. The wallet-screen notice reads this. let arkRenewWarn = null; // Pasting your OWN ark address — or a coinos name that resolves to it — // is never a payment: it means "top up Spending". Route to the board // panel, funded from on-chain Savings; prefill an amount when the caller // has one (the review path carries the typed amount, paste-time has none // and the panel's own field takes over). async function boardOwnAddress(addr, { connect = false, amountSat = 0 } = {}) { if (!arkAvailable() || wallet.watchOnly) return false; let mgr = ark; if (!mgr) { if (!connect) return false; try { mgr = await connectArk(); } catch { return false; } } try { if (addr !== mgr.address()) return false; } catch { return false; } ctx.goHome(); ui.arkMoveOpen = true; ui.arkMoveDir = 'toSpending'; ui.arkBoardAmt = amountSat > 0 ? String(amountSat) : ''; ui.send = blankSend(); ui.sendError = ''; toast(t('arkOwnAddrBoard')); render(); return true; } async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; // NOT filtered by expiryRejected. A coin the server refused as expired // can still be REFRESHED — that is the one thing an expired vtxo can // still do — and excluding it here meant a wallet holding nothing else // did nothing at all: no renewal attempt, and no warning either. const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable'); if (!spendables.length) { arkRenewWarn = null; return; } // Register the server-side backstop for anything not covered yet. Ahead // of the in-flight guard below on purpose: a standing order takes no coin // and starts no round, so a send in progress is no reason to skip the one // renewal that survives the app never being opened again. Stamped on its // own clock — the guard below can return before the refresh throttle is // stamped, and this must not then run on every five-second sync. if (Date.now() - arkBackstopAt > 30 * 60_000) { arkBackstopAt = Date.now(); await mgr.ensureScheduledRefreshes(backstopWindow()).catch(() => {}); } // never start a round while any other action is still in flight if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; arkAutoRefreshAt = Date.now(); try { const tip = await mgr.chain.tipHeight(); const RENEW_BLOCKS = renewWindow(); const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < RENEW_BLOCKS); const expiring = expiringCoins.length > 0; // Fragmentation alone is no longer worth paying for: multi-input sends // handle up to 24 coins, and consolidating FRESH coins bills the round // fee's top ppm bracket (a burst of refund coins after failed payments // once cost a user ~1% overnight). Consolidate only when the coin count // itself approaches the 24-input send ceiling. const fragmented = spendables.length >= 20; const totalSat = spendables.reduce((n, v) => n + v.amountSat, 0); const renewable = totalSat - mgr.refreshFee(spendables, tip) >= 330; if (expiring && !renewable) { // Can't save these coins ourselves — tell the user while receiving // any amount (or spending) can still beat the clock. const minExpiry = Math.min(...spendables.map((v) => v.expiryHeight || Infinity)); const blockMs = getNetwork() === 'regtest' ? 10_000 : 600_000; arkRenewWarn = { deadlineMs: Date.now() + Math.max(0, minExpiry - tip) * blockMs, sat: totalSat }; notifyRenewWarn(); render(); return; } arkRenewWarn = null; if (!expiring && !fragmented) return; // Renew ONLY the coins that need it: dragging far-from-expiry coins // into the round would bill their (higher) fee bracket for nothing. // The rare fragmentation consolidation still sweeps everything. // // But the subset must clear the server's 330-sat output floor BY // ITSELF — the renewable check above prices the whole balance. Two // dust coins at expiry (133 sats between them) failed their renewal // round silently every 30 minutes forever. Pull in the smallest // healthy coins until the renewed output is mintable. let renewSet = expiringCoins; if (!fragmented) { const extras = spendables .filter((v) => !expiringCoins.includes(v)) .sort((a, b) => a.amountSat - b.amountSat); const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip); while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()]; if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it } await mgr.refresh(fragmented ? undefined : renewSet.map((v) => v.id)); render(); } catch {} // transient — the next throttled attempt retries } // A local heads-up (at most one a day) for users who granted notifications — // the in-app notice alone can't reach someone who isn't looking. function notifyRenewWarn() { try { if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; const KEY = 'arkRenewNotifiedAt'; if (Date.now() - (Number(localStorage.getItem(KEY)) || 0) < 24 * 3600_000) return; localStorage.setItem(KEY, String(Date.now())); const body = t('arkRenewWarnPush', { date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }); navigator.serviceWorker?.ready .then((reg) => reg.showNotification('coinos', { body })) .catch(() => { try { new Notification('coinos', { body }); } catch {} }); } catch {} } // Moving money between the two balances, in plain terms. This is the only // place the two rails meet, and it never names them: Saving is on-chain, // Spending is instant, and the wallet handles the rest. // Amount field with an inline Max — a text affordance inside the input's // right edge rather than a button eating a third of the row. function amountField({ value, onInput, maxSat, placeholder, onMax }) { return h('div', { class: 'amt-field' }, h('input', { type: 'number', inputmode: 'numeric', min: '0', placeholder, value: value || '', onInput, }), maxSat > 0 ? h('button', { type: 'button', class: 'amt-max', onClick: onMax }, t('max')) : null); } // Boarding drains an on-chain balance, so Max must leave room for the // mining fee: ask the tx builder what a sweep would actually deliver — TO // THE REAL OUTPUT SHAPE. The board funding address is P2TR, 12 vbytes // bigger than this wallet's P2WPKH change; drafting the sweep against // change priced Max a dozen sats past what the funding transaction could // carry, and tapping Max earned "insufficient funds". The manager's own // key stands in for the funding key — same size, always a valid point. function maxBoardSat() { try { return Math.max(0, previewBoardFunding(wallet, ark, 0, true).fundingSat); } catch { return 0; } } // The ASP decides how many confirmations a board needs — say that number, // not a hardcoded one that contradicts the n/need counter under the balance. const boardedNote = () => { const n = (ark && ark.info && ark.info.requiredBoardConfirmations) || 1; return n === 1 ? t('arkBoardedNoteOne') : t('arkBoardedNote', { n }); }; function boardForm() { const minBoard = (ark && ark.info && ark.info.minBoardAmountSat) || 0; const canBoard = wallet.spendable >= minBoard; return h('div', { class: 'col', style: 'width:100%;gap:8px' }, h('div', { class: 'input-group' }, amountField({ value: ui.arkBoardAmt, placeholder: t('arkOffboardAmtPlaceholder'), // the minimum is stated below onInput: (e) => { ui.arkBoardAmt = e.target.value; render(); }, maxSat: canBoard ? wallet.spendable : 0, onMax: () => { ui.arkBoardAmt = String(maxBoardSat()); render(); }, }), h('button', { class: 'btn-primary', disabled: !!ui.arkBusy || !canBoard, onClick: doArkBoard }, ui.arkBusy === 'board' ? h('span', { class: 'spinner sm' }) : t('arkBoardBtn'))), (() => { const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); if (!sats || sats < minBoard || !ark) return null; try { const quote = previewBoardFunding(wallet, ark, sats); return h('div', { class: 'col small muted', style: 'text-align:center;gap:4px' }, t('arkBoardFeeNote', { fee: quote.feeSat.toLocaleString(), net: quote.netSat.toLocaleString() }), h('div', { class: 'small faint' }, t('arkFeeChain') + ': ' + fmtAmount(quote.chainFeeSat) + ' ' + unitLabel() + ' · ' + t('arkFeeService') + ': ' + fmtAmount(quote.serviceFeeSat) + ' ' + unitLabel())); } catch (e) { return h('div', { class: 'notice err small' }, e.message); } })(), canBoard ? h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardAvailable', { n: fmtAmount(wallet.spendable) + ' ' + unitLabel() })) : h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardNoFunds', { n: minBoard.toLocaleString() }))); } // Moving money between the two balances, inline on the balance card — the // one door between the rails opens right where the balances live. function movePanel() { if (!ark) connectArk().catch(() => {}); const dir = ui.arkMoveDir || 'toSpending'; const b = arkBalance(); return h('div', { class: 'col balance-move' }, h('div', { class: 'seg' }, h('button', { class: dir === 'toSpending' ? 'active' : '', onClick: () => { ui.arkMoveDir = 'toSpending'; ui.arkError = ''; render(); } }, t('moveToSpending')), h('button', { class: dir === 'toSaving' ? 'active' : '', onClick: () => { ui.arkMoveDir = 'toSaving'; ui.arkError = ''; render(); } }, t('moveToSaving'))), dir === 'toSpending' ? h('div', { class: 'col', style: 'gap:8px' }, h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSpendingDesc')), ark ? boardForm() : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting')))) : h('div', { class: 'col', style: 'gap:8px' }, h('p', { class: 'small muted', style: 'margin:0' }, t('moveToSavingDesc')), // no "X sats in Spending" line — the balance sits right above on this card (() => { const spendable = b ? b.spendableSat : 0; const sats = parseInt((ui.arkOffboardAmt || '').trim(), 10) || 0; const valid = sats > 0 && sats <= spendable; return h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'input-group' }, amountField({ value: ui.arkOffboardAmt, placeholder: t('arkOffboardAmtPlaceholder'), onInput: (e) => { ui.arkOffboardAmt = e.target.value; render(); }, maxSat: spendable, onMax: () => { ui.arkOffboardAmt = String(spendable); render(); }, }), h('button', { class: 'btn-primary', disabled: !!ui.arkBusy || !valid, onClick: () => doArkOffboard(sats), }, ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('moveToSavingBtn'))), ui.arkBusy === 'offboard' && sats < spendable ? h('div', { class: 'small muted', style: 'text-align:center' }, t('arkSplitting')) : null, h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.arkMoveOpen = false; ui.arkExitPage = true; ui.arkError = ''; render(); }, }, t('arkUniTitle'))); })()), ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, // the panel unfolds on the balance card with no obvious way back out — // a dismiss returns you to the plain card h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.arkMoveOpen = false; ui.arkError = ''; render(); }, }, t('cancel'))); } // "Payment received!" takeover for unseen Ark receives. function arkCelebration() { // An Ark payment landed — same celebration as an on-chain receive, dismissed // with a tap (the ack persists in the ark state so it shows exactly once). const arkUnseen = ark && ark.state ? ark.unseenReceives() : []; if (arkUnseen.length) { const amt = arkUnseen.reduce((n, m) => n + m.amountSat, 0); return h( 'div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', onClick: () => { ark.ackReceives(); render(); }, }, h('div', { class: 'check-badge' }, '✓'), h('h2', { style: 'margin:0' }, t('paymentReceived')), amt ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(amt) + ' ' + unitLabel()) : null, h('div', { class: 'small muted' }, t('tapToProceed')) ); } return null; } // Offboard success screen — mirror of the boarded screen. function arkOffboardedScreen() { if (!ui.arkOffboarded) return null; const o = ui.arkOffboarded; const url = wallet.api.explorerTx(o.txid); return h( 'div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:40px 20px' }, h('div', { class: 'check-badge' }, '✓'), h('h2', { style: 'margin:0' }, t('arkOffboardedTitle')), h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(o.netSat) + ' ' + unitLabel()), h('p', { class: 'small muted', style: 'margin:0' }, t('arkOffboardedNote')), o.feeSat ? h('div', { class: 'small faint' }, t('feeShort', { x: fmtAmount(o.feeSat) })) : null, h('div', { class: 'addr-box', style: 'width:100%' }, o.txid), h('div', { class: 'row gap6' }, copyBtn(o.txid, t('copyTxid')), h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool'))), h('button', { class: 'btn-primary btn-block', onClick: () => { ui.arkOffboarded = null; ui.arkExitPage = null; render(); } }, t('done')) ); } // Boarding success screen (txid + explorer link) until dismissed. function arkBoardedScreen() { // A board just broadcast — show its success screen until dismissed. if (ui.arkBoarded) { const b = ui.arkBoarded; const url = wallet.api.explorerTx(b.txid); return h( 'div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:40px 20px' }, h('div', { class: 'check-badge' }, '✓'), h('h2', { style: 'margin:0' }, t('arkBoardedTitle')), h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(b.netSat) + ' ' + unitLabel()), h('p', { class: 'small muted', style: 'margin:0' }, boardedNote()), h('div', { class: 'addr-box', style: 'width:100%' }, b.txid), h('div', { class: 'row gap6' }, copyBtn(b.txid, t('copyTxid')), h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool'))), h('button', { class: 'btn-primary btn-block', onClick: () => { ui.arkBoarded = null; render(); } }, t('done')) ); } return null; } // Per-exit progress lines (chain hops / timelock countdown / claiming), with // a cancel for an exit that never published. Shown on the exit page. function arkExitStatusLines() { const s = arkStateNow(); const acts = ((s && s.actions) || []).filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)); if (!acts.length) return []; const exitDelta = (ark && ark.info && ark.info.vtxoExitDelta) || 144; const totalSat = acts.reduce((n, a) => n + a.amountSat, 0); // One story for the whole batch: each coin's chain unrolls a confirmed // hop per block, then waits out the CSV timelock, then the claim itself // confirms. Coins run in parallel, so the slowest one sets the ETA. const blocksFor = (a) => a.step === 'chain' ? ((a.txids || []).length - (a.hopsDone || 0)) + exitDelta + 1 : a.step === 'timelock' ? Math.max(0, a.claimableAt - (a.tipSeen || 0)) + 1 : 1; const mins = Math.max(...acts.map(blocksFor)) * 10; const eta = getNetwork() === 'regtest' ? t('arkExitSoon') : mins >= 2880 ? `${Math.round(mins / 1440)} days` : mins >= 120 ? `${Math.round(mins / 60)} hours` : `${mins} minutes`; // The most advanced tx that's actually out there — the one worth watching. // A chain hop that errored before broadcast has nothing to link yet. let watch = null, watchScore = -1; for (const a of acts) { const score = a.step === 'claiming' ? 3 : a.step === 'timelock' ? 2 : a.hopsDone > 0 ? 1 : 0; const txid = exitWatchTxid(a); if (score > 0 && txid && score > watchScore) { watch = txid; watchScore = score; } } const err = acts.find((a) => a.lastError && a.actionable); const softErr = !err && acts.some((a) => a.lastError); // Cancelling is only real while nothing has touched the chain AND the // driver is parked waiting on the user (a needs-fee stall) — a healthy // exit may be mid-broadcast this very second. One button for the whole // batch, gone the moment it would be a lie. const cancellable = acts.every((a) => a.actionable && !(a.hopsDone > 0) && a.step === 'chain'); return [h('div', { class: 'small muted', style: 'margin-top:4px' }, t('arkExitSummary', { n: fmtSats(totalSat), eta }), err ? h('div', { class: 'small err' }, err.lastError) : softErr ? h('div', { class: 'small faint' }, t('arkExitRetrying')) : null, watch ? h('div', { style: 'margin-top:4px' }, h('a', { class: 'linklike small', href: wallet.api.explorerTx(watch), target: '_blank', rel: 'noopener' }, t('arkExitViewTx'))) : null, cancellable ? h('button', { class: 'linklike small', style: 'display:block;margin-top:4px', onClick: () => { for (const a of acts) { a.step = 'failed'; const v = ark && ark._vtxo(a.vtxoId); if (v && v.state === 'pending') v.state = 'spendable'; } if (ark) ark._save(); render(); } }, t('arkExitCancel')) : null)]; } // The exit page: cooperative offboard vs unilateral exit, with explanation. // Reached from the "Exit" link on the Ark balance line. function arkExitPage() { if (ui.arkOffboarded) return arkOffboardedScreen(); // cooperative success takeover const b = arkBalance() || { spendableSat: 0, pendingSat: 0 }; const spendable = b.spendableSat; const total = b.spendableSat + b.pendingSat; const nSpend = ((arkStateNow() && arkStateNow().vtxos) || []).filter((v) => v.state === 'spendable').length; const exitingSat = ((arkStateNow() && arkStateNow().actions) || []) .filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)) .reduce((n, a) => n + a.amountSat, 0); const exits = arkExitStatusLines(); const back = () => { ui.arkExitPage = null; ui.arkError = ''; render(); }; return h('div', { class: 'col', style: 'gap:16px' }, h('div', { class: 'card col', style: 'gap:8px' }, h('h3', { class: 'row gap6', style: 'align-items:center;margin:0' }, h('span', { html: ARK_ICON(18) }), t('arkExitPageTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitPageIntro')), total > 0 ? h('div', { class: 'row between', style: 'margin-top:4px' }, h('span', { class: 'small muted' }, t('arkBalance')), h('span', { class: 'small' }, fmtAmount(total) + ' ' + unitLabel())) : null, null), // cooperative h('div', { class: 'card col', style: 'gap:8px' }, h('h4', { style: 'margin:0' }, t('arkCoopTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')), spendable > 0 ? h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: () => doArkOffboard() }, ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtAmount(spendable) + ' ' + unitLabel() })) : h('div', { class: 'small faint' }, exitingSat > 0 ? t('arkExitCoopHeld') : t('arkExitNoBalance'))), // unilateral h('div', { class: 'card col', style: 'gap:8px' }, h('h4', { style: 'margin:0' }, t('arkUniTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('arkUniDesc')), nSpend > 0 ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkExit }, ui.arkBusy === 'exit' ? h('span', { class: 'spinner sm' }) : t('arkExitBtn')) : null, ...exits), ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')) ); } return { id: 'ark', init() { _autoSelected = false; ui.accountUserChosen = false; initArk(); }, stop() { stopArk(); }, // Boot waits on this before opening a wallet: the Spending balance and // its history live in the ark store, which is IndexedDB and therefore // opens asynchronously. Painting first meant painting an empty Spending // wallet and filling it in a beat later — a 0 that counted itself up, // green, as if money had just arrived. Capped, because a store that // won't open must not hold the whole app hostage; the retry in initArk // catches up if it lands after the cap. arkStoreReady() { const store = wallet._arkStore; if (!store || store.ready) return null; return Promise.race([ store.open().catch(() => {}), new Promise((r) => setTimeout(r, 800)), ]); }, screenView() { return ui.arkCoinsPage ? arkCoinsPage() : ui.arkExitPage ? arkExitPage() : null; }, receiveTakeover() { const offboarded = arkOffboardedScreen(); if (offboarded) return offboarded; const boarded = arkBoardedScreen(); if (boarded) return boarded; return arkCelebration(); }, isSendDest(a) { return isArkAddress(a) && arkAvailable(); }, hideSendControls(a) { return isArkAddress(a); }, // Max for an ark recipient: arkoor sends are free, so the whole // spendable Spending balance is the honest maximum — WHEN Spending is // the selected rail. From Savings this destination is either a board // (your own address — review routes it to the board panel) or a // wrong-rail send review will bounce; either way the honest maximum is // what Savings can actually fund. Filling Spending's number here handed // the board panel an amount Savings couldn't cover, and the user read // the resulting 'insufficient funds' as a fee miscalculation. sendMaxFill(a) { if (!isArkAddress(a)) return null; const sat = ctx.getAccount() === 'spending' ? (arkBalance()?.spendableSat || 0) : maxBoardSat(); if (!sat) return null; return ctx.getUnit() === 'sats' ? String(sat) : (sat / 1e8).toFixed(8); }, sendFormNote(a) { if (!isArkAddress(a)) return null; maybeReconcile(); return h('div', { class: 'small faint' }, t('arkSendHint')); }, interceptReview(s) { if (s.recipients.length === 1) { const inv = (s.recipients[0].address || '').trim().replace(/^lightning:/i, ''); if (maybeLnInvoice(inv) && startArkLnPay(inv)) return true; } if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) { if (!arkAvailable()) throw new Error(t('arkNotConnected')); const sats = ctx.parseAmount(s.recipients[0].amount, ctx.getUnit()); if (!sats || sats <= 0) throw new Error(t('enterValidAmtForN', { n: 1 })); const dest = s.recipients[0].address.trim(); const fromSavings = ctx.getAccount() !== 'spending'; (async () => { // Whose address is this? The manager may still be connecting, so // resolve async before painting a review. Our own address routes // to the board panel with the typed amount riding along. try { await connectArk(); } catch (e) { ui.sendError = e.message; render(); return; } if (await boardOwnAddress(dest, { amountSat: sats })) return; if (fromSavings) { // An ark payment spends the Spending balance — never silently // spend a different balance than the one selected. ui.sendError = t('arkPayFromSpending'); } else { ui.arkSend = { address: dest, amountSat: sats }; } render(); })(); return true; } return false; }, sendView() { if (ui.arkOffboardSend) return arkOffboardSendView(); if (ui.arkLnPaid || ui.arkLnPay) return arkLnPayView(); if (ui.arkZapped || ui.arkZap) return arkZapView(); if (ui.arkSent || ui.arkSend) return arkSendReview(); return null; }, // A bolt11 pays from the ark balance when it can cover it. Reached via the // swaps feature's delegation (its matcher runs first) or directly in // builds without the swaps feature. startArkLnPay(invoice, meta) { return startArkLnPay(invoice, meta); }, // The zaps feature's generic Lightning seams (once served by the retired // swaps feature): the ASP pays invoices natively over ark. canLnPay() { return arkAvailable() && !wallet.watchOnly && !!(ark && ark.info); }, // Fetch + verify a bolt12 invoice for an offer (the ASP's CLN speaks the // onion messages) — resolves { invoice (lni), amountSat, paymentHash }. async arkFetchBolt12Invoice(offerStr, amountSat) { const mgr = await connectArk(); return mgr.fetchBolt12(offerStr, amountSat); }, lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, // Max for any Lightning amount form: spendable minus estimated fees. lnMaxSendSat() { return lnMaxSat(); }, settingsCards() { return [autoWithdrawCard()]; }, // a wallet signing out takes its ark state with it, wherever it lives wipeCache(prefixes) { for (const p of prefixes) wallet._arkStore.removePrefix(p); }, // The onboarding wizard's top-up step borrows the board form wholesale. arkBoardForm() { if (!arkAvailable() || wallet.watchOnly) return null; if (!ark) { connectArk().catch(() => {}); return null; } return boardForm(); }, // A payment wants more Spending than exists: open the board panel with a // prefill that nets out to the need (gross of the board fee, with a bit // of routing headroom) — editable, so boarding extra is one keystroke. arkOfferBoard(needSat) { if (!arkAvailable() || wallet.watchOnly) return false; const b = arkBalance(); const have = b ? b.spendableSat : 0; 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; ctx.goHome(); ui.arkMoveOpen = true; ui.arkMoveDir = 'toSpending'; ui.arkBoardAmt = String(Math.max(gross, minBoard)); render(); return true; }, startLnPay(invoice, meta) { return startArkLnPay(invoice, meta); }, // ---- headless seam for the NWC wallet service ---- arkPayInvoice(invoice, opts) { return payInvoiceHeadless(invoice, opts); }, // The settled outcome of a payment another device of this wallet // started (the ASP refuses a second HTLC for the same invoice). async arkLnOutcome(paymentHash, timeoutMs) { const mgr = await connectArk(); return mgr.lnOutcome(paymentHash, timeoutMs); }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, arkReady() { return arkAvailable(); }, async arkMakeInvoice(amountSat, description) { const mgr = await connectArk(); const a = await mgr.createLnInvoice(amountSat, description); return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat }; }, arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; }, // The wallet's reusable ark receive address (BIP-353 names publish it). async arkStaticAddress() { const mgr = await connectArk(); return mgr.address(); }, // Whether background answering can mirror right now. arkBgReady() { return !!(ark && ark.info && ark.state); }, // 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); }, async arkBgSpendableSat() { const rec = await loadBg(wallet._cacheKey()); return ((rec && rec.mgr && rec.mgr.vtxos) || []) .filter((v) => v.state === 'spendable').reduce((n, v) => n + (v.amountSat || 0), 0); }, // Worker payments become ordinary history lines once the app opens. async arkBgNoteSpends(spends) { const mgr = await connectArk(); for (const sp of spends) { mgr._movement({ type: 'ln-send', amountSat: sp.amountSat, status: 'complete', detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '', }); } mgr._save(); }, // A name that resolved to OUR OWN ark address: the names feature asks // before filling the send form, so the board panel opens at paste time — // nobody should have to type an amount into a payment form just to be // told they were topping themselves up. arkBoardIfOwn(addr) { return boardOwnAddress(addr, { connect: true }); }, // An npub pasted into Send becomes an ark zap (needs the nostr seam from // the sync feature and a connected-able ark). A bolt11 is handled here // only in builds without the swaps feature (whose matcher runs first and // delegates back via the startArkLnPay hook). matchSendText(text) { const inv = (text || '').trim().replace(/^lightning:/i, ''); // A raw paste of our own ark address means the same thing — start the // check now (the manager may need to connect); if it's ours the board // panel replaces the filled form a beat later. if (isArkAddress(inv) && arkAvailable()) boardOwnAddress(inv, { connect: true }).catch(() => {}); // In a Spending wallet an on-chain address means "exit ark to there": // the offboard happens right in the send flow, no Move money ceremony. if (ctx.getAccount() === 'spending' && wallet.isOnchainAddress(inv) && arkAvailable()) { const b = arkBalance() || {}; if ((b.spendableSat || 0) > 0) { ui.arkOffboardSend = { address: inv, amount: '' }; ui.sendError = ''; render(); return true; } // Every coin is mid-exit: the send page is for CREATING payments, // so stay right here with an honest error — the in-flight exit is a // history record now, and sendFormExtras links straight to it. if ((b.pendingSat || 0) > 0 && (arkStateNow()?.actions || []).some((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step))) { ui.sendError = t('arkNoSpendableMidExit'); render(); return true; } } if (maybeLnInvoice(inv)) { if (startArkLnPay(inv)) return true; if (arkAvailable() && !wallet.watchOnly) { // An invoice Spending can't cover used to fall through SILENTLY — // the bolt11 landed in the address field looking like a dead // 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; ui.sendError = t('arkLnExceedsSpending', { need: fmtAmount(need) + ' ' + unitLabel(), have: fmtAmount(arkBalance()?.spendableSat || 0) + ' ' + unitLabel(), }); render(); return true; } return false; } const pk = npubToHex(text); if (!pk || !arkAvailable() || !wallet.nostrFetch) return false; startNpubPay(pk, String(text).trim()); return true; }, // Zap a person — optionally a specific note of theirs (the profile feed's // ⚡ button) — through the same resolution ladder as a pasted npub. The // event id rides into the receipt's e tag; callers fall back to the // lnZapNpub hook when ark can't serve this build/wallet. zapNpub(pk, npub, eventId, autoSat) { if (!arkAvailable() || !wallet.nostrFetch) return false; startNpubPay(pk, npub, eventId || null, autoSat || 0); return true; }, sendFormExtras() { if (ctx.getAccount() !== 'spending') return null; const acts = ((arkStateNow() && arkStateNow().actions) || []).filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)); if (!acts.length) return null; return h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.tab = 'history'; ui.sendError = ''; ui.arkExitDetail = acts[0].id; render(); } }, t('arkExitInFlightLink', { n: fmtAmount(acts.reduce((s2, a) => s2 + a.amountSat, 0)) })); }, historyEntries(_txs, sel) { if (sel && sel !== 'spending') return []; // ark movements are the Spending timeline const s = arkStateNow(); if (!s) return []; // Memoized on the state's REVISION, not a timer. This rebuild sorts and // dedupes every movement, resolves gifts, and touches secp256k1 — ~60ms // cold. A 1.5s TTL used to re-cool it between unhurried flicks, so every // account switch paid the rebuild and stuttered the slide; keyed on _rev // it's reused until the data actually changes (each _save bumps _rev), // and stays warm no matter how slowly the user flicks back and forth. const key = `${s._rev || 0}:${(s.movements || []).length}:${(s.actions || []).length}`; if (this._histMemo && this._histMemo.key === key) return this._histMemo.val; const val = this._histBuild(s); this._histMemo = { key, val }; return val; }, _histBuild(s) { // In-flight exits are payments the user made — they belong in history // as pending records, not on a monitoring page nobody thinks to visit. const exits = (s.actions || []) .filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)) .map((a) => ({ time: exitStartedTs(a), render: () => arkExitActionItem(a) })); // Same-payment duplicates (the old settle race, or a snapshot adopted // whole before the merge learned to dedupe) fold at display time too — // the earliest telling wins, everywhere, however the state got here. // Renewals fold on their unlock hash (two devices can adopt the same // participation), and only ones that COST something get a row — a free // renewal is net zero, invisible money movement, noise. const seenPay = new Set(); const moves = (s.movements || []) .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit', 'ln-send', 'ln-receive', 'refresh', 'reconcile'].includes(m.type) && (m.status === 'complete' || (m.type === 'send' && m.status === 'failed'))) .filter((m) => m.type !== 'refresh' || m.feeSat > 0) .sort((m, n) => (m.ts || 0) - (n.ts || 0)) .filter((m) => { if (!m.type.startsWith('ln-') && !(m.type === 'refresh' && m.unlockHash)) return true; const key = m.type + ':' + (m.unlockHash || m.paymentHash || m.preimage || m.invoice || m.id); if (seenPay.has(key)) return false; seenPay.add(key); return true; }); // Coins the server reported spent elsewhere: without a row for them the // history stops summing to the balance (cooney's looked 29k sats rich). // One reconcile pass marks many coins in the same instant — fold each // pass (and re-imported duplicates, deduped by coin) into one row. // A coin whose spend is ACCOUNTED FOR is excluded outright — a // renewal's inputs (the renewed coin balances the books invisibly), // and equally a send's, an offboard's, an exit's: those movements and // actions sync, so painting the vanished input beside the real Sent // row showed one spend twice, at input magnitude the second time. // The red row survives only for spends nothing anywhere explains. const accounted = new Set(); for (const a of s.actions || []) { for (const id of a.inputIds || []) accounted.add(id); for (const part of a.parts || []) if (part.inputId) accounted.add(part.inputId); } for (const m of s.movements || []) for (const id of m.inputIds || []) accounted.add(id); const out = []; const seenCoin = new Set(); for (const m of moves) { 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); const last = out[out.length - 1]; if (last && last.type === 'reconcile' && last.ts === m.ts) { last.amountSat += m.amountSat; last.count += 1; if (m.vtxoId) last.vtxoIds.push(m.vtxoId); } else { out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] }); } } return exits.concat(out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) }))); }, historyDetail() { if (ui.arkExitDetail) { const a = ((arkStateNow() && arkStateNow().actions) || []).find((x) => x.id === ui.arkExitDetail); if (a && !['done', 'failed'].includes(a.step)) return arkExitActionDetail(a); ui.arkExitDetail = null; } // the folded reconcile row isn't a stored movement — its detail rides // the ui state the row's tap put there (and nav history restores) if (ui.arkReconDetail && typeof ui.arkReconDetail === 'object') return arkReconDetailView(ui.arkReconDetail); if (!ui.arkMoveDetail) return null; const s = arkStateNow(); const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null; if (m) return arkMoveDetailView(m); ui.arkMoveDetail = null; return null; }, // The spendable off-chain balance IS the wallet's "Spending" — the balance // card shows it by name, so this feature only reports the number. // "Move money" under the balance — the one door between the two balances. balanceActions() { return []; }, // Move money retired: exits ride the Send field now balanceExtra() { if (!ui.arkMoveOpen || wallet.watchOnly || !arkAvailable()) return null; return movePanel(); }, // Sats mid-board (the on-chain tx awaiting its confirmation) — Spending // doesn't count them yet, but a top-up PROMPT must: nagging someone to // top up again while their top-up confirms is noise. spendingBoardingSat() { const b = arkBalance(); return b ? b.boardingSat || 0 : 0; }, spendingSat() { const b = arkBalance(); return b ? b.spendableSat + b.pendingSat : 0; }, balanceLines() { const b = arkBalance(); if (!b) return []; // Money mid-move: it has left Saving but hasn't landed in Spending, so // say where it went rather than letting it vanish for a confirmation — // with the confirmation count, so the wait has a visible reason. if (!(b.boardingSat > 0)) return []; const a = ((arkStateNow() || {}).actions || []).find( (x) => x.type === 'board' && x.fundingTxid && !['done', 'failed'].includes(x.step) && x.needConfs); const prog = a ? ` · ${Math.min(a.confs || 0, a.needConfs)}/${a.needConfs}` : ''; return [{ label: t('movingLabel') + prog, sat: b.boardingSat, side: 'spending' }]; }, walletNotices() { // Everything the balance needs to say, said once. These used to be a // stack of separate boxes — two red cards and a grey line, three // Manage links between them, all pointing at the same page. One card // now: red if anything in it is a warning, plain otherwise, and one // Manage at the bottom. const lines = []; // { text, err, manage } // A pending move that keeps erroring would otherwise wait in silence. for (const a of ((arkStateNow() || {}).actions || [])) { if (a.type === 'board' && a.lastError && !['done', 'failed'].includes(a.step)) lines.push({ text: t('arkBoardStuck', { error: a.lastError }), err: true }); } if (arkRenewWarn && !wallet.watchOnly) lines.push({ text: t('arkRenewWarn', { amount: fmtAmount(arkRenewWarn.sat) + ' ' + unitLabel(), date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }), err: true, }); // Coins past their expiry. The balance already leaves these out, and // until now it did so in silence — money apparently gone, with the one // page that could bring it back reachable only through an advisory // that most wallets never see (it wants four hops of depth) or a // renewal forecast that only appears when the renewal COSTS something, // which is no longer the normal case. // // An expired vtxo can still be renewed — that is the whole of the fix, // and it is one tap away on the Manage page. What it cannot do is be // spent over Ark or exited unilaterally, so saying nothing is the one // option that isn't honest. if (!wallet.watchOnly && ark && ark.balance) { let expiredSat = 0; try { expiredSat = ark.balance().expiredSat || 0; } catch {} if (expiredSat > 0) lines.push({ text: t('arkExpiredNotice', { amount: fmtAmount(expiredSat) + ' ' + unitLabel() }), err: true, manage: true, }); } const depthNotice = exitDepthNotice(); if (depthNotice) lines.push({ text: depthNotice, manage: true }); // An upcoming renewal that will COST something — stated before it // happens. Ark is new to nearly everyone, and a fee nobody announced // reads as money gone missing (it did, once). Free renewals — the // normal case now — pass without comment, and one line under the // balance is enough: the depth advisory outranks this forecast, and // Manage carries the full story either way. if (!depthNotice && ark && ark.state && ark._tipH && !wallet.watchOnly) { const spend = (ark.state.vtxos || []).filter((v) => v.state === 'spendable' && v.expiryHeight && !v.expiryRejected); if (spend.length) { const tip = ark._tipH; const RENEW = renewWindow(); const minExpiry = Math.min(...spend.map((v) => v.expiryHeight)); const blocksUntil = Math.max(0, minExpiry - RENEW - tip); // price it at the moment it will actually run: only the coins then // inside the window ride along, billed at that moment's bracket const simTip = minExpiry - RENEW + 1; const batch = spend.filter((v) => v.expiryHeight - simTip < RENEW); let feeSat = 0; try { feeSat = ark.refreshFee(batch, simTip); } catch {} const days = Math.max(1, Math.round(blocksUntil / 144)); if (feeSat > 0) lines.push({ text: t(blocksUntil <= 0 ? 'arkRenewForecastNow' : 'arkRenewForecast', { when: days === 1 ? t('arkCoinsSpanDay') : t('arkCoinsSpanDays', { n: days }), fee: fmtAmount(feeSat) + ' ' + unitLabel(), }), manage: true, }); } } if (!lines.length) return []; const err = lines.some((l) => l.err); const manage = lines.some((l) => l.manage) ? h('button', { class: 'linklike small', style: 'align-self:flex-end', onClick: () => { ui.arkCoinsPage = true; render(); } }, t('arkDepthBtn')) : null; // one plain advisory keeps its quiet centred line; anything more, or // anything urgent, is a card if (!err && lines.length === 1) { return [h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, lines[0].text, manage ? ' ' : null, manage)]; } return [h('div', { class: 'notice' + (err ? ' err' : ' info') + ' col', style: 'margin:12px 0 0;gap:8px' }, ...lines.map((l) => h('div', { style: l.err || !err ? '' : 'opacity:.8' }, l.text)), manage)]; }, decorateTxRow(tx) { const s = arkStateNow(); if (!s) return null; const acts = s.actions || []; if (tx.net < 0) { const a = acts.find((x) => x.type === 'board' && x.fundingTxid === tx.txid); return a ? { icon: h('span', { html: ARK_MARK(16) }), label: h('span', {}, t('arkBoardHistory')) } : null; } const o = acts.find((x) => x.type === 'offboard' && x.txid === tx.txid); if (o) return { icon: h('span', { html: ARK_MARK(16) }), label: h('span', {}, t('arkOffboardHistory')) }; const e = acts.find((x) => x.type === 'exit' && x.claimTxid === tx.txid); return e ? { icon: h('span', { html: ARK_MARK(16) }), label: h('span', {}, t('arkExitHistory')) } : null; }, txDetailSection(tx) { const s = arkStateNow(); if (!s) return null; const a = (s.actions || []).find((x) => x.type === 'board' && x.fundingTxid === tx.txid); if (!a) return null; const done = a.step === 'done'; return h('div', { class: 'summary col', style: 'gap:0' }, h('div', { class: 'row gap6', style: 'font-weight:600;margin:12px 0 2px;align-items:center' }, h('span', { html: ARK_ICON(16) }), t('arkBoardHistory')), h('div', { class: 'line' }, h('span', { class: 'k' }, t('arkBalance')), h('span', { class: 'v' }, '+' + fmtAmount(a.amountSat - a.feeSat) + ' ' + unitLabel())), a.feeSat > 0 ? h('div', { class: 'line' }, h('span', { class: 'k' }, t('arkBoardFeeLabel')), h('span', { class: 'v' }, fmtAmount(a.feeSat) + ' ' + unitLabel())) : null, done ? null : h('div', { class: 'small muted', style: 'margin-top:2px' }, boardedNote())); }, // ---- ark-gift hooks (called by the gifts feature via ctx.hook) ---- arkGiftInfo() { // Persisted state, not the live manager: the gift form renders before // connectArk() has run, and a null here silently flips the gift to the // on-chain source — "more than your balance" against Savings while the // header shows a healthy Spending balance. Creation connects on demand. const b = arkAvailable() ? arkBalance() : null; if (!b) return null; maybeReconcile(); // the gift form is a send-intent signal too (throttled) return { spendableSat: b.spendableSat, pendingSat: b.pendingSat }; }, arkGiftDecode(code) { return decodeArkGiftCode(code); }, // A fresh visitor (no wallets) opening a gift link lands on the gift's // network automatically instead of being told to change Settings. arkGiftAdoptNetwork(code) { const g = decodeArkGiftCode(code); if (!g || g.net === getNetwork()) return false; setNetwork(g.net); return true; }, arkGiftCreate(amountSat) { return createArkGift(amountSat); }, // Claim-side status: claimable (with the live amount), taken, wrongnet, or // unknown (not visible here — wrong server or not yet delivered). async arkGiftStatus(code) { const g = decodeArkGiftCode(code); if (!g) return null; if (g.net !== getNetwork()) return { state: 'wrongnet', net: g.net, amountSat: g.amountSat }; const gm = await giftManager(g.secretHex); await gm.sync().catch(() => {}); await gm.reconcile().catch(() => {}); const amt = gm.balance().spendableSat; if (amt > 0) return { state: 'claimable', amountSat: amt }; const seen = gm.state.vtxos.length > 0; return { state: seen ? 'taken' : 'unknown', amountSat: g.amountSat }; }, async arkGiftClaim(code) { if (wallet.watchOnly) throw new Error(t('arkWatchOnly')); const mine = await connectArk(); const amount = await sweepArkGift(code, mine.address()); await mine.sync().catch(() => {}); // pull the swept vtxo in right away mine.ackReceives(); // the gift UI celebrates; don't double-celebrate here return amount; }, arkGiftOutstanding() { refreshArkGiftRecords(); return arkGiftRecords().filter((g) => !g.revoked && !g.claimed) .map((g) => ({ id: g.id, amountSat: g.amountSat, created: g.created })); }, async arkGiftRevoke(id) { return doArkGiftRevoke(id); }, }; async function doArkGiftRevoke(id) { const g = arkGiftRecords().find((x) => x.id === id); if (!g) throw new Error('unknown gift'); const mine = await connectArk(); const code = encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex)); let amount; try { amount = await sweepArkGift(code, mine.address()); } catch (e) { // beaten by the claimer — record it so the row self-heals immediately if (e && e.giftTaken) { g.claimed = true; mine._save(); } throw e; } g.revoked = true; mine._save(); await mine.sync().catch(() => {}); mine.ackReceives(); // our own sweep-back isn't a "payment received" return amount; } }