diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 22:01:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 22:01:48 -0700 |
| commit | fabfff494c5c5a76ccfb0021f3a7c0fe6f6d9577 (patch) | |
| tree | 9814c007b55762442481e37b53758ae532ebd496 /src | |
| parent | 6e8cf569c0fa04495b37b673857b9b1d5c7a669b (diff) | |
| download | coinosv3-fabfff494c5c5a76ccfb0021f3a7c0fe6f6d9577.tar.gz coinosv3-fabfff494c5c5a76ccfb0021f3a7c0fe6f6d9577.zip | |
Balance, claim button, and gift-recipient backup verification
- Spendable headline no longer counts pending incoming receives: compute it
from UTXOs (confirmed coins + our own unconfirmed change, minus gift locks)
so a pending spend still debits immediately but a pending receive shows only
on the PENDING line (new pendingIncoming getter).
- Claim button shows the fee in parens in slightly smaller font, no dot.
- Gift recipients now get the same "Verify backup" / "Skip verification" flow
as new-wallet creation instead of a single "I've written it down" button.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 56 | ||||
| -rw-r--r-- | src/wallet.js | 18 |
2 files changed, 64 insertions, 10 deletions
@@ -1342,7 +1342,45 @@ function claimScreen() { words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))) ), h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; render(); } }, t('giftBackedUp')) + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')), + h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; render(); } }, t('skipVerification')) + ) + ); + } + if (ui.claimStep === 'verify') { + // Same word-confirmation as new-wallet creation: prove the recipient wrote + // the phrase down before sending them off into their freshly funded wallet. + const words = wallet.mnemonic.split(' '); + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col' }, + h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')), + h('p', { class: 'muted', style: 'margin:0' }, t('confirmBackupIntro')), + ...ui.confirm.map((c, i) => + h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })), + h('input', { + type: 'text', class: 'mono-input', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + value: c.value, onInput: (e) => (ui.confirm[i].value = e.target.value.trim()), + }) + ) + ), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost', onClick: () => { ui.claimStep = 'backup'; ui.claimError = ''; render(); } }, t('back')), + h('button', { + class: 'btn-primary grow', + onClick: () => { + const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); + if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; } + ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = ''; + render(); + }, + }, t('openWallet')) + ), + h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; render(); } }, t('skipVerification')) ) ); } @@ -1369,7 +1407,8 @@ function claimScreen() { ui.busy ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary btn-block', onClick: doClaim }, - t('claimBtn') + ' · ' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() })) + t('claimBtn'), ' ', + h('span', { style: 'font-size:0.85em;opacity:0.9' }, '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')) ); } @@ -1510,20 +1549,21 @@ function balanceCard() { // Only dim on the very first load; background updates happen silently. const firstLoad = wallet.scanning && !wallet.loaded; const locked = wallet.lockedValue; + const pending = wallet.pendingIncoming; return h( 'div', { class: 'card balance' }, h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.05em' }, t('balance')), - // Headline is the spendable, projected balance (confirmed + mempool, minus - // coins locked in unclaimed gifts), so both a pending spend and a reserved - // gift are debited immediately rather than waiting to confirm/claim. + // Headline is the spendable balance: confirmed coins plus our own pending + // 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')), - wallet.pending > 0 || locked > 0 + pending > 0 || locked > 0 ? h( 'div', { class: 'split' }, - wallet.pending > 0 - ? h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(wallet.pending), ' ', unitTag())) + pending > 0 + ? h('div', {}, h('div', { class: 'k' }, t('pending')), h('div', { class: 'v pending' }, fmtAmount(pending), ' ', unitTag())) : null, locked > 0 ? h('div', {}, h('div', { class: 'k' }, t('lockedInGifts')), h('div', { class: 'v' }, fmtAmount(locked), ' ', unitTag())) diff --git a/src/wallet.js b/src/wallet.js index d4418a5..5b524e6 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -591,9 +591,23 @@ export class Wallet { const res = this.reservedSet(); return this.utxos.reduce((s, u) => s + (res.has(utxoId(u)) ? u.value : 0), 0); } - // What the user can actually spend right now: everything minus gift locks. + // The spendable headline: confirmed coins plus our own unconfirmed change (the + // remainder of a spend we just made, so the spend debits immediately), minus + // anything locked in gifts. Unconfirmed *incoming* receives are NOT counted + // here — they show as pending until they confirm. get spendable() { - return this.total - this.lockedValue; + const res = this.reservedSet(); + return this.utxos.reduce((s, u) => { + if (res.has(utxoId(u))) return s; + if (u.confirmed || u.chain === 1) return s + u.value; + return s; + }, 0); + } + // Unconfirmed incoming receives (not our change, not gift-locked) — shown as a + // separate pending line, kept out of the spendable headline. + get pendingIncoming() { + const res = this.reservedSet(); + return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0); } _recomputeBalanceFromChains() { |
