package alby import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net" "net/http" "net/url" "strconv" "sync" "time" "git.devvul.com/asara/gologger" "git.devvul.com/asara/well-goknown/config" "github.com/gorilla/schema" "github.com/jmoiron/sqlx" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip04" ) var ( DB *sqlx.DB qsDecoder = schema.NewDecoder() ) type AlbyApp struct { Id int32 `json:"id"` Name string `json:"name"` AppPubkey string `json:"appPubkey"` } type AlbyApps []AlbyApp type lnurlp struct { Status string `json:"status"` Tag string `json:"tag"` CommentAllowed int32 `json:"commentAllowed"` Callback string `json:"callback"` MinSendable int64 `json:"minSendable"` MaxSendable int64 `json:"maxSendable"` Metadata string `json:"metadata"` AllowsNostr bool `json:"allowsNostr"` NostrPubkey string `json:"nostrPubkey"` } type lnurlpError struct { Status string `json:"status"` Reason string `json:"reason"` } type ZapEvent struct { Id string `json:"id"` Pubkey string `json:"pubkey"` CreatedAt nostr.Timestamp `json:"created_at"` Kind int `json:"kind"` Tags nostr.Tags `json:"tags"` Content string `json:"content"` Signature string `json:"sig"` } type NWCReq struct { Nostr string `json:"nostr"` Amount string `json:"amount"` Comment string `json:"comment"` LNUrl string `json:"lnurl"` } type NWCSecret struct { Name string Domain string Wallet string ClientPubkey string AppPubkey string Relay string Secret string } func (s *NWCSecret) decodeSecret() { l := gologger.Get(config.GetConfig().LogLevel).With().Caller().Logger() u, err := url.Parse(s.Wallet) if err != nil { l.Error().Msgf("failed") } s.AppPubkey = u.Host q, _ := url.ParseQuery(u.RawQuery) s.Relay = q.Get("relay") s.Secret = q.Get("secret") } type LookupInvoiceParams struct { Invoice string `json:"invoice"` } type LookupInvoice struct { Method string `json:"method"` Params LookupInvoiceParams `json:"params"` } type LookupInvoiceResponseResult struct { Type string `json:"type"` State string `json:"state"` Invoice string `json:"invoice"` Description string `json:"description"` DescriptionHash string `json:"description_hash"` Preimage string `json:"preimage"` PaymentHash string `json:"payment_hash"` Amount int64 `json:"amount"` FeesPaid int64 `json:"fees_paid"` CreatedAt nostr.Timestamp `json:"created_at"` ExpiresAt nostr.Timestamp `json:"expires_at"` SettledAt nostr.Timestamp `json:"settled_at"` Metadata string `json:"metadata"` } func (s *LookupInvoiceResponseResult) isExpired() bool { if time.Now().Unix() > s.ExpiresAt.Time().Unix() { return true } return false } func (s *LookupInvoiceResponseResult) isSettled() bool { if s.SettledAt.Time().Unix() != 0 { return true } return false } type LookupInvoiceResponse struct { Result LookupInvoiceResponseResult `json:"result"` ResultType string `json:"result_type"` } type MakeInvoiceParams struct { Amount int64 `json:"amount"` Description string `json:"description"` DescriptionHash string `json:"description_hash"` Expiry int32 `json:"expiry"` } type MakeInvoice struct { Method string `json:"method"` Params MakeInvoiceParams `json:"params"` } type MakeInvoiceResponseResult struct { Type string `json:"type"` Invoice string `json:"invoice"` Description string `json:"description"` DescriptionHash string `json:"description_hash"` Preimage string `json:"preimage"` PaymentHash string `json:"payment_hash"` Amount int64 `json:"amount"` FeesPaid int64 `json:"fees_paid"` CreatedAt int64 `json:"created_at"` ExpiresAt int64 `json:"expires_at"` SettledAt int64 `json:"settled_at"` Metadata string `json:"metadata"` } type MakeInvoiceResponse struct { Result MakeInvoiceResponseResult `json:"result"` ResultType string `json:"result_type"` } type InvoiceResponse struct { Invoice string `json:"pr"` Routes []int `json:"routes"` } func GetLnurlp(w http.ResponseWriter, r *http.Request) { l := gologger.Get(config.GetConfig().LogLevel).With().Caller().Logger() albyAdmin := config.GetConfig().AlbyAdminAuth // setup response type w.Header().Set("Content-Type", "application/json") // normalize domain domain, _, err := net.SplitHostPort(r.Host) if err != nil { domain = r.Host } name := r.PathValue("name") // get all alby apps client := http.Client{} req, err := http.NewRequest("GET", "https://alby.devvul.com/api/apps", nil) if err != nil { l.Error().Msgf("unable to generate alby request for %s@%s: %s", name, domain, err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unknown error"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } req.Header = http.Header{"Authorization": {fmt.Sprintf("Bearer %s", albyAdmin)}} resp, err := client.Do(req) defer resp.Body.Close() var albyApps AlbyApps err = json.NewDecoder(resp.Body).Decode(&albyApps) if err != nil { l.Error().Msgf("unable to unmarshal alby request for %s@%s: %s", name, domain, err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unknown error"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } // check if user exists var npk string for _, element := range albyApps { if element.Name == name { npk = element.AppPubkey } } if len(npk) == 0 { l.Debug().Msgf("user doesn't exist in alby %s@%s", name, domain) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "user does not exist"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } // get server pubkey var secret NWCSecret err = DB.QueryRow("SELECT name, domain, wallet, pubkey FROM lnwallets WHERE name=$1 AND domain=$2", name, domain). Scan(&secret.Name, &secret.Domain, &secret.Wallet, &secret.ClientPubkey) if err != nil { l.Debug().Msgf("user doesn't exist in alby %s@%s: %s", name, domain, err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "user does not exist"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } secret.decodeSecret() lnurlpReturn := &lnurlp{ Status: "OK", Tag: "payRequest", CommentAllowed: 255, Callback: fmt.Sprintf("https://%s/.well-known/lnurlp/%s/callback", domain, name), MinSendable: 1000, MaxSendable: 10000000, Metadata: fmt.Sprintf("[[\"text/plain\", \"ln address payment to %s on the devvul server\"],[\"text/identifier\", \"%s@%s\"]]", name, name, domain), AllowsNostr: true, NostrPubkey: secret.ClientPubkey, } ret, err := json.Marshal(lnurlpReturn) if err != nil { l.Error().Msgf("unable to marshal json for %s@%s: %s", name, domain, err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "User not found"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } l.Debug().Msgf("returning pay request callback for %s@%s", name, domain) w.WriteHeader(http.StatusOK) w.Write(ret) return } func GetLnurlpCallback(w http.ResponseWriter, r *http.Request) { l := gologger.Get(config.GetConfig().LogLevel).With().Caller().Logger() var nwc NWCReq var zapEvent ZapEvent // normalize domain domain, _, err := net.SplitHostPort(r.Host) if err != nil { domain = r.Host } name := r.PathValue("name") err = qsDecoder.Decode(&nwc, r.URL.Query()) if err != nil { l.Error().Msgf("unable to marshal json for %s@%s: %s", name, domain, err.Error()) } // get NWC secret var secret NWCSecret err = DB.QueryRow("SELECT name, domain, wallet, pubkey FROM lnwallets WHERE name=$1 AND domain=$2", name, domain). Scan(&secret.Name, &secret.Domain, &secret.Wallet, &secret.ClientPubkey) if err != nil { l.Debug().Msgf("user doesn't exist in alby %s@%s: %s", name, domain, err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "user does not exist"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } secret.decodeSecret() // if there is a nostr payload unmarshal it if nwc.Nostr != "" { err = json.Unmarshal([]byte(nwc.Nostr), &zapEvent) if err != nil { l.Debug().Msgf("unable to unmarshal nwc value: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to connect to relay"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } ok := checkEvent(nwc.Nostr) if !ok { l.Debug().Msgf("nostr event is not valid", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "check your request and try again"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } } // connect to the relay relayCtx, relayCancel := context.WithTimeout(context.Background(), 10*time.Second) defer relayCancel() relay, err := nostr.RelayConnect(relayCtx, secret.Relay) if err != nil { l.Debug().Msgf("unable to connect to relay: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to connect to relay"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } // create make_invoice request amt, err := strconv.ParseInt(nwc.Amount, 10, 64) if err != nil { l.Debug().Msgf("unable to parse amount: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } var hash string if nwc.Nostr != "" { sha := sha256.Sum256([]byte(nwc.Nostr)) hash = hex.EncodeToString(sha[:]) } if nwc.Nostr == "" { hash = "" } invoiceParams := MakeInvoiceParams{ Amount: amt, Description: nwc.Nostr, DescriptionHash: hash, Expiry: 300, } invoice := MakeInvoice{ Method: "make_invoice", Params: invoiceParams, } invoiceJson, err := json.Marshal(invoice) if err != nil { l.Debug().Msgf("unable to marshal invoice: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } // generate nip-04 shared secret sharedSecret, err := nip04.ComputeSharedSecret(secret.AppPubkey, secret.Secret) if err != nil { l.Debug().Msgf("unable to marshal invoice: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } // create the encrypted content payload encryptedContent, err := nip04.Encrypt(string(invoiceJson), sharedSecret) if err != nil { l.Debug().Msgf("unable to marshal invoice: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } recipient := nostr.Tag{"p", secret.AppPubkey} nwcEv := nostr.Event{ PubKey: secret.ClientPubkey, CreatedAt: nostr.Now(), Kind: nostr.KindNWCWalletRequest, Tags: nostr.Tags{recipient}, Content: encryptedContent, } // sign the message with the app token nwcEv.Sign(secret.Secret) var filters nostr.Filters t := make(map[string][]string) t["e"] = []string{nwcEv.GetID()} filters = []nostr.Filter{ { Kinds: []int{ nostr.KindNWCWalletResponse, }, Tags: t, }, } subCtx, subCancel := context.WithTimeout(context.Background(), 5*time.Second) defer subCancel() sub, err := relay.Subscribe(subCtx, filters) if err != nil { l.Debug().Msgf("unable to connect to relay: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to connect to relay"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } var wg sync.WaitGroup wg.Add(1) // watch for the invoice evs := make([]nostr.Event, 0, 1) go func() { defer wg.Done() for { select { case ev, ok := <-sub.Events: if !ok { l.Debug().Msgf("subscription events channel is closed") return } if ev.Kind != 0 { evs = append(evs, *ev) } if len(evs) > 0 { return } case <-sub.EndOfStoredEvents: l.Trace().Msgf("end of stored events received") case <-subCtx.Done(): l.Debug().Msgf("subscription context cancelled or done: %v", subCtx.Err()) return } } }() // publish the invoice request if err := relay.Publish(relayCtx, nwcEv); err != nil { l.Debug().Msgf("unable to marshal invoice: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } // wait for the invoice to get returned wg.Wait() // decrypt the invoice response, err := nip04.Decrypt(evs[0].Content, sharedSecret) resStruct := MakeInvoiceResponse{} err = json.Unmarshal([]byte(response), &resStruct) if err != nil { l.Debug().Msgf("unable to create invoice: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to connect to relay"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } retStruct := InvoiceResponse{ Invoice: resStruct.Result.Invoice, Routes: []int{}, } // return the invoice to the requester ret, err := json.Marshal(retStruct) if err != nil { l.Error().Msgf("unable to marshal json for invoice: %s", err.Error()) lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create invoice"} retError, _ := json.Marshal(lnurlpReturnError) w.WriteHeader(http.StatusNotFound) w.Write(retError) return } if nwc.Nostr != "" { l.Debug().Msgf("starting background job for invoice") go watchForReceipt(nwc.Nostr, secret, retStruct.Invoice) } l.Info().Msg("returning lnurl-p payload") w.WriteHeader(http.StatusOK) w.Write(ret) }