aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/gifts.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/gifts.js')
-rw-r--r--src/features/gifts.js118
1 files changed, 98 insertions, 20 deletions
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 399a807..42e794d 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -9,6 +9,10 @@ import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredG
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
import { fmtBtc, timeAgo, shortAddr } from '../format.js';
+import { getEventHash, verifyEvent } from 'nostr-tools/pure';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
+import { base32nopad } from '@scure/base';
export function giftsFeature(ctx) {
const {
@@ -33,6 +37,7 @@ export function giftsFeature(ctx) {
const am = location.pathname.match(/^\/ag\/([A-Za-z0-9]+)\/?$/i); // ark gift
if (am) {
ui.claimNote = readNoteFrag();
+ ui.claimFrom = readFromFrag(am[1]);
history.replaceState(null, '', '/');
return arkGiftOf(am[1]) ? am[1] : null;
}
@@ -44,7 +49,7 @@ export function giftsFeature(ctx) {
if (hm) code = hm[1];
}
if (!code) return null;
- if (pm) ui.claimNote = readNoteFrag();
+ if (pm) { ui.claimNote = readNoteFrag(); ui.claimFrom = readFromFrag(code); }
history.replaceState(null, '', '/');
return previewGift(code) ? code : null;
} catch {
@@ -60,6 +65,7 @@ export function giftsFeature(ctx) {
const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/);
if (!m) return null;
ui.claimNote = null;
+ ui.claimFrom = null;
history.replaceState(null, '', '/');
return previewLockedGift(m[1]);
} catch { return null; }
@@ -127,20 +133,72 @@ export function giftsFeature(ctx) {
// leaves the browser, so no server sees it, and it is only as private as the
// link itself (which already IS the money).
const GIFT_NOTE_MAX = 280;
- const noteFrag = (note) => (note ? '#m=' + encodeURIComponent(note) : '');
+ // Who it's from rides there too (f=…): the sender's nostr key, signed over
+ // the gift's code, so a link can't be passed on as someone else's gift.
+ // 100 bytes in base32: pubkey, created_at, schnorr sig.
+ const giftFrag = (note, from) => {
+ const p = [];
+ if (note) p.push('m=' + encodeURIComponent(note));
+ if (from) p.push('f=' + from);
+ return p.length ? '#' + p.join('&') : '';
+ };
function readNoteFrag() {
try {
const m = new URLSearchParams(location.hash.slice(1)).get('m');
return m ? m.trim().slice(0, GIFT_NOTE_MAX) || null : null;
} catch { return null; }
}
+ const GIFT_FROM_KIND = 27121; // ephemeral range: never meant for a relay
+ const giftFromTmpl = (code, created_at) => ({
+ kind: GIFT_FROM_KIND, created_at, content: '',
+ tags: [['x', bytesToHex(sha256(new TextEncoder().encode(String(code).toUpperCase())))]],
+ });
+ // the sender's pubkey, if the link's f= is their signature over this code
+ function readFromFrag(code) {
+ try {
+ const f = [...new URLSearchParams(location.hash.slice(1))].find(([k]) => k.toLowerCase() === 'f')?.[1];
+ const b = f && base32nopad.decode(f.toUpperCase());
+ if (!b || b.length !== 100) return null;
+ const ev = { ...giftFromTmpl(code, new DataView(b.buffer, b.byteOffset).getUint32(32)),
+ pubkey: bytesToHex(b.slice(0, 32)), sig: bytesToHex(b.slice(36)) };
+ ev.id = getEventHash(ev);
+ return verifyEvent(ev) ? ev.pubkey : null;
+ } catch { return null; }
+ }
+ // Sign a new gift as ours (the messages feature holds the identity); null
+ // when there's no identity, the signer refuses, or it takes too long.
+ async function signGiftFrom(code) {
+ try {
+ const ev = await Promise.race([
+ hook('signGiftFrom', giftFromTmpl(code, Math.floor(Date.now() / 1000))),
+ new Promise((r) => setTimeout(() => r(null), 20_000)),
+ ]);
+ if (!ev || !ev.sig) return null;
+ const b = new Uint8Array(100);
+ b.set(hexToBytes(ev.pubkey), 0);
+ new DataView(b.buffer).setUint32(32, ev.created_at);
+ b.set(hexToBytes(ev.sig), 36);
+ return base32nopad.encode(b);
+ } catch { return null; }
+ }
+ const GIFT_ANON_KEY = 'giftAnon';
+ const giftAnon = () => { try { return localStorage.getItem(GIFT_ANON_KEY) === '1'; } catch { return false; } };
+ // who sent it, and that claiming follows them
+ const giftFromView = () => (ui.claimFrom
+ ? h('div', { class: 'col', style: 'align-items:center;gap:4px' },
+ h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' },
+ h('span', { class: 'small muted' }, t('giftFrom')),
+ hook('profileChip', ui.claimFrom, undefined, () => {})
+ || h('span', { class: 'small mono' }, (npubOf(ui.claimFrom) || '').slice(0, 16) + '…')),
+ h('div', { class: 'small faint' }, t('giftClaimFollows')))
+ : null);
// the sender's words, quoted above the amount wherever the gift is opened
const giftNoteView = () => (ui.claimNote
? h('blockquote', { class: 'gift-note' }, ui.claimNote)
: null);
- // with a note the link has lower case in it: a byte-mode QR (the bare
- // link is upper-cased into the denser alphanumeric mode)
- const giftQr = (url, hasNote) => qrSvg(hasNote ? url : url.toUpperCase(), { ec: 'L', mode: hasNote ? undefined : 'Alphanumeric' });
+ // with a note or a sender the link has a # in it: a byte-mode QR (the
+ // bare link is upper-cased into the denser alphanumeric mode)
+ const giftQr = (url, hasFrag) => qrSvg(hasFrag ? url : url.toUpperCase(), { ec: 'L', mode: hasFrag ? undefined : 'Alphanumeric' });
// 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
@@ -193,6 +251,7 @@ export function giftsFeature(ctx) {
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')),
+ giftFromView(),
giftNoteView(),
pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null,
h('div', { class: 'small faint' }, claimFeeNote(code)),
@@ -334,6 +393,7 @@ export function giftsFeature(ctx) {
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')),
+ giftFromView(),
giftNoteView(),
h('div', { class: 'amt', style: 'font-size:30px' },
h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit')
@@ -426,6 +486,7 @@ export function giftsFeature(ctx) {
ui.claimedAmount = amount;
const wasProvisional = !!(activeAccount() && activeAccount().provisional);
commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one)
+ if (ui.claimFrom) hook('followGiftSender', ui.claimFrom);
if (wasProvisional) {
ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up
} else {
@@ -560,9 +621,9 @@ export function giftsFeature(ctx) {
// the sender can re-share an unclaimed gift.
function viewGiftView() {
const g = ui.viewGift; // { code, locked, amount, claimCode }
- const url = `${location.origin}/g/${g.code}${noteFrag(g.note)}`;
+ const url = `${location.origin}/g/${g.code}${giftFrag(g.note, g.from)}`;
let svg = null;
- try { svg = giftQr(url, !!g.note); } catch {}
+ try { svg = giftQr(url, !!(g.note || g.from)); } 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')),
@@ -604,7 +665,7 @@ export function giftsFeature(ctx) {
try {
const g = await hook('arkGiftCreate', amt, giftNoteNow());
ui.giftIsArk = true;
- finishGift(g, amt);
+ await finishGift(g, amt);
} catch (e) {
ui.giftError = e.message;
}
@@ -646,12 +707,19 @@ export function giftsFeature(ctx) {
doCreateGift(amt, rate);
}
- // Finalize a freshly-built gift: a plain bearer link.
- function finishGift(g, fallbackAmt) {
+ // Finalize a freshly-built gift: a plain bearer link, signed as ours
+ // unless the sender would rather stay anonymous. Recorded before the
+ // signer is asked, so a signer that never answers can't lose the gift.
+ async function finishGift(g, fallbackAmt) {
const amount = g.amount || fallbackAmt || 0;
+ const note = giftNoteNow(); // the link says what was typed when it was made
+ const rec = { code: g.code, locked: false, amount, outpoints: g.reserved, note };
+ wallet.recordGift(rec);
+ const from = giftAnon() ? null : await signGiftFrom(g.code);
+ if (from) wallet.recordGift({ ...rec, from });
ui.giftCode = g.code;
- ui.giftNoteMade = giftNoteNow(); // the link says what was typed when it was made
- wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved, note: ui.giftNoteMade });
+ ui.giftNoteMade = note;
+ ui.giftFromMade = from;
}
const giftNoteNow = () => String(ui.giftNote || '').trim().slice(0, GIFT_NOTE_MAX) || null;
@@ -668,14 +736,14 @@ export function giftsFeature(ctx) {
// (many-input) gift can exceed QR capacity — then fall back to the link.
(() => {
let svg = null;
- try { svg = giftQr(giftUrl(), !!ui.giftNoteMade); } catch {}
+ try { svg = giftQr(giftUrl(), !!(ui.giftNoteMade || ui.giftFromMade)); } catch {}
return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong'));
})(),
ui.giftNoteMade ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, ui.giftNoteMade) : null,
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.giftNote = ''; ui.giftNoteMade = null; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother'))
+ h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftNote = ''; ui.giftNoteMade = null; ui.giftFromMade = null; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother'))
)
)
: ui.giftSplitOffer
@@ -733,6 +801,12 @@ export function giftsFeature(ctx) {
// an optional note, shown to whoever opens the gift
h('textarea', { class: 'gift-note-input', rows: 2, maxlength: GIFT_NOTE_MAX, placeholder: t('giftNotePlaceholder'),
value: ui.giftNote || '', onInput: (e) => { ui.giftNote = e.target.value; } }),
+ // the link names its sender (and claiming follows them) unless unticked
+ h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
+ h('input', { type: 'checkbox', checked: !giftAnon(), onChange: (e) => {
+ try { if (e.target.checked) localStorage.removeItem(GIFT_ANON_KEY); else localStorage.setItem(GIFT_ANON_KEY, '1'); } catch {}
+ } }),
+ h('span', { class: 'small' }, t('giftFromMe'))),
ui.giftError && h('div', { class: 'notice err' }, ui.giftError),
ui.busy
? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
@@ -905,36 +979,40 @@ export function giftsFeature(ctx) {
// 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.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}${noteFrag(ui.giftNoteMade)}`;
+ return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}${giftFrag(ui.giftNoteMade, ui.giftFromMade)}`;
}
function giftRate() {
return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5;
}
- function doCreateGift(amt, rate) {
+ async function doCreateGift(amt, rate) {
+ ui.busy = true; render();
try {
- finishGift(wallet.createGift(amt, rate), amt);
+ await finishGift(wallet.createGift(amt, rate), amt);
ui.giftError = '';
ui.giftSplitOffer = null;
} catch (e) {
ui.giftError = e.message;
ui.giftSplitOffer = null;
}
+ ui.busy = false;
render();
}
// Gift the whole spendable balance as a no-change sweep (the recipient receives
// everything minus their claim fee).
- function doCreateGiftAll(rate) {
+ async function doCreateGiftAll(rate) {
+ ui.busy = true; render();
try {
- finishGift(wallet.createGiftAll(rate));
+ await finishGift(wallet.createGiftAll(rate));
ui.giftError = '';
ui.giftMax = false;
ui.giftSplitOffer = null;
} catch (e) {
ui.giftError = e.message;
}
+ ui.busy = false;
render();
}
@@ -947,7 +1025,7 @@ export function giftsFeature(ctx) {
ui.busy = true; ui.giftError = ''; render();
try {
const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5;
- finishGift(await wallet.createGiftFromSplit(amt, rate), amt);
+ await finishGift(await wallet.createGiftFromSplit(amt, rate), amt);
ui.giftSplitOffer = null;
ui.giftError = '';
} catch (e) {