aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js26
-rw-r--r--src/i18n.js5
-rw-r--r--src/nostr.js21
-rw-r--r--src/wallet.js6
4 files changed, 45 insertions, 13 deletions
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;
diff --git a/src/i18n.js b/src/i18n.js
index 48dcef0..31cc3c9 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -240,6 +240,11 @@ const STR = {
giftDmSending: 'Sending the claim code…',
giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:',
giftCopyCode: 'Copy claim code',
+ claimWithExtension: 'Claim with nostr extension',
+ orEnterCode: 'or paste the claim code',
+ extWrongAccount: 'Your extension is signed in as a different nostr account than this gift is for.',
+ extNoNip44: 'Your nostr extension doesn’t support NIP-44 — paste the code instead.',
+ extDecryptFailed: 'Couldn’t decrypt with the extension — try the code.',
nostrKeyTitle: 'Nostr address',
nostrKeyDesc: 'Share this so others can lock a gift to you — the claim code is sent here as a nostr DM.',
giftSplitExplain: 'This gift would lock {lock} until it’s claimed ({change} of it comes back to you then). You can split a right-sized coin first for about {fee} so only the gift amount stays locked.',
diff --git a/src/nostr.js b/src/nostr.js
index 378cec9..0ebfe92 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -12,7 +12,7 @@
import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
import * as nip04 from 'nostr-tools/nip04';
-import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19';
import { Relay } from 'nostr-tools/relay';
import { randomBytes } from '@noble/hashes/utils';
@@ -28,12 +28,19 @@ export function parseNostrPubkey(input) {
}
export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } }
-// Encrypt a payload under a fresh one-time code (32 random bytes → base64url).
-// The code travels to the recipient out-of-band (a nostr DM), so the public link
-// holding the ciphertext can't be claimed without it. Returns { code, ct }.
-export function encryptWithCode(plaintext) {
- const key = randomBytes(32);
- return { code: base64urlnopad.encode(key), ct: nip44.encrypt(plaintext, key) };
+// Encrypt a gift payload two ways: (1) under a fresh one-time code, delivered to
+// the recipient out-of-band via a nostr DM — the manual path; and (2) to the
+// recipient's nostr pubkey via an ephemeral key, so a NIP-07 browser extension
+// can decrypt it in-place with no code. Both decrypt to the same payload.
+export function encryptGiftPayload(plaintext, recipientPkHex) {
+ const codeKey = randomBytes(32);
+ const ephSk = generateSecretKey();
+ return {
+ code: base64urlnopad.encode(codeKey),
+ ctCode: nip44.encrypt(plaintext, codeKey),
+ eph: getPublicKey(ephSk),
+ ctKey: nip44.encrypt(plaintext, nip44.getConversationKey(ephSk, recipientPkHex)),
+ };
}
export function decryptWithCode(code, ct) {
return nip44.decrypt(ct, base64urlnopad.decode((code || '').trim()));
diff --git a/src/wallet.js b/src/wallet.js
index b2ff3e3..e2b5b35 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -23,7 +23,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js';
import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js';
import { ElectrumApi } from './electrum.js';
-import { NostrSync, getSyncConfig, encryptWithCode, npubOf } from './nostr.js';
+import { NostrSync, getSyncConfig, encryptGiftPayload, npubOf } from './nostr.js';
import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js';
import { schnorr } from '@noble/curves/secp256k1';
@@ -2579,8 +2579,8 @@ const LOCKED_GIFT_VERSION = 1;
// to the recipient via a nostr DM; the public link carries only the ciphertext.
// Returns { blob (for the /lg/ link), claimCode (to DM) }.
export function lockGift(code, amount, recipientPkHex) {
- const { code: claimCode, ct } = encryptWithCode(code);
- const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct })));
+ const { code: claimCode, ctCode, eph, ctKey } = encryptGiftPayload(code, recipientPkHex);
+ const blob = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify({ v: LOCKED_GIFT_VERSION, amount, to: recipientPkHex, ct: ctCode, eph, ctKey })));
return { blob, claimCode };
}
// The public fields of a locked-gift blob (or null). Decryption needs the code.