aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-04 09:05:23 -0700
committerAdam Soltys <[email protected]>2026-07-04 09:05:23 -0700
commit5fb48ee5284dbba34c8fa3b77c36379f49404ac2 (patch)
tree10e0ddb60968ca3f4f9a88e75d4a990e8cc34cb8
parent11a14600da683769c74d49e3d973183aa1a2fe5e (diff)
downloadcoinosv3-5fb48ee5284dbba34c8fa3b77c36379f49404ac2.tar.gz
coinosv3-5fb48ee5284dbba34c8fa3b77c36379f49404ac2.zip
Features: extract gifts behind the plugin seam
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
-rw-r--r--src/app.js797
-rw-r--r--src/features/gifts.js805
2 files changed, 853 insertions, 749 deletions
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/<base32-compact>; legacy links are #gift=<base64url-psbt>. 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/<base64url>. 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/<base32-compact>; legacy links are #gift=<base64url-psbt>. 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/<base64url>. 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);
+ },
+ };
+}