aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-29 08:49:16 -0700
committerAdam Soltys <[email protected]>2026-06-29 08:49:16 -0700
commit81df06c53eef1ef2931ed48955aded1e6ce0fa4f (patch)
treece00441809d4cb5a9cbaa12cb1167e2cb3ddcb25
parent5c87dc5e6e09c3cc5e359852621575dbbec8e784 (diff)
downloadcoinosv3-81df06c53eef1ef2931ed48955aded1e6ce0fa4f.tar.gz
coinosv3-81df06c53eef1ef2931ed48955aded1e6ce0fa4f.zip
Send progressive disclosure, swap context in tx detail, LN amount placeholder
- Send: show only the destination input ('Address or Lightning invoice') until a valid on-chain/SP address is entered, then reveal amount + fee rate + coin selection + Review. A pasted bolt11 still auto-advances to its own confirmation, so those controls never appear for it. New wallet.isOnchainAddress() validator + a destReady() gate; recipient onInput/paste re-render only when validity flips. - History: stop listing swaps at the top. Instead, a send/receive that was part of a swap shows its Lightning context (invoice, swap fee, status, refund) on the tx detail screen via swaps.findByTxid(). Removed the dead swapListView. - Lightning receive: drop the '50000' amount placeholder — a bare number looked like a value was already entered; the min/max hint below covers it. Verified in-browser: controls hidden until a valid address, then revealed; LN receive placeholder empty; no errors. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--src/app.js107
-rw-r--r--src/i18n.js1
-rw-r--r--src/swap.js3
-rw-r--r--src/wallet.js5
4 files changed, 68 insertions, 48 deletions
diff --git a/src/app.js b/src/app.js
index d88074e..08d0cd0 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2552,39 +2552,6 @@ async function doRefund(id) {
ui.swapBusy = false; render();
}
-function swapListView() {
- const list = swaps.list().slice().reverse();
- if (!list.length) return null;
- return h('div', { class: 'card col' },
- h('h3', {}, 'Swaps'),
- ...list.map((s) => h('div', { class: 'col', style: 'gap:6px;border-top:1px solid #2a2a2a;padding-top:8px' },
- h('div', { class: 'row', style: 'justify-content:space-between' },
- h('span', {}, s.kind === 'reverse' ? 'Receive ⚡→⛓' : 'Pay ⛓→⚡'),
- h('span', { class: 'small muted' }, s.status + (s.boltzStatus ? ` (${s.boltzStatus})` : ''))
- ),
- (s.kind === 'reverse' && s.status === 'awaiting-payment')
- ? h('div', { class: 'col', style: 'gap:6px' },
- h('div', { class: 'small faint' }, `Pay this ${s.onchainAmount ? '' : ''}invoice from any Lightning wallet:`),
- h('div', { html: qrSvg(s.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }),
- h('div', { class: 'addr-box break', style: 'font-size:11px' }, s.invoice))
- : null,
- (s.kind === 'reverse' && s.status === 'claimed')
- ? h('div', { class: 'small', style: 'color:#36d36b' }, `Received ${s.received} sats on-chain ✓`) : null,
- (s.kind === 'submarine' && s.status === 'success')
- ? h('div', { class: 'small', style: 'color:#36d36b' }, 'Invoice paid ✓') : null,
- (s.kind === 'submarine' && ['funding', 'paying'].includes(s.status))
- ? h('div', { class: 'small faint' }, 'Funding lockup / waiting for the swap to settle…') : null,
- (s.kind === 'submarine' && s.status === 'refundable')
- ? h('div', { class: 'col', style: 'gap:4px' },
- h('div', { class: 'small', style: 'color:#e0a13a' }, 'Payment failed — refundable after timeout block ' + s.timeoutBlockHeight),
- h('button', { disabled: !!ui.swapBusy, onClick: () => doRefund(s.id) }, 'Refund on-chain'))
- : null,
- (s.status === 'refunded') ? h('div', { class: 'small', style: 'color:#36d36b' }, 'Refunded ✓') : null,
- s.lastError ? h('div', { class: 'small', style: 'color:#e06a6a' }, s.lastError) : null
- ))
- );
-}
-
function tabsBar() {
const tabs = [
['receive', t('tabReceive')],
@@ -2724,7 +2691,7 @@ function lnReceiveContent() {
h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('lnReceiveIntro')),
h('label', { class: 'field', style: 'width:100%' },
h('span', { class: 'lab' }, t('amountSatsLabel')),
- h('input', { type: 'number', inputmode: 'numeric', placeholder: lim ? String(lim.min) : '100000', value: ui.swapReverseAmt || '', onInput: (e) => { ui.swapReverseAmt = e.target.value; } }),
+ h('input', { type: 'number', inputmode: 'numeric', placeholder: '', value: ui.swapReverseAmt || '', onInput: (e) => { ui.swapReverseAmt = e.target.value; } }),
lim ? h('span', { class: 'small muted' }, `Min ${lim.min.toLocaleString()} · max ${lim.max.toLocaleString()} sats`) : null),
ui.swapError ? h('div', { class: 'notice err' }, ui.swapError) : null,
h('button', { class: 'btn-primary btn-block', disabled: !!ui.swapBusy, onClick: doReverse }, ui.swapBusy ? h('span', { class: 'spinner' }) : t('lnCreateInvoice')),
@@ -3063,10 +3030,19 @@ function addrVerifyNodes(a) {
];
}
+// True once the destination is a real on-chain or silent-payment address — used to
+// progressively reveal the amount/fee/coin controls. A Lightning invoice instead
+// auto-advances to its own confirmation, so it never needs them.
+function destReady(a) {
+ a = (a || '').trim();
+ return !!a && (wallet.isOnchainAddress(a) || isSilentPaymentAddress(a));
+}
+
// One recipient: address + amount. Max is only offered for a single recipient.
function recipientRow(s, r, i) {
const single = s.recipients.length === 1;
const maxOn = single && s.max;
+ r._ready = destReady(r.address); // reflected each render; onInput re-renders on a flip
// Updated imperatively on input (and on render) so paste, typing, and scan
// all reflect immediately without disrupting the input's focus/cursor.
@@ -3086,16 +3062,21 @@ function recipientRow(s, r, i) {
return false;
};
const addrInput = h('input', {
- type: 'text', class: 'mono-input grow', placeholder: 'bc1q…',
+ type: 'text', class: 'mono-input grow', placeholder: i === 0 ? t('destPlaceholder') : 'bc1q…',
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: r.address,
- onInput: (e) => { r.address = e.target.value; syncCheck(); tryLn(e.target.value); },
+ onInput: (e) => {
+ const v = e.target.value;
+ r.address = v; syncCheck();
+ if (tryLn(v)) return; // a bolt11 advances to its own confirmation
+ if (destReady(v) !== r._ready) render(); // reveal/hide amount + controls as validity flips
+ },
});
const row = h(
'div',
{ class: 'col gap6' },
h('div', { class: 'input-group' },
addrInput,
- pasteBtn((text) => { addrInput.value = text; r.address = text; syncCheck(); tryLn(text); }),
+ pasteBtn((text) => { addrInput.value = text; r.address = text; syncCheck(); if (!tryLn(text) && destReady(text) !== r._ready) render(); }),
i === 0 && canScan() && h('button', {
type: 'button', class: 'btn-sm', title: t('scanQr'), onClick: scanIntoSend,
html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3"/></svg>',
@@ -3103,7 +3084,7 @@ function recipientRow(s, r, i) {
!single && h('button', { type: 'button', class: 'btn-sm', title: t('remove'), onClick: () => { s.recipients.splice(i, 1); render(); } }, '✕')
),
check,
- h('div', { class: 'input-group' },
+ r._ready ? h('div', { class: 'input-group' },
h('input', {
type: 'number', step: unit === 'sats' ? '1' : '0.00000001', min: '0',
placeholder: unit === 'sats' ? '0' : '0.00000000',
@@ -3126,7 +3107,7 @@ function recipientRow(s, r, i) {
}),
h('button', { type: 'button', title: t('switchUnit'), onClick: toggleUnit }, unitLabel()),
single && h('button', { type: 'button', class: s.max ? 'btn-primary' : '', onClick: () => { s.max = !s.max; render(); } }, t('max'))
- )
+ ) : null
);
syncCheck();
return row;
@@ -3134,6 +3115,9 @@ function recipientRow(s, r, i) {
function sendForm() {
const s = ui.send;
+ // Progressive disclosure: until the destination is a valid on-chain/SP address,
+ // show only the destination input (a Lightning invoice auto-advances instead).
+ const ready = destReady((s.recipients[0] || {}).address);
const feeOpts = [
['economyFee', t('feeEconomy')],
['halfHourFee', t('feeNormal')],
@@ -3150,13 +3134,13 @@ function sendForm() {
h('div', { class: 'col', style: 'gap:14px' },
s.recipients.map((r, i) => recipientRow(s, r, i))
),
- s.recipients.length < 10 &&
+ ready && s.recipients.length < 10 &&
h('button', {
type: 'button', class: 'linklike small mt8',
onClick: () => { s.recipients.push({ address: '', amount: '' }); s.max = false; render(); },
}, t('addRecipient'))
),
- h(
+ ready && h(
'div',
{ class: 'field' },
h('span', { class: 'lab' }, t('feeRate')),
@@ -3184,9 +3168,9 @@ function sendForm() {
s.feeChoice !== 'custom' &&
h('div', { class: 'small faint mt8' }, t('selectedRate', { n: currentFeeRate() }))
),
- coinControl(),
+ ready ? coinControl() : null,
ui.sendError && h('div', { class: 'notice err' }, ui.sendError),
- h('button', { class: 'btn-primary btn-block', onClick: reviewSend }, t('reviewTx')),
+ ready && h('button', { class: 'btn-primary btn-block', onClick: reviewSend }, t('reviewTx')),
h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink'))
);
}
@@ -3585,10 +3569,8 @@ function historyTab() {
? [...wallet.outstandingGifts(), ...wallet.claimedGifts().map((c) => ({ ...c, claimed: true }))]
: [];
if (ui.giftsAll && gifts.length) return giftsAllView(gifts);
- // In-flight / recent Lightning swaps (formerly the Swap tab) sit above history.
- const swapsCard = swapListView();
if (!txs.length && !gifts.length)
- return swapsCard || h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet')));
+ return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet')));
// Show at most 3 gifts inline; the rest live behind "View all". Transactions
// paginate 10 at a time.
@@ -3596,7 +3578,7 @@ function historyTab() {
const txPages = Math.ceil(txs.length / PAGE_SIZE);
const txPage = Math.min(ui.txPage, Math.max(0, txPages - 1));
const txSlice = txs.slice(txPage * PAGE_SIZE, txPage * PAGE_SIZE + PAGE_SIZE);
- const historyCard = h(
+ return h(
'div',
{ class: 'card' },
h('div', { class: 'list' },
@@ -3616,7 +3598,6 @@ function historyTab() {
)
: null
);
- return swapsCard ? h('div', { class: 'col', style: 'gap:12px' }, swapsCard, historyCard) : historyCard;
}
// Open a tx's detail view, lazily fetching its fee/details if we don't have them
@@ -3643,8 +3624,37 @@ async function openTx(txid) {
} catch {}
}
+// Lightning-swap context shown inside a tx's detail screen (the on-chain tx is the
+// swap's lockup funding, claim, or refund). Replaces the old standalone Swap list.
+function swapTxDetailSection(swap, tx) {
+ const u = ' ' + unitLabel();
+ const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v));
+ const head = (txt) => h('div', { style: 'font-weight:600;margin:12px 0 2px' }, txt);
+ if (swap.refundTxid === tx.txid)
+ return h('div', { class: 'summary col', style: 'gap:0' }, head('↩ Lightning swap refund'), line(t('status'), 'Refunded ✓'));
+ if (swap.kind === 'submarine') {
+ const fee = (swap.expectedAmount || 0) - (swap.invoiceAmount || 0);
+ const st = swap.status === 'success' ? 'Invoice paid ✓'
+ : swap.status === 'refundable' ? 'Payment failed — refundable'
+ : swap.status === 'refunded' ? 'Refunded' : 'Paying…';
+ return h('div', { class: 'summary col', style: 'gap:0' },
+ head('⚡ Paid over Lightning'),
+ swap.invoiceAmount != null ? line('Invoice', fmtAmount(swap.invoiceAmount) + u) : null,
+ fee > 0 ? line('Swap fee', fmtAmount(fee) + u) : null,
+ line(t('status'), st),
+ swap.status === 'refundable'
+ ? h('button', { class: 'btn-sm btn-block', style: 'margin-top:8px', disabled: !!ui.swapBusy, onClick: () => doRefund(swap.id) }, 'Refund on-chain')
+ : null);
+ }
+ // reverse swap claim
+ return h('div', { class: 'summary col', style: 'gap:0' },
+ head('⚡ Received over Lightning'),
+ line(t('status'), swap.status === 'claimed' ? 'Settled ✓' : swap.status));
+}
+
function txDetailView(tx) {
const incoming = tx.net >= 0;
+ const swap = swaps.findByTxid(tx.txid); // this tx's swap (Lightning), if any
const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v));
return h(
'div',
@@ -3663,6 +3673,7 @@ function txDetailView(tx) {
tx.confirmed && tx.blockTime ? line(t('date'), new Date(tx.blockTime * 1000).toLocaleString()) : null,
tx.fee ? line(t('networkFee'), fmtAmount(tx.fee) + ' ' + unitLabel()) : null
),
+ swap ? swapTxDetailSection(swap, tx) : null,
!tx.confirmed && wallet.isStuck(tx)
? h('div', { class: 'warn-box' }, incoming ? t('stuckIncomingNote') : t('stuckOutgoingNote'))
: null,
diff --git a/src/i18n.js b/src/i18n.js
index 719c45b..c852c5c 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -356,6 +356,7 @@ const STR = {
lnCreateInvoice: 'Create invoice',
copyInvoice: 'Copy invoice',
amountSatsLabel: 'Amount (sats)',
+ destPlaceholder: 'Address or Lightning invoice',
lnQuoting: 'Getting a quote…',
lnPayTitle: 'Pay over Lightning',
lnPayAmount: 'Amount',
diff --git a/src/swap.js b/src/swap.js
index 1e56223..8bc4183 100644
--- a/src/swap.js
+++ b/src/swap.js
@@ -239,6 +239,9 @@ export class SwapManager {
}
list() { return this.wallet.loadSwaps(); }
+ // The swap (if any) an on-chain tx belongs to — its lockup funding (submarine),
+ // claim (reverse), or refund. Lets the tx detail screen show the swap context.
+ findByTxid(txid) { return this.list().find((s) => s.fundTxid === txid || s.claimTxid === txid || s.refundTxid === txid) || null; }
_get(id) { return this.wallet.loadSwaps().find((s) => s.id === id); }
_save(rec) {
const list = this.wallet.loadSwaps();
diff --git a/src/wallet.js b/src/wallet.js
index 519e3e1..b3e3d40 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -401,6 +401,11 @@ export class Wallet {
return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null;
}
+ // True if addr is a valid on-chain address for this wallet's network.
+ isOnchainAddress(addr) {
+ try { btc.Address(this.netCfg.net).decode((addr || '').trim()); return true; } catch { return false; }
+ }
+
// Is SP receiving available here? (we can derive keys AND an indexer is set)
silentPaymentsAvailable() {
return !!(this.silentPaymentKeys() && spIndexerUrl(this.netName));