diff --git a/src/lib/zap-relay-selection.test.ts b/src/lib/zap-relay-selection.test.ts new file mode 100644 index 0000000..ce9bf09 --- /dev/null +++ b/src/lib/zap-relay-selection.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { selectZapRelays, getZapRelays } from "./zap-relay-selection"; + +// Mock the relay list cache +vi.mock("@/services/relay-list-cache", () => ({ + relayListCache: { + getInboxRelays: vi.fn(), + }, +})); + +// Mock the loaders for AGGREGATOR_RELAYS +vi.mock("@/services/loaders", () => ({ + AGGREGATOR_RELAYS: [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.nostr.band", + ], +})); + +import { relayListCache } from "@/services/relay-list-cache"; + +describe("selectZapRelays", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("explicit relays", () => { + it("should use explicit relays when provided", async () => { + const explicitRelays = ["wss://explicit1.com", "wss://explicit2.com"]; + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + senderPubkey: "sender456", + explicitRelays, + }); + + expect(result.relays).toEqual(explicitRelays); + expect(result.sources.recipientInbox).toEqual([]); + expect(result.sources.senderInbox).toEqual([]); + expect(result.sources.fallback).toEqual([]); + // Should not call cache when explicit relays provided + expect(relayListCache.getInboxRelays).not.toHaveBeenCalled(); + }); + + it("should limit explicit relays to 10", async () => { + const explicitRelays = Array.from( + { length: 15 }, + (_, i) => `wss://relay${i}.com`, + ); + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + explicitRelays, + }); + + expect(result.relays.length).toBe(10); + }); + }); + + describe("recipient inbox priority", () => { + it("should prioritize recipient's inbox relays", async () => { + const recipientRelays = [ + "wss://recipient1.com", + "wss://recipient2.com", + "wss://recipient3.com", + ]; + const senderRelays = [ + "wss://sender1.com", + "wss://sender2.com", + "wss://sender3.com", + ]; + + vi.mocked(relayListCache.getInboxRelays).mockImplementation( + async (pubkey) => { + if (pubkey === "recipient123") return recipientRelays; + if (pubkey === "sender456") return senderRelays; + return null; + }, + ); + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + senderPubkey: "sender456", + }); + + // Recipient relays should come first + expect(result.relays.slice(0, 3)).toEqual(recipientRelays); + expect(result.sources.recipientInbox).toEqual(recipientRelays); + expect(result.sources.senderInbox).toEqual(senderRelays); + }); + + it("should use only recipient relays when sender is anonymous", async () => { + const recipientRelays = ["wss://recipient1.com", "wss://recipient2.com"]; + + vi.mocked(relayListCache.getInboxRelays).mockResolvedValue( + recipientRelays, + ); + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + // No senderPubkey - anonymous zap + }); + + expect(result.relays).toEqual(recipientRelays); + expect(result.sources.recipientInbox).toEqual(recipientRelays); + expect(result.sources.senderInbox).toEqual([]); + }); + }); + + describe("relay deduplication", () => { + it("should deduplicate relays shared by recipient and sender", async () => { + const sharedRelay = "wss://shared.com"; + const recipientRelays = [sharedRelay, "wss://recipient-only.com"]; + const senderRelays = [sharedRelay, "wss://sender-only.com"]; + + vi.mocked(relayListCache.getInboxRelays).mockImplementation( + async (pubkey) => { + if (pubkey === "recipient123") return recipientRelays; + if (pubkey === "sender456") return senderRelays; + return null; + }, + ); + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + senderPubkey: "sender456", + }); + + // Count occurrences of shared relay + const sharedCount = result.relays.filter((r) => r === sharedRelay).length; + expect(sharedCount).toBe(1); + + // Should have all unique relays + expect(result.relays).toContain(sharedRelay); + expect(result.relays).toContain("wss://recipient-only.com"); + expect(result.relays).toContain("wss://sender-only.com"); + }); + }); + + describe("fallback relays", () => { + it("should use fallback relays when neither party has preferences", async () => { + vi.mocked(relayListCache.getInboxRelays).mockResolvedValue(null); + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + senderPubkey: "sender456", + }); + + expect(result.relays.length).toBeGreaterThan(0); + expect(result.sources.fallback.length).toBeGreaterThan(0); + expect(result.relays).toContain("wss://relay.damus.io"); + }); + + it("should use fallback when recipient has empty relay list", async () => { + vi.mocked(relayListCache.getInboxRelays).mockResolvedValue([]); + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + }); + + expect(result.relays.length).toBeGreaterThan(0); + expect(result.sources.fallback.length).toBeGreaterThan(0); + }); + }); + + describe("relay limits", () => { + it("should limit total relays to 10", async () => { + const recipientRelays = Array.from( + { length: 8 }, + (_, i) => `wss://recipient${i}.com`, + ); + const senderRelays = Array.from( + { length: 8 }, + (_, i) => `wss://sender${i}.com`, + ); + + vi.mocked(relayListCache.getInboxRelays).mockImplementation( + async (pubkey) => { + if (pubkey === "recipient123") return recipientRelays; + if (pubkey === "sender456") return senderRelays; + return null; + }, + ); + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + senderPubkey: "sender456", + }); + + expect(result.relays.length).toBeLessThanOrEqual(10); + }); + + it("should ensure minimum relays per party when possible", async () => { + const recipientRelays = [ + "wss://r1.com", + "wss://r2.com", + "wss://r3.com", + "wss://r4.com", + "wss://r5.com", + ]; + const senderRelays = [ + "wss://s1.com", + "wss://s2.com", + "wss://s3.com", + "wss://s4.com", + "wss://s5.com", + ]; + + vi.mocked(relayListCache.getInboxRelays).mockImplementation( + async (pubkey) => { + if (pubkey === "recipient123") return recipientRelays; + if (pubkey === "sender456") return senderRelays; + return null; + }, + ); + + const result = await selectZapRelays({ + recipientPubkey: "recipient123", + senderPubkey: "sender456", + }); + + // Should have at least 3 recipient relays (MIN_RELAYS_PER_PARTY) + const recipientCount = result.relays.filter((r) => + r.startsWith("wss://r"), + ).length; + expect(recipientCount).toBeGreaterThanOrEqual(3); + + // Should have at least 3 sender relays + const senderCount = result.relays.filter((r) => + r.startsWith("wss://s"), + ).length; + expect(senderCount).toBeGreaterThanOrEqual(3); + }); + }); +}); + +describe("getZapRelays", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return just the relay URLs", async () => { + const recipientRelays = ["wss://recipient1.com", "wss://recipient2.com"]; + + vi.mocked(relayListCache.getInboxRelays).mockResolvedValue(recipientRelays); + + const relays = await getZapRelays("recipient123", "sender456"); + + expect(Array.isArray(relays)).toBe(true); + expect(relays).toEqual(recipientRelays); + }); + + it("should work without sender pubkey (anonymous)", async () => { + const recipientRelays = ["wss://recipient1.com"]; + + vi.mocked(relayListCache.getInboxRelays).mockResolvedValue(recipientRelays); + + const relays = await getZapRelays("recipient123"); + + expect(relays).toEqual(recipientRelays); + }); +}); diff --git a/src/lib/zap-relay-selection.ts b/src/lib/zap-relay-selection.ts new file mode 100644 index 0000000..dba9e76 --- /dev/null +++ b/src/lib/zap-relay-selection.ts @@ -0,0 +1,138 @@ +/** + * Zap Relay Selection Utilities + * + * Provides optimal relay selection for zap receipts (kind 9735). + * The relays tag in a zap request specifies where the zap receipt should be published. + * + * Priority order: + * 1. Recipient's inbox (read) relays - so recipient sees the zap + * 2. Sender's inbox (read) relays - so sender can verify the zap receipt + * 3. Fallback aggregator relays - if neither party has relay preferences + */ + +import { relayListCache } from "@/services/relay-list-cache"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; + +/** Maximum number of relays to include in zap request */ +const MAX_ZAP_RELAYS = 10; + +/** Minimum relays to ensure good coverage */ +const MIN_RELAYS_PER_PARTY = 3; + +export interface ZapRelaySelectionParams { + /** Pubkey of the zap recipient */ + recipientPubkey: string; + /** Pubkey of the zap sender (undefined for anonymous zaps) */ + senderPubkey?: string; + /** Explicit relays to use (overrides automatic selection) */ + explicitRelays?: string[]; +} + +export interface ZapRelaySelectionResult { + /** Selected relays for zap receipt publication */ + relays: string[]; + /** Debug info about relay sources */ + sources: { + recipientInbox: string[]; + senderInbox: string[]; + fallback: string[]; + }; +} + +/** + * Select optimal relays for zap receipt publication + * + * Strategy: + * - Prioritize recipient's inbox relays (they need to see the zap) + * - Add sender's inbox relays (they want to verify/see the receipt) + * - Use fallback aggregators if neither has preferences + * - Deduplicate and limit to MAX_ZAP_RELAYS + */ +export async function selectZapRelays( + params: ZapRelaySelectionParams, +): Promise { + const { recipientPubkey, senderPubkey, explicitRelays } = params; + + // If explicit relays provided, use them directly + if (explicitRelays && explicitRelays.length > 0) { + return { + relays: explicitRelays.slice(0, MAX_ZAP_RELAYS), + sources: { + recipientInbox: [], + senderInbox: [], + fallback: [], + }, + }; + } + + const sources = { + recipientInbox: [] as string[], + senderInbox: [] as string[], + fallback: [] as string[], + }; + + // Fetch relays in parallel + const [recipientInbox, senderInbox] = await Promise.all([ + relayListCache.getInboxRelays(recipientPubkey), + senderPubkey ? relayListCache.getInboxRelays(senderPubkey) : null, + ]); + + if (recipientInbox && recipientInbox.length > 0) { + sources.recipientInbox = recipientInbox; + } + + if (senderInbox && senderInbox.length > 0) { + sources.senderInbox = senderInbox; + } + + // Build relay list with priority ordering + const relaySet = new Set(); + + // Priority 1: Recipient's inbox relays (take up to MIN_RELAYS_PER_PARTY first) + for (const relay of sources.recipientInbox.slice(0, MIN_RELAYS_PER_PARTY)) { + relaySet.add(relay); + } + + // Priority 2: Sender's inbox relays (take up to MIN_RELAYS_PER_PARTY) + for (const relay of sources.senderInbox.slice(0, MIN_RELAYS_PER_PARTY)) { + relaySet.add(relay); + } + + // Add remaining recipient relays + for (const relay of sources.recipientInbox.slice(MIN_RELAYS_PER_PARTY)) { + if (relaySet.size >= MAX_ZAP_RELAYS) break; + relaySet.add(relay); + } + + // Add remaining sender relays + for (const relay of sources.senderInbox.slice(MIN_RELAYS_PER_PARTY)) { + if (relaySet.size >= MAX_ZAP_RELAYS) break; + relaySet.add(relay); + } + + // Fallback to aggregator relays if we don't have enough + if (relaySet.size === 0) { + sources.fallback = [...AGGREGATOR_RELAYS]; + for (const relay of AGGREGATOR_RELAYS) { + if (relaySet.size >= MAX_ZAP_RELAYS) break; + relaySet.add(relay); + } + } + + return { + relays: Array.from(relaySet), + sources, + }; +} + +/** + * Get a simple list of relays for zap receipt publication + * Convenience wrapper that just returns the relay URLs + */ +export async function getZapRelays( + recipientPubkey: string, + senderPubkey?: string, +): Promise { + const result = await selectZapRelays({ recipientPubkey, senderPubkey }); + return result.relays; +}