package main import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip04" ) type Config struct { ServicePubkey string ClientSecret string ClientPubkey string Relays []string Username string Domain string } type LnurlResponse struct { Callback string `json:"callback"` MaxSendable uint64 `json:"maxSendable"` MinSendable uint64 `json:"minSendable"` Metadata string `json:"metadata"` CommentAllowed int `json:"commentAllowed"` Tag string `json:"tag"` } type LnurlCallbackResponse struct { Pr string `json:"pr"` SuccessAction interface{} `json:"successAction"` Disposable *bool `json:"disposable"` Routes []interface{} `json:"routes"` } type NWCRequest struct { Method string `json:"method"` Params interface{} `json:"params"` } type CreateInvoiceParams struct { Amount uint64 `json:"amount"` Description string `json:"description,omitempty"` DescriptionHash string `json:"description_hash,omitempty"` } type NWCResponse struct { ResultType string `json:"result_type"` Result interface{} `json:"result,omitempty"` Error *NWCError `json:"error,omitempty"` } type NWCError struct { Code string `json:"code"` Message string `json:"message"` } type InvoiceResult struct { Invoice string `json:"invoice"` } // nwcClient is a NIP-47 request/response client that keeps a single persistent // relay connection and reuses it across requests instead of opening a fresh // WebSocket per invoice. Requests are serialized with a mutex so concurrent // callbacks never cross-talk on the shared connection. type nwcClient struct { mu sync.Mutex servicePubkey string clientSecret string clientPubkey string relays []string relay *nostr.Relay relayURL string } func parseNWCURI(nwcURI string) (servicePubkey, clientSecret string, relays []string, err error) { if !strings.HasPrefix(nwcURI, "nostr+walletconnect://") { err = fmt.Errorf("invalid NWC URI format") return } parts := strings.SplitN(nwcURI[22:], "?", 2) servicePubkey = parts[0] if len(parts) > 1 { params, _ := url.ParseQuery(parts[1]) relays = params["relay"] if len(relays) == 0 { relays = []string{"wss://relay.getalby.com"} } clientSecret = params.Get("secret") } return } func ensurePreferredRelay() { preferred := "wss://relay.mleku.dev" found := false for i, r := range config.Relays { if r == preferred { if i != 0 { config.Relays = append([]string{preferred}, append(config.Relays[:i], config.Relays[i+1:]...)...) } found = true break } } if !found { config.Relays = append([]string{preferred}, config.Relays...) } } // ensureRelay returns the persistent connection, (re)establishing it if it has // dropped. It walks the configured relays in order so a dead preferred relay // falls through to the next one. The connection is kept for reuse. func (c *nwcClient) ensureRelay(ctx context.Context) (*nostr.Relay, error) { if c.relay != nil && c.relay.IsConnected() { return c.relay, nil } if c.relay != nil { c.relay.Close() c.relay = nil c.relayURL = "" } var lastErr error for _, relayURL := range c.relays { relay, err := nostr.RelayConnect(ctx, relayURL) if err != nil { log.Printf("failed to connect to %s: %v", relayURL, err) lastErr = err continue } c.relay = relay c.relayURL = relayURL log.Printf("connected to %s", relayURL) return relay, nil } return nil, fmt.Errorf("all relays failed: %v", lastErr) } // makeInvoice sends a NIP-47 make_invoice request and returns the bolt11 invoice. // The caller-supplied ctx bounds the whole exchange; a per-request timeout is // layered on top so a hung wallet never leaves a subscription or goroutine behind. func (c *nwcClient) makeInvoice(ctx context.Context, amountMsat uint64, descriptionHash string) (string, error) { c.mu.Lock() defer c.mu.Unlock() req := NWCRequest{ Method: "make_invoice", Params: CreateInvoiceParams{ Amount: amountMsat, DescriptionHash: descriptionHash, }, } reqJSON, err := json.Marshal(req) if err != nil { return "", fmt.Errorf("failed to marshal request: %w", err) } sharedSecret, err := nip04.ComputeSharedSecret(c.servicePubkey, c.clientSecret) if err != nil { return "", fmt.Errorf("failed to compute shared secret: %v", err) } content, err := nip04.Encrypt(string(reqJSON), sharedSecret) if err != nil { return "", fmt.Errorf("failed to encrypt: %v", err) } ev := nostr.Event{ CreatedAt: nostr.Now(), Kind: 23194, Content: content, Tags: nostr.Tags{ {"p", c.servicePubkey}, }, } for _, r := range c.relays { ev.Tags = append(ev.Tags, nostr.Tag{"relay", r}) } if err := ev.Sign(c.clientSecret); err != nil { return "", fmt.Errorf("failed to sign event: %v", err) } // Bound the whole request/response exchange. reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() relay, err := c.ensureRelay(reqCtx) if err != nil { return "", err } // Subscribe before publishing so the response is never missed. Filter on // the request event id (#e) and our client pubkey (#p) so only the response // to THIS request matches - a late response to a previous request can't be // mis-delivered. The per-request context cancellation tears down the // subscription (go-nostr unsubs and closes sub.Events on ctx cancel). sub, err := relay.Subscribe(reqCtx, nostr.Filters{{ Kinds: []int{23195}, Authors: []string{c.servicePubkey}, Tags: nostr.TagMap{ "e": []string{ev.ID}, }, Limit: 1, }}) if err != nil { return "", fmt.Errorf("failed to subscribe: %v", err) } defer sub.Unsub() if err := relay.Publish(reqCtx, ev); err != nil { return "", fmt.Errorf("failed to publish: %v", err) } select { case msg, ok := <-sub.Events: if !ok { return "", fmt.Errorf("subscription closed") } sharedSecret, err := nip04.ComputeSharedSecret(c.servicePubkey, c.clientSecret) if err != nil { return "", fmt.Errorf("failed to compute shared secret: %v", err) } decrypted, err := nip04.Decrypt(msg.Content, sharedSecret) if err != nil { return "", fmt.Errorf("failed to decrypt response: %v", err) } var resp NWCResponse if err := json.Unmarshal([]byte(decrypted), &resp); err != nil { return "", fmt.Errorf("failed to parse response: %v", err) } if resp.Error != nil { return "", fmt.Errorf("NWC error: %s - %s", resp.Error.Code, resp.Error.Message) } resultJSON, _ := json.Marshal(resp.Result) var invoiceResult InvoiceResult if err := json.Unmarshal(resultJSON, &invoiceResult); err != nil { return "", fmt.Errorf("failed to parse invoice result: %v", err) } return invoiceResult.Invoice, nil case <-reqCtx.Done(): return "", fmt.Errorf("timeout waiting for wallet response") } } func handleLnurl(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/.well-known/lnurlp/") parts := strings.SplitN(path, "/", 2) if len(parts) == 1 || parts[1] == "" { username := parts[0] metadata := fmt.Sprintf(`[["text/plain","Pay %s@%s"],["text/identifier","%s@%s"]]`, username, r.Host, username, r.Host) commentAllowed := 256 resp := LnurlResponse{ Callback: fmt.Sprintf("https://%s/.well-known/lnurlp/%s/callback", r.Host, username), MaxSendable: 100000000, MinSendable: 1000, Metadata: metadata, CommentAllowed: commentAllowed, Tag: "payRequest", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) return } if parts[1] == "callback" { username := parts[0] amountStr := r.URL.Query().Get("amount") amountMsat, err := strconv.ParseUint(amountStr, 10, 64) if err != nil { http.Error(w, "Invalid amount", http.StatusBadRequest) return } metadata := fmt.Sprintf(`[["text/plain","Pay %s@%s"],["text/identifier","%s@%s"]]`, username, r.Host, username, r.Host) // Compute SHA256 hash of metadata for the h tag hash := sha256.Sum256([]byte(metadata)) descriptionHash := hex.EncodeToString(hash[:]) ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() invoice, err := nwc.makeInvoice(ctx, amountMsat, descriptionHash) if err != nil { log.Printf("failed to create invoice: %v", err) http.Error(w, "Failed to create invoice", http.StatusInternalServerError) return } disposable := false callbackResp := LnurlCallbackResponse{ Pr: invoice, SuccessAction: nil, Disposable: &disposable, Routes: []interface{}{}, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(callbackResp) return } http.NotFound(w, r) } var config Config var nwc *nwcClient func main() { nwcURI := os.Getenv("NWC_URI") if nwcURI == "" { log.Fatal("NWC_URI environment variable required") } var err error config.ServicePubkey, config.ClientSecret, config.Relays, err = parseNWCURI(nwcURI) if err != nil { log.Fatalf("Failed to parse NWC URI: %v", err) } ensurePreferredRelay() config.ClientPubkey, err = nostr.GetPublicKey(config.ClientSecret) if err != nil { log.Fatalf("Failed to derive client pubkey: %v", err) } nwc = &nwcClient{ servicePubkey: config.ServicePubkey, clientSecret: config.ClientSecret, clientPubkey: config.ClientPubkey, relays: config.Relays, } config.Username = os.Getenv("LNURL_USERNAME") if config.Username == "" { config.Username = "me" } config.Domain = os.Getenv("LNURL_DOMAIN") if config.Domain == "" { config.Domain = "mleku.dev" } log.Printf("LNURL server for %s@%s", config.Username, config.Domain) log.Printf("Relays: %v", config.Relays) mux := http.NewServeMux() mux.HandleFunc("/.well-known/lnurlp/", handleLnurl) port := os.Getenv("PORT") if port == "" { port = "8096" } // Explicit timeouts prevent slow/stalled clients from pinning connections // and goroutines open indefinitely. srv := &http.Server{ Addr: ":" + port, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } go func() { log.Printf("Listening on :%s", port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server error: %v", err) } }() // Graceful shutdown on SIGINT/SIGTERM. sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) <-sig log.Printf("shutting down...") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Printf("server shutdown error: %v", err) } if nwc != nil && nwc.relay != nil { nwc.relay.Close() } log.Printf("stopped") }