diff options
| author | Adam Soltys <[email protected]> | 2026-06-28 14:25:09 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-28 14:25:09 -0700 |
| commit | a267f7f37fd178ebb58871188067b571b18a67f1 (patch) | |
| tree | 1b31c134c568fa04f0305f1e604d693310217d72 | |
| parent | 06376a71d4b9a4f579095933d91fe0a369b5e0fe (diff) | |
| download | coinosv3-a267f7f37fd178ebb58871188067b571b18a67f1.tar.gz coinosv3-a267f7f37fd178ebb58871188067b571b18a67f1.zip | |
Gifts: re-view link/QR for unclaimed gifts + mark claimed ones in history
Persist a per-account gift record (shareable link/code + amount, plus a locked
gift's claim code) when a gift is created, so:
- Unclaimed gifts in History get a 'Gift link' button that re-opens the QR + link
(and the claim code for locked gifts), to re-share without recreating the gift.
- Once a gift's coin is spent and we didn't revoke it, it shows in History as
'claimed' instead of vanishing. Revoking marks the record so a reclaim isn't
mislabelled as claimed.
Verified on regtest: create → outstanding=1, link stored; after the recipient
claims → outstanding=0, claimed=1 with the right amount.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| -rw-r--r-- | src/app.js | 58 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/wallet.js | 26 |
3 files changed, 77 insertions, 10 deletions
@@ -57,6 +57,7 @@ const ui = { claimLocked: null, // a locked gift being opened: { v, amount, to, ct } claimCodeInput: '', // the claim code the recipient pastes from their nostr DM consolidateError: null, // error from a coin-consolidation attempt + viewGift: null, // re-viewing a previously created gift's link/QR { code, locked, amount, claimCode } giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) @@ -261,6 +262,8 @@ function render() { const screen = ui.claimLocked ? lockedGiftClaimView() + : ui.viewGift + ? viewGiftView() : ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' @@ -1510,13 +1513,18 @@ function createGiftLink() { // 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; return; } - const { blob, claimCode } = lockGift(g.code, g.amount || fallbackAmt || 0, pk); + 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'; - const amount = fmtAmount(g.amount || fallbackAmt || 0) + ' ' + unitLabel(); - const dm = t('giftDmText', { amount, link: giftUrl(), code: claimCode }); - wallet.sendNostrDM(pk, dm).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); + 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 { @@ -1682,6 +1690,7 @@ async function doRevoke(id) { 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(() => {}); @@ -1882,6 +1891,29 @@ function readLockedGiftHash() { } } +// 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. @@ -3179,16 +3211,20 @@ function giftHistoryItem(g) { ui.busy ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.revokeId = null; render(); } }, t('back')) ); } + const amt = g.value != null ? g.value : g.amount; + const rec = g.claimed ? null : wallet.giftLink(g.id); return h('div', { class: 'item' }, h('div', { class: 'ico out' }, '🎁'), h('div', { class: 'grow' }, h('div', { class: 'row gap6' }, t('giftHistoryTitle'), - h('span', { class: 'tag pending' }, g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), - g.reserved ? h('div', { class: 'small faint' }, t('lockedInGifts')) : null + h('span', { class: 'tag ' + (g.claimed ? 'conf' : 'pending') }, g.claimed ? t('giftClaimedTag') : g.reserved ? t('giftUnclaimedTag') : t('giftReclaimedTag'))), + !g.claimed && g.reserved ? h('div', { class: 'small faint' }, t('lockedInGifts')) : null ), h('div', { style: 'text-align:right' }, - g.value != null ? h('div', { class: 'amount' }, fmtAmount(g.value)) : null, - h('button', { class: 'btn-sm', style: 'margin-top:4px', onClick: () => { ui.revokeId = g.id; render(); } }, t('giftCancel')) + amt != null ? h('div', { class: 'amount' }, fmtAmount(amt)) : null, + g.claimed ? null : h('div', { class: 'row gap6', style: 'justify-content:flex-end;margin-top:4px' }, + rec ? h('button', { class: 'btn-sm', onClick: () => { ui.viewGift = rec; render(); } }, t('giftView')) : null, + h('button', { class: 'btn-sm', onClick: () => { ui.revokeId = g.id; render(); } }, t('giftCancel'))) ) ); } @@ -3265,7 +3301,9 @@ function historyTab() { ); // Outstanding sent gifts (reserved/reclaimed but unclaimed) sit above the // on-chain history; they aren't transactions until claimed or revoked. - const gifts = wallet.loaded ? wallet.outstandingGifts() : []; + const gifts = wallet.loaded + ? [...wallet.outstandingGifts(), ...wallet.claimedGifts().map((c) => ({ ...c, claimed: true }))] + : []; if (ui.giftsAll && gifts.length) return giftsAllView(gifts); if (!txs.length && !gifts.length) return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet'))); diff --git a/src/i18n.js b/src/i18n.js index 3562337..047509b 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -269,6 +269,9 @@ const STR = { newWalletBody: 'Back up your recovery phrase to keep it safe.', giftHistoryTitle: 'Gift link', giftUnclaimedTag: 'unclaimed', + giftClaimedTag: 'claimed', + giftView: 'Gift link', + giftCodeLabel: 'Claim code (sent via DM)', giftCancel: 'Cancel', viewAllGifts: 'View all gifts ({n})', prevPage: '‹ Prev', diff --git a/src/wallet.js b/src/wallet.js index 6e3ea81..4a8ddc4 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1549,6 +1549,32 @@ export class Wallet { })); } + // Persistent gift records (per account): the shareable link/code + amount, so a + // gift's link/QR can be re-viewed later and a claimed one identified in history. + _giftRecordsKey() { return this._cacheKey() + ':giftrec'; } + giftRecords() { + if (!this._giftRecords) { try { this._giftRecords = JSON.parse(localStorage.getItem(this._giftRecordsKey()) || '{}'); } catch { this._giftRecords = {}; } } + return this._giftRecords; + } + _saveGiftRecords() { try { localStorage.setItem(this._giftRecordsKey(), JSON.stringify(this.giftRecords())); } catch {} } + recordGift({ code, locked, amount, claimCode, outpoints }) { + if (!outpoints || !outpoints.length) return; + this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false }; + this._saveGiftRecords(); + } + giftLink(id) { return this.giftRecords()[id] || null; } + markGiftRevoked(id) { const r = this.giftRecords()[id]; if (r) { r.revoked = true; this._saveGiftRecords(); } } + // Gifts whose coin has been spent and weren't revoked by us → claimed. (Only + // once scanned, so an un-synced wallet doesn't mislabel everything as claimed.) + claimedGifts() { + if (!this.loaded) return []; + const live = new Set(this.utxos.map((u) => utxoId(u))); + return Object.values(this.giftRecords()) + .filter((r) => !r.revoked && r.outpoints && !r.outpoints.some((o) => live.has(o))) + .map((r) => ({ id: r.id, amount: r.amount, locked: r.locked, created: r.created })) + .sort((a, b) => (b.created || 0) - (a.created || 0)); + } + // Best-fit coin selection for a gift of `gift` sats (needs gift + a dust // change): the smallest single confirmed coin that covers it — one input and // the least value locked until claim — else the fewest large coins. Returns |
