aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/gifts-wallet.js4
-rw-r--r--src/features/gifts.js118
-rw-r--r--src/features/messages.js15
-rw-r--r--src/i18n.js3
-rw-r--r--src/locales/ar.json3
-rw-r--r--src/locales/bn.json3
-rw-r--r--src/locales/de.json3
-rw-r--r--src/locales/es.json3
-rw-r--r--src/locales/fr.json3
-rw-r--r--src/locales/hi.json3
-rw-r--r--src/locales/id.json3
-rw-r--r--src/locales/it.json3
-rw-r--r--src/locales/ja.json3
-rw-r--r--src/locales/ko.json3
-rw-r--r--src/locales/mr.json3
-rw-r--r--src/locales/pt.json3
-rw-r--r--src/locales/ru.json3
-rw-r--r--src/locales/sw.json3
-rw-r--r--src/locales/ta.json3
-rw-r--r--src/locales/te.json3
-rw-r--r--src/locales/tr.json3
-rw-r--r--src/locales/ur.json3
-rw-r--r--src/locales/vi.json3
-rw-r--r--src/locales/zh.json3
-rw-r--r--tools/gift-from-test.js71
25 files changed, 249 insertions, 22 deletions
diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js
index 7004b12..9532125 100644
--- a/src/features/gifts-wallet.js
+++ b/src/features/gifts-wallet.js
@@ -274,9 +274,9 @@ export function installGiftWallet(wallet) {
// gift's link/QR can be re-viewed later and a claimed one identified in history.
_giftRecordsKey() { return this._cacheKey() + ':giftrec'; },
- recordGift({ code, locked, amount, claimCode, outpoints, note }) {
+ recordGift({ code, locked, amount, claimCode, outpoints, note, from }) {
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, ...(note ? { note } : {}) };
+ this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false, ...(note ? { note } : {}), ...(from ? { from } : {}) };
this._saveGiftRecords();
try { this.saveCache(); } catch {} // publish the new gift to cross-device sync
},
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) {
diff --git a/src/features/messages.js b/src/features/messages.js
index 6c6b48d..76d8c4f 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -18457,6 +18457,21 @@ export function messagesFeature(ctx) {
}, avatar(pk, 'chat-avatar ' + (big ? 'send-avatar' : 'mini'), false),
h('span', { class: big ? '' : 'small' }, displayName(pk)));
},
+ // a gift link names its sender: sign its template as us (gifts.js)
+ async signGiftFrom(tmpl) {
+ const id = await identity();
+ if (!id) return null;
+ return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl);
+ },
+ // A claimed gift's sender: follow them, once any follow publish in
+ // flight (a fresh wallet's starter follows) has landed — followMany
+ // drops a call that arrives mid-publish.
+ async followGiftSender(pk) {
+ for (let i = 0; followsPub && i < 60; i++) await new Promise((r) => setTimeout(r, 500));
+ if (!(await identity())) return false;
+ await followMany([pk]).catch(() => {});
+ return true;
+ },
// A sign-in (Google, passkey, key) that found no wallet on its identity:
// the same start, but only the follows of an identity that follows
// nobody yet — an established nostr user keeps their own list.
diff --git a/src/i18n.js b/src/i18n.js
index a01999a..5695c4a 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1060,6 +1060,9 @@ const STR = {
giftBackedUp: 'I’ve written it down',
giftLink: 'Gift some Bitcoin',
giftNotePlaceholder: 'Add a note (optional)',
+ giftFromMe: 'Show it’s from me (whoever claims it follows you)',
+ giftFrom: 'From',
+ giftClaimFollows: 'Claiming it follows them',
giftLinkDesc: 'Presign an amount as a link. Whoever opens it claims the Bitcoin into a brand-new wallet that only they control.',
giftLinkWarn: '⚠ Anyone who opens this link can claim the amount, so share it only with the person you are gifting. Keep amounts small.',
giftLinkReveal: 'Create gift link',
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 7d11408..2d7bd17 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -365,6 +365,9 @@
"giftBackedUp": "لقد دوّنتها",
"giftLink": "أهدِ بعض عملات Bitcoin",
"giftNotePlaceholder": "أضف ملاحظة (اختياري)",
+ "giftFromMe": "قل إنها مني (المطالبة بها تتابعك)",
+ "giftFrom": "من",
+ "giftClaimFollows": "المطالبة بها تعني متابعة المُرسِل",
"giftLinkDesc": "وقّع مبلغاً مسبقاً كرابط. من يفتحه يطالب بعملات Bitcoin في محفظة جديدة تماماً يتحكم بها وحده.",
"giftLinkWarn": "⚠ أي شخص يفتح هذا الرابط يمكنه المطالبة بالمبلغ، لذا شاركه فقط مع الشخص الذي تهديه إياه. أبقِ المبالغ صغيرة.",
"giftLinkReveal": "إنشاء رابط الإهداء",
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 27cac0b..82bbd7f 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -365,6 +365,9 @@
"giftBackedUp": "আমি এটি লিখে রেখেছি",
"giftLink": "কিছু Bitcoin উপহার দিন",
"giftNotePlaceholder": "একটি নোট যোগ করুন (ঐচ্ছিক)",
+ "giftFromMe": "জানাও এটা আমার কাছ থেকে (দাবি করলে তোমাকে ফলো করবে)",
+ "giftFrom": "পাঠিয়েছেন",
+ "giftClaimFollows": "দাবি করলে তাঁকে ফলো করা হবে",
"giftLinkDesc": "একটি পরিমাণ লিঙ্ক হিসেবে আগে থেকে সই করুন। যে এটি খুলবে সে Bitcoin-টি একটি একদম নতুন ওয়ালেটে দাবি করে, যা কেবল সে-ই নিয়ন্ত্রণ করে।",
"giftLinkWarn": "⚠ যে কেউ এই লিঙ্ক খুললে পরিমাণটি দাবি করতে পারে, তাই কেবল যাকে উপহার দিচ্ছেন তার সাথেই এটি শেয়ার করুন। পরিমাণ কম রাখুন।",
"giftLinkReveal": "লিংক নির্মাণ করুন",
diff --git a/src/locales/de.json b/src/locales/de.json
index 3a18040..0f0464e 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -365,6 +365,9 @@
"giftBackedUp": "Ich habe sie aufgeschrieben",
"giftLink": "Bitcoin verschenken",
"giftNotePlaceholder": "Notiz hinzufügen (optional)",
+ "giftFromMe": "Zeigen, dass es von mir ist (wer es einlöst, folgt dir)",
+ "giftFrom": "Von",
+ "giftClaimFollows": "Beim Einlösen folgst du dieser Person",
"giftLinkDesc": "Signiere einen Betrag im Voraus als Link. Wer ihn öffnet, beansprucht das Bitcoin in einer brandneuen Wallet, die nur er selbst kontrolliert.",
"giftLinkWarn": "⚠ Wer diesen Link öffnet, kann den Betrag beanspruchen. Teile ihn daher nur mit der Person, die du beschenkst. Halte die Beträge klein.",
"giftLinkReveal": "Geschenk-Link erstellen",
diff --git a/src/locales/es.json b/src/locales/es.json
index 40176a7..f7a74b8 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -365,6 +365,9 @@
"giftBackedUp": "Ya la anoté",
"giftLink": "Regala algo de Bitcoin",
"giftNotePlaceholder": "Añade una nota (opcional)",
+ "giftFromMe": "Decir que es mío (quien lo reclame te seguirá)",
+ "giftFrom": "De",
+ "giftClaimFollows": "Al reclamarlo, seguirás a esta persona",
"giftLinkDesc": "Prefirma un importe como enlace. Quien lo abra reclama el Bitcoin en una billetera totalmente nueva que solo esa persona controla.",
"giftLinkWarn": "⚠ Cualquiera que abra este enlace puede reclamar el importe, así que compártelo solo con la persona a quien se lo regalas. Mantén los importes pequeños.",
"giftLinkReveal": "Crear enlace de regalo",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 407794b..650a208 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -365,6 +365,9 @@
"giftBackedUp": "Je l’ai notée",
"giftLink": "Offrir des Bitcoin",
"giftNotePlaceholder": "Ajouter un mot (facultatif)",
+ "giftFromMe": "Dire que ça vient de moi (qui le réclame vous suivra)",
+ "giftFrom": "De",
+ "giftClaimFollows": "En le réclamant, vous suivrez cette personne",
"giftLinkDesc": "Présignez un montant sous forme de lien. Celui qui l’ouvre récupère les Bitcoin dans un tout nouveau portefeuille qu’il est seul à contrôler.",
"giftLinkWarn": "⚠ Quiconque ouvre ce lien peut récupérer le montant, alors ne le partagez qu’avec la personne à qui vous l’offrez. Gardez les montants faibles.",
"giftLinkReveal": "Créer le lien cadeau",
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 41eb792..c3245c7 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -365,6 +365,9 @@
"giftBackedUp": "मैंने इसे लिख लिया है",
"giftLink": "कुछ Bitcoin उपहार में दें",
"giftNotePlaceholder": "एक नोट जोड़ें (वैकल्पिक)",
+ "giftFromMe": "बताएं कि यह मेरी ओर से है (दावा करने पर आपको फ़ॉलो करेंगे)",
+ "giftFrom": "भेजने वाले",
+ "giftClaimFollows": "दावा करने पर आप उन्हें फ़ॉलो करेंगे",
"giftLinkDesc": "एक राशि को लिंक के रूप में पहले से साइन करें। जो भी इसे खोलेगा, वह Bitcoin को एक बिल्कुल नए वॉलेट में प्राप्त कर लेगा जिसे केवल वही नियंत्रित करता है।",
"giftLinkWarn": "⚠ जो भी इस लिंक को खोलेगा वह राशि प्राप्त कर सकता है, इसलिए इसे केवल उसी व्यक्ति के साथ साझा करें जिसे आप उपहार दे रहे हैं। राशि कम रखें।",
"giftLinkReveal": "उपहार लिंक बनाएँ",
diff --git a/src/locales/id.json b/src/locales/id.json
index f16c07b..a2c08e4 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -138,6 +138,9 @@
"giftBackedUp": "Saya sudah mencatatnya",
"giftLink": "Hadiahkan Bitcoin",
"giftNotePlaceholder": "Tambahkan catatan (opsional)",
+ "giftFromMe": "Tunjukkan ini dari saya (yang mengklaim akan mengikuti Anda)",
+ "giftFrom": "Dari",
+ "giftClaimFollows": "Mengklaimnya akan mengikuti pengirim",
"giftLinkDesc": "Tandatangani sejumlah dana sebagai tautan. Siapa pun yang membukanya mengklaim Bitcoin itu ke dompet baru yang hanya dia kendalikan.",
"giftLinkWarn": "⚠ Siapa pun yang membuka tautan ini bisa mengklaim jumlahnya, jadi bagikan hanya kepada orang yang Anda beri hadiah. Jaga jumlahnya tetap kecil.",
"giftAmountLabel": "Jumlah yang dihadiahkan",
diff --git a/src/locales/it.json b/src/locales/it.json
index 9988888..37407d4 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -828,6 +828,9 @@
"giftBackedUp": "L'ho scritta",
"giftLink": "Regala dei Bitcoin",
"giftNotePlaceholder": "Aggiungi un messaggio (facoltativo)",
+ "giftFromMe": "Di’ che è da parte mia (chi lo riscatta ti segue)",
+ "giftFrom": "Da",
+ "giftClaimFollows": "Riscattandolo seguirai questa persona",
"giftLinkDesc": "Prefirma un importo sotto forma di link. Chi lo apre riscuote i Bitcoin in un portafoglio nuovo di zecca che controlla solo lui.",
"giftLinkWarn": "⚠ Chiunque apra questo link può riscuotere l'importo, quindi condividilo solo con la persona a cui fai il regalo. Mantieni importi piccoli.",
"giftLinkReveal": "Crea link regalo",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 010955a..32c1b07 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -365,6 +365,9 @@
"giftBackedUp": "書き留めました",
"giftLink": "Bitcoin を贈る",
"giftNotePlaceholder": "メッセージを添える(任意)",
+ "giftFromMe": "自分からだと伝える(受け取った人があなたをフォローします)",
+ "giftFrom": "送り主",
+ "giftClaimFollows": "受け取ると送り主をフォローします",
"giftLinkDesc": "金額をリンクとして事前署名します。リンクを開いた人が、その人だけが管理できる真新しいウォレットに Bitcoin を受け取ります。",
"giftLinkWarn": "⚠ このリンクを開いた人は誰でもその金額を受け取れるので、贈る相手とだけ共有してください。金額は少なめにしましょう。",
"giftLinkReveal": "ギフトリンクを作成",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index e25af6e..703a137 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -364,6 +364,9 @@
"giftBackedUp": "적어 두었습니다",
"giftLink": "Bitcoin 선물하기",
"giftNotePlaceholder": "메시지 추가 (선택)",
+ "giftFromMe": "내가 보냈다고 알리기 (받는 사람이 나를 팔로우함)",
+ "giftFrom": "보낸 사람",
+ "giftClaimFollows": "받으면 보낸 사람을 팔로우합니다",
"giftLinkDesc": "금액을 링크로 미리 서명하세요. 링크를 여는 사람은 그 Bitcoin을 자신만 통제하는 완전히 새로운 지갑으로 받게 됩니다.",
"giftLinkWarn": "⚠ 이 링크를 여는 사람은 누구나 금액을 받을 수 있으니, 선물할 상대에게만 공유하세요. 금액은 적게 유지하세요.",
"giftLinkReveal": "선물 링크 만들기",
diff --git a/src/locales/mr.json b/src/locales/mr.json
index a603747..0d86d70 100644
--- a/src/locales/mr.json
+++ b/src/locales/mr.json
@@ -138,6 +138,9 @@
"giftBackedUp": "मी ते लिहून ठेवले आहे",
"giftLink": "थोडे Bitcoin भेट द्या",
"giftNotePlaceholder": "एक नोंद जोडा (पर्यायी)",
+ "giftFromMe": "हे माझ्याकडून आहे असे सांगा (दावा करणारे तुम्हाला फॉलो करतील)",
+ "giftFrom": "पाठवणारे",
+ "giftClaimFollows": "दावा केल्यावर तुम्ही त्यांना फॉलो कराल",
"giftLinkDesc": "एखादी रक्कम लिंक म्हणून आधीच स्वाक्षरित करा. ती उघडणारा कोणीही ते Bitcoin फक्त त्याच्याच नियंत्रणातील एका नव्या कोऱ्या वॉलेटमध्ये घेतो.",
"giftLinkWarn": "⚠ ही लिंक उघडणारा कोणीही ती रक्कम घेऊ शकतो, म्हणून ज्याला भेट देत आहात फक्त त्याच्यासोबतच ती शेअर करा. रक्कम कमी ठेवा.",
"giftAmountLabel": "भेट देण्याची रक्कम",
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 48496ee..ddbcdb8 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -365,6 +365,9 @@
"giftBackedUp": "Já anotei",
"giftLink": "Presentear Bitcoin",
"giftNotePlaceholder": "Adicione um recado (opcional)",
+ "giftFromMe": "Dizer que é meu (quem resgatar vai te seguir)",
+ "giftFrom": "De",
+ "giftClaimFollows": "Ao resgatar, você passa a seguir essa pessoa",
"giftLinkDesc": "Pré-assine um valor como um link. Quem o abrir resgata os Bitcoin para uma carteira novinha que só essa pessoa controla.",
"giftLinkWarn": "⚠ Qualquer pessoa que abrir este link pode resgatar o valor, então compartilhe-o apenas com a pessoa que você está presenteando. Mantenha os valores baixos.",
"giftLinkReveal": "Criar link de presente",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 6c0b003..a58be9a 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -365,6 +365,9 @@
"giftBackedUp": "Я записал(а)",
"giftLink": "Подарить немного Bitcoin",
"giftNotePlaceholder": "Добавьте записку (необязательно)",
+ "giftFromMe": "Указать, что это от меня (получатель подпишется на вас)",
+ "giftFrom": "От",
+ "giftClaimFollows": "Получив подарок, вы подпишетесь на отправителя",
"giftLinkDesc": "Заранее подпишите сумму в виде ссылки. Тот, кто откроет её, получит Bitcoin в совершенно новый кошелёк, которым управляет только он.",
"giftLinkWarn": "⚠ Любой, кто откроет эту ссылку, может получить эту сумму, поэтому делитесь ею только с тем, кому дарите. Держите суммы небольшими.",
"giftLinkReveal": "Создать ссылку-подарок",
diff --git a/src/locales/sw.json b/src/locales/sw.json
index 4ffba61..2749871 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -138,6 +138,9 @@
"giftBackedUp": "Nimeshakiandika",
"giftLink": "Toa Bitcoin kama zawadi",
"giftNotePlaceholder": "Ongeza ujumbe (si lazima)",
+ "giftFromMe": "Sema ni kutoka kwangu (atakayedai atakufuata)",
+ "giftFrom": "Kutoka kwa",
+ "giftClaimFollows": "Ukidai utamfuata",
"giftLinkDesc": "Tia saini kiasi mapema kama kiungo. Yeyote anayekifungua hudai Bitcoin kwenye pochi mpya kabisa anayoidhibiti yeye pekee.",
"giftLinkWarn": "⚠ Yeyote anayefungua kiungo hiki anaweza kudai kiasi hicho, kwa hivyo kishiriki tu na mtu unayempa zawadi. Weka kiasi kidogo.",
"giftAmountLabel": "Kiasi cha kutoa zawadi",
diff --git a/src/locales/ta.json b/src/locales/ta.json
index c9e6aae..65f405f 100644
--- a/src/locales/ta.json
+++ b/src/locales/ta.json
@@ -138,6 +138,9 @@
"giftBackedUp": "நான் எழுதி வைத்துவிட்டேன்",
"giftLink": "கொஞ்சம் Bitcoin பரிசளி",
"giftNotePlaceholder": "ஒரு குறிப்பைச் சேர்க்கவும் (விருப்பம்)",
+ "giftFromMe": "இது என்னிடமிருந்து என்று சொல் (பெறுபவர் உங்களைப் பின்தொடர்வார்)",
+ "giftFrom": "அனுப்பியவர்",
+ "giftClaimFollows": "பெற்றால் அவரைப் பின்தொடர்வீர்கள்",
"giftLinkDesc": "ஒரு தொகையை இணைப்பாக முன்கூட்டியே கையொப்பமிடுங்கள். இதைத் திறப்பவர், அவர்கள் மட்டுமே கட்டுப்படுத்தும் புத்தம்புதிய வாலட்டிற்குள் Bitcoin-ஐ உரிமை கொள்வார்கள்.",
"giftLinkWarn": "⚠ இந்த இணைப்பைத் திறக்கும் எவரும் தொகையை உரிமை கொள்ளலாம், எனவே நீங்கள் பரிசளிக்கும் நபருடன் மட்டுமே இதைப் பகிரவும். தொகையைக் குறைவாக வைத்திருங்கள்.",
"giftAmountLabel": "பரிசளிக்கும் தொகை",
diff --git a/src/locales/te.json b/src/locales/te.json
index 7a30efb..2ff97d3 100644
--- a/src/locales/te.json
+++ b/src/locales/te.json
@@ -138,6 +138,9 @@
"giftBackedUp": "నేను దాన్ని రాసుకున్నాను",
"giftLink": "కొంత Bitcoin బహుమతిగా ఇవ్వండి",
"giftNotePlaceholder": "ఒక సందేశం జోడించండి (ఐచ్ఛికం)",
+ "giftFromMe": "ఇది నా నుండి అని చెప్పు (క్లెయిమ్ చేసినవారు మిమ్మల్ని ఫాలో అవుతారు)",
+ "giftFrom": "పంపినవారు",
+ "giftClaimFollows": "క్లెయిమ్ చేస్తే వారిని ఫాలో అవుతారు",
"giftLinkDesc": "ఒక మొత్తాన్ని ముందుగా సంతకం చేసి లింక్‌గా ఇవ్వండి. దాన్ని తెరిచినవారు ఆ Bitcoinను తాము మాత్రమే నియంత్రించే సరికొత్త వాలెట్‌లోకి క్లెయిమ్ చేసుకుంటారు.",
"giftLinkWarn": "⚠ ఈ లింక్ తెరిచిన ఎవరైనా ఆ మొత్తాన్ని క్లెయిమ్ చేయగలరు, కాబట్టి మీరు బహుమతి ఇస్తున్న వ్యక్తికి మాత్రమే దీన్ని పంచుకోండి. మొత్తాలను తక్కువగా ఉంచండి.",
"giftAmountLabel": "బహుమతిగా ఇచ్చే మొత్తం",
diff --git a/src/locales/tr.json b/src/locales/tr.json
index a406b7b..d3538ef 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -364,6 +364,9 @@
"giftBackedUp": "Yazdım",
"giftLink": "Bitcoin hediye et",
"giftNotePlaceholder": "Not ekle (isteğe bağlı)",
+ "giftFromMe": "Benden olduğunu söyle (alan seni takip eder)",
+ "giftFrom": "Gönderen",
+ "giftClaimFollows": "Alınca gönderen kişiyi takip edersin",
"giftLinkDesc": "Bir tutarı önceden imzalayıp bağlantı olarak verin. Bağlantıyı açan, Bitcoin’i yalnızca kendi kontrolündeki yepyeni bir cüzdana alır.",
"giftLinkWarn": "⚠ Bu bağlantıyı açan herkes tutarı talep edebilir, bu yüzden yalnızca hediye ettiğiniz kişiyle paylaşın. Tutarları küçük tutun.",
"giftLinkReveal": "Hediye bağlantısı oluştur",
diff --git a/src/locales/ur.json b/src/locales/ur.json
index 1f35df9..f83299a 100644
--- a/src/locales/ur.json
+++ b/src/locales/ur.json
@@ -138,6 +138,9 @@
"giftBackedUp": "میں نے اسے لکھ لیا ہے",
"giftLink": "کچھ Bitcoin تحفے میں دیں",
"giftNotePlaceholder": "ایک نوٹ شامل کریں (اختیاری)",
+ "giftFromMe": "بتائیں کہ یہ میری طرف سے ہے (وصول کرنے والا آپ کو فالو کرے گا)",
+ "giftFrom": "بھیجنے والا",
+ "giftClaimFollows": "وصول کرنے پر آپ انہیں فالو کریں گے",
"giftLinkDesc": "ایک رقم کو بطور لنک پہلے سے دستخط کریں۔ جو بھی اسے کھولے وہ Bitcoin کو ایک بالکل نئے والیٹ میں وصول کر لیتا ہے جو صرف اسی کے کنٹرول میں ہوتا ہے۔",
"giftLinkWarn": "⚠ جو بھی یہ لنک کھولے وہ اس رقم کا دعویٰ کر سکتا ہے، اس لیے اسے صرف اسی شخص کے ساتھ شیئر کریں جسے آپ تحفہ دے رہے ہیں۔ رقم کم رکھیں۔",
"giftLinkReveal": "تحفہ لنک بنائیں",
diff --git a/src/locales/vi.json b/src/locales/vi.json
index 31f61ad..a24391b 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -138,6 +138,9 @@
"giftBackedUp": "Tôi đã ghi lại rồi",
"giftLink": "Tặng một ít Bitcoin",
"giftNotePlaceholder": "Thêm lời nhắn (không bắt buộc)",
+ "giftFromMe": "Cho biết là từ tôi (người nhận sẽ theo dõi bạn)",
+ "giftFrom": "Từ",
+ "giftClaimFollows": "Nhận quà sẽ theo dõi người gửi",
"giftLinkDesc": "Ký trước một số tiền thành một liên kết. Ai mở liên kết sẽ nhận số Bitcoin đó vào một ví hoàn toàn mới mà chỉ riêng họ kiểm soát.",
"giftLinkWarn": "⚠ Bất kỳ ai mở liên kết này đều có thể nhận số tiền, vì vậy chỉ chia sẻ với người bạn muốn tặng. Hãy giữ số tiền nhỏ.",
"giftAmountLabel": "Số tiền muốn tặng",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 1ed54fa..c75e801 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -365,6 +365,9 @@
"giftBackedUp": "我已经抄写下来了",
"giftLink": "赠送一些 Bitcoin",
"giftNotePlaceholder": "添加留言(可选)",
+ "giftFromMe": "显示是我送的(领取者会关注你)",
+ "giftFrom": "来自",
+ "giftClaimFollows": "领取后会关注对方",
"giftLinkDesc": "将一笔金额预签为一个链接。打开链接的人可将这笔 Bitcoin 领取到一个仅由他们自己掌控的全新钱包中。",
"giftLinkWarn": "⚠ 任何打开此链接的人都能领取这笔金额,所以请仅分享给你要赠送的对象。请保持金额较小。",
"giftLinkReveal": "创建赠送链接",
diff --git a/tools/gift-from-test.js b/tools/gift-from-test.js
new file mode 100644
index 0000000..a0ffaf8
--- /dev/null
+++ b/tools/gift-from-test.js
@@ -0,0 +1,71 @@
+// A gift link can name its sender: #f=<base32 pubkey|created_at|sig>, a
+// nostr signature over the gift's code. The claim screen shows who it's
+// from (and that claiming follows them); a link whose signature doesn't
+// match its code — passed on as someone else's gift — shows no sender.
+// Run: bun run build && bun tools/gift-from-test.js
+import puppeteer from './puppeteer.js';
+import { base32nopad } from '@scure/base';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const server = Bun.serve({ port: 0, async fetch(req) {
+ const p = new URL(req.url).pathname;
+ const f = Bun.file('dist' + (p === '/' ? '/index.html' : p));
+ return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } });
+} });
+const giftCode = () => {
+ const b = new Uint8Array(42);
+ b[0] = 0x11; b[1] = 3; // ark gift, mutinynet
+ new DataView(b.buffer).setBigUint64(2, 1000n, true);
+ b.set(crypto.getRandomValues(new Uint8Array(32)), 10);
+ return base32nopad.encode(b);
+};
+const sk = generateSecretKey();
+const fromFrag = (code) => {
+ const ev = finalizeEvent({ kind: 27121, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['x', bytesToHex(sha256(new TextEncoder().encode(code.toUpperCase())))]] }, sk);
+ 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);
+};
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const text = (page) => page.evaluate(() => document.body.innerText);
+const waitText = async (page, re, ms = 30000) => {
+ for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); }
+ return false;
+};
+async function open(path) {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); });
+ await page.goto(server.url.href + path);
+ await waitText(page, /aren.t visible/i);
+ await sleep(500);
+ return page;
+}
+try {
+ const code = giftCode();
+ let page = await open(`ag/${code}#m=hi&f=${fromFrag(code)}`);
+ let tx = await text(page);
+ check('a signed link names its sender', /\bFrom\b/.test(tx) && /Claiming it follows them/.test(tx), tx.slice(0, 300).replace(/\n/g, ' | '));
+ check('the note still shows', /\bhi\b/.test(tx));
+ await page.close();
+ // an upper-case key, as a scanner may hand it over
+ page = await open(`ag/${code}#F=${fromFrag(code)}`);
+ { const tx = await text(page); check("an upper-cased link still names its sender", /Claiming it follows them/.test(tx), tx.slice(0, 200).replace(/\n/g, " | ")); }
+ await page.close();
+ // the same signature on a different gift: no sender
+ page = await open(`ag/${giftCode()}#f=${fromFrag(code)}`);
+ check('a signature over another code names nobody', !/Claiming it follows them/.test(await text(page)));
+ await page.close();
+} finally {
+ await browser.close();
+ server.stop();
+}
+console.log(ok ? 'PASS' : 'FAIL');
+process.exit(ok ? 0 : 1);