From 5fb48ee5284dbba34c8fa3b77c36379f49404ac2 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 4 Jul 2026 09:05:23 -0700 Subject: Features: extract gifts behind the plugin seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole gift surface moves to src/features/gifts.js (~800 lines): create/split/lock flows, link viewing, revoke/reclaim, history rows + detail page, claim-tx row decoration and tx-detail section, the claim flow (fresh or existing wallet, nostr-locked gifts, claim codes, NIP-07 extension), the boot URL handling (/g/, /lg/, #gift=), the post-claim celebration, and the locked-in-gifts balance line. New seams this required: screenView (full-screen feature views), bootUrl (URL takeover at startup), giftOpened (claim pre-check on wallet activation), sendFormExtras, decorateTxRow, and multi-section txDetailSection. Account access goes through explicit ctx accessors (getAccounts/setAccounts) — the moved code briefly relied on an accidental cross-module binding that broke activeAccount() during claims; caught by the gift e2e. app.js: 4,206 → 3,000 lines. Ark + gift browser suites pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy --- src/app.js | 799 +++---------------------------------------------- src/features/gifts.js | 805 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 854 insertions(+), 750 deletions(-) create mode 100644 src/features/gifts.js (limited to 'src') diff --git a/src/app.js b/src/app.js index 1ca9cf8..2c2a314 100644 --- a/src/app.js +++ b/src/app.js @@ -4,12 +4,13 @@ // which rebuilds the active screen. Text inputs write back into `ui` on `input` // (without re-rendering) so their values survive structural re-renders. -import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault, lockGift, previewLockedGift } from './wallet.js'; +import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; 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 } from './api.js'; import { swapsFeature } from './features/swaps.js'; +import { giftsFeature } from './features/gifts.js'; import { arkFeature } from './features/ark.js'; import { spFeature } from './features/sp.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; @@ -284,13 +285,8 @@ function render() { try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {} } const screen = - ui.claimLocked - ? lockedGiftClaimView() - : ui.viewGift - ? viewGiftView() - : ui.claimChoose - ? claimChooseView() - : ui.screen === 'wallet' + featureHook('screenView') + || (ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' ? accountsScreen() @@ -298,11 +294,9 @@ function render() { ? accountSettingsScreen() : ui.screen === 'vault' ? vaultScreen() - : ui.screen === 'claim' - ? claimScreen() : ui.screen === 'howItWorks' ? howItWorksScreen() - : unlockScreen(); + : unlockScreen()); root.replaceChildren(screen, footer()); if (fpath) { const el = nodeAtPath(fpath); @@ -853,9 +847,9 @@ async function activateAccount(acc, opts = {}) { // discovery scan. The cache/Nostr state is already on screen. wallet.startRealtime(); - // Opened a gift link? Verify its funding coin is still unspent before letting - // the claim proceed — so a second opener can't try to race/double-spend. - if (opts.gift) checkGiftClaimed(opts.gift); + // Opened a gift link? Let the gifts feature verify its funding coin is + // still unspent before the claim proceeds. + if (opts.gift) featureHook('giftOpened', opts.gift); // Cross-device state in the background. State comes from the local cache or // Nostr — both restore the full balance, coins, and history — so a full API @@ -1495,239 +1489,18 @@ async function doConsolidate() { } -// Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims -// into a fresh wallet only they control. The coin is reserved until claimed. -function giftUrl() { - return `${location.origin}/${ui.giftLocked ? 'lg' : 'g'}/${ui.giftCode}`; -} -function giftRate() { - return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; -} -function createGiftLink() { - const rate = giftRate(); - if (ui.giftMax) { doCreateGiftAll(rate); return; } - const min = giftMinimum(rate); - const amt = parseAmount(ui.giftAmount, unit); // entered in the current display unit - if (!amt || amt < min) { ui.giftError = t('giftAmountInvalid', { n: fmtAmount(min) + ' ' + unitLabel() }); render(); return; } - const spendable = wallet.spendable; - if (amt > spendable) { ui.giftError = t('giftExceedsBalance'); render(); return; } - // A specific-amount gift must leave us a dust change output (the gift PSBT - // commits one). If the amount is so close to the balance that no dust change - // is possible, point the user at Max to gift the whole balance instead. - const summary = wallet.giftCoinSummary(amt); - if (summary == null) { - ui.giftError = t('giftNeedsHeadroom', { n: fmtAmount(Math.max(0, spendable - 294)) + ' ' + unitLabel() }); - render(); - return; - } - const lock = summary.lock; - // Offer to split/consolidate into one coin first when it would free meaningful - // change OR when the gift would otherwise bundle several coins — a many-input - // gift is costly to claim (each input adds to the claimer's fee) and its link - // can outgrow a QR. Splitting absorbs the fragmentation into the funding tx and - // leaves the claimer a clean single-input gift. - const splitFee = Math.ceil((11 + 68 + 31 * 2) * Math.max(1, Math.round(rate))); - const freed = lock - amt - 294; - if ((freed > splitFee || summary.count >= 3) && !wallet.offline) { - ui.giftSplitOffer = { amt, lock, freed: Math.max(0, freed), fee: splitFee, count: summary.count }; - ui.giftError = ''; - render(); - return; - } - doCreateGift(amt, rate); -} -// Finalize a freshly-built gift. With a recipient npub it's a locked gift: the -// claim payload is encrypted under a one-time code, the link carries only the -// ciphertext, and the code is DM'd to the recipient over nostr. Without, it's a -// plain bearer gift. -function finishGift(g, fallbackAmt) { - const amount = g.amount || fallbackAmt || 0; - const pk = ui.giftLockNpub.trim() ? parseNostrPubkey(ui.giftLockNpub) : null; - if (!pk) { - ui.giftCode = g.code; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; - wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved }); - return; - } - const { blob, claimCode } = lockGift(g.code, amount, pk); - ui.giftCode = blob; ui.giftLocked = true; ui.giftClaimCode = claimCode; ui.giftDmStatus = 'sending'; - wallet.recordGift({ code: blob, locked: true, amount, claimCode, outpoints: g.reserved }); - const dmText = t('giftDmText', { amount: fmtAmount(amount) + ' ' + unitLabel(), link: giftUrl(), code: claimCode }); - wallet.sendNostrDM(pk, dmText).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); -} -function doCreateGift(amt, rate) { - try { - finishGift(wallet.createGift(amt, rate), amt); - ui.giftError = ''; - ui.giftSplitOffer = null; - } catch (e) { - ui.giftError = e.message; - ui.giftSplitOffer = null; - } - render(); -} -// Gift the whole spendable balance as a no-change sweep (the recipient receives -// everything minus their claim fee). -function doCreateGiftAll(rate) { - try { - finishGift(wallet.createGiftAll(rate)); - ui.giftError = ''; - ui.giftMax = false; - ui.giftSplitOffer = null; - } catch (e) { - ui.giftError = e.message; - } - render(); -} -// Split + gift in one tap: broadcast a self-send carving out a right-sized -// coin, then immediately build the gift from that (unconfirmed) carve-out, so -// only ~the gift amount is locked and there's no confirmation wait. The link is -// ready right away; the recipient sees a pending balance until the split lands. -async function doSplitForGift(amt) { - if (wallet.offline) { ui.giftError = t('scanOffline'); render(); return; } - ui.busy = true; ui.giftError = ''; render(); - try { - const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - finishGift(await wallet.createGiftFromSplit(amt, rate), amt); - ui.giftSplitOffer = null; - ui.giftError = ''; - } catch (e) { - ui.giftError = e.message || t('giftSplitFailed'); - } - ui.busy = false; - wallet.scan().catch(() => {}); // reconcile the split from the mempool - render(); -} -function giftCard() { - const active = wallet.outstandingGifts(); - return h( - 'div', - { class: 'card col' }, - h('h3', {}, t('giftLink')), - ui.giftCode - ? h('div', { class: 'col', style: 'align-items:center;gap:10px' }, - ui.giftLocked && (() => { - const lk = previewLockedGift(ui.giftCode); - if (!lk) return null; - return h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, - h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockedTo')), profileChip(lk.to)), - ui.giftDmStatus === 'sent' ? h('div', { class: 'small', style: 'color:var(--ok,#2a8)' }, t('giftDmSent')) - : ui.giftDmStatus === 'failed' ? h('div', { class: 'col gap6', style: 'align-items:center' }, h('div', { class: 'small err' }, t('giftDmFailed')), ui.giftClaimCode ? copyBtn(ui.giftClaimCode, t('giftCopyCode')) : null) - : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending')))); - })(), - // A bearer gift uppercases the URL for a smaller alphanumeric QR (the - // base32 code is case-insensitive). A locked gift's payload is - // case-sensitive base64url, so it uses a plain byte-mode QR. A large - // (many-input) gift can exceed QR capacity — then fall back to the link. - (() => { - let svg = null; - try { svg = ui.giftLocked ? qrSvg(giftUrl()) : qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} - return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')); - })(), - h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()), - h('div', { class: 'row gap6 wrap' }, - copyBtn(giftUrl(), t('copyLink')), - h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftLockNpub = ''; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; render(); } }, t('giftAnother')) - ) - ) - : ui.giftSplitOffer - ? (() => { - const o = ui.giftSplitOffer; - const u = ' ' + unitLabel(); - const manyCoins = (o.count || 0) >= 3; - return h('div', { class: 'col gap6' }, - h('div', { class: 'small muted' }, - manyCoins - ? t('giftConsolidateExplain', { count: o.count, fee: fmtAmount(o.fee) + u }) - : t('giftSplitExplain', { lock: fmtAmount(o.lock) + u, change: fmtAmount(o.lock - o.amt) + u, fee: fmtAmount(o.fee) + u })), - ui.giftError && h('div', { class: 'notice err' }, ui.giftError), - ui.busy - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('div', { class: 'col gap6' }, - h('button', { class: 'btn-primary btn-block', onClick: () => doSplitForGift(o.amt) }, manyCoins ? t('giftConsolidateFirst') : t('giftSplitFirst')), - h('button', { class: 'btn-block', onClick: () => doCreateGift(o.amt, giftRate()) }, manyCoins ? t('giftUseManyCoins', { count: o.count }) : t('giftLockWhole', { n: fmtAmount(o.lock) + u })), - h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.giftSplitOffer = null; render(); } }, t('back')) - ) - ); - })() - : h('div', { class: 'col gap6' }, - h('div', { class: 'input-group' }, - h('input', { type: 'number', step: unit === 'sats' ? '1' : '0.00000001', min: '0', inputmode: 'decimal', placeholder: t('giftAmountLabel'), - disabled: ui.giftMax, - value: ui.giftMax ? (unit === 'sats' ? String(wallet.spendable) : fmtBtc(wallet.spendable)) : ui.giftAmount, - onInput: (e) => (ui.giftAmount = e.target.value) }), - h('button', { type: 'button', class: ui.giftMax ? 'btn-primary' : '', onClick: () => { ui.giftMax = !ui.giftMax; ui.giftError = ''; render(); } }, t('max')), - h('div', { style: 'display:flex;align-items:center' }, unitTag()) - ), - h('div', { class: 'small faint' }, ui.giftMax ? t('giftAllNote') : t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), - (() => { - const raw = ui.giftLockNpub.trim(); - const pk = raw ? parseNostrPubkey(raw) : null; - return h('div', { class: 'col gap6' }, - h('input', { type: 'text', class: 'mono-input', placeholder: t('giftLockPlaceholder'), - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.giftLockNpub, - onInput: (e) => { ui.giftLockNpub = e.target.value; render(); } }), - !raw ? h('div', { class: 'small faint' }, t('giftLockHint')) - : pk ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockTo')), profileChip(pk)) - : h('div', { class: 'small err' }, t('giftLockInvalid'))); - })(), - ui.giftError && h('div', { class: 'notice err' }, ui.giftError), - h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal')) - ), - active.length - ? h('div', { class: 'col gap6', style: 'margin-top:4px' }, - h('span', { class: 'small muted' }, t('giftReserved', { n: active.length })), - ...active.map((g) => { - const amt = g.value != null ? fmtAmount(g.value) + ' ' + unitLabel() : g.id.slice(0, 12) + '…'; - const label = amt + (g.reserved ? '' : ' · ' + t('giftReclaimedTag')); - if (ui.revokeId === g.id) { - return h('div', { class: 'col gap6' }, - h('span', { class: 'small muted' }, g.reserved ? t('giftReclaimPrompt') : t('giftRevokeConfirm')), - ui.busy - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('div', { class: 'row gap6' }, - g.reserved ? h('button', { class: 'btn-ghost grow', onClick: () => doReclaim(g.id) }, t('giftReclaim')) : null, - h('button', { class: 'btn-primary grow', onClick: () => doRevoke(g.id) }, t('giftRevoke')) - ), - ui.busy ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.revokeId = null; render(); } }, t('back')) - ); - } - return h('div', { class: 'row between' }, - h('span', { class: 'small mono' }, label), - h('button', { class: 'btn-sm', onClick: () => { ui.revokeId = g.id; render(); } }, g.reserved ? t('giftReclaim') : t('giftRevoke')) - ); - }) - ) - : null - ); -} -// Passive reclaim: just free the coin for a future payment (no fee, no -// broadcast). The link stays claimable until the coin is actually spent. -function doReclaim(id) { - wallet.unreserve(id); - ui.revokeId = null; - toast(t('giftReclaimed')); - render(); -} -// Active revoke: spend the coin back now (pays a fee), killing the link. -async function doRevoke(id) { - if (wallet.offline) { toast(t('scanOffline')); return; } - ui.busy = true; - render(); - try { - const rate = (wallet.feeRates && wallet.feeRates.fastestFee) || 10; - await wallet.revokeGift(id, rate); - wallet.markGiftRevoked(id); // we took it back — don't later mislabel it as claimed - ui.revokeId = null; - toast(t('giftRevoked')); - wallet.scan().catch(() => {}); - } catch (e) { - toast(e.message); - } - ui.busy = false; - render(); -} + + + + + + + + + + // Switch the active Bitcoin network. Changes addresses/balances entirely, so we // persist the choice and reload the active account (or empty wallet) under it. @@ -1888,193 +1661,19 @@ function goHome() { render(); } -// ================================================================ GIFT / CLAIM -// Read a gift link, returning the code (and scrubbing it from the URL so the -// bearer instrument doesn't linger in the address bar / history). New links are -// /g/; legacy links are #gift=. Both validate by -// decoding to a real gift. -function readGiftHash() { - try { - let code = null; - const pm = location.pathname.match(/^\/g\/([A-Za-z0-9]+)\/?$/i); // new path form - if (pm) code = pm[1]; - else { - const hm = location.hash.match(/^#gift=([A-Za-z0-9_-]+)$/); // legacy hash form - if (hm) code = hm[1]; - } - if (!code) return null; - history.replaceState(null, '', '/'); - return previewGift(code) ? code : null; - } catch { - return null; - } -} -// A gift locked to a nostr key: /lg/. Returns the public preview -// ({ amount, to, eph, ct }) — claimed by the recipient's own wallet, not a fresh -// one, so we don't auto-create a wallet here. -function readLockedGiftHash() { - try { - const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/); - if (!m) return null; - history.replaceState(null, '', '/'); - return previewLockedGift(m[1]); - } catch { - return null; - } -} -// Re-view a previously created gift's link + QR (from its stored record), so the -// sender can re-share an unclaimed gift. For a locked gift, also surfaces the -// claim code that was DM'd, in case it needs re-sending. -function viewGiftView() { - const g = ui.viewGift; // { code, locked, amount, claimCode } - const url = `${location.origin}/${g.locked ? 'lg' : 'g'}/${g.code}`; - let svg = null; - try { svg = g.locked ? qrSvg(url) : qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} - return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, - h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, - h('h3', { style: 'margin:0' }, t('giftView')), - g.amount != null ? h('div', { class: 'amt' }, fmtAmount(g.amount), ' ', unitTag()) : null, - svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')), - h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, url), - copyBtn(url, t('copyLink')), - g.locked && g.claimCode - ? h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, h('div', { class: 'small muted' }, t('giftCodeLabel')), copyBtn(g.claimCode, t('giftCopyCode'))) - : null, - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.viewGift = null; render(); } }, t('back')) - ) - ); -} -// A locked gift opened from a link: the amount + recipient are public. The -// recipient pastes the claim code from their nostr DM; that decrypts the payload, -// then it's claimed into a fresh wallet exactly like a normal gift. -function lockedGiftClaimView() { - const lk = ui.claimLocked; - // NIP-07: if a nostr browser extension is present, the recipient can decrypt - // in-place — no code needed. - const hasExt = !!(typeof globalThis !== 'undefined' && globalThis.nostr && lk.eph && lk.ctKey); - return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, - h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, - h('h3', { style: 'margin:0' }, t('giftForYou')), - h('div', { class: 'amt' }, fmtAmount(lk.amount), ' ', unitTag()), - h('div', { class: 'row gap6', style: 'align-items:center' }, - h('span', { class: 'small muted' }, t('giftLockedTo')), - profileChip(lk.to, { size: 36 })), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - hasExt ? h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: claimViaExtension }, ui.busy ? h('span', { class: 'spinner' }) : t('claimWithExtension')) : null, - hasExt ? h('div', { class: 'small faint', style: 'text-align:center' }, t('orEnterCode')) : h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), - h('input', { type: 'text', class: 'mono-input', style: 'width:100%', placeholder: t('claimCodePlaceholder'), - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.claimCodeInput, - onInput: (e) => { ui.claimCodeInput = e.target.value; } }), - h('button', { class: (hasExt ? '' : 'btn-primary ') + 'btn-block', onClick: submitLockedCode }, t('claimGift')), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); } }, t('dismiss')) - ) - ); -} -async function claimViaExtension() { - const ext = typeof globalThis !== 'undefined' && globalThis.nostr; - if (!ext) return; - ui.busy = true; ui.claimError = ''; render(); - try { - const pk = await ext.getPublicKey(); - if (pk !== ui.claimLocked.to) { ui.claimError = t('extWrongAccount'); ui.busy = false; render(); return; } - if (!ext.nip44 || !ext.nip44.decrypt) { ui.claimError = t('extNoNip44'); ui.busy = false; render(); return; } - const payload = await ext.nip44.decrypt(ui.claimLocked.eph, ui.claimLocked.ctKey); - if (!payload || !previewGift(payload)) { ui.claimError = t('extDecryptFailed'); ui.busy = false; render(); return; } - ui.busy = false; ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; - claimGift(payload); // claim into an existing wallet or a fresh one - } catch { - ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); - } -} -function submitLockedCode() { - const code = (ui.claimCodeInput || '').trim(); - if (!code) return; - let payload = null; - try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {} - if (!payload || !previewGift(payload)) { ui.claimError = t('claimCodeWrong'); render(); return; } - ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; - claimGift(payload); // claim into an existing wallet or a fresh one -} - -// Has this gift already been claimed? Its funding coin being spent — even by an -// unconfirmed claim — means the gift is gone; show the "already claimed" screen -// rather than let the user attempt a doomed double-spend. -async function checkGiftClaimed(code) { - const ops = giftOutpoints(code); - if (!ops.length) { ui.claimChecking = false; render(); return; } - try { - const res = await wallet.api.outspend(ops[0].txid, ops[0].vout); - if (res && res.spent) ui.claimTaken = { txid: res.txid || null }; - // Unknown txids don't 404 on the status/outspend endpoints, so verify the - // funding tx is actually visible — if not, the claimer is likely on a - // different network/data source than the sender (or it hasn't propagated). - if (!ui.claimTaken) { - const tx = await wallet.api.getTx(ops[0].txid).catch(() => null); - ui.claimNotVisible = !tx; - } - } catch { - // Couldn't check (offline/transient) — leave it claimable; the broadcast in - // doClaim is the final guard (a double-spend is rejected by the network). - } - ui.claimChecking = false; - render(); -} -// Broadcast the presigned gift to this fresh wallet's first receive address. -async function doClaim() { - if (wallet.offline) { ui.claimError = t('scanOffline'); render(); return; } - if (ui.claimChecking || ui.claimTaken) return; // not verified / already taken - ui.busy = true; - ui.claimError = ''; - render(); - try { - const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - const to = wallet.receive[0] ? wallet.receive[0].address : wallet.derive(0, 0).address; - const claim = buildClaimTx(ui.claimCode, to, rate, wallet.netCfg.net); - await wallet.broadcast(claim.hex); - ui.claimedAmount = claim.amount; - const wasProvisional = !!(activeAccount() && activeAccount().provisional); - commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) - if (wasProvisional) { - ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up - } else { - ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up - ui.claimStep = null; - ui.tab = 'receive'; - ui.giftJustClaimed = claim.amount; // celebration shows instantly, no scan wait - } - wallet.scan().then(() => { - // The claim credit advances the fresh receive index in the background; - // ack it so the index-based celebration doesn't fire a second time - // behind the instant one we're already showing. - if (ui.giftJustClaimed != null) { - ui.receiveSeenIndex = wallet.nextReceiveIndex; - wallet.setReceiveAck(wallet.nextReceiveIndex); - render(); - } - }).catch(() => {}); - } catch (e) { - // Broadcast failed — most likely someone claimed it in the race window. - // Re-check the funding coin and, if spent, show the "already claimed" screen. - const ops = giftOutpoints(ui.claimCode); - if (ops.length) { - try { - const res = await wallet.api.outspend(ops[0].txid, ops[0].vout); - if (res && res.spent) ui.claimTaken = { txid: res.txid || null }; - } catch {} - } - if (!ui.claimTaken) { - ui.claimError = /missing.?inputs|missingorspent|bad-txns-inputs/i.test(String(e && e.message)) - ? t('claimNotVisibleBody') // funding tx unknown here: wrong chain/source - : t('claimFailed'); - } - } - ui.busy = false; - render(); -} + + + + + + + + + // Wallets a gift could be claimed into: full accounts open this session plus the // durable directory (every wallet's xpub, watch-only), deduped by xpub. Claiming @@ -2088,188 +1687,13 @@ function claimTargets() { return [...open, ...loadWatchAccounts().filter((w) => !seen.has(w.xpub))]; } -// Entry point for claiming a gift code. If the recipient already has wallet(s), -// offer to claim into one or make a new one; otherwise go straight to a fresh -// wallet (the original first-timer flow). -function claimGift(code) { - ui.claimLocked = null; - const targets = claimTargets(); - if (targets.length) { accounts = targets; ui.claimChoose = { code }; render(); } - else enterWallet(newMnemonic(), '', { gift: code }); -} -// Claim a gift into an existing wallet (no new seed). activateAccount loads it and -// opens the claim screen; existingClaim keeps it committed and, in doClaim, skips -// the seed-backup step. -function claimIntoAccount(acc, code) { - ui.claimChoose = null; - activateAccount(acc, { gift: code, existingClaim: true, fresh: true }); -} -// Pick where to receive a gift: an existing wallet, or a brand-new one. -function claimChooseView() { - const code = ui.claimChoose.code; - const pv = previewGift(code); - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, - h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), - h('h2', { style: 'margin:0' }, t('giftWelcome')), - pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null, - h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt')) - ), - h('div', { class: 'card col gap6' }, - h('div', { class: 'small muted' }, t('claimIntoExisting')), - ...accounts.map((a) => - h('button', { class: 'btn-block', style: 'display:flex;justify-content:space-between;align-items:center;text-align:left', onClick: () => claimIntoAccount(a, code) }, - h('span', {}, - a.label, - a.type === 'watch' ? h('span', { class: 'small faint', style: 'margin-left:6px' }, '(' + t('watchOnly') + ')') : null), - h('span', { class: 'muted', style: 'font-size:18px;line-height:1' }, '\u203a')) - ), - h('div', { class: 'small faint', style: 'text-align:center' }, t('claimOr')), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimChoose = null; enterWallet(newMnemonic(), '', { gift: code }); } }, t('claimNewWallet')) - ) - ); -} -// Gift-claim flow: a fresh wallet only the claimer controls. Step 'welcome' -// shows the amount + a Claim button; 'backup' (after a successful claim) shows -// the fresh recovery phrase to write down. -function claimScreen() { - if (ui.claimStep === 'backup') { - const words = wallet.mnemonic.split(' '); - const claimed = ui.claimedAmount > 0; // false when "create a wallet anyway" - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:8px' }, - h('div', { style: 'font-size:72px;line-height:1' + (claimed ? ';color:var(--green)' : '') }, claimed ? '✓' : '🔑'), - h('h2', { style: 'margin:0' }, claimed ? t('claimedTitle') : t('newWalletTitle')), - h('p', { class: 'muted', style: 'margin:0' }, claimed ? t('claimedBody') : t('newWalletBody')) - ), - h('div', { class: 'card col' }, - h('h3', {}, t('recoveryPhrase')), - h('div', { class: 'warn-box' }, t('writeDownWarn')), - h('div', { class: 'words' }, - 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.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')) - ) - ); - } - // While verifying the gift is still unclaimed, show a loading screen rather - // than flashing the claimable amount (which would then jump to "already - // claimed" for a spent gift). - if (ui.claimChecking) { - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' }, - h('span', { class: 'spinner' }), - h('p', { class: 'muted', style: 'margin:0' }, t('giftChecking')) - ) - ); - } - // Already claimed (funding coin spent, possibly only in the mempool) — show a - // dead end with a link to the claim, instead of a claimable amount. - if (ui.claimTaken) { - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, - h('div', { style: 'font-size:56px;line-height:1' }, '🎁'), - h('h2', { style: 'margin:0' }, t('giftTakenTitle')), - h('p', { class: 'muted', style: 'margin:0' }, t('giftTakenBody')) - ), - // A fresh wallet was already generated for the claim — offer to keep it, - // view the claim on a block explorer, or head back to the home screen. - h('button', { class: 'btn-primary btn-block', onClick: () => { - ui.claimTaken = null; ui.claimedAmount = 0; - if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); } - else { ui.screen = 'wallet'; ui.claimStep = null; } - render(); - } }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')), - ui.claimTaken.txid - ? h('a', { class: 'btn btn-block', href: wallet.api.explorerTx(ui.claimTaken.txid), target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(wallet.api.explorerTx(ui.claimTaken.txid)); } }, t('viewOnMempool')) - : null, - h('button', { class: 'btn-ghost btn-block', onClick: goHome }, t('goToHome')) - ); - } - const pv = previewGift(ui.claimCode); - // The headline is the full amount received (inputs minus the sender's change). - // The network fee is determined now, at claim time, and comes out of that - // amount — we surface the estimate on the Claim button so it isn't a surprise. - const rate = Math.max(1, Math.round((wallet.feeRates && wallet.feeRates.halfHourFee) || 5)); - const estFee = pv ? Math.ceil((11 + 68 * pv.inputs + 31 * 2) * rate) : 0; - const total = pv ? pv.room : 0; - return h( - 'div', - { class: 'col', style: 'gap:16px' }, - brandHeader(false), - h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, - h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), - h('h2', { style: 'margin:0' }, t('giftWelcome')), - h('div', { class: 'amt', style: 'font-size:30px' }, - h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit') - ), - h('p', { class: 'muted', style: 'margin:0' }, - (() => { const acc = activeAccount(); // claiming into an existing wallet vs a brand-new one - return acc && !acc.provisional ? t('claimBodyExisting', { name: acc.label }) : t('claimBody'); })()) - ), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - !ui.claimError && ui.claimNotVisible && h('div', { class: 'notice info' }, t('claimNotVisibleBody')), - ui.busy || ui.claimChecking - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('button', { class: 'btn-primary btn-block', onClick: doClaim }, - t('claimBtn'), ' ', - h('span', { style: 'font-size:0.85em;opacity:0.9' }, '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')) - ); -} + + + + // Account switcher: pick a wallet, add another, or lock the session. function accountsScreen() { @@ -2524,7 +1948,6 @@ function offlineBanner() { 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; const featLines = featureAll('balanceLines'); return h( @@ -2535,16 +1958,13 @@ 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 || featLines.length + pending > 0 || featLines.length ? h( 'div', { class: 'split' }, 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())) - : null, ...featLines.map((l) => h('div', {}, h('div', { class: 'k' }, l.label), h('div', { class: 'v' }, fmtAmount(l.sat), ' ', unitTag()))) ) @@ -2600,29 +2020,6 @@ function hasRecentIncoming() { // ---------------------------------------------------------------- Receive function receiveTab() { - // A gift was just claimed into this wallet — celebrate immediately (the - // background scan credits the coin and advances the address meanwhile). - if (ui.giftJustClaimed != null) { - const amt = ui.giftJustClaimed; - return h( - 'div', - { - class: 'card col', - style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', - onClick: () => { - ui.giftJustClaimed = null; - ui.receiveSeenIndex = wallet.nextReceiveIndex; - wallet.setReceiveAck(wallet.nextReceiveIndex); - 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')) - ); - } - // Feature takeovers: boarding success, Ark receive celebration, etc. const takeover = featureHook('receiveTakeover'); if (takeover) return takeover; @@ -2735,20 +2132,10 @@ function sendTab() { const featView = featureHook('sendView'); if (featView) return featView; if (ui.draft) return reviewView(); - if (ui.giftMode) return giftView(); return sendForm(); } -// The gift UI as a send-page sub-view (entered from a link on the send form). -function giftView() { - return h( - 'div', - { class: 'col', style: 'gap:12px' }, - giftCard(), - // The split-offer card has its own Back; don't show a second one under it. - ui.giftSplitOffer ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; goBack(() => { ui.giftMode = false; }); } }, t('back')) - ); -} + // QR scanning is only possible in a secure context with a camera. const canScan = () => @@ -3127,7 +2514,7 @@ function sendForm() { ready && !arkDest ? coinControl() : null, ui.sendError && h('div', { class: 'notice err' }, ui.sendError), 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')) + ...FEATURES.map((f) => (f.sendFormExtras ? f.sendFormExtras() : null)) ); } @@ -3373,76 +2760,23 @@ function sendResultView() { ); } -// ---------------------------------------------------------------- History -// An outstanding sent gift shown in History: tappable to cancel (reclaim the -// coin for a future payment, or revoke the link on-chain) without going through -// the Send → gift card. Reuses the same confirm state and handlers. -function giftHistoryItem(g) { - const amt = g.value != null ? g.value : g.amount; - const created = (wallet.loaded && (wallet.giftRecords()[g.id] || {}).created) || g.created; - return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.giftDetail = g.id; render(); } }, - h('div', { class: 'ico out' }, '🎁'), - h('div', { class: 'grow' }, - h('div', { class: 'row gap6' }, t('giftHistoryTitle'), - h('span', { class: 'tag ' + (g.claimed ? 'conf' : 'pending') }, g.claimed ? t('giftClaimedTag') : g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), - h('div', { class: 'small faint' }, created ? timeAgo(created / 1000) : (!g.claimed && g.reserved ? t('lockedInGifts') : '')) - ), - h('div', { style: 'text-align:right' }, - amt != null ? h('div', { class: 'amount' }, fmtAmount(amt)) : null) - ); -} -// Gift detail page: status + the link itself (unclaimed), cancel/reclaim. -function giftDetailView(g) { - const amt = g.value != null ? g.value : g.amount; - const created = (wallet.loaded && (wallet.giftRecords()[g.id] || {}).created) || g.created; - const rec = g.claimed ? null : wallet.giftLink(g.id); - const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)); - const back = () => { ui.giftDetail = null; ui.revokeId = null; render(); }; - return h( - 'div', - { class: 'card col' }, - h('div', { class: 'row between' }, - h('h3', {}, '🎁 ' + t('giftHistoryTitle')), - h('span', { class: 'tag ' + (g.claimed ? 'conf' : 'pending') }, g.claimed ? t('giftClaimedTag') : g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), - amt != null - ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount' }, fmtAmount(amt)), ' ', unitTag('unit')) - : null, - h('div', { class: 'summary col', style: 'gap:0' }, - created ? line(t('dateLabel'), new Date(created).toLocaleString()) : null, - !g.claimed && g.reserved ? line(t('status'), t('lockedInGifts')) : null), - ui.revokeId === g.id - ? h('div', { class: 'col', style: 'gap:8px' }, - h('span', { class: 'small muted' }, g.reserved ? t('giftReclaimPrompt') : t('giftRevokeConfirm')), - ui.busy - ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) - : h('div', { class: 'row gap6' }, - g.reserved ? h('button', { class: 'btn-ghost grow', onClick: () => doReclaim(g.id) }, t('giftReclaim')) : null, - h('button', { class: 'btn-primary grow', onClick: () => doRevoke(g.id) }, t('giftRevoke'))), - ui.busy ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.revokeId = null; render(); } }, t('back'))) - : g.claimed - ? null - : h('div', { class: 'row gap6' }, - rec ? h('button', { class: 'grow', onClick: () => { ui.viewGift = rec; render(); } }, t('giftView')) : null, - h('button', { class: 'grow', onClick: () => { ui.revokeId = g.id; render(); } }, t('giftCancel'))), - ui.revokeId === g.id ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')) - ); -} + + function txHistoryItem(tx) { const incoming = tx.net >= 0; const stuck = !tx.confirmed && wallet.isStuck(tx); - const gift = !incoming && wallet.loaded - ? wallet.claimedGifts().find((c) => c.claimTxid === tx.txid) : null; + const deco = featureHook('decorateTxRow', tx); // e.g. a claimed gift's claim tx return h( 'div', { class: 'item', style: 'cursor:pointer', onClick: () => openTx(tx.txid) }, - h('div', { class: `ico ${gift ? 'out' : incoming ? 'in' : 'out'}` }, gift ? '🎁' : incoming ? '↓' : '↑'), + h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, deco ? deco.icon : incoming ? '↓' : '↑'), h('div', { class: 'grow' }, h('div', { class: 'row gap6' }, - gift ? h('span', {}, t('giftHistoryTitle'), ' ', h('span', { class: 'tag conf' }, t('giftClaimedTag'))) : + deco ? deco.label : incoming ? t('received') : t('sent'), tx.confirmed ? null : stuck ? h('span', { class: 'tag', style: 'background:var(--red-soft);color:var(--red)' }, t('stuckTag')) @@ -3474,14 +2808,6 @@ function historyTab() { const txs = wallet.history; // BIP84 txs + silent-payment receipts, newest first const featDetail = featureHook('historyDetail'); if (featDetail) return featDetail; - if (ui.giftDetail) { - const g = wallet.loaded - ? [...wallet.outstandingGifts(), ...wallet.claimedGifts().map((c) => ({ ...c, claimed: true }))] - .find((x) => x.id === ui.giftDetail) - : null; - if (g) return giftDetailView(g); - ui.giftDetail = null; - } if (ui.txDetail) { const tx = txs.find((x) => x.txid === ui.txDetail); if (tx) return txDetailView(tx); @@ -3496,39 +2822,16 @@ function historyTab() { h('span', { class: 'spinner' }), wallet.historyLoading ? h('p', { class: 'small muted', style: 'margin:0' }, t('loadingHistory')) : null ); - // Outstanding sent gifts (reserved/reclaimed but unclaimed) sit above the - // on-chain history; they aren't transactions until claimed or revoked. - // A claimed gift merges into its claim transaction's row once we know which - // tx spent it (resolved lazily below) — no duplicate gift + Sent entries. - const txids = new Set(txs.map((x) => x.txid)); - const claimed = wallet.loaded ? wallet.claimedGifts() : []; - for (const c of claimed) { - if (c.claimTxid || !c.outpoints || !c.outpoints.length) continue; - if (!ui._giftClaimResolving) ui._giftClaimResolving = new Set(); - if (ui._giftClaimResolving.has(c.id) || wallet.offline) continue; - ui._giftClaimResolving.add(c.id); - const [txid, vout] = c.outpoints[0].split(':'); - wallet.api.outspend(txid, Number(vout)).then((res) => { - if (res && res.spent && res.txid) { wallet.setGiftClaimTx(c.id, res.txid); render(); } - }).catch(() => {}).finally(() => ui._giftClaimResolving.delete(c.id)); - } - const gifts = wallet.loaded - ? [...wallet.outstandingGifts(), ...claimed.filter((c) => !(c.claimTxid && txids.has(c.claimTxid))).map((c) => ({ ...c, claimed: true }))] - : []; // Feature movements (Ark receives/sends/boards, ...) interleave with // on-chain txs by time. const featEntries = featureAll('historyEntries', txs); - if (!txs.length && !gifts.length && !featEntries.length) + if (!txs.length && !featEntries.length) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet'))); - // Gifts, transactions, and Ark movements merge into one timeline (claimed - // gifts whose claim tx is in the list ride that tx's row instead). - const recs = wallet.loaded ? wallet.giftRecords() : {}; - const giftTime = (g) => (recs[g.id] && recs[g.id].created) || g.created || Date.now(); + // Transactions and feature entries merge into one timeline. const entries = [ ...txs.map((tx) => ({ time: tx.confirmed ? (tx.blockTime || 0) * 1000 : Date.now(), render: () => txHistoryItem(tx) })), ...featEntries, - ...gifts.map((g) => ({ time: giftTime(g), render: () => giftHistoryItem(g) })), ].sort((a, b) => b.time - a.time); const txPages = Math.ceil(entries.length / PAGE_SIZE); const txPage = Math.min(ui.txPage, Math.max(0, txPages - 1)); @@ -3578,7 +2881,7 @@ async function openTx(txid) { function txDetailView(tx) { const incoming = tx.net >= 0; - const gift = wallet.loaded ? wallet.claimedGifts().find((c) => c.claimTxid === tx.txid) : null; + const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)); return h( 'div', @@ -3597,12 +2900,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 ), - featureHook('txDetailSection', tx), - gift - ? h('div', { class: 'summary col', style: 'gap:0' }, - h('div', { style: 'font-weight:600;margin:12px 0 2px' }, '🎁 ' + t('giftClaimedContext')), - gift.amount != null ? h('div', { class: 'line' }, h('span', { class: 'k' }, t('giftAmountLabel')), h('span', { class: 'v' }, fmtAmount(gift.amount) + ' ' + unitLabel())) : null) - : null, + ...FEATURES.map((f) => (f.txDetailSection ? f.txDetailSection(tx) : null)), !tx.confirmed && wallet.isStuck(tx) ? h('div', { class: 'warn-box' }, incoming ? t('stuckIncomingNote') : t('stuckOutgoingNote')) : null, @@ -3673,9 +2971,12 @@ async function importSnapshotFile(e) { // only invoked at runtime (first render happens after loadLocale below). const ctx = { h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal, - fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, + fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, toggleUnit, download, + brandHeader, profileChip, activeAccount, setAccounts: (list) => { accounts = list; }, + getAccounts: () => accounts, + claimTargets, enterWallet, activateAccount, commitAccount, }; -const FEATURES = [swapsFeature, arkFeature, spFeature].map((f) => f(ctx)); +const FEATURES = [giftsFeature, swapsFeature, arkFeature, spFeature].map((f) => f(ctx)); // apply text direction, then restore a wallet left open in this tab — otherwise // show the unlock screen. @@ -3690,9 +2991,7 @@ document.addEventListener('visibilitychange', () => { }); loadLocale(getLang()).finally(() => { applyBootAutoLogout(); // clear an overdue session before we read it for claim targets - const code = readGiftHash(); - if (code) { claimGift(code); return; } // bearer gift → claim into an existing wallet or a new one - ui.claimLocked = readLockedGiftHash(); // locked gift → claim with your own wallet + if (featureHook('bootUrl')) return; // a feature consumed the URL (e.g. a gift claim) if (!restoreAccountsState()) render(); }); diff --git a/src/features/gifts.js b/src/features/gifts.js new file mode 100644 index 0000000..a0d1f8c --- /dev/null +++ b/src/features/gifts.js @@ -0,0 +1,805 @@ +// Gifts feature — presigned bearer gift links (create, share, revoke, +// reclaim) and the claim flow (fresh or existing wallet, nostr-locked gifts, +// claim codes / NIP-07 extension). Gift primitives live in ../wallet.js. + +import { newMnemonic, previewGift, giftOutpoints, buildClaimTx, giftMinimum, lockGift, previewLockedGift } from '../wallet.js'; +import { parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from '../nostr.js'; +import { t } from '../i18n.js'; +import { qrSvg } from '../qr.js'; +import { fmtBtc, timeAgo, shortAddr } from '../format.js'; + +export function giftsFeature(ctx) { + const { + h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal, + fmtAmount, unitLabel, unitTag, parseAmount, getUnit, toggleUnit, download, + brandHeader, profileChip, activeAccount, setAccounts, getAccounts, claimTargets, + enterWallet, activateAccount, commitAccount, + } = ctx; + + // ================================================================ GIFT / CLAIM + // Read a gift link, returning the code (and scrubbing it from the URL so the + // bearer instrument doesn't linger in the address bar / history). New links are + // /g/; legacy links are #gift=. Both validate by + // decoding to a real gift. + function readGiftHash() { + try { + let code = null; + const pm = location.pathname.match(/^\/g\/([A-Za-z0-9]+)\/?$/i); // new path form + if (pm) code = pm[1]; + else { + const hm = location.hash.match(/^#gift=([A-Za-z0-9_-]+)$/); // legacy hash form + if (hm) code = hm[1]; + } + if (!code) return null; + history.replaceState(null, '', '/'); + return previewGift(code) ? code : null; + } catch { + return null; + } + } + + // A gift locked to a nostr key: /lg/. Returns the public preview + // ({ amount, to, eph, ct }) — claimed by the recipient's own wallet, not a fresh + // one, so we don't auto-create a wallet here. + function readLockedGiftHash() { + try { + const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/); + if (!m) return null; + history.replaceState(null, '', '/'); + return previewLockedGift(m[1]); + } catch { + return null; + } + } + + // Entry point for claiming a gift code. If the recipient already has wallet(s), + // offer to claim into one or make a new one; otherwise go straight to a fresh + // wallet (the original first-timer flow). + function claimGift(code) { + ui.claimLocked = null; + const targets = claimTargets(); + if (targets.length) { setAccounts(targets); ui.claimChoose = { code }; render(); } + else enterWallet(newMnemonic(), '', { gift: code }); + } + + // Claim a gift into an existing wallet (no new seed). activateAccount loads it and + // opens the claim screen; existingClaim keeps it committed and, in doClaim, skips + // the seed-backup step. + function claimIntoAccount(acc, code) { + ui.claimChoose = null; + activateAccount(acc, { gift: code, existingClaim: true, fresh: true }); + } + + // Pick where to receive a gift: an existing wallet, or a brand-new one. + function claimChooseView() { + const code = ui.claimChoose.code; + const pv = previewGift(code); + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, + h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), + h('h2', { style: 'margin:0' }, t('giftWelcome')), + pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null, + h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt')) + ), + h('div', { class: 'card col gap6' }, + h('div', { class: 'small muted' }, t('claimIntoExisting')), + ...getAccounts().map((a) => + h('button', { class: 'btn-block', style: 'display:flex;justify-content:space-between;align-items:center;text-align:left', onClick: () => claimIntoAccount(a, code) }, + h('span', {}, + a.label, + a.type === 'watch' ? h('span', { class: 'small faint', style: 'margin-left:6px' }, '(' + t('watchOnly') + ')') : null), + h('span', { class: 'muted', style: 'font-size:18px;line-height:1' }, '\u203a')) + ), + h('div', { class: 'small faint', style: 'text-align:center' }, t('claimOr')), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimChoose = null; enterWallet(newMnemonic(), '', { gift: code }); } }, t('claimNewWallet')) + ) + ); + } + + // Gift-claim flow: a fresh wallet only the claimer controls. Step 'welcome' + // shows the amount + a Claim button; 'backup' (after a successful claim) shows + // the fresh recovery phrase to write down. + function claimScreen() { + if (ui.claimStep === 'backup') { + const words = wallet.mnemonic.split(' '); + const claimed = ui.claimedAmount > 0; // false when "create a wallet anyway" + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:8px' }, + h('div', { style: 'font-size:72px;line-height:1' + (claimed ? ';color:var(--green)' : '') }, claimed ? '✓' : '🔑'), + h('h2', { style: 'margin:0' }, claimed ? t('claimedTitle') : t('newWalletTitle')), + h('p', { class: 'muted', style: 'margin:0' }, claimed ? t('claimedBody') : t('newWalletBody')) + ), + h('div', { class: 'card col' }, + h('h3', {}, t('recoveryPhrase')), + h('div', { class: 'warn-box' }, t('writeDownWarn')), + h('div', { class: 'words' }, + 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.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')) + ) + ); + } + // While verifying the gift is still unclaimed, show a loading screen rather + // than flashing the claimable amount (which would then jump to "already + // claimed" for a spent gift). + if (ui.claimChecking) { + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' }, + h('span', { class: 'spinner' }), + h('p', { class: 'muted', style: 'margin:0' }, t('giftChecking')) + ) + ); + } + // Already claimed (funding coin spent, possibly only in the mempool) — show a + // dead end with a link to the claim, instead of a claimable amount. + if (ui.claimTaken) { + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, + h('div', { style: 'font-size:56px;line-height:1' }, '🎁'), + h('h2', { style: 'margin:0' }, t('giftTakenTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t('giftTakenBody')) + ), + // A fresh wallet was already generated for the claim — offer to keep it, + // view the claim on a block explorer, or head back to the home screen. + h('button', { class: 'btn-primary btn-block', onClick: () => { + ui.claimTaken = null; ui.claimedAmount = 0; + if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); } + else { ui.screen = 'wallet'; ui.claimStep = null; } + render(); + } }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')), + ui.claimTaken.txid + ? h('a', { class: 'btn btn-block', href: wallet.api.explorerTx(ui.claimTaken.txid), target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(wallet.api.explorerTx(ui.claimTaken.txid)); } }, t('viewOnMempool')) + : null, + h('button', { class: 'btn-ghost btn-block', onClick: goHome }, t('goToHome')) + ); + } + const pv = previewGift(ui.claimCode); + // The headline is the full amount received (inputs minus the sender's change). + // The network fee is determined now, at claim time, and comes out of that + // amount — we surface the estimate on the Claim button so it isn't a surprise. + const rate = Math.max(1, Math.round((wallet.feeRates && wallet.feeRates.halfHourFee) || 5)); + const estFee = pv ? Math.ceil((11 + 68 * pv.inputs + 31 * 2) * rate) : 0; + const total = pv ? pv.room : 0; + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, + h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), + h('h2', { style: 'margin:0' }, t('giftWelcome')), + h('div', { class: 'amt', style: 'font-size:30px' }, + h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit') + ), + h('p', { class: 'muted', style: 'margin:0' }, + (() => { const acc = activeAccount(); // claiming into an existing wallet vs a brand-new one + return acc && !acc.provisional ? t('claimBodyExisting', { name: acc.label }) : t('claimBody'); })()) + ), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + !ui.claimError && ui.claimNotVisible && h('div', { class: 'notice info' }, t('claimNotVisibleBody')), + ui.busy || ui.claimChecking + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) + : h('button', { class: 'btn-primary btn-block', onClick: doClaim }, + t('claimBtn'), ' ', + h('span', { style: 'font-size:0.85em;opacity:0.9' }, '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')) + ); + } + + // Has this gift already been claimed? Its funding coin being spent — even by an + // unconfirmed claim — means the gift is gone; show the "already claimed" screen + // rather than let the user attempt a doomed double-spend. + async function checkGiftClaimed(code) { + const ops = giftOutpoints(code); + if (!ops.length) { ui.claimChecking = false; render(); return; } + try { + const res = await wallet.api.outspend(ops[0].txid, ops[0].vout); + if (res && res.spent) ui.claimTaken = { txid: res.txid || null }; + // Unknown txids don't 404 on the status/outspend endpoints, so verify the + // funding tx is actually visible — if not, the claimer is likely on a + // different network/data source than the sender (or it hasn't propagated). + if (!ui.claimTaken) { + const tx = await wallet.api.getTx(ops[0].txid).catch(() => null); + ui.claimNotVisible = !tx; + } + } catch { + // Couldn't check (offline/transient) — leave it claimable; the broadcast in + // doClaim is the final guard (a double-spend is rejected by the network). + } + ui.claimChecking = false; + render(); + } + + // Broadcast the presigned gift to this fresh wallet's first receive address. + async function doClaim() { + if (wallet.offline) { ui.claimError = t('scanOffline'); render(); return; } + if (ui.claimChecking || ui.claimTaken) return; // not verified / already taken + ui.busy = true; + ui.claimError = ''; + render(); + try { + const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; + const to = wallet.receive[0] ? wallet.receive[0].address : wallet.derive(0, 0).address; + const claim = buildClaimTx(ui.claimCode, to, rate, wallet.netCfg.net); + await wallet.broadcast(claim.hex); + ui.claimedAmount = claim.amount; + const wasProvisional = !!(activeAccount() && activeAccount().provisional); + commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one) + if (wasProvisional) { + ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up + } else { + ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up + ui.claimStep = null; + ui.tab = 'receive'; + ui.giftJustClaimed = claim.amount; // celebration shows instantly, no scan wait + } + wallet.scan().then(() => { + // The claim credit advances the fresh receive index in the background; + // ack it so the index-based celebration doesn't fire a second time + // behind the instant one we're already showing. + if (ui.giftJustClaimed != null) { + ui.receiveSeenIndex = wallet.nextReceiveIndex; + wallet.setReceiveAck(wallet.nextReceiveIndex); + render(); + } + }).catch(() => {}); + } catch (e) { + // Broadcast failed — most likely someone claimed it in the race window. + // Re-check the funding coin and, if spent, show the "already claimed" screen. + const ops = giftOutpoints(ui.claimCode); + if (ops.length) { + try { + const res = await wallet.api.outspend(ops[0].txid, ops[0].vout); + if (res && res.spent) ui.claimTaken = { txid: res.txid || null }; + } catch {} + } + if (!ui.claimTaken) { + ui.claimError = /missing.?inputs|missingorspent|bad-txns-inputs/i.test(String(e && e.message)) + ? t('claimNotVisibleBody') // funding tx unknown here: wrong chain/source + : t('claimFailed'); + } + } + ui.busy = false; + render(); + } + + // A locked gift opened from a link: the amount + recipient are public. The + // recipient pastes the claim code from their nostr DM; that decrypts the payload, + // then it's claimed into a fresh wallet exactly like a normal gift. + function lockedGiftClaimView() { + const lk = ui.claimLocked; + // NIP-07: if a nostr browser extension is present, the recipient can decrypt + // in-place — no code needed. + const hasExt = !!(typeof globalThis !== 'undefined' && globalThis.nostr && lk.eph && lk.ctKey); + return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, + h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, + h('h3', { style: 'margin:0' }, t('giftForYou')), + h('div', { class: 'amt' }, fmtAmount(lk.amount), ' ', unitTag()), + h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small muted' }, t('giftLockedTo')), + profileChip(lk.to, { size: 36 })), + ui.claimError && h('div', { class: 'notice err' }, ui.claimError), + hasExt ? h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: claimViaExtension }, ui.busy ? h('span', { class: 'spinner' }) : t('claimWithExtension')) : null, + hasExt ? h('div', { class: 'small faint', style: 'text-align:center' }, t('orEnterCode')) : h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), + h('input', { type: 'text', class: 'mono-input', style: 'width:100%', placeholder: t('claimCodePlaceholder'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.claimCodeInput, + onInput: (e) => { ui.claimCodeInput = e.target.value; } }), + h('button', { class: (hasExt ? '' : 'btn-primary ') + 'btn-block', onClick: submitLockedCode }, t('claimGift')), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); } }, t('dismiss')) + ) + ); + } + + async function claimViaExtension() { + const ext = typeof globalThis !== 'undefined' && globalThis.nostr; + if (!ext) return; + ui.busy = true; ui.claimError = ''; render(); + try { + const pk = await ext.getPublicKey(); + if (pk !== ui.claimLocked.to) { ui.claimError = t('extWrongAccount'); ui.busy = false; render(); return; } + if (!ext.nip44 || !ext.nip44.decrypt) { ui.claimError = t('extNoNip44'); ui.busy = false; render(); return; } + const payload = await ext.nip44.decrypt(ui.claimLocked.eph, ui.claimLocked.ctKey); + if (!payload || !previewGift(payload)) { ui.claimError = t('extDecryptFailed'); ui.busy = false; render(); return; } + ui.busy = false; ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; + claimGift(payload); // claim into an existing wallet or a fresh one + } catch { + ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); + } + } + + function submitLockedCode() { + const code = (ui.claimCodeInput || '').trim(); + if (!code) return; + let payload = null; + try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {} + if (!payload || !previewGift(payload)) { ui.claimError = t('claimCodeWrong'); render(); return; } + ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; + claimGift(payload); // claim into an existing wallet or a fresh one + } + + // Re-view a previously created gift's link + QR (from its stored record), so the + // sender can re-share an unclaimed gift. For a locked gift, also surfaces the + // claim code that was DM'd, in case it needs re-sending. + function viewGiftView() { + const g = ui.viewGift; // { code, locked, amount, claimCode } + const url = `${location.origin}/${g.locked ? 'lg' : 'g'}/${g.code}`; + let svg = null; + try { svg = g.locked ? qrSvg(url) : qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' }, + h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, + h('h3', { style: 'margin:0' }, t('giftView')), + g.amount != null ? h('div', { class: 'amt' }, fmtAmount(g.amount), ' ', unitTag()) : null, + svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, url), + copyBtn(url, t('copyLink')), + g.locked && g.claimCode + ? h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, h('div', { class: 'small muted' }, t('giftCodeLabel')), copyBtn(g.claimCode, t('giftCopyCode'))) + : null, + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.viewGift = null; render(); } }, t('back')) + ) + ); + } + + function createGiftLink() { + const rate = giftRate(); + if (ui.giftMax) { doCreateGiftAll(rate); return; } + const min = giftMinimum(rate); + const amt = parseAmount(ui.giftAmount, getUnit()); // entered in the current display unit + if (!amt || amt < min) { ui.giftError = t('giftAmountInvalid', { n: fmtAmount(min) + ' ' + unitLabel() }); render(); return; } + const spendable = wallet.spendable; + if (amt > spendable) { ui.giftError = t('giftExceedsBalance'); render(); return; } + // A specific-amount gift must leave us a dust change output (the gift PSBT + // commits one). If the amount is so close to the balance that no dust change + // is possible, point the user at Max to gift the whole balance instead. + const summary = wallet.giftCoinSummary(amt); + if (summary == null) { + ui.giftError = t('giftNeedsHeadroom', { n: fmtAmount(Math.max(0, spendable - 294)) + ' ' + unitLabel() }); + render(); + return; + } + const lock = summary.lock; + // Offer to split/consolidate into one coin first when it would free meaningful + // change OR when the gift would otherwise bundle several coins — a many-input + // gift is costly to claim (each input adds to the claimer's fee) and its link + // can outgrow a QR. Splitting absorbs the fragmentation into the funding tx and + // leaves the claimer a clean single-input gift. + const splitFee = Math.ceil((11 + 68 + 31 * 2) * Math.max(1, Math.round(rate))); + const freed = lock - amt - 294; + if ((freed > splitFee || summary.count >= 3) && !wallet.offline) { + ui.giftSplitOffer = { amt, lock, freed: Math.max(0, freed), fee: splitFee, count: summary.count }; + ui.giftError = ''; + render(); + return; + } + doCreateGift(amt, rate); + } + + // Finalize a freshly-built gift. With a recipient npub it's a locked gift: the + // claim payload is encrypted under a one-time code, the link carries only the + // ciphertext, and the code is DM'd to the recipient over nostr. Without, it's a + // plain bearer gift. + function finishGift(g, fallbackAmt) { + const amount = g.amount || fallbackAmt || 0; + const pk = ui.giftLockNpub.trim() ? parseNostrPubkey(ui.giftLockNpub) : null; + if (!pk) { + ui.giftCode = g.code; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; + wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved }); + return; + } + const { blob, claimCode } = lockGift(g.code, amount, pk); + ui.giftCode = blob; ui.giftLocked = true; ui.giftClaimCode = claimCode; ui.giftDmStatus = 'sending'; + wallet.recordGift({ code: blob, locked: true, amount, claimCode, outpoints: g.reserved }); + const dmText = t('giftDmText', { amount: fmtAmount(amount) + ' ' + unitLabel(), link: giftUrl(), code: claimCode }); + wallet.sendNostrDM(pk, dmText).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); + } + + function giftCard() { + const active = wallet.outstandingGifts(); + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('giftLink')), + ui.giftCode + ? h('div', { class: 'col', style: 'align-items:center;gap:10px' }, + ui.giftLocked && (() => { + const lk = previewLockedGift(ui.giftCode); + if (!lk) return null; + return h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockedTo')), profileChip(lk.to)), + ui.giftDmStatus === 'sent' ? h('div', { class: 'small', style: 'color:var(--ok,#2a8)' }, t('giftDmSent')) + : ui.giftDmStatus === 'failed' ? h('div', { class: 'col gap6', style: 'align-items:center' }, h('div', { class: 'small err' }, t('giftDmFailed')), ui.giftClaimCode ? copyBtn(ui.giftClaimCode, t('giftCopyCode')) : null) + : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending')))); + })(), + // A bearer gift uppercases the URL for a smaller alphanumeric QR (the + // base32 code is case-insensitive). A locked gift's payload is + // case-sensitive base64url, so it uses a plain byte-mode QR. A large + // (many-input) gift can exceed QR capacity — then fall back to the link. + (() => { + let svg = null; + try { svg = ui.giftLocked ? qrSvg(giftUrl()) : qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')); + })(), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()), + h('div', { class: 'row gap6 wrap' }, + copyBtn(giftUrl(), t('copyLink')), + h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftLockNpub = ''; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; render(); } }, t('giftAnother')) + ) + ) + : ui.giftSplitOffer + ? (() => { + const o = ui.giftSplitOffer; + const u = ' ' + unitLabel(); + const manyCoins = (o.count || 0) >= 3; + return h('div', { class: 'col gap6' }, + h('div', { class: 'small muted' }, + manyCoins + ? t('giftConsolidateExplain', { count: o.count, fee: fmtAmount(o.fee) + u }) + : t('giftSplitExplain', { lock: fmtAmount(o.lock) + u, change: fmtAmount(o.lock - o.amt) + u, fee: fmtAmount(o.fee) + u })), + ui.giftError && h('div', { class: 'notice err' }, ui.giftError), + ui.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) + : h('div', { class: 'col gap6' }, + h('button', { class: 'btn-primary btn-block', onClick: () => doSplitForGift(o.amt) }, manyCoins ? t('giftConsolidateFirst') : t('giftSplitFirst')), + h('button', { class: 'btn-block', onClick: () => doCreateGift(o.amt, giftRate()) }, manyCoins ? t('giftUseManyCoins', { count: o.count }) : t('giftLockWhole', { n: fmtAmount(o.lock) + u })), + h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.giftSplitOffer = null; render(); } }, t('back')) + ) + ); + })() + : h('div', { class: 'col gap6' }, + h('div', { class: 'input-group' }, + h('input', { type: 'number', step: getUnit() === 'sats' ? '1' : '0.00000001', min: '0', inputmode: 'decimal', placeholder: t('giftAmountLabel'), + disabled: ui.giftMax, + value: ui.giftMax ? (getUnit() === 'sats' ? String(wallet.spendable) : fmtBtc(wallet.spendable)) : ui.giftAmount, + onInput: (e) => (ui.giftAmount = e.target.value) }), + h('button', { type: 'button', class: ui.giftMax ? 'btn-primary' : '', onClick: () => { ui.giftMax = !ui.giftMax; ui.giftError = ''; render(); } }, t('max')), + h('div', { style: 'display:flex;align-items:center' }, unitTag()) + ), + h('div', { class: 'small faint' }, ui.giftMax ? t('giftAllNote') : t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), + (() => { + const raw = ui.giftLockNpub.trim(); + const pk = raw ? parseNostrPubkey(raw) : null; + return h('div', { class: 'col gap6' }, + h('input', { type: 'text', class: 'mono-input', placeholder: t('giftLockPlaceholder'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.giftLockNpub, + onInput: (e) => { ui.giftLockNpub = e.target.value; render(); } }), + !raw ? h('div', { class: 'small faint' }, t('giftLockHint')) + : pk ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockTo')), profileChip(pk)) + : h('div', { class: 'small err' }, t('giftLockInvalid'))); + })(), + ui.giftError && h('div', { class: 'notice err' }, ui.giftError), + h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal')) + ), + active.length + ? h('div', { class: 'col gap6', style: 'margin-top:4px' }, + h('span', { class: 'small muted' }, t('giftReserved', { n: active.length })), + ...active.map((g) => { + const amt = g.value != null ? fmtAmount(g.value) + ' ' + unitLabel() : g.id.slice(0, 12) + '…'; + const label = amt + (g.reserved ? '' : ' · ' + t('giftReclaimedTag')); + if (ui.revokeId === g.id) { + return h('div', { class: 'col gap6' }, + h('span', { class: 'small muted' }, g.reserved ? t('giftReclaimPrompt') : t('giftRevokeConfirm')), + ui.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) + : h('div', { class: 'row gap6' }, + g.reserved ? h('button', { class: 'btn-ghost grow', onClick: () => doReclaim(g.id) }, t('giftReclaim')) : null, + h('button', { class: 'btn-primary grow', onClick: () => doRevoke(g.id) }, t('giftRevoke')) + ), + ui.busy ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.revokeId = null; render(); } }, t('back')) + ); + } + return h('div', { class: 'row between' }, + h('span', { class: 'small mono' }, label), + h('button', { class: 'btn-sm', onClick: () => { ui.revokeId = g.id; render(); } }, g.reserved ? t('giftReclaim') : t('giftRevoke')) + ); + }) + ) + : null + ); + } + + // The gift UI as a send-page sub-view (entered from a link on the send form). + function giftView() { + return h( + 'div', + { class: 'col', style: 'gap:12px' }, + giftCard(), + // The split-offer card has its own Back; don't show a second one under it. + ui.giftSplitOffer ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.giftCode = null; ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; ui.revokeId = null; goBack(() => { ui.giftMode = false; }); } }, t('back')) + ); + } + + // Passive reclaim: just free the coin for a future payment (no fee, no + // broadcast). The link stays claimable until the coin is actually spent. + function doReclaim(id) { + wallet.unreserve(id); + ui.revokeId = null; + toast(t('giftReclaimed')); + render(); + } + + // Active revoke: spend the coin back now (pays a fee), killing the link. + async function doRevoke(id) { + if (wallet.offline) { toast(t('scanOffline')); return; } + ui.busy = true; + render(); + try { + const rate = (wallet.feeRates && wallet.feeRates.fastestFee) || 10; + await wallet.revokeGift(id, rate); + wallet.markGiftRevoked(id); // we took it back — don't later mislabel it as claimed + ui.revokeId = null; + toast(t('giftRevoked')); + wallet.scan().catch(() => {}); + } catch (e) { + toast(e.message); + } + ui.busy = false; + render(); + } + + // ---------------------------------------------------------------- History + // An outstanding sent gift shown in History: tappable to cancel (reclaim the + // coin for a future payment, or revoke the link on-chain) without going through + // the Send → gift card. Reuses the same confirm state and handlers. + function giftHistoryItem(g) { + const amt = g.value != null ? g.value : g.amount; + const created = (wallet.loaded && (wallet.giftRecords()[g.id] || {}).created) || g.created; + return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.giftDetail = g.id; render(); } }, + h('div', { class: 'ico out' }, '🎁'), + h('div', { class: 'grow' }, + h('div', { class: 'row gap6' }, t('giftHistoryTitle'), + h('span', { class: 'tag ' + (g.claimed ? 'conf' : 'pending') }, g.claimed ? t('giftClaimedTag') : g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), + h('div', { class: 'small faint' }, created ? timeAgo(created / 1000) : (!g.claimed && g.reserved ? t('lockedInGifts') : '')) + ), + h('div', { style: 'text-align:right' }, + amt != null ? h('div', { class: 'amount' }, fmtAmount(amt)) : null) + ); + } + + // Gift detail page: status + the link itself (unclaimed), cancel/reclaim. + function giftDetailView(g) { + const amt = g.value != null ? g.value : g.amount; + const created = (wallet.loaded && (wallet.giftRecords()[g.id] || {}).created) || g.created; + const rec = g.claimed ? null : wallet.giftLink(g.id); + const line = (k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)); + const back = () => { ui.giftDetail = null; ui.revokeId = null; render(); }; + return h( + 'div', + { class: 'card col' }, + h('div', { class: 'row between' }, + h('h3', {}, '🎁 ' + t('giftHistoryTitle')), + h('span', { class: 'tag ' + (g.claimed ? 'conf' : 'pending') }, g.claimed ? t('giftClaimedTag') : g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), + amt != null + ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount' }, fmtAmount(amt)), ' ', unitTag('unit')) + : null, + h('div', { class: 'summary col', style: 'gap:0' }, + created ? line(t('dateLabel'), new Date(created).toLocaleString()) : null, + !g.claimed && g.reserved ? line(t('status'), t('lockedInGifts')) : null), + ui.revokeId === g.id + ? h('div', { class: 'col', style: 'gap:8px' }, + h('span', { class: 'small muted' }, g.reserved ? t('giftReclaimPrompt') : t('giftRevokeConfirm')), + ui.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) + : h('div', { class: 'row gap6' }, + g.reserved ? h('button', { class: 'btn-ghost grow', onClick: () => doReclaim(g.id) }, t('giftReclaim')) : null, + h('button', { class: 'btn-primary grow', onClick: () => doRevoke(g.id) }, t('giftRevoke'))), + ui.busy ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.revokeId = null; render(); } }, t('back'))) + : g.claimed + ? null + : h('div', { class: 'row gap6' }, + rec ? h('button', { class: 'grow', onClick: () => { ui.viewGift = rec; render(); } }, t('giftView')) : null, + h('button', { class: 'grow', onClick: () => { ui.revokeId = g.id; render(); } }, t('giftCancel'))), + ui.revokeId === g.id ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')) + ); + } + + // Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims + // into a fresh wallet only they control. The coin is reserved until claimed. + function giftUrl() { + return `${location.origin}/${ui.giftLocked ? 'lg' : 'g'}/${ui.giftCode}`; + } + + function giftRate() { + return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; + } + + function doCreateGift(amt, rate) { + try { + finishGift(wallet.createGift(amt, rate), amt); + ui.giftError = ''; + ui.giftSplitOffer = null; + } catch (e) { + ui.giftError = e.message; + ui.giftSplitOffer = null; + } + render(); + } + + // Gift the whole spendable balance as a no-change sweep (the recipient receives + // everything minus their claim fee). + function doCreateGiftAll(rate) { + try { + finishGift(wallet.createGiftAll(rate)); + ui.giftError = ''; + ui.giftMax = false; + ui.giftSplitOffer = null; + } catch (e) { + ui.giftError = e.message; + } + render(); + } + + // Split + gift in one tap: broadcast a self-send carving out a right-sized + // coin, then immediately build the gift from that (unconfirmed) carve-out, so + // only ~the gift amount is locked and there's no confirmation wait. The link is + // ready right away; the recipient sees a pending balance until the split lands. + async function doSplitForGift(amt) { + if (wallet.offline) { ui.giftError = t('scanOffline'); render(); return; } + ui.busy = true; ui.giftError = ''; render(); + try { + const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; + finishGift(await wallet.createGiftFromSplit(amt, rate), amt); + ui.giftSplitOffer = null; + ui.giftError = ''; + } catch (e) { + ui.giftError = e.message || t('giftSplitFailed'); + } + ui.busy = false; + wallet.scan().catch(() => {}); // reconcile the split from the mempool + render(); + } + + // "Payment received!" takeover right after a gift claim (no scan wait). + function claimCelebration() { + // A gift was just claimed into this wallet — celebrate immediately (the + // background scan credits the coin and advances the address meanwhile). + if (ui.giftJustClaimed != null) { + const amt = ui.giftJustClaimed; + return h( + 'div', + { + class: 'card col', + style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', + onClick: () => { + ui.giftJustClaimed = null; + ui.receiveSeenIndex = wallet.nextReceiveIndex; + wallet.setReceiveAck(wallet.nextReceiveIndex); + 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; + } + + return { + id: 'gifts', + // opened with ?gift / /g/ / /lg/ in the URL — start the claim flow + bootUrl() { + const code = readGiftHash(); + if (code) { claimGift(code); return true; } // bearer gift → claim into an existing wallet or a new one + ui.claimLocked = readLockedGiftHash(); // locked gift → claim with your own wallet + return false; + }, + giftOpened(code) { checkGiftClaimed(code); }, + screenView() { + if (ui.claimLocked) return lockedGiftClaimView(); + if (ui.viewGift) return viewGiftView(); + if (ui.claimChoose) return claimChooseView(); + if (ui.screen === 'claim') return claimScreen(); + return null; + }, + receiveTakeover() { return claimCelebration(); }, + sendView() { return ui.giftMode ? giftView() : null; }, + sendFormExtras() { + return h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')); + }, + balanceLines() { + const locked = wallet.lockedValue; + return locked > 0 ? [{ label: t('lockedInGifts'), sat: locked }] : []; + }, + historyEntries(txs) { + // Outstanding sent gifts (reserved/reclaimed but unclaimed) sit above the + // on-chain history; they aren't transactions until claimed or revoked. + // A claimed gift merges into its claim transaction's row once we know which + // tx spent it (resolved lazily below) — no duplicate gift + Sent entries. + const txids = new Set(txs.map((x) => x.txid)); + const claimed = wallet.loaded ? wallet.claimedGifts() : []; + for (const c of claimed) { + if (c.claimTxid || !c.outpoints || !c.outpoints.length) continue; + if (!ui._giftClaimResolving) ui._giftClaimResolving = new Set(); + if (ui._giftClaimResolving.has(c.id) || wallet.offline) continue; + ui._giftClaimResolving.add(c.id); + const [txid, vout] = c.outpoints[0].split(':'); + wallet.api.outspend(txid, Number(vout)).then((res) => { + if (res && res.spent && res.txid) { wallet.setGiftClaimTx(c.id, res.txid); render(); } + }).catch(() => {}).finally(() => ui._giftClaimResolving.delete(c.id)); + } + const gifts = wallet.loaded + ? [...wallet.outstandingGifts(), ...claimed.filter((c) => !(c.claimTxid && txids.has(c.claimTxid))).map((c) => ({ ...c, claimed: true }))] + : []; + const recs2 = wallet.loaded ? wallet.giftRecords() : {}; + const giftTime = (g) => (recs2[g.id] && recs2[g.id].created) || g.created || Date.now(); + return gifts.map((g) => ({ time: giftTime(g), render: () => giftHistoryItem(g) })); + }, + historyDetail() { + if (ui.giftDetail) { + const g = wallet.loaded + ? [...wallet.outstandingGifts(), ...wallet.claimedGifts().map((c) => ({ ...c, claimed: true }))] + .find((x) => x.id === ui.giftDetail) + : null; + if (g) return giftDetailView(g); + ui.giftDetail = null; + } + return null; + }, + decorateTxRow(tx) { + if (tx.net >= 0 || !wallet.loaded) return null; + const gift = wallet.claimedGifts().find((c) => c.claimTxid === tx.txid); + if (!gift) return null; + return { + icon: '🎁', + label: h('span', {}, t('giftHistoryTitle'), ' ', h('span', { class: 'tag conf' }, t('giftClaimedTag'))), + }; + }, + txDetailSection(tx) { + const gift = wallet.loaded ? wallet.claimedGifts().find((c) => c.claimTxid === tx.txid) : null; + if (!gift) return null; + return h('div', { class: 'summary col', style: 'gap:0' }, + h('div', { style: 'font-weight:600;margin:12px 0 2px' }, '🎁 ' + t('giftClaimedContext')), + gift.amount != null ? h('div', { class: 'line' }, h('span', { class: 'k' }, t('giftAmountLabel')), h('span', { class: 'v' }, fmtAmount(gift.amount) + ' ' + unitLabel())) : null); + }, + }; +} -- cgit v1.2.3