diff --git a/src/components/GrimoireWelcome.tsx b/src/components/GrimoireWelcome.tsx
index db6097c..6791e47 100644
--- a/src/components/GrimoireWelcome.tsx
+++ b/src/components/GrimoireWelcome.tsx
@@ -1,6 +1,10 @@
import { Terminal } from "lucide-react";
import { Button } from "./ui/button";
import { Kbd, KbdGroup } from "./ui/kbd";
+import { Progress } from "./ui/progress";
+import { MONTHLY_GOAL_SATS } from "@/services/supporters";
+import { useLiveQuery } from "dexie-react-hooks";
+import db from "@/services/db";
interface GrimoireWelcomeProps {
onLaunchCommand: () => void;
@@ -8,23 +12,53 @@ interface GrimoireWelcomeProps {
}
const EXAMPLE_COMMANDS = [
+ {
+ command: "zap grimoire.rocks",
+ description: "Support Grimoire development",
+ showProgress: true,
+ },
{
command: "chat groups.0xchat.com'NkeVhXuWHGKKJCpn",
description: "Join the Grimoire welcome chat",
},
- { command: "nip 29", description: "View relay-based groups spec" },
{
command: "profile fiatjaf.com",
description: "Explore a Nostr profile",
},
{ command: "req -k 1 -l 20", description: "Query recent notes" },
- { command: "nips", description: "Browse all NIPs" },
];
export function GrimoireWelcome({
onLaunchCommand,
onExecuteCommand,
}: GrimoireWelcomeProps) {
+ // Calculate monthly donations reactively from DB (last 30 days)
+ const monthlyDonations =
+ useLiveQuery(async () => {
+ const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
+ let total = 0;
+ await db.grimoireZaps
+ .where("timestamp")
+ .aboveOrEqual(thirtyDaysAgo)
+ .each((zap) => {
+ total += zap.amountSats;
+ });
+ return total;
+ }, []) ?? 0;
+
+ // Calculate progress
+ const goalProgress = (monthlyDonations / MONTHLY_GOAL_SATS) * 100;
+
+ // Format sats
+ function formatSats(sats: number): string {
+ if (sats >= 1_000_000) {
+ return `${(sats / 1_000_000).toFixed(1)}M`;
+ } else if (sats >= 1_000) {
+ return `${Math.floor(sats / 1_000)}k`;
+ }
+ return sats.toString();
+ }
+
return (
@@ -89,7 +123,7 @@ export function GrimoireWelcome({
Try these commands:
- {EXAMPLE_COMMANDS.map(({ command, description }) => (
+ {EXAMPLE_COMMANDS.map(({ command, description, showProgress }) => (
))}
diff --git a/src/components/nostr/UserName.tsx b/src/components/nostr/UserName.tsx
index 4134a2a..f2f8a18 100644
--- a/src/components/nostr/UserName.tsx
+++ b/src/components/nostr/UserName.tsx
@@ -3,7 +3,8 @@ import { getDisplayName } from "@/lib/nostr-utils";
import { cn } from "@/lib/utils";
import { useGrimoire } from "@/core/state";
import { isGrimoireMember } from "@/lib/grimoire-members";
-import { BadgeCheck } from "lucide-react";
+import { BadgeCheck, Zap } from "lucide-react";
+import { useIsSupporter } from "@/hooks/useIsSupporter";
interface UserNameProps {
pubkey: string;
@@ -20,11 +21,15 @@ interface UserNameProps {
* - Orange→Amber gradient for logged-in member
* - Violet→Fuchsia gradient for other members
* - BadgeCheck icon that scales with username size
+ * Shows Grimoire supporters (non-members who zapped):
+ * - Premium supporters (2.1k+ sats/month): Zap badge in their username color
+ * - Regular supporters: Yellow zap badge (no username color change)
*/
export function UserName({ pubkey, isMention, className }: UserNameProps) {
const { addWindow, state } = useGrimoire();
const profile = useProfile(pubkey);
const isGrimoire = isGrimoireMember(pubkey);
+ const { isSupporter, isPremiumSupporter } = useIsSupporter(pubkey);
const displayName = getDisplayName(pubkey, profile);
// Check if this is the logged-in user
@@ -66,6 +71,23 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
)}
/>
)}
+ {!isGrimoire && isSupporter && (
+
+ )}
);
}
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx
index 7274dd6..7bd0fec 100644
--- a/src/components/nostr/user-menu.tsx
+++ b/src/components/nostr/user-menu.tsx
@@ -7,6 +7,7 @@ import {
RefreshCw,
Eye,
EyeOff,
+ Zap,
} from "lucide-react";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
@@ -14,6 +15,8 @@ import { use$ } from "applesauce-react/hooks";
import { getDisplayName } from "@/lib/nostr-utils";
import { useGrimoire } from "@/core/state";
import { Button } from "@/components/ui/button";
+import { useLiveQuery } from "dexie-react-hooks";
+import db from "@/services/db";
import {
DropdownMenu,
DropdownMenuContent,
@@ -43,6 +46,12 @@ import { useState } from "react";
import { useTheme } from "@/lib/themes";
import { toast } from "sonner";
import { useWallet } from "@/hooks/useWallet";
+import { Progress } from "@/components/ui/progress";
+import {
+ GRIMOIRE_DONATE_PUBKEY,
+ GRIMOIRE_LIGHTNING_ADDRESS,
+} from "@/lib/grimoire-members";
+import { MONTHLY_GOAL_SATS } from "@/services/supporters";
function UserAvatar({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
@@ -86,6 +95,33 @@ export default function UserMenu() {
const [showWalletInfo, setShowWalletInfo] = useState(false);
const { themeId, setTheme, availableThemes } = useTheme();
+ // Calculate monthly donations reactively from DB (last 30 days)
+ const monthlyDonations =
+ useLiveQuery(async () => {
+ const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
+ let total = 0;
+ await db.grimoireZaps
+ .where("timestamp")
+ .aboveOrEqual(thirtyDaysAgo)
+ .each((zap) => {
+ total += zap.amountSats;
+ });
+ return total;
+ }, []) ?? 0;
+
+ // Calculate monthly donation progress
+ const goalProgress = (monthlyDonations / MONTHLY_GOAL_SATS) * 100;
+
+ // Format numbers for display
+ function formatSats(sats: number): string {
+ if (sats >= 1_000_000) {
+ return `${(sats / 1_000_000).toFixed(1)}M`;
+ } else if (sats >= 1_000) {
+ return `${Math.floor(sats / 1_000)}k`;
+ }
+ return sats.toString();
+ }
+
// Get wallet service profile for display name, using wallet relays as hints
const walletServiceProfile = useProfile(
nwcConnection?.service,
@@ -113,6 +149,17 @@ export default function UserMenu() {
addWindow("wallet", {}, "Wallet");
}
+ function openDonate() {
+ addWindow(
+ "zap",
+ {
+ recipientPubkey: GRIMOIRE_DONATE_PUBKEY,
+ recipientLightningAddress: GRIMOIRE_LIGHTNING_ADDRESS,
+ },
+ "Support Grimoire",
+ );
+ }
+
async function logout() {
if (!account) return;
accounts.removeAccount(account);
@@ -376,6 +423,28 @@ export default function UserMenu() {
)}
+ {/* Support Grimoire Section */}
+
+
+
+
+
+ Support Grimoire
+
+
+ Monthly goal
+
+ {formatSats(monthlyDonations)} /{" "}
+ {formatSats(MONTHLY_GOAL_SATS)} sats
+
+
+
+
+
+
{account && (
<>
{relays && relays.length > 0 && (
diff --git a/src/hooks/useIsSupporter.ts b/src/hooks/useIsSupporter.ts
new file mode 100644
index 0000000..63dd011
--- /dev/null
+++ b/src/hooks/useIsSupporter.ts
@@ -0,0 +1,50 @@
+/**
+ * Hook to check if a user is a Grimoire supporter
+ */
+
+import { useLiveQuery } from "dexie-react-hooks";
+import { useState, useEffect } from "react";
+import supportersService from "@/services/supporters";
+import db from "@/services/db";
+
+/**
+ * Check if a pubkey belongs to a Grimoire supporter
+ * @param pubkey - User's hex public key
+ * @returns Object with supporter status and premium status
+ */
+export function useIsSupporter(pubkey: string | undefined): {
+ isSupporter: boolean;
+ isPremiumSupporter: boolean;
+} {
+ // Get all unique supporter pubkeys reactively from DB
+ const supporterPubkeys = useLiveQuery(
+ () => db.grimoireZaps.orderBy("senderPubkey").uniqueKeys(),
+ [],
+ );
+
+ const [isPremium, setIsPremium] = useState(false);
+
+ // Convert to Set for efficient lookup
+ const supporters = supporterPubkeys
+ ? new Set(supporterPubkeys as string[])
+ : new Set
();
+
+ // Check premium status async
+ useEffect(() => {
+ if (!pubkey || !supporters.has(pubkey)) {
+ setIsPremium(false);
+ return;
+ }
+
+ supportersService.isPremiumSupporter(pubkey).then(setIsPremium);
+ }, [pubkey, supporters.size]); // Use supporters.size to avoid Set equality issues
+
+ if (!pubkey) {
+ return { isSupporter: false, isPremiumSupporter: false };
+ }
+
+ return {
+ isSupporter: supporters.has(pubkey),
+ isPremiumSupporter: isPremium,
+ };
+}
diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts
index b6ad479..4f71468 100644
--- a/src/lib/chat/adapters/nip-53-adapter.ts
+++ b/src/lib/chat/adapters/nip-53-adapter.ts
@@ -720,7 +720,7 @@ export class Nip53Adapter extends ChatProtocolAdapter {
}
// Default fallback relays for live activities
- return ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band"];
+ return ["wss://relay.damus.io", "wss://nos.lol", "wss://purplepag.es"];
}
/**
diff --git a/src/lib/grimoire-members.ts b/src/lib/grimoire-members.ts
index 41e7c34..9527888 100644
--- a/src/lib/grimoire-members.ts
+++ b/src/lib/grimoire-members.ts
@@ -94,3 +94,15 @@ export function getGrimoireUsername(pubkey: string): string | undefined {
export function getGrimoireNip05(pubkey: string): string | undefined {
return membersByPubkey.get(pubkey.toLowerCase())?.nip05;
}
+
+/**
+ * Official Grimoire project pubkey for donations
+ * Corresponds to user "_" in GRIMOIRE_MEMBERS
+ */
+export const GRIMOIRE_DONATE_PUBKEY =
+ "c8fb0d3aa788b9ace4f6cb92dd97d3f292db25b5c9f92462ef6c64926129fbaf";
+
+/**
+ * Official Grimoire project Lightning address for donations
+ */
+export const GRIMOIRE_LIGHTNING_ADDRESS = "grimoire@coinos.io";
diff --git a/src/lib/relay-transformer.test.ts b/src/lib/relay-transformer.test.ts
index c99feea..0cae894 100644
--- a/src/lib/relay-transformer.test.ts
+++ b/src/lib/relay-transformer.test.ts
@@ -296,7 +296,7 @@ describe("relayReferences transformer", () => {
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
- "wss://relay.nostr.band",
+ "wss://purplepag.es",
"wss://nostr.wine",
];
diff --git a/src/main.tsx b/src/main.tsx
index 3959323..59c887b 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -9,10 +9,14 @@ import { TooltipProvider } from "./components/ui/tooltip";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { initializeErrorHandling } from "./lib/error-handler";
import { ThemeProvider } from "./lib/themes";
+import { initSupporters } from "./services/supporters";
// Initialize global error handling
initializeErrorHandling();
+// Initialize supporter tracking
+initSupporters();
+
createRoot(document.getElementById("root")!).render(
diff --git a/src/services/db.ts b/src/services/db.ts
index 7523d98..4dd8206 100644
--- a/src/services/db.ts
+++ b/src/services/db.ts
@@ -100,6 +100,14 @@ export interface LnurlCache {
fetchedAt: number; // Timestamp for cache invalidation
}
+export interface GrimoireZap {
+ eventId: string; // Primary key - zap receipt event ID
+ senderPubkey: string; // Who sent the zap
+ amountSats: number; // Amount in sats (not msats)
+ timestamp: number; // Unix timestamp when zap was sent (created_at)
+ comment?: string; // Optional zap comment/message
+}
+
class GrimoireDb extends Dexie {
profiles!: Table;
nip05!: Table;
@@ -112,6 +120,7 @@ class GrimoireDb extends Dexie {
spells!: Table;
spellbooks!: Table;
lnurlCache!: Table;
+ grimoireZaps!: Table;
constructor(name: string) {
super(name);
@@ -362,6 +371,23 @@ class GrimoireDb extends Dexie {
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
lnurlCache: "&address, fetchedAt",
});
+
+ // Version 17: Add Grimoire donation tracking
+ this.version(17).stores({
+ profiles: "&pubkey",
+ nip05: "&nip05",
+ nips: "&id",
+ relayInfo: "&url",
+ relayAuthPreferences: "&url",
+ relayLists: "&pubkey, updatedAt",
+ relayLiveness: "&url",
+ blossomServers: "&pubkey, updatedAt",
+ spells: "&id, alias, createdAt, isPublished, deletedAt",
+ spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
+ lnurlCache: "&address, fetchedAt",
+ grimoireZaps:
+ "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]",
+ });
}
}
diff --git a/src/services/loaders.test.ts b/src/services/loaders.test.ts
index fbd5780..4c5ebb6 100644
--- a/src/services/loaders.test.ts
+++ b/src/services/loaders.test.ts
@@ -76,9 +76,7 @@ describe("eventLoader", () => {
expect(result).toBeDefined();
expect((result as any)._testPointer.id).toBe("test123");
// mergeRelaySets normalizes URLs with trailing slash
- expect((result as any)._testPointer.relays).toContain(
- "wss://relay.nostr.band/",
- );
+ expect((result as any)._testPointer.relays).toContain("wss://nos.lol/");
});
it("should handle EventPointer with relay hints", () => {
@@ -100,9 +98,7 @@ describe("eventLoader", () => {
expect(result).toBeDefined();
// mergeRelaySets normalizes URLs with trailing slash
- expect((result as any)._testPointer.relays).toContain(
- "wss://relay.nostr.band/",
- );
+ expect((result as any)._testPointer.relays).toContain("wss://nos.lol/");
});
});
@@ -249,7 +245,7 @@ describe("eventLoader", () => {
expect(relays).toContain("wss://r-tag.com/");
expect(relays).toContain("wss://e-tag.com/");
// mergeRelaySets normalizes aggregator relays with trailing slash
- expect(relays).toContain("wss://relay.nostr.band/");
+ expect(relays).toContain("wss://nos.lol/");
});
});
@@ -328,9 +324,7 @@ describe("eventLoader", () => {
expect(result).toBeDefined();
// mergeRelaySets normalizes aggregator relays with trailing slash
- expect((result as any)._testPointer.relays).toContain(
- "wss://relay.nostr.band/",
- );
+ expect((result as any)._testPointer.relays).toContain("wss://nos.lol/");
});
it("should handle invalid e tags gracefully", () => {
@@ -390,7 +384,7 @@ describe("eventLoader", () => {
const relays = (result as any)._testPointer.relays;
// Should only have aggregator relays (normalized with trailing slash)
- expect(relays).toContain("wss://relay.nostr.band/");
+ expect(relays).toContain("wss://nos.lol/");
expect(relays).toContain("wss://nos.lol/");
expect(relays).toContain("wss://purplepag.es/");
expect(relays).toContain("wss://relay.primal.net/");
diff --git a/src/services/loaders.ts b/src/services/loaders.ts
index fef398e..706265e 100644
--- a/src/services/loaders.ts
+++ b/src/services/loaders.ts
@@ -52,7 +52,6 @@ function extractRelayContext(event: NostrEvent): {
// Aggregator relays for better event discovery
// IMPORTANT: URLs must be normalized (trailing slash, lowercase) to match RelayStateManager keys
export const AGGREGATOR_RELAYS = [
- "wss://relay.nostr.band/",
"wss://nos.lol/",
"wss://purplepag.es/",
"wss://relay.primal.net/",
diff --git a/src/services/relay-selection.test.ts b/src/services/relay-selection.test.ts
index b923b85..3b694f0 100644
--- a/src/services/relay-selection.test.ts
+++ b/src/services/relay-selection.test.ts
@@ -76,7 +76,7 @@ describe("selectRelaysForFilter", () => {
const relayListEvent = createRelayListEvent(testSecretKeys[0], [
["r", "wss://relay.damus.io"],
["r", "wss://nos.lol"],
- ["r", "wss://relay.nostr.band", "read"],
+ ["r", "wss://purplepag.es", "read"],
]);
// Add to event store
@@ -99,7 +99,7 @@ describe("selectRelaysForFilter", () => {
result.relays.includes("wss://nos.lol/");
expect(hasWriteRelay).toBe(true);
// Should NOT include read-only relay
- expect(result.relays).not.toContain("wss://relay.nostr.band/");
+ expect(result.relays).not.toContain("wss://purplepag.es/");
});
it("should handle multiple authors", async () => {
@@ -141,7 +141,7 @@ describe("selectRelaysForFilter", () => {
const relayListEvent = createRelayListEvent(testSecretKeys[2], [
["r", "wss://relay.damus.io", "write"],
["r", "wss://nos.lol", "read"],
- ["r", "wss://relay.nostr.band", "read"],
+ ["r", "wss://purplepag.es", "read"],
]);
eventStore.add(relayListEvent);
@@ -160,7 +160,7 @@ describe("selectRelaysForFilter", () => {
// Should include at least one read relay - selectOptimalRelays may pick subset
const hasReadRelay =
result.relays.includes("wss://nos.lol/") ||
- result.relays.includes("wss://relay.nostr.band/");
+ result.relays.includes("wss://purplepag.es/");
expect(hasReadRelay).toBe(true);
// Should NOT include write-only relay
expect(result.relays).not.toContain("wss://relay.damus.io/");
diff --git a/src/services/supporters.ts b/src/services/supporters.ts
new file mode 100644
index 0000000..2d867ff
--- /dev/null
+++ b/src/services/supporters.ts
@@ -0,0 +1,365 @@
+/**
+ * Grimoire Supporters Singleton Service
+ *
+ * Tracks users who have zapped Grimoire by monitoring kind 9735 (zap receipt) events.
+ * Subscribes to relays and stores individual zap records in IndexedDB for accurate tracking.
+ */
+
+import { Subscription } from "rxjs";
+import { firstValueFrom, timeout as rxTimeout, of } from "rxjs";
+import { catchError } from "rxjs/operators";
+import pool from "./relay-pool";
+import relayListCache from "./relay-list-cache";
+import { createTimelineLoader, addressLoader } from "./loaders";
+import {
+ getZapRecipient,
+ getZapSender,
+ getZapAmount,
+ isValidZap,
+ getZapRequest,
+} from "applesauce-common/helpers/zap";
+import { GRIMOIRE_DONATE_PUBKEY } from "@/lib/grimoire-members";
+import type { NostrEvent } from "@/types/nostr";
+import db, { type GrimoireZap } from "./db";
+
+export interface SupporterInfo {
+ pubkey: string;
+ totalSats: number;
+ zapCount: number;
+ lastZapTimestamp: number;
+}
+
+/**
+ * Monthly donation goal in sats (210k sats = 0.0021 BTC)
+ */
+export const MONTHLY_GOAL_SATS = 210_000;
+
+/**
+ * Premium supporter threshold per month (2.1k sats)
+ * Users above this get special badge treatment
+ */
+export const PREMIUM_SUPPORTER_THRESHOLD = 2_100;
+
+/**
+ * Hardcoded relays known to have Grimoire zaps
+ * Used as immediate fallback for cold start before relay list loads
+ */
+const GRIMOIRE_ZAP_RELAYS = ["wss://nos.lol"];
+
+class SupportersService {
+ private subscription: Subscription | null = null;
+
+ /**
+ * Initialize the service - subscribe to zap receipts
+ * Can be called multiple times (re-initializes subscription)
+ */
+ async init() {
+ // Clean up existing subscription if any
+ if (this.subscription) {
+ this.subscription.unsubscribe();
+ this.subscription = null;
+ }
+
+ // Subscribe to new zaps (will fetch relay list)
+ await this.subscribeToZapReceipts();
+ }
+
+ /**
+ * Subscribe to zap receipts for Grimoire donation pubkey
+ */
+ private async subscribeToZapReceipts() {
+ try {
+ // Start with hardcoded relays for immediate cold start
+ let grimRelays = [...GRIMOIRE_ZAP_RELAYS];
+
+ // Fetch relay list in background (non-blocking)
+ // Don't await - let it happen in parallel with subscription
+ this.fetchAndMergeRelayList();
+
+ // Subscribe to zap receipts (kind 9735) for Grimoire
+ // Using 'p' tag filter for recipient (NIP-57 zap receipts tag the recipient)
+ const loader = createTimelineLoader(pool, grimRelays, [
+ {
+ kinds: [9735],
+ "#p": [GRIMOIRE_DONATE_PUBKEY],
+ limit: 500, // Many relays reject limits over 500
+ },
+ ]);
+
+ // Subscribe directly to the loader's observable
+ // TimelineLoader returns Observable - emits individual events from relays
+ const loaderSubscription = loader().subscribe({
+ next: (event: NostrEvent) => {
+ // Process each event as it arrives from relays
+ this.processZapReceipt(event);
+ },
+ error: (error) => {
+ console.error("[Supporters] Timeline loader error:", error);
+ },
+ });
+
+ // Store subscription for cleanup
+ this.subscription = loaderSubscription;
+ } catch (error) {
+ console.error("[Supporters] Failed to subscribe to zap receipts:", error);
+ }
+ }
+
+ /**
+ * Fetch Grimoire's relay list from kind 10002 (non-blocking)
+ * Returns array of relay URLs
+ */
+ private async fetchAndMergeRelayList(): Promise {
+ try {
+ await firstValueFrom(
+ addressLoader({
+ kind: 10002,
+ pubkey: GRIMOIRE_DONATE_PUBKEY,
+ identifier: "",
+ }).pipe(
+ rxTimeout(10000),
+ catchError(() => of(null)),
+ ),
+ );
+
+ // Give relayListCache a moment to update
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Get inbox relays from cache
+ const inboxRelays = await relayListCache.getInboxRelays(
+ GRIMOIRE_DONATE_PUBKEY,
+ );
+
+ if (inboxRelays && inboxRelays.length > 0) {
+ return inboxRelays;
+ }
+
+ return [];
+ } catch (err) {
+ return [];
+ }
+ }
+
+ /**
+ * Process a zap receipt event and store in DB
+ */
+ private async processZapReceipt(event: NostrEvent) {
+ try {
+ // Only process valid zaps
+ if (!isValidZap(event)) return;
+
+ // Double-check recipient is Grimoire
+ const recipient = getZapRecipient(event);
+ if (recipient !== GRIMOIRE_DONATE_PUBKEY) return;
+
+ // Get sender
+ const sender = getZapSender(event);
+ if (!sender) return;
+
+ // Check if already recorded (deduplication)
+ const existing = await db.grimoireZaps.get(event.id);
+ if (existing) return;
+
+ // Get amount (millisats -> sats)
+ const amountMsats = getZapAmount(event);
+ const amountSats = amountMsats ? Math.floor(amountMsats / 1000) : 0;
+
+ // Get comment from zap request
+ const zapRequest = getZapRequest(event);
+ const comment = zapRequest?.content;
+
+ // Store in DB
+ const zapRecord: GrimoireZap = {
+ eventId: event.id,
+ senderPubkey: sender,
+ amountSats,
+ timestamp: event.created_at,
+ comment: comment || undefined,
+ };
+
+ await db.grimoireZaps.add(zapRecord);
+ } catch (error) {
+ // Silently ignore duplicate key errors (race condition protection)
+ if ((error as any).name !== "ConstraintError") {
+ console.error("[Supporters] Failed to process zap:", error);
+ }
+ }
+ }
+
+ /**
+ * Check if a pubkey is a Grimoire supporter (async)
+ */
+ async isSupporter(pubkey: string): Promise {
+ const count = await db.grimoireZaps
+ .where("senderPubkey")
+ .equals(pubkey)
+ .count();
+ return count > 0;
+ }
+
+ /**
+ * Get supporter info for a pubkey (efficient indexed query)
+ */
+ async getSupporterInfo(pubkey: string): Promise {
+ let totalSats = 0;
+ let zapCount = 0;
+ let lastZapTimestamp = 0;
+
+ await db.grimoireZaps
+ .where("senderPubkey")
+ .equals(pubkey)
+ .each((zap) => {
+ totalSats += zap.amountSats;
+ zapCount += 1;
+ lastZapTimestamp = Math.max(lastZapTimestamp, zap.timestamp);
+ });
+
+ if (zapCount === 0) return undefined;
+
+ return {
+ pubkey,
+ totalSats,
+ zapCount,
+ lastZapTimestamp,
+ };
+ }
+
+ /**
+ * Get monthly supporter info for a pubkey
+ * Returns sats donated in last 30 days
+ */
+ async getMonthlySupporterInfo(
+ pubkey: string,
+ ): Promise<{ totalSats: number; zapCount: number } | undefined> {
+ const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
+
+ let totalSats = 0;
+ let zapCount = 0;
+
+ // Use compound index [senderPubkey+timestamp] for efficient query
+ await db.grimoireZaps
+ .where("[senderPubkey+timestamp]")
+ .between([pubkey, thirtyDaysAgo], [pubkey, Infinity])
+ .each((zap) => {
+ totalSats += zap.amountSats;
+ zapCount += 1;
+ });
+
+ if (zapCount === 0) return undefined;
+
+ return { totalSats, zapCount };
+ }
+
+ /**
+ * Check if pubkey is a premium supporter (2.1k+ sats this month)
+ */
+ async isPremiumSupporter(pubkey: string): Promise {
+ const monthlyInfo = await this.getMonthlySupporterInfo(pubkey);
+ return (monthlyInfo?.totalSats || 0) >= PREMIUM_SUPPORTER_THRESHOLD;
+ }
+
+ /**
+ * Get all supporters sorted by total sats (descending)
+ */
+ async getAllSupporters(): Promise {
+ const supporterMap = new Map();
+
+ // Use Dexie iteration to avoid loading all into memory
+ await db.grimoireZaps.each((zap) => {
+ const existing = supporterMap.get(zap.senderPubkey);
+ if (existing) {
+ existing.totalSats += zap.amountSats;
+ existing.zapCount += 1;
+ existing.lastZapTimestamp = Math.max(
+ existing.lastZapTimestamp,
+ zap.timestamp,
+ );
+ } else {
+ supporterMap.set(zap.senderPubkey, {
+ pubkey: zap.senderPubkey,
+ totalSats: zap.amountSats,
+ zapCount: 1,
+ lastZapTimestamp: zap.timestamp,
+ });
+ }
+ });
+
+ return Array.from(supporterMap.values()).sort(
+ (a, b) => b.totalSats - a.totalSats,
+ );
+ }
+
+ /**
+ * Get total donations (all-time) using Dexie iteration
+ */
+ async getTotalDonations(): Promise {
+ let total = 0;
+ await db.grimoireZaps.each((zap) => {
+ total += zap.amountSats;
+ });
+ return total;
+ }
+
+ /**
+ * Get donations in last 30 days using indexed query
+ */
+ async getMonthlyDonations(): Promise {
+ const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
+
+ let total = 0;
+ await db.grimoireZaps
+ .where("timestamp")
+ .aboveOrEqual(thirtyDaysAgo)
+ .each((zap) => {
+ total += zap.amountSats;
+ });
+
+ return total;
+ }
+
+ /**
+ * Get donations in current calendar month using indexed query
+ */
+ async getCurrentMonthDonations(): Promise {
+ const now = new Date();
+ const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+ const firstOfMonthTimestamp = Math.floor(firstOfMonth.getTime() / 1000);
+
+ let total = 0;
+ await db.grimoireZaps
+ .where("timestamp")
+ .aboveOrEqual(firstOfMonthTimestamp)
+ .each((zap) => {
+ total += zap.amountSats;
+ });
+
+ return total;
+ }
+
+ /**
+ * Get supporter count using Dexie uniqueKeys
+ */
+ async getSupporterCount(): Promise {
+ const uniquePubkeys = await db.grimoireZaps
+ .orderBy("senderPubkey")
+ .uniqueKeys();
+ return uniquePubkeys.length;
+ }
+
+ /**
+ * Cleanup when shutting down
+ */
+ destroy() {
+ if (this.subscription) {
+ this.subscription.unsubscribe();
+ this.subscription = null;
+ }
+ }
+}
+
+// Export singleton instance
+const supportersService = new SupportersService();
+export default supportersService;
+
+// Legacy export for compatibility
+export const initSupporters = () => supportersService.init();