diff options
| -rw-r--r-- | src/app.js | 144 | ||||
| -rw-r--r-- | src/i18n.js | 18 | ||||
| -rw-r--r-- | src/nostr.js | 70 | ||||
| -rw-r--r-- | src/wallet.js | 32 |
4 files changed, 251 insertions, 13 deletions
@@ -4,10 +4,10 @@ // which rebuilds the active screen. Text inputs write back into `ui` on `input` // (without re-rendering) so their values survive structural re-renders. -import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; +import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault, lockGift, previewLockedGift } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; -import { getSyncConfig, setSyncConfig } from './nostr.js'; +import { getSyncConfig, setSyncConfig, parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from './nostr.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from './api.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; @@ -50,6 +50,12 @@ const ui = { pubkeyShown: false, // account public key revealed in Settings giftMode: false, // gift sub-view active on the Send page giftAmount: '', // gift-create amount input + giftLockNpub: '', // optional: lock the gift to this recipient nostr npub + giftLocked: false, // the just-created gift is locked to a nostr key + giftClaimCode: null, // the one-time claim code DM'd to the recipient (fallback) + giftDmStatus: null, // 'sending' | 'sent' | 'failed' — nostr DM of the claim code + claimLocked: null, // a locked gift being opened: { v, amount, to, ct } + claimCodeInput: '', // the claim code the recipient pastes from their nostr DM giftCode: null, // last-created gift PSBT code giftError: '', giftMax: false, // gift the whole spendable balance (no-change sweep) @@ -252,7 +258,9 @@ function render() { try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {} } const screen = - ui.screen === 'wallet' + ui.claimLocked + ? lockedGiftClaimView() + : ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' ? accountsScreen() @@ -387,6 +395,30 @@ function download(filename, text, mime = 'application/json') { setTimeout(() => URL.revokeObjectURL(url), 1000); } +// --- nostr profiles (avatar + name) — cached, lazily fetched, re-renders ---- +const _profileCache = new Map(); // pubkeyHex -> profile | null | 'loading' +function nostrProfile(pkHex) { + if (_profileCache.has(pkHex)) return _profileCache.get(pkHex); + _profileCache.set(pkHex, 'loading'); + fetchNostrProfile(pkHex).then((p) => { _profileCache.set(pkHex, p || null); render(); }).catch(() => { _profileCache.set(pkHex, null); render(); }); + return 'loading'; +} +// A row showing the recipient's avatar + name (or a shortened npub fallback). +function profileChip(pkHex, { size = 30 } = {}) { + const p = nostrProfile(pkHex); + const npub = npubOf(pkHex) || pkHex; + const short = npub.slice(0, 12) + '…' + npub.slice(-4); + const avatar = (pic) => + pic + ? h('img', { src: pic, style: `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;flex:0 0 auto`, onError: (e) => { e.target.style.visibility = 'hidden'; } }) + : h('div', { style: `width:${size}px;height:${size}px;border-radius:50%;background:#9993;flex:0 0 auto` }); + if (p === 'loading') return h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, short)); + const name = (p && p.name) || short; + return h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(p && p.picture), + h('span', { class: p && p.name ? '' : 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, name)); +} + function copyBtn(text, label = t('copy')) { return h('button', { class: 'btn-sm', onClick: () => copy(text) }, label); } @@ -1406,7 +1438,7 @@ function settingsTab() { // 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}/g/${ui.giftCode}`; + return `${location.origin}/${ui.giftLocked ? 'lg' : 'g'}/${ui.giftCode}`; } function giftRate() { return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; @@ -1442,9 +1474,22 @@ function createGiftLink() { } doCreateGift(amt, rate); } +// Finalize a freshly-built gift. With a recipient npub it's a locked gift: the +// claim payload is encrypted under a one-time code, the link carries only the +// ciphertext, and the code is DM'd to the recipient over nostr. Without, it's a +// plain bearer gift. +function finishGift(g, fallbackAmt) { + 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); + 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(); }); +} function doCreateGift(amt, rate) { try { - ui.giftCode = wallet.createGift(amt, rate).code; + finishGift(wallet.createGift(amt, rate), amt); ui.giftError = ''; ui.giftSplitOffer = null; } catch (e) { @@ -1457,7 +1502,7 @@ function doCreateGift(amt, rate) { // everything minus their claim fee). function doCreateGiftAll(rate) { try { - ui.giftCode = wallet.createGiftAll(rate).code; + finishGift(wallet.createGiftAll(rate)); ui.giftError = ''; ui.giftMax = false; ui.giftSplitOffer = null; @@ -1475,7 +1520,7 @@ async function doSplitForGift(amt) { ui.busy = true; ui.giftError = ''; render(); try { const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - ui.giftCode = (await wallet.createGiftFromSplit(amt, rate)).code; + finishGift(await wallet.createGiftFromSplit(amt, rate), amt); ui.giftSplitOffer = null; ui.giftError = ''; } catch (e) { @@ -1493,14 +1538,23 @@ function giftCard() { h('h3', {}, t('giftLink')), ui.giftCode ? h('div', { class: 'col', style: 'align-items:center;gap:10px' }, - // Uppercase the whole URL + alphanumeric mode → much smaller QR. The - // domain is case-insensitive and the base32 code is already uppercase; - // the opener parses /g/ and the code case-insensitively. - h('div', { html: qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), + ui.giftLocked && (() => { + const lk = previewLockedGift(ui.giftCode); + if (!lk) return null; + return h('div', { class: 'col gap6', style: 'width:100%;align-items:center' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockedTo')), profileChip(lk.to)), + ui.giftDmStatus === 'sent' ? h('div', { class: 'small', style: 'color:var(--ok,#2a8)' }, t('giftDmSent')) + : ui.giftDmStatus === 'failed' ? h('div', { class: 'col gap6', style: 'align-items:center' }, h('div', { class: 'small err' }, t('giftDmFailed')), ui.giftClaimCode ? copyBtn(ui.giftClaimCode, t('giftCopyCode')) : null) + : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending')))); + })(), + // A bearer gift uppercases the URL for a smaller alphanumeric QR (the + // base32 code is case-insensitive). A locked gift's payload is + // case-sensitive base64url, so it uses a plain byte-mode QR. + h('div', { html: ui.giftLocked ? qrSvg(giftUrl()) : qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), 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.giftMax = false; render(); } }, t('giftAnother')) + h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftLockNpub = ''; ui.giftLocked = false; ui.giftClaimCode = null; ui.giftDmStatus = null; render(); } }, t('giftAnother')) ) ) : ui.giftSplitOffer @@ -1530,6 +1584,17 @@ function giftCard() { h('div', { style: 'display:flex;align-items:center' }, unitTag()) ), h('div', { class: 'small faint' }, ui.giftMax ? t('giftAllNote') : t('giftMinNote', { n: fmtAmount(giftMinimum(giftRate())) + ' ' + unitLabel() })), + (() => { + const raw = ui.giftLockNpub.trim(); + const pk = raw ? parseNostrPubkey(raw) : null; + return h('div', { class: 'col gap6' }, + h('input', { type: 'text', class: 'mono-input', placeholder: t('giftLockPlaceholder'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.giftLockNpub, + onInput: (e) => { ui.giftLockNpub = e.target.value; render(); } }), + !raw ? h('div', { class: 'small faint' }, t('giftLockHint')) + : pk ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'small muted' }, t('giftLockTo')), profileChip(pk)) + : h('div', { class: 'small err' }, t('giftLockInvalid'))); + })(), ui.giftError && h('div', { class: 'notice err' }, ui.giftError), h('button', { class: 'btn-block', onClick: createGiftLink }, t('giftLinkReveal')) ), @@ -1764,6 +1829,52 @@ function readGiftHash() { } } +// A gift locked to a nostr key: /lg/<base64url>. Returns the public preview +// ({ amount, to, eph, ct }) — claimed by the recipient's own wallet, not a fresh +// one, so we don't auto-create a wallet here. +function readLockedGiftHash() { + try { + const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/); + if (!m) return null; + history.replaceState(null, '', '/'); + return previewLockedGift(m[1]); + } catch { + return null; + } +} + +// 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. +function lockedGiftClaimView() { + const lk = ui.claimLocked; + 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')), + h('div', { class: 'amt' }, fmtAmount(lk.amount), ' ', unitTag()), + 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')), + 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: 'btn-ghost btn-block', onClick: () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); } }, t('dismiss')) + ) + ); +} +function submitLockedCode() { + const code = (ui.claimCodeInput || '').trim(); + if (!code) return; + let payload = null; + try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {} + if (!payload || !previewGift(payload)) { ui.claimError = t('claimCodeWrong'); render(); return; } + ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; + enterWallet(newMnemonic(), '', { gift: payload }); // fresh-wallet claim, like a normal gift +} + // Has this gift already been claimed? Its funding coin being spent — even by an // unconfirmed claim — means the gift is gone; show the "already claimed" screen // rather than let the user attempt a doomed double-spend. @@ -2107,6 +2218,14 @@ function accountSettingsScreen() { h('button', { class: 'btn-sm grow', onClick: () => { ui.pubkeyShown = false; render(); } }, t('hide')))) : h('button', { class: 'btn-block', onClick: () => { ui.pubkeyShown = true; render(); } }, t('showPublicKey')) ), + // Nostr address — share it so others can send you locked gifts. + (a.id === activeId && wallet.nostrNpub()) + ? h('div', { class: 'card col' }, + h('h3', {}, t('nostrKeyTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('nostrKeyDesc')), + h('div', { class: 'addr-box break', style: 'font-size:12px' }, wallet.nostrNpub()), + copyBtn(wallet.nostrNpub(), t('copyKey'))) + : null, // Auto-logout (bottom) h('div', { class: 'card col' }, h('h3', {}, t('autolockTitle')), @@ -3242,6 +3361,7 @@ document.addEventListener('visibilitychange', () => { loadLocale(getLang()).finally(() => { const code = readGiftHash(); if (code) { enterWallet(newMnemonic(), '', { gift: code }); return; } + ui.claimLocked = readLockedGiftHash(); // locked gift → claim with your own wallet applyBootAutoLogout(); // clear an overdue session before it's restored if (!restoreAccountsState()) render(); }); diff --git a/src/i18n.js b/src/i18n.js index 5b9f1e8..48dcef0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -224,6 +224,24 @@ const STR = { giftExceedsBalance: 'That’s more than your balance.', giftNeedsHeadroom: 'Gift up to {n}, or tap Max to gift your whole balance.', giftAnother: 'Create another', + giftLockPlaceholder: 'Lock to npub (optional)', + giftLockHint: 'Optionally lock this gift to a nostr account: the link stays public, but only they get the claim code — sent to them as a nostr DM.', + giftLockTo: 'For', + giftLockInvalid: 'Not a valid npub or hex pubkey.', + giftLockedTo: 'For', + giftForYou: 'A gift for you', + claimGift: 'Claim', + dismiss: 'Dismiss', + giftCodeHint: 'Check your nostr direct messages for the claim code, then paste it below.', + claimCodePlaceholder: 'Paste claim code', + claimCodeWrong: 'That code didn’t work — check your nostr DM and try again.', + giftDmText: "🎁 You've received a Bitcoin gift of {amount}! Claim it here: {link}\n\nClaim code: {code}", + giftDmSent: 'Claim code sent to them via nostr DM.', + giftDmSending: 'Sending the claim code…', + giftDmFailed: 'Couldn’t send the nostr DM. Share this code with them privately:', + giftCopyCode: 'Copy claim 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.', giftSplitFirst: 'Split a coin first', giftLockWhole: 'Lock the whole coin ({n})', diff --git a/src/nostr.js b/src/nostr.js index 74d8d25..02e37a4 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -11,8 +11,64 @@ 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 { decode as nip19decode, npubEncode } from 'nostr-tools/nip19'; import { Relay } from 'nostr-tools/relay'; +import { randomBytes } from '@noble/hashes/utils'; +import { base64urlnopad } from '@scure/base'; + +// --- locking a gift to a recipient's Nostr key --------------------------- +// An npub or 64-hex nostr pubkey → hex, or null if it isn't one. +export function parseNostrPubkey(input) { + const s = (input || '').trim(); + if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase(); + try { const d = nip19decode(s); if (d.type === 'npub') return d.data; } catch {} + return null; +} +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) }; +} +export function decryptWithCode(code, ct) { + return nip44.decrypt(ct, base64urlnopad.decode((code || '').trim())); +} + +// Profiles live across the network, so look them up on popular relays (broader +// than the sync relays). +export const PROFILE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.coinos.io']; +// Fetch a recipient's profile (name + picture) for a pubkey, newest across relays. +export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { + const results = await Promise.allSettled(relays.map((url) => _fetchProfileFrom(url, pubkeyHex))); + let best = null; + for (const r of results) { + const v = r.status === 'fulfilled' ? r.value : null; + if (v && (!best || (v.created_at || 0) > (best.created_at || 0))) best = v; + } + if (!best) return null; + try { + const m = JSON.parse(best.content); + return { name: m.display_name || m.name || null, picture: m.picture || null, nip05: m.nip05 || null }; + } catch { return null; } +} +async function _fetchProfileFrom(url, pk) { + let relay; + try { relay = await Relay.connect(url); } catch { return null; } + return new Promise((resolve) => { + let ev = null; + const done = () => { try { sub.close(); } catch {} try { relay.close(); } catch {} resolve(ev); }; + const sub = relay.subscribe([{ kinds: [0], authors: [pk], limit: 1 }], { + onevent: (e) => { if (!ev || e.created_at > ev.created_at) ev = e; }, + oneose: done, + }); + setTimeout(done, 5000); + }); +} const DTAG = 'bitcoin-wallet'; const SYNC_KEY = 'btc-wallet-sync'; @@ -112,6 +168,20 @@ export class NostrSync { ); } + // Send an encrypted DM (NIP-04 kind 4) — used to deliver a locked gift's claim + // code. Best-effort across the broad relay set so the recipient's client sees + // it. Returns true if at least one relay accepted it. + async sendDM(recipientPkHex, text, relays = PROFILE_RELAYS) { + if (!this.sk) return false; + let evt; + try { + const content = await nip04.encrypt(this.sk, recipientPkHex, text); + evt = finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPkHex]], content }, this.sk); + } catch { return false; } + const res = await Promise.allSettled((relays || PROFILE_RELAYS).map(async (url) => { const r = await this._connect(url); await r.publish(evt); })); + return res.some((x) => x.status === 'fulfilled'); + } + // Fetch from every relay; return the newest decrypted state, or null. async fetch() { if (!this.sk) return null; diff --git a/src/wallet.js b/src/wallet.js index dbb9b73..b2ff3e3 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 } from './nostr.js'; +import { NostrSync, getSyncConfig, encryptWithCode, npubOf } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; @@ -1628,6 +1628,14 @@ export class Wallet { return this._buildGiftPsbt(sel, gift); } + // Our nostr identity (used to DM a locked gift's claim code to the recipient). + nostrPubkey() { return (this.nostr && this.nostr.pk) || null; } + nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; } + // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey. + async sendNostrDM(recipientPkHex, text) { + return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false; + } + // Split + gift in one shot, with no confirmation wait: broadcast a self-send // that carves out a gift+dust coin, then build the gift spending that // still-unconfirmed carve-out. The funding and the claim form a chain that @@ -2562,6 +2570,28 @@ export function buildClaimTx(code, toAddress, feeRate, net) { return { hex: tx.hex, txid: tx.id, amount: Number(out), fee: Number(fee) }; } +// A gift locked to a recipient's nostr key. The amount + recipient pubkey are +// public (anyone can see what it is and who it's for), but the claim payload (the +// gift code) is NIP-44-encrypted to the recipient via an ephemeral key — so the +// link can be shared openly yet only the recipient's nostr key can claim it. +const LOCKED_GIFT_VERSION = 1; +// Encrypt the gift's claim payload under a one-time code. The code is delivered +// 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 }))); + return { blob, claimCode }; +} +// The public fields of a locked-gift blob (or null). Decryption needs the code. +export function previewLockedGift(blob) { + try { + const o = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(blob))); + if (o.v === LOCKED_GIFT_VERSION && typeof o.amount === 'number' && o.to && o.ct) return o; + } catch {} + return null; +} + function firstUnused(chain) { const u = chain.find((a) => !a.used); return u ? u.index : chain.length; |
