diff --git a/.env.example b/.env.example index 22bdb0d7..8bd93915 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ #RELAY=ws://localhost:7447/v1 #PORT=8080 +# Alby OAuth configuration +#ALBY_OAUTH_CLIENT_SECRET= +#ALBY_OAUTH_CLIENT_ID= +#BASE_URL= + # Polar LND Client #LN_BACKEND_TYPE=LND #LND_CERT_FILE=/home/YOUR_USERNAME/.polar/networks/1/volumes/lnd/alice/tls.cert diff --git a/README.md b/README.md index 92573392..9ab81529 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,12 @@ _To configure via env, the following parameters must be provided:_ - `LDK_ESPLORA_SERVER=https://mutinynet.com/api` - `LDK_GOSSIP_SOURCE=https://rgs.mutinynet.com/snapshot` +### Alby OAuth + +Create an OAuth client at the [Alby Developer Portal](https://getalby.com/developer) and set your `ALBY_OAUTH_CLIENT_ID` and `ALBY_OAUTH_CLIENT_SECRET` in your .env. If not running locally, you'll also need to change your `BASE_URL`. + +> If running the React app locally, OAuth redirects will not work locally if running the react app you will need to manually change the port to 5173. **Login in Wails mode is not yet supported** + ## Application deeplink options ### `/apps/new` deeplink options diff --git a/alby/alby.go b/alby/alby.go new file mode 100644 index 00000000..be6a0293 --- /dev/null +++ b/alby/alby.go @@ -0,0 +1,331 @@ +package alby + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/getAlby/nostr-wallet-connect/events" + "github.com/getAlby/nostr-wallet-connect/models/config" + "github.com/sirupsen/logrus" + "golang.org/x/oauth2" +) + +type AlbyOAuthService struct { + appConfig *config.AppConfig + kvStore config.ConfigKVStore + oauthConf *oauth2.Config + logger *logrus.Logger +} + +// TODO: move to models/alby +type AlbyMe struct { + Identifier string `json:"identifier"` + NPub string `json:"nostr_pubkey"` + LightningAddress string `json:"lightning_address"` + Email string `json:"email"` + Name string `json:"name"` + Avatar string `json:"avatar"` + KeysendPubkey string `json:"keysend_pubkey"` +} + +type AlbyBalance struct { + Balance int64 `json:"balance"` + Unit string `json:"unit"` + Currency string `json:"currency"` +} + +const ( + ACCESS_TOKEN_KEY = "AlbyOAuthAccessToken" + ACCESS_TOKEN_EXPIRY_KEY = "AlbyOAuthAccessTokenExpiry" + REFRESH_TOKEN_KEY = "AlbyOAuthRefreshToken" +) + +func NewAlbyOauthService(logger *logrus.Logger, kvStore config.ConfigKVStore, appConfig *config.AppConfig) *AlbyOAuthService { + conf := &oauth2.Config{ + ClientID: appConfig.AlbyClientId, + ClientSecret: appConfig.AlbyClientSecret, + Scopes: []string{"account:read", "balance:read", "payments:send"}, + Endpoint: oauth2.Endpoint{ + TokenURL: appConfig.AlbyAPIURL + "/oauth/token", + AuthURL: appConfig.AlbyOAuthAuthUrl, + AuthStyle: 2, // use HTTP Basic Authorization https://pkg.go.dev/golang.org/x/oauth2#AuthStyle + }, + RedirectURL: appConfig.BaseUrl + "/api/alby/callback", + } + + albyOAuthSvc := &AlbyOAuthService{ + appConfig: appConfig, + oauthConf: conf, + kvStore: kvStore, + logger: logger, + } + return albyOAuthSvc +} + +func (svc *AlbyOAuthService) CallbackHandler(ctx context.Context, code string) error { + token, err := svc.oauthConf.Exchange(ctx, code) + if err != nil { + svc.logger.WithError(err).Error("Failed to exchange token") + return err + } + + svc.saveToken(token) + + return nil +} + +func (svc *AlbyOAuthService) saveToken(token *oauth2.Token) { + svc.kvStore.SetUpdate(ACCESS_TOKEN_EXPIRY_KEY, strconv.FormatInt(token.Expiry.Unix(), 10), "") + svc.kvStore.SetUpdate(ACCESS_TOKEN_KEY, token.AccessToken, "") + svc.kvStore.SetUpdate(REFRESH_TOKEN_KEY, token.RefreshToken, "") +} + +var tokenMutex sync.Mutex + +func (svc *AlbyOAuthService) fetchUserToken(ctx context.Context) (*oauth2.Token, error) { + tokenMutex.Lock() + defer tokenMutex.Unlock() + accessToken, err := svc.kvStore.Get(ACCESS_TOKEN_KEY, "") + if err != nil { + return nil, err + } + expiry, err := svc.kvStore.Get(ACCESS_TOKEN_EXPIRY_KEY, "") + if err != nil { + return nil, err + } + expiry64, err := strconv.ParseInt(expiry, 10, 64) + if err != nil { + return nil, err + } + refreshToken, err := svc.kvStore.Get(REFRESH_TOKEN_KEY, "") + if err != nil { + return nil, err + } + currentToken := &oauth2.Token{ + AccessToken: accessToken, + Expiry: time.Unix(expiry64, 0), + RefreshToken: refreshToken, + } + + if currentToken.Expiry.After(time.Now().Add(time.Duration(1) * time.Second)) { + svc.logger.Info("Using existing Alby OAuth token") + return currentToken, nil + } + + newToken, err := svc.oauthConf.TokenSource(ctx, currentToken).Token() + if err != nil { + svc.logger.WithError(err).Error("Failed to refresh existing token") + return nil, err + } + + svc.saveToken(newToken) + return newToken, nil +} + +func (svc *AlbyOAuthService) GetMe(ctx context.Context) (*AlbyMe, error) { + + token, err := svc.fetchUserToken(ctx) + if err != nil { + svc.logger.WithError(err).Error("Failed to fetch user token") + return nil, err + } + + client := svc.oauthConf.Client(ctx, token) + + req, err := http.NewRequest("GET", fmt.Sprintf("%s/user/me", svc.appConfig.AlbyAPIURL), nil) + if err != nil { + svc.logger.WithError(err).Error("Error creating request /me") + return nil, err + } + + req.Header.Set("User-Agent", "NWC-next") + + res, err := client.Do(req) + if err != nil { + svc.logger.WithError(err).Error("Failed to fetch /me") + return nil, err + } + me := &AlbyMe{} + err = json.NewDecoder(res.Body).Decode(me) + if err != nil { + svc.logger.WithError(err).Error("Failed to decode API response") + return nil, err + } + + svc.logger.WithFields(logrus.Fields{"me": me}).Info("Alby me response") + return me, nil +} + +func (svc *AlbyOAuthService) GetBalance(ctx context.Context) (*AlbyBalance, error) { + + token, err := svc.fetchUserToken(ctx) + if err != nil { + svc.logger.WithError(err).Error("Failed to fetch user token") + return nil, err + } + + client := svc.oauthConf.Client(ctx, token) + + req, err := http.NewRequest("GET", fmt.Sprintf("%s/balance", svc.appConfig.AlbyAPIURL), nil) + if err != nil { + svc.logger.WithError(err).Error("Error creating request /balance") + return nil, err + } + + req.Header.Set("User-Agent", "NWC-next") + + res, err := client.Do(req) + if err != nil { + svc.logger.WithError(err).Error("Failed to fetch /balance") + return nil, err + } + balance := &AlbyBalance{} + err = json.NewDecoder(res.Body).Decode(balance) + if err != nil { + svc.logger.WithError(err).Error("Failed to decode API response") + return nil, err + } + + svc.logger.WithFields(logrus.Fields{"balance": balance}).Info("Alby balance response") + return balance, nil +} + +func (svc *AlbyOAuthService) SendPayment(ctx context.Context, invoice string) error { + token, err := svc.fetchUserToken(ctx) + if err != nil { + svc.logger.WithError(err).Error("Failed to fetch user token") + return err + } + + client := svc.oauthConf.Client(ctx, token) + + type PayRequest struct { + Invoice string `json:"invoice"` + } + + body := bytes.NewBuffer([]byte{}) + payload := &PayRequest{ + Invoice: invoice, + } + err = json.NewEncoder(body).Encode(payload) + + if err != nil { + svc.logger.WithError(err).Error("Failed to encode request payload") + return err + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/payments/bolt11", svc.appConfig.AlbyAPIURL), body) + if err != nil { + svc.logger.WithError(err).Error("Error creating request /payments/bolt11") + return err + } + + req.Header.Set("User-Agent", "NWC-next") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + svc.logger.WithFields(logrus.Fields{ + "invoice": invoice, + }).WithError(err).Error("Failed to pay invoice") + return err + } + + type PayResponse struct { + Preimage string `json:"payment_preimage"` + PaymentHash string `json:"payment_hash"` + } + + if resp.StatusCode >= 300 { + + type ErrorResponse struct { + Error bool `json:"error"` + Code int `json:"code"` + Message string `json:"message"` + } + + errorPayload := &ErrorResponse{} + err = json.NewDecoder(resp.Body).Decode(errorPayload) + if err != nil { + svc.logger.WithFields(logrus.Fields{ + "status": resp.StatusCode, + }).WithError(err).Error("Failed to decode payment error response payload") + return err + } + + svc.logger.WithFields(logrus.Fields{ + "invoice": invoice, + "status": resp.StatusCode, + "message": errorPayload.Message, + }).Error("Payment failed") + return errors.New(errorPayload.Message) + } + + responsePayload := &PayResponse{} + err = json.NewDecoder(resp.Body).Decode(responsePayload) + if err != nil { + svc.logger.WithError(err).Error("Failed to decode response payload") + return err + } + svc.logger.WithFields(logrus.Fields{ + "invoice": invoice, + "paymentHash": responsePayload.PaymentHash, + "preimage": responsePayload.Preimage, + }).Info("Payment successful") + return nil +} + +func (svc *AlbyOAuthService) GetAuthUrl() string { + return svc.oauthConf.AuthCodeURL("unused") +} + +func (svc *AlbyOAuthService) Log(ctx context.Context, event *events.Event) error { + token, err := svc.fetchUserToken(ctx) + if err != nil { + svc.logger.WithError(err).Error("Failed to fetch user token") + } + + client := svc.oauthConf.Client(ctx, token) + + body := bytes.NewBuffer([]byte{}) + err = json.NewEncoder(body).Encode(event) + + if err != nil { + svc.logger.WithError(err).Error("Failed to encode request payload") + return err + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/events", svc.appConfig.AlbyAPIURL), body) + if err != nil { + svc.logger.WithError(err).Error("Error creating request /events") + return err + } + + req.Header.Set("User-Agent", "NWC-next") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + svc.logger.WithFields(logrus.Fields{ + "event": event, + }).WithError(err).Error("Failed to send request to /events") + return err + } + + if resp.StatusCode >= 300 { + svc.logger.WithFields(logrus.Fields{ + "event": event, + "status": resp.StatusCode, + }).Error("Request to /events returned non-success status") + return errors.New("request to /events returned non-success status") + } + + return nil +} diff --git a/alby/alby_http_service.go b/alby/alby_http_service.go new file mode 100644 index 00000000..e79c77b2 --- /dev/null +++ b/alby/alby_http_service.go @@ -0,0 +1,90 @@ +package alby + +import ( + "fmt" + "net/http" + + "github.com/getAlby/nostr-wallet-connect/models/api" + models "github.com/getAlby/nostr-wallet-connect/models/http" + "github.com/labstack/echo/v4" + "github.com/sirupsen/logrus" +) + +type AlbyHttpService struct { + albyOAuthSvc *AlbyOAuthService + logger *logrus.Logger +} + +func NewAlbyHttpService(albyOAuthSvc *AlbyOAuthService, logger *logrus.Logger) *AlbyHttpService { + return &AlbyHttpService{ + albyOAuthSvc: albyOAuthSvc, + logger: logger, + } +} + +func (albyHttpSvc *AlbyHttpService) RegisterSharedRoutes(e *echo.Echo, authMiddleware func(next echo.HandlerFunc) echo.HandlerFunc) { + e.GET("/api/alby/callback", albyHttpSvc.albyCallbackHandler, authMiddleware) + e.GET("/api/alby/me", albyHttpSvc.albyMeHandler, authMiddleware) + e.GET("/api/alby/balance", albyHttpSvc.albyBalanceHandler, authMiddleware) + e.POST("/api/alby/pay", albyHttpSvc.albyPayHandler, authMiddleware) +} + +func (albyHttpSvc *AlbyHttpService) albyCallbackHandler(c echo.Context) error { + code := c.QueryParam("code") + + err := albyHttpSvc.albyOAuthSvc.CallbackHandler(c.Request().Context(), code) + if err != nil { + albyHttpSvc.logger.WithError(err).Error("Failed to handle Alby OAuth callback") + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Message: fmt.Sprintf("Failed to handle Alby OAuth callback: %s", err.Error()), + }) + } + + // FIXME: redirects will not work for wails + return c.Redirect(302, albyHttpSvc.albyOAuthSvc.appConfig.BaseUrl+"/#/channels/first") +} + +func (albyHttpSvc *AlbyHttpService) albyMeHandler(c echo.Context) error { + me, err := albyHttpSvc.albyOAuthSvc.GetMe(c.Request().Context()) + if err != nil { + albyHttpSvc.logger.WithError(err).Error("Failed to request alby me endpoint") + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Message: fmt.Sprintf("Failed to request alby me endpoint: %s", err.Error()), + }) + } + + return c.JSON(http.StatusOK, me) +} + +func (albyHttpSvc *AlbyHttpService) albyBalanceHandler(c echo.Context) error { + balance, err := albyHttpSvc.albyOAuthSvc.GetBalance(c.Request().Context()) + if err != nil { + albyHttpSvc.logger.WithError(err).Error("Failed to request alby balance endpoint") + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Message: fmt.Sprintf("Failed to request alby balance endpoint: %s", err.Error()), + }) + } + + return c.JSON(http.StatusOK, &api.AlbyBalanceResponse{ + Sats: balance.Balance, + }) +} + +func (albyHttpSvc *AlbyHttpService) albyPayHandler(c echo.Context) error { + var payRequest api.AlbyPayRequest + if err := c.Bind(&payRequest); err != nil { + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Message: fmt.Sprintf("Bad request: %s", err.Error()), + }) + } + + err := albyHttpSvc.albyOAuthSvc.SendPayment(c.Request().Context(), payRequest.Invoice) + if err != nil { + albyHttpSvc.logger.WithError(err).Error("Failed to request alby pay endpoint") + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Message: fmt.Sprintf("Failed to request alby pay endpoint: %s", err.Error()), + }) + } + + return c.NoContent(http.StatusNoContent) +} diff --git a/api.go b/api.go index 190df12c..d5134674 100644 --- a/api.go +++ b/api.go @@ -13,6 +13,7 @@ import ( "strings" "time" + alby "github.com/getAlby/nostr-wallet-connect/alby" "github.com/nbd-wtf/go-nostr" "gorm.io/gorm" @@ -23,12 +24,15 @@ import ( ) type API struct { - svc *Service + svc *Service + albyOAuthSvc *alby.AlbyOAuthService } -func NewAPI(svc *Service) *API { +func NewAPI(svc *Service, albyOAuthSvc *alby.AlbyOAuthService) *API { + return &API{ - svc: svc, + svc: svc, + albyOAuthSvc: albyOAuthSvc, } } @@ -332,12 +336,9 @@ func (api *API) Stop() error { if api.svc.lnClient == nil { return errors.New("LNClient not started") } - // Shut down the lnclient + // stop the lnclient // The user will be forced to re-enter their unlock password to restart the node - err := api.svc.lnClient.Shutdown() - if err == nil { - api.svc.lnClient = nil - } + err := api.svc.StopLNClient() return err } @@ -638,6 +639,7 @@ func (api *API) GetInfo() (*models.InfoResponse, error) { info.SetupCompleted = unlockPasswordCheck != "" info.Running = api.svc.lnClient != nil info.BackendType = backendType + info.AlbyAuthUrl = api.albyOAuthSvc.GetAuthUrl() if info.BackendType != config.LNDBackendType { nextBackupReminder, _ := api.svc.cfg.Get("NextBackupReminder", "") diff --git a/events/event_logger.go b/events/event_logger.go new file mode 100644 index 00000000..215efa18 --- /dev/null +++ b/events/event_logger.go @@ -0,0 +1,50 @@ +package events + +import ( + "context" + + "github.com/sirupsen/logrus" +) + +type EventListener interface { + Log(ctx context.Context, event *Event) error +} + +type Event struct { + Event string `json:"event"` + Properties interface{} `json:"properties,omitempty"` +} + +type eventLogger struct { + logger *logrus.Logger + listeners []EventListener +} + +type EventLogger interface { + Subscribe(eventListener EventListener) + Log(ctx context.Context, event *Event) +} + +func NewEventLogger(logger *logrus.Logger) *eventLogger { + eventLogger := &eventLogger{ + logger: logger, + listeners: []EventListener{}, + } + return eventLogger +} + +func (el *eventLogger) Subscribe(listener EventListener) { + el.listeners = append(el.listeners, listener) +} + +func (el *eventLogger) Log(ctx context.Context, event *Event) { + el.logger.WithField("event", event).Info("Logging event") + for _, listener := range el.listeners { + go func(listener EventListener) { + err := listener.Log(ctx, event) + if err != nil { + el.logger.WithError(err).Error("Failed to log event") + } + }(listener) + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39fec846..d31a662b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,9 @@ import { ImportMnemonic } from "src/screens/setup/ImportMnemonic"; import { SetupFinish } from "src/screens/setup/SetupFinish"; import { BackupMnemonic } from "src/screens/BackupMnemonic"; import NewInstantChannel from "src/screens/channels/NewInstantChannel"; +import FirstChannel from "src/screens/channels/FirstChannel"; +import { ChannelsRedirect } from "src/components/redirects/ChannelsRedirect"; +import MigrateAlbyFunds from "src/screens/channels/MigrateAlbyFunds"; function App() { return ( @@ -65,22 +68,21 @@ function App() { } /> } /> - } /> - } /> - } - /> - } - /> - } - /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + } /> } /> diff --git a/frontend/src/components/icons/Alby.tsx b/frontend/src/components/icons/Alby.tsx new file mode 100644 index 00000000..dd6e06a1 --- /dev/null +++ b/frontend/src/components/icons/Alby.tsx @@ -0,0 +1,51 @@ +import { SVGAttributes } from "react"; + +export function AlbyIcon(props: SVGAttributes) { + return ( + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/redirects/AppsRedirect.tsx b/frontend/src/components/redirects/AppsRedirect.tsx index b1f24b8c..372df176 100644 --- a/frontend/src/components/redirects/AppsRedirect.tsx +++ b/frontend/src/components/redirects/AppsRedirect.tsx @@ -10,7 +10,10 @@ export function AppsRedirect() { const navigate = useNavigate(); React.useEffect(() => { - if (!info || (info.running && info.unlocked)) { + if (!info) { + return; + } + if (info.running && info.unlocked) { window.localStorage.removeItem(localStorageKeys.returnTo); return; } diff --git a/frontend/src/components/redirects/ChannelsRedirect.tsx b/frontend/src/components/redirects/ChannelsRedirect.tsx new file mode 100644 index 00000000..899aad19 --- /dev/null +++ b/frontend/src/components/redirects/ChannelsRedirect.tsx @@ -0,0 +1,23 @@ +import { useInfo } from "src/hooks/useInfo"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import React from "react"; +import Loading from "src/components/Loading"; + +export function ChannelsRedirect() { + const { data: info } = useInfo(); + const location = useLocation(); + const navigate = useNavigate(); + + React.useEffect(() => { + if (!info || (info.running && info.unlocked)) { + return; + } + navigate("/"); + }, [info, location, navigate]); + + if (!info) { + return ; + } + + return ; +} diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 9a83b0a6..1acba555 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -2,3 +2,9 @@ export const localStorageKeys = { returnTo: "returnTo", onchainAddress: "onchainAddress", }; + +export const MIN_0CONF_BALANCE = 20000; +export const ALBY_SERVICE_FEE = 8 / 1000; +export const ALBY_FEE_RESERVE = 0.01; /* TODO: remove when no fee reserve is needed */ +export const MIN_ALBY_BALANCE = + MIN_0CONF_BALANCE + 1000; /* TODO: remove when no fee reserve is needed */ diff --git a/frontend/src/hooks/useAlbyBalance.ts b/frontend/src/hooks/useAlbyBalance.ts new file mode 100644 index 00000000..2b3b7267 --- /dev/null +++ b/frontend/src/hooks/useAlbyBalance.ts @@ -0,0 +1,8 @@ +import useSWR from "swr"; + +import { swrFetcher } from "src/utils/swr"; +import { AlbyBalance } from "src/types"; + +export function useAlbyBalance() { + return useSWR("/api/alby/balance", swrFetcher); +} diff --git a/frontend/src/hooks/useAlbyMe.ts b/frontend/src/hooks/useAlbyMe.ts new file mode 100644 index 00000000..6d87907b --- /dev/null +++ b/frontend/src/hooks/useAlbyMe.ts @@ -0,0 +1,8 @@ +import useSWR from "swr"; + +import { swrFetcher } from "src/utils/swr"; +import { AlbyMe } from "src/types"; + +export function useAlbyMe() { + return useSWR("/api/alby/me", swrFetcher); +} diff --git a/frontend/src/hooks/useEncryptedMnemonic.ts b/frontend/src/hooks/useEncryptedMnemonic.ts index 475583b2..5e528e19 100644 --- a/frontend/src/hooks/useEncryptedMnemonic.ts +++ b/frontend/src/hooks/useEncryptedMnemonic.ts @@ -1,8 +1,11 @@ import useSWR from "swr"; import { swrFetcher } from "src/utils/swr"; -import { MnemonicResponse } from "src/types"; +import { EncryptedMnemonicResponse } from "src/types"; export function useEncryptedMnemonic() { - return useSWR("/api/encrypted-mnemonic", swrFetcher); + return useSWR( + "/api/encrypted-mnemonic", + swrFetcher + ); } diff --git a/frontend/src/screens/channels/Channels.tsx b/frontend/src/screens/channels/Channels.tsx index f278afe5..694001d5 100644 --- a/frontend/src/screens/channels/Channels.tsx +++ b/frontend/src/screens/channels/Channels.tsx @@ -255,7 +255,7 @@ export default function Channels() { Open a channel Onchain address diff --git a/frontend/src/screens/channels/FirstChannel.tsx b/frontend/src/screens/channels/FirstChannel.tsx new file mode 100644 index 00000000..9072602b --- /dev/null +++ b/frontend/src/screens/channels/FirstChannel.tsx @@ -0,0 +1,84 @@ +import { Link } from "react-router-dom"; +import Loading from "src/components/Loading"; +import { AlbyIcon } from "src/components/icons/Alby"; +import { MIN_ALBY_BALANCE } from "src/constants"; +import { useAlbyBalance } from "src/hooks/useAlbyBalance"; +import { useAlbyMe } from "src/hooks/useAlbyMe"; +import { useInfo } from "src/hooks/useInfo"; + +export default function FirstChannel() { + const { data: info } = useInfo(); + const { data: albyMe } = useAlbyMe(); + const { data: albyBalance } = useAlbyBalance(); + + if (!info) { + return ; + } + + return ( +
+

+ Your Alby has grown up now. You have your own node and you also need + your own channels to send and receive payments on the lightning network. +

+ + {!albyMe && ( + <> +

+ If you have funds on your Alby account you can use them to open your + first channel. +

+ + + + + + )} + {albyMe && albyBalance && ( +
+

+ Logged in as{" "} + {albyMe.lightning_address} +

+ + {albyBalance.sats >= MIN_ALBY_BALANCE && ( + <> + + + +

+ You have {albyBalance.sats} sats to migrate +

+ + )} + {albyBalance.sats < MIN_ALBY_BALANCE && ( + <> +

+ You don't have enough sats in your Alby account to open a + channel. +

+ + )} +
+ )} + +

-- or --

+ + + Fund & Open Channel + +
+ ); +} diff --git a/frontend/src/screens/channels/MigrateAlbyFunds.tsx b/frontend/src/screens/channels/MigrateAlbyFunds.tsx new file mode 100644 index 00000000..2605c02b --- /dev/null +++ b/frontend/src/screens/channels/MigrateAlbyFunds.tsx @@ -0,0 +1,200 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import Loading from "src/components/Loading"; +import toast from "src/components/Toast"; +import { + ALBY_FEE_RESERVE, + ALBY_SERVICE_FEE, + MIN_ALBY_BALANCE, +} from "src/constants"; +import { useAlbyBalance } from "src/hooks/useAlbyBalance"; +import { useAlbyMe } from "src/hooks/useAlbyMe"; +import { useCSRF } from "src/hooks/useCSRF"; +import { useChannels } from "src/hooks/useChannels"; +import { useInfo } from "src/hooks/useInfo"; +import { + LSPOption, + NewWrappedInvoiceRequest, + NewWrappedInvoiceResponse, +} from "src/types"; +import { handleRequestError } from "src/utils/handleRequestError"; +import { request } from "src/utils/request"; + +const DEFAULT_LSP: LSPOption = "OLYMPUS"; + +export default function MigrateAlbyFunds() { + const { data: albyMe } = useAlbyMe(); + const { data: albyBalance } = useAlbyBalance(); + const { data: csrf } = useCSRF(); + const { data: channels } = useChannels(); + const { mutate: refetchInfo } = useInfo(); + const [prePurchaseChannelCount, setPrePurchaseChannelCount] = React.useState< + number | undefined + >(); + const [error, setError] = React.useState(""); + const [hasRequestedInvoice, setRequestedInvoice] = React.useState(false); + const [isOpeningChannel, setOpeningChannel] = React.useState(false); + const navigate = useNavigate(); + const [amount, setAmount] = React.useState(0); + + const [wrappedInvoiceResponse, setWrappedInvoiceResponse] = React.useState< + NewWrappedInvoiceResponse | undefined + >(); + + const requestWrappedInvoice = React.useCallback( + async (amount: number) => { + try { + if (!channels) { + throw new Error("Channels not loaded"); + } + setPrePurchaseChannelCount(channels.length); + if (!csrf) { + throw new Error("csrf not loaded"); + } + const newJITChannelRequest: NewWrappedInvoiceRequest = { + lsp: DEFAULT_LSP, + amount, + }; + const response = await request( + "/api/wrapped-invoices", + { + method: "POST", + headers: { + "X-CSRF-Token": csrf, + "Content-Type": "application/json", + }, + body: JSON.stringify(newJITChannelRequest), + } + ); + if (!response?.wrappedInvoice) { + throw new Error("No wrapped invoice in response"); + } + setWrappedInvoiceResponse(response); + } catch (error) { + setError("Failed to connect to request wrapped invoice: " + error); + } + }, + [channels, csrf] + ); + + const payWrappedInvoice = React.useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (!wrappedInvoiceResponse) { + throw new Error("No wrapped invoice"); + } + if (!csrf) { + throw new Error("No csrf token"); + } + setOpeningChannel(true); + await request("/api/alby/pay", { + method: "POST", + headers: { + "X-CSRF-Token": csrf, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + invoice: wrappedInvoiceResponse.wrappedInvoice, + }), + }); + } catch (error) { + handleRequestError("Failed to pay channel funding invoice", error); + setOpeningChannel(false); + } + }, + [csrf, wrappedInvoiceResponse] + ); + + React.useEffect(() => { + if (hasRequestedInvoice || !channels || !albyMe || !albyBalance) { + return; + } + setRequestedInvoice(true); + const _amount = Math.floor( + albyBalance.sats * (1 - ALBY_FEE_RESERVE) * (1 - ALBY_SERVICE_FEE) + ); + setAmount(_amount); + requestWrappedInvoice(_amount); + }, [ + hasRequestedInvoice, + albyBalance, + channels, + albyMe, + requestWrappedInvoice, + ]); + + const hasOpenedChannel = + channels && + prePurchaseChannelCount !== undefined && + channels.length > prePurchaseChannelCount; + + React.useEffect(() => { + if (hasOpenedChannel) { + (async () => { + toast.success("Channel opened!"); + await refetchInfo(); + navigate("/"); + })(); + } + }, [hasOpenedChannel, navigate, refetchInfo]); + + if (!albyMe || !albyBalance || !channels || !wrappedInvoiceResponse) { + return ; + } + + if (error) { + return

{error}

; + } + + if (albyBalance.sats < MIN_ALBY_BALANCE) { + return ( +

You don't have enough sats in your Alby account to open a channel.

+ ); + } + + /*if (channels.length) { + return ( +

You already have a channel.

+ ); + }*/ + + const LSP_FREE_INCOMING = 100000; + const estimatedChannelSize = + amount - wrappedInvoiceResponse.fee + LSP_FREE_INCOMING; + return ( +
+

Migrate Alby Account Funds

+

Alby Account Balance: {albyBalance.sats} sats

+

Invoice to pay: {amount} sats

+

+ LSP fee ({DEFAULT_LSP}): {wrappedInvoiceResponse.fee} sats +

+

+ Alby service fee: {Math.floor(amount * ALBY_SERVICE_FEE)} sats +

+

+ Alby fee reserve: {Math.floor(albyBalance.sats * ALBY_FEE_RESERVE)} sats +

+

+ Estimated Channel size: {estimatedChannelSize} sats +

+

+ Estimated sendable: {amount - wrappedInvoiceResponse.fee} sats +

+

+ Estimated receivable: {LSP_FREE_INCOMING - wrappedInvoiceResponse.fee}{" "} + sats +

+
+ +
+
+ ); +} diff --git a/frontend/src/screens/channels/NewInstantChannel.tsx b/frontend/src/screens/channels/NewInstantChannel.tsx index 1c90a8e1..0c84453e 100644 --- a/frontend/src/screens/channels/NewInstantChannel.tsx +++ b/frontend/src/screens/channels/NewInstantChannel.tsx @@ -1,26 +1,20 @@ import { Payment, init } from "@getalby/bitcoin-connect-react"; import React from "react"; import ConnectButton from "src/components/ConnectButton"; +import { MIN_0CONF_BALANCE } from "src/constants"; import { useCSRF } from "src/hooks/useCSRF"; import { useChannels } from "src/hooks/useChannels"; +import { + LSPOption, + LSP_OPTIONS, + NewWrappedInvoiceRequest, + NewWrappedInvoiceResponse, +} from "src/types"; import { request } from "src/utils/request"; init({ showBalance: false, }); -type LSPOption = "OLYMPUS" | "VOLTAGE"; -const LSP_OPTIONS: LSPOption[] = ["OLYMPUS", "VOLTAGE"]; - -type NewWrappedInvoiceRequest = { - amount: number; - lsp: LSPOption; -}; - -type NewWrappedInvoiceResponse = { - wrappedInvoice: string; - fee: number; -}; - export default function NewInstantChannel() { const { data: csrf } = useCSRF(); const { data: channels } = useChannels(); @@ -35,7 +29,7 @@ export default function NewInstantChannel() { const amountSats = React.useMemo(() => { try { const _amountSats = parseInt(amount); - if (_amountSats >= 20000) { + if (_amountSats >= MIN_0CONF_BALANCE) { return _amountSats; } } catch (error) { @@ -114,9 +108,9 @@ export default function NewInstantChannel() { <>

2. Purchase Liquidity

- Enter at least 20,000 sats. You'll receive outgoing liquidity of - this amount minus any LSP fees. You'll also get some incoming - liquidity. + Enter at least {MIN_0CONF_BALANCE} sats. You'll receive outgoing + liquidity of this amount minus any LSP fees. You'll also get some + incoming liquidity.

Amount in sats

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a5ecdf43..8c20a433 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -125,10 +125,11 @@ export interface InfoResponse { setupCompleted: boolean; running: boolean; unlocked: boolean; + albyAuthUrl: string; showBackupReminder: boolean; } -export interface MnemonicResponse { +export interface EncryptedMnemonicResponse { mnemonic: string; } @@ -209,6 +210,35 @@ export type SetupNodeInfo = Partial<{ lndMacaroonHex?: string; }>; +// TODO: move to different file +export type AlbyMe = { + identifier: string; + nostr_pubkey: string; + lightning_address: string; + email: string; + name: string; + avatar: string; + keysend_pubkey: string; +}; + +export type AlbyBalance = { + sats: number; +}; + +// TODO: move to different file +export type LSPOption = "OLYMPUS" | "VOLTAGE"; +export const LSP_OPTIONS: LSPOption[] = ["OLYMPUS", "VOLTAGE"]; + +export type NewWrappedInvoiceRequest = { + amount: number; + lsp: LSPOption; +}; + +export type NewWrappedInvoiceResponse = { + wrappedInvoice: string; + fee: number; +}; + export type RedeemOnchainFundsResponse = { txId: string; }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a782ac91..3d7659f0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,7 +8,6 @@ export default defineConfig({ server: { proxy: { "/api": "http://localhost:8080", - "/alby": "http://localhost:8080", }, }, resolve: { diff --git a/go.mod b/go.mod index eb463050..77038543 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/stretchr/testify v1.8.2 github.com/wailsapp/wails/v2 v2.7.1 golang.org/x/crypto v0.14.0 + golang.org/x/oauth2 v0.4.0 google.golang.org/grpc v1.53.0 gopkg.in/macaroon.v2 v2.1.0 gorm.io/gorm v1.25.4 @@ -179,6 +180,7 @@ require ( golang.org/x/text v0.13.0 // indirect golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.6.0 // indirect + google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230113154510-dbe35b8444a5 // indirect google.golang.org/protobuf v1.29.1 // indirect gopkg.in/errgo.v1 v1.0.1 // indirect diff --git a/handle_multi_pay_invoice_request.go b/handle_multi_pay_invoice_request.go index 6ff98061..6cc89785 100644 --- a/handle_multi_pay_invoice_request.go +++ b/handle_multi_pay_invoice_request.go @@ -7,6 +7,7 @@ import ( "strings" "sync" + "github.com/getAlby/nostr-wallet-connect/events" "github.com/nbd-wtf/go-nostr" decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/sirupsen/logrus" @@ -109,6 +110,16 @@ func (svc *Service) HandleMultiPayInvoiceEvent(ctx context.Context, request *Nip "bolt11": bolt11, }).Infof("Failed to send payment: %v", err) + svc.EventLogger.Log(ctx, &events.Event{ + Event: "nwc_payment_failed", + Properties: map[string]interface{}{ + "error": fmt.Sprintf("%v", err), + "multi": true, + "invoice": bolt11, + "amount": paymentRequest.MSatoshi / 1000, + }, + }) + publishResponse(&Nip47Response{ ResultType: request.Method, Error: &Nip47Error{ @@ -122,6 +133,13 @@ func (svc *Service) HandleMultiPayInvoiceEvent(ctx context.Context, request *Nip mu.Lock() svc.db.Save(&payment) mu.Unlock() + svc.EventLogger.Log(ctx, &events.Event{ + Event: "nwc_payment_succeeded", + Properties: map[string]interface{}{ + "multi": true, + "amount": paymentRequest.MSatoshi / 1000, + }, + }) publishResponse(&Nip47Response{ ResultType: request.Method, Result: Nip47PayResponse{ diff --git a/handle_multi_pay_keysend_request.go b/handle_multi_pay_keysend_request.go index 4503460f..43eaa76b 100644 --- a/handle_multi_pay_keysend_request.go +++ b/handle_multi_pay_keysend_request.go @@ -3,8 +3,10 @@ package main import ( "context" "encoding/json" + "fmt" "sync" + "github.com/getAlby/nostr-wallet-connect/events" "github.com/nbd-wtf/go-nostr" "github.com/sirupsen/logrus" ) @@ -83,6 +85,15 @@ func (svc *Service) HandleMultiPayKeysendEvent(ctx context.Context, request *Nip "appId": app.ID, "recipientPubkey": keysendInfo.Pubkey, }).Infof("Failed to send payment: %v", err) + svc.EventLogger.Log(ctx, &events.Event{ + Event: "nwc_payment_failed", + Properties: map[string]interface{}{ + "error": fmt.Sprintf("%v", err), + "keysend": true, + "multi": true, + "amount": keysendInfo.Amount / 1000, + }, + }) publishResponse(&Nip47Response{ ResultType: request.Method, @@ -97,6 +108,14 @@ func (svc *Service) HandleMultiPayKeysendEvent(ctx context.Context, request *Nip mu.Lock() svc.db.Save(&payment) mu.Unlock() + svc.EventLogger.Log(ctx, &events.Event{ + Event: "nwc_payment_succeeded", + Properties: map[string]interface{}{ + "keysend": true, + "multi": true, + "amount": keysendInfo.Amount / 1000, + }, + }) publishResponse(&Nip47Response{ ResultType: request.Method, Result: Nip47PayResponse{ diff --git a/handle_pay_keysend_request.go b/handle_pay_keysend_request.go index eeb9dcca..e3f016c0 100644 --- a/handle_pay_keysend_request.go +++ b/handle_pay_keysend_request.go @@ -3,7 +3,9 @@ package main import ( "context" "encoding/json" + "fmt" + "github.com/getAlby/nostr-wallet-connect/events" "github.com/sirupsen/logrus" ) @@ -56,6 +58,14 @@ func (svc *Service) HandlePayKeysendEvent(ctx context.Context, request *Nip47Req "appId": app.ID, "recipientPubkey": payParams.Pubkey, }).Infof("Failed to send payment: %v", err) + svc.EventLogger.Log(ctx, &events.Event{ + Event: "nwc_payment_failed", + Properties: map[string]interface{}{ + "error": fmt.Sprintf("%v", err), + "keysend": true, + "amount": payParams.Amount / 1000, + }, + }) return &Nip47Response{ ResultType: request.Method, Error: &Nip47Error{ @@ -66,6 +76,13 @@ func (svc *Service) HandlePayKeysendEvent(ctx context.Context, request *Nip47Req } payment.Preimage = &preimage svc.db.Save(&payment) + svc.EventLogger.Log(ctx, &events.Event{ + Event: "nwc_payment_succeeded", + Properties: map[string]interface{}{ + "keysend": true, + "amount": payParams.Amount / 1000, + }, + }) return &Nip47Response{ ResultType: request.Method, Result: Nip47PayResponse{ diff --git a/handle_payment_request.go b/handle_payment_request.go index 5f7a17ba..137e880f 100644 --- a/handle_payment_request.go +++ b/handle_payment_request.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/getAlby/nostr-wallet-connect/events" decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/sirupsen/logrus" ) @@ -78,6 +79,14 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req "appId": app.ID, "bolt11": bolt11, }).Infof("Failed to send payment: %v", err) + svc.EventLogger.Log(ctx, &events.Event{ + Event: "nwc_payment_failed", + Properties: map[string]interface{}{ + "invoice": bolt11, + "error": fmt.Sprintf("%v", err), + "amount": paymentRequest.MSatoshi / 1000, + }, + }) return &Nip47Response{ ResultType: request.Method, Error: &Nip47Error{ @@ -88,6 +97,15 @@ func (svc *Service) HandlePayInvoiceEvent(ctx context.Context, request *Nip47Req } payment.Preimage = &preimage svc.db.Save(&payment) + + svc.EventLogger.Log(ctx, &events.Event{ + Event: "nwc_payment_succeeded", + Properties: map[string]interface{}{ + "bolt11": bolt11, + "amount": paymentRequest.MSatoshi / 1000, + }, + }) + return &Nip47Response{ ResultType: request.Method, Result: Nip47PayResponse{ diff --git a/http_service.go b/http_service.go index 38b6dbb7..1ab618af 100644 --- a/http_service.go +++ b/http_service.go @@ -6,6 +6,9 @@ import ( "net/http" echologrus "github.com/davrux/echo-logrus/v4" + "github.com/getAlby/nostr-wallet-connect/alby" + "github.com/getAlby/nostr-wallet-connect/events" + models "github.com/getAlby/nostr-wallet-connect/models/http" "github.com/gorilla/sessions" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" @@ -17,8 +20,9 @@ import ( ) type HttpService struct { - svc *Service - api *API + svc *Service + api *API + albyHttpSvc *alby.AlbyHttpService } const ( @@ -28,8 +32,9 @@ const ( func NewHttpService(svc *Service) *HttpService { return &HttpService{ - svc: svc, - api: NewAPI(svc), + svc: svc, + api: NewAPI(svc, svc.AlbyOAuthSvc), + albyHttpSvc: alby.NewAlbyHttpService(svc.AlbyOAuthSvc, svc.Logger), } } @@ -85,6 +90,8 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { e.POST("/api/reset-router", httpSvc.resetRouterHandler, authMiddleware) e.POST("/api/stop", httpSvc.stopHandler, authMiddleware) + httpSvc.albyHttpSvc.RegisterSharedRoutes(e, authMiddleware) + e.GET("/api/mempool/lightning/nodes/:pubkey", httpSvc.mempoolLightningNodeHandler, authMiddleware) frontend.RegisterHandlers(e) @@ -93,7 +100,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { func (httpSvc *HttpService) csrfHandler(c echo.Context) error { csrf, _ := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string) if csrf == "" { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: "CSRF token not available", }) } @@ -103,7 +110,7 @@ func (httpSvc *HttpService) csrfHandler(c echo.Context) error { func (httpSvc *HttpService) infoHandler(c echo.Context) error { responseBody, err := httpSvc.api.GetInfo() if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: err.Error(), }) } @@ -119,14 +126,14 @@ func (httpSvc *HttpService) encryptedMnemonicHandler(c echo.Context) error { func (httpSvc *HttpService) backupReminderHandler(c echo.Context) error { var backupReminderRequest api.BackupReminderRequest if err := c.Bind(&backupReminderRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } err := httpSvc.api.SetNextBackupReminder(&backupReminderRequest) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to store backup reminder: %s", err.Error()), }) } @@ -137,14 +144,14 @@ func (httpSvc *HttpService) backupReminderHandler(c echo.Context) error { func (httpSvc *HttpService) startHandler(c echo.Context) error { var startRequest api.StartRequest if err := c.Bind(&startRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } err := httpSvc.api.Start(&startRequest) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to start node: %s", err.Error()), }) } @@ -152,7 +159,7 @@ func (httpSvc *HttpService) startHandler(c echo.Context) error { err = httpSvc.saveSessionCookie(c) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to save session: %s", err.Error()), }) } @@ -163,13 +170,13 @@ func (httpSvc *HttpService) startHandler(c echo.Context) error { func (httpSvc *HttpService) unlockHandler(c echo.Context) error { var unlockRequest api.UnlockRequest if err := c.Bind(&unlockRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } if !httpSvc.svc.cfg.CheckUnlockPassword(unlockRequest.UnlockPassword) { - return c.JSON(http.StatusUnauthorized, ErrorResponse{ + return c.JSON(http.StatusUnauthorized, models.ErrorResponse{ Message: "Invalid password", }) } @@ -177,11 +184,15 @@ func (httpSvc *HttpService) unlockHandler(c echo.Context) error { err := httpSvc.saveSessionCookie(c) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to save session: %s", err.Error()), }) } + httpSvc.svc.EventLogger.Log(c.Request().Context(), &events.Event{ + Event: "nwc_unlocked", + }) + return c.NoContent(http.StatusNoContent) } @@ -200,7 +211,7 @@ func (httpSvc *HttpService) saveSessionCookie(c echo.Context) error { sess.Values[sessionCookieAuthKey] = true err := sess.Save(c.Request(), c.Response()) if err != nil { - httpSvc.svc.Logger.Errorf("Failed to save session: %v", err) + httpSvc.svc.Logger.WithError(err).Error("Failed to save session") } return err } @@ -208,13 +219,13 @@ func (httpSvc *HttpService) saveSessionCookie(c echo.Context) error { func (httpSvc *HttpService) logoutHandler(c echo.Context) error { sess, err := session.Get(sessionCookieName, c) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: "Failed to get session", }) } sess.Options.MaxAge = -1 if err := sess.Save(c.Request(), c.Response()); err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: "Failed to save session", }) } @@ -227,7 +238,7 @@ func (httpSvc *HttpService) channelsListHandler(c echo.Context) error { channels, err := httpSvc.api.ListChannels(ctx) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: err.Error(), }) } @@ -241,7 +252,7 @@ func (httpSvc *HttpService) resetRouterHandler(c echo.Context) error { err := httpSvc.api.ResetRouter(ctx) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: err.Error(), }) } @@ -254,7 +265,7 @@ func (httpSvc *HttpService) stopHandler(c echo.Context) error { err := httpSvc.api.Stop() if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: err.Error(), }) } @@ -268,7 +279,7 @@ func (httpSvc *HttpService) nodeConnectionInfoHandler(c echo.Context) error { info, err := httpSvc.api.GetNodeConnectionInfo(ctx) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: err.Error(), }) } @@ -282,7 +293,7 @@ func (httpSvc *HttpService) onchainBalanceHandler(c echo.Context) error { onchainBalanceResponse, err := httpSvc.api.GetOnchainBalance(ctx) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: err.Error(), }) } @@ -293,14 +304,14 @@ func (httpSvc *HttpService) onchainBalanceHandler(c echo.Context) error { func (httpSvc *HttpService) mempoolLightningNodeHandler(c echo.Context) error { pubkey := c.Param("pubkey") if pubkey == "" { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: "Invalid pubkey parameter", }) } response, err := httpSvc.api.GetMempoolLightningNode(pubkey) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to request mempool API: %s", err.Error()), }) } @@ -313,7 +324,7 @@ func (httpSvc *HttpService) connectPeerHandler(c echo.Context) error { var connectPeerRequest api.ConnectPeerRequest if err := c.Bind(&connectPeerRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } @@ -321,7 +332,7 @@ func (httpSvc *HttpService) connectPeerHandler(c echo.Context) error { err := httpSvc.api.ConnectPeer(ctx, &connectPeerRequest) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to connect peer: %s", err.Error()), }) } @@ -334,7 +345,7 @@ func (httpSvc *HttpService) openChannelHandler(c echo.Context) error { var openChannelRequest api.OpenChannelRequest if err := c.Bind(&openChannelRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } @@ -342,7 +353,7 @@ func (httpSvc *HttpService) openChannelHandler(c echo.Context) error { openChannelResponse, err := httpSvc.api.OpenChannel(ctx, &openChannelRequest) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to open channel: %s", err.Error()), }) } @@ -355,7 +366,7 @@ func (httpSvc *HttpService) closeChannelHandler(c echo.Context) error { var closeChannelRequest api.CloseChannelRequest if err := c.Bind(&closeChannelRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } @@ -363,7 +374,7 @@ func (httpSvc *HttpService) closeChannelHandler(c echo.Context) error { closeChannelResponse, err := httpSvc.api.CloseChannel(ctx, &closeChannelRequest) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to close channel: %s", err.Error()), }) } @@ -376,7 +387,7 @@ func (httpSvc *HttpService) newWrappedInvoiceHandler(c echo.Context) error { var newWrappedInvoiceRequest api.NewWrappedInvoiceRequest if err := c.Bind(&newWrappedInvoiceRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } @@ -384,7 +395,7 @@ func (httpSvc *HttpService) newWrappedInvoiceHandler(c echo.Context) error { newWrappedInvoiceResponse, err := httpSvc.api.NewWrappedInvoice(ctx, &newWrappedInvoiceRequest) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to request wrapped invoice: %s", err.Error()), }) } @@ -398,7 +409,7 @@ func (httpSvc *HttpService) newOnchainAddressHandler(c echo.Context) error { newAddressResponse, err := httpSvc.api.GetNewOnchainAddress(ctx) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to request new onchain address: %s", err.Error()), }) } @@ -411,7 +422,7 @@ func (httpSvc *HttpService) redeemOnchainFundsHandler(c echo.Context) error { var redeemOnchainFundsRequest api.RedeemOnchainFundsRequest if err := c.Bind(&redeemOnchainFundsRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } @@ -419,7 +430,7 @@ func (httpSvc *HttpService) redeemOnchainFundsHandler(c echo.Context) error { redeemOnchainFundsResponse, err := httpSvc.api.RedeemOnchainFunds(ctx, redeemOnchainFundsRequest.ToAddress) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to redeem onchain funds: %s", err.Error()), }) } @@ -432,7 +443,7 @@ func (httpSvc *HttpService) appsListHandler(c echo.Context) error { apps, err := httpSvc.api.ListApps() if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: err.Error(), }) } @@ -445,7 +456,7 @@ func (httpSvc *HttpService) appsShowHandler(c echo.Context) error { findResult := httpSvc.svc.db.Where("nostr_pubkey = ?", c.Param("pubkey")).First(&app) if findResult.RowsAffected == 0 { - return c.JSON(http.StatusNotFound, ErrorResponse{ + return c.JSON(http.StatusNotFound, models.ErrorResponse{ Message: "App does not exist", }) } @@ -458,7 +469,7 @@ func (httpSvc *HttpService) appsShowHandler(c echo.Context) error { func (httpSvc *HttpService) appsUpdateHandler(c echo.Context) error { var requestData api.UpdateAppRequest if err := c.Bind(&requestData); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } @@ -467,7 +478,7 @@ func (httpSvc *HttpService) appsUpdateHandler(c echo.Context) error { findResult := httpSvc.svc.db.Where("nostr_pubkey = ?", c.Param("pubkey")).First(&app) if findResult.RowsAffected == 0 { - return c.JSON(http.StatusNotFound, ErrorResponse{ + return c.JSON(http.StatusNotFound, models.ErrorResponse{ Message: "App does not exist", }) } @@ -476,7 +487,7 @@ func (httpSvc *HttpService) appsUpdateHandler(c echo.Context) error { if err != nil { httpSvc.svc.Logger.WithError(err).Error("Failed to update app") - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to update app: %v", err), }) } @@ -487,7 +498,7 @@ func (httpSvc *HttpService) appsUpdateHandler(c echo.Context) error { func (httpSvc *HttpService) appsDeleteHandler(c echo.Context) error { pubkey := c.Param("pubkey") if pubkey == "" { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: "Invalid pubkey parameter", }) } @@ -495,17 +506,17 @@ func (httpSvc *HttpService) appsDeleteHandler(c echo.Context) error { result := httpSvc.svc.db.Where("nostr_pubkey = ?", pubkey).First(&app) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return c.JSON(http.StatusNotFound, ErrorResponse{ + return c.JSON(http.StatusNotFound, models.ErrorResponse{ Message: "App not found", }) } - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: "Failed to fetch app", }) } if err := httpSvc.api.DeleteApp(&app); err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: "Failed to delete app", }) } @@ -515,7 +526,7 @@ func (httpSvc *HttpService) appsDeleteHandler(c echo.Context) error { func (httpSvc *HttpService) appsCreateHandler(c echo.Context) error { var requestData api.CreateAppRequest if err := c.Bind(&requestData); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } @@ -523,8 +534,8 @@ func (httpSvc *HttpService) appsCreateHandler(c echo.Context) error { responseBody, err := httpSvc.api.CreateApp(&requestData) if err != nil { - httpSvc.svc.Logger.Errorf("Failed to save app: %v", err) - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + httpSvc.svc.Logger.WithField("requestData", requestData).WithError(err).Error("Failed to save app") + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to save app: %v", err), }) } @@ -535,14 +546,14 @@ func (httpSvc *HttpService) appsCreateHandler(c echo.Context) error { func (httpSvc *HttpService) setupHandler(c echo.Context) error { var setupRequest api.SetupRequest if err := c.Bind(&setupRequest); err != nil { - return c.JSON(http.StatusBadRequest, ErrorResponse{ + return c.JSON(http.StatusBadRequest, models.ErrorResponse{ Message: fmt.Sprintf("Bad request: %s", err.Error()), }) } err := httpSvc.api.Setup(&setupRequest) if err != nil { - return c.JSON(http.StatusInternalServerError, ErrorResponse{ + return c.JSON(http.StatusInternalServerError, models.ErrorResponse{ Message: fmt.Sprintf("Failed to setup node: %s", err.Error()), }) } diff --git a/ldk.go b/ldk.go index 0e3e05be..aa66b8b8 100644 --- a/ldk.go +++ b/ldk.go @@ -13,6 +13,7 @@ import ( "time" "github.com/getAlby/ldk-node-go/ldk_node" + "github.com/getAlby/nostr-wallet-connect/events" "github.com/getAlby/nostr-wallet-connect/models/lnclient" "github.com/getAlby/nostr-wallet-connect/models/lsp" decodepay "github.com/nbd-wtf/ln-decodepay" @@ -26,6 +27,7 @@ type LDKService struct { ldkEventBroadcaster LDKEventBroadcaster cancel context.CancelFunc network string + eventLogger events.EventLogger } func NewLDKService(svc *Service, mnemonic, workDir string, network string, esploraServer string, gossipSource string) (result lnclient.LNClient, err error) { @@ -81,14 +83,19 @@ func NewLDKService(svc *Service, mnemonic, workDir string, network string, esplo return nil, err } - err = node.Start() - if err != nil { - svc.Logger.Errorf("Failed to start LDK node: %v", err) - return nil, err - } - ldkEventConsumer := make(chan *ldk_node.Event) ctx, cancel := context.WithCancel(svc.ctx) + ldkEventBroadcaster := NewLDKEventBroadcaster(svc.Logger, ctx, ldkEventConsumer) + + ls := LDKService{ + workdir: newpath, + node: node, + svc: svc, + cancel: cancel, + ldkEventBroadcaster: ldkEventBroadcaster, + network: network, + eventLogger: svc.EventLogger, + } // check for and forward new LDK events to LDKEventBroadcaster (through ldkEventConsumer) go func() { @@ -106,9 +113,7 @@ func NewLDKService(svc *Service, mnemonic, workDir string, network string, esplo continue } - svc.Logger.WithFields(logrus.Fields{ - "event": event, - }).Info("Received LDK event") + ls.logLdkEvent(ctx, event) ldkEventConsumer <- event node.EventHandled() @@ -116,15 +121,10 @@ func NewLDKService(svc *Service, mnemonic, workDir string, network string, esplo } }() - // TODO: rename "gs" in this file - gs := LDKService{ - workdir: newpath, - node: node, - //listener: &listener, - svc: svc, - cancel: cancel, - ldkEventBroadcaster: NewLDKEventBroadcaster(svc.Logger, ctx, ldkEventConsumer), - network: network, + err = node.Start() + if err != nil { + svc.Logger.Errorf("Failed to start LDK node: %v", err) + return nil, err } nodeId := node.NodeId() @@ -137,7 +137,7 @@ func NewLDKService(svc *Service, mnemonic, workDir string, network string, esplo "nodeId": nodeId, }).Info("Connected to LDK node") - return &gs, nil + return &ls, nil } func (gs *LDKService) Shutdown() error { @@ -148,12 +148,33 @@ func (gs *LDKService) Shutdown() error { return nil } -func (gs *LDKService) SendPaymentSync(ctx context.Context, payReq string) (preimage string, err error) { +func (gs *LDKService) SendPaymentSync(ctx context.Context, invoice string) (preimage string, err error) { + maxSendable := gs.getMaxSendable() + + paymentRequest, err := decodepay.Decodepay(invoice) + if err != nil { + gs.svc.Logger.WithFields(logrus.Fields{ + "bolt11": invoice, + }).Errorf("Failed to decode bolt11 invoice: %v", err) + + return "", err + } + + gs.eventLogger.Log(ctx, &events.Event{ + Event: "nwc_ldk_send_payment", + Properties: map[string]interface{}{ + "invoice": invoice, + "amount": paymentRequest.MSatoshi / 1000, + "max_sendable": maxSendable, + "num_channels": len(gs.node.ListChannels()), + }, + }) + paymentStart := time.Now() ldkEventSubscription := gs.ldkEventBroadcaster.Subscribe() defer gs.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription) - paymentHash, err := gs.node.Bolt11Payment().Send(payReq) + paymentHash, err := gs.node.Bolt11Payment().Send(invoice) if err != nil { gs.svc.Logger.WithError(err).Error("SendPayment failed") return "", err @@ -222,6 +243,15 @@ func (gs *LDKService) SendPaymentSync(ctx context.Context, payReq string) (preim "failureReasonMessage": failureReasonMessage, }).Error("Received payment failed event") + gs.eventLogger.Log(ctx, &events.Event{ + Event: "nwc_ldk_payment_failed", + Properties: map[string]interface{}{ + "invoice": invoice, + "reason": failureReason, + "reason_message": failureReasonMessage, + }, + }) + return "", fmt.Errorf("payment failed event: %v %s", failureReason, failureReasonMessage) } } @@ -343,14 +373,49 @@ func (gs *LDKService) GetBalance(ctx context.Context) (balance int64, err error) balance = 0 for _, channel := range channels { - balance += int64(channel.OutboundCapacityMsat) + if channel.IsUsable { + balance += int64(channel.OutboundCapacityMsat) + } } return balance, nil } +func (gs *LDKService) getMaxReceivable() int64 { + var receivable int64 = 0 + channels := gs.node.ListChannels() + for _, channel := range channels { + if channel.IsUsable { + receivable += min(int64(channel.InboundCapacityMsat), int64(*channel.InboundHtlcMaximumMsat)) + } + } + return int64(receivable) +} + +func (gs *LDKService) getMaxSendable() int64 { + var spendable int64 = 0 + channels := gs.node.ListChannels() + for _, channel := range channels { + if channel.IsUsable { + spendable += min(int64(channel.OutboundCapacityMsat), int64(*channel.CounterpartyOutboundHtlcMaximumMsat)) + } + } + return int64(spendable) +} + func (gs *LDKService) MakeInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64) (transaction *Nip47Transaction, err error) { + maxReceivable := gs.getMaxReceivable() + + gs.eventLogger.Log(ctx, &events.Event{ + Event: "nwc_ldk_make_invoice", + Properties: map[string]interface{}{ + "amount": amount / 1000, + "max_receivable": maxReceivable, + "num_channels": len(gs.node.ListChannels()), + }, + }) + // TODO: support passing description hash invoice, err := gs.node.Bolt11Payment().Receive(uint64(amount), description, @@ -684,3 +749,44 @@ func (gs *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails) ExpiresAt: expiresAt, }, nil } + +func (ls *LDKService) logLdkEvent(ctx context.Context, event *ldk_node.Event) { + ls.svc.Logger.WithFields(logrus.Fields{ + "event": event, + }).Info("Received LDK event") + + switch v := (*event).(type) { + case ldk_node.EventChannelReady: + ls.eventLogger.Log(ctx, &events.Event{ + Event: "nwc_ldk_channel_ready", + Properties: map[string]interface{}{ + "counterparty_node_id": v.CounterpartyNodeId, + }, + }) + case ldk_node.EventChannelClosed: + ls.eventLogger.Log(ctx, &events.Event{ + Event: "nwc_ldk_channel_closed", + Properties: map[string]interface{}{ + "counterparty_node_id": v.CounterpartyNodeId, + "reason": fmt.Sprintf("%+v", v.Reason), + }, + }) + case ldk_node.EventPaymentReceived: + ls.eventLogger.Log(ctx, &events.Event{ + Event: "nwc_ldk_payment_received", + Properties: map[string]interface{}{ + "payment_hash": v.PaymentHash, + "amount": v.AmountMsat / 1000, + }, + }) + case ldk_node.EventPaymentFailed: + ls.eventLogger.Log(ctx, &events.Event{ + Event: "nwc_ldk_payment_failed", + Properties: map[string]interface{}{ + "payment_hash": v.PaymentHash, + "reason": fmt.Sprintf("%+v", v.Reason), + }, + }) + } + +} diff --git a/migrations/202403171120_delete_ldk_payments.go b/migrations/202403171120_delete_ldk_payments.go index bab4d1da..33c8b80d 100644 --- a/migrations/202403171120_delete_ldk_payments.go +++ b/migrations/202403171120_delete_ldk_payments.go @@ -15,6 +15,7 @@ import ( ) // Delete LDK payments that were not migrated to the new LDK format (PaymentKind) +// TODO: delete this sometime in the future (only affects current testers) func _202403171120_delete_ldk_payments(appConfig *config.AppConfig, logger *logrus.Logger) *gormigrate.Migration { return &gormigrate.Migration{ ID: "202403171120_delete_ldk_payments", diff --git a/models.go b/models.go index 461803d3..a943346e 100644 --- a/models.go +++ b/models.go @@ -110,10 +110,6 @@ type PayRequest struct { Invoice string `json:"invoice"` } -type ErrorResponse struct { - Message string `json:"message"` -} - // TODO: move to models/Nip47 type Nip47Request struct { Method string `json:"method"` diff --git a/models/api/api.go b/models/api/api.go index 5f265cd9..db0a8b9e 100644 --- a/models/api/api.go +++ b/models/api/api.go @@ -92,6 +92,7 @@ type InfoResponse struct { SetupCompleted bool `json:"setupCompleted"` Running bool `json:"running"` Unlocked bool `json:"unlocked"` + AlbyAuthUrl string `json:"albyAuthUrl"` ShowBackupReminder bool `json:"showBackupReminder"` } @@ -123,8 +124,17 @@ type RedeemOnchainFundsResponse struct { TxId string `json:"txId"` } +type OnchainBalanceResponse = lnclient.OnchainBalanceResponse + type NewOnchainAddressResponse struct { Address string `json:"address"` } -type OnchainBalanceResponse = lnclient.OnchainBalanceResponse +// TODO: move to different file +type AlbyBalanceResponse struct { + Sats int64 `json:"sats"` +} + +type AlbyPayRequest struct { + Invoice string `json:"invoice"` +} diff --git a/models/config/config.go b/models/config/config.go index 63c7144a..fc73e116 100644 --- a/models/config/config.go +++ b/models/config/config.go @@ -21,6 +21,17 @@ type AppConfig struct { LDKNetwork string `envconfig:"LDK_NETWORK" default:"bitcoin"` LDKEsploraServer string `envconfig:"LDK_ESPLORA_SERVER" default:"https://blockstream.info/api"` LDKGossipSource string `envconfig:"LDK_GOSSIP_SOURCE" default:"https://rapidsync.lightningdevkit.org/snapshot"` - MempoolApi string `envconfig:"MEMPOOL_API" default:"https://mempool.space/api"` LDKLogLevel string `envconfig:"LDK_LOG_LEVEL"` + MempoolApi string `envconfig:"MEMPOOL_API" default:"https://mempool.space/api"` + AlbyAPIURL string `envconfig:"ALBY_API_URL" default:"https://api.getalby.com"` + AlbyClientId string `envconfig:"ALBY_OAUTH_CLIENT_ID"` + AlbyClientSecret string `envconfig:"ALBY_OAUTH_CLIENT_SECRET"` + AlbyOAuthAuthUrl string `envconfig:"ALBY_OAUTH_AUTH_URL" default:"https://getalby.com/oauth"` + BaseUrl string `envconfig:"BASE_URL" default:"http://localhost:8080"` +} + +type ConfigKVStore interface { + Get(key string, encryptionKey string) (string, error) + SetIgnore(key string, value string, encryptionKey string) + SetUpdate(key string, value string, encryptionKey string) } diff --git a/models/http/http.go b/models/http/http.go new file mode 100644 index 00000000..0f58304c --- /dev/null +++ b/models/http/http.go @@ -0,0 +1,5 @@ +package http + +type ErrorResponse struct { + Message string `json:"message"` +} diff --git a/service.go b/service.go index 37e69d81..72850533 100644 --- a/service.go +++ b/service.go @@ -20,6 +20,8 @@ import ( "github.com/nbd-wtf/go-nostr/nip04" "github.com/sirupsen/logrus" + alby "github.com/getAlby/nostr-wallet-connect/alby" + "github.com/getAlby/nostr-wallet-connect/events" "github.com/glebarez/sqlite" "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" @@ -33,12 +35,14 @@ import ( type Service struct { // config from .env only. Fetch dynamic config from db - cfg *Config - db *gorm.DB - lnClient lnclient.LNClient - Logger *logrus.Logger - ctx context.Context - wg *sync.WaitGroup + cfg *Config + db *gorm.DB + lnClient lnclient.LNClient + Logger *logrus.Logger + AlbyOAuthSvc *alby.AlbyOAuthService + EventLogger events.EventLogger + ctx context.Context + wg *sync.WaitGroup } // TODO: move to service.go @@ -111,32 +115,65 @@ func NewService(ctx context.Context) (*Service, error) { err = migrations.Migrate(db, appConfig, logger) if err != nil { - logger.Errorf("Failed to migrate: %v", err) + logger.WithError(err).Error("Failed to migrate") return nil, err } cfg := &Config{} cfg.Init(db, appConfig, logger) + albyOAuthSvc := alby.NewAlbyOauthService(logger, cfg, cfg.Env) + if err != nil { + logger.WithError(err).Error("Failed to create Alby OAuth service") + return nil, err + } + + eventLogger := events.NewEventLogger(logger) + eventLogger.Subscribe(albyOAuthSvc) + var wg sync.WaitGroup svc := &Service{ - cfg: cfg, - db: db, - ctx: ctx, - wg: &wg, - Logger: logger, + cfg: cfg, + db: db, + ctx: ctx, + wg: &wg, + Logger: logger, + AlbyOAuthSvc: albyOAuthSvc, + EventLogger: eventLogger, } + eventLogger.Log(ctx, &events.Event{ + Event: "nwc_started", + }) + return svc, nil } -func (svc *Service) launchLNBackend(encryptionKey string) error { +func (svc *Service) StopLNClient() error { if svc.lnClient != nil { err := svc.lnClient.Shutdown() if err != nil { + svc.Logger.WithError(err).Error("Failed to stop LN backend") + svc.EventLogger.Log(svc.ctx, &events.Event{ + Event: "nwc_node_stop_failed", + Properties: map[string]interface{}{ + "error": fmt.Sprintf("%v", err), + }, + }) return err } svc.lnClient = nil + svc.EventLogger.Log(svc.ctx, &events.Event{ + Event: "nwc_node_stopped", + }) + } + return nil +} + +func (svc *Service) launchLNBackend(encryptionKey string) error { + err := svc.StopLNClient() + if err != nil { + return err } lndBackend, _ := svc.cfg.Get("LNBackendType", "") @@ -146,7 +183,6 @@ func (svc *Service) launchLNBackend(encryptionKey string) error { svc.Logger.Infof("Launching LN Backend: %s", lndBackend) var lnClient lnclient.LNClient - var err error switch lndBackend { case config.LNDBackendType: LNDAddress, _ := svc.cfg.Get("LNDAddress", encryptionKey) @@ -178,6 +214,13 @@ func (svc *Service) launchLNBackend(encryptionKey string) error { svc.Logger.Errorf("Failed to launch LN backend: %v", err) return err } + + svc.EventLogger.Log(svc.ctx, &events.Event{ + Event: "nwc_node_started", + Properties: map[string]interface{}{ + "backend": lndBackend, + }, + }) svc.lnClient = lnClient return nil } @@ -611,6 +654,14 @@ func (svc *Service) hasPermission(app *App, requestMethod string, amount int64) RequestMethod: requestMethod, }) if findPermissionResult.RowsAffected == 0 { + svc.EventLogger.Log(svc.ctx, &events.Event{ + Event: "nwc_permission_missing", + Properties: map[string]interface{}{ + "request_method": requestMethod, + "app_name": app.Name, + }, + }) + // No permission for this request method return false, NIP_47_ERROR_RESTRICTED, fmt.Sprintf("This app does not have permission to request %s", requestMethod) } @@ -622,6 +673,13 @@ func (svc *Service) hasPermission(app *App, requestMethod string, amount int64) "appId": app.ID, "pubkey": app.NostrPubkey, }).Info("This pubkey is expired") + svc.EventLogger.Log(svc.ctx, &events.Event{ + Event: "nwc_permission_expired", + Properties: map[string]interface{}{ + "request_method": requestMethod, + "app_name": app.Name, + }, + }) return false, NIP_47_ERROR_EXPIRED, "This app has expired" } @@ -631,6 +689,16 @@ func (svc *Service) hasPermission(app *App, requestMethod string, amount int64) budgetUsage := svc.GetBudgetUsage(&appPermission) if budgetUsage+amount/1000 > int64(maxAmount) { + svc.EventLogger.Log(svc.ctx, &events.Event{ + Event: "nwc_permission_exceeded", + Properties: map[string]interface{}{ + "request_method": requestMethod, + "app_name": app.Name, + "max_amount": maxAmount, + "budget_usage": budgetUsage, + "amount": amount / 1000, + }, + }) return false, NIP_47_ERROR_QUOTA_EXCEEDED, "Insufficient budget remaining to make payment" } } diff --git a/service_test.go b/service_test.go index 5425c5cc..ee60a161 100644 --- a/service_test.go +++ b/service_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/getAlby/nostr-wallet-connect/events" "github.com/getAlby/nostr-wallet-connect/migrations" "github.com/getAlby/nostr-wallet-connect/models/config" "github.com/getAlby/nostr-wallet-connect/models/lnclient" @@ -1203,9 +1204,10 @@ func createTestService(ln *MockLn) (svc *Service, err error) { NostrSecretKey: sk, NostrPublicKey: pk, }, - db: gormDb, - lnClient: ln, - Logger: logger, + db: gormDb, + lnClient: ln, + Logger: logger, + EventLogger: events.NewEventLogger(logger), }, nil } diff --git a/start.go b/start.go index 3b8fa4b2..a17da278 100644 --- a/start.go +++ b/start.go @@ -4,6 +4,7 @@ import ( "errors" "time" + "github.com/getAlby/nostr-wallet-connect/events" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" ) @@ -120,11 +121,10 @@ func (svc *Service) StartApp(encryptionKey string) error { } func (svc *Service) Shutdown() { - if svc.lnClient != nil { - svc.Logger.Info("Shutting down LN backend...") - err := svc.lnClient.Shutdown() - if err != nil { - svc.Logger.Error(err) - } - } + svc.StopLNClient() + svc.EventLogger.Log(svc.ctx, &events.Event{ + Event: "nwc_stopped", + }) + // wait for any remaining events + time.Sleep(1 * time.Second) } diff --git a/wails_app.go b/wails_app.go index 9734add0..ae8d01c4 100644 --- a/wails_app.go +++ b/wails_app.go @@ -23,7 +23,7 @@ type WailsApp struct { func NewApp(svc *Service) *WailsApp { return &WailsApp{ svc: svc, - api: NewAPI(svc), + api: NewAPI(svc, svc.AlbyOAuthSvc), } }