diff options
| author | Adam Soltys <[email protected]> | 2026-07-03 15:08:26 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-03 15:08:26 -0700 |
| commit | 190c13a338462a7a76ee65e9486e42c0165a65f4 (patch) | |
| tree | 0006e713e91e39b5b90d458a35b6c4a33cef34cc | |
| parent | 5b7f8ba40cbf5c8189147b6a37aadc1b230ac4e9 (diff) | |
| download | coinosv3-190c13a338462a7a76ee65e9486e42c0165a65f4.tar.gz coinosv3-190c13a338462a7a76ee65e9486e42c0165a65f4.zip | |
Ark: wire into the wallet UI
- Settings: Ark server card (off by default, per-network presets incl.
Second mainnet + local regtest, custom URLs) with balance, coin count,
board form (on-chain -> Ark; funding output pinned to vout 0 via the
new buildTx noSort option), refresh/consolidate, and recent activity.
- Receive: Ark option in the address-type dropdown (QR + copy).
- Send: t?ark1 addresses are recognized in the normal send form and
route to an Ark confirmation (no mining fee) with a success view.
- Balance card: Ark balance line under the on-chain figure.
- ArkManager lifecycle: per-wallet instance created on account
activation, background sync every 5s (regtest) / 30s, state persisted
under the wallet cache key.
Verified end-to-end in a real browser (tools/ark/ui-test.js, puppeteer
against the regtest stack): receive, send, board, and refresh all green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
| -rw-r--r-- | src/api.js | 49 | ||||
| -rw-r--r-- | src/app.js | 256 | ||||
| -rw-r--r-- | src/i18n.js | 22 | ||||
| -rw-r--r-- | src/wallet.js | 18 | ||||
| -rw-r--r-- | tools/ark/ui-shots.js | 61 | ||||
| -rw-r--r-- | tools/ark/ui-test.js | 172 |
6 files changed, 573 insertions, 5 deletions
@@ -181,6 +181,55 @@ const deriveBoltzWs = (api) => api ? api.replace(/^http/, 'ws').replace(/\/+$/, export function getBoltzApi() { return getBoltzProvider().api; } export function getBoltzWs() { const p = getBoltzProvider(); return p.ws || deriveBoltzWs(p.api); } +// Ark server (ASP) — Second's bark/captaind protocol, spoken natively over +// gRPC-web (src/ark). Off by default: enabling Ark is an explicit opt-in per +// network. The esplora URL is the chain source the Ark client checks anchors +// and confirmations against (independent from the wallet's own data source so +// each preset ships a known-good pairing). +const ARK_PRESETS_BY_NET = { + mainnet: [ + { id: 'off', label: 'Off', ark: '', esplora: '' }, + { id: 'second', label: 'Second (ark.second.tech)', ark: 'https://ark.second.tech', esplora: 'https://mempool.second.tech/api' }, + { id: 'custom', label: 'Custom…', ark: '', esplora: '' }, + ], + regtest: [ + { id: 'off', label: 'Off', ark: '', esplora: '' }, + { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002' }, + { id: 'custom', label: 'Custom…', ark: '', esplora: '' }, + ], +}; +const ARK_PROVIDER_KEY = 'btc-wallet-ark-provider'; // selected preset id, per network +const ARK_CUSTOM_KEY = 'btc-wallet-ark-custom'; // { ark, esplora } for custom, per network + +export function arkPresets(net = getNetwork()) { + return ARK_PRESETS_BY_NET[net] || ARK_PRESETS_BY_NET.mainnet; +} +export function getArkProviderId(net = getNetwork()) { + try { + const id = localStorage.getItem(nk(ARK_PROVIDER_KEY, net)); + if (id && arkPresets(net).some((p) => p.id === id)) return id; + } catch {} + return 'off'; +} +export function setArkProviderId(id, net = getNetwork()) { + try { localStorage.setItem(nk(ARK_PROVIDER_KEY, net), id); } catch {} +} +export function getArkCustom(net = getNetwork()) { + try { const c = JSON.parse(localStorage.getItem(nk(ARK_CUSTOM_KEY, net)) || 'null'); if (c) return { ark: c.ark || '', esplora: c.esplora || '' }; } catch {} + return { ark: '', esplora: '' }; +} +export function setArkCustom({ ark, esplora }, net = getNetwork()) { + try { localStorage.setItem(nk(ARK_CUSTOM_KEY, net), JSON.stringify({ ark: (ark || '').trim(), esplora: (esplora || '').trim() })); } catch {} +} +// The active Ark endpoints, or null when Ark is off / incompletely configured. +export function getArkConfig(net = getNetwork()) { + const id = getArkProviderId(net); + if (id === 'off') return null; + const p = arkPresets(net).find((x) => x.id === id); + const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora }; + return cfg.ark && cfg.esplora ? cfg : null; +} + // Silent-payment (BIP-352) tweak indexer — a separate, optional endpoint used // only for SP receiving. It serves public tweak/UTXO data (the scan key never // leaves the device), but a public one can observe which blocks hold your @@ -8,8 +8,9 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig, parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from './nostr.js'; -import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom } from './api.js'; +import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets, getBoltzApi, BOLTZ_PRESETS, getBoltzProviderId, setBoltzProviderId, getBoltzCustom, setBoltzCustom, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from './api.js'; import { SwapManager } from './swap.js'; +import { ArkManager } from './ark/manager.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { @@ -29,6 +30,58 @@ const swaps = new SwapManager({ feeRate: 2, onUpdate: () => render(), }); +// Ark (off-chain payments via an ASP, spoken natively — see src/ark). One +// manager per open wallet; null when Ark is off in Settings, watch-only, or +// still connecting. State persists under the wallet's cache key. +let ark = null; +let arkTimer = null; +let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch + +function stopArk() { + arkInitGen++; + if (arkTimer) clearInterval(arkTimer); + arkTimer = null; + ark = null; +} + +function initArk() { + stopArk(); + ui.arkError = ''; + const cfg = getArkConfig(); + if (!cfg || wallet.watchOnly || !wallet.account) return; + 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(), + }); + mgr.init().then(() => { + if (gen !== arkInitGen) return; // superseded by a newer initArk()/stopArk() + ark = mgr; + const tick = () => mgr.sync().catch(() => {}); + tick(); + // Regtest rounds are seconds apart; on mainnet a slower poll is plenty. + arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); + render(); + }).catch((e) => { + if (gen !== arkInitGen) return; + ui.arkError = e.message; + render(); + }); +} + +// The spendable Ark balance (sats), or null when Ark isn't active. +function arkBalance() { + if (!ark || !ark.state) return null; + return ark.balance(); +} + +// t?ark1… bech32m — an Ark address for this or another ASP. +function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); } + const ui = { screen: 'unlock', // 'unlock' | 'wallet' | 'claim' | 'howItWorks' claimStep: null, // 'welcome' | 'backup' when opening a gift link @@ -777,6 +830,7 @@ async function activateAccount(acc, opts = {}) { persistAccounts(); swaps.network = getNetwork(); if (!wallet.watchOnly) { try { swaps.resumeAll(); } catch {} } // re-attach in-flight swap watchers + initArk(); // connect to the Ark server (no-op when Ark is off in Settings) const hadCache = wallet.restoreCache(); // show last-known balance/history instantly // An opened gift link starts on a claim/back-up screen instead of the wallet. ui.screen = opts.gift ? 'claim' : 'wallet'; @@ -1444,6 +1498,7 @@ function settingsTab() { ), networkCard(), boltzProviderCard(), + arkCard(), consolidateCard(), explorerCard(), spIndexerCard(), @@ -1757,6 +1812,128 @@ function boltzProviderCard() { ); } +// Ark server selector + wallet panel. Off by default; picking a server spins +// up the ArkManager for this wallet (off-chain balance, board, refresh). +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, + ui.arkError ? null : h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting')))); + } + + const bal = ark.balance(); + const spendables = ark.vtxos().filter((v) => v.state === 'spendable'); + const pendingActions = ark.pendingActions(); + const moves = ark.movements().slice(-5).reverse(); + 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, + row(t('arkBalance'), fmtAmount(bal.spendableSat) + ' ' + unitLabel() + (bal.pendingSat ? ` (+${fmtAmount(bal.pendingSat)} ${t('pending').toLowerCase()})` : '')), + 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, + + // Board: move on-chain sats into the Ark (one on-chain tx, then confirms). + h('div', { class: 'input-group' }, + h('input', { + type: 'number', inputmode: 'numeric', min: '0', + placeholder: t('arkBoardPlaceholder', { n: (ark.info.minBoardAmountSat || 0).toLocaleString() }), + value: ui.arkBoardAmt || '', + onInput: (e) => { ui.arkBoardAmt = e.target.value; }, + }), + h('button', { class: 'btn-sm', disabled: !!ui.arkBusy, onClick: doArkBoard }, ui.arkBusy === 'board' ? h('span', { class: 'spinner sm' }) : t('arkBoardBtn'))), + + 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, + + moves.length + ? h('div', { class: 'col', style: 'gap:4px' }, + h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.05em' }, t('arkActivity')), + moves.map((m) => row( + `${m.type}${m.detail ? ' · ' + m.detail : ''}`, + `${m.type === 'send' ? '-' : '+'}${fmtAmount(m.amountSat)} · ${m.status}`))) + : null + ); +} + +// Board: startBoard() gives the funding address; hal's own on-chain wallet pays +// it (recipient pinned to vout 0 — the ASP requires the board output there), +// then the manager waits for confirmations and registers via the sync loop. +async function doArkBoard() { + const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); + if (!sats) return; + ui.arkBusy = 'board'; ui.arkError = ''; render(); + try { + const { actionId, fundingAddress } = 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 = ''; + toast(t('arkBoardStarted')); + 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')); + } catch (e) { + ui.arkError = e.message; + } + ui.arkBusy = null; render(); +} + // Network selector. Per-network endpoints (Electrum server / block explorer) are // configured in the Data source card below — no duplicate URL fields here. function networkCard() { @@ -2512,6 +2689,8 @@ function balanceCard() { const firstLoad = wallet.scanning && !wallet.loaded; const locked = wallet.lockedValue; const pending = wallet.pendingIncoming; + const b = arkBalance(); + const arkBal = b && (b.spendableSat + b.pendingSat) > 0 ? b : null; return h( 'div', { class: 'card balance' }, @@ -2520,7 +2699,7 @@ function balanceCard() { // change, minus gift locks — so a pending spend debits immediately, while a // pending incoming receive stays out of it until it confirms. h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, fmtAmount(wallet.spendable), ' ', unitTag('unit')), - pending > 0 || locked > 0 + pending > 0 || locked > 0 || arkBal ? h( 'div', { class: 'split' }, @@ -2529,6 +2708,9 @@ function balanceCard() { : null, locked > 0 ? h('div', {}, h('div', { class: 'k' }, t('lockedInGifts')), h('div', { class: 'v' }, fmtAmount(locked), ' ', unitTag())) + : null, + arkBal + ? h('div', {}, h('div', { class: 'k' }, t('arkBalance')), h('div', { class: 'v' }, fmtAmount(arkBal.spendableSat + arkBal.pendingSat), ' ', unitTag())) : null ) : null @@ -2634,11 +2816,13 @@ function receiveTab() { let mode = ui.receiveType || 'address'; if (mode === 'sp' && !spAddr) mode = 'address'; if (mode === 'ln' && !canLn) mode = 'address'; + if (mode === 'ark' && !(ark && ark.state)) mode = 'address'; // A compact dropdown (not a 3-button segmented control) so it fits on mobile and // scales if more address types are added. const opts = [['address', t('receiveAddressTab')]]; if (canLn) opts.push(['ln', t('receiveLnTab')]); if (spAddr) opts.push(['sp', t('receiveSpTab')]); + if (ark && ark.state) opts.push(['ark', t('receiveArkTab')]); const seg = opts.length > 1 ? h('select', { style: 'width:100%', onChange: (e) => { ui.receiveType = e.target.value; render(); } }, opts.map(([id, label]) => h('option', { value: id, selected: mode === id }, label))) @@ -2646,6 +2830,18 @@ function receiveTab() { if (mode === 'ln') { return h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, seg, ...lnReceiveContent()); } + if (mode === 'ark') { + const arkAddr = ark.address(); + 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')) + ); + } const addr = mode === 'sp' ? spAddr : fresh.address; return h( 'div', @@ -2773,6 +2969,7 @@ function sendTab() { } if (ui.sendResult) return sendResultView(); if (ui.broadcastTx) return broadcastConfirmView(); + if (ui.arkSent || ui.arkSend) return arkSendReview(); if (ui.lnSent || ui.lnSend) return lnSendReview(); if (ui.lnSendBusy) return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, h('span', { class: 'spinner' }), h('p', { class: 'muted', style: 'margin:0' }, t('lnQuoting'))); if (ui.draft) return reviewView(); @@ -3034,7 +3231,7 @@ function addrVerifyNodes(a) { // auto-advances to its own confirmation, so it never needs them. function destReady(a) { a = (a || '').trim(); - return !!a && (wallet.isOnchainAddress(a) || isSilentPaymentAddress(a)); + return !!a && (wallet.isOnchainAddress(a) || isSilentPaymentAddress(a) || (isArkAddress(a) && !!ark)); } // One recipient: address + amount. Max is only offered for a single recipient. @@ -3255,6 +3452,14 @@ function reviewSend() { // submarine swap, shown with an itemized cost. To the user it's just a payment. const lnTarget = s.recipients.length === 1 ? (s.recipients[0].address || '').trim().replace(/^lightning:/i, '') : ''; if (isLnInvoice(lnTarget)) { startLnSend(lnTarget); return; } + // An Ark address → instant off-chain send, no mining fee; its own confirmation. + if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) { + if (!ark || !ark.state) throw new Error(t('arkNotConnected')); + const sats = parseAmount(s.recipients[0].amount, unit); + if (!sats || sats <= 0) throw new Error(t('enterValidAmtForN', { n: 1 })); + ui.arkSend = { address: s.recipients[0].address.trim(), amountSat: sats }; + return render(); + } const feeRate = currentFeeRate(); let coinIds = null; if (s.manual) { @@ -3284,6 +3489,51 @@ function reviewSend() { render(); } +// Ark send confirmation: amount + destination, zero fees, instant. After the +// send, a simple success view (there is no on-chain txid to link). +function arkSendReview() { + if (ui.arkSent) { + 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('arkSentTitle')), + h('div', { class: 'amount-pos', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkSent.amountSat) + ' ' + unitLabel()), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.arkSent = null; ui.send = blankSend(); render(); } }, t('done')) + ); + } + 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 { + await ark.send(a.address, a.amountSat); + ui.arkSent = { amountSat: a.amountSat }; + ui.arkSend = null; + } catch (e) { + ui.sendError = e.message; + } + ui.busy = false; render(); +} + // A bolt11 Lightning invoice (any network prefix); long ln-prefixed bech32 string. function isLnInvoice(t) { t = (t || '').trim(); return /^ln[a-z0-9]+$/i.test(t) && t.length > 50; } diff --git a/src/i18n.js b/src/i18n.js index c852c5c..32af229 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -349,6 +349,28 @@ const STR = { receiveAddressTab: 'Bitcoin address', receiveLnTab: 'Lightning', receiveSpTab: 'Silent payment', + receiveArkTab: 'Ark', + arkReceiveIntro: 'Receive instantly over Ark — off-chain, self-custodial, no fees.', + arkDesc: 'Off-chain payments via an Ark server: instant, no mining fees, self-custodial (you can always exit on-chain). Off unless you pick a server.', + arkServerUrl: 'Ark server URL', + arkEsploraUrl: 'Esplora URL (chain source)', + arkWatchOnly: 'Ark needs the wallet keys — load the seed to use it.', + arkConnecting: 'Connecting to the Ark server…', + arkNotConnected: 'Ark is not connected — pick a server in Settings.', + arkBalance: 'Ark balance', + arkVtxos: 'Coins (VTXOs)', + arkPendingActions: '{n} operation(s) in progress', + arkBoardPlaceholder: 'Board amount (min {n} sats)', + arkBoardBtn: 'Board', + arkBoardStarted: 'Boarding — spendable in the Ark after 1 confirmation.', + arkRefreshBtn: 'Refresh / consolidate {n} coin(s)', + arkRefreshStarted: 'Refresh submitted — completes with the next round.', + arkActivity: 'Ark activity', + arkSendTitle: 'Send over Ark', + arkPayTo: 'To', + arkNoFee: 'none — instant off-chain send', + arkSendBtn: 'Send', + arkSentTitle: 'Sent!', lnReceiveIntro: 'Receive over Lightning — paid into this wallet on-chain, non-custodial.', lnReceiveAwaiting: 'Share this invoice. When it’s paid, the sats land here automatically.', lnReceiveWatching: 'Waiting for payment…', diff --git a/src/wallet.js b/src/wallet.js index 1972f22..156d2f4 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1191,7 +1191,7 @@ export class Wallet { // recipients: [{ address, amount(sats) }] // coinIds: optional array of "txid:vout" to force manual coin control. // sendMax: drain all selected coins to the single recipient. - buildTx({ recipients, feeRate, coinIds = null, sendMax = false }) { + buildTx({ recipients, feeRate, coinIds = null, sendMax = false, noSort = false }) { const pool_ = coinIds ? this.utxos.filter((u) => coinIds.includes(utxoId(u))) : this.utxos.filter((u) => !this.isReserved(utxoId(u))); // skip coins set aside for gifts @@ -1282,7 +1282,10 @@ export class Wallet { feePerByte, network: this.netCfg.net, createTx: true, - bip69: true, + // noSort keeps recipients at their given indexes (change appended last) — + // required when an output's position is protocol-relevant (Ark board + // funding must sit at vout 0). + bip69: !noSort, disableScriptCheck: true, }); if (!sel || !sel.tx) @@ -1533,6 +1536,17 @@ export class Wallet { try { localStorage.setItem(this._reclaimedKey(), JSON.stringify([...this.reclaimedSet()])); } catch {} } + // ---- Ark support ------------------------------------------------------- + // Persisted ArkManager state (vtxos, in-flight action checkpoints, movement + // history — no secrets: vtxo keys are re-derived from the seed). + _arkKey() { return this._cacheKey() + ':ark'; } + loadArkState() { + try { return JSON.parse(localStorage.getItem(this._arkKey()) || 'null'); } catch { return null; } + } + saveArkState(state) { + try { localStorage.setItem(this._arkKey(), JSON.stringify(state)); } catch {} + } + // ---- Boltz swap support ---------------------------------------------- // Persisted swap records (public metadata only — keys/preimage are re-derived // deterministically from the seed via swapNode, so nothing secret is stored). diff --git a/tools/ark/ui-shots.js b/tools/ark/ui-shots.js new file mode 100644 index 0000000..5331ab8 --- /dev/null +++ b/tools/ark/ui-shots.js @@ -0,0 +1,61 @@ +// Screenshot run: import a wallet, receive over Ark, show the balance card + +// Ark settings card. Writes PNGs to /tmp/ark-ui-*.png. +import { spawn, execSync } from 'node:child_process'; +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +const PORT = 5199; +const APP = `http://localhost:${PORT}`; +const BARK = `${process.env.HOME}/bark/target/debug/bark`; +const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); + +const dev = spawn('bun', ['run', 'dev'], { cwd: process.env.HOME + '/halwallet', env: { ...process.env, PORT: String(PORT) }, stdio: 'ignore' }); +for (let i = 0; i < 30; i++) { try { if ((await fetch(APP)).ok) break; } catch {} await sleep(500); } + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 420, height: 860, deviceScaleFactor: 2 }); +const clickText = (sel, text) => page.evaluate((sel, text) => { + const el = [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().toLowerCase().includes(text.toLowerCase())); + if (el) el.click(); +}, sel, text); + +await page.goto(APP, { waitUntil: 'domcontentloaded' }); +await page.evaluate(() => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' })); + localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); +}); +await page.reload({ waitUntil: 'domcontentloaded' }); +await sleep(500); +await clickText('button', 'Import existing'); +await sleep(200); +await page.type('textarea', generateMnemonic(wordlist)); +await clickText('button', 'Open wallet'); +await sleep(4000); + +await page.select('select', 'ark'); +await sleep(400); +const addr = await page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim()); +await page.screenshot({ path: '/tmp/ark-ui-receive.png' }); + +sh(`${ALICE} -q send ${addr} "25000 sat"`); +for (let i = 0; i < 30; i++) { await sleep(1000); if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes('ark balance')) break; } +await sleep(500); +await page.screenshot({ path: '/tmp/ark-ui-balance.png' }); + +await clickText('.tabs button', 'Settings'); +await sleep(500); +await page.evaluate(() => { + const el = [...document.querySelectorAll('h3')].find((e) => e.textContent.includes('Ark')); + if (el) el.scrollIntoView({ block: 'start' }); +}); +await sleep(300); +await page.screenshot({ path: '/tmp/ark-ui-settings.png' }); + +await browser.close(); +dev.kill(); +console.log('done'); diff --git a/tools/ark/ui-test.js b/tools/ark/ui-test.js new file mode 100644 index 0000000..f8288e2 --- /dev/null +++ b/tools/ark/ui-test.js @@ -0,0 +1,172 @@ +// Browser-level e2e of the Ark UI wiring, driven with puppeteer against the +// real dev server + the ark regtest stack: +// +// import wallet -> ark connects -> receive over Ark (alice pays the address +// shown in the Receive tab) -> send over Ark from the Send form -> board +// from the Settings card -> refresh/consolidate -> balances line up. +// +// Usage: bun tools/ark/ui-test.js (regtest stack up + nothing on :5199) + +import { spawn, execSync } from 'node:child_process'; +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +const PORT = 5199; +const APP = `http://localhost:${PORT}`; +const BARK = `${process.env.HOME}/bark/target/debug/bark`; +const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`; +const BCLI = `bitcoin-cli -regtest -datadir=${process.env.HOME}/ark-regtest/bitcoind -rpcport=18543 -rpcwallet=miner`; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); +const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`); +const aliceBalance = () => JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat; + +let ok = true; +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!cond) ok = false; +}; + +// --- dev server --- +const dev = spawn('bun', ['run', 'dev'], { cwd: process.env.HOME + '/halwallet', env: { ...process.env, PORT: String(PORT) }, stdio: 'ignore' }); +for (let i = 0; i < 30; i++) { + try { if ((await fetch(APP)).ok) break; } catch {} + await sleep(500); +} + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +page.on('pageerror', (e) => console.log(' [pageerror]', e.message)); + +// helpers — NB innerText respects CSS text-transform, so match case-insensitively +const bodyText = () => page.evaluate(() => document.body.innerText); +const clickText = async (sel, text) => { + const found = await page.evaluate((sel, text) => { + const el = [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().toLowerCase().includes(text.toLowerCase())); + if (el) { el.click(); return true; } + return false; + }, sel, text); + if (!found) throw new Error(`clickText: no ${sel} with "${text}"`); +}; +const waitText = async (text, ms = 20000) => { + for (let i = 0; i < ms / 250; i++) { + if ((await bodyText()).toLowerCase().includes(text.toLowerCase())) return true; + await sleep(250); + } + return false; +}; +// The number shown next to a label like "ark balance" (sats mode, comma groups) +const labeledNumber = async (label) => { + const txt = await bodyText(); + const esc = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const m = txt.match(new RegExp(esc + String.raw`[\s\n]*([\d,]+)`, 'i')); + return m ? Number(m[1].replaceAll(',', '')) : null; +}; +const waitLabeledNumber = async (label, want, ms = 30000) => { + for (let i = 0; i < ms / 500; i++) { + if (await labeledNumber(label) === want) return true; + await sleep(500); + } + return false; +}; + +try { + // --- configure: regtest, esplora on the ark chain, ark local --- + await page.goto(APP, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' })); + localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(500); + + // --- import a fresh wallet --- + console.log('\n[1] import wallet + ark connects'); + const mnemonic = generateMnemonic(wordlist); + await clickText('button', 'Import existing'); + await sleep(200); + await page.type('textarea', mnemonic); + await clickText('button', 'Open wallet'); + check('wallet opened', await waitText('receive', 15000)); + + check('ark connected (receive option present)', await (async () => { + for (let i = 0; i < 40; i++) { + const has = await page.evaluate(() => [...document.querySelectorAll('option')].some((o) => o.value === 'ark')); + if (has) return true; + await sleep(500); + } + return false; + })()); + + // --- receive over ark --- + console.log('\n[2] receive over Ark'); + await page.select('select', 'ark'); + await sleep(300); + const arkAddr = await page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim() || ''); + check('ark address shown', /^tark1[a-z0-9]+$/.test(arkAddr), arkAddr.slice(0, 24) + '…'); + sh(`${ALICE} -q send ${arkAddr} "25000 sat"`); + check('ark balance shows 25,000', await waitLabeledNumber('ark balance', 25000, 30000), String(await labeledNumber('ark balance'))); + + // --- send over ark --- + console.log('\n[3] send over Ark'); + const aliceAddr = sh(`${ALICE} -q address`); + const before = aliceBalance(); + await clickText('.tabs button', 'Send'); + await sleep(300); + await page.type('.mono-input', aliceAddr); + await sleep(400); // destReady re-render reveals the amount input + await page.type('input[type="number"]', '7000'); + await clickText('button.btn-primary', 'Review transaction'); + check('ark review shown', await waitText('Send over Ark', 5000)); + await clickText('button.btn-primary', 'Send'); + check('sent view', await waitText('Sent!', 15000)); + check('alice +7000', aliceBalance() === before + 7000, String(aliceBalance() - before)); + + // --- board from settings --- + console.log('\n[4] board'); + await clickText('.tabs button', 'Receive'); + await sleep(300); + await page.select('select', 'address'); + await sleep(300); + const onchainAddr = await page.evaluate(() => document.querySelector('.addr-box').textContent.trim()); + sh(`${BCLI} sendtoaddress ${onchainAddr} 0.001`); + mine(1); + check('onchain funds arrive', await waitText('100,000', 30000)); + + await clickText('.tabs button', 'Settings'); + await sleep(300); + const preBoard = await labeledNumber('ark balance'); + const boardInput = await page.evaluateHandle(() => + [...document.querySelectorAll('input')].find((i) => (i.placeholder || '').toLowerCase().startsWith('board amount'))); + await boardInput.asElement().type('30000'); + await clickText('button', 'Board'); + check('board started', await waitText('Boarding', 20000)); + mine(2); + check('board vtxo lands (+29,670)', await waitLabeledNumber('ark balance', preBoard + 29670, 40000), + String(await labeledNumber('ark balance'))); + + // --- refresh / consolidate --- + console.log('\n[5] refresh'); + await clickText('button', 'Refresh / consolidate'); + check('refresh submitted', await waitText('Refresh submitted', 10000)); + const done = await (async () => { + for (let i = 0; i < 45; i++) { + mine(1); + await sleep(2000); + if (await labeledNumber('coins (vtxos)') === 1 && !(await bodyText()).toLowerCase().includes('in progress')) return true; + } + return false; + })(); + check('refresh consolidated to 1 coin', done, String(await labeledNumber('coins (vtxos)'))); + console.log(` final ark balance: ${await labeledNumber('ark balance')} sats`); + + console.log(ok ? '\n✅ SUCCESS: Ark UI — receive, send, board, refresh all work in the browser' + : '\n❌ some checks failed'); +} finally { + await browser.close(); + dev.kill(); +} +process.exit(ok ? 0 : 1); |
