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.
This commit is contained in:
Claude
2026-01-19 09:31:43 +00:00
parent 3f811ed072
commit 53d7191402
2 changed files with 72 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[][] = [