Skip to content

Commit

Permalink
feat: new first channel flow (#324)
Browse files Browse the repository at this point in the history
* feat: new first channel flow

* chore: hide public channel checkbox behind advanced options

* chore: always use new channel flow

* chore: add extra links to other ways to open channels

* fix: check if payment exists on order response

* chore: remove request to pay channel with alby shared node funds, add error handling

* feat: use auto channel flow for first channel

* chore: better other options on channel purchase pages

* chore: add user agent to alby api requests

* chore: remove unused lsp types

* chore: remove unused constant

* fix: hide sidebar hint on first channel flow

* fix: sync wallet on opening page

* fix: do not shown 0 confirmations on opening first channel page

* fix: make sure confetti only shows once

* fix: only show transferred alby funds toast once

* feat: add minimum threshold, add illustration

* fix: add constant

* fix: spacing, illustrations, copy

* fix: copy

* fix: copy

* fix: exchange light / dark images

* chore: update first channel opened page CTA to go to wallet

---------

Co-authored-by: René Aaron <rene@twentyuno.net>
  • Loading branch information
rolznz and reneaaron authored Jul 25, 2024
1 parent 60d1467 commit b86c39d
Show file tree
Hide file tree
Showing 25 changed files with 930 additions and 769 deletions.
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,9 @@ Follow the steps to integrate Mutinynet with your NWC Next setup:

2. Proceed as described in the [Development](https://github.com/getAlby/hub#Development) section to run the frontend and backend

3. During onboarding, after setting your password and authorizing via Alby OAuth, you'll be directed to `/onboarding/lightning/migrate-alby`. Click "Skip For Now" to access your wallet interface
3. Navigate to `channels/outgoing`, copy your On-Chain Address, then visit the [Mutinynet Faucet](https://faucet.mutinynet.com/) to deposit sats. Ensure the transaction confirms on [mempool.space](https://mutinynet.com/)

4. Navigate to `channels/onchain/deposit-bitcoin`, copy your On-Chain Address, then visit the [Mutinynet Faucet](https://faucet.mutinynet.com/) to deposit sats. Ensure the transaction confirms on [mempool.space](https://mutinynet.com/)

5. Your On-chain balance will update under `/channels`
4. Your On-chain balance will update under `/channels`

### Opening a channel from Mutinynet

Expand Down
285 changes: 272 additions & 13 deletions alby/alby_oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"

decodepay "github.com/nbd-wtf/ln-decodepay"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"gorm.io/gorm"
Expand All @@ -25,6 +29,8 @@ import (
"github.com/getAlby/hub/nip47/permissions"
"github.com/getAlby/hub/service/keys"
"github.com/getAlby/hub/transactions"
"github.com/getAlby/hub/utils"
"github.com/getAlby/hub/version"
)

type albyOAuthService struct {
Expand Down Expand Up @@ -209,7 +215,7 @@ func (svc *albyOAuthService) GetMe(ctx context.Context) (*AlbyMe, error) {
return nil, err
}

req.Header.Set("User-Agent", "NWC-next")
setDefaultRequestHeaders(req)

res, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -244,7 +250,7 @@ func (svc *albyOAuthService) GetBalance(ctx context.Context) (*AlbyBalance, erro
return nil, err
}

req.Header.Set("User-Agent", "NWC-next")
setDefaultRequestHeaders(req)

res, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -328,8 +334,7 @@ func (svc *albyOAuthService) SendPayment(ctx context.Context, invoice string) er
return err
}

req.Header.Set("User-Agent", "NWC-next")
req.Header.Set("Content-Type", "application/json")
setDefaultRequestHeaders(req)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -561,8 +566,7 @@ func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Eve
return
}

req.Header.Set("User-Agent", "NWC-next")
req.Header.Set("Content-Type", "application/json")
setDefaultRequestHeaders(req)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -630,8 +634,7 @@ func (svc *albyOAuthService) backupChannels(ctx context.Context, event *events.E
return fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("User-Agent", "NWC-next")
req.Header.Set("Content-Type", "application/json")
setDefaultRequestHeaders(req)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -675,8 +678,7 @@ func (svc *albyOAuthService) createAlbyAccountNWCNode(ctx context.Context) (stri
return "", err
}

req.Header.Set("User-Agent", "NWC-next")
req.Header.Set("Content-Type", "application/json")
setDefaultRequestHeaders(req)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -726,8 +728,7 @@ func (svc *albyOAuthService) activateAlbyAccountNWCNode(ctx context.Context) err
return err
}

req.Header.Set("User-Agent", "NWC-next")
req.Header.Set("Content-Type", "application/json")
setDefaultRequestHeaders(req)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -763,7 +764,7 @@ func (svc *albyOAuthService) GetChannelPeerSuggestions(ctx context.Context) ([]C
return nil, err
}

req.Header.Set("User-Agent", "NWC-next")
setDefaultRequestHeaders(req)

res, err := client.Do(req)
if err != nil {
Expand All @@ -790,3 +791,261 @@ func (svc *albyOAuthService) GetChannelPeerSuggestions(ctx context.Context) ([]C
logger.Logger.WithFields(logrus.Fields{"channel_suggestions": suggestions}).Info("Alby channel peer suggestions response")
return suggestions, nil
}

func (svc *albyOAuthService) RequestAutoChannel(ctx context.Context, lnClient lnclient.LNClient, isPublic bool) (*AutoChannelResponse, error) {
nodeInfo, err := lnClient.GetInfo(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to request own node info", err)
return nil, err
}

requestUrl := fmt.Sprintf("https://api.getalby.com/internal/lsp/alby/%s", nodeInfo.Network)

pubkey, address, port, err := svc.getLSPInfo(ctx, requestUrl+"/v1/get_info")

if err != nil {
logger.Logger.WithError(err).Error("Failed to request LSP info")
return nil, err
}

err = lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
Pubkey: pubkey,
Address: address,
Port: port,
})

if err != nil {
logger.Logger.WithFields(logrus.Fields{
"pubkey": pubkey,
"address": address,
"port": port,
}).WithError(err).Error("Failed to connect to peer")
return nil, err
}

logger.Logger.WithFields(logrus.Fields{
"pubkey": pubkey,
"public": isPublic,
}).Info("Requesting auto channel")

autoChannelResponse, err := svc.requestAutoChannel(ctx, requestUrl+"/auto_channel", nodeInfo.Pubkey, isPublic)
if err != nil {
logger.Logger.WithError(err).Error("Failed to request auto channel")
return nil, err
}
return autoChannelResponse, nil
}

func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string, pubkey string, isPublic bool) (*AutoChannelResponse, error) {
token, err := svc.fetchUserToken(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to fetch user token")
}

client := svc.oauthConf.Client(ctx, token)
client.Timeout = 60 * time.Second

type autoChannelRequest struct {
NodePubkey string `json:"node_pubkey"`
AnnounceChannel bool `json:"announce_channel"`
}

newAutoChannelRequest := autoChannelRequest{
NodePubkey: pubkey,
AnnounceChannel: isPublic,
}

payloadBytes, err := json.Marshal(newAutoChannelRequest)
if err != nil {
return nil, err
}
bodyReader := bytes.NewReader(payloadBytes)

req, err := http.NewRequest(http.MethodPost, url, bodyReader)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to create auto channel request")
return nil, err
}

setDefaultRequestHeaders(req)

res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to request auto channel invoice")
return nil, err
}

defer res.Body.Close()

body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to read response body")
return nil, errors.New("failed to read response body")
}

if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"newLSPS1ChannelRequest": newAutoChannelRequest,
"body": string(body),
"statusCode": res.StatusCode,
}).Error("auto channel endpoint returned non-success code")
return nil, fmt.Errorf("auto channel endpoint returned non-success code: %s", string(body))
}

type newLSPS1ChannelPaymentBolt11 struct {
Invoice string `json:"invoice"`
FeeTotalSat string `json:"fee_total_sat"`
}

type newLSPS1ChannelPayment struct {
Bolt11 newLSPS1ChannelPaymentBolt11 `json:"bolt11"`
// TODO: add onchain
}
type autoChannelResponse struct {
LspBalanceSat string `json:"lsp_balance_sat"`
Payment *newLSPS1ChannelPayment `json:"payment"`
}

var newAutoChannelResponse autoChannelResponse

err = json.Unmarshal(body, &newAutoChannelResponse)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to deserialize json")
return nil, fmt.Errorf("failed to deserialize json %s %s", url, string(body))
}

var invoice string
var fee uint64

if newAutoChannelResponse.Payment != nil {
invoice = newAutoChannelResponse.Payment.Bolt11.Invoice
fee, err = strconv.ParseUint(newAutoChannelResponse.Payment.Bolt11.FeeTotalSat, 10, 64)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to parse fee")
return nil, fmt.Errorf("failed to parse fee %v", err)
}

paymentRequest, err := decodepay.Decodepay(invoice)
if err != nil {
logger.Logger.WithError(err).Error("Failed to decode bolt11 invoice")
return nil, err
}

if fee != uint64(paymentRequest.MSatoshi/1000) {
logger.Logger.WithFields(logrus.Fields{
"invoice_amount": paymentRequest.MSatoshi / 1000,
"fee": fee,
}).WithError(err).Error("Invoice amount does not match LSP fee")
return nil, errors.New("invoice amount does not match LSP fee")
}
}

channelSize, err := strconv.ParseUint(newAutoChannelResponse.LspBalanceSat, 10, 64)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to parse lsp balance sat")
return nil, fmt.Errorf("failed to parse lsp balance sat %v", err)
}

return &AutoChannelResponse{
Invoice: invoice,
Fee: fee,
ChannelSize: channelSize,
}, nil
}

func (svc *albyOAuthService) getLSPInfo(ctx context.Context, url string) (pubkey string, address string, port uint16, err error) {

token, err := svc.fetchUserToken(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to fetch user token")
}

client := svc.oauthConf.Client(ctx, token)
client.Timeout = 60 * time.Second

type lsps1LSPInfo struct {
URIs []string `json:"uris"`
}
var lsps1LspInfo lsps1LSPInfo

req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to create lsp info request")
return "", "", uint16(0), err
}

setDefaultRequestHeaders(req)

res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to request lsp info")
return "", "", uint16(0), err
}

defer res.Body.Close()

body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to read response body")
return "", "", uint16(0), errors.New("failed to read response body")
}

err = json.Unmarshal(body, &lsps1LspInfo)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to deserialize json")
return "", "", uint16(0), fmt.Errorf("failed to deserialize json %s %s", url, string(body))
}

httpUris := utils.Filter(lsps1LspInfo.URIs, func(uri string) bool {
return !strings.Contains(uri, ".onion")
})
if len(httpUris) == 0 {
logger.Logger.WithField("uris", lsps1LspInfo.URIs).WithError(err).Error("Couldn't find HTTP URI")

return "", "", uint16(0), err
}
uri := httpUris[0]

// make sure it's a valid IPv4 URI
regex := regexp.MustCompile(`^([0-9a-f]+)@([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+):([0-9]+)$`)
parts := regex.FindStringSubmatch(uri)
logger.Logger.WithField("parts", parts).Info("Split URI")
if parts == nil || len(parts) != 4 {
logger.Logger.WithField("parts", parts).Error("Unsupported URI")
return "", "", uint16(0), errors.New("could not decode LSP URI")
}

portValue, err := strconv.Atoi(parts[3])
if err != nil {
logger.Logger.WithField("port", parts[3]).WithError(err).Error("Failed to decode port number")

return "", "", uint16(0), err
}

return parts[1], parts[2], uint16(portValue), nil
}

func setDefaultRequestHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "AlbyHub/"+version.Tag)
}
Loading

0 comments on commit b86c39d

Please sign in to comment.