Add anonymous zap option with throwaway signer (#154)

* feat: add anonymous zap option

Add "Zap anonymously" checkbox that allows users to send zaps without
revealing their identity. When enabled, creates a throwaway keypair to
sign the zap request instead of using the active account's signer.

This also enables users without a signer account to send zaps by
checking the anonymous option.

* 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.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-19 12:33:46 +01:00
committed by GitHub
parent 3f811ed072
commit 97dd30f587
4 changed files with 476 additions and 58 deletions

View File

@@ -21,10 +21,14 @@ import {
Loader2,
CheckCircle2,
LogIn,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { PrivateKeySigner } from "applesauce-signers";
import { generateSecretKey } from "nostr-tools";
import QRCode from "qrcode";
import { useProfile } from "@/hooks/useProfile";
import { use$ } from "applesauce-react/hooks";
@@ -138,6 +142,7 @@ export function ZapWindow({
const [showQrDialog, setShowQrDialog] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [paymentTimedOut, setPaymentTimedOut] = useState(false);
const [zapAnonymously, setZapAnonymously] = useState(false);
// Editor ref and search functions
const editorRef = useRef<MentionEditorHandle>(null);
@@ -356,6 +361,13 @@ export function ZapWindow({
}
// Step 3: Create and sign zap request event (kind 9734)
// If zapping anonymously, create a throwaway signer
let anonymousSigner;
if (zapAnonymously) {
const throwawayKey = generateSecretKey();
anonymousSigner = new PrivateKeySigner(throwawayKey);
}
const zapRequest = await createZapRequest({
recipientPubkey,
amountMillisats,
@@ -366,6 +378,7 @@ export function ZapWindow({
lnurl: lud16 || undefined,
emojiTags,
customTags,
signer: anonymousSigner,
});
const serializedZapRequest = serializeZapRequest(zapRequest);
@@ -657,6 +670,26 @@ export function ZapWindow({
className="rounded-md border border-input bg-background px-3 py-1 text-base md:text-sm min-h-9"
/>
)}
{/* Anonymous zap checkbox */}
{hasLightningAddress && (
<div className="flex items-center gap-2">
<Checkbox
id="zap-anonymously"
checked={zapAnonymously}
onCheckedChange={(checked) =>
setZapAnonymously(checked === true)
}
/>
<label
htmlFor="zap-anonymously"
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
>
<EyeOff className="size-3.5" />
Zap anonymously
</label>
</div>
)}
</div>
{/* No Lightning Address Warning */}
@@ -667,7 +700,7 @@ export function ZapWindow({
)}
{/* Payment Button */}
{!canSign ? (
{!canSign && !zapAnonymously ? (
<Button
onClick={handleLogin}
className="w-full"
@@ -713,6 +746,12 @@ export function ZapWindow({
Pay with Wallet (
{selectedAmount || parseInt(customAmount) || 0} sats)
</>
) : zapAnonymously ? (
<>
<EyeOff className="size-4 mr-2" />
Zap Anonymously (
{selectedAmount || parseInt(customAmount) || 0} sats)
</>
) : (
<>
<Zap className="size-4 mr-2" />

View File

@@ -3,11 +3,11 @@
*/
import { EventFactory } from "applesauce-core/event-factory";
import type { ISigner } from "applesauce-signers";
import type { NostrEvent } from "@/types/nostr";
import type { EventPointer, AddressPointer } from "./open-parser";
import accountManager from "@/services/accounts";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { selectZapRelays } from "./zap-relay-selection";
export interface EmojiTag {
shortcode: string;
@@ -36,75 +36,50 @@ export interface ZapRequestParams {
* Used for additional protocol-specific tagging
*/
customTags?: string[][];
/** Optional signer for anonymous zaps (overrides account signer) */
signer?: ISigner;
}
/**
* Create and sign a zap request event (kind 9734)
* This event is NOT published to relays - it's sent to the LNURL callback
*
* @param params.signer - Optional signer for anonymous zaps. When provided,
* uses this signer instead of the active account's signer.
*/
export async function createZapRequest(
params: ZapRequestParams,
): Promise<NostrEvent> {
const account = accountManager.active;
// Use provided signer (for anonymous zaps) or fall back to account signer
let signer = params.signer;
let senderPubkey: string | undefined;
if (!account) {
throw new Error("No active account. Please log in to send zaps.");
}
if (signer) {
// Anonymous zap - use provided signer
senderPubkey = await signer.getPublicKey();
} else {
// Normal zap - use account signer
const account = accountManager.active;
const signer = account.signer;
if (!signer) {
throw new Error("No signer available for active account");
if (!account) {
throw new Error("No active account. Please log in to send zaps.");
}
signer = account.signer;
if (!signer) {
throw new Error("No signer available for active account");
}
senderPubkey = account.pubkey;
}
// Get relays for zap receipt publication
// Priority: explicit params.relays > semantic author relays > sender read relays > aggregators
let relays: string[] | undefined = params.relays
? [...new Set(params.relays)] // Deduplicate explicit relays
: undefined;
if (!relays || relays.length === 0) {
const collectedRelays: string[] = [];
// Collect outbox relays from semantic authors (event author and/or addressable event pubkey)
const authorsToQuery: string[] = [];
if (params.eventPointer?.author) {
authorsToQuery.push(params.eventPointer.author);
}
if (params.addressPointer?.pubkey) {
authorsToQuery.push(params.addressPointer.pubkey);
}
// Deduplicate authors
const uniqueAuthors = [...new Set(authorsToQuery)];
// Fetch outbox relays for each author
for (const authorPubkey of uniqueAuthors) {
const authorOutboxes =
(await relayListCache.getOutboxRelays(authorPubkey)) || [];
collectedRelays.push(...authorOutboxes);
}
// Include relay hints from pointers
if (params.eventPointer?.relays) {
collectedRelays.push(...params.eventPointer.relays);
}
if (params.addressPointer?.relays) {
collectedRelays.push(...params.addressPointer.relays);
}
// Deduplicate collected relays
const uniqueRelays = [...new Set(collectedRelays)];
if (uniqueRelays.length > 0) {
relays = uniqueRelays;
} else {
// Fallback to sender's read relays (where they want to receive zap receipts)
const senderReadRelays =
(await relayListCache.getInboxRelays(account.pubkey)) || [];
relays =
senderReadRelays.length > 0 ? senderReadRelays : AGGREGATOR_RELAYS;
}
}
// Priority: explicit relays > recipient's inbox > sender's inbox > fallback aggregators
const zapRelayResult = await selectZapRelays({
recipientPubkey: params.recipientPubkey,
senderPubkey,
explicitRelays: params.relays,
});
const relays = zapRelayResult.relays;
// Build tags
const tags: string[][] = [

View 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);
});
});

View 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;
}