aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-02 23:20:42 -0700
committerAdam Soltys <[email protected]>2026-10-02 23:20:42 -0700
commitd4d5a8b65dde249bc6eb27b7c120b7f5c9b238fe (patch)
tree127276077dc776827ee73c90bf8166dc27cc03be
parent9e12335231f3fa4c4222bea71c4a1737a15a4994 (diff)
downloadcoinosv3-d4d5a8b65dde249bc6eb27b7c120b7f5c9b238fe.tar.gz
coinosv3-d4d5a8b65dde249bc6eb27b7c120b7f5c9b238fe.zip
Zaps find lightning addresses our relays have pruned
Zapping a post looked the author's lightning address up on our four home relays only. Those prune old events, so anyone whose kind 0 hadn't changed in a while (1776, Brad Mills) came back as "no Lightning address or Ark address" — while their profile page, which also asks the index relays and the author's own relays, showed the address right there. The zap lookup now asks both ways at once and takes whichever carries a lightning address. And when someone does have one but Lightning can't be paid right now, the toast says that instead of claiming they have no address. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VKv2uEFRR42Gp6NyqcN3A9
-rw-r--r--src/features/ark.js3
-rw-r--r--src/features/messages.js15
-rw-r--r--src/features/sync.js10
-rw-r--r--src/i18n.js1
-rw-r--r--tools/zap-deep-profile-test.js56
5 files changed, 83 insertions, 2 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 16786ec..fb6d2a3 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1724,7 +1724,8 @@ export function arkFeature(ctx) {
// A post's author with no Lightning or Ark address can't be zapped:
// a gift DM is for paying a person on purpose (Pay, the send form),
// not something a tap on a post should send.
- if (z.eventId) { bail(t('arkZapNoPostZap')); render(); return; }
+ // They do have a lightning address — it's paying it that isn't possible right now.
+ if (z.eventId) { bail(t(z.lnOk ? 'arkZapLnDown' : 'arkZapNoPostZap')); render(); return; }
// 4. last resort: an ark gift locked to their nostr key, DMed to them.
// To the payer it's just a zap.
if (z.autoSat && zapGiftOk(z.autoSat)) {
diff --git a/src/features/messages.js b/src/features/messages.js
index 1c0dab8..b0f4526 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3430,6 +3430,21 @@ export function messagesFeature(ctx) {
return pending;
}
+ // A person's profile the way their profile page finds it: index relays and
+ // their own relays, not just ours. The zap path asks here — a lightning
+ // address on a kind 0 that our relays have long since pruned is still
+ // somebody's lightning address.
+ wallet.deepProfile = async (pk) => {
+ await fetchFullProfile(pk);
+ const m = fullProfiles.get(pk);
+ if (!m || typeof m !== 'object') return null;
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
+ return {
+ name: str(m.display_name) || str(m.name), picture: str(m.picture), about: str(m.about), nip05: str(m.nip05),
+ lud16: str(m.lud16), lud06: str(m.lud06),
+ };
+ };
+
// Warm a profile PAGE (full kind-0 + latest notes + relay list) before
// anyone taps it, so the page opens complete instead of behind spinners.
// Queued with a small concurrency cap: likely tap-targets only, never a
diff --git a/src/features/sync.js b/src/features/sync.js
index ed22322..44f5809 100644
--- a/src/features/sync.js
+++ b/src/features/sync.js
@@ -163,7 +163,15 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) {
// broad profile relays — independent of whether cross-device sync is on.
async nostrProfile(pkHex) {
if (this.offline) return null;
- return fetchNostrProfile(pkHex);
+ // Our relays prune: an old kind 0 (and the lightning address on it)
+ // may only survive on index relays or the person's own. Ask both ways
+ // at once; a lightning address from either wins.
+ const [near, deep] = await Promise.all([
+ fetchNostrProfile(pkHex).catch(() => null),
+ this.deepProfile ? this.deepProfile(pkHex).catch(() => null) : null,
+ ]);
+ const pays = (p) => p && (p.lud16 || p.lud06);
+ return pays(near) ? near : pays(deep) ? deep : near || deep;
},
// Relays to advertise in a zap request so we can later find the receipt:
diff --git a/src/i18n.js b/src/i18n.js
index 47369c2..c2a6aca 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -974,6 +974,7 @@ const STR = {
arkZapBtn: 'Zap',
arkZapSentTitle: 'Zapped!',
arkZapNoPostZap: 'Can’t zap this post: its author has no Lightning address or Ark address.',
+ arkZapLnDown: 'Can’t pay Lightning right now: Spending isn’t connected. Try again in a moment.',
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.',
diff --git a/tools/zap-deep-profile-test.js b/tools/zap-deep-profile-test.js
new file mode 100644
index 0000000..555b54c
--- /dev/null
+++ b/tools/zap-deep-profile-test.js
@@ -0,0 +1,56 @@
+// A zap must find the author's lightning address even when our relays have
+// pruned their kind 0 — the profile page already finds it through index
+// relays and the author's own relays; the zap path has to look there too.
+//
+// Live: real relays, a throwaway mainnet wallet (no funds, so the zap stops
+// at "not enough in Spending" — past the address lookup, which is the point).
+//
+// Run: bun tools/zap-deep-profile-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+
+// 1776: a kind 0 with lud16 that none of our home relays still carry (2026-10-02)
+const NPUB = 'npub1e7dj5ymf5nxezvnrnvf6uwfqp4crqschn2q5apjga8jngzfhamqsemfw7d';
+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: 5253, 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();
+await page.setViewport({ width: 420, height: 900 });
+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 text = () => page.evaluate(() => document.body.innerText);
+const until = async (fn, ms) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(400); } };
+
+try {
+ await page.goto('http://localhost:5253/', { waitUntil: 'domcontentloaded' });
+ await sleep(500);
+ await click('button', 'Create a new wallet'); await sleep(300);
+ await click('button', 'Have an existing seed'); await sleep(300);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', generateMnemonic(wordlist));
+ await click('button', 'Open wallet');
+ await until(async () => /receive|not now/i.test(await text()), 20000);
+ for (let i = 0; i < 3 && await click('button', 'Not now'); i++) await sleep(600);
+ await page.goto('http://localhost:5253/' + NPUB, { waitUntil: 'domcontentloaded' });
+ check('their profile shows the lightning address', !!await until(async () => /fervidwage316/.test(await text()), 30000));
+ const zapBtn = await until(() => page.$('.note-actions .zap, button.zap, [aria-label*="zap" i]'), 20000);
+ check('a post with a zap button', !!zapBtn);
+ if (zapBtn) {
+ await zapBtn.click();
+ // whatever the zap ends in, it says so in a toast or on the zap screen
+ const said = await until(() => page.evaluate(() => {
+ const t = [...document.querySelectorAll('.toast, .notice')].map((n) => n.textContent.trim()).filter(Boolean).join(' | ');
+ return t || null;
+ }), 30000);
+ check('the zap gets past the address lookup', !!said && !/no Lightning address or Ark/.test(said), said || 'nothing said');
+ }
+} catch (e) { console.log(e); ok = false; }
+await browser.close();
+server.stop();
+console.log(ok ? '\nall passed' : '\nFAILED');
+process.exit(ok ? 0 : 1);