From 0fbeb3c55413fb221d890b3bf3464ebd2aed9fa1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 28 Jun 2026 09:31:51 -0700 Subject: Locked gifts: let NIP-07 extension users decrypt in-place, skip the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gift payload is now encrypted two ways in the link: under the one-time code (the DM / manual path) AND to the recipient's nostr pubkey via an ephemeral key. When a NIP-07 browser extension (window.nostr) is present, the claim view offers 'Claim with nostr extension' — it checks the extension's pubkey matches the gift, decrypts ctKey via window.nostr.nip44.decrypt, and claims into a fresh wallet with no code. The pasted-code path remains as the fallback for everyone else. Verified: both decrypt paths recover the payload from the blob; a different extension account is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index 84cec51..803a82b 100644 --- a/src/app.js +++ b/src/app.js @@ -1848,6 +1848,9 @@ function readLockedGiftHash() { // 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')), @@ -1855,16 +1858,33 @@ function lockedGiftClaimView() { h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockedTo')), profileChip(lk.to, { size: 36 })), - h('p', { class: 'small muted', style: 'text-align:center;margin:0' }, t('giftCodeHint')), + 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; } }), - ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - h('button', { class: 'btn-primary btn-block', onClick: submitLockedCode }, t('claimGift')), + 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 = ''; + enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim + } catch { + ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); + } +} function submitLockedCode() { const code = (ui.claimCodeInput || '').trim(); if (!code) return; -- cgit v1.2.3