Browse Source

Zaps, first pass

imwald
Nuša Pukšič 2 months ago
parent
commit
2bfbc89f9b
  1. 1
      assets/app.js
  2. 46
      assets/styles/03-components/zaps.css
  3. 204
      src/Service/LNURLResolver.php
  4. 85
      src/Service/NostrSigner.php
  5. 61
      src/Service/QRGenerator.php
  6. 196
      src/Twig/Components/Molecules/ZapButton.php
  7. 153
      templates/components/Molecules/ZapButton.html.twig
  8. 7
      templates/partial/_author-section.html.twig

1
assets/app.js

@ -36,6 +36,7 @@ import './styles/03-components/picture-event.css'; @@ -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';

46
assets/styles/03-components/zaps.css

@ -0,0 +1,46 @@ @@ -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;
}

204
src/Service/LNURLResolver.php

@ -0,0 +1,204 @@ @@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use function tkijewski\lnurl\decodeUrl;
/**
* Resolves Lightning Addresses (LUD-16) and LNURL-pay (LUD-06) endpoints
* to obtain LNURL-pay info for NIP-57 zaps
*/
class LNURLResolver
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
) {}
/**
* Resolve a Lightning Address (name@domain) or lnurl to LNURL-pay info
*
* @param string|null $lud16 Lightning Address (e.g., "alice@example.com")
* @param string|null $lud06 LNURL bech32 string (e.g., "lnurl1...")
* @return object Object with callback, minSendable, maxSendable, allowsNostr, nostrPubkey, bech32
* @throws \RuntimeException If resolution fails
*/
public function resolve(?string $lud16 = null, ?string $lud06 = null): object
{
if (!$lud16 && !$lud06) {
throw new \RuntimeException('No Lightning Address or LNURL provided');
}
try {
// Prefer LUD-16 (Lightning Address) over LUD-06
if ($lud16) {
return $this->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());
}
}
}

85
src/Service/NostrSigner.php

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Service;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use swentel\nostr\Sign\Sign;
/**
* Service for signing Nostr events
* For zap requests, we use ephemeral anonymous keys since these don't need user identity
*/
class NostrSigner
{
private Key $key;
public function __construct()
{
$this->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);
}
}

61
src/Service/QRGenerator.php

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\RoundBlockSizeMode;
use Endroid\QrCode\Writer\SvgWriter;
/**
* Simple QR code generator service using endroid/qr-code
*/
class QRGenerator
{
/**
* Generate an SVG QR code
*
* @param string $data The data to encode (e.g., BOLT11 invoice, Lightning URI)
* @param int $size QR code size in pixels
* @return string SVG markup
*/
public function svg(string $data, int $size = 300): string
{
$result = (new Builder())->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();
}
}

196
src/Twig/Components/Molecules/ZapButton.php

@ -0,0 +1,196 @@ @@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Twig\Components\Molecules;
use App\Service\LNURLResolver;
use App\Service\NostrSigner;
use App\Service\QRGenerator;
use Psr\Log\LoggerInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
/**
* Minimalist NIP-57 Zap Button Component
*
* Provides a simple UI to zap Nostr users via their Lightning Address
* without requiring any third-party relay or LNbits backend.
*/
#[AsLiveComponent]
final class ZapButton
{
use DefaultActionTrait;
// Props: Recipient info (passed from parent)
#[LiveProp]
public string $recipientPubkey = '';
#[LiveProp]
public ?string $recipientLud16 = null;
#[LiveProp]
public ?string $recipientLud06 = null;
// UI state props (internal)
#[LiveProp(writable: true)]
public bool $open = false;
#[LiveProp(writable: true)]
public string $phase = 'idle'; // idle, input, loading, invoice, error
#[LiveProp(writable: true)]
public int $amount = 21; // Amount in sats (default 21)
#[LiveProp(writable: true)]
public string $comment = '';
#[LiveProp]
public string $error = '';
#[LiveProp]
public string $bolt11 = '';
#[LiveProp]
public string $qrSvg = '';
// LNURL info (stored after resolution)
#[LiveProp]
public int $minSendable = 1000; // millisats
#[LiveProp]
public int $maxSendable = 11000000000; // millisats (11M sats default)
public function __construct(
private readonly LNURLResolver $lnurlResolver,
private readonly NostrSigner $nostrSigner,
private readonly QRGenerator $qrGenerator,
private readonly LoggerInterface $logger,
) {}
/**
* Open the zap dialog
*/
#[LiveAction]
public function openDialog(): void
{
if (!$this->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(),
]);
}
}
}

153
templates/components/Molecules/ZapButton.html.twig

@ -0,0 +1,153 @@ @@ -0,0 +1,153 @@
<div {{ attributes.defaults({class: 'zap-button-component'}) }}>
{# Zap Button - always visible #}
<button
type="button"
class="btn btn-sm btn-outline-warning zap-trigger"
data-action="live#action"
data-live-action-param="openDialog"
title="Send a zap"
>
⚡ Zap
</button>
{# Modal Dialog #}
{% if this.open %}
<div class="zap-modal-overlay" data-action="click->live#action" data-live-action-param="closeDialog">
<div class="zap-modal-content card shadow-lg" onclick="event.stopPropagation()">
<div class="card-body">
{# Header #}
<div class="d-flex flex-row justify-content-between align-items-center mb-3">
<h5 class="m-0">⚡ Send Zap</h5>
<button
type="button"
class="btn btn-secondary"
data-action="live#action"
data-live-action-param="closeDialog"
>Close</button>
</div>
{# Phase: Input #}
{% if this.phase == 'input' %}
<form data-action="submit->live#action:prevent" data-live-action-param="createInvoice">
<div class="mb-3">
<label for="zap-amount" class="form-label">Amount (sats)</label>
<input
type="number"
id="zap-amount"
class="form-control"
min="1"
step="1"
data-model="debounce(300)|amount"
value="{{ amount }}"
required
autofocus
/>
<small class="form-text text-muted">Suggested: 21, 210, 2100 sats</small>
</div>
<div class="mb-3">
<label for="zap-comment" class="form-label">Note (optional)</label>
<textarea
id="zap-comment"
class="form-control"
rows="2"
data-model="norender|comment"
placeholder="Add a message..."
>{{ comment }}</textarea>
</div>
<button type="submit" class="btn btn-warning w-100">
⚡ Create Invoice
</button>
</form>
{% endif %}
{# Phase: Loading #}
{% if this.phase == 'loading' %}
<div class="text-center py-4">
<div class="spinner-border text-warning mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted">Creating invoice...</p>
</div>
{% endif %}
{# Phase: Invoice #}
{% if this.phase == 'invoice' %}
<div class="zap-invoice">
<div class="text-center mb-3">
<p class="text-success mb-2">✓ Invoice ready!</p>
<p class="text-muted small">Scan with your Lightning wallet</p>
</div>
{# QR Code #}
<div class="qr-container text-center mb-3">
{{ qrSvg|raw }}
</div>
{# BOLT11 Invoice String #}
<div class="mb-3">
<label class="form-label small text-muted">BOLT11 Invoice:</label>
<div class="input-group input-group-sm">
<input
type="text"
class="form-control font-monospace small"
value="{{ bolt11 }}"
readonly
onclick="this.select()"
/>
<button
class="btn btn-outline-secondary"
type="button"
onclick="navigator.clipboard.writeText('{{ bolt11|e('js') }}'); this.textContent='Copied!'; setTimeout(() => this.textContent='Copy', 2000)"
>
Copy
</button>
</div>
</div>
{# Lightning URI Link #}
<div class="text-center mb-3">
<a
href="lightning:{{ bolt11|upper }}"
class="btn btn-warning w-100"
>
⚡ Open in Wallet
</a>
<small class="form-text text-muted d-block mt-2">
Or scan the QR code above
</small>
</div>
{# Back button #}
<button
type="button"
class="btn btn-sm btn-outline-secondary w-100"
data-action="live#action"
data-live-action-param="openDialog"
>
← Create Another Zap
</button>
</div>
{% endif %}
{# Phase: Error #}
{% if this.phase == 'error' %}
<div class="alert alert-danger mb-3">
<strong>Error:</strong> {{ error }}
</div>
<button
type="button"
class="btn btn-outline-primary w-100"
data-action="live#action"
data-live-action-param="openDialog"
>
← Try Again
</button>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>

7
templates/partial/_author-section.html.twig

@ -6,6 +6,13 @@ @@ -6,6 +6,13 @@
</div>
{% endif %}
{% if author.lud16 is defined and author.lud16 %}
<twig:Molecules:ZapButton
recipientPubkey="{{ pubkey }}"
recipientLud16="{{ author.lud16 is iterable ? author.lud16|first : author.lud16 }}"
/>
{% endif %}
{% if author.nip05 is defined %}
{% if author.nip05 is iterable %}
{% for nip05Value in author.nip05 %}

Loading…
Cancel
Save