From 2bfbc89f9b45676597898f8184c255c53f719d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 5 Nov 2025 20:56:57 +0100 Subject: [PATCH] Zaps, first pass --- assets/app.js | 1 + assets/styles/03-components/zaps.css | 46 ++++ src/Service/LNURLResolver.php | 204 ++++++++++++++++++ src/Service/NostrSigner.php | 85 ++++++++ src/Service/QRGenerator.php | 61 ++++++ src/Twig/Components/Molecules/ZapButton.php | 196 +++++++++++++++++ .../components/Molecules/ZapButton.html.twig | 153 +++++++++++++ templates/partial/_author-section.html.twig | 7 + 8 files changed, 753 insertions(+) create mode 100644 assets/styles/03-components/zaps.css create mode 100644 src/Service/LNURLResolver.php create mode 100644 src/Service/NostrSigner.php create mode 100644 src/Service/QRGenerator.php create mode 100644 src/Twig/Components/Molecules/ZapButton.php create mode 100644 templates/components/Molecules/ZapButton.html.twig diff --git a/assets/app.js b/assets/app.js index 03d844e..d7d4a12 100644 --- a/assets/app.js +++ b/assets/app.js @@ -36,6 +36,7 @@ import './styles/03-components/picture-event.css'; import './styles/03-components/video-event.css'; import './styles/03-components/search.css'; import './styles/03-components/image-upload.css'; +import './styles/03-components/zaps.css'; // 04 - Page-specific styles import './styles/04-pages/landing.css'; diff --git a/assets/styles/03-components/zaps.css b/assets/styles/03-components/zaps.css new file mode 100644 index 0000000..999eeea --- /dev/null +++ b/assets/styles/03-components/zaps.css @@ -0,0 +1,46 @@ +.zap-button-component { + margin: var(--spacing-2) 0; +} +.zap-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1050; + padding: 1rem; +} + +.zap-modal-content { + max-width: 450px; + width: 100%; + max-height: 90vh; + overflow-y: auto; +} + +.zap-modal-content .card-body { + padding: 1.5rem; +} + +.qr-container svg { + max-width: 100%; + height: auto; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 0.5rem; + background: white; +} + +.zap-trigger:hover { + transform: scale(1.05); + transition: transform 0.2s ease; +} + +/* Prevent body scroll when modal is open */ +body:has(.zap-modal-overlay) { + overflow: hidden; +} diff --git a/src/Service/LNURLResolver.php b/src/Service/LNURLResolver.php new file mode 100644 index 0000000..be63f3b --- /dev/null +++ b/src/Service/LNURLResolver.php @@ -0,0 +1,204 @@ +resolveLightningAddress($lud16); + } + + return $this->resolveLnurl($lud06); + } catch (\Exception $e) { + $this->logger->error('LNURL resolution failed', [ + 'lud16' => $lud16, + 'lud06' => $lud06, + 'error' => $e->getMessage(), + ]); + throw new \RuntimeException('Could not resolve Lightning endpoint: ' . $e->getMessage()); + } + } + + /** + * Resolve a Lightning Address (LUD-16) + */ + private function resolveLightningAddress(string $address): object + { + if (!preg_match('/^(.+)@(.+)$/', $address, $matches)) { + throw new \RuntimeException('Invalid Lightning Address format'); + } + + [$_, $name, $domain] = $matches; + $url = sprintf('https://%s/.well-known/lnurlp/%s', $domain, $name); + + try { + $response = $this->httpClient->request('GET', $url, [ + 'timeout' => 10, + 'headers' => ['Accept' => 'application/json'], + ]); + + if ($response->getStatusCode() !== 200) { + throw new \RuntimeException('Lightning Address endpoint returned status ' . $response->getStatusCode()); + } + + $data = $response->toArray(); + return $this->parseLnurlPayResponse($data, null); + } catch (\Exception $e) { + throw new \RuntimeException('Failed to fetch Lightning Address info: ' . $e->getMessage()); + } + } + + /** + * Resolve a bech32 LNURL (LUD-06) + */ + private function resolveLnurl(string $lnurl): object + { + try { + // Decode bech32 LNURL to get the actual URL + $decoded = decodeUrl($lnurl); + $url = $decoded['url'] ?? ''; + + if (empty($url)) { + throw new \RuntimeException('Could not decode LNURL'); + } + + $response = $this->httpClient->request('GET', $url, [ + 'timeout' => 10, + 'headers' => ['Accept' => 'application/json'], + ]); + + if ($response->getStatusCode() !== 200) { + throw new \RuntimeException('LNURL endpoint returned status ' . $response->getStatusCode()); + } + + $data = $response->toArray(); + return $this->parseLnurlPayResponse($data, $lnurl); + } catch (\Exception $e) { + throw new \RuntimeException('Failed to decode or fetch LNURL: ' . $e->getMessage()); + } + } + + /** + * Parse LNURL-pay response and validate required fields + */ + private function parseLnurlPayResponse(array $data, ?string $bech32): object + { + // Validate required LNURL-pay fields + if (!isset($data['callback']) || !isset($data['minSendable']) || !isset($data['maxSendable'])) { + throw new \RuntimeException('Invalid LNURL-pay response: missing required fields'); + } + + if (isset($data['tag']) && $data['tag'] !== 'payRequest') { + throw new \RuntimeException('Not a LNURL-pay endpoint'); + } + + // NIP-57 specific fields + $allowsNostr = isset($data['allowsNostr']) && $data['allowsNostr'] === true; + $nostrPubkey = $data['nostrPubkey'] ?? null; + + return (object) [ + 'callback' => $data['callback'], + 'minSendable' => (int) $data['minSendable'], + 'maxSendable' => (int) $data['maxSendable'], + 'allowsNostr' => $allowsNostr, + 'nostrPubkey' => $nostrPubkey, + 'metadata' => $data['metadata'] ?? '[]', + 'bech32' => $bech32, + ]; + } + + /** + * Request a BOLT11 invoice from the LNURL callback + * + * @param string $callback The callback URL from LNURL-pay info + * @param int $amountMillisats Amount in millisatoshis + * @param string|null $nostrEvent Signed NIP-57 zap request event (JSON) + * @param string|null $lnurl Original LNURL bech32 (if available) + * @return string BOLT11 invoice + * @throws \RuntimeException If invoice request fails + */ + public function requestInvoice( + string $callback, + int $amountMillisats, + ?string $nostrEvent = null, + ?string $lnurl = null + ): string { + try { + // Build query parameters + $params = ['amount' => $amountMillisats]; + + if ($nostrEvent !== null) { + $params['nostr'] = $nostrEvent; + } + + if ($lnurl !== null) { + $params['lnurl'] = $lnurl; + } + + // Some LNURL services expect the callback URL to already contain query params + $separator = str_contains($callback, '?') ? '&' : '?'; + $url = $callback . $separator . http_build_query($params); + + $response = $this->httpClient->request('GET', $url, [ + 'timeout' => 15, + 'headers' => ['Accept' => 'application/json'], + ]); + + if ($response->getStatusCode() !== 200) { + throw new \RuntimeException('Callback returned status ' . $response->getStatusCode()); + } + + $data = $response->toArray(); + + // Check for error in response + if (isset($data['status']) && $data['status'] === 'ERROR') { + throw new \RuntimeException($data['reason'] ?? 'Unknown error from Lightning service'); + } + + if (!isset($data['pr'])) { + throw new \RuntimeException('No invoice (pr) in callback response'); + } + + return $data['pr']; + } catch (\Exception $e) { + $this->logger->error('LNURL invoice request failed', [ + 'callback' => $callback, + 'amount' => $amountMillisats, + 'error' => $e->getMessage(), + ]); + throw new \RuntimeException('Could not obtain invoice: ' . $e->getMessage()); + } + } +} + diff --git a/src/Service/NostrSigner.php b/src/Service/NostrSigner.php new file mode 100644 index 0000000..d361b6e --- /dev/null +++ b/src/Service/NostrSigner.php @@ -0,0 +1,85 @@ +key = new Key(); + } + + /** + * Sign a Nostr event with an ephemeral key + * Returns the signed event as JSON string + * + * @param int $kind Event kind + * @param array $tags Event tags + * @param string $content Event content + * @param int|null $createdAt Optional timestamp (defaults to now) + * @return string JSON-encoded signed event + */ + public function signEphemeral(int $kind, array $tags, string $content = '', ?int $createdAt = null): string + { + // Generate ephemeral key pair for anonymous zaps + $privateKey = bin2hex(random_bytes(32)); + $publicKey = $this->key->getPublicKey($privateKey); + + $event = new Event(); + $event->setKind($kind); + $event->setTags($tags); + $event->setContent($content); + $event->setCreatedAt($createdAt ?? time()); + + // Sign the event using the Sign class + $signer = new Sign(); + $signer->signEvent($event, $privateKey); + + // Return as JSON using Event's built-in method + return $event->toJson(); + } + + /** + * Build and sign a NIP-57 zap request event (kind 9734) + * + * @param string $recipientPubkey Recipient's pubkey (hex) + * @param int $amountMillisats Amount in millisatoshis + * @param string $lnurl The LNURL or callback URL + * @param string $comment Optional comment/note + * @param array $relays Optional list of relays + * @return string JSON-encoded signed zap request + */ + public function buildZapRequest( + string $recipientPubkey, + int $amountMillisats, + string $lnurl, + string $comment = '', + array $relays = [] + ): string { + $tags = [ + ['p', $recipientPubkey], + ['amount', (string) $amountMillisats], + ['lnurl', $lnurl], + ]; + + // Add relays if provided + foreach ($relays as $relay) { + $tags[] = ['relays', $relay]; + } + + return $this->signEphemeral(9734, $tags, $comment); + } +} + diff --git a/src/Service/QRGenerator.php b/src/Service/QRGenerator.php new file mode 100644 index 0000000..53d6043 --- /dev/null +++ b/src/Service/QRGenerator.php @@ -0,0 +1,61 @@ +build( + data: $data, + writer: new SvgWriter(), + encoding: new Encoding('UTF-8'), + errorCorrectionLevel: ErrorCorrectionLevel::Low, + size: $size, + margin: 10, + roundBlockSizeMode: RoundBlockSizeMode::Margin + ); + + return $result->getString(); + } + + /** + * Generate a data URI (for embedding in img src) + * + * @param string $data The data to encode + * @param int $size QR code size in pixels + * @return string Data URI + */ + public function dataUri(string $data, int $size = 300): string + { + $result = (new Builder())->build( + data: $data, + encoding: new Encoding('UTF-8'), + errorCorrectionLevel: ErrorCorrectionLevel::Low, + size: $size, + margin: 10, + roundBlockSizeMode: RoundBlockSizeMode::Margin + ); + + return $result->getDataUri(); + } +} + diff --git a/src/Twig/Components/Molecules/ZapButton.php b/src/Twig/Components/Molecules/ZapButton.php new file mode 100644 index 0000000..89f319d --- /dev/null +++ b/src/Twig/Components/Molecules/ZapButton.php @@ -0,0 +1,196 @@ +recipientLud16 && !$this->recipientLud06) { + $this->error = 'Recipient has no Lightning Address configured'; + $this->phase = 'error'; + $this->open = true; + return; + } + + $this->open = true; + $this->phase = 'input'; + $this->error = ''; + $this->bolt11 = ''; + $this->qrSvg = ''; + } + + /** + * Close the dialog and reset state + */ + #[LiveAction] + public function closeDialog(): void + { + $this->open = false; + $this->phase = 'idle'; + $this->error = ''; + $this->bolt11 = ''; + $this->qrSvg = ''; + $this->comment = ''; + $this->amount = 21; + } + + /** + * Create a zap invoice + */ + #[LiveAction] + public function createInvoice(): void + { + $this->error = ''; + $this->phase = 'loading'; + + try { + // Validate amount + if ($this->amount <= 0) { + throw new \RuntimeException('Amount must be greater than 0'); + } + + // Resolve LNURL + $lnurlInfo = $this->lnurlResolver->resolve($this->recipientLud16, $this->recipientLud06); + + // Store min/max for validation + $this->minSendable = $lnurlInfo->minSendable; + $this->maxSendable = $lnurlInfo->maxSendable; + + // Validate NIP-57 support + if (!$lnurlInfo->allowsNostr) { + throw new \RuntimeException('Recipient does not support Nostr zaps (allowsNostr not enabled)'); + } + + if (!$lnurlInfo->nostrPubkey) { + throw new \RuntimeException('Recipient has not configured a Nostr pubkey for zaps'); + } + + // Convert sats to millisats + $amountMillisats = $this->amount * 1000; + + // Validate amount against limits + if ($amountMillisats < $lnurlInfo->minSendable) { + $minSats = (int) ceil($lnurlInfo->minSendable / 1000); + throw new \RuntimeException("Amount too low. Minimum: {$minSats} sats"); + } + + if ($amountMillisats > $lnurlInfo->maxSendable) { + $maxSats = (int) floor($lnurlInfo->maxSendable / 1000); + throw new \RuntimeException("Amount too high. Maximum: {$maxSats} sats"); + } + + // Build and sign NIP-57 zap request (kind 9734) + $zapRequestJson = $this->nostrSigner->buildZapRequest( + recipientPubkey: $this->recipientPubkey, + amountMillisats: $amountMillisats, + lnurl: $lnurlInfo->bech32 ?? ($this->recipientLud16 ?? $this->recipientLud06 ?? ''), + comment: $this->comment, + relays: [] // Optional: could add user's preferred relays + ); + + // URL-encode the zap request for the callback + $nostrParam = urlencode($zapRequestJson); + + // Request invoice from LNURL callback + $this->bolt11 = $this->lnurlResolver->requestInvoice( + callback: $lnurlInfo->callback, + amountMillisats: $amountMillisats, + nostrEvent: $zapRequestJson, + lnurl: $lnurlInfo->bech32 + ); + + // Generate QR code + $this->qrSvg = $this->qrGenerator->svg('lightning:' . strtoupper($this->bolt11), 280); + + $this->phase = 'invoice'; + + } catch (\RuntimeException $e) { + $this->error = $e->getMessage(); + $this->phase = 'error'; + $this->logger->error('Zap invoice creation failed', [ + 'recipient' => $this->recipientPubkey, + 'error' => $e->getMessage(), + ]); + } catch (\Exception $e) { + $this->error = 'Could not reach Lightning endpoint. Please try again.'; + $this->phase = 'error'; + $this->logger->error('Zap invoice creation failed with unexpected error', [ + 'recipient' => $this->recipientPubkey, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } +} + diff --git a/templates/components/Molecules/ZapButton.html.twig b/templates/components/Molecules/ZapButton.html.twig new file mode 100644 index 0000000..4483684 --- /dev/null +++ b/templates/components/Molecules/ZapButton.html.twig @@ -0,0 +1,153 @@ +
+ {# Zap Button - always visible #} + + + {# Modal Dialog #} + {% if this.open %} +
+
+
+ {# Header #} +
+
⚡ Send Zap
+ +
+ + {# Phase: Input #} + {% if this.phase == 'input' %} +
+
+ + + Suggested: 21, 210, 2100 sats +
+ +
+ + +
+ + +
+ {% endif %} + + {# Phase: Loading #} + {% if this.phase == 'loading' %} +
+
+ Loading... +
+

Creating invoice...

+
+ {% endif %} + + {# Phase: Invoice #} + {% if this.phase == 'invoice' %} +
+
+

✓ Invoice ready!

+

Scan with your Lightning wallet

+
+ + {# QR Code #} +
+ {{ qrSvg|raw }} +
+ + {# BOLT11 Invoice String #} +
+ +
+ + +
+
+ + {# Lightning URI Link #} +
+ + ⚡ Open in Wallet + + + Or scan the QR code above + +
+ + {# Back button #} + +
+ {% endif %} + + {# Phase: Error #} + {% if this.phase == 'error' %} +
+ Error: {{ error }} +
+ + {% endif %} +
+
+
+ {% endif %} +
+ diff --git a/templates/partial/_author-section.html.twig b/templates/partial/_author-section.html.twig index 3284c40..1c52b1e 100644 --- a/templates/partial/_author-section.html.twig +++ b/templates/partial/_author-section.html.twig @@ -6,6 +6,13 @@ {% endif %} + {% if author.lud16 is defined and author.lud16 %} + + {% endif %} + {% if author.nip05 is defined %} {% if author.nip05 is iterable %} {% for nip05Value in author.nip05 %}