From 19c14131ce3fc69a06d3c0af8d445b056d703bf9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 8 Aug 2026 08:21:11 -0700 Subject: Profiles wear their latest notes, each one zappable; a zap with no ark address to land on becomes a gift only their key can open Co-Authored-By: Claude Fable 5 --- src/app.js | 2 + src/features/ark.js | 163 +++++++++++++++++++++++++++++++++---------- src/features/gifts-wallet.js | 22 ++++++ src/features/gifts.js | 86 ++++++++++++++++++++++- src/features/messages.js | 80 +++++++++++++++++++++ src/features/sync.js | 3 + src/features/zaps.js | 13 ++-- src/i18n.js | 20 ++++++ src/nostr.js | 36 ++++++++++ src/style.css | 3 + tools/locked-gift-test.js | 59 ++++++++++++++++ tools/profile-feed-test.js | 67 ++++++++++++++++++ 12 files changed, 509 insertions(+), 45 deletions(-) create mode 100644 tools/locked-gift-test.js create mode 100644 tools/profile-feed-test.js diff --git a/src/app.js b/src/app.js index d4ac31a..921717d 100644 --- a/src/app.js +++ b/src/app.js @@ -87,6 +87,8 @@ const ui = { giftSplitOffer: null, // { amt, lock, freed, fee } when offering to split a coin first revokeId: null, // outpoint of a gift being revoked (confirm state) claimCode: null, // gift code being claimed (opened from a #gift= link) + claimLocked: null, // npub-locked gift blob opened from a /lg/ link (public fields) + claimCodeInput: '', // one-time claim code typed on the locked-gift screen claimedAmount: 0, claimError: '', offlineFallback: false, // auto-entered offline because the network was unreachable diff --git a/src/features/ark.js b/src/features/ark.js index 1a5a2a2..54bd32b 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -885,8 +885,9 @@ export function arkFeature(ctx) { // 2. lightning: profile lud16/lud06, via the zap flow // 3. an on-chain address from the BIP-353 record const NAMES_REGISTRAR = 'https://names.coinos.io'; - function startNpubPay(pk, npub) { - const z = (ui.arkZap = { npub, pk, amount: '', comment: '', status: 'lookup' }); + function startNpubPay(pk, npub, eventId = null) { + const z = (ui.arkZap = { npub, pk, eventId, amount: '', comment: '', status: 'lookup' }); + ui.zap = null; // a stale Lightning-zap card must not resurface behind ours ui.sendError = ''; render(); const live = () => ui.arkZap === z; @@ -925,7 +926,7 @@ export function arkFeature(ctx) { if (profile && (profile.lud16 || profile.lud06)) { await connectArk().catch(() => {}); if (!live()) return; - if (ctx.hook('canLnZap')) { ctx.hook('lnZapNpub', pk, npub); return; } + if (ctx.hook('canLnZap')) { ctx.hook('lnZapNpub', pk, npub, z.eventId); return; } } // 3. on-chain fallback if (onchain) { @@ -979,7 +980,8 @@ export function arkFeature(ctx) { await wallet.nostrPublish({ kind: ARK_ZAP_KIND, content: (z.comment || '').slice(0, 280), - tags: [['p', z.pk], ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + ['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()]], }).catch(() => {}); ui.arkZapped = { amountSat: sats, npub: z.npub }; ui.arkZap = null; @@ -989,51 +991,121 @@ export function arkFeature(ctx) { ui.busy = false; render(); } + // No ark address published: the zap leaves anyway, as an ark gift locked to + // their nostr key. The sats move into a bearer vtxo now; the public link is + // safe to put in the receipt because only the recipient's key — or the + // claim code we DM them — opens it. Unclaimed, it stays revocable from the + // gift card like any other ark gift. + async function doArkZapGift() { + const z = ui.arkZap; + const sats = ctx.parseAmount(z.amount, ctx.getUnit()); + if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; } + if (sats > (arkBalance()?.spendableSat || 0)) { ui.sendError = t('giftExceedsBalance'); render(); return; } + ui.busy = true; ui.sendError = ''; render(); + try { + const g = await createArkGift(sats, z.pk); + const locked = ctx.hook('lockArkGift', g.code, sats, z.pk); + if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build + noteZap('to:' + g.address, z.pk); + const done = (ui.arkZapped = { amountSat: sats, npub: z.npub, gift: { url: locked.url, claimCode: locked.claimCode, dm: 'sending' } }); + ui.arkZap = null; + // best-effort receipt; carries the locked link so the recipient can + // discover the gift from the note even if the DM never lands + await wallet.nostrPublish({ + kind: ARK_ZAP_KIND, + content: (z.comment || '').slice(0, 280), + tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), + ['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url]], + }).catch(() => {}); + const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode }); + if (wallet.sendNostrDM) { + wallet.sendNostrDM(z.pk, dmText) + .then((ok) => { done.gift.dm = ok ? 'sent' : 'failed'; render(); }) + .catch(() => { done.gift.dm = 'failed'; render(); }); + } else done.gift.dm = 'failed'; + } catch (e) { + ui.sendError = e.message; + } + ui.busy = false; render(); + } + function arkZapView() { // While resolving, show the SHAPE of the card that's coming — same // title, same recipient chip, a spinner where the form will be. The // screen fills in rather than being replaced. if (ui.arkZap && ui.arkZap.status === 'lookup') return zapSkeleton(ui.arkZap.pk, ui.arkZap.npub); if (ui.arkZapped) { - return h('div', { - class: 'card col', - style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', - onClick: () => { ui.arkZapped = null; ui.send = blankSend(); render(); }, - }, - h('div', { class: 'check-badge' }, '⚡'), - h('h2', { style: 'margin:0' }, t('arkZapSentTitle')), + const gift = ui.arkZapped.gift; + const finish = () => { ui.arkZapped = null; ui.send = blankSend(); render(); }; + if (!gift) { + return h('div', { + class: 'card col', + style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', + onClick: finish, + }, + h('div', { class: 'check-badge' }, '⚡'), + h('h2', { style: 'margin:0' }, t('arkZapSentTitle')), + h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), + h('div', { class: 'small muted' }, t('tapToProceed'))); + } + // A locked-gift zap has aftercare: the DM's fate, and the link + claim + // code to pass along by hand when the DM couldn't be delivered. + return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px;padding:28px 16px' }, + h('div', { class: 'check-badge' }, '🎁'), + h('h2', { style: 'margin:0' }, t('arkZapGiftSentTitle')), h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()), - h('div', { class: 'small muted' }, t('tapToProceed'))); + gift.dm === 'sending' + ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending'))) + : gift.dm === 'sent' + ? h('div', { class: 'small', style: 'color:var(--green)' }, t('giftDmSent')) + : h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, gift.url), + h('div', { class: 'row gap6 wrap', style: 'justify-content:center' }, + copyBtn(gift.url, t('copyLink')), + gift.dm !== 'sent' ? copyBtn(gift.claimCode, t('giftCopyCode')) : null), + h('button', { class: 'btn-primary btn-block', onClick: finish }, t('done'))); } const z = ui.arkZap; if (!z) return null; + const spendable = arkBalance()?.spendableSat || 0; + // No ark address found — but a zap can still leave as an ark gift locked + // to their nostr key, when this build carries the gifts feature. + const giftOk = z.status === 'noark' && !wallet.watchOnly && spendable >= 1 && !!ctx.hook('canLockGift'); + const amountInputs = (hint) => h('div', { class: 'col gap6' }, + h('div', { class: 'input-group' }, + h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount, + onInput: (e) => { z.amount = e.target.value; } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())), + h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment, + onInput: (e) => { z.comment = e.target.value; } }), + h('div', { class: 'small faint' }, hint)); return h('div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, '⚡ ' + t('zapTitle')), ctx.hook('profileChip', z.pk, 'lg') || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.npub), z.status === 'lookup' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkZapLookup'))) : null, - z.status === 'noark' ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null, + z.status === 'noark' && !giftOk ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null, + giftOk ? h('div', { class: 'notice info' }, t('arkZapNoArkGift')) : null, + giftOk ? amountInputs(t('arkZapGiftHint')) : null, + giftOk + ? (ui.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) + : h('button', { class: 'btn-primary btn-block', onClick: doArkZapGift }, '🎁 ' + t('arkZapGiftBtn'))) + : null, + z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, // No Ark address, but they may still take a Lightning zap — hand off to // the zaps feature (present alongside swaps). (z.status === 'noark' || z.status === 'wrongnet') && ctx.hook('canLnZap') - ? h('button', { class: 'btn-primary btn-block', onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub); } }, '⚡ ' + t('lnZapFallback')) + ? h('button', { class: (giftOk ? '' : 'btn-primary ') + 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback')) : null, - z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, - z.status === 'ready' && (arkBalance()?.spendableSat || 0) < 330 + z.status === 'ready' && spendable < 330 ? h('div', { class: 'notice info' }, t('zapNoBalance')) : z.status === 'ready' - ? h('div', { class: 'col gap6' }, - h('div', { class: 'input-group' }, - h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount, - onInput: (e) => { z.amount = e.target.value; } }), - h('div', { style: 'display:flex;align-items:center' }, unitTag())), - h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment, - onInput: (e) => { z.comment = e.target.value; } }), - h('div', { class: 'small faint' }, t('arkZapHint'))) + ? amountInputs(t('arkZapHint')) : null, ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: () => { ui.arkZap = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')), - z.status === 'ready' && (arkBalance()?.spendableSat || 0) >= 330 + z.status === 'ready' && spendable >= 330 ? (ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary grow', onClick: doArkZap }, t('arkZapBtn'))) @@ -1510,6 +1582,24 @@ export function arkFeature(ctx) { })).then((flags) => { if (flags.some(Boolean)) ark._save(); }); } + // Fund a fresh bearer gift identity. Shared by the gifts feature's hook and + // the zap flow's locked-gift fallback; `lockedTo` records who a locked gift + // was for (the sender can still revoke either kind while unclaimed). + async function createArkGift(amountSat, lockedTo = null) { + const mgr = await connectArk(); + const secret = crypto.getRandomValues(new Uint8Array(32)); + const secretHex = hex.encode(secret); + const gm = await giftManager(secretHex); + const address = gm.address(); + const actionId = await mgr.send(address, amountSat); + const action = mgr.state.actions.find((a) => a.id === actionId); + if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); + const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; + arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}) }); + mgr._save(); + return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address }; + } + // Sweep the gift identity's balance to `destAddress`. Shared by claim // (recipient's address) and revoke (sender's own address). async function sweepArkGift(code, destAddress) { @@ -1962,6 +2052,15 @@ export function arkFeature(ctx) { startNpubPay(pk, String(text).trim()); return true; }, + // Zap a person — optionally a specific note of theirs (the profile feed's + // ⚡ button) — through the same resolution ladder as a pasted npub. The + // event id rides into the receipt's e tag; callers fall back to the + // lnZapNpub hook when ark can't serve this build/wallet. + zapNpub(pk, npub, eventId) { + if (!arkAvailable() || !wallet.nostrFetch) return false; + startNpubPay(pk, npub, eventId || null); + return true; + }, historyEntries() { const s = arkStateNow(); if (!s) return []; @@ -2062,19 +2161,7 @@ export function arkFeature(ctx) { setNetwork(g.net); return true; }, - async arkGiftCreate(amountSat) { - const mgr = await connectArk(); - const secret = crypto.getRandomValues(new Uint8Array(32)); - const secretHex = hex.encode(secret); - const gm = await giftManager(secretHex); - const actionId = await mgr.send(gm.address(), amountSat); - const action = mgr.state.actions.find((a) => a.id === actionId); - if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed')); - const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id; - arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false }); - mgr._save(); - return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat }; - }, + arkGiftCreate(amountSat) { return createArkGift(amountSat); }, // Claim-side status: claimable (with the live amount), taken, wrongnet, or // unknown (not visible here — wrong server or not yet delivered). async arkGiftStatus(code) { diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js index bf8a82d..c25e85a 100644 --- a/src/features/gifts-wallet.js +++ b/src/features/gifts-wallet.js @@ -5,6 +5,7 @@ import * as btc from '@scure/btc-signer'; import { hex, base64urlnopad, base32nopad } from '@scure/base'; +import { encryptGiftPayload } from '../nostr.js'; import { utxoId } from '../wallet.js'; import { p2wpkh } from '@scure/btc-signer/payment'; import { concatBytes } from '@noble/hashes/utils'; @@ -467,3 +468,24 @@ export function buildClaimTx(code, toAddress, feeRate, net) { // 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. +// v2 wraps an ARK gift code (v1 wrapped on-chain PSBT gifts and is retired — +// its links no longer exist in the wild). Two ways in, same plaintext: the +// one-time claim code DM'd to the recipient, or a NIP-07 extension holding +// the recipient's key decrypting the ephemeral-key ciphertext in place. +const LOCKED_GIFT_VERSION = 2; +// 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, 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. +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; +} diff --git a/src/features/gifts.js b/src/features/gifts.js index e3025d3..9c4e0eb 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -4,7 +4,8 @@ import { newMnemonic } from '../wallet.js'; import { getNetwork } from '../api.js'; -import { installGiftWallet, previewGift, giftOutpoints, buildClaimTx, giftMinimum } from './gifts-wallet.js'; +import { decryptWithCode, npubOf } from '../nostr.js'; +import { installGiftWallet, previewGift, giftOutpoints, buildClaimTx, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { fmtBtc, timeAgo, shortAddr } from '../format.js'; @@ -49,6 +50,76 @@ export function giftsFeature(ctx) { } } + // A locked ark gift (/lg/ — base64url, so case-sensitive unlike /g/ and + // /ag/): the blob is public, the ark gift code inside is ciphertext only + // the recipient's nostr key (or the DM'd claim code) can open. + 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; } + } + + // The locked-gift claim screen: who it's for, and the two ways to open it. + function lockedGiftClaimView() { + const lk = ui.claimLocked; + const hasExt = !!(typeof globalThis !== 'undefined' && globalThis.nostr && lk.eph && lk.ctKey); + const dismiss = () => { ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; render(); }; + 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('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'), + 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')), + hook('profileChip', lk.to) || h('span', { class: 'small mono' }, (npubOf(lk.to) || lk.to).slice(0, 16) + '…')), + 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; } }), + h('button', { class: (hasExt ? '' : 'btn-primary ') + 'btn-block', onClick: submitLockedCode }, t('claimBtn')), + h('button', { class: 'btn-ghost btn-block', onClick: dismiss }, t('dismiss')) + ) + ); + } + + // Both unlock paths decrypt to the ordinary ark gift code and then walk the + // ordinary claim flow — the lock is purely a gate on learning the code. + function acceptLockedPayload(payload) { + if (!payload || !arkGiftOf(payload)) return false; + ui.claimLocked = null; ui.claimCodeInput = ''; ui.claimError = ''; + claimGift(payload); + return true; + } + + 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); + ui.busy = false; + if (!acceptLockedPayload(payload)) { ui.claimError = t('extDecryptFailed'); render(); } + } catch { + ui.busy = false; ui.claimError = t('extDecryptFailed'); render(); + } + } + + function submitLockedCode() { + const code = (ui.claimCodeInput || '').trim(); + if (!code) return; + let payload = null; + try { payload = decryptWithCode(code, ui.claimLocked.ct); } catch {} + if (!acceptLockedPayload(payload)) { ui.claimError = t('claimCodeWrong'); render(); } + } + // 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 // wallet (the original first-timer flow). @@ -779,19 +850,30 @@ export function giftsFeature(ctx) { return { id: 'gifts', - // opened with ?gift / /g/ / /lg/ in the URL — start the claim flow + // opened with ?gift / /g/ / /ag/ / /lg/ in the URL — start the claim flow bootUrl() { const code = readGiftHash(); if (code) { claimGift(code); return true; } // bearer gift → claim into an existing wallet or a new one + const locked = readLockedGiftHash(); + if (locked) { ui.claimLocked = locked; render(); return true; } // locked gift → unlock screen first return false; }, giftOpened(code) { checkGiftClaimed(code); }, screenView() { + if (ui.claimLocked) return lockedGiftClaimView(); if (ui.viewGift) return viewGiftView(); if (ui.claimChoose) return claimChooseView(); if (ui.screen === 'claim') return claimScreen(); return null; }, + // Wrap an ark gift code in an npub-locked blob (the ark zap flow's + // "no ark address" fallback). Returns the public link + the one-time + // claim code to deliver to the recipient. + canLockGift() { return true; }, + lockArkGift(code, amount, recipientPkHex) { + const { blob, claimCode } = lockGift(code, amount, recipientPkHex); + return { blob, claimCode, url: `${location.origin}/lg/${blob}` }; + }, receiveTakeover() { return claimCelebration(); }, sendView() { return ui.giftMode ? giftView() : null; }, sendFormExtras() { diff --git a/src/features/messages.js b/src/features/messages.js index 6d9217d..ab2e900 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1010,6 +1010,75 @@ export function messagesFeature(ctx) { } } + // ---- profile notes: latest public posts & replies ----------------------- + + const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; + const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] } + function notesFor(pk) { + let c = notesCache.get(pk); + if (c) return c; + c = { status: 'loading', notes: [] }; + notesCache.set(pk, c); + queryOn(NOTE_RELAYS, { kinds: [1], authors: [pk], limit: 30 }, 4500).then((evs) => { + const seen = new Set(); + c.notes = (evs || []) + .filter((e) => !seen.has(e.id) && seen.add(e.id)) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, 20); + c.status = 'ready'; + if (ui.profilePk === pk) render(); + }).catch(() => { c.status = 'ready'; if (ui.profilePk === pk) render(); }); + return c; + } + + // Note content, safely: text stays text nodes — relay content must never + // reach innerHTML. URLs become links (image URLs inline), npub mentions a + // clickable @name, other nostr: refs a dim stub. + const NOTE_SPLIT = /(https?:\/\/[^\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+)/gi; + function noteBody(text) { + const out = []; + for (const part of String(text || '').split(NOTE_SPLIT)) { + if (!part) continue; + if (/^https?:\/\//i.test(part)) { + if (/\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(part)) { + out.push(h('img', { src: part, class: 'note-img', loading: 'lazy', + onError: (e) => { e.target.style.display = 'none'; } })); + } else { + out.push(h('a', { href: part, target: '_blank', rel: 'noopener noreferrer' }, + part.length > 64 ? part.slice(0, 61) + '…' : part)); + } + } else if (/^nostr:npub1/i.test(part)) { + const mpk = parseNostrPubkey(part.slice(6)); + if (mpk) out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(mpk); } }, '@' + displayName(mpk))); + else out.push(part); + } else if (/^nostr:/i.test(part)) { + out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); + } else out.push(part); + } + return out; + } + + // Zap a specific note: ark first (instant, free, e-tagged receipt), else + // the Lightning zap flow — same ladder as paying the person directly. + function zapNote(pk, ev) { + const npubStr = npubOf(pk); + ui.profilePk = null; + ui.chatOpen = false; + ui.tab = 'send'; + render(); + if (!hook('zapNpub', pk, npubStr, ev.id)) hook('lnZapNpub', pk, npubStr, ev.id); + } + + function noteCard(pk, ev) { + const isReply = ev.tags.some((x) => x[0] === 'e'); + const canZap = !isMe(pk) && !!(hook('arkReady') || hook('canLnZap')); + return h('div', { class: 'card col', style: 'gap:6px' }, + h('div', { class: 'row between', style: 'align-items:center' }, + h('span', { class: 'small faint' }, (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)), + canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: () => zapNote(pk, ev) }, '⚡') : null), + h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content))); + } + async function saveProfile() { const id = await identity(); if (!id) { toast(t('msgNoIdentity')); return; } @@ -1131,6 +1200,17 @@ export function messagesFeature(ctx) { render(); hook('matchSendText', npubStr); } }, t('profPay')))), + // Their latest public notes, zappable in place. + (() => { + const c = notesFor(pk); + if (c.status === 'loading') + return h('div', { class: 'card col', style: 'align-items:center;padding:18px' }, h('span', { class: 'spinner sm' })); + if (!c.notes.length) + return h('div', { class: 'small faint', style: 'text-align:center' }, t('profNotesNone')); + return h('div', { class: 'col', style: 'gap:10px' }, + h('div', { class: 'small muted', style: 'padding:0 2px' }, t('profNotesTitle')), + ...c.notes.map((ev) => noteCard(pk, ev))); + })(), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profilePk = null; ui.profEdit = null; render(); } }, t('back'))); } diff --git a/src/features/sync.js b/src/features/sync.js index 481dda9..35bf358 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -63,6 +63,9 @@ export function installSyncWallet(wallet) { 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; + }, // Generic event publish/fetch on the configured sync relays — the seam // other features (ark zaps) use to speak their own kinds. No-ops when diff --git a/src/features/zaps.js b/src/features/zaps.js index 18676de..3f1b599 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -41,7 +41,8 @@ export function zapsFeature(ctx) { if (profile && profile.name) z.name = profile.name; const lnTarget = zapTargetFromProfile(profile, target.pk); if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; } - z.target = target = lnTarget; + z.target = target = { ...lnTarget, eventId: target.eventId || null }; // keep the note being zapped + z.address = lnTarget.address || z.address; } const params = await fetchPayParams(target.url); @@ -72,7 +73,8 @@ export function zapsFeature(ctx) { if (canZap) { zapRequest = buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32, - recipientPk: z.target.pk, comment: z.comment, signFn: (partial) => wallet.nostrSign(partial), + recipientPk: z.target.pk, eventId: z.target.eventId || null, comment: z.comment, + signFn: (partial) => wallet.nostrSign(partial), }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment }); @@ -197,10 +199,11 @@ export function zapsFeature(ctx) { // ark's "no Ark address" screen checks this before offering the fallback. canLnZap() { return canPay() && !!wallet.nostrProfile; }, // Ark's "no Ark address published" screen offers a Lightning fallback, - // which lands here with the recipient's pubkey. - lnZapNpub(pk, npub) { + // which lands here with the recipient's pubkey (and, when the zap was + // aimed at a note, its event id for the zap request's e tag). + lnZapNpub(pk, npub, eventId) { if (!canPay() || !wallet.nostrProfile) return false; - begin({ kind: 'npub', pk }, shortNpub(npub || npubOf(pk))); + begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); return true; }, sendView() { return zapView(); }, diff --git a/src/i18n.js b/src/i18n.js index 3c56c30..2623a56 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -206,6 +206,9 @@ const STR = { profAbout: 'About', profPicture: 'Picture URL', profSaved: 'Profile published', + profNotesTitle: 'Latest posts', + profNotesNone: 'No recent posts found.', + profReplyTag: 'reply', msgSearching: 'Searching…', msgNoMatches: 'No matches', msgBadNpub: 'Not a valid npub', @@ -512,6 +515,23 @@ const STR = { arkZapCommentPh: 'Comment (optional)', arkZapBtn: 'Zap', arkZapSentTitle: 'Zapped!', + arkZapNoArkGift: 'They haven’t set up an Ark address yet — send the zap as an Ark gift locked to their nostr key instead. They’ll get a DM with the claim link, and only they can open it.', + arkZapGiftBtn: 'Send locked gift', + arkZapGiftHint: 'Instant Ark gift only their nostr key can claim. You can revoke it from the gift card while unclaimed.', + arkZapGiftSentTitle: 'Gift sent!', + giftDmText: '🎁 Someone sent you {amount} on coinos! Claim it here: {link}\nIf you’re asked for a claim code, use: {code}', + giftDmSending: 'Sending them the claim link by DM…', + giftDmSent: 'Claim link sent to them by DM ✓', + giftDmFailed: 'Couldn’t reach them by DM — copy the link and claim code and pass them along yourself.', + giftLockedTo: 'Locked to', + giftCodeHint: 'Enter the claim code from your DM to unlock this gift.', + claimCodePlaceholder: 'Claim code', + claimCodeWrong: 'That code doesn’t unlock this gift.', + claimWithExtension: 'Unlock with nostr extension', + orEnterCode: 'or enter your claim code', + extWrongAccount: 'Your extension is signed in to a different nostr account.', + extNoNip44: 'Your extension doesn’t support NIP-44 decryption.', + extDecryptFailed: 'Couldn’t decrypt with the extension.', arkOpsBoard: '{n} deposit(s) confirming', arkOpsSend: '{n} send(s) finishing up', arkOpsRefresh: '{n} refresh(es) waiting for the next round', diff --git a/src/nostr.js b/src/nostr.js index 98515fe..8615f7d 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -14,6 +14,7 @@ import * as nip44 from 'nostr-tools/nip44'; import * as nip04 from 'nostr-tools/nip04'; import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; import { decode as nip19decode, npubEncode, nsecEncode } from 'nostr-tools/nip19'; +import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17'; import { SimplePool } from 'nostr-tools/pool'; import { randomBytes } from '@noble/hashes/utils'; import { base64urlnopad } from '@scure/base'; @@ -152,6 +153,25 @@ export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) { return []; } +// 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. +// (NIP-44 takes raw 32-byte key material, so the one-time code works as a key.) +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())); +} + const DTAG = 'bitcoin-wallet'; // Each network syncs under its own d-tag base — a shared one would let a signet // snapshot overwrite mainnet state (and vice versa). Mainnet keeps the bare tag @@ -285,6 +305,22 @@ export class NostrSync { } catch { return null; } } + // Deliver an encrypted DM (a locked gift's claim code) as a NIP-17 gift wrap + // (kind 1059) — the modern standard every current client supports. Goes to the + // recipient's published inbox relays (NIP-17/65) plus a broad fallback. + async sendDM(recipientPkHex, text, relays = null) { + if (!this.sk) return false; + let evt; + try { evt = nip17WrapEvent(this.sk, { publicKey: recipientPkHex }, text); } catch { return false; } + let targets = relays; + if (!targets) { + const inbox = await fetchInboxRelays(recipientPkHex); + targets = [...new Set([...inbox, ...PROFILE_RELAYS])].slice(0, 8); + } + const res = await Promise.allSettled(pool.publish(targets, evt)); + return res.some((x) => x.status === 'fulfilled'); + } + // Publish an event signed by some other key (the NWC service key). The // wallet's own key must not sign wallet-service traffic — connections are // revocable and shouldn't be tied to the user's nostr identity. diff --git a/src/style.css b/src/style.css index 759b15d..b029e97 100644 --- a/src/style.css +++ b/src/style.css @@ -425,6 +425,9 @@ hr.divider { margin: 16px 0; } .chat-bubble.me { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); text-align: left; } .chat-row.dm .chat-time { font-size: 10px; color: var(--faint); margin-top: 2px; } +/* Profile note feed: inline images stay contained, links wrap. */ +.note-img { display: block; max-width: 100%; max-height: 320px; border-radius: 10px; margin-top: 6px; object-fit: contain; } + /* Inline move-money panel on the balance card. */ .balance-move { gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); text-align: left; } diff --git a/tools/locked-gift-test.js b/tools/locked-gift-test.js new file mode 100644 index 0000000..12c0dff --- /dev/null +++ b/tools/locked-gift-test.js @@ -0,0 +1,59 @@ +// An npub-locked ark gift link must show who it's for and gate the code +// behind the claim code (or the recipient's NIP-07 key) — a wrong code stays +// on the unlock screen, the right one walks into the ordinary claim flow. +// +// Run: bun tools/locked-gift-test.js +import puppeteer from 'puppeteer-core'; +import { hex, base32nopad } from '@scure/base'; +import { buildHtml } from '../build.js'; +import { lockGift, previewLockedGift } from '../src/features/gifts-wallet.js'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; + +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; }; + +// A syntactically valid mainnet ark gift code (random secret — nothing real). +const secret = crypto.getRandomValues(new Uint8Array(32)); +const raw = new Uint8Array(42); +raw[0] = 0x11; raw[1] = 0; // magic, mainnet +new DataView(raw.buffer).setBigUint64(2, 2100n, true); +raw.set(secret, 10); +const giftCode = base32nopad.encode(raw); + +const recipientSk = generateSecretKey(); +const recipientPk = getPublicKey(recipientSk); +const { blob, claimCode } = lockGift(giftCode, 2100, recipientPk); +check('blob previews', !!previewLockedGift(blob)); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5233, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const bodyText = () => page.evaluate(() => document.body.innerText); +const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 15000) => { for (let i = 0; i < ms / 250; i++) { if ((await bodyText()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; + +await page.goto(`http://localhost:5233/lg/${blob}`, { waitUntil: 'networkidle2' }); +check('unlock screen shows', await waitText('A gift for you')); +check('amount shows', (await bodyText()).includes('2,100') || (await bodyText()).includes('2100')); +check('locked-to line shows', (await bodyText()).toLowerCase().includes('locked to')); +check('code field present', await page.evaluate(() => !!document.querySelector('input.mono-input'))); + +// wrong code: stay put, say so +check('typed wrong code', await setInput('input.mono-input', 'definitely-not-the-code')); +check('clicked claim', await click('claim it')); +check('wrong code rejected', await waitText('doesn’t unlock', 5000)); + +// right code: advances into the ordinary gift claim flow (fresh wallet path) +check('typed right code', await setInput('input.mono-input', claimCode)); +check('clicked claim again', await click('claim it')); +const advanced = await waitText('checking', 10000) || await waitText('claim', 10000); +const stillLocked = (await bodyText()).includes('A gift for you'); +check('unlock advanced to claim flow', advanced && !stillLocked, stillLocked ? 'still on unlock screen' : ''); + +await browser.close(); +server.stop(); +console.log(ok ? '\n✅ locked gift unlock flow works' : '\n❌ locked gift flow broken'); +process.exit(ok ? 0 : 1); diff --git a/tools/profile-feed-test.js b/tools/profile-feed-test.js new file mode 100644 index 0000000..e499e5a --- /dev/null +++ b/tools/profile-feed-test.js @@ -0,0 +1,67 @@ +// A profile screen must show the person's latest nostr posts and replies — +// and a zap button on each note when the wallet can pay. Driven against the +// live coinos community: open chat, tap an avatar, land on their profile. +// +// Run: bun tools/profile-feed-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { buildHtml } from '../build.js'; + +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 html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5234, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(String(e))); +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); +const bodyText = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await bodyText()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; + +try { + await page.goto('http://localhost:5234/', { waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'I already have a wallet'); + await sleep(300); + await click('button', 'Import existing'); + await sleep(300); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + check('wallet opens', await waitText('receive', 20000)); + + await page.evaluate(() => document.querySelector('.header-msgs')?.click()); + await sleep(800); + check('chat home opens', await waitText('coinos', 10000)); + await click('.item, .chat-thread-row', 'coinos'); + const gotChat = await page.waitForSelector('.chat-avatar.clickable', { timeout: 25000 }).then(() => true).catch(() => false); + check('community chat shows avatars', gotChat); + + if (gotChat) { + await page.evaluate(() => document.querySelector('.chat-avatar.clickable')?.click()); + check('profile screen opens', await waitText('back', 10000)); + // the feed: either notes arrive or the explicit empty state shows + const fed = await waitText('Latest posts', 15000); + const empty = !fed && (await bodyText()).includes('No recent posts'); + check('note feed resolves', fed || empty, fed ? 'notes shown' : 'no posts for this pk'); + if (fed) { + const zaps = await page.evaluate(() => [...document.querySelectorAll('button')].filter((b) => b.textContent.trim() === '⚡').length); + check('zap buttons on notes', zaps > 0, `${zaps} note(s) zappable`); + const imgsBroken = await page.evaluate(() => [...document.querySelectorAll('.note-img')].some((i) => i.style.display !== 'none' && i.complete && i.naturalWidth === 0)); + check('no broken inline images', !imgsBroken); + // tap a zap: should land on the send tab's zap card (resolution ladder) + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '⚡')?.click()); + check('zap flow opens', await waitText('Send a zap', 10000)); + } + } + check('no page errors', pageErrors.length === 0, pageErrors.slice(0, 2).join(' | ')); +} finally { + await browser.close(); + server.stop(); +} +console.log(ok ? '\n✅ profile feed works' : '\n❌ profile feed broken'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3