aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-28 14:25:09 -0700
committerAdam Soltys <[email protected]>2026-06-28 14:25:09 -0700
commita267f7f37fd178ebb58871188067b571b18a67f1 (patch)
tree1b31c134c568fa04f0305f1e604d693310217d72 /src
parent06376a71d4b9a4f579095933d91fe0a369b5e0fe (diff)
downloadcoinosv3-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]>
Diffstat (limited to 'src')
-rw-r--r--src/app.js58
-rw-r--r--src/i18n.js3
-rw-r--r--src/wallet.js26
3 files changed, 77 insertions, 10 deletions
diff --git a/src/app.js b/src/app.js
index 3f7b0b4..eaf9b86 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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