diff options
| author | Adam Soltys <[email protected]> | 2026-07-03 19:27:34 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-03 19:27:34 -0700 |
| commit | c8d384ebfe760682fe9ba3bcdcc31276df352929 (patch) | |
| tree | 93033da239e9642978e7bc36b24bd38c719a5780 | |
| parent | fd5306a3091e14904c06c09b03bb59ca7ea597de (diff) | |
| download | coinosv3-c8d384ebfe760682fe9ba3bcdcc31276df352929.tar.gz coinosv3-c8d384ebfe760682fe9ba3bcdcc31276df352929.zip | |
Ark: connect lazily — only dial the server once the wallet uses Ark
Opening a wallet no longer contacts the ASP unless it already holds
vtxos / history / unfinished operations. Fresh wallets connect on first
use instead: selecting Ark on the Receive tab, confirming an Ark send,
or the Connect button on the Settings card. connectArk() is idempotent
and generation-guarded against wallet switches mid-dial.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
| -rw-r--r-- | src/app.js | 71 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | tools/ark/ui-test.js | 11 |
3 files changed, 67 insertions, 16 deletions
@@ -35,6 +35,7 @@ const swaps = new SwapManager({ // still connecting. State persists under the wallet's cache key. let ark = null; let arkTimer = null; +let arkConnectPromise = null; let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch function stopArk() { @@ -42,13 +43,37 @@ function stopArk() { if (arkTimer) clearInterval(arkTimer); arkTimer = null; ark = null; + arkConnectPromise = null; +} + +// Ark is available (a server is configured and we hold keys), even if we +// haven't dialed the server yet — gates the UI entry points. +function arkAvailable() { + return !!getArkConfig() && !wallet.watchOnly && !!wallet.account; +} + +// Whether this wallet has used Ark before (coins, unfinished operations, or +// history). Only then does opening the wallet dial the server on its own — +// fresh wallets stay silent until the user first touches an Ark feature. +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(() => {}); +} + +// Connect on demand; idempotent (returns the live manager or the in-flight +// connection). Callers surface ui.arkError on failure. +function connectArk() { + if (ark) return Promise.resolve(ark); + if (arkConnectPromise) return arkConnectPromise; + if (!arkAvailable()) return Promise.reject(new Error(t('arkNotConnected'))); const cfg = getArkConfig(); - if (!cfg || wallet.watchOnly || !wallet.account) return; const gen = arkInitGen; const mgr = new ArkManager({ account: wallet.account(), @@ -58,19 +83,22 @@ function initArk() { network: getNetwork(), onUpdate: () => render(), }); - mgr.init().then(() => { - if (gen !== arkInitGen) return; // superseded by a newer initArk()/stopArk() + 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(); // Regtest rounds are seconds apart; on mainnet a slower poll is plenty. arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); render(); + return mgr; }).catch((e) => { - if (gen !== arkInitGen) return; - ui.arkError = e.message; - render(); - }); + if (gen === arkInitGen) { ui.arkError = e.message; render(); } + throw e; + }).finally(() => { arkConnectPromise = null; }); + render(); + return arkConnectPromise; } // The spendable Ark balance (sats), or null when Ark isn't active. @@ -1855,8 +1883,10 @@ function arkCard() { } 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')))); + 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 bal = ark.balance(); @@ -2826,13 +2856,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'; + if (mode === 'ark' && !arkAvailable()) 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')]); + if (arkAvailable()) 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))) @@ -2841,6 +2871,18 @@ function receiveTab() { return h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, seg, ...lnReceiveContent()); } if (mode === 'ark') { + 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(); return h( 'div', @@ -3241,7 +3283,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) || (isArkAddress(a) && !!ark)); + return !!a && (wallet.isOnchainAddress(a) || isSilentPaymentAddress(a) || (isArkAddress(a) && arkAvailable())); } // One recipient: address + amount. Max is only offered for a single recipient. @@ -3464,7 +3506,7 @@ function reviewSend() { 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')); + if (!arkAvailable()) 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 }; @@ -3535,7 +3577,8 @@ async function doArkSend() { const a = ui.arkSend; ui.busy = true; ui.sendError = ''; render(); try { - await ark.send(a.address, a.amountSat); + const mgr = await connectArk(); + await mgr.send(a.address, a.amountSat); ui.arkSent = { amountSat: a.amountSat }; ui.arkSend = null; } catch (e) { diff --git a/src/i18n.js b/src/i18n.js index 77a14c5..c9417f3 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -356,6 +356,7 @@ const STR = { arkEsploraUrl: 'Esplora URL (chain source)', arkWatchOnly: 'Ark needs the wallet keys — load the seed to use it.', arkConnecting: 'Connecting to the Ark server…', + arkConnectBtn: 'Connect', arkNotConnected: 'Ark is not connected — pick a server in Settings.', arkBalance: 'Ark balance', arkVtxos: 'Coins (VTXOs)', diff --git a/tools/ark/ui-test.js b/tools/ark/ui-test.js index 006c7eb..1d36b06 100644 --- a/tools/ark/ui-test.js +++ b/tools/ark/ui-test.js @@ -104,7 +104,7 @@ try { // --- receive over ark --- console.log('\n[2] receive over Ark'); await page.select('select', 'ark'); - await sleep(300); + check('lazy connect on first use', await waitText('tark1', 15000)); 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"`); @@ -172,7 +172,14 @@ try { return false; })(); check('refresh consolidated to 1 coin', done, String(await labeledNumber('coins (vtxos)'))); - console.log(` final ark balance: ${await labeledNumber('ark balance')} sats`); + const finalBal = await labeledNumber('ark balance'); + console.log(` final ark balance: ${finalBal} sats`); + + // --- reload: a wallet holding vtxos reconnects on its own --- + console.log('\n[6] reload auto-connects (has vtxos)'); + await page.reload({ waitUntil: 'domcontentloaded' }); + check('ark balance back without interaction', await waitLabeledNumber('ark balance', finalBal, 20000), + String(await labeledNumber('ark balance'))); console.log(ok ? '\n✅ SUCCESS: Ark UI — receive, send, board, refresh all work in the browser' : '\n❌ some checks failed'); |
