Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions migrations/0007_zaps.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Migration number: 0007 zaps
-- NIP-57 zap receipts (#12 v1): server-side aggregation of kind 9735
-- receipts referencing claimed authors' posts, so blog pages can render
-- "⚡ N sats · M zaps" with zero client JS.

-- One row per validated receipt, deduped by the receipt event id. `address`
-- is the NIP-33 a-coordinate of the zapped post (30023:<pubkey>:<d_tag>).
CREATE TABLE zaps (
receipt_id TEXT PRIMARY KEY,
address TEXT NOT NULL,
author_pubkey TEXT NOT NULL,
sender_pubkey TEXT,
amount_msat INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_zaps_address ON zaps(address);

-- Materialized per-post rollup so the render path (post page, discover,
-- search) is a single PK lookup / cheap LEFT JOIN, never a SUM over zaps.
-- Rebuilt idempotently per address after each ingest batch.
CREATE TABLE zap_totals (
address TEXT PRIMARY KEY,
msat_total INTEGER NOT NULL,
zap_count INTEGER NOT NULL
);

-- Cache of LNURL-pay `nostrPubkey` lookups, keyed by the exact lud16 string
-- (receipt validation binds receipts to the author's wallet key; the
-- .well-known fetch must not run per receipt or per request).
CREATE TABLE lnurl_cache (
lud16 TEXT PRIMARY KEY,
nostr_pubkey TEXT,
checked_at INTEGER NOT NULL
);
8 changes: 8 additions & 0 deletions src/cron/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { readBlogSettings, type User } from "../services/users";
import { fetchEvents } from "../nostr/relay";
import { bumpGen, mirrorEvent } from "../services/mirror";
import { storedEventIds } from "../services/events";
import { refreshZapsForUser } from "../services/zaps";
import type { NostrEvent } from "../nostr/event";
import { isSelfRelayHost } from "../relay/url";

Expand Down Expand Up @@ -244,5 +245,12 @@ export async function runRefresh(
// One user's relay trouble must not sink the whole run.
console.error(`refresh failed for ${user.pubkey}:`, err);
}
// Zap receipt pass (#12): independent try — a broken LNURL endpoint or
// relay must not cost the user their post sync (or vice versa).
try {
await refreshZapsForUser(env, relays, user);
} catch (err) {
console.error(`zap refresh failed for ${user.pubkey}:`, err);
}
}
}
10 changes: 9 additions & 1 deletion src/routes/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { CACHE_STATUS_HEADER, defaultCache } from "../middleware/cache";
import type { BlogProfile } from "../views/tenant/layout";
import { BlogHome } from "../views/tenant/home";
import { PostPage } from "../views/tenant/post";
import { postZap } from "./tenant";
import { NotFoundPage } from "../views/tenant/not-found";
import { rssFeed, atomFeed } from "../views/tenant/xml";

Expand Down Expand Up @@ -495,7 +496,12 @@ async function npubProfile(
): Promise<BlogProfile | null> {
const row = await getProfileRow(env, pubkey);
return row
? { name: row.name, picture: row.picture, about: row.about }
? {
name: row.name,
picture: row.picture,
about: row.about,
lud16: row.lud16,
}
: null;
}

Expand Down Expand Up @@ -614,6 +620,7 @@ mainRoutes.get(`${NPUB_PARAM}/:slug`, async (c) => {
if (bodyHtml === null) return notFoundNpub(c);

const profile = await npubProfile(c.env, r.pubkey);
const zap = await postZap(c.env, r.pubkey, slug, profile?.lud16);
return c.html(
PostPage({
handle: displayNpub(r.npub),
Expand All @@ -623,6 +630,7 @@ mainRoutes.get(`${NPUB_PARAM}/:slug`, async (c) => {
themeCss: "",
mainHost,
basePath: `/${r.npub}`,
zap,
}),
);
});
48 changes: 46 additions & 2 deletions src/routes/tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import {
rowToEvent,
} from "../services/events";
import { getProfile as getProfileRow } from "../services/profiles";
import { safeLud16, zapTotals } from "../services/zaps";
import { naddrEncode } from "../nostr/nip19";
import { selfRelayUrl } from "../relay/url";
import { BlogHome } from "../views/tenant/home";
import { PostPage } from "../views/tenant/post";
import { PostPage, type PostZap } from "../views/tenant/post";
import { NotFoundPage } from "../views/tenant/not-found";
import { rssFeed, atomFeed, sitemapXml } from "../views/tenant/xml";

Expand Down Expand Up @@ -49,7 +52,12 @@ const d1Provider: TenantDataProvider = {
async getProfile(env, pubkey) {
const row = await getProfileRow(env, pubkey);
if (!row) return null;
return { name: row.name, picture: row.picture, about: row.about };
return {
name: row.name,
picture: row.picture,
about: row.about,
lud16: row.lud16,
};
},
async listPosts(env, pubkey) {
const rows = await listPostsByPubkey(env, pubkey);
Expand Down Expand Up @@ -125,6 +133,40 @@ function notFound(c: Context<DispatchEnv>) {
return c.html(NotFoundPage({ handle }), 404);
}

/**
* Zap affordance for a post (#12 v1): only when the author's kind 0 carries
* a shape-valid lud16. The naddr hints our first-party relay so a hand-off
* client can find the post; totals come from the zap_totals rollup. A d-tag
* too long for nip19 TLV (255 bytes) simply drops the affordance.
*/
export async function postZap(
env: Env,
pubkey: string,
dTag: string,
lud16Raw: string | null | undefined,
): Promise<PostZap | null> {
const lud16 = safeLud16(lud16Raw);
if (lud16 === null) return null;
let naddr: string;
try {
naddr = naddrEncode({
identifier: dTag,
pubkey,
kind: 30023,
relays: [selfRelayUrl(env)],
});
} catch {
return null;
}
const totals = await zapTotals(env, `30023:${pubkey}:${dTag}`);
return {
lud16,
naddr,
msatTotal: totals?.msatTotal ?? 0,
zapCount: totals?.zapCount ?? 0,
};
}

/** Routes served on blog subdomains (<handle>.MAIN_HOST). */
export const tenantRoutes = new Hono<DispatchEnv>();

Expand Down Expand Up @@ -216,6 +258,7 @@ tenantRoutes.get("/:slug", async (c) => {
return notFound(c);
}
const profile = await provider.getProfile(c.env, ctx.pubkey);
const zap = await postZap(c.env, ctx.pubkey, slug, profile?.lud16);
return c.html(
PostPage({
handle: ctx.handle,
Expand All @@ -225,6 +268,7 @@ tenantRoutes.get("/:slug", async (c) => {
about: ctx.about,
themeCss: ctx.themeCss,
mainHost: ctx.mainHost,
zap,
}),
);
});
Expand Down
13 changes: 11 additions & 2 deletions src/services/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,15 @@ export const FEED_CONTENT_PREFIX_CHARS = 2048;
export const FEED_SELECT_COLUMNS =
"e.id, e.pubkey, e.kind, e.d_tag, e.created_at, e.tags, " +
`substr(e.content, 1, ${FEED_CONTENT_PREFIX_CHARS}) AS content, ` +
"u.handle AS handle";
"u.handle AS handle, zt.msat_total AS zap_msat, zt.zap_count AS zap_count";

/**
* LEFT JOIN clause pairing FEED_SELECT_COLUMNS' zap columns (#12): the
* zap_totals rollup keyed by the post's a-coordinate. The concatenation
* resolves per row and then hits zap_totals' PRIMARY KEY index — no scan.
*/
export const FEED_ZAP_JOIN =
"LEFT JOIN zap_totals zt ON zt.address = '30023:' || e.pubkey || ':' || e.d_tag";

/**
* A slim feed row: the events columns the discover/search pages actually
Expand All @@ -131,7 +139,7 @@ export const FEED_SELECT_COLUMNS =
export type FeedRow = Pick<
EventRow,
"id" | "pubkey" | "kind" | "d_tag" | "created_at" | "content" | "tags"
> & { handle: string };
> & { handle: string; zap_msat: number | null; zap_count: number | null };

/**
* Recent posts across ALL claimed, non-blocked users, newest first (P6
Expand Down Expand Up @@ -163,6 +171,7 @@ export async function listRecentClaimedPosts(
`SELECT ${FEED_SELECT_COLUMNS}
FROM events e
JOIN users u ON u.pubkey = e.pubkey
${FEED_ZAP_JOIN}
WHERE e.kind = 30023 AND e.deleted = 0
AND u.handle IS NOT NULL AND u.blocked = 0
ORDER BY e.created_at DESC, e.id ASC
Expand Down
2 changes: 2 additions & 0 deletions src/services/search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
DISCOVER_PAGE_SIZE,
FEED_SELECT_COLUMNS,
FEED_ZAP_JOIN,
type FeedRow,
} from "./events";

Expand Down Expand Up @@ -83,6 +84,7 @@ export async function searchPosts(
FROM posts_fts
JOIN events e ON e.rowid = posts_fts.rowid
JOIN users u ON u.pubkey = e.pubkey
${FEED_ZAP_JOIN}
WHERE posts_fts MATCH ?1
AND e.kind = 30023 AND e.deleted = 0
AND u.handle IS NOT NULL AND u.blocked = 0
Expand Down
Loading
Loading