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