diff options
| author | Adam Soltys <[email protected]> | 2026-07-04 08:55:45 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-04 08:55:45 -0700 |
| commit | 11a14600da683769c74d49e3d973183aa1a2fe5e (patch) | |
| tree | 004ac05ad9a6f6e109acb2d0efdf82b19747d48a /src/features/ark.js | |
| parent | 65ad54a060a8035d8cf1e1ca2004f7c3bc5ff296 (diff) | |
| download | coinosv3-11a14600da683769c74d49e3d973183aa1a2fe5e.tar.gz coinosv3-11a14600da683769c74d49e3d973183aa1a2fe5e.zip | |
Features: extract swaps, Ark, and SP behind a plugin seam
First step toward optional build profiles. app.js gains a small feature
registry (ctx + FEATURES + hook helpers); swaps/Ark/silent-payments UI
moves wholesale to src/features/{swaps,ark,sp}.js, each exporting a
factory returning integration hooks:
init/stop/networkChanged wallet lifecycle
receiveModes/receiveTakeover receive tab
isSendDest/matchSendText/
interceptReview/sendView/
hideSendControls/sendFormNote send flow
historyEntries/historyDetail/
txDetailSection history
balanceLines/settingsCards balance card + settings
No behavior change: ark + gift browser e2e suites pass, and a probe
confirms LN receive, all receive modes, and the three settings cards
render as before. Gifts extraction and build flags come next.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
Diffstat (limited to 'src/features/ark.js')
| -rw-r--r-- | src/features/ark.js | 429 |
1 files changed, 429 insertions, 0 deletions
diff --git a/src/features/ark.js b/src/features/ark.js new file mode 100644 index 0000000..24acfea --- /dev/null +++ b/src/features/ark.js @@ -0,0 +1,429 @@ +// 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 { ArkManager } from '../ark/manager.js'; +import { getNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from '../api.js'; +import { t } from '../i18n.js'; +import { qrSvg } from '../qr.js'; +import { shortAddr, shortTxid, timeAgo } 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()); } + +export function arkFeature(ctx) { + const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx; + + // 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() { + arkInitGen++; + if (arkTimer) clearInterval(arkTimer); + arkTimer = null; + if (ark) ark.stopMailboxStream(); + ark = null; + arkConnectPromise = null; + } + + function arkAvailable() { + return !!getArkConfig() && !wallet.watchOnly && !!wallet.account; + } + + function arkWanted() { + const st = wallet.loadArkState(); + return !!(st && ((st.vtxos || []).length || (st.movements || []).length + || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); + } + + function initArk() { + stopArk(); + ui.arkError = ''; + if (arkAvailable() && arkWanted()) connectArk().catch(() => {}); + } + + 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; + const mgr = new ArkManager({ + account: wallet.account(), + storage: { load: () => wallet.loadArkState(), save: (s) => wallet.saveArkState(s) }, + arkUrl: cfg.ark, + esploraUrl: cfg.esplora, + network: getNetwork(), + onUpdate: () => render(), + }); + ui.arkError = ''; + arkConnectPromise = mgr.init().then(() => { + if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect + ark = mgr; + const tick = () => mgr.sync().catch(() => {}); + tick(); + // Receives arrive in real time over the mailbox stream; the poll is the + // fallback and what drives in-flight boards/refreshes forward. + mgr.startMailboxStream(); + 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; + } + + function arkBalance() { + if (!ark || !ark.state) return null; + return ark.balance(); + } + + 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, + 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' })) + : 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(); + } + + function arkHistoryItem(m) { + const incoming = m.type !== 'send'; + const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent'); + return h( + 'div', + { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, + h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, incoming ? '↓' : '↑'), + h('div', { class: 'grow' }, + h('div', { class: 'row gap6' }, + label, + h('span', { class: 'tag' }, 'Ark'), + m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), + h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), + h('div', { style: 'text-align:right' }, + h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '-') + fmtAmount(m.amountSat))) + ); + } + + function arkMoveDetailView(m) { + const incoming = m.type !== 'send'; + const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : 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' }, + h('h3', { style: 'margin:0' }, label), + h('span', { class: 'tag' }, 'Ark'), + m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), + h('div', { class: incoming ? 'amount-pos' : 'amount-neg', style: 'font-size:20px' }, + (incoming ? '+' : '-') + fmtAmount(m.amountSat) + ' ' + unitLabel()), + row(t('dateLabel'), new Date(m.ts).toLocaleString()), + m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null, + m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null, + m.detail ? row(t('detailsLabel'), m.detail) : 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), + h('div', { class: 'row gap6' }, + copyBtn(m.txid, t('copyTxid')), + 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, + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkMoveDetail = null; render(); } }, t('back')) + ); + } + + function arkCard() { + const net = getNetwork(); + const id = getArkProviderId(net); + const custom = getArkCustom(net); + const presets = arkPresets(net); + const applyProvider = (v) => { + setArkProviderId(v, net); + ui.receiveType = null; + initArk(); + render(); + }; + const head = [ + h('h3', {}, '⚔ Ark'), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkDesc')), + h('select', { onChange: (e) => applyProvider(e.target.value) }, + presets.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))), + id === 'custom' + ? h('div', { class: 'col', style: 'gap:8px' }, + h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('arkServerUrl')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://ark.example.com', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.ark, + onChange: (e) => { setArkCustom({ ark: e.target.value.trim(), esplora: custom.esplora }, net); initArk(); render(); }, + })), + h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('arkEsploraUrl')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://mempool.example.com/api', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.esplora, + onChange: (e) => { setArkCustom({ ark: custom.ark, esplora: e.target.value.trim() }, net); initArk(); render(); }, + }))) + : null, + ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, + ]; + if (id === 'off' || wallet.watchOnly) { + return h('div', { class: 'card col' }, ...head, + wallet.watchOnly && id !== 'off' ? h('div', { class: 'small faint' }, t('arkWatchOnly')) : null); + } + if (!ark || !ark.state) { + return h('div', { class: 'card col' }, ...head, + arkConnectPromise + ? h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))) + : h('button', { class: 'btn-ghost btn-block', onClick: () => connectArk().catch(() => {}) }, t('arkConnectBtn'))); + } + + const spendables = ark.vtxos().filter((v) => v.state === 'spendable'); + const pendingActions = ark.pendingActions(); + 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:10px' }, + ...head, + spendables.length ? row(t('arkVtxos'), String(spendables.length)) : null, + pendingActions.length + ? h('div', { class: 'small muted' }, t('arkPendingActions', { n: pendingActions.length }) + ' — ' + pendingActions.map((a) => `${a.type}:${a.step}`).join(', ')) + : null, + spendables.length >= 1 + ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkRefresh }, + ui.arkBusy === 'refresh' ? h('span', { class: 'spinner sm' }) : t('arkRefreshBtn', { n: spendables.length })) + : null + ); + } + + async function doArkBoard() { + const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); + if (!sats) return; + ui.arkBusy = 'board'; ui.arkError = ''; render(); + try { + const { actionId, fundingAddress, feeSat } = await ark.startBoard(sats); + 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); + await ark.completeBoard(actionId, txid); + ui.arkBoardAmt = ''; + ui.arkBoarded = { txid, netSat: sats - feeSat }; + wallet.scan().catch(() => {}); + } catch (e) { + ui.arkError = e.message; + } + ui.arkBusy = null; render(); + } + + async function doArkRefresh() { + ui.arkBusy = 'refresh'; ui.arkError = ''; render(); + try { + await ark.refresh(); + toast(t('arkRefreshStarted'), 5000); + } catch (e) { + ui.arkError = e.message; + } + ui.arkBusy = null; render(); + } + + // The Ark receive pane: connect-on-demand, then address + board form. + function arkReceivePane(seg) { + if (true) { + if (!ark) { + connectArk().catch(() => {}); + return h( + 'div', + { class: 'card col', style: 'align-items:center;gap:14px' }, + seg, + ui.arkError + ? h('div', { class: 'notice err', style: 'width:100%' }, ui.arkError) + : h('div', { class: 'row gap6', style: 'align-items:center;padding:24px 0' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))) + ); + } + const arkAddr = ark.address(); + const minBoard = ark.info.minBoardAmountSat || 0; + const canBoard = wallet.spendable >= minBoard; + return h( + 'div', + { class: 'card col', style: 'align-items:center;gap:14px' }, + seg, + h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('arkReceiveIntro')), + h('div', { html: qrSvg(arkAddr) }), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, arkAddr), + copyBtn(arkAddr, t('copyAddress')), + // Board: fund your Ark balance from this wallet's on-chain coins. + h('div', { class: 'col', style: 'width:100%;gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:14px' }, + h('div', { class: 'small muted', style: 'text-align:center' }, t('arkBoardIntro')), + h('div', { class: 'input-group' }, + h('input', { + type: 'number', inputmode: 'numeric', min: '0', + placeholder: t('arkBoardPlaceholder', { n: minBoard.toLocaleString() }), + value: ui.arkBoardAmt || '', + onInput: (e) => { ui.arkBoardAmt = e.target.value; }, + }), + h('button', { class: 'btn-sm', disabled: !!ui.arkBusy || !canBoard, onClick: doArkBoard }, + ui.arkBusy === 'board' ? h('span', { class: 'spinner sm' }) : t('arkBoardBtn'))), + 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() })), + ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null) + ); + } + throw new Error('unreachable'); + } + + // "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; + } + + // 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' }, t('arkBoardedNote')), + 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; + } + + return { + id: 'ark', + init() { initArk(); }, + stop() { stopArk(); }, + receiveModes() { + if (!arkAvailable()) return []; + return [{ id: 'ark', label: t('receiveArkTab'), render: (seg) => arkReceivePane(seg) }]; + }, + receiveTakeover() { + const boarded = arkBoardedScreen(); + if (boarded) return boarded; + return arkCelebration(); + }, + isSendDest(a) { return isArkAddress(a) && arkAvailable(); }, + hideSendControls(a) { return isArkAddress(a); }, + sendFormNote(a) { + return isArkAddress(a) ? h('div', { class: 'small faint' }, t('arkSendHint')) : null; + }, + interceptReview(s) { + 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 })); + ui.arkSend = { address: s.recipients[0].address.trim(), amountSat: sats }; + render(); + return true; + } + return false; + }, + sendView() { + if (ui.arkSent || ui.arkSend) return arkSendReview(); + return null; + }, + historyEntries() { + if (!ark || !ark.state) return []; + return ark.movements() + .filter((m) => ['receive', 'send', 'board'].includes(m.type) && m.status === 'complete') + .map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })); + }, + historyDetail() { + if (!ui.arkMoveDetail) return null; + const m = ark && ark.state ? ark.movements().find((x) => x.id === ui.arkMoveDetail) : null; + if (m) return arkMoveDetailView(m); + ui.arkMoveDetail = null; + return null; + }, + balanceLines() { + const b = arkBalance(); + if (!b || (b.spendableSat + b.pendingSat) <= 0) return []; + return [{ label: t('arkBalance'), sat: b.spendableSat + b.pendingSat }]; + }, + settingsCards() { return [arkCard()]; }, + }; +} |
