diff options
| author | Adam Soltys <[email protected]> | 2026-08-04 22:43:49 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-04 22:43:49 -0700 |
| commit | bc5b34f053cf35c11a68e57a9c43c987331452a7 (patch) | |
| tree | 5517bff7d4491d65bfdb43bba1296f69d6943e51 | |
| parent | e35c0af04fafd6e069d0d1e67664bcaed8c9c671 (diff) | |
| download | coinos-server-bc5b34f053cf35c11a68e57a9c43c987331452a7.tar.gz coinos-server-bc5b34f053cf35c11a68e57a9c43c987331452a7.zip | |
lnurl: defer to the v3 registrar for names it has claimed
Serving local accounts unconditionally sent migrated names (whose legacy
user record still exists) back to the old account, and left v3-only
custom names 500ing — only npub1* names had a Cloudflare redirect.
Resolution order is now: registrar-claimed -> registrar (response handed
through, cached 60s, fail-open to local if the registrar is down), else
local account, else 404. The migrated flag stays out of it: register.ts
stamps it on essentially every account. No loop: we defer only for names
the registrar HAS, and it never queries us for those.
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | routes/lnurl.ts | 68 |
1 files changed, 53 insertions, 15 deletions
diff --git a/routes/lnurl.ts b/routes/lnurl.ts index be4c2b73..1acc4d76 100644 --- a/routes/lnurl.ts +++ b/routes/lnurl.ts @@ -16,6 +16,34 @@ const fiveMinutes = 1000 * 60 * 5; // v3 payment addresses (coinos v3 / halwallet) are registered here. const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io"; +// Does the v3 registrar serve this name? Returns its payRequest, or null. +// Cached briefly: claims change on the order of days, and this sits on the +// payment path for every coinos.io address lookup. Only definitive answers +// (payRequest / 404) are cached — a sick registrar shouldn't be remembered. +const registrarCache = new Map(); +const REGISTRAR_TTL = 60_000; +async function registrarLookup(name) { + const hit = registrarCache.get(name); + if (hit && Date.now() - hit.at < REGISTRAR_TTL) return hit.body; + try { + const r = await fetch( + `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${host}`, + { signal: AbortSignal.timeout(3000) }, + ); + if (r.ok) { + const body: any = await r.json(); + if (body?.tag === "payRequest") { + registrarCache.set(name, { at: Date.now(), body }); + return body; + } + } + if (r.status === 404) registrarCache.set(name, { at: Date.now(), body: null }); + } catch (e) { + warn("names registrar lookup failed for", name, (e as any).message); + } + return null; +} + export default { async encode(req, res) { const { @@ -61,22 +89,32 @@ export default { query: { minSendable = 1000, maxSendable = 100000000000 }, } = req; try { - const user = await getUser( - username - .replace("lightning:", "") - .replace(/\s/g, "") - .replace("=", "") - .toLowerCase(), - ); + const name = username + .replace("lightning:", "") + .replace(/\s/g, "") + .replace("=", "") + .toLowerCase(); + + // Resolution order for [email protected] — this has flip-flopped twice, so + // the invariant, in full: + // 1. A name CLAIMED in the v3 registrar is served by it. That is where + // migrated accounts live — their legacy user record still exists + // here, so "local account exists" does NOT mean the name is ours — + // and also v3-native custom names, which have no local account and + // only a npub1* Cloudflare rule redirecting for them today. + // 2. Otherwise a local account is served locally. Never key this on + // `migrated`: register.ts stamps that flag on essentially every + // account (it means "don't reserve this name for v3", not "user + // moved away"). + // 3. Neither → not found. + // No loop: step 1 defers only for names the registrar HAS, and the + // registrar never queries this endpoint for those. If the registrar is + // unreachable we fall through and serve the local account — a briefly + // stale destination beats an unpayable address. + const v3 = await registrarLookup(name); + if (v3) return res.send(v3); - // The v3 names registrar (v3.coinos.io) is the FRONT for v3 accounts and - // falls back to THIS coinos.io endpoint for anything it doesn't have. So - // this endpoint is the fallback target: it must serve its own accounts - // directly and must NOT defer back to the registrar — that inverts the - // fallback and loops once the registrar's fallback is live. (It also can't - // key off `migrated`: register.ts stamps that flag on essentially every - // account, and those names were never synced to the registrar.) Just serve - // the local account, and 404 only when there is no local account at all. + const user = await getUser(name); if (!user) fail(`User ${username} not found`); const { id: uid } = user; |
