/
lnurl.go
386 lines (350 loc) · 11.9 KB
/
lnurl.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
package lnurl
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/eko/gocache/store"
tb "gopkg.in/lightningtipbot/telebot.v3"
"github.com/LightningTipBot/LightningTipBot/internal"
"github.com/LightningTipBot/LightningTipBot/internal/api"
"github.com/LightningTipBot/LightningTipBot/internal/storage"
"gorm.io/gorm"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
"github.com/LightningTipBot/LightningTipBot/internal/runtime"
"github.com/LightningTipBot/LightningTipBot/internal/telegram"
"github.com/fiatjaf/go-lnurl"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
)
const (
PayRequestTag = "payRequest"
Endpoint = ".well-known/lnurlp"
MinSendable = 1000 // mSat
MaxSendable = 1_000_000_000
CommentAllowed = 500
)
type Invoice struct {
*telegram.Invoice
Comment string `json:"comment"`
User *lnbits.User `json:"user"`
CreatedAt time.Time `json:"created_at"`
Paid bool `json:"paid"`
PaidAt time.Time `json:"paid_at"`
From string `json:"from"`
}
type Lnurl struct {
telegram *tb.Bot
c *lnbits.Client
database *gorm.DB
callbackHostname *url.URL
buntdb *storage.DB
WebhookServer string
cache telegram.Cache
}
func New(bot *telegram.TipBot) Lnurl {
return Lnurl{
c: bot.Client,
database: bot.DB.Users,
callbackHostname: internal.Configuration.Bot.LNURLHostUrl,
WebhookServer: internal.Configuration.Lnbits.WebhookServer,
buntdb: bot.Bunt,
telegram: bot.Telegram,
cache: bot.Cache,
}
}
func (lnurlInvoice Invoice) Key() string {
return fmt.Sprintf("lnurl-p:%s", lnurlInvoice.PaymentHash)
}
func (w Lnurl) Handle(writer http.ResponseWriter, request *http.Request) {
var err error
var response interface{}
username := mux.Vars(request)["username"]
if request.URL.RawQuery == "" {
response, err = w.serveLNURLpFirst(username)
} else {
stringAmount := request.FormValue("amount")
if stringAmount == "" {
api.NotFoundHandler(writer, fmt.Errorf("[handleLnUrl] Form value 'amount' is not set"))
return
}
var amount int64
if amount, err = strconv.ParseInt(stringAmount, 10, 64); err != nil {
// if the value wasn't a clean msat denomination, parse it
amount, err = telegram.GetAmount(stringAmount)
if err != nil {
api.NotFoundHandler(writer, fmt.Errorf("[handleLnUrl] Couldn't cast amount to int: %v", err))
return
}
// GetAmount returns sat, we need msat
amount *= 1000
}
comment := request.FormValue("comment")
if len(comment) > CommentAllowed {
api.NotFoundHandler(writer, fmt.Errorf("[handleLnUrl] Comment is too long"))
return
}
// payer data
payerdata := request.FormValue("payerdata")
var payerData lnurl.PayerDataValues
err = json.Unmarshal([]byte(payerdata), &payerData)
if err != nil {
// api.NotFoundHandler(writer, fmt.Errorf("[handleLnUrl] Couldn't parse payerdata: %v", err))
log.Errorf("[handleLnUrl] Couldn't parse payerdata: %v", err)
// log.Errorf("[handleLnUrl] payerdata: %v", payerdata)
}
response, err = w.serveLNURLpSecond(username, int64(amount), comment, payerData)
}
// check if error was returned from first or second handlers
if err != nil {
// log the error
log.Errorf("[LNURL] %v", err.Error())
if response != nil {
// there is a valid error response
err = api.WriteResponse(writer, response)
if err != nil {
api.NotFoundHandler(writer, err)
}
}
return
}
// no error from first or second handler
err = api.WriteResponse(writer, response)
if err != nil {
api.NotFoundHandler(writer, err)
}
}
func (w Lnurl) getMetaDataCached(username string) lnurl.Metadata {
key := fmt.Sprintf("lnurl_metadata_%s", username)
// load metadata from cache
if m, err := w.cache.Get(key); err == nil {
return m.(lnurl.Metadata)
}
// otherwise, create new metadata
metadata := w.metaData(username)
// load the user profile picture
if internal.Configuration.Bot.LNURLSendImage {
// get the user from the database
user, tx := findUser(w.database, username)
if tx.Error == nil && user.Telegram != nil {
addImageToMetaData(w.telegram, &metadata, username, user.Telegram)
}
}
// save into cache
runtime.IgnoreError(w.cache.Set(key, metadata, &store.Options{Expiration: 30 * time.Minute}))
return metadata
}
// serveLNURLpFirst serves the first part of the LNURLp protocol with the endpoint
// to call and the metadata that matches the description hash of the second response
func (w Lnurl) serveLNURLpFirst(username string) (*lnurl.LNURLPayParams, error) {
log.Infof("[LNURL] Serving endpoint for user %s", username)
callbackURL, err := url.Parse(fmt.Sprintf("%s/%s/%s", w.callbackHostname.String(), Endpoint, username))
if err != nil {
return nil, err
}
// produce the metadata including the image
metadata := w.getMetaDataCached(username)
return &lnurl.LNURLPayParams{
LNURLResponse: lnurl.LNURLResponse{Status: api.StatusOk},
Tag: PayRequestTag,
Callback: callbackURL.String(),
MinSendable: MinSendable,
MaxSendable: MaxSendable,
EncodedMetadata: metadata.Encode(),
CommentAllowed: CommentAllowed,
PayerData: &lnurl.PayerDataSpec{
FreeName: &lnurl.PayerDataItemSpec{},
LightningAddress: &lnurl.PayerDataItemSpec{},
Email: &lnurl.PayerDataItemSpec{},
},
}, nil
}
// serveLNURLpSecond serves the second LNURL response with the payment request with the correct description hash
func (w Lnurl) serveLNURLpSecond(username string, amount_msat int64, comment string, payerData lnurl.PayerDataValues) (*lnurl.LNURLPayValues, error) {
log.Infof("[LNURL] Serving invoice for user %s", username)
if amount_msat < MinSendable || amount_msat > MaxSendable {
// amount is not ok
return &lnurl.LNURLPayValues{
LNURLResponse: lnurl.LNURLResponse{
Status: api.StatusError,
Reason: fmt.Sprintf("Amount out of bounds (min: %d sat, max: %d sat).", MinSendable/1000, MaxSendable/1000)},
}, fmt.Errorf("amount out of bounds")
}
// check comment length
if len(comment) > CommentAllowed {
return &lnurl.LNURLPayValues{
LNURLResponse: lnurl.LNURLResponse{
Status: api.StatusError,
Reason: fmt.Sprintf("Comment too long (max: %d characters).", CommentAllowed)},
}, fmt.Errorf("comment too long")
}
user, tx := findUser(w.database, username)
if tx.Error != nil {
return &lnurl.LNURLPayValues{
LNURLResponse: lnurl.LNURLResponse{
Status: api.StatusError,
Reason: fmt.Sprintf("Invalid user.")},
}, fmt.Errorf("[GetUser] Couldn't fetch user info from database: %v", tx.Error)
}
if user.Wallet == nil {
return &lnurl.LNURLPayValues{
LNURLResponse: lnurl.LNURLResponse{
Status: api.StatusError,
Reason: fmt.Sprintf("Invalid user.")},
}, fmt.Errorf("[serveLNURLpSecond] user %s not found", username)
}
// user is ok now create invoice
// set wallet lnbits client
var resp *lnurl.LNURLPayValues
// the same description_hash needs to be built in the second request
metadata := w.getMetaDataCached(username)
var payerDataByte []byte
var err error
if payerData.Email != "" || payerData.LightningAddress != "" || payerData.FreeName != "" {
payerDataByte, err = json.Marshal(payerData)
if err != nil {
return nil, err
}
} else {
payerDataByte = []byte("")
}
descriptionHash, err := w.DescriptionHash(metadata, string(payerDataByte))
if err != nil {
return nil, err
}
invoice, err := user.Wallet.Invoice(
lnbits.InvoiceParams{
Amount: amount_msat / 1000,
Out: false,
DescriptionHash: descriptionHash,
Webhook: w.WebhookServer},
w.c)
if err != nil {
err = fmt.Errorf("[serveLNURLpSecond] Couldn't create invoice: %v", err.Error())
resp = &lnurl.LNURLPayValues{
LNURLResponse: lnurl.LNURLResponse{
Status: api.StatusError,
Reason: "Couldn't create invoice."},
}
return resp, err
}
invoiceStruct := &telegram.Invoice{
PaymentRequest: invoice.PaymentRequest,
PaymentHash: invoice.PaymentHash,
Amount: amount_msat / 1000,
}
// save lnurl invoice struct for later use (will hold the comment or other metadata for a notification when paid)
runtime.IgnoreError(w.buntdb.Set(
Invoice{
Invoice: invoiceStruct,
User: user,
Comment: comment,
CreatedAt: time.Now(),
From: extractSenderFromPayerdata(payerData),
}))
// save the invoice Event that will be loaded when the invoice is paid and trigger the comment display callback
runtime.IgnoreError(w.buntdb.Set(
telegram.InvoiceEvent{
Invoice: invoiceStruct,
User: user,
Callback: telegram.InvoiceCallbackLNURLPayReceive,
}))
return &lnurl.LNURLPayValues{
LNURLResponse: lnurl.LNURLResponse{Status: api.StatusOk},
PR: invoice.PaymentRequest,
Routes: make([]struct{}, 0),
SuccessAction: &lnurl.SuccessAction{Message: "Payment received!", Tag: "message"},
}, nil
}
// DescriptionHash is the SHA256 hash of the metadata
func (w Lnurl) DescriptionHash(metadata lnurl.Metadata, payerData string) (string, error) {
var hashString string
var hash [32]byte
if len(payerData) == 0 {
hash = sha256.Sum256([]byte(metadata.Encode()))
hashString = hex.EncodeToString(hash[:])
} else {
hash = sha256.Sum256([]byte(metadata.Encode() + payerData))
hashString = hex.EncodeToString(hash[:])
}
return hashString, nil
}
// metaData returns the metadata that is sent in the first response
// and is used again in the second response to verify the description hash
func (w Lnurl) metaData(username string) lnurl.Metadata {
// this is a bit stupid but if the address is a UUID starting with 1x...
// we actually want to find the users username so it looks nicer in the
// metadata description
if strings.HasPrefix(username, "1x") {
user, _ := findUser(w.database, username)
if user.Telegram.Username != "" {
username = user.Telegram.Username
}
}
return lnurl.Metadata{
Description: fmt.Sprintf("Pay to %s@%s", username, w.callbackHostname.Hostname()),
LightningAddress: fmt.Sprintf("%s@%s", username, w.callbackHostname.Hostname()),
}
}
// addImageMetaData add images an image to the LNURL metadata
func addImageToMetaData(tb *tb.Bot, metadata *lnurl.Metadata, username string, user *tb.User) {
metadata.Image.Ext = "jpeg"
// if the username is anonymous, add the bot's picture
if isAnonUsername(username) {
metadata.Image.Bytes = telegram.BotProfilePicture
return
}
// if the user has a profile picture, add it
picture, err := telegram.DownloadProfilePicture(tb, user)
if err != nil {
log.Debugf("[LNURL] Couldn't download user %s's profile picture: %v", username, err)
// in case the user has no image, use bot's picture
metadata.Image.Bytes = telegram.BotProfilePicture
return
}
metadata.Image.Bytes = picture
}
func isAnonUsername(username string) bool {
if _, err := strconv.ParseInt(username, 10, 64); err == nil {
return true
} else {
return strings.HasPrefix(username, "0x")
}
}
func findUser(database *gorm.DB, username string) (*lnbits.User, *gorm.DB) {
// now check for the user
user := &lnbits.User{}
// check if "username" is actually the user ID
tx := database
if _, err := strconv.ParseInt(username, 10, 64); err == nil {
// asume it's anon_id
tx = database.Where("anon_id = ?", username).First(user)
} else if strings.HasPrefix(username, "0x") {
// asume it's anon_id_sha256
tx = database.Where("anon_id_sha256 = ?", username).First(user)
} else if strings.HasPrefix(username, "1x") {
// asume it's uuid
tx = database.Where("uuid = ?", username).First(user)
} else {
// assume it's a string @username
tx = database.Where("telegram_username = ? COLLATE NOCASE", username).First(user)
}
return user, tx
}
func extractSenderFromPayerdata(payer lnurl.PayerDataValues) string {
if payer.LightningAddress != "" {
return payer.LightningAddress
}
if payer.Email != "" {
return payer.Email
}
if payer.FreeName != "" {
return payer.FreeName
}
return ""
}