feat: new first channel flow (#324)

* 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>
This commit is contained in:
Roland 2024-07-25 20:57:16 +07:00 committed by GitHub
parent 60d1467f82
commit b86c39d542
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 931 additions and 770 deletions

View file

@ -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

View file

@ -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"
@ -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 {
@ -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 {
@ -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 {
@ -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 {
@ -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 {
@ -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 {
@ -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 {
@ -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 {
@ -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 {
@ -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)
}

View file

@ -19,6 +19,7 @@ type AlbyOAuthService interface {
GetMe(ctx context.Context) (*AlbyMe, error)
SendPayment(ctx context.Context, invoice string) error
DrainSharedWallet(ctx context.Context, lnClient lnclient.LNClient) error
RequestAutoChannel(ctx context.Context, lnClient lnclient.LNClient, isPublic bool) (*AutoChannelResponse, error)
}
type AlbyBalanceResponse struct {
@ -34,6 +35,16 @@ type AlbyLinkAccountRequest struct {
Renewal string `json:"renewal"`
}
type AutoChannelRequest struct {
IsPublic bool `json:"isPublic"`
}
type AutoChannelResponse struct {
Invoice string `json:"invoice"`
ChannelSize uint64 `json:"channelSize"`
Fee uint64 `json:"fee"`
}
type AlbyMeHub struct {
LatestVersion string `json:"latest_version"`
}

View file

@ -28,32 +28,19 @@ type lspInfo struct {
MaxChannelExpiryBlocks uint64
}
func (api *api) NewInstantChannelInvoice(ctx context.Context, request *NewInstantChannelInvoiceRequest) (*NewInstantChannelInvoiceResponse, error) {
func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (*LSPOrderResponse, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
if request.LSPType != lsp.LSP_TYPE_LSPS1 && request.Public {
return nil, errors.New("This LSP option does not support public channels")
if request.LSPType != lsp.LSP_TYPE_LSPS1 {
return nil, fmt.Errorf("unsupported LSP type: %v", request.LSPType)
}
logger.Logger.Infoln("Requesting LSP info")
lspInfo, err := api.getLSPS1LSPInfo(request.LSPUrl + "/get_info")
var lspInfo *lspInfo
var err error
switch request.LSPType {
case lsp.LSP_TYPE_FLOW_2_0:
fallthrough
case lsp.LSP_TYPE_PMLSP:
lspInfo, err = api.getFlowLSPInfo(request.LSPUrl + "/info")
case lsp.LSP_TYPE_LSPS1:
lspInfo, err = api.getLSPS1LSPInfo(request.LSPUrl + "/get_info")
default:
return nil, fmt.Errorf("unsupported LSP type: %v", request.LSPType)
}
if err != nil {
logger.Logger.WithError(err).Error("Failed to request LSP info")
return nil, err
@ -82,17 +69,8 @@ func (api *api) NewInstantChannelInvoice(ctx context.Context, request *NewInstan
return nil, err
}
invoice := ""
var fee uint64 = 0
invoice, fee, err := api.requestLSPS1Invoice(ctx, request, nodeInfo.Pubkey, lspInfo.MaxChannelExpiryBlocks)
switch request.LSPType {
case lsp.LSP_TYPE_FLOW_2_0:
invoice, fee, err = api.requestFlow20WrappedInvoice(ctx, request, nodeInfo.Pubkey)
case lsp.LSP_TYPE_PMLSP:
invoice, fee, err = api.requestPMLSPInvoice(request, nodeInfo.Pubkey)
case lsp.LSP_TYPE_LSPS1:
invoice, fee, err = api.requestLSPS1Invoice(ctx, request, nodeInfo.Pubkey, lspInfo.MaxChannelExpiryBlocks)
}
if err != nil {
logger.Logger.WithError(err).Error("Failed to request invoice")
return nil, err
@ -116,7 +94,7 @@ func (api *api) NewInstantChannelInvoice(ctx context.Context, request *NewInstan
outgoingLiquidity = invoiceAmount - fee
}
newChannelResponse := &NewInstantChannelInvoiceResponse{
newChannelResponse := &LSPOrderResponse{
Invoice: invoice,
Fee: fee,
InvoiceAmount: invoiceAmount,
@ -208,327 +186,13 @@ func (api *api) getLSPS1LSPInfo(url string) (*lspInfo, error) {
MaxChannelExpiryBlocks: lsps1LspInfo.MaxChannelExpiryBlocks,
}, nil
}
func (api *api) getFlowLSPInfo(url string) (*lspInfo, error) {
type FlowLSPConnectionMethod struct {
Address string `json:"address"`
Port uint16 `json:"port"`
Type string `json:"type"`
}
type FlowLSPInfo struct {
Pubkey string `json:"pubkey"`
ConnectionMethods []FlowLSPConnectionMethod `json:"connection_methods"`
}
var flowLspInfo FlowLSPInfo
func (api *api) requestLSPS1Invoice(ctx context.Context, request *LSPOrderRequest, pubkey string, channelExpiryBlocks uint64) (invoice string, fee uint64, err error) {
client := http.Client{
Timeout: time.Second * 10,
}
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 nil, err
Timeout: time.Second * 60,
}
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to request lsp info")
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")
}
err = json.Unmarshal(body, &flowLspInfo)
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))
}
ipIndex := -1
for i, cm := range flowLspInfo.ConnectionMethods {
if strings.HasPrefix(cm.Type, "ip") {
ipIndex = i
break
}
}
if ipIndex == -1 {
logger.Logger.Error("No ipv4/ipv6 connection method found in LSP info")
return nil, errors.New("unexpected LSP connection method")
}
return &lspInfo{
Pubkey: flowLspInfo.Pubkey,
Address: flowLspInfo.ConnectionMethods[ipIndex].Address,
Port: flowLspInfo.ConnectionMethods[ipIndex].Port,
}, nil
}
func (api *api) requestFlow20WrappedInvoice(ctx context.Context, request *NewInstantChannelInvoiceRequest, pubkey string) (invoice string, fee uint64, err error) {
logger.Logger.Infoln("Requesting fee information")
type FeeRequest struct {
AmountMsat uint64 `json:"amount_msat"`
Pubkey string `json:"pubkey"`
}
type FeeResponse struct {
FeeAmountMsat uint64 `json:"fee_amount_msat"`
Id string `json:"id"`
}
var feeResponse FeeResponse
{
client := http.Client{
Timeout: time.Second * 10,
}
payloadBytes, err := json.Marshal(FeeRequest{
AmountMsat: request.Amount * 1000,
Pubkey: pubkey,
})
if err != nil {
return "", 0, err
}
bodyReader := bytes.NewReader(payloadBytes)
req, err := http.NewRequest(http.MethodPost, request.LSPUrl+"/fee", bodyReader)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to create lsp fee request")
return "", 0, err
}
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to request lsp fee")
return "", 0, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to read response body")
return "", 0, errors.New("failed to read response body")
}
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"statusCode": res.StatusCode,
}).Error("fee endpoint returned non-success code")
return "", 0, fmt.Errorf("fee endpoint returned non-success code: %s", string(body))
}
err = json.Unmarshal(body, &feeResponse)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to deserialize json")
return "", 0, fmt.Errorf("failed to deserialize json %s %s", request.LSPUrl, string(body))
}
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
"feeResponse": feeResponse,
}).Info("Got fee response")
if feeResponse.Id == "" {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"feeResponse": feeResponse,
}).Error("No fee id in fee response")
return "", 0, fmt.Errorf("no fee id in fee response %v", feeResponse)
}
fee = feeResponse.FeeAmountMsat / 1000
}
// because we don't want the sender to pay the fee
// see: https://docs.voltage.cloud/voltage-lsp#gqBqV
makeInvoiceResponse, err := api.svc.GetLNClient().MakeInvoice(ctx, int64(request.Amount)*1000-int64(feeResponse.FeeAmountMsat), "", "", 60*60)
if err != nil {
logger.Logger.WithError(err).Error("Failed to request own invoice")
return "", 0, fmt.Errorf("failed to request own invoice %v", err)
}
type ProposalRequest struct {
Bolt11 string `json:"bolt11"`
FeeId string `json:"fee_id"`
}
type ProposalResponse struct {
Bolt11 string `json:"jit_bolt11"`
}
logger.Logger.Infoln("Proposing invoice")
var proposalResponse ProposalResponse
{
client := http.Client{
Timeout: time.Second * 10,
}
payloadBytes, err := json.Marshal(ProposalRequest{
Bolt11: makeInvoiceResponse.Invoice,
FeeId: feeResponse.Id,
})
if err != nil {
return "", 0, err
}
bodyReader := bytes.NewReader(payloadBytes)
req, err := http.NewRequest(http.MethodPost, request.LSPUrl+"/proposal", bodyReader)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to create lsp fee request")
return "", 0, err
}
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to request lsp fee")
return "", 0, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to read response body")
return "", 0, errors.New("failed to read response body")
}
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"statusCode": res.StatusCode,
}).Error("proposal endpoint returned non-success code")
return "", 0, fmt.Errorf("proposal endpoint returned non-success code: %s", string(body))
}
err = json.Unmarshal(body, &proposalResponse)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to deserialize json")
return "", 0, fmt.Errorf("failed to deserialize json %s %s", request.LSPUrl, string(body))
}
logger.Logger.WithField("proposalResponse", proposalResponse).Info("Got proposal response")
if proposalResponse.Bolt11 == "" {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
"proposalResponse": proposalResponse,
}).Error("No bolt11 in proposal response")
return "", 0, fmt.Errorf("no bolt11 in proposal response %v", proposalResponse)
}
}
invoice = proposalResponse.Bolt11
return invoice, fee, nil
}
func (api *api) requestPMLSPInvoice(request *NewInstantChannelInvoiceRequest, pubkey string) (invoice string, fee uint64, err error) {
type NewInstantChannelRequest struct {
Amount uint64 `json:"amount"`
Pubkey string `json:"pubkey"`
}
client := http.Client{
Timeout: time.Second * 10,
}
payloadBytes, err := json.Marshal(NewInstantChannelRequest{
Amount: request.Amount,
Pubkey: pubkey,
})
if err != nil {
return "", 0, err
}
bodyReader := bytes.NewReader(payloadBytes)
req, err := http.NewRequest(http.MethodPost, request.LSPUrl+"/new-channel", bodyReader)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to create new channel request")
return "", 0, err
}
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to request new channel invoice")
return "", 0, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to read response body")
return "", 0, errors.New("failed to read response body")
}
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"statusCode": res.StatusCode,
}).Error("new-channel endpoint returned non-success code")
return "", 0, fmt.Errorf("new-channel endpoint returned non-success code: %s", string(body))
}
type newInstantChannelResponse struct {
FeeAmountMsat uint64 `json:"fee_amount_msat"`
Invoice string `json:"invoice"`
}
var newChannelResponse newInstantChannelResponse
err = json.Unmarshal(body, &newChannelResponse)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": request.LSPUrl,
}).Error("Failed to deserialize json")
return "", 0, fmt.Errorf("failed to deserialize json %s %s", request.LSPUrl, string(body))
}
invoice = newChannelResponse.Invoice
fee = newChannelResponse.FeeAmountMsat / 1000
return invoice, fee, nil
}
func (api *api) requestLSPS1Invoice(ctx context.Context, request *NewInstantChannelInvoiceRequest, pubkey string, channelExpiryBlocks uint64) (invoice string, fee uint64, err error) {
client := http.Client{
Timeout: time.Second * 10,
}
type NewLSPS1ChannelRequest struct {
type lsps1ChannelRequest struct {
PublicKey string `json:"public_key"`
LSPBalanceSat string `json:"lsp_balance_sat"`
ClientBalanceSat string `json:"client_balance_sat"`
@ -554,7 +218,7 @@ func (api *api) requestLSPS1Invoice(ctx context.Context, request *NewInstantChan
requiredChannelConfirmations = 6
}
newLSPS1ChannelRequest := NewLSPS1ChannelRequest{
newLSPS1ChannelRequest := lsps1ChannelRequest{
PublicKey: pubkey,
LSPBalanceSat: strconv.FormatUint(request.Amount, 10),
ClientBalanceSat: "0",
@ -619,7 +283,7 @@ func (api *api) requestLSPS1Invoice(ctx context.Context, request *NewInstantChan
// TODO: add onchain
}
type newLSPS1ChannelResponse struct {
Payment newLSPS1ChannelPayment `json:"payment"`
Payment *newLSPS1ChannelPayment `json:"payment"`
}
var newChannelResponse newLSPS1ChannelResponse

View file

@ -49,7 +49,7 @@ type API interface {
GetNetworkGraph(nodeIds []string) (NetworkGraphResponse, error)
SyncWallet() error
GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error)
NewInstantChannelInvoice(ctx context.Context, request *NewInstantChannelInvoiceRequest) (*NewInstantChannelInvoiceResponse, error)
RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (*LSPOrderResponse, error)
CreateBackup(unlockPassword string, w io.Writer) error
RestoreBackup(unlockPassword string, r io.Reader) error
GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error)
@ -265,14 +265,14 @@ type BasicRestoreWailsRequest struct {
type NetworkGraphResponse = lnclient.NetworkGraphResponse
type NewInstantChannelInvoiceRequest struct {
type LSPOrderRequest struct {
Amount uint64 `json:"amount"`
LSPType string `json:"lspType"`
LSPUrl string `json:"lspUrl"`
Public bool `json:"public"`
}
type NewInstantChannelInvoiceResponse struct {
type LSPOrderResponse struct {
Invoice string `json:"invoice"`
Fee uint64 `json:"fee"`
InvoiceAmount uint64 `json:"invoiceAmount"`

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 66 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -39,6 +39,7 @@ function SidebarHint() {
if (
!location.pathname.startsWith("/home") &&
!location.pathname.startsWith("/channels/order") &&
!location.pathname.startsWith("/channels/first") &&
!isLoading &&
openChecklistItems.length
) {

View file

@ -3,11 +3,6 @@ export const localStorageKeys = {
channelOrder: "channelOrder",
};
const MIN_0CONF_BALANCE = 200000; // 100,000 for Alby. 20000 works for Olympus and Voltage
export const ALBY_SERVICE_FEE = 8 / 1000;
export const ALBY_MIN_BALANCE = Math.ceil(
MIN_0CONF_BALANCE * (1 + ALBY_SERVICE_FEE)
);
export const ONCHAIN_DUST_SATS = 1000;
export const ALBY_HIDE_HOSTED_BALANCE_BELOW = 100;
export const ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL = 30_000;

View file

@ -1,6 +1,5 @@
// src/hooks/useOnboardingData.ts
import { ALBY_MIN_BALANCE, ALBY_SERVICE_FEE } from "src/constants";
import { useAlbyBalance } from "src/hooks/useAlbyBalance";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useApps } from "src/hooks/useApps";
@ -59,21 +58,13 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
apps && apps.find((x) => x.name !== "getalby.com") !== undefined;
const hasTransaction = transactions.length > 0;
const canMigrateAlbyFundsToNewChannel =
hasChannelManagement &&
info.backendType === "LDK" &&
albyBalance.sats * (1 - ALBY_SERVICE_FEE) >
ALBY_MIN_BALANCE + 50000; /* accommodate for onchain fees */
const checklistItems: Omit<ChecklistItem, "disabled">[] = [
{
title: "1. Open your first channel",
description:
"Establish a new Lightning channel to enable fast and low-fee Bitcoin transactions.",
checked: hasChannel,
to: canMigrateAlbyFundsToNewChannel
? "/onboarding/lightning/migrate-alby"
: "/channels/outgoing",
to: "/channels/first",
},
{
title: "2. Link to your Alby Account",

View file

@ -26,7 +26,9 @@ import Channels from "src/screens/channels/Channels";
import { CurrentChannelOrder } from "src/screens/channels/CurrentChannelOrder";
import IncreaseIncomingCapacity from "src/screens/channels/IncreaseIncomingCapacity";
import IncreaseOutgoingCapacity from "src/screens/channels/IncreaseOutgoingCapacity";
import MigrateAlbyFunds from "src/screens/onboarding/MigrateAlbyFunds";
import { FirstChannel } from "src/screens/channels/first/FirstChannel";
import { OpenedFirstChannel } from "src/screens/channels/first/OpenedFirstChannel";
import { OpeningFirstChannel } from "src/screens/channels/first/OpeningFirstChannel";
import { Success } from "src/screens/onboarding/Success";
import BuyBitcoin from "src/screens/onchain/BuyBitcoin";
import DepositBitcoin from "src/screens/onchain/DepositBitcoin";
@ -176,6 +178,21 @@ const routes = [
index: true,
element: <Channels />,
},
{
path: "first",
element: <FirstChannel />,
handle: { crumb: () => "Open Your First Channel" },
},
{
path: "first/opening",
element: <OpeningFirstChannel />,
handle: { crumb: () => "Opening Your First Channel" },
},
{
path: "first/opened",
element: <OpenedFirstChannel />,
handle: { crumb: () => "First Channel Opened!" },
},
{
path: "outgoing",
element: <IncreaseOutgoingCapacity />,
@ -325,10 +342,6 @@ const routes = [
path: "onboarding",
element: <OnboardingRedirect />,
children: [
{
path: "lightning/migrate-alby",
element: <MigrateAlbyFunds />,
},
{
path: "success",
element: <Success />,

View file

@ -1,6 +1,5 @@
import React from "react";
import {
Channel,
ConnectPeerRequest,
NewChannelOrder,
Node,
@ -36,6 +35,7 @@ import {
import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label";
import { LoadingButton } from "src/components/ui/loading-button";
import { Separator } from "src/components/ui/separator";
import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table";
import {
Tooltip,
@ -55,10 +55,7 @@ import { copyToClipboard } from "src/lib/clipboard";
import { splitSocketAddress } from "src/lib/utils";
import { Success } from "src/screens/onboarding/Success";
import useChannelOrderStore from "src/state/ChannelOrderStore";
import {
NewInstantChannelInvoiceRequest,
NewInstantChannelInvoiceResponse,
} from "src/types";
import { LSPOrderRequest, LSPOrderResponse } from "src/types";
import { request } from "src/utils/request";
init({
showBalance: false,
@ -96,6 +93,9 @@ function ChannelOrderInternal({ order }: { order: NewChannelOrder }) {
break;
}
break;
case "paid":
// LSPS1 only
return <PaidLightningChannelOrder />;
case "opening":
return <ChannelOpening fundingTxId={order.fundingTxId} />;
case "success":
@ -525,29 +525,18 @@ function PayBitcoinChannelOrderWithSpendableFunds({
);
}
function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
if (order.paymentMethod !== "lightning") {
throw new Error("incorrect payment method");
}
const { data: csrf } = useCSRF();
const { toast } = useToast();
function useWaitForNewChannel() {
const order = useChannelOrderStore((store) => store.order);
const { data: channels } = useChannels(true);
const [, setRequestedInvoice] = React.useState(false);
const [prevChannels, setPrevChannels] = React.useState<
Channel[] | undefined
>();
const [wrappedInvoiceResponse, setWrappedInvoiceResponse] = React.useState<
NewInstantChannelInvoiceResponse | undefined
>();
const { toast } = useToast();
// This is not a good check if user already has enough inbound liquidity
// - check balance instead or how else to check the invoice is paid?
const newChannel =
channels && prevChannels
channels && order?.prevChannelIds
? channels.find(
(newChannel) =>
!prevChannels.some((current) => current.id === newChannel.id) &&
newChannel.fundingTxId
!order.prevChannelIds.some(
(current) => newChannel.id === current
) && newChannel.fundingTxId
)
: undefined;
@ -564,9 +553,34 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
})();
}
}, [newChannel, toast]);
}
function PaidLightningChannelOrder() {
useWaitForNewChannel();
return (
<div className="flex w-full h-full gap-2 items-center justify-center">
<Loading /> <p>Waiting for channel to be opened...</p>
</div>
);
}
function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
if (order.paymentMethod !== "lightning") {
throw new Error("incorrect payment method");
}
const { data: csrf } = useCSRF();
const { toast } = useToast();
const { data: channels } = useChannels(true);
const [, setRequestedInvoice] = React.useState(false);
const [wrappedInvoiceResponse, setWrappedInvoiceResponse] = React.useState<
LSPOrderResponse | undefined
>();
useWaitForNewChannel();
React.useEffect(() => {
// TODO: move fetching to NewChannel page otherwise fee cannot be retrieved
if (!channels || !csrf) {
return;
}
@ -574,26 +588,24 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
if (!current) {
(async () => {
try {
setPrevChannels(channels);
if (!order.lspType || !order.lspUrl) {
throw new Error("missing lsp info in order");
}
const newInstantChannelInvoiceRequest: NewInstantChannelInvoiceRequest =
{
lspType: order.lspType,
lspUrl: order.lspUrl,
amount: parseInt(order.amount),
public: order.isPublic,
};
const response = await request<NewInstantChannelInvoiceResponse>(
"/api/instant-channel-invoices",
const newLSPOrderRequest: LSPOrderRequest = {
lspType: order.lspType,
lspUrl: order.lspUrl,
amount: parseInt(order.amount),
public: order.isPublic,
};
const response = await request<LSPOrderResponse>(
"/api/lsp-orders",
{
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json",
},
body: JSON.stringify(newInstantChannelInvoiceRequest),
body: JSON.stringify(newLSPOrderRequest),
}
);
if (!response?.invoice) {
@ -625,7 +637,6 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
wrappedInvoiceResponse.invoiceAmount
);
const [isPaying, setPaying] = React.useState(false);
const [paid, setPaid] = React.useState(false);
const [payExternally, setPayExternally] = React.useState(false);
return (
@ -697,77 +708,88 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
</TableBody>
</Table>
</div>
{paid ? (
<div className="flex gap-2 items-center justify-center">
<Loading /> <p>Waiting for channel to be opened...</p>
</div>
) : (
<>
{canPayInternally && (
<>
<LoadingButton
loading={isPaying}
className="mt-4"
onClick={async () => {
try {
if (!csrf) {
throw new Error("csrf not loaded");
}
setPaying(true);
const payInvoiceResponse =
await request<PayInvoiceResponse>(
`/api/payments/${wrappedInvoiceResponse.invoice}`,
{
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json",
},
}
);
if (payInvoiceResponse) {
setPaid(true);
toast({
title: "Channel successfully requested",
});
}
setPaid(true);
} catch (e) {
toast({
variant: "destructive",
title: "Failed to send: " + e,
});
console.error(e);
<>
{canPayInternally && (
<>
<LoadingButton
loading={isPaying}
className="mt-4"
onClick={async () => {
try {
if (!csrf) {
throw new Error("csrf not loaded");
}
setPaying(false);
}}
>
Pay and open channel
</LoadingButton>
{!payExternally && (
<Button
type="button"
variant="link"
className="text-muted-foreground text-xs"
onClick={() => setPayExternally(true)}
>
Pay with another wallet
</Button>
)}
</>
)}
setPaying(true);
{(payExternally || !canPayInternally) && (
<Payment
invoice={wrappedInvoiceResponse.invoice}
payment={
newChannel ? { preimage: "dummy preimage" } : undefined
}
paymentMethods="external"
/>
)}
</>
)}
await request<PayInvoiceResponse>(
`/api/payments/${wrappedInvoiceResponse.invoice}`,
{
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json",
},
}
);
useChannelOrderStore.getState().updateOrder({
status: "paid",
});
toast({
title: "Channel successfully requested",
});
} catch (e) {
toast({
variant: "destructive",
title: "Failed to send: " + e,
});
console.error(e);
}
setPaying(false);
}}
>
Pay and open channel
</LoadingButton>
{!payExternally && (
<Button
type="button"
variant="link"
className="text-muted-foreground text-xs"
onClick={() => setPayExternally(true)}
>
Pay with another wallet
</Button>
)}
</>
)}
{(payExternally || !canPayInternally) && (
<Payment
invoice={wrappedInvoiceResponse.invoice}
paymentMethods="external"
/>
)}
<div className="flex-1 flex flex-col justify-end items-center gap-4">
<Separator className="my-16" />
<p className="text-sm text-muted-foreground text-center">
Other options
</p>
<Link to="/channels/outgoing" className="w-full">
<Button className="w-full" variant="secondary">
Increase spending balance
</Button>
</Link>
<ExternalLink
to="https://www.getalby.com/topup"
className="w-full"
>
<Button className="w-full" variant="secondary">
Buy Bitcoin
</Button>
</ExternalLink>
</div>
</>
</div>
</>
)}

View file

@ -1,6 +1,6 @@
import { ChevronDown } from "lucide-react";
import React, { FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import AppHeader from "src/components/AppHeader";
import ExternalLink from "src/components/ExternalLink";
import Loading from "src/components/Loading";
@ -21,7 +21,12 @@ import { useChannels } from "src/hooks/useChannels";
import { useInfo } from "src/hooks/useInfo";
import { cn, formatAmount } from "src/lib/utils";
import useChannelOrderStore from "src/state/ChannelOrderStore";
import { Network, NewChannelOrder, RecommendedChannelPeer } from "src/types";
import {
Channel,
Network,
NewChannelOrder,
RecommendedChannelPeer,
} from "src/types";
function getPeerKey(peer: RecommendedChannelPeer) {
return JSON.stringify(peer);
@ -29,18 +34,25 @@ function getPeerKey(peer: RecommendedChannelPeer) {
export default function IncreaseIncomingCapacity() {
const { data: info } = useInfo();
const { data: channels } = useChannels();
if (!info?.network) {
if (!info?.network || !channels) {
return <Loading />;
}
return <NewChannelInternal network={info.network} />;
return <NewChannelInternal network={info.network} channels={channels} />;
}
function NewChannelInternal({ network }: { network: Network }) {
function NewChannelInternal({
network,
channels,
}: {
network: Network;
channels: Channel[];
}) {
const { data: _channelPeerSuggestions } = useChannelPeerSuggestions();
const navigate = useNavigate();
const { data: channels } = useChannels();
const { toast } = useToast();
const presetAmounts = [1_000_000, 2_000_000, 3_000_000];
@ -49,6 +61,7 @@ function NewChannelInternal({ network }: { network: Network }) {
paymentMethod: "lightning",
status: "pay",
amount: presetAmounts[0].toString(),
prevChannelIds: channels.map((channel) => channel.id),
});
const [showAdvanced, setShowAdvanced] = React.useState(false);
@ -175,10 +188,19 @@ function NewChannelInternal({ network }: { network: Network }) {
<AppHeader
title="Increase Receiving Capacity"
description="Purchase a channel with incoming capacity to receive payments"
contentRight={
<div className="flex items-end">
<Link to="/channels/outgoing">
<Button className="w-full" variant="secondary">
Need spending capacity?
</Button>
</Link>
</div>
}
/>
<form
onSubmit={onSubmit}
className="md:max-w-md max-w-full flex flex-col gap-5"
className="md:max-w-md max-w-full flex flex-col gap-5 flex-1"
>
<div className="grid gap-1.5">
<Label htmlFor="amount">Channel size (sats)</Label>
@ -312,7 +334,7 @@ function NewChannelInternal({ network }: { network: Network }) {
Public Channel
</Label>
<p className="text-xs text-muted-foreground">
Enable if you want to receive keysend payments. (e.g.
Only enable if you want to receive keysend payments. (e.g.
podcasting)
</p>
</div>
@ -331,6 +353,21 @@ function NewChannelInternal({ network }: { network: Network }) {
</Button>
)}
<Button size="lg">Next</Button>
<div className="flex-1 flex flex-col justify-end items-center gap-4">
<p className="mt-32 text-sm text-muted-foreground text-center">
Other options
</p>
<Link to="/channels/outgoing" className="w-full">
<Button className="w-full" variant="secondary">
Increase spending balance
</Button>
</Link>
<ExternalLink to="https://www.getalby.com/topup" className="w-full">
<Button className="w-full" variant="secondary">
Buy Bitcoin
</Button>
</ExternalLink>
</div>
</form>
</>
);

View file

@ -210,7 +210,7 @@ function NewChannelInternal({ network }: { network: Network }) {
/>
<form
onSubmit={onSubmit}
className="md:max-w-md max-w-full flex flex-col gap-5"
className="md:max-w-md max-w-full flex flex-col gap-5 flex-1"
>
<div className="grid gap-1.5">
<Label htmlFor="amount">Channel size (sats)</Label>
@ -369,6 +369,22 @@ function NewChannelInternal({ network }: { network: Network }) {
</Button>
)}
<Button size="lg">{openImmediately ? "Open Channel" : "Next"}</Button>
<div className="flex-1 flex flex-col justify-end items-center gap-4">
<p className="mt-32 text-sm text-muted-foreground text-center">
Other options
</p>
<Link to="/channels/incoming" className="w-full">
<Button className="w-full" variant="secondary">
Increase receiving capacity
</Button>
</Link>
<ExternalLink to="https://www.getalby.com/topup" className="w-full">
<Button className="w-full" variant="secondary">
Buy Bitcoin
</Button>
</ExternalLink>
</div>
</form>
</>
);

View file

@ -0,0 +1,200 @@
import { Payment } from "@getalby/bitcoin-connect-react";
import { ChevronDown } from "lucide-react";
import React from "react";
import { Link, useNavigate } from "react-router-dom";
import AppHeader from "src/components/AppHeader";
import ExternalLink from "src/components/ExternalLink";
import Loading from "src/components/Loading";
import { Button } from "src/components/ui/button";
import { Checkbox } from "src/components/ui/checkbox";
import { Label } from "src/components/ui/label";
import { LoadingButton } from "src/components/ui/loading-button";
import { Separator } from "src/components/ui/separator";
import { useToast } from "src/components/ui/use-toast";
import { useAlbyBalance } from "src/hooks/useAlbyBalance";
import { useChannels } from "src/hooks/useChannels";
import { useCSRF } from "src/hooks/useCSRF";
import { useInfo } from "src/hooks/useInfo";
import { AutoChannelRequest, AutoChannelResponse } from "src/types";
import { request } from "src/utils/request";
import { ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL } from "src/constants";
import lightningNetworkDark from "/images/illustrations/lightning-network-dark.svg";
import lightningNetworkLight from "/images/illustrations/lightning-network-light.svg";
export function FirstChannel() {
const { data: info } = useInfo();
const { data: channels } = useChannels(true);
const [isLoading, setLoading] = React.useState(false);
const [showAdvanced, setShowAdvanced] = React.useState(false);
const [isPublic, setPublic] = React.useState(false);
const { data: csrf } = useCSRF();
const navigate = useNavigate();
const { toast } = useToast();
const [invoice, setInvoice] = React.useState<string>();
const [channelSize, setChannelSize] = React.useState<number>();
const { data: albyBalance } = useAlbyBalance();
React.useEffect(() => {
if (channels?.length) {
navigate("/channels/first/opening");
}
}, [channels, navigate]);
if (!info || !channels) {
return <Loading />;
}
async function openChannel() {
if (!info || !channels || !csrf) {
return;
}
setLoading(true);
try {
const newInstantChannelInvoiceRequest: AutoChannelRequest = {
isPublic,
};
const autoChannelResponse = await request<AutoChannelResponse>(
"/api/alby/auto-channel",
{
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json",
},
body: JSON.stringify(newInstantChannelInvoiceRequest),
}
);
if (!autoChannelResponse) {
throw new Error("unexpected auto channel response");
}
setInvoice(autoChannelResponse.invoice);
setChannelSize(autoChannelResponse.channelSize);
} catch (error) {
setLoading(false);
console.error(error);
toast({
title: "Something went wrong. Please try again",
variant: "destructive",
});
}
}
const canPayForFirstChannel =
albyBalance &&
albyBalance.sats >= ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL;
return (
<>
<AppHeader
title="Open Your First Channel"
description="Open a channel to another lightning network node to join the lightning network"
/>
{invoice && channelSize && (
<div className="flex flex-col gap-4 items-center justify-center max-w-md">
<p className="text-muted-foreground">
Please pay the lightning invoice below which will cover the costs of
opening your first channel. You will receive a channel with{" "}
{new Intl.NumberFormat().format(channelSize)} sats of incoming
liquidity.
</p>
<Payment invoice={invoice} paymentMethods="external" />
<Separator className="mt-8" />
<p className="mt-8 text-sm mb-2 text-muted-foreground">
Other options
</p>
<Link to="/channels/outgoing" className="w-full">
<Button className="w-full" variant="secondary">
Open Channel with On-Chain Bitcoin
</Button>
</Link>
<ExternalLink to="https://www.getalby.com/topup" className="w-full">
<Button className="w-full" variant="secondary">
Buy Bitcoin
</Button>
</ExternalLink>
</div>
)}
{!invoice && (
<>
<div className="flex flex-col gap-6 max-w-md text-muted-foreground">
<img
src={lightningNetworkDark}
className="w-full hidden dark:block"
/>
<img src={lightningNetworkLight} className="w-full dark:hidden" />
{canPayForFirstChannel ? (
<>
<p>
Your Alby hosted balance currently holds{" "}
<span className="font-medium text-foreground">
{new Intl.NumberFormat().format(albyBalance?.sats)} sats
</span>
.
</p>
<p>
Those funds will be used to open your first lightning channel
and then migrated to your Hub spending balance.
</p>
</>
) : (
<>
<p>
You're now going to open your first lightning channel and can
begin using your Hub in the booming bitcoin economy!
</p>
<p>
After paying a lightning invoice to cover on-chain fees,
you'll immediately able to receive and send bitcoin with your
Hub.
</p>
</>
)}
{showAdvanced && (
<>
<div className="mt-2 flex items-top space-x-2">
<Checkbox
id="public-channel"
onCheckedChange={() => setPublic(!isPublic)}
className="mr-2"
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="public-channel"
className="flex items-center gap-2"
>
Public Channel
</Label>
<p className="text-xs text-muted-foreground">
Only enable if you want to receive keysend payments. (e.g.
podcasting)
</p>
</div>
</div>
</>
)}
{!showAdvanced && (
<div>
<Button
type="button"
variant="link"
className="text-muted-foreground text-xs px-0"
onClick={() => setShowAdvanced((current) => !current)}
>
Advanced Options
<ChevronDown className="w-4 h-4 ml-1" />
</Button>
</div>
)}
<LoadingButton loading={isLoading} onClick={openChannel}>
Open Channel
{albyBalance && albyBalance?.sats > 0 && <> and Migrate Funds</>}
</LoadingButton>
</div>
</>
)}
</>
);
}

View file

@ -0,0 +1,99 @@
import confetti from "canvas-confetti";
import React from "react";
import { Link } from "react-router-dom";
import ExternalLink from "src/components/ExternalLink";
import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
import { Button } from "src/components/ui/button";
import { useToast } from "src/components/ui/use-toast";
import { ALBY_HIDE_HOSTED_BALANCE_BELOW } from "src/constants";
import { useAlbyBalance } from "src/hooks/useAlbyBalance";
import { useCSRF } from "src/hooks/useCSRF";
import { request } from "src/utils/request";
export function OpenedFirstChannel() {
const { data: csrf } = useCSRF();
const { data: albyBalance, mutate: reloadAlbyBalance } = useAlbyBalance();
const [, setShowedAlbyMigrationToast] = React.useState(false);
const { toast } = useToast();
// automatically drain Alby balance into new channel if possible
// TODO: remove this code once all Alby users have migrated to Alby Hub
React.useEffect(() => {
(async () => {
if (
!csrf ||
!albyBalance ||
albyBalance.sats < ALBY_HIDE_HOSTED_BALANCE_BELOW
) {
return;
}
try {
await request("/api/alby/drain", {
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json",
},
});
await reloadAlbyBalance();
// This may run multiple times (to drain the final 1%), but we should only show a toast once
setShowedAlbyMigrationToast((current) => {
if (!current) {
toast({
description:
"🎉 Funds from Alby shared wallet transferred to your Alby Hub!",
});
}
return true;
});
} catch (error) {
console.error("Failed to transfer any alby shared wallet funds", error);
}
})();
}, [albyBalance, csrf, reloadAlbyBalance, toast]);
React.useEffect(() => {
for (let i = 0; i < 10; i++) {
setTimeout(
() => {
confetti({
origin: {
x: Math.random(),
y: Math.random(),
},
colors: ["#000", "#333", "#666", "#999", "#BBB", "#FFF"],
});
},
Math.floor(Math.random() * 1000)
);
}
}, []);
return (
<div className="flex flex-col justify-center gap-5 p-5 max-w-md items-stretch">
<TwoColumnLayoutHeader
title="Channel Opened"
description="Your new lightning channel is ready to use"
/>
<p>
Congratulations! Your first lightning channel is active and can be used
to send and receive payments.
</p>
<p>
To ensure you can both send and receive, make sure to balance your{" "}
<ExternalLink
to="https://guides.getalby.com/user-guide/v/alby-account-and-browser-extension/alby-hub/liquidity"
className="underline"
>
channel's liquidity
</ExternalLink>
.
</p>
<Link to="/wallet" className="flex justify-center mt-8">
<Button>Go To Your Wallet</Button>
</Link>
</div>
);
}

View file

@ -0,0 +1,57 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import Loading from "src/components/Loading";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { useChannels } from "src/hooks/useChannels";
import { useSyncWallet } from "src/hooks/useSyncWallet";
export function OpeningFirstChannel() {
useSyncWallet();
const { data: channels } = useChannels(true);
const navigate = useNavigate();
const firstChannel = channels?.[0];
React.useEffect(() => {
if (firstChannel?.active) {
navigate("/channels/first/opened");
}
}, [firstChannel, navigate]);
if (!firstChannel || !firstChannel.confirmationsRequired) {
// 0-conf channel, this should only take a few seconds
return <Loading />;
}
return (
<>
<div className="flex flex-col justify-center gap-2">
<Card>
<CardHeader>
<CardTitle>Your channel is being opened</CardTitle>
<CardDescription>
Waiting for {firstChannel.confirmationsRequired} confirmations
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-row gap-2">
<Loading />
{firstChannel.confirmations ?? "0"} /{" "}
{firstChannel.confirmationsRequired ?? "unknown"} confirmations
</div>
</CardContent>
</Card>
<div className="w-full mt-40 gap-20 flex flex-col items-center justify-center">
<p>Feel free to leave this page or browse around Alby Hub!</p>
<p>We'll send you an email as soon as your channel is active.</p>
</div>
</div>
</>
);
}

View file

@ -1,250 +0,0 @@
import { AlertTriangle } from "lucide-react";
import React from "react";
import { Link, useNavigate } from "react-router-dom";
import Loading from "src/components/Loading";
import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
import { Button } from "src/components/ui/button";
import { LoadingButton } from "src/components/ui/loading-button";
import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table";
import { useToast } from "src/components/ui/use-toast";
import { ALBY_MIN_BALANCE, ALBY_SERVICE_FEE } 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 {
NewInstantChannelInvoiceRequest,
NewInstantChannelInvoiceResponse,
} from "src/types";
import { handleRequestError } from "src/utils/handleRequestError";
import { request } from "src/utils/request";
export default function MigrateAlbyFunds() {
const { data: albyMe } = useAlbyMe();
const { data: albyBalance } = useAlbyBalance();
const { data: csrf } = useCSRF();
const { data: info } = useInfo();
const { data: channels } = useChannels(true);
const { mutate: refetchInfo } = useInfo();
const { toast } = useToast();
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<number>(0);
const [instantChannelResponse, setInstantChannelResponse] = React.useState<
NewInstantChannelInvoiceResponse | undefined
>();
const requestWrappedInvoice = React.useCallback(
async (amount: number) => {
try {
if (!info) {
throw new Error("Info not loaded");
}
// other node implementations may not work / may not support 0-conf.
// so for now we are not allowing other backend types.
// They can open a channel with a different method and then migrate
// their shared funds once they have enough receiving capacity.
if (info.backendType !== "LDK") {
throw new Error("Only LDK backend is supported");
}
if (!channels) {
throw new Error("Channels not loaded");
}
setPrePurchaseChannelCount(channels.length);
if (!csrf) {
throw new Error("csrf not loaded");
}
const newInstantChannelInvoiceRequest: NewInstantChannelInvoiceRequest =
{
lspUrl: "https://lsp.albylabs.com",
lspType: "PMLSP",
amount,
public: false,
};
const response = await request<NewInstantChannelInvoiceResponse>(
"/api/instant-channel-invoices",
{
method: "POST",
headers: {
"X-CSRF-Token": csrf,
"Content-Type": "application/json",
},
body: JSON.stringify(newInstantChannelInvoiceRequest),
}
);
if (!response?.invoice) {
throw new Error("No invoice in response");
}
setInstantChannelResponse(response);
} catch (error) {
setError("Failed to connect to request wrapped invoice: " + error);
}
},
[channels, csrf, info]
);
const payWrappedInvoice = React.useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
try {
if (!instantChannelResponse) {
throw new Error("No 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: instantChannelResponse.invoice,
}),
});
} catch (error) {
handleRequestError(
toast,
"Failed to pay channel funding invoice",
error
);
setOpeningChannel(false);
}
},
[csrf, toast, instantChannelResponse]
);
React.useEffect(() => {
if (hasRequestedInvoice || !info || !channels || !albyMe || !albyBalance) {
return;
}
setRequestedInvoice(true);
const _amount = Math.floor(albyBalance.sats * (1 - ALBY_SERVICE_FEE));
setAmount(_amount);
if (_amount < ALBY_MIN_BALANCE) {
return;
}
requestWrappedInvoice(_amount);
}, [
hasRequestedInvoice,
albyBalance,
channels,
albyMe,
info,
requestWrappedInvoice,
]);
const hasOpenedChannel =
channels &&
prePurchaseChannelCount !== undefined &&
channels.length > prePurchaseChannelCount;
React.useEffect(() => {
if (hasOpenedChannel) {
(async () => {
toast({ title: "Successfully opened channel" });
await refetchInfo();
navigate("/onboarding/success");
})();
}
}, [hasOpenedChannel, navigate, refetchInfo, toast]);
return (
<div className="flex flex-col justify-center gap-5 p-5 max-w-md items-stretch">
<TwoColumnLayoutHeader
title="Open a Channel"
description="You can use your remaining balance on Alby hosted lightning wallet to
fund your first lightning channel."
/>
{error ? (
<>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error requesting wrapped invoice</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</>
) : !albyMe ||
!albyBalance ||
!channels ||
(amount >= ALBY_MIN_BALANCE && !instantChannelResponse) ? (
<Loading className="mx-auto" />
) : instantChannelResponse &&
amount - instantChannelResponse.fee >= ALBY_MIN_BALANCE ? (
<>
<div className="border rounded-lg">
<Table>
<TableBody>
<TableRow className="border-b-0">
<TableCell className="font-medium p-3">
Current Account balance
</TableCell>
<TableCell className="text-right p-3">
{new Intl.NumberFormat().format(albyBalance.sats)} sats
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium p-3 flex flex-row gap-1.5 items-center">
Fee
</TableCell>
<TableCell className="text-right p-3">
{new Intl.NumberFormat().format(
albyBalance.sats - amount + instantChannelResponse.fee
)}{" "}
sats
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium p-3">
Alby Hub Balance
</TableCell>
<TableCell className="font-semibold text-right p-3">
{new Intl.NumberFormat().format(
amount - instantChannelResponse.fee
)}{" "}
sats
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<form className="flex flex-col justify-between text-center gap-2">
<LoadingButton
onClick={payWrappedInvoice}
disabled={isOpeningChannel}
loading={isOpeningChannel}
>
Migrate Funds and Open Channel
</LoadingButton>
</form>
</>
) : (
<>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Not enough funds available!</AlertTitle>
<AlertDescription>
You don't have enough funds in your Alby account to fund a new
channel right now. You can open a channel manually and pay with an
external wallet though.
</AlertDescription>
</Alert>
<Link to="/channels" className="w-full">
<Button className="w-full">Explore Other Options</Button>
</Link>
</>
)}
</div>
);
}

View file

@ -21,7 +21,7 @@ export function Success() {
Math.floor(Math.random() * 1000)
);
}
});
}, []);
return (
<div className="flex flex-col justify-center gap-5 p-5 max-w-md items-stretch">

View file

@ -283,7 +283,7 @@ export type SetupNodeInfo = Partial<{
phoenixdAuthorization?: string;
}>;
export type LSPType = "LSPS1" | "Flow 2.0" | "PMLSP";
export type LSPType = "LSPS1";
export type RecommendedChannelPeer = {
network: Network;
@ -324,14 +324,14 @@ export type AlbyBalance = {
sats: number;
};
export type NewInstantChannelInvoiceRequest = {
export type LSPOrderRequest = {
amount: number;
lspType: LSPType;
lspUrl: string;
public: boolean;
};
export type NewInstantChannelInvoiceResponse = {
export type LSPOrderResponse = {
invoice: string;
fee: number;
invoiceAmount: number;
@ -339,6 +339,15 @@ export type NewInstantChannelInvoiceResponse = {
outgoingLiquidity: number;
};
export type AutoChannelRequest = {
isPublic: boolean;
};
export type AutoChannelResponse = {
invoice?: string;
fee?: number;
channelSize: number;
};
export type RedeemOnchainFundsResponse = {
txId: string;
};
@ -372,13 +381,14 @@ export type Transaction = {
metadata: unknown;
};
export type NewChannelOrderStatus = "pay" | "success" | "opening";
export type NewChannelOrderStatus = "pay" | "paid" | "success" | "opening";
export type NewChannelOrder = {
amount: string;
isPublic: boolean;
status: NewChannelOrderStatus;
fundingTxId?: string;
prevChannelIds: string[];
} & (
| {
paymentMethod: "onchain";

View file

@ -32,6 +32,28 @@ func (albyHttpSvc *AlbyHttpService) RegisterSharedRoutes(e *echo.Echo, authMiddl
e.POST("/api/alby/pay", albyHttpSvc.albyPayHandler, authMiddleware)
e.POST("/api/alby/drain", albyHttpSvc.albyDrainHandler, authMiddleware)
e.POST("/api/alby/link-account", albyHttpSvc.albyLinkAccountHandler, authMiddleware)
e.POST("/api/alby/auto-channel", albyHttpSvc.autoChannelHandler, authMiddleware)
}
func (albyHttpSvc *AlbyHttpService) autoChannelHandler(c echo.Context) error {
ctx := c.Request().Context()
var autoChannelRequest alby.AutoChannelRequest
if err := c.Bind(&autoChannelRequest); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: fmt.Sprintf("Bad request: %s", err.Error()),
})
}
autoChannelResponseResponse, err := albyHttpSvc.albyOAuthSvc.RequestAutoChannel(ctx, albyHttpSvc.svc.GetLNClient(), autoChannelRequest.IsPublic)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to request wrapped invoice: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, autoChannelResponseResponse)
}
func (albyHttpSvc *AlbyHttpService) albyCallbackHandler(c echo.Context) error {

View file

@ -91,8 +91,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
e.GET("/api/channels", httpSvc.channelsListHandler, authMiddleware)
e.POST("/api/channels", httpSvc.openChannelHandler, authMiddleware)
e.GET("/api/channels/suggestions", httpSvc.channelPeerSuggestionsHandler, authMiddleware)
// TODO: review naming
e.POST("/api/instant-channel-invoices", httpSvc.newInstantChannelInvoiceHandler, authMiddleware)
e.POST("/api/lsp-orders", httpSvc.newInstantChannelInvoiceHandler, authMiddleware)
e.GET("/api/node/connection-info", httpSvc.nodeConnectionInfoHandler, authMiddleware)
e.GET("/api/node/status", httpSvc.nodeStatusHandler, authMiddleware)
e.GET("/api/node/network-graph", httpSvc.nodeNetworkGraphHandler, authMiddleware)
@ -625,14 +624,14 @@ func (httpSvc *HttpService) updateChannelHandler(c echo.Context) error {
func (httpSvc *HttpService) newInstantChannelInvoiceHandler(c echo.Context) error {
ctx := c.Request().Context()
var newWrappedInvoiceRequest api.NewInstantChannelInvoiceRequest
var newWrappedInvoiceRequest api.LSPOrderRequest
if err := c.Bind(&newWrappedInvoiceRequest); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: fmt.Sprintf("Bad request: %s", err.Error()),
})
}
newWrappedInvoiceResponse, err := httpSvc.api.NewInstantChannelInvoice(ctx, &newWrappedInvoiceRequest)
newWrappedInvoiceResponse, err := httpSvc.api.RequestLSPOrder(ctx, &newWrappedInvoiceRequest)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{

View file

@ -816,7 +816,7 @@ func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, err
channelError = &channelErrorValue
}
isActive := ldkChannel.IsUsable /* superset of ldkChannel.IsReady */ && channelError == nil
isActive := ldkChannel.IsUsable /* superset of ldkChannel.IsReady */ && channelError == nil && fundingTxId != ""
channels = append(channels, lnclient.Channel{
InternalChannel: internalChannel,

View file

@ -5,9 +5,7 @@ type LSP struct {
}
const (
LSP_TYPE_FLOW_2_0 = "Flow 2.0"
LSP_TYPE_PMLSP = "PMLSP"
LSP_TYPE_LSPS1 = "LSPS1"
LSP_TYPE_LSPS1 = "LSPS1"
)
func OlympusMutinynetLSP() LSP {

View file

@ -450,11 +450,10 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: *capabilitiesResponse, Error: ""}
// TODO: review naming
case "/api/instant-channel-invoices":
newInstantChannelRequest := &api.NewInstantChannelInvoiceRequest{}
case "/api/lsp-orders":
newInstantChannelRequest := &api.LSPOrderRequest{}
err := json.Unmarshal([]byte(body), newInstantChannelRequest)
newInstantChannelResponse, err := app.api.NewInstantChannelInvoice(ctx, newInstantChannelRequest)
newInstantChannelResponse, err := app.api.RequestLSPOrder(ctx, newInstantChannelRequest)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
@ -504,6 +503,24 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
infoResponse.Unlocked = infoResponse.Running
res := WailsRequestRouterResponse{Body: *infoResponse, Error: ""}
return res
case "/api/alby/auto-channel":
newAutoChannelRequest := &alby.AutoChannelRequest{}
err := json.Unmarshal([]byte(body), newAutoChannelRequest)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"route": route,
"method": method,
"body": body,
}).WithError(err).Error("Failed to decode request to wails router")
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
autoChannelResponse, err := app.svc.GetAlbyOAuthSvc().RequestAutoChannel(ctx, app.svc.GetLNClient(), newAutoChannelRequest.IsPublic)
if err != nil {
return WailsRequestRouterResponse{Body: *autoChannelResponse, Error: err.Error()}
}
res := WailsRequestRouterResponse{Error: ""}
return res
case "/api/alby/link-account":
linkAccountRequest := &alby.AlbyLinkAccountRequest{}
err := json.Unmarshal([]byte(body), linkAccountRequest)