diff --git a/package-lock.json b/package-lock.json index d3346c1..d0252d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "ghostr", - "version": "0.6.4", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostr", - "version": "0.6.4", + "version": "0.7.1", "license": "ISC", "dependencies": { + "@breeztech/breez-sdk-spark": "^0.1.0", "@nostr-dev-kit/ndk": "^2.18.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", @@ -346,6 +347,15 @@ "node": ">=6.9.0" } }, + "node_modules/@breeztech/breez-sdk-spark": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.1.9.tgz", + "integrity": "sha512-v1Ipzmhsvtn704q4/U9VjC34F1sKZWsifLq7oXJsUXHSuT45sItJmq7T7e5DIDtgOYgaR1Gmi5EoH6/jY0j+Tg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@cashu/cashu-ts": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz", diff --git a/package.json b/package.json index e0f67b0..75d4d70 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "author": "", "license": "ISC", "dependencies": { + "@breeztech/breez-sdk-spark": "^0.1.0", "@nostr-dev-kit/ndk": "^2.18.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/components/ghostNote/GhostNoteCompose.tsx b/src/components/ghostNote/GhostNoteCompose.tsx index f069315..7e68e5c 100644 --- a/src/components/ghostNote/GhostNoteCompose.tsx +++ b/src/components/ghostNote/GhostNoteCompose.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Send, Loader2, Copy, Check, X } from 'lucide-react' +import { Send, Loader2, Copy, Check, X, Zap } from 'lucide-react' import { GhostNoteIcon } from '@/components/common/GhostNoteIcon' import { Dialog, @@ -23,9 +23,11 @@ import { type SearchProfile, } from '@/services/profileSearchService' import { toast } from '@/hooks/useToast' +import { useWalletStore } from '@/stores/walletStore' import { EXPIRATION_OPTIONS, DEFAULT_EXPIRATION_SECONDS } from '@/types/ghostNote' const MAX_CONTENT_LENGTH = 500 +const MAX_PAID_CONTENT_LENGTH = 10000 interface GhostNoteComposeProps { open: boolean @@ -40,6 +42,7 @@ export function GhostNoteCompose({ }: GhostNoteComposeProps) { const { user } = useAuthStore() const { addGhostNote, settings } = useGhostNoteStore() + const walletStatus = useWalletStore((s) => s.status) const [recipientNpub, setRecipientNpub] = useState('') const [selectedProfile, setSelectedProfile] = useState( @@ -50,6 +53,8 @@ export function GhostNoteCompose({ settings.defaultExpiration || DEFAULT_EXPIRATION_SECONDS ) const [isSending, setIsSending] = useState(false) + const [isPaid, setIsPaid] = useState(false) + const [priceSats, setPriceSats] = useState('') // Success state const [showSuccess, setShowSuccess] = useState(false) @@ -64,6 +69,8 @@ export function GhostNoteCompose({ setShowSuccess(false) setSuccessLink('') setCopiedLink(false) + setIsPaid(false) + setPriceSats('') if (initialRecipient) { setSelectedProfile(initialRecipient) @@ -122,11 +129,14 @@ export function GhostNoteCompose({ throw new Error('Invalid recipient') } + const price = isPaid && priceSats ? parseInt(priceSats) : undefined + const result = await createGhostNote({ recipientPubkey, content: content.trim(), expirationSeconds, notifyOnRead: true, // Always notify sender when read + priceSats: price && price > 0 ? price : undefined, }) if (result.success && result.ghostNote) { @@ -187,7 +197,8 @@ export function GhostNoteCompose({ return option?.label || `${Math.floor(seconds / 3600)} hours` } - const remainingChars = MAX_CONTENT_LENGTH - content.length + const maxLength = isPaid ? MAX_PAID_CONTENT_LENGTH : MAX_CONTENT_LENGTH + const remainingChars = maxLength - content.length const isOverLimit = remainingChars < 0 // Success view @@ -326,7 +337,7 @@ export function GhostNoteCompose({ placeholder="Type your secret message..." rows={4} className="resize-none" - maxLength={MAX_CONTENT_LENGTH + 50} // Allow some overage for visual feedback + maxLength={maxLength + 50} // Allow some overage for visual feedback /> @@ -360,6 +371,50 @@ export function GhostNoteCompose({ + {/* Lightning Payment Option */} +
+
+ +
+

You'll be notified when the recipient reads this message. You can manually revoke access anytime before expiration.

diff --git a/src/components/ghostNote/GhostNoteViewer.tsx b/src/components/ghostNote/GhostNoteViewer.tsx index eb49b48..686c1cf 100644 --- a/src/components/ghostNote/GhostNoteViewer.tsx +++ b/src/components/ghostNote/GhostNoteViewer.tsx @@ -18,6 +18,8 @@ import { import { fetchProfile, getDisplayName } from '@/services/profileSearchService' import { toast } from '@/hooks/useToast' import type { GhostNote } from '@/types/ghostNote' +import { PaymentGate } from '@/components/lightning/PaymentGate' +import { hasPaymentProof } from '@/lib/lightning/paymentVerifier' import { v4 as uuidv4 } from 'uuid' export function GhostNoteViewer() { @@ -35,6 +37,7 @@ export function GhostNoteViewer() { const [decryptedMessage, setDecryptedMessage] = useState(null) const [copiedContent, setCopiedContent] = useState(false) const [viewTimer, setViewTimer] = useState(0) + const [paymentConfirmed, setPaymentConfirmed] = useState(false) const VIEW_TIMEOUT_SECONDS = 60 @@ -107,6 +110,15 @@ export function GhostNoteViewer() { return } + // Extract Lightning payment fields + const priceTag = event.tags.find((t: string[]) => t[0] === 'price') + const bolt11Tag = event.tags.find((t: string[]) => t[0] === 'bolt11') + const paymentHashTag = event.tags.find((t: string[]) => t[0] === 'payment_hash') + + const priceSats = priceTag ? parseInt(priceTag[1] || '') : undefined + const bolt11 = bolt11Tag?.[1] + const paymentHash = paymentHashTag?.[1] + // Create ghost note object const note: GhostNote = { id: uuidv4(), @@ -124,6 +136,9 @@ export function GhostNoteViewer() { revokedAt: null, relayUrls: [], notificationEnabled: false, + priceSats, + bolt11, + paymentHash, } // Add to store @@ -352,7 +367,16 @@ export function GhostNoteViewer() {
- {!decryptedMessage ? ( + {/* Payment gate for paid Ghost Notes */} + {!decryptedMessage && ghostNote.priceSats && ghostNote.bolt11 && ghostNote.paymentHash && !paymentConfirmed && !hasPaymentProof(ghostNote.dTag) ? ( + setPaymentConfirmed(true)} + /> + ) : !decryptedMessage ? ( // Pre-decrypt view <> {isExpired ? ( diff --git a/src/components/lightning/InvoiceDisplay.tsx b/src/components/lightning/InvoiceDisplay.tsx new file mode 100644 index 0000000..dd47b54 --- /dev/null +++ b/src/components/lightning/InvoiceDisplay.tsx @@ -0,0 +1,56 @@ +/** + * InvoiceDisplay - Shows a Lightning invoice with copy button + * Used in wallet management and payment confirmation screens + */ + +import { useState } from "react"; +import { Copy, Check, Zap } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/hooks/useToast"; + +interface InvoiceDisplayProps { + bolt11: string; + amountSats: number; + label?: string; +} + +export function InvoiceDisplay({ bolt11, amountSats, label }: InvoiceDisplayProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(bolt11); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast({ title: "Copy failed", variant: "destructive" }); + } + }; + + return ( +
+ {label && ( +

+ + {label} +

+ )} +
+
+

{bolt11}

+

+ {amountSats.toLocaleString()} sats +

+
+ +
+ {/* TODO: Add QR code rendering */} +
+ ); +} diff --git a/src/components/lightning/PaymentGate.tsx b/src/components/lightning/PaymentGate.tsx new file mode 100644 index 0000000..3b9b19f --- /dev/null +++ b/src/components/lightning/PaymentGate.tsx @@ -0,0 +1,146 @@ +/** + * PaymentGate - "Pay X sats to unlock" UI shown to Ghost Note receivers + * + * When a Ghost Note has a price, this component is shown instead of the + * decrypt button. The receiver must pay the Lightning invoice to unlock. + */ + +import { useState } from "react"; +import { Zap, Copy, Check, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { storePaymentProof, hasPaymentProof } from "@/lib/lightning/paymentVerifier"; +import { toast } from "@/hooks/useToast"; + +interface PaymentGateProps { + ghostNoteDTag: string; + amountSats: number; + bolt11: string; + paymentHash: string; + onPaymentConfirmed: () => void; +} + +export function PaymentGate({ + ghostNoteDTag, + amountSats, + bolt11, + paymentHash, + onPaymentConfirmed, +}: PaymentGateProps) { + const [copiedInvoice, setCopiedInvoice] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + + // Check if already paid + if (hasPaymentProof(ghostNoteDTag)) { + onPaymentConfirmed(); + return null; + } + + const handleCopyInvoice = async () => { + try { + await navigator.clipboard.writeText(bolt11); + setCopiedInvoice(true); + setTimeout(() => setCopiedInvoice(false), 2000); + } catch { + toast({ title: "Copy failed", variant: "destructive" }); + } + }; + + const handleConfirmPayment = async () => { + setIsVerifying(true); + + // In the prototype, we trust the user's confirmation + // TODO: Verify payment on-chain or via sender's relay notification + storePaymentProof(ghostNoteDTag, paymentHash); + + toast({ title: "Payment confirmed!", description: "Unlocking Ghost Note..." }); + + setTimeout(() => { + setIsVerifying(false); + onPaymentConfirmed(); + }, 500); + }; + + const formatSats = (sats: number): string => { + return sats.toLocaleString(); + }; + + return ( + + +
+ +

Paid Ghost Note

+

+ This message requires a Lightning payment to unlock +

+
+ + {/* Price */} +
+ + {formatSats(amountSats)} + + sats +
+ + {/* Invoice */} +
+

+ Pay with any Lightning wallet: +

+
+ + {bolt11} + + +
+
+ + {/* QR Code placeholder */} +
+

+ {/* TODO: Add QR code rendering with a lightweight library */} + Scan QR code or copy invoice above +

+
+ + {/* Actions */} +
+ +

+ Payment goes directly to the sender's self-custodial wallet. + No middleman. +

+
+
+
+ ); +} diff --git a/src/components/lightning/WalletSetup.tsx b/src/components/lightning/WalletSetup.tsx new file mode 100644 index 0000000..1a75a62 --- /dev/null +++ b/src/components/lightning/WalletSetup.tsx @@ -0,0 +1,223 @@ +/** + * WalletSetup - Simple onboarding for Spark wallet + * + * Allows users to: + * - Generate a new seed phrase + * - Import an existing seed phrase + * - Enter their Breez API key + */ + +import { useState } from "react"; +import { Loader2, Wallet, Key, Copy, Check, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useWalletStore } from "@/stores/walletStore"; +import { toast } from "@/hooks/useToast"; + +interface WalletSetupProps { + onComplete?: () => void; +} + +export function WalletSetup({ onComplete }: WalletSetupProps) { + const { setupWallet, status } = useWalletStore(); + + const [mode, setMode] = useState<"choose" | "generate" | "import">("choose"); + const [mnemonic, setMnemonic] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [copiedSeed, setCopiedSeed] = useState(false); + const [confirmedBackup, setConfirmedBackup] = useState(false); + + const isConnecting = status === "connecting"; + + const handleGenerateSeed = async () => { + // TODO: Use Breez SDK to generate mnemonic + // For prototype, instruct user to use an external tool + setMode("generate"); + toast({ + title: "Generate seed externally", + description: + "For the prototype, please generate a 12-word BIP39 mnemonic using another tool and paste it below.", + }); + }; + + const handleConnect = async () => { + const trimmedMnemonic = mnemonic.trim(); + const words = trimmedMnemonic.split(/\s+/); + + if (words.length !== 12 && words.length !== 24) { + toast({ + title: "Invalid seed phrase", + description: "Please enter a 12 or 24 word mnemonic", + variant: "destructive", + }); + return; + } + + if (!apiKey.trim()) { + toast({ + title: "API key required", + description: "Please enter your Breez API key", + variant: "destructive", + }); + return; + } + + try { + await setupWallet(trimmedMnemonic, apiKey.trim()); + toast({ title: "Wallet connected!", description: "Your Lightning wallet is ready" }); + onComplete?.(); + } catch (err) { + toast({ + title: "Connection failed", + description: err instanceof Error ? err.message : "Could not connect wallet", + variant: "destructive", + }); + } + }; + + const handleCopySeed = async () => { + try { + await navigator.clipboard.writeText(mnemonic); + setCopiedSeed(true); + setTimeout(() => setCopiedSeed(false), 2000); + } catch { + toast({ title: "Copy failed", variant: "destructive" }); + } + }; + + // Mode selection + if (mode === "choose") { + return ( + + +
+ +

Lightning Wallet Setup

+

+ Set up a self-custodial Lightning wallet to receive payments for your Ghost Notes. + Your keys, your sats. +

+
+ +
+ + +
+
+
+ ); + } + + // Generate / Import form + return ( + + +
+ +

+ {mode === "generate" ? "New Wallet" : "Import Wallet"} +

+
+ + {/* Seed phrase */} +
+ +