From 33b47f868230dd3cb641ee38ad38657fadc13cd2 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 19:47:00 +0000 Subject: [PATCH] =?UTF-8?q?task/29:=20fix=20code=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20LNURL=20zap,=20vouch=20binding,=20migration=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Code review issues resolved ### 1. Zap-out: real LNURL-pay resolution (was: log-only when no bolt11) - `zap.ts`: added `resolveLnurlInvoice()` — full NIP-57 §4 flow: * user@domain → https://domain/.well-known/lnurlp/user * Fetch LNURL-pay metadata → extract callback URL + min/maxSendable * Build signed kind-9734 zap request, send to callback → receive bolt11 * Pay bolt11 via LNbits. Log event regardless of payment outcome. - `nostr-identities.ts`: added `lightningAddress` column (nullable TEXT) - `identity.ts /verify`: extracts `["lud16", "user@domain.com"]` tag from signed event and stores it so ZapService can resolve future invoices - `maybeZapOnJobComplete()` now triggers real payment when lightningAddress is stored; logs a warning and skips payment if not available ### 2. Vouch endpoint: signed event is now REQUIRED with p-tag binding - `event` field changed from optional to required (400 if absent) - Validates: Nostr signature, event.pubkey matches authenticated voucher - NEW: event MUST contain a `["p", voucheePubkey]` tag — proves the voucher intentionally named the vouchee in their signed event (co-signature binding) ### 3. DB migration file added - `lib/db/migrations/0006_timmy_economic_peer.sql` — covers: * CREATE TABLE IF NOT EXISTS timmy_nostr_events (with indexes) * CREATE TABLE IF NOT EXISTS nostr_trust_vouches (with indexes) * ALTER TABLE nostr_identities ADD COLUMN IF NOT EXISTS lightning_address - Schema pushed to production: `lightning_address` column confirmed live ### Additional - `GET /api/identity/timmy` now includes `relayUrl` field (null when unset) - TypeScript compiles cleanly (tsc --noEmit: 0 errors) - All smoke tests pass: /timmy 200, /challenge nonce, /vouch 401/400 --- artifacts/api-server/src/lib/zap.ts | 178 ++++++++++++++++-- artifacts/api-server/src/routes/identity.ts | 92 ++++++--- .../migrations/0006_timmy_economic_peer.sql | 47 +++++ lib/db/src/schema/nostr-identities.ts | 5 + 4 files changed, 285 insertions(+), 37 deletions(-) create mode 100644 lib/db/migrations/0006_timmy_economic_peer.sql diff --git a/artifacts/api-server/src/lib/zap.ts b/artifacts/api-server/src/lib/zap.ts index 6cf3f4f..2a01cdd 100644 --- a/artifacts/api-server/src/lib/zap.ts +++ b/artifacts/api-server/src/lib/zap.ts @@ -1,9 +1,17 @@ /** * zap.ts — NIP-57 zap-out capability for Timmy. * - * ZapService constructs and signs a NIP-57 zap request event with Timmy's key, - * optionally pays a BOLT11 invoice via LNbits, and logs the outbound event to - * the `timmy_nostr_events` audit table. + * ZapService constructs and signs a NIP-57 zap request event, resolves the + * recipient's LNURL-pay endpoint from their stored lightning address, obtains + * a BOLT11 invoice, pays it via LNbits, and logs the outbound event. + * + * LNURL-pay resolution flow (NIP-57 §4): + * 1. Look up recipient's lightningAddress from nostr_identities. + * 2. Convert user@domain → https://domain/.well-known/lnurlp/user + * 3. GET the LNURL-pay metadata — extract callback URL. + * 4. Build the signed kind-9734 zap request event. + * 5. GET callback?amount=&nostr= → receive bolt11. + * 6. Pay bolt11 via LNbits. Log event regardless of payment outcome. * * Configuration (env vars): * ZAP_PCT_DEFAULT — percentage of work fee to zap back (default 0 = disabled) @@ -12,7 +20,8 @@ */ import { randomUUID } from "crypto"; -import { db, timmyNostrEvents } from "@workspace/db"; +import { db, timmyNostrEvents, nostrIdentities } from "@workspace/db"; +import { eq } from "drizzle-orm"; import { timmyIdentityService } from "./timmy-identity.js"; import { lnbitsService } from "./lnbits.js"; import { makeLogger } from "./logger.js"; @@ -28,6 +37,9 @@ const ZAP_PCT_DEFAULT = envInt("ZAP_PCT_DEFAULT", 0); const ZAP_MIN_SATS = envInt("ZAP_MIN_SATS", 10); const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? ""; +// LNURL fetch timeout (ms) +const LNURL_TIMEOUT_MS = 8_000; + export interface ZapRequest { recipientPubkey: string; amountSats: number; @@ -42,6 +54,110 @@ export interface ZapResult { paymentHash?: string; } +// ── LNURL-pay resolution helpers ────────────────────────────────────────────── + +/** + * Convert a lightning address (user@domain.com) to the LNURL-pay well-known URL. + * Returns null if the input is not a valid address. + */ +function lightningAddressToUrl(address: string): string | null { + const match = address.match(/^([^@]+)@(.+)$/); + if (!match) return null; + const [, user, domain] = match; + return `https://${domain}/.well-known/lnurlp/${encodeURIComponent(user!)}`; +} + +/** + * Fetch a BOLT11 invoice for the given amount via the LNURL-pay protocol, + * embedding a NIP-57 zap request event in the callback. + * + * Returns the invoice string, or null if any step fails. + */ +async function resolveLnurlInvoice( + lightningAddress: string, + amountMsats: number, + zapRequestEvent: Record, +): Promise { + const metaUrl = lightningAddressToUrl(lightningAddress); + if (!metaUrl) { + logger.warn("zap: invalid lightning address format", { lightningAddress }); + return null; + } + + let callbackUrl: string; + let minSendable: number; + let maxSendable: number; + + // Step 1 — fetch LNURL-pay metadata + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), LNURL_TIMEOUT_MS); + const metaRes = await fetch(metaUrl, { signal: ctrl.signal }); + clearTimeout(timer); + + if (!metaRes.ok) { + logger.warn("zap: LNURL metadata fetch failed", { status: metaRes.status, metaUrl }); + return null; + } + + const meta = await metaRes.json() as Record; + if (typeof meta["callback"] !== "string") { + logger.warn("zap: LNURL metadata missing callback field", { metaUrl }); + return null; + } + + callbackUrl = meta["callback"] as string; + minSendable = typeof meta["minSendable"] === "number" ? meta["minSendable"] as number : 1000; + maxSendable = typeof meta["maxSendable"] === "number" ? meta["maxSendable"] as number : 10_000_000_000; + } catch (err) { + logger.warn("zap: LNURL metadata fetch error", { + lightningAddress, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + // Validate amount within LNURL-pay limits + if (amountMsats < minSendable || amountMsats > maxSendable) { + logger.warn("zap: amount outside LNURL-pay bounds", { amountMsats, minSendable, maxSendable }); + return null; + } + + // Step 2 — call LNURL-pay callback with zap request + try { + const params = new URLSearchParams({ + amount: amountMsats.toString(), + nostr: JSON.stringify(zapRequestEvent), + }); + const callbackFull = `${callbackUrl}${callbackUrl.includes("?") ? "&" : "?"}${params.toString()}`; + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), LNURL_TIMEOUT_MS); + const callRes = await fetch(callbackFull, { signal: ctrl.signal }); + clearTimeout(timer); + + if (!callRes.ok) { + logger.warn("zap: LNURL callback failed", { status: callRes.status }); + return null; + } + + const body = await callRes.json() as Record; + if (typeof body["pr"] !== "string") { + logger.warn("zap: LNURL callback response missing pr field"); + return null; + } + + return body["pr"] as string; + } catch (err) { + logger.warn("zap: LNURL callback error", { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +// ── ZapService ──────────────────────────────────────────────────────────────── + class ZapService { /** Calculate the zap amount for a given work fee, returns 0 if disabled. */ zapAmountSats(workFeeSats: number, pct: number = ZAP_PCT_DEFAULT): number { @@ -52,11 +168,16 @@ class ZapService { /** * Send a zap from Timmy to a trusted partner. * - * If bolt11 is provided, pays the invoice via LNbits. - * Event is always logged to timmy_nostr_events for auditing. + * Resolution order for payment: + * 1. Use provided bolt11 (caller already has invoice). + * 2. Look up recipient's lightningAddress in nostr_identities and resolve + * a fresh invoice via LNURL-pay. + * 3. If neither is available, log the zap event without a payment. + * + * The signed kind-9734 event is always logged to timmy_nostr_events. */ async sendZap(req: ZapRequest): Promise { - const { recipientPubkey, amountSats, bolt11, message } = req; + const { recipientPubkey, amountSats, bolt11: providedBolt11, message } = req; if (amountSats < ZAP_MIN_SATS) { return { skipped: true, reason: `amount ${amountSats} < ZAP_MIN_SATS ${ZAP_MIN_SATS}` }; @@ -80,12 +201,45 @@ class ZapService { content: message ?? "⚡ zap from Timmy", }); + // ── Resolve bolt11 ──────────────────────────────────────────────────────── + let resolvedBolt11: string | undefined = providedBolt11; + + if (!resolvedBolt11 && !lnbitsService.stubMode) { + // Try LNURL-pay resolution from stored lightning address + const rows = await db + .select({ lightningAddress: nostrIdentities.lightningAddress }) + .from(nostrIdentities) + .where(eq(nostrIdentities.pubkey, recipientPubkey)); + + const lightningAddress = rows[0]?.lightningAddress ?? null; + + if (lightningAddress) { + logger.info("zap: resolving LNURL-pay for recipient", { + pubkey: recipientPubkey.slice(0, 8), + lightningAddress, + }); + const invoice = await resolveLnurlInvoice(lightningAddress, amountMsats, zapRequestEvent as unknown as Record); + if (invoice) { + resolvedBolt11 = invoice; + } else { + logger.warn("zap: LNURL-pay resolution failed — event logged without payment", { + pubkey: recipientPubkey.slice(0, 8), + }); + } + } else { + logger.info("zap: no lightning address stored for recipient — event logged without payment", { + pubkey: recipientPubkey.slice(0, 8), + }); + } + } + + // ── Pay invoice ─────────────────────────────────────────────────────────── let paymentHash: string | undefined; - if (bolt11 && !lnbitsService.stubMode) { + if (resolvedBolt11 && !lnbitsService.stubMode) { try { - paymentHash = await lnbitsService.payInvoice(bolt11); - logger.info("zap payment sent", { amountSats, paymentHash, recipientPubkey: recipientPubkey.slice(0, 8) }); + paymentHash = await lnbitsService.payInvoice(resolvedBolt11); + logger.info("zap payment sent", { amountSats, paymentHash, pubkey: recipientPubkey.slice(0, 8) }); } catch (err) { logger.warn("zap payment failed — event logged anyway", { error: err instanceof Error ? err.message : String(err), @@ -96,6 +250,7 @@ class ZapService { logger.info("zap stub payment simulated", { amountSats, paymentHash }); } + // ── Log event ───────────────────────────────────────────────────────────── await db.insert(timmyNostrEvents).values({ id: randomUUID(), kind: 9734, @@ -107,8 +262,9 @@ class ZapService { logger.info("zap event logged", { eventId: zapRequestEvent.id, - recipientPubkey: recipientPubkey.slice(0, 8), + pubkey: recipientPubkey.slice(0, 8), amountSats, + paid: !!paymentHash, }); return { skipped: false, eventId: zapRequestEvent.id, paymentHash }; diff --git a/artifacts/api-server/src/routes/identity.ts b/artifacts/api-server/src/routes/identity.ts index f5e0e63..66be46e 100644 --- a/artifacts/api-server/src/routes/identity.ts +++ b/artifacts/api-server/src/routes/identity.ts @@ -53,6 +53,8 @@ router.post("/identity/challenge", (_req: Request, res: Response) => { // event.pubkey — 64-char hex pubkey // event.content — the nonce returned by /identity/challenge // event.kind — any (27235 recommended per NIP-98, but not enforced) +// event.tags — optional: include ["lud16", "user@domain.com"] to store +// a lightning address for future zap-out payments router.post("/identity/verify", async (req: Request, res: Response) => { // Accept both { event } and { pubkey, event } shapes (pubkey is optional but asserted if present) @@ -133,6 +135,22 @@ router.post("/identity/verify", async (req: Request, res: Response) => { const token = trustService.issueToken(pubkey); const tierInfo = await trustService.getIdentityWithDecay(pubkey); + // ── Optional: persist lightning address from lud16 tag ──────────────── + // Allows ZapService to resolve LNURL-pay invoices for this identity. + const tags = Array.isArray(ev["tags"]) ? ev["tags"] as unknown[][] : []; + const lud16Tag = tags.find(t => Array.isArray(t) && t[0] === "lud16" && typeof t[1] === "string"); + if (lud16Tag) { + const lightningAddress = lud16Tag[1] as string; + const lnAddrRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + if (lnAddrRe.test(lightningAddress)) { + await db + .update(nostrIdentities) + .set({ lightningAddress, updatedAt: new Date() }) + .where(eq(nostrIdentities.pubkey, pubkey)); + logger.info("lightning address stored", { pubkey: pubkey.slice(0, 8), lightningAddress }); + } + } + logger.info("identity verified", { pubkey: pubkey.slice(0, 8), tier: identity.tier, @@ -173,6 +191,7 @@ router.get("/identity/timmy", async (_req: Request, res: Response) => { npub: timmyIdentityService.npub, pubkeyHex: timmyIdentityService.pubkeyHex, zapCount, + relayUrl: process.env["NOSTR_RELAY_URL"] ?? null, }); } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch Timmy identity" }); @@ -181,10 +200,15 @@ router.get("/identity/timmy", async (_req: Request, res: Response) => { // ── POST /identity/vouch ────────────────────────────────────────────────────── // Allows an elite-tier identity to co-sign a new pubkey, granting it a trust -// boost. The voucher's Nostr event is recorded in nostr_trust_vouches. +// boost. The voucher MUST provide a signed Nostr event that explicitly tags the +// voucheePubkey in a "p" tag, proving the co-signing is intentional. // // Requires: X-Nostr-Token with elite tier. -// Body: { voucheePubkey: string, event: NostrEvent (optional — voucher's signed event) } +// Body: { +// voucheePubkey: string, — 64-char hex pubkey of the identity to vouch for +// event: NostrSignedEvent — REQUIRED: voucher's signed kind-1 (or 9735) event +// containing a ["p", voucheePubkey] tag +// } const VOUCH_TRUST_BOOST = 20; @@ -224,40 +248,56 @@ router.post("/identity/vouch", async (req: Request, res: Response) => { return; } - // Verify the voucher event if provided - if (event !== undefined) { - if (!event || typeof event !== "object") { - res.status(400).json({ error: "event must be a Nostr signed event object" }); - return; - } - if (!validateEvent(event as Parameters[0])) { - res.status(400).json({ error: "Invalid Nostr event structure" }); - return; - } - let valid = false; - try { valid = verifyEvent(event as Parameters[0]); } catch { valid = false; } - if (!valid) { - res.status(401).json({ error: "Nostr signature verification failed" }); - return; - } - const ev = event as Record; - if (ev["pubkey"] !== parsed.pubkey) { - res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" }); - return; - } + // ── event is REQUIRED — voucher must produce a signed Nostr event ───────── + if (!event || typeof event !== "object") { + res.status(400).json({ + error: "event is required: include a signed Nostr event with a [\"p\", voucheePubkey] tag", + }); + return; + } + + if (!validateEvent(event as Parameters[0])) { + res.status(400).json({ error: "Invalid Nostr event structure" }); + return; + } + + let valid = false; + try { valid = verifyEvent(event as Parameters[0]); } catch { valid = false; } + if (!valid) { + res.status(401).json({ error: "Nostr signature verification failed" }); + return; + } + + const ev = event as Record; + + // ── event.pubkey must match the authenticated voucher ──────────────────── + if (ev["pubkey"] !== parsed.pubkey) { + res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" }); + return; + } + + // ── event must contain a ["p", voucheePubkey] tag — binding co-signature ─ + // This proves the voucher intentionally named the vouchee in their signed event. + const tags = Array.isArray(ev["tags"]) ? ev["tags"] as unknown[][] : []; + const pTag = tags.find( + t => Array.isArray(t) && t[0] === "p" && t[1] === voucheePubkey + ); + if (!pTag) { + res.status(400).json({ + error: `event must include a ["p", "${voucheePubkey}"] tag to bind the co-signature`, + }); + return; } try { // Upsert vouchee identity so it exists before applying boost await trustService.getOrCreate(voucheePubkey); - const vouchEventJson = event ? JSON.stringify(event) : JSON.stringify({ voucher: parsed.pubkey, vouchee: voucheePubkey, ts: Date.now() }); - await db.insert(nostrTrustVouches).values({ id: randomUUID(), voucherPubkey: parsed.pubkey, voucheePubkey, - vouchEventJson, + vouchEventJson: JSON.stringify(event), trustBoost: VOUCH_TRUST_BOOST, }); diff --git a/lib/db/migrations/0006_timmy_economic_peer.sql b/lib/db/migrations/0006_timmy_economic_peer.sql new file mode 100644 index 0000000..f1fa8b0 --- /dev/null +++ b/lib/db/migrations/0006_timmy_economic_peer.sql @@ -0,0 +1,47 @@ +-- Migration: Timmy as Economic Peer (Task #29) +-- New tables for Timmy's outbound Nostr events and trust vouching, +-- plus lightning address storage on existing identities. + +-- ── timmy_nostr_events ──────────────────────────────────────────────────────── +-- Audit log of every Nostr event Timmy signs and broadcasts (zaps, DMs, etc). + +CREATE TABLE IF NOT EXISTS timmy_nostr_events ( + id TEXT PRIMARY KEY, + kind INTEGER NOT NULL, + recipient_pubkey TEXT, + event_json TEXT NOT NULL, + amount_sats INTEGER, + relay_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_timmy_nostr_events_kind + ON timmy_nostr_events(kind); + +CREATE INDEX IF NOT EXISTS idx_timmy_nostr_events_recipient + ON timmy_nostr_events(recipient_pubkey); + +-- ── nostr_trust_vouches ─────────────────────────────────────────────────────── +-- Records elite-tier co-signing events. Each row grants a trust boost to the +-- vouchee and stores the raw Nostr event for auditability. + +CREATE TABLE IF NOT EXISTS nostr_trust_vouches ( + id TEXT PRIMARY KEY, + voucher_pubkey TEXT NOT NULL REFERENCES nostr_identities(pubkey), + vouchee_pubkey TEXT NOT NULL, + vouch_event_json TEXT NOT NULL, + trust_boost INTEGER NOT NULL DEFAULT 20, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_nostr_trust_vouches_voucher + ON nostr_trust_vouches(voucher_pubkey); + +CREATE INDEX IF NOT EXISTS idx_nostr_trust_vouches_vouchee + ON nostr_trust_vouches(vouchee_pubkey); + +-- ── nostr_identities: lightning address ────────────────────────────────────── +-- NIP-57 lud16 field stored on first verify so ZapService can resolve invoices. + +ALTER TABLE nostr_identities + ADD COLUMN IF NOT EXISTS lightning_address TEXT; diff --git a/lib/db/src/schema/nostr-identities.ts b/lib/db/src/schema/nostr-identities.ts index eb14769..3688d0c 100644 --- a/lib/db/src/schema/nostr-identities.ts +++ b/lib/db/src/schema/nostr-identities.ts @@ -25,6 +25,11 @@ export const nostrIdentities = pgTable("nostr_identities", { .defaultNow() .notNull(), + // Optional Lightning address (NIP-57 lud16 field, e.g. "user@domain.com"). + // Stored on first /identity/verify if the signed event includes a lud16 tag. + // Used by ZapService to resolve LNURL-pay invoices for zap-out payments. + lightningAddress: text("lightning_address"), + lastSeen: timestamp("last_seen", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),