task/29: fix code review findings — LNURL zap, vouch binding, migration SQL

## 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
This commit is contained in:
alexpaynex
2026-03-19 19:47:00 +00:00
parent 45f4e72f14
commit 33b47f8682
4 changed files with 285 additions and 37 deletions

View File

@@ -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=<msats>&nostr=<urlencoded-event> → 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<string, unknown>,
): Promise<string | null> {
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<string, unknown>;
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<string, unknown>;
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<ZapResult> {
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<string, unknown>);
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 };