main.go raw
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "log"
10 "net/http"
11 "net/url"
12 "os"
13 "os/signal"
14 "strconv"
15 "strings"
16 "sync"
17 "syscall"
18 "time"
19
20 "github.com/nbd-wtf/go-nostr"
21 "github.com/nbd-wtf/go-nostr/nip04"
22 )
23
24 type Config struct {
25 ServicePubkey string
26 ClientSecret string
27 ClientPubkey string
28 Relays []string
29 Username string
30 Domain string
31 }
32
33 type LnurlResponse struct {
34 Callback string `json:"callback"`
35 MaxSendable uint64 `json:"maxSendable"`
36 MinSendable uint64 `json:"minSendable"`
37 Metadata string `json:"metadata"`
38 CommentAllowed int `json:"commentAllowed"`
39 Tag string `json:"tag"`
40 }
41
42 type LnurlCallbackResponse struct {
43 Pr string `json:"pr"`
44 SuccessAction interface{} `json:"successAction"`
45 Disposable *bool `json:"disposable"`
46 Routes []interface{} `json:"routes"`
47 }
48
49 type NWCRequest struct {
50 Method string `json:"method"`
51 Params interface{} `json:"params"`
52 }
53
54 type CreateInvoiceParams struct {
55 Amount uint64 `json:"amount"`
56 Description string `json:"description,omitempty"`
57 DescriptionHash string `json:"description_hash,omitempty"`
58 }
59
60 type NWCResponse struct {
61 ResultType string `json:"result_type"`
62 Result interface{} `json:"result,omitempty"`
63 Error *NWCError `json:"error,omitempty"`
64 }
65
66 type NWCError struct {
67 Code string `json:"code"`
68 Message string `json:"message"`
69 }
70
71 type InvoiceResult struct {
72 Invoice string `json:"invoice"`
73 }
74
75 // nwcClient is a NIP-47 request/response client that keeps a single persistent
76 // relay connection and reuses it across requests instead of opening a fresh
77 // WebSocket per invoice. Requests are serialized with a mutex so concurrent
78 // callbacks never cross-talk on the shared connection.
79 type nwcClient struct {
80 mu sync.Mutex
81 servicePubkey string
82 clientSecret string
83 clientPubkey string
84 relays []string
85
86 relay *nostr.Relay
87 relayURL string
88 }
89
90 func parseNWCURI(nwcURI string) (servicePubkey, clientSecret string, relays []string, err error) {
91 if !strings.HasPrefix(nwcURI, "nostr+walletconnect://") {
92 err = fmt.Errorf("invalid NWC URI format")
93 return
94 }
95
96 parts := strings.SplitN(nwcURI[22:], "?", 2)
97 servicePubkey = parts[0]
98
99 if len(parts) > 1 {
100 params, _ := url.ParseQuery(parts[1])
101 relays = params["relay"]
102 if len(relays) == 0 {
103 relays = []string{"wss://relay.getalby.com"}
104 }
105 clientSecret = params.Get("secret")
106 }
107
108 return
109 }
110
111 func ensurePreferredRelay() {
112 preferred := "wss://relay.mleku.dev"
113 found := false
114 for i, r := range config.Relays {
115 if r == preferred {
116 if i != 0 {
117 config.Relays = append([]string{preferred}, append(config.Relays[:i], config.Relays[i+1:]...)...)
118 }
119 found = true
120 break
121 }
122 }
123 if !found {
124 config.Relays = append([]string{preferred}, config.Relays...)
125 }
126 }
127
128 // ensureRelay returns the persistent connection, (re)establishing it if it has
129 // dropped. It walks the configured relays in order so a dead preferred relay
130 // falls through to the next one. The connection is kept for reuse.
131 func (c *nwcClient) ensureRelay(ctx context.Context) (*nostr.Relay, error) {
132 if c.relay != nil && c.relay.IsConnected() {
133 return c.relay, nil
134 }
135 if c.relay != nil {
136 c.relay.Close()
137 c.relay = nil
138 c.relayURL = ""
139 }
140 var lastErr error
141 for _, relayURL := range c.relays {
142 relay, err := nostr.RelayConnect(ctx, relayURL)
143 if err != nil {
144 log.Printf("failed to connect to %s: %v", relayURL, err)
145 lastErr = err
146 continue
147 }
148 c.relay = relay
149 c.relayURL = relayURL
150 log.Printf("connected to %s", relayURL)
151 return relay, nil
152 }
153 return nil, fmt.Errorf("all relays failed: %v", lastErr)
154 }
155
156 // makeInvoice sends a NIP-47 make_invoice request and returns the bolt11 invoice.
157 // The caller-supplied ctx bounds the whole exchange; a per-request timeout is
158 // layered on top so a hung wallet never leaves a subscription or goroutine behind.
159 func (c *nwcClient) makeInvoice(ctx context.Context, amountMsat uint64, descriptionHash string) (string, error) {
160 c.mu.Lock()
161 defer c.mu.Unlock()
162
163 req := NWCRequest{
164 Method: "make_invoice",
165 Params: CreateInvoiceParams{
166 Amount: amountMsat,
167 DescriptionHash: descriptionHash,
168 },
169 }
170 reqJSON, err := json.Marshal(req)
171 if err != nil {
172 return "", fmt.Errorf("failed to marshal request: %w", err)
173 }
174
175 sharedSecret, err := nip04.ComputeSharedSecret(c.servicePubkey, c.clientSecret)
176 if err != nil {
177 return "", fmt.Errorf("failed to compute shared secret: %v", err)
178 }
179
180 content, err := nip04.Encrypt(string(reqJSON), sharedSecret)
181 if err != nil {
182 return "", fmt.Errorf("failed to encrypt: %v", err)
183 }
184
185 ev := nostr.Event{
186 CreatedAt: nostr.Now(),
187 Kind: 23194,
188 Content: content,
189 Tags: nostr.Tags{
190 {"p", c.servicePubkey},
191 },
192 }
193 for _, r := range c.relays {
194 ev.Tags = append(ev.Tags, nostr.Tag{"relay", r})
195 }
196
197 if err := ev.Sign(c.clientSecret); err != nil {
198 return "", fmt.Errorf("failed to sign event: %v", err)
199 }
200
201 // Bound the whole request/response exchange.
202 reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
203 defer cancel()
204
205 relay, err := c.ensureRelay(reqCtx)
206 if err != nil {
207 return "", err
208 }
209
210 // Subscribe before publishing so the response is never missed. Filter on
211 // the request event id (#e) and our client pubkey (#p) so only the response
212 // to THIS request matches - a late response to a previous request can't be
213 // mis-delivered. The per-request context cancellation tears down the
214 // subscription (go-nostr unsubs and closes sub.Events on ctx cancel).
215 sub, err := relay.Subscribe(reqCtx, nostr.Filters{{
216 Kinds: []int{23195},
217 Authors: []string{c.servicePubkey},
218 Tags: nostr.TagMap{
219 "e": []string{ev.ID},
220 },
221 Limit: 1,
222 }})
223 if err != nil {
224 return "", fmt.Errorf("failed to subscribe: %v", err)
225 }
226 defer sub.Unsub()
227
228 if err := relay.Publish(reqCtx, ev); err != nil {
229 return "", fmt.Errorf("failed to publish: %v", err)
230 }
231
232 select {
233 case msg, ok := <-sub.Events:
234 if !ok {
235 return "", fmt.Errorf("subscription closed")
236 }
237 sharedSecret, err := nip04.ComputeSharedSecret(c.servicePubkey, c.clientSecret)
238 if err != nil {
239 return "", fmt.Errorf("failed to compute shared secret: %v", err)
240 }
241 decrypted, err := nip04.Decrypt(msg.Content, sharedSecret)
242 if err != nil {
243 return "", fmt.Errorf("failed to decrypt response: %v", err)
244 }
245 var resp NWCResponse
246 if err := json.Unmarshal([]byte(decrypted), &resp); err != nil {
247 return "", fmt.Errorf("failed to parse response: %v", err)
248 }
249 if resp.Error != nil {
250 return "", fmt.Errorf("NWC error: %s - %s", resp.Error.Code, resp.Error.Message)
251 }
252 resultJSON, _ := json.Marshal(resp.Result)
253 var invoiceResult InvoiceResult
254 if err := json.Unmarshal(resultJSON, &invoiceResult); err != nil {
255 return "", fmt.Errorf("failed to parse invoice result: %v", err)
256 }
257 return invoiceResult.Invoice, nil
258
259 case <-reqCtx.Done():
260 return "", fmt.Errorf("timeout waiting for wallet response")
261 }
262 }
263
264 func handleLnurl(w http.ResponseWriter, r *http.Request) {
265 path := strings.TrimPrefix(r.URL.Path, "/.well-known/lnurlp/")
266 parts := strings.SplitN(path, "/", 2)
267
268 if len(parts) == 1 || parts[1] == "" {
269 username := parts[0]
270 metadata := fmt.Sprintf(`[["text/plain","Pay %s@%s"],["text/identifier","%s@%s"]]`,
271 username, r.Host, username, r.Host)
272
273 commentAllowed := 256
274
275 resp := LnurlResponse{
276 Callback: fmt.Sprintf("https://%s/.well-known/lnurlp/%s/callback", r.Host, username),
277 MaxSendable: 100000000,
278 MinSendable: 1000,
279 Metadata: metadata,
280 CommentAllowed: commentAllowed,
281 Tag: "payRequest",
282 }
283
284 w.Header().Set("Content-Type", "application/json")
285 json.NewEncoder(w).Encode(resp)
286 return
287 }
288
289 if parts[1] == "callback" {
290 username := parts[0]
291 amountStr := r.URL.Query().Get("amount")
292 amountMsat, err := strconv.ParseUint(amountStr, 10, 64)
293 if err != nil {
294 http.Error(w, "Invalid amount", http.StatusBadRequest)
295 return
296 }
297
298 metadata := fmt.Sprintf(`[["text/plain","Pay %s@%s"],["text/identifier","%s@%s"]]`,
299 username, r.Host, username, r.Host)
300
301 // Compute SHA256 hash of metadata for the h tag
302 hash := sha256.Sum256([]byte(metadata))
303 descriptionHash := hex.EncodeToString(hash[:])
304
305 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
306 defer cancel()
307
308 invoice, err := nwc.makeInvoice(ctx, amountMsat, descriptionHash)
309 if err != nil {
310 log.Printf("failed to create invoice: %v", err)
311 http.Error(w, "Failed to create invoice", http.StatusInternalServerError)
312 return
313 }
314
315 disposable := false
316
317 callbackResp := LnurlCallbackResponse{
318 Pr: invoice,
319 SuccessAction: nil,
320 Disposable: &disposable,
321 Routes: []interface{}{},
322 }
323
324 w.Header().Set("Content-Type", "application/json")
325 json.NewEncoder(w).Encode(callbackResp)
326 return
327 }
328
329 http.NotFound(w, r)
330 }
331
332 var config Config
333 var nwc *nwcClient
334
335 func main() {
336 nwcURI := os.Getenv("NWC_URI")
337 if nwcURI == "" {
338 log.Fatal("NWC_URI environment variable required")
339 }
340
341 var err error
342 config.ServicePubkey, config.ClientSecret, config.Relays, err = parseNWCURI(nwcURI)
343 if err != nil {
344 log.Fatalf("Failed to parse NWC URI: %v", err)
345 }
346
347 ensurePreferredRelay()
348
349 config.ClientPubkey, err = nostr.GetPublicKey(config.ClientSecret)
350 if err != nil {
351 log.Fatalf("Failed to derive client pubkey: %v", err)
352 }
353
354 nwc = &nwcClient{
355 servicePubkey: config.ServicePubkey,
356 clientSecret: config.ClientSecret,
357 clientPubkey: config.ClientPubkey,
358 relays: config.Relays,
359 }
360
361 config.Username = os.Getenv("LNURL_USERNAME")
362 if config.Username == "" {
363 config.Username = "me"
364 }
365
366 config.Domain = os.Getenv("LNURL_DOMAIN")
367 if config.Domain == "" {
368 config.Domain = "mleku.dev"
369 }
370
371 log.Printf("LNURL server for %s@%s", config.Username, config.Domain)
372 log.Printf("Relays: %v", config.Relays)
373
374 mux := http.NewServeMux()
375 mux.HandleFunc("/.well-known/lnurlp/", handleLnurl)
376
377 port := os.Getenv("PORT")
378 if port == "" {
379 port = "8096"
380 }
381
382 // Explicit timeouts prevent slow/stalled clients from pinning connections
383 // and goroutines open indefinitely.
384 srv := &http.Server{
385 Addr: ":" + port,
386 Handler: mux,
387 ReadHeaderTimeout: 5 * time.Second,
388 ReadTimeout: 30 * time.Second,
389 WriteTimeout: 30 * time.Second,
390 IdleTimeout: 60 * time.Second,
391 }
392
393 go func() {
394 log.Printf("Listening on :%s", port)
395 if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
396 log.Fatalf("server error: %v", err)
397 }
398 }()
399
400 // Graceful shutdown on SIGINT/SIGTERM.
401 sig := make(chan os.Signal, 1)
402 signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
403 <-sig
404 log.Printf("shutting down...")
405 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
406 defer shutdownCancel()
407 if err := srv.Shutdown(shutdownCtx); err != nil {
408 log.Printf("server shutdown error: %v", err)
409 }
410 if nwc != nil && nwc.relay != nil {
411 nwc.relay.Close()
412 }
413 log.Printf("stopped")
414 }
415