multi_pay_invoice_controller_test.go raw
1 package controllers
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "slices"
8 "sync"
9 "testing"
10
11 "github.com/getAlby/go-nostr"
12 "github.com/sirupsen/logrus"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "github.com/getAlby/hub/constants"
17 "github.com/getAlby/hub/db"
18 "github.com/getAlby/hub/lnclient"
19 "github.com/getAlby/hub/logger"
20 "github.com/getAlby/hub/nip47/models"
21 "github.com/getAlby/hub/tests"
22 )
23
24 const nip47MultiPayJson = `
25 {
26 "method": "multi_pay_invoice",
27 "params": {
28 "invoices": [{
29 "invoice": "lntbs1230n1pnkqautdqyw3jsnp4q09a0z84kg4a2m38zjllw43h953fx5zvqe8qxfgw694ymkq26u8zcpp5yvnh6hsnlnj4xnuh2trzlnunx732dv8ta2wjr75pdfxf6p2vlyassp5hyeg97a3ft5u769kjwsn7p0e85h79pzz8kladmnqhpcypz2uawjs9qyysgqcqpcxq8zals8sq9yeg2pa9eywkgj50cyzxd5elatujuc0c0wh6j9nat5mn34pgk8u9ufpgs99tw9ldlfk42cqlkr48au3lmuh09269prg4qkggh4a8cyqpfl0y6j"
30 },
31 {
32 "invoice": "lntbs1230n1pnkq7q2dqqnp4q09a0z84kg4a2m38zjllw43h953fx5zvqe8qxfgw694ymkq26u8zcpp54sde879ktfrwnt4re3t2ckkrt5tr6dgv6cfdjgkar7942ruccvuqsp52qlk3rxr926s630fmnc5mg6sexnng4cyyfas4msrms8j6q28j8ys9qyysgqcqpcxq8zals8sqgjd3a60n6dy92jn7ggtkywhw952sc302qj0cwfupp7gayadznaj5cahvuq7py8p7hnq8yxylru6279urzxta3783cxze2atj9zmwadcq36muep"
33 }
34 ]
35 }
36 }
37 `
38
39 const nip47MultiPayOneMalformedInvoiceJson = `
40 {
41 "method": "multi_pay_invoice",
42 "params": {
43 "invoices": [{
44 "invoice": "",
45 "id": "invoiceId123"
46 },
47 {
48 "invoice": "lntbs1230n1pnkqautdqyw3jsnp4q09a0z84kg4a2m38zjllw43h953fx5zvqe8qxfgw694ymkq26u8zcpp5yvnh6hsnlnj4xnuh2trzlnunx732dv8ta2wjr75pdfxf6p2vlyassp5hyeg97a3ft5u769kjwsn7p0e85h79pzz8kladmnqhpcypz2uawjs9qyysgqcqpcxq8zals8sq9yeg2pa9eywkgj50cyzxd5elatujuc0c0wh6j9nat5mn34pgk8u9ufpgs99tw9ldlfk42cqlkr48au3lmuh09269prg4qkggh4a8cyqpfl0y6j"
49 }
50 ]
51 }
52 }
53 `
54
55 // the first invoice is expired
56 const nip47MultiPayOneExpiredInvoiceJson = `
57 {
58 "method": "multi_pay_invoice",
59 "params": {
60 "invoices": [{
61 "invoice": "lntb1230n1pjypux0pp5xgxzcks5jtx06k784f9dndjh664wc08ucrganpqn52d0ftrh9n8sdqyw3jscqzpgxqyz5vqsp5rkx7cq252p3frx8ytjpzc55rkgyx2mfkzzraa272dqvr2j6leurs9qyyssqhutxa24r5hqxstchz5fxlslawprqjnarjujp5sm3xj7ex73s32sn54fthv2aqlhp76qmvrlvxppx9skd3r5ut5xutgrup8zuc6ay73gqmra29m"
62 },
63 {
64 "invoice": "lntbs1230n1pnkqautdqyw3jsnp4q09a0z84kg4a2m38zjllw43h953fx5zvqe8qxfgw694ymkq26u8zcpp5yvnh6hsnlnj4xnuh2trzlnunx732dv8ta2wjr75pdfxf6p2vlyassp5hyeg97a3ft5u769kjwsn7p0e85h79pzz8kladmnqhpcypz2uawjs9qyysgqcqpcxq8zals8sq9yeg2pa9eywkgj50cyzxd5elatujuc0c0wh6j9nat5mn34pgk8u9ufpgs99tw9ldlfk42cqlkr48au3lmuh09269prg4qkggh4a8cyqpfl0y6j"
65 }
66 ]
67 }
68 }
69 `
70 const MockExpiredPaymentHash = "320c2c5a1492ccfd5bc7aa4ad9b657d6aaec3cfcc0d1d98413a29af4ac772ccf" // for the expired invoice
71
72 func TestHandleMultiPayInvoiceEvent_Success(t *testing.T) {
73 ctx := context.TODO()
74
75 svc, err := tests.CreateTestService(t)
76 require.NoError(t, err)
77 defer svc.Remove()
78
79 var preimages = []string{"123preimage", "123preimage2"}
80
81 svc.LNClient.(*tests.MockLn).PayInvoiceResponses = []*lnclient.PayInvoiceResponse{{
82 Preimage: preimages[0],
83 }, {
84 Preimage: preimages[1],
85 }}
86 svc.LNClient.(*tests.MockLn).PayInvoiceErrors = []error{nil, nil}
87
88 app, _, err := tests.CreateApp(svc)
89 assert.NoError(t, err)
90
91 appPermission := &db.AppPermission{
92 AppId: app.ID,
93 App: *app,
94 Scope: constants.PAY_INVOICE_SCOPE,
95 }
96 err = svc.DB.Create(appPermission).Error
97 assert.NoError(t, err)
98
99 nip47Request := &models.Request{}
100 err = json.Unmarshal([]byte(nip47MultiPayJson), nip47Request)
101 assert.NoError(t, err)
102
103 responses := []*models.Response{}
104 dTags := []nostr.Tags{}
105
106 var mu sync.Mutex
107
108 publishResponse := func(response *models.Response, tags nostr.Tags) {
109 mu.Lock()
110 defer mu.Unlock()
111 responses = append(responses, response)
112 dTags = append(dTags, tags)
113 }
114
115 dbRequestEvent := &db.RequestEvent{}
116 err = svc.DB.Create(&dbRequestEvent).Error
117 assert.NoError(t, err)
118
119 NewTestNip47Controller(svc).
120 HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
121
122 var paymentHashes = []string{
123 "23277d5e13fce5534f9752c62fcf9337a2a6b0ebea9d21fa816a4c9d054cf93b",
124 "ac1b93f8b65a46e9aea3cc56ac5ac35d163d350cd612d922dd1f8b550f98c338",
125 }
126
127 assert.Equal(t, 2, len(responses))
128
129 for i := 0; i < len(responses); i++ {
130 require.Nil(t, responses[i].Error)
131 }
132
133 // we can't guarantee which request was processed first
134 // so swap them if they are back to front
135 if dTags[0].Find("d")[1] != paymentHashes[0] {
136 responses[0], responses[1] = responses[1], responses[0]
137 dTags[0], dTags[1] = dTags[1], dTags[0]
138 }
139 // we can't guarantee which request was processed first
140 // so swap them if they are back to front
141 if responses[0].Result.(payResponse).Preimage != preimages[0] {
142 preimages[0], preimages[1] = preimages[1], preimages[0]
143 }
144
145 for i := 0; i < len(responses); i++ {
146 assert.Equal(t, preimages[i], responses[i].Result.(payResponse).Preimage)
147 assert.Equal(t, paymentHashes[i], dTags[i].Find("d")[1])
148 }
149 }
150
151 func TestHandleMultiPayInvoiceEvent_OneMalformedInvoice(t *testing.T) {
152 ctx := context.TODO()
153
154 svc, err := tests.CreateTestService(t)
155 require.NoError(t, err)
156 defer svc.Remove()
157
158 app, _, err := tests.CreateApp(svc)
159 assert.NoError(t, err)
160
161 appPermission := &db.AppPermission{
162 AppId: app.ID,
163 App: *app,
164 Scope: constants.PAY_INVOICE_SCOPE,
165 }
166 err = svc.DB.Create(appPermission).Error
167 assert.NoError(t, err)
168
169 nip47Request := &models.Request{}
170 err = json.Unmarshal([]byte(nip47MultiPayOneMalformedInvoiceJson), nip47Request)
171 assert.NoError(t, err)
172
173 responses := []*models.Response{}
174 dTags := []nostr.Tags{}
175
176 var mu sync.Mutex
177
178 publishResponse := func(response *models.Response, tags nostr.Tags) {
179 mu.Lock()
180 defer mu.Unlock()
181 responses = append(responses, response)
182 dTags = append(dTags, tags)
183 }
184
185 requestEvent := &db.RequestEvent{}
186 svc.DB.Save(requestEvent)
187
188 NewTestNip47Controller(svc).
189 HandleMultiPayInvoiceEvent(ctx, nip47Request, requestEvent.ID, app, publishResponse)
190
191 assert.Equal(t, 2, len(responses))
192 assert.Equal(t, 2, len(dTags))
193
194 // we can't guarantee which request was processed first
195 // so swap them if they are back to front
196 if responses[0].Result != nil {
197 responses[0], responses[1] = responses[1], responses[0]
198 dTags[0], dTags[1] = dTags[1], dTags[0]
199 }
200
201 assert.Equal(t, "invoiceId123", dTags[0].Find("d")[1])
202 assert.Equal(t, constants.ERROR_BAD_REQUEST, responses[0].Error.Code)
203 assert.Nil(t, responses[0].Result)
204
205 assert.Equal(t, tests.MockPaymentHash, dTags[1].Find("d")[1])
206 assert.Equal(t, "123preimage", responses[1].Result.(payResponse).Preimage)
207 assert.Nil(t, responses[1].Error)
208 }
209
210 func TestHandleMultiPayInvoiceEvent_OneExpiredInvoice(t *testing.T) {
211 ctx := context.TODO()
212
213 svc, err := tests.CreateTestService(t)
214 require.NoError(t, err)
215 defer svc.Remove()
216
217 app, _, err := tests.CreateApp(svc)
218 assert.NoError(t, err)
219
220 appPermission := &db.AppPermission{
221 AppId: app.ID,
222 App: *app,
223 Scope: constants.PAY_INVOICE_SCOPE,
224 }
225 err = svc.DB.Create(appPermission).Error
226 assert.NoError(t, err)
227
228 nip47Request := &models.Request{}
229 err = json.Unmarshal([]byte(nip47MultiPayOneExpiredInvoiceJson), nip47Request)
230 assert.NoError(t, err)
231
232 responses := []*models.Response{}
233 dTags := []nostr.Tags{}
234
235 var mu sync.Mutex
236
237 publishResponse := func(response *models.Response, tags nostr.Tags) {
238 mu.Lock()
239 defer mu.Unlock()
240 responses = append(responses, response)
241 dTags = append(dTags, tags)
242 }
243
244 requestEvent := &db.RequestEvent{}
245 svc.DB.Save(requestEvent)
246
247 NewTestNip47Controller(svc).
248 HandleMultiPayInvoiceEvent(ctx, nip47Request, requestEvent.ID, app, publishResponse)
249
250 assert.Equal(t, 2, len(responses))
251 assert.Equal(t, 2, len(dTags))
252
253 // we can't guarantee which request was processed first
254 // so swap them if they are back to front
255 if responses[0].Result != nil {
256 responses[0], responses[1] = responses[1], responses[0]
257 dTags[0], dTags[1] = dTags[1], dTags[0]
258 }
259
260 assert.Equal(t, MockExpiredPaymentHash, dTags[0].Find("d")[1])
261 assert.Equal(t, constants.ERROR_INTERNAL, responses[0].Error.Code)
262 assert.Equal(t, "this invoice has expired", responses[0].Error.Message)
263 assert.Nil(t, responses[0].Result)
264
265 assert.Equal(t, tests.MockPaymentHash, dTags[1].Find("d")[1])
266 assert.Equal(t, "123preimage", responses[1].Result.(payResponse).Preimage)
267 assert.Nil(t, responses[1].Error)
268 }
269
270 func TestHandleMultiPayInvoiceEvent_IsolatedApp_OneBudgetExceeded(t *testing.T) {
271 ctx := context.TODO()
272
273 svc, err := tests.CreateTestService(t)
274 require.NoError(t, err)
275 defer svc.Remove()
276
277 app, _, err := tests.CreateApp(svc)
278 assert.NoError(t, err)
279 app.Isolated = true
280 svc.DB.Save(&app)
281
282 svc.DB.Create(&db.Transaction{
283 AppId: &app.ID,
284 State: constants.TRANSACTION_STATE_SETTLED,
285 Type: constants.TRANSACTION_TYPE_INCOMING,
286 // invoices paid are 123000 millisats
287 AmountMsat: 200000,
288 })
289
290 appPermission := &db.AppPermission{
291 AppId: app.ID,
292 App: *app,
293 Scope: constants.PAY_INVOICE_SCOPE,
294 }
295 err = svc.DB.Create(appPermission).Error
296 assert.NoError(t, err)
297
298 nip47Request := &models.Request{}
299 err = json.Unmarshal([]byte(nip47MultiPayJson), nip47Request)
300 assert.NoError(t, err)
301
302 responses := []*models.Response{}
303 dTags := []nostr.Tags{}
304
305 var mu sync.Mutex
306
307 publishResponse := func(response *models.Response, tags nostr.Tags) {
308 mu.Lock()
309 defer mu.Unlock()
310 responses = append(responses, response)
311 dTags = append(dTags, tags)
312 }
313
314 dbRequestEvent := &db.RequestEvent{}
315 err = svc.DB.Create(&dbRequestEvent).Error
316 assert.NoError(t, err)
317
318 NewTestNip47Controller(svc).
319 HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
320
321 assert.Equal(t, 2, len(responses))
322 assert.Equal(t, 2, len(dTags))
323
324 // we can't guarantee which request was processed first
325 // so swap them if they are back to front
326 if responses[0].Result == nil {
327 responses[0], responses[1] = responses[1], responses[0]
328 dTags[0], dTags[1] = dTags[1], dTags[0]
329 }
330
331 // we cannot guarantee which payment will be made first,
332 // so ensure we have results for both payment hashes
333 var paymentHashes = []string{
334 "23277d5e13fce5534f9752c62fcf9337a2a6b0ebea9d21fa816a4c9d054cf93b",
335 "ac1b93f8b65a46e9aea3cc56ac5ac35d163d350cd612d922dd1f8b550f98c338",
336 }
337
338 assert.NotEqual(t, dTags[0].Find("d")[1], dTags[1].Find("d")[1])
339
340 assert.Contains(t, paymentHashes, dTags[0].Find("d")[1])
341 assert.Equal(t, "123preimage", responses[0].Result.(payResponse).Preimage)
342 assert.Nil(t, responses[0].Error)
343
344 assert.Contains(t, paymentHashes, dTags[1].Find("d")[1])
345 assert.Nil(t, responses[1].Result)
346 assert.Equal(t, constants.ERROR_INSUFFICIENT_BALANCE, responses[1].Error.Code)
347 }
348
349 func TestHandleMultiPayInvoiceEvent_LNClient_OnePaymentFailed(t *testing.T) {
350
351 ctx := context.TODO()
352
353 svc, err := tests.CreateTestService(t)
354 require.NoError(t, err)
355 defer svc.Remove()
356 svc.LNClient.(*tests.MockLn).PayInvoiceResponses = []*lnclient.PayInvoiceResponse{{
357 Preimage: "123preimage",
358 }, nil}
359 svc.LNClient.(*tests.MockLn).PayInvoiceErrors = []error{nil, errors.New("Some error")}
360
361 app, _, err := tests.CreateApp(svc)
362 assert.NoError(t, err)
363
364 appPermission := &db.AppPermission{
365 AppId: app.ID,
366 App: *app,
367 Scope: constants.PAY_INVOICE_SCOPE,
368 }
369 err = svc.DB.Create(appPermission).Error
370 assert.NoError(t, err)
371
372 nip47Request := &models.Request{}
373 err = json.Unmarshal([]byte(nip47MultiPayJson), nip47Request)
374 assert.NoError(t, err)
375
376 responses := []*models.Response{}
377 dTags := []nostr.Tags{}
378
379 var mu sync.Mutex
380
381 publishResponse := func(response *models.Response, tags nostr.Tags) {
382 logger.Logger.WithFields(logrus.Fields{
383 "response": response,
384 "tags": tags,
385 }).Info("Publish response")
386 mu.Lock()
387 defer mu.Unlock()
388 responses = append(responses, response)
389 dTags = append(dTags, tags)
390 }
391
392 dbRequestEvent := &db.RequestEvent{}
393 err = svc.DB.Create(&dbRequestEvent).Error
394 assert.NoError(t, err)
395
396 NewTestNip47Controller(svc).
397 HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
398
399 assert.Equal(t, 2, len(responses))
400 assert.Equal(t, 2, len(dTags))
401
402 logger.Logger.WithField("dTags", dTags).WithField("responses", responses).Info("Got responses")
403 // we can't guarantee which request was processed first
404 // so swap them if they are back to front
405 if responses[0].Result == nil {
406 responses[0], responses[1] = responses[1], responses[0]
407 dTags[0], dTags[1] = dTags[1], dTags[0]
408 }
409
410 // we cannot guarantee which payment will be made first,
411 // so ensure we have results for both payment hashes
412 var paymentHashes = []string{
413 "23277d5e13fce5534f9752c62fcf9337a2a6b0ebea9d21fa816a4c9d054cf93b",
414 "ac1b93f8b65a46e9aea3cc56ac5ac35d163d350cd612d922dd1f8b550f98c338",
415 }
416
417 assert.NotEqual(t, dTags[0].Find("d")[1], dTags[1].Find("d")[1])
418
419 assert.Contains(t, paymentHashes, dTags[0].Find("d")[1])
420 assert.Equal(t, "123preimage", responses[0].Result.(payResponse).Preimage)
421 assert.Nil(t, responses[0].Error)
422
423 assert.Contains(t, paymentHashes, dTags[1].Find("d")[1])
424 assert.Nil(t, responses[1].Result)
425 assert.Equal(t, constants.ERROR_INTERNAL, responses[1].Error.Code)
426 assert.Equal(t, "Some error", responses[1].Error.Message)
427 }
428
429 func TestHandleMultiPayInvoiceEvent_IsolatedApp_ConcurrentPayments(t *testing.T) {
430 ctx := context.TODO()
431
432 svc, err := tests.CreateTestService(t)
433 require.NoError(t, err)
434 defer svc.Remove()
435
436 app, _, err := tests.CreateApp(svc)
437 assert.NoError(t, err)
438 app.Isolated = true
439 svc.DB.Save(&app)
440
441 svc.DB.Create(&db.Transaction{
442 AppId: &app.ID,
443 State: constants.TRANSACTION_STATE_SETTLED,
444 Type: constants.TRANSACTION_TYPE_INCOMING,
445 // invoices paid are 123000 millisats
446 AmountMsat: 200000,
447 })
448
449 // force delay inside transaction
450 if svc.DB.Dialector.Name() == "postgres" {
451 err = svc.DB.Exec(`
452 CREATE OR REPLACE FUNCTION slow_down_query()
453 RETURNS TRIGGER AS $slow_down_query$
454 BEGIN
455 -- Introduce a delay of 1 second
456 PERFORM pg_sleep(1);
457 RETURN NEW;
458 END;
459 $slow_down_query$ LANGUAGE plpgsql;
460
461 CREATE TRIGGER slow_down_query
462 AFTER INSERT ON transactions
463 FOR EACH ROW
464 EXECUTE PROCEDURE slow_down_query();`).Error
465
466 require.NoError(t, err)
467 }
468
469 appPermission := &db.AppPermission{
470 AppId: app.ID,
471 App: *app,
472 Scope: constants.PAY_INVOICE_SCOPE,
473 }
474 err = svc.DB.Create(appPermission).Error
475 assert.NoError(t, err)
476
477 nip47Request := &models.Request{}
478 err = json.Unmarshal([]byte(nip47MultiPayJson), nip47Request)
479 assert.NoError(t, err)
480
481 responses := []*models.Response{}
482
483 var mu sync.Mutex
484
485 publishResponse := func(response *models.Response, tags nostr.Tags) {
486 mu.Lock()
487 defer mu.Unlock()
488 responses = append(responses, response)
489 }
490
491 dbRequestEvent := &db.RequestEvent{}
492 err = svc.DB.Create(&dbRequestEvent).Error
493 assert.NoError(t, err)
494
495 NewTestNip47Controller(svc).
496 HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
497
498 require.Equal(t, 2, len(responses))
499
500 // we can't guarantee which request was processed first
501 // so put the successful one at the front
502 successfulIdx := slices.IndexFunc(responses, func(r *models.Response) bool {
503 return r.Result != nil
504 })
505 require.GreaterOrEqual(t, successfulIdx, 0)
506
507 if successfulIdx > 0 {
508 responses[0], responses[successfulIdx] = responses[successfulIdx], responses[0]
509 }
510
511 for _, response := range responses[1:] {
512 require.Nil(t, response.Result)
513 assert.Equal(t, constants.ERROR_INSUFFICIENT_BALANCE, response.Error.Code)
514 }
515 }
516