diff --git a/controllers/keysend.ctrl.go b/controllers/keysend.ctrl.go new file mode 100644 index 0000000..bf28ad3 --- /dev/null +++ b/controllers/keysend.ctrl.go @@ -0,0 +1,102 @@ +package controllers + +import ( + "fmt" + "net/http" + + "github.com/getAlby/lndhub.go/lib" + "github.com/getAlby/lndhub.go/lib/responses" + "github.com/getAlby/lndhub.go/lib/service" + "github.com/getAlby/lndhub.go/lnd" + "github.com/getsentry/sentry-go" + "github.com/labstack/echo/v4" + "github.com/lightningnetwork/lnd/lnrpc" +) + +// KeySendController : Key send controller struct +type KeySendController struct { + svc *service.LndhubService +} + +func NewKeySendController(svc *service.LndhubService) *KeySendController { + return &KeySendController{svc: svc} +} + +type KeySendRequestBody struct { + Amount int64 `json:"amount" validate:"required"` + Destination string `json:"destination" validate:"required"` + Memo string `json:"memo" validate:"omitempty"` +} + +type KeySendResponseBody struct { + RHash *lib.JavaScriptBuffer `json:"payment_hash,omitempty"` + Amount int64 `json:"num_satoshis,omitempty"` + Description string `json:"description,omitempty"` + Destination string `json:"destination,omitempty"` + DescriptionHashStr string `json:"description_hash,omitempty"` + PaymentError string `json:"payment_error,omitempty"` + PaymentPreimage *lib.JavaScriptBuffer `json:"payment_preimage,omitempty"` + PaymentRoute *service.Route `json:"route,omitempty"` +} + +// KeySend : Key send Controller +func (controller *KeySendController) KeySend(c echo.Context) error { + userID := c.Get("UserID").(int64) + reqBody := KeySendRequestBody{} + if err := c.Bind(&reqBody); err != nil { + c.Logger().Errorf("Failed to load keysend request body: %v", err) + return c.JSON(http.StatusBadRequest, responses.BadArgumentsError) + } + + if err := c.Validate(&reqBody); err != nil { + c.Logger().Errorf("Invalid keysend request body: %v", err) + return c.JSON(http.StatusBadRequest, responses.BadArgumentsError) + } + + lnPayReq := &lnd.LNPayReq{ + PayReq: &lnrpc.PayReq{ + Destination: reqBody.Destination, + NumSatoshis: reqBody.Amount, + Description: reqBody.Memo, + }, + Keysend: true, + } + + invoice, err := controller.svc.AddOutgoingInvoice(c.Request().Context(), userID, "", lnPayReq) + if err != nil { + return err + } + + currentBalance, err := controller.svc.CurrentUserBalance(c.Request().Context(), userID) + if err != nil { + return err + } + + if currentBalance < invoice.Amount { + c.Logger().Errorf("User does not have enough balance invoice_id=%v user_id=%v balance=%v amount=%v", invoice.ID, userID, currentBalance, invoice.Amount) + return c.JSON(http.StatusBadRequest, responses.NotEnoughBalanceError) + } + + sendPaymentResponse, err := controller.svc.PayInvoice(c.Request().Context(), invoice) + if err != nil { + c.Logger().Errorf("Payment failed: %v", err) + sentry.CaptureException(err) + return c.JSON(http.StatusBadRequest, echo.Map{ + "error": true, + "code": 10, + "message": fmt.Sprintf("Payment failed. Does the receiver have enough inbound capacity? (%v)", err), + }) + } + + responseBody := &KeySendResponseBody{} + responseBody.RHash = &lib.JavaScriptBuffer{Data: sendPaymentResponse.PaymentHash} + responseBody.Amount = invoice.Amount + responseBody.Destination = invoice.DestinationPubkeyHex + responseBody.Description = invoice.Memo + responseBody.DescriptionHashStr = invoice.DescriptionHash + responseBody.PaymentError = sendPaymentResponse.PaymentError + responseBody.PaymentPreimage = &lib.JavaScriptBuffer{Data: sendPaymentResponse.PaymentPreimage} + responseBody.PaymentRoute = sendPaymentResponse.PaymentRoute + + return c.JSON(http.StatusOK, responseBody) +} diff --git a/controllers/payinvoice.ctrl.go b/controllers/payinvoice.ctrl.go index 36c4e7e..1a658cd 100644 --- a/controllers/payinvoice.ctrl.go +++ b/controllers/payinvoice.ctrl.go @@ -7,6 +7,7 @@ import ( "github.com/getAlby/lndhub.go/lib" "github.com/getAlby/lndhub.go/lib/responses" "github.com/getAlby/lndhub.go/lib/service" + "github.com/getAlby/lndhub.go/lnd" "github.com/getsentry/sentry-go" "github.com/labstack/echo/v4" ) @@ -69,7 +70,12 @@ func (controller *PayInvoiceController) PayInvoice(c echo.Context) error { } */ - invoice, err := controller.svc.AddOutgoingInvoice(c.Request().Context(), userID, paymentRequest, decodedPaymentRequest) + lnPayReq := &lnd.LNPayReq{ + PayReq: decodedPaymentRequest, + Keysend: false, + } + + invoice, err := controller.svc.AddOutgoingInvoice(c.Request().Context(), userID, paymentRequest, lnPayReq) if err != nil { return err } diff --git a/db/migrations/20220304103000_keysend_invoice.up.sql b/db/migrations/20220304103000_keysend_invoice.up.sql new file mode 100644 index 0000000..d7ca05d --- /dev/null +++ b/db/migrations/20220304103000_keysend_invoice.up.sql @@ -0,0 +1 @@ +alter table invoices add column keysend boolean; \ No newline at end of file diff --git a/db/models/invoice.go b/db/models/invoice.go index a117adf..0679240 100644 --- a/db/models/invoice.go +++ b/db/models/invoice.go @@ -21,6 +21,7 @@ type Invoice struct { RHash string `json:"r_hash"` Preimage string `json:"preimage" bun:",nullzero"` Internal bool `json:"internal" bun:",nullzero"` + Keysend bool `json:"keysend" bun:",nullzero"` State string `json:"state" bun:",default:'initialized'"` ErrorMessage string `json:"error_message" bun:",nullzero"` AddIndex uint64 `json:"add_index" bun:",nullzero"` diff --git a/integration_tests/keysend_test.go b/integration_tests/keysend_test.go new file mode 100644 index 0000000..d834a27 --- /dev/null +++ b/integration_tests/keysend_test.go @@ -0,0 +1,122 @@ +package integration_tests + +import ( + "context" + "fmt" + "log" + "testing" + "time" + + "github.com/getAlby/lndhub.go/controllers" + "github.com/getAlby/lndhub.go/lib" + "github.com/getAlby/lndhub.go/lib/responses" + "github.com/getAlby/lndhub.go/lib/service" + "github.com/getAlby/lndhub.go/lib/tokens" + "github.com/getAlby/lndhub.go/lnd" + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type KeySendTestSuite struct { + TestSuite + fundingClient *lnd.LNDWrapper + service *service.LndhubService + aliceLogin controllers.CreateUserResponseBody + aliceToken string + invoiceUpdateSubCancelFn context.CancelFunc +} + +func (suite *KeySendTestSuite) SetupSuite() { + lndClient, err := lnd.NewLNDclient(lnd.LNDoptions{ + Address: lnd2RegtestAddress, + MacaroonHex: lnd2RegtestMacaroonHex, + }) + if err != nil { + log.Fatalf("Error setting up funding client: %v", err) + } + suite.fundingClient = lndClient + + svc, err := LndHubTestServiceInit(nil) + if err != nil { + log.Fatalf("Error initializing test service: %v", err) + } + users, userTokens, err := createUsers(svc, 1) + if err != nil { + log.Fatalf("Error creating test users: %v", err) + } + // Subscribe to LND invoice updates in the background + // store cancel func to be called in tear down suite + ctx, cancel := context.WithCancel(context.Background()) + suite.invoiceUpdateSubCancelFn = cancel + go svc.InvoiceUpdateSubscription(ctx) + suite.service = svc + e := echo.New() + + e.HTTPErrorHandler = responses.HTTPErrorHandler + e.Validator = &lib.CustomValidator{Validator: validator.New()} + suite.echo = e + assert.Equal(suite.T(), 1, len(users)) + assert.Equal(suite.T(), 1, len(userTokens)) + suite.aliceLogin = users[0] + suite.aliceToken = userTokens[0] + suite.echo.Use(tokens.Middleware([]byte(suite.service.Config.JWTSecret))) + suite.echo.GET("/balance", controllers.NewBalanceController(suite.service).Balance) + suite.echo.POST("/addinvoice", controllers.NewAddInvoiceController(suite.service).AddInvoice) + suite.echo.POST("/payinvoice", controllers.NewPayInvoiceController(suite.service).PayInvoice) + suite.echo.POST("/keysend", controllers.NewKeySendController(suite.service).KeySend) +} + +func (suite *KeySendTestSuite) TearDownSuite() { + suite.invoiceUpdateSubCancelFn() +} + +func (suite *KeySendTestSuite) TestKeysendPayment() { + aliceFundingSats := 1000 + externalSatRequested := 500 + //fund alice account + invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test external payment alice", suite.aliceToken) + sendPaymentRequest := lnrpc.SendRequest{ + PaymentRequest: invoiceResponse.PayReq, + FeeLimit: nil, + } + _, err := suite.fundingClient.SendPaymentSync(context.Background(), &sendPaymentRequest) + assert.NoError(suite.T(), err) + + //wait a bit for the callback event to hit + time.Sleep(100 * time.Millisecond) + + suite.createKeySendReq(int64(externalSatRequested), "key send test", simnetLnd3PubKey, suite.aliceToken) + + // check that balance was reduced + userId := getUserIdFromToken(suite.aliceToken) + aliceBalance, err := suite.service.CurrentUserBalance(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting balance %v\n", err.Error()) + } + assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested), aliceBalance) +} + +func (suite *KeySendTestSuite) TestKeysendPaymentNonExistentDestination() { + aliceFundingSats := 1000 + externalSatRequested := 500 + //fund alice account + invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test external payment alice", suite.aliceToken) + sendPaymentRequest := lnrpc.SendRequest{ + PaymentRequest: invoiceResponse.PayReq, + FeeLimit: nil, + } + _, err := suite.fundingClient.SendPaymentSync(context.Background(), &sendPaymentRequest) + assert.NoError(suite.T(), err) + + //wait a bit for the callback event to hit + time.Sleep(100 * time.Millisecond) + + suite.createKeySendReqError(int64(externalSatRequested), "key send test", "12345", suite.aliceToken) +} + +func TestKeySendTestSuite(t *testing.T) { + suite.Run(t, new(KeySendTestSuite)) +} diff --git a/integration_tests/util.go b/integration_tests/util.go index 6a15abc..6361391 100644 --- a/integration_tests/util.go +++ b/integration_tests/util.go @@ -29,6 +29,9 @@ const ( lnd1RegtestMacaroonHex = "0201036c6e6402f801030a10e2133a1cac2c5b4d56e44e32dc64c8551201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620c4f9783e0873fa50a2091806f5ebb919c5dc432e33800b401463ada6485df0ed" lnd2RegtestAddress = "rpc.lnd2.regtest.getalby.com:443" lnd2RegtestMacaroonHex = "0201036C6E6402F801030A101782922F4358E80655920FC7A7C3E9291201301A160A0761646472657373120472656164120577726974651A130A04696E666F120472656164120577726974651A170A08696E766F69636573120472656164120577726974651A210A086D616361726F6F6E120867656E6572617465120472656164120577726974651A160A076D657373616765120472656164120577726974651A170A086F6666636861696E120472656164120577726974651A160A076F6E636861696E120472656164120577726974651A140A057065657273120472656164120577726974651A180A067369676E6572120867656E657261746512047265616400000620628FFB2938C8540DD3AA5E578D9B43456835FAA176E175FFD4F9FBAE540E3BE9" + simnetLnd1PubKey = "0242898f86064c2fd72de22059c947a83ba23e9d97aedeae7b6dba647123f1d71b" + simnetLnd2PubKey = "025c1d5d1b4c983cc6350fc2d756fbb59b4dc365e45e87f8e3afe07e24013e8220" + simnetLnd3PubKey = "03c7092d076f799ab18806743634b4c9bb34e351bdebc91d5b35963f3dc63ec5aa" ) func LndHubTestServiceInit(lndClientMock lnd.LightningClientWrapper) (svc *service.LndhubService, err error) { @@ -150,6 +153,44 @@ func (suite *TestSuite) createAddInvoiceReq(amt int, memo, token string) *contro return invoiceResponse } +func (suite *TestSuite) createKeySendReq(amount int64, memo, destination, token string) *controllers.KeySendResponseBody { + rec := httptest.NewRecorder() + var buf bytes.Buffer + assert.NoError(suite.T(), json.NewEncoder(&buf).Encode(&controllers.KeySendRequestBody{ + Amount: amount, + Destination: destination, + Memo: memo, + })) + req := httptest.NewRequest(http.MethodPost, "/keysend", &buf) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + suite.echo.ServeHTTP(rec, req) + + keySendResponse := &controllers.KeySendResponseBody{} + assert.Equal(suite.T(), http.StatusOK, rec.Code) + assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(keySendResponse)) + return keySendResponse +} + +func (suite *TestSuite) createKeySendReqError(amount int64, memo, destination, token string) *responses.ErrorResponse { + rec := httptest.NewRecorder() + var buf bytes.Buffer + assert.NoError(suite.T(), json.NewEncoder(&buf).Encode(&controllers.KeySendRequestBody{ + Amount: amount, + Destination: destination, + Memo: memo, + })) + req := httptest.NewRequest(http.MethodPost, "/keysend", &buf) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + suite.echo.ServeHTTP(rec, req) + + errorResponse := &responses.ErrorResponse{} + assert.Equal(suite.T(), http.StatusBadRequest, rec.Code) + assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(errorResponse)) + return errorResponse +} + func (suite *TestSuite) createPayInvoiceReq(payReq string, token string) *controllers.PayInvoiceResponseBody { rec := httptest.NewRecorder() var buf bytes.Buffer diff --git a/lib/service/invoices.go b/lib/service/invoices.go index 9b43d93..087d387 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -2,6 +2,7 @@ package service import ( "context" + "crypto/sha256" "encoding/hex" "errors" "math/rand" @@ -9,6 +10,7 @@ import ( "github.com/getAlby/lndhub.go/common" "github.com/getAlby/lndhub.go/db/models" + "github.com/getAlby/lndhub.go/lnd" "github.com/getsentry/sentry-go" "github.com/labstack/gommon/random" "github.com/lightningnetwork/lnd/lnrpc" @@ -99,25 +101,14 @@ func (svc *LndhubService) SendInternalPayment(ctx context.Context, invoice *mode func (svc *LndhubService) SendPaymentSync(ctx context.Context, invoice *models.Invoice) (SendPaymentResponse, error) { sendPaymentResponse := SendPaymentResponse{} - // TODO: set dynamic fee limit - feeLimit := lnrpc.FeeLimit{ - //Limit: &lnrpc.FeeLimit_Percent{ - // Percent: 2, - //}, - Limit: &lnrpc.FeeLimit_Fixed{ - Fixed: 300, - }, - } - // Prepare the LNRPC call - sendPaymentRequest := lnrpc.SendRequest{ - PaymentRequest: invoice.PaymentRequest, - Amt: invoice.Amount, - FeeLimit: &feeLimit, + sendPaymentRequest, err := createLnRpcSendRequest(invoice) + if err != nil { + return sendPaymentResponse, err } // Execute the payment - sendPaymentResult, err := svc.LndClient.SendPaymentSync(ctx, &sendPaymentRequest) + sendPaymentResult, err := svc.LndClient.SendPaymentSync(ctx, sendPaymentRequest) if err != nil { return sendPaymentResponse, err } @@ -137,6 +128,44 @@ func (svc *LndhubService) SendPaymentSync(ctx context.Context, invoice *models.I return sendPaymentResponse, nil } +func createLnRpcSendRequest(invoice *models.Invoice) (*lnrpc.SendRequest, error) { + // TODO: set dynamic fee limit + feeLimit := lnrpc.FeeLimit{ + //Limit: &lnrpc.FeeLimit_Percent{ + // Percent: 2, + //}, + Limit: &lnrpc.FeeLimit_Fixed{ + Fixed: 300, + }, + } + + if !invoice.Keysend { + return &lnrpc.SendRequest{ + PaymentRequest: invoice.PaymentRequest, + Amt: invoice.Amount, + FeeLimit: &feeLimit, + }, nil + } + + preImage := makePreimageHex() + pHash := sha256.New() + pHash.Write(preImage) + // Prepare the LNRPC call + //See: https://github.com/hsjoberg/blixt-wallet/blob/9fcc56a7dc25237bc14b85e6490adb9e044c009c/src/lndmobile/index.ts#L251-L270 + destBytes, err := hex.DecodeString(invoice.DestinationPubkeyHex) + if err != nil { + return nil, err + } + return &lnrpc.SendRequest{ + Dest: destBytes, + Amt: invoice.Amount, + PaymentHash: pHash.Sum(nil), + FeeLimit: &feeLimit, + DestFeatures: []lnrpc.FeatureBit{lnrpc.FeatureBit_TLV_ONION_REQ}, + DestCustomRecords: map[uint64][]byte{KEYSEND_CUSTOM_RECORD: preImage, TLV_WHATSAT_MESSAGE: []byte(invoice.Memo)}, + }, nil +} + func (svc *LndhubService) PayInvoice(ctx context.Context, invoice *models.Invoice) (*SendPaymentResponse, error) { userId := invoice.UserID @@ -235,19 +264,20 @@ func (svc *LndhubService) HandleSuccessfulPayment(ctx context.Context, invoice * return err } -func (svc *LndhubService) AddOutgoingInvoice(ctx context.Context, userID int64, paymentRequest string, decodedInvoice *lnrpc.PayReq) (*models.Invoice, error) { +func (svc *LndhubService) AddOutgoingInvoice(ctx context.Context, userID int64, paymentRequest string, lnPayReq *lnd.LNPayReq) (*models.Invoice, error) { // Initialize new DB invoice invoice := models.Invoice{ Type: common.InvoiceTypeOutgoing, UserID: userID, PaymentRequest: paymentRequest, - RHash: decodedInvoice.PaymentHash, - Amount: decodedInvoice.NumSatoshis, + RHash: lnPayReq.PayReq.PaymentHash, + Amount: lnPayReq.PayReq.NumSatoshis, State: common.InvoiceStateInitialized, - DestinationPubkeyHex: decodedInvoice.Destination, - DescriptionHash: decodedInvoice.DescriptionHash, - Memo: decodedInvoice.Description, - ExpiresAt: bun.NullTime{Time: time.Unix(decodedInvoice.Timestamp, 0).Add(time.Duration(decodedInvoice.Expiry) * time.Second)}, + DestinationPubkeyHex: lnPayReq.PayReq.Destination, + DescriptionHash: lnPayReq.PayReq.DescriptionHash, + Memo: lnPayReq.PayReq.Description, + Keysend: lnPayReq.Keysend, + ExpiresAt: bun.NullTime{Time: time.Unix(lnPayReq.PayReq.Timestamp, 0).Add(time.Duration(lnPayReq.PayReq.Expiry) * time.Second)}, } // Save invoice diff --git a/lib/service/ln.go b/lib/service/ln.go index a9f609a..f139e2b 100644 --- a/lib/service/ln.go +++ b/lib/service/ln.go @@ -6,6 +6,13 @@ import ( "github.com/lightningnetwork/lnd/lnrpc" ) +//https://github.com/hsjoberg/blixt-wallet/blob/9fcc56a7dc25237bc14b85e6490adb9e044c009c/src/utils/constants.ts#L5 +const ( + KEYSEND_CUSTOM_RECORD = 5482373484 + TLV_WHATSAT_MESSAGE = 34349334 + TLV_RECORD_NAME = 128100 +) + func (svc *LndhubService) GetInfo(ctx context.Context) (*lnrpc.GetInfoResponse, error) { return svc.LndClient.GetInfo(ctx, &lnrpc.GetInfoRequest{}) } diff --git a/lnd/lnd.go b/lnd/lnd.go index dbfe8c8..bdfe887 100644 --- a/lnd/lnd.go +++ b/lnd/lnd.go @@ -15,6 +15,11 @@ import ( "gopkg.in/macaroon.v2" ) +type LNPayReq struct { + PayReq *lnrpc.PayReq + Keysend bool +} + // LNDoptions are the options for the connection to the lnd node. type LNDoptions struct { Address string diff --git a/main.go b/main.go index c67ff7e..19b5843 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,7 @@ func main() { secured.GET("/checkpayment/:payment_hash", controllers.NewCheckPaymentController(svc).CheckPayment) secured.GET("/balance", controllers.NewBalanceController(svc).Balance) secured.GET("/getinfo", controllers.NewGetInfoController(svc).GetInfo) + secured.POST("/keysend", controllers.NewKeySendController(svc).KeySend) // These endpoints are currently not supported and we return a blank response for backwards compatibility blankController := controllers.NewBlankController(svc)