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