Skip to content
This repository has been archived by the owner on Sep 2, 2024. It is now read-only.

Feat: migrate funds from Alby account (WIP) #130

Merged
merged 27 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c6a5743
feat: migrate alby account funds (WIP)
rolznz Mar 18, 2024
68d24a4
fix: use alby auth URL from backend, fix access token renewal
rolznz Mar 18, 2024
3323919
Merge branch 'feat/0conf' into feat/migrate-funds
rolznz Mar 18, 2024
b17fb6b
feat: pay wrapped invoice using alby account
rolznz Mar 18, 2024
7bf8673
fix: refetch info before navigating
rolznz Mar 18, 2024
9655366
Merge branch 'feat/0conf' into feat/migrate-funds
rolznz Mar 18, 2024
2a3a5fe
chore: remove unnecessary unit check on alby balance
rolznz Mar 19, 2024
a835a3d
chore: move alby http methods out of http service
rolznz Mar 19, 2024
524128c
Merge remote-tracking branch 'origin/master' into feat/migrate-funds
rolznz Mar 20, 2024
78250e5
chore: remove unnecessary hasChannels code
rolznz Mar 20, 2024
edc3f8a
chore: remove node pubkey check
rolznz Mar 20, 2024
4e82921
chore: remove unnecessary package
rolznz Mar 20, 2024
a43bbad
chore: add TODO
rolznz Mar 20, 2024
feac4ab
chore: improve logging
rolznz Mar 20, 2024
668accc
Merge branch 'master' into feat/migrate-funds
rolznz Mar 24, 2024
51e13be
fix: use correct error response model
rolznz Mar 24, 2024
aa1c971
feat: log events WIP
rolznz Mar 24, 2024
e38a063
feat: add nwc_unlocked event (http mode only)
rolznz Mar 24, 2024
d9dd28f
feat: log extra events
rolznz Mar 25, 2024
ff3d541
fix: tests
rolznz Mar 25, 2024
ee4e129
fix: remove hardcoded OAuth redirects, update README
rolznz Mar 26, 2024
fbcb95b
fix: access token key
rolznz Mar 26, 2024
c658ab3
Merge pull request #153 from getAlby/feat/alby-events
rolznz Mar 26, 2024
b16ae8e
Merge remote-tracking branch 'origin/master' into feat/migrate-funds
rolznz Mar 26, 2024
7f9672d
chore: improve alby migration fee calculation
rolznz Mar 26, 2024
e13b19f
fix: fee reserve value in fees list
rolznz Mar 26, 2024
3b6dea9
feat: add loading state on open channel button in migrate funds page
rolznz Mar 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 283 additions & 0 deletions alby/alby.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
package alby

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"sync"
"time"

"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"`
}

func NewAlbyOauthService(logger *logrus.Logger, kvStore config.ConfigKVStore, appConfig *config.AppConfig) (result *AlbyOAuthService, err error) {
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.AlbyOAuthRedirectUrl,
}

albyOAuthSvc := &AlbyOAuthService{
appConfig: appConfig,
oauthConf: conf,
kvStore: kvStore,
logger: logger,
}
return albyOAuthSvc, err
}

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) {
// TODO: can these be encrypted?
svc.logger.WithField("token", token).Info("Got token") // FIXME: remove
svc.kvStore.SetUpdate("AccessTokenExpiry", strconv.FormatInt(token.Expiry.Unix(), 10), "")
svc.kvStore.SetUpdate("AccessToken", token.AccessToken, "")
svc.kvStore.SetUpdate("RefreshToken", 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("AccessToken", "")
if err != nil {
return nil, err
}
expiry, err := svc.kvStore.Get("AccessTokenExpiry", "")
if err != nil {
return nil, err
}
expiry64, err := strconv.ParseInt(expiry, 10, 64)
if err != nil {
return nil, err
}
refreshToken, err := svc.kvStore.Get("RefreshToken", "")
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.WithError(err).Error("Failed to decode 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 {
// FIXME: use env variable
redirectUri := "http://localhost:8080/api/alby/callback"
scopes := "account:read%20balance:read%20payments:send"
return fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s", svc.appConfig.AlbyOAuthAuthUrl, svc.appConfig.AlbyClientId, redirectUri, scopes)
}
92 changes: 92 additions & 0 deletions alby/alby_http_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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: redirect to the correct place
//return c.Redirect(302, "/")
// FIXME: redirects will not work for wails
return c.Redirect(302, "http://localhost:5173/#/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)
}
Loading
Loading