mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
feat: prioritize recipient's inbox relays for zap receipts
Add selectZapRelays utility that properly selects relays for zap receipt publication with the following priority: 1. Recipient's inbox relays (so they see the zap) 2. Sender's inbox relays (so sender can verify) 3. Fallback aggregator relays This ensures zap receipts are published where recipients will actually see them, rather than just the sender's relays. Includes comprehensive tests for relay selection logic.
This commit is contained in:
266
src/lib/zap-relay-selection.test.ts
Normal file
266
src/lib/zap-relay-selection.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
138
src/lib/zap-relay-selection.ts
Normal file
138
src/lib/zap-relay-selection.ts
Normal file
@@ -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<ZapRelaySelectionResult> {
|
||||
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<string>();
|
||||
|
||||
// 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<string[]> {
|
||||
const result = await selectZapRelays({ recipientPubkey, senderPubkey });
|
||||
return result.relays;
|
||||
}
|
||||
Reference in New Issue
Block a user