aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-04 08:55:45 -0700
committerAdam Soltys <[email protected]>2026-07-04 08:55:45 -0700
commit11a14600da683769c74d49e3d973183aa1a2fe5e (patch)
tree004ac05ad9a6f6e109acb2d0efdf82b19747d48a /src/features/ark.js
parent65ad54a060a8035d8cf1e1ca2004f7c3bc5ff296 (diff)
downloadcoinosv3-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.js429
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()]; },
+ };
+}