alby-hub/api/api.go
Roland Bewick 30fbc7dded fix: validate LND and CLN credential files during setup
The setup API accepts file paths for the LND certificate and macaroon
and for the CLN lightning directory. Previously the raw file contents
were read and stored without any validation.

Validate these inputs before persisting them:

- LND cert: parse the PEM and store only the re-encoded certificate(s),
  discarding any other PEM blocks (e.g. a bundled private key).
- LND macaroon: unmarshal and store the re-marshalled macaroon.
- CLN lightning directory: verify it contains the TLS credentials
  (ca.pem, client.pem, client-key.pem) that CLN loads at connect time,
  including the hold subdirectory when configured.

On failure, return a generic error to the client and log the detail
server-side. File paths remain supported for Umbrel-style installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 16:04:05 +07:00

2302 lines
72 KiB
Go

package api
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
"gopkg.in/macaroon.v2"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/getAlby/hub/alby"
"github.com/getAlby/hub/apps"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/db/queries"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
permissions "github.com/getAlby/hub/nip47/permissions"
"github.com/getAlby/hub/service"
"github.com/getAlby/hub/service/keys"
"github.com/getAlby/hub/swaps"
"github.com/getAlby/hub/utils"
"github.com/getAlby/hub/version"
)
type api struct {
db *gorm.DB
appsSvc apps.AppsService
cfg config.Config
svc service.Service
permissionsSvc permissions.PermissionsService
keys keys.Keys
albyOAuthSvc alby.AlbyOAuthService
albySvc alby.AlbyService
startupError error
startupErrorTime time.Time
eventPublisher events.EventPublisher
}
func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albySvc alby.AlbyService, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api {
return &api{
db: gormDB,
appsSvc: apps.NewAppsService(gormDB, eventPublisher, keys, config),
cfg: config,
svc: svc,
permissionsSvc: permissions.NewPermissionsService(gormDB, eventPublisher),
keys: keys,
albySvc: albySvc,
albyOAuthSvc: albyOAuthSvc,
eventPublisher: eventPublisher,
}
}
func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppResponse, error) {
if slices.Contains(createAppRequest.Scopes, constants.SUPERUSER_SCOPE) {
if !api.cfg.CheckUnlockPassword(createAppRequest.UnlockPassword) {
return nil, fmt.Errorf(
"incorrect unlock password to create app with superuser permission")
}
}
maxAmountSat := uint64(0)
resolvedMaxAmountSat := ResolveToSat(createAppRequest.MaxAmountSat, createAppRequest.MaxAmountMsat, createAppRequest.MaxAmount, nil)
if resolvedMaxAmountSat != nil {
maxAmountSat = *resolvedMaxAmountSat
}
if createAppRequest.Name == alby.ALBY_ACCOUNT_APP_NAME {
return nil, fmt.Errorf("Reserved app name: %s", alby.ALBY_ACCOUNT_APP_NAME)
}
expiresAt, err := api.parseExpiresAt(createAppRequest.ExpiresAt)
if err != nil {
return nil, fmt.Errorf("invalid expiresAt: %v", err)
}
for _, scope := range createAppRequest.Scopes {
if !slices.Contains(permissions.AllScopes(), scope) {
return nil, fmt.Errorf("did not recognize requested scope: %s", scope)
}
}
app, pairingSecretKey, err := api.appsSvc.CreateApp(
createAppRequest.Name,
createAppRequest.Pubkey,
maxAmountSat,
createAppRequest.BudgetRenewal,
expiresAt,
createAppRequest.Scopes,
createAppRequest.Isolated,
createAppRequest.Metadata,
)
if err != nil {
return nil, err
}
relayUrls := api.cfg.GetRelayUrls()
lightningAddress, err := api.albyOAuthSvc.GetLightningAddress()
if err != nil {
return nil, err
}
responseBody := &CreateAppResponse{}
responseBody.Id = app.ID
responseBody.Name = app.Name
responseBody.Pubkey = app.AppPubkey
responseBody.PairingSecret = pairingSecretKey
responseBody.WalletPubkey = *app.WalletPubkey
responseBody.RelayUrls = relayUrls
responseBody.Lud16 = lightningAddress
if createAppRequest.ReturnTo != "" {
returnToUrl, err := url.Parse(createAppRequest.ReturnTo)
if err == nil {
query := returnToUrl.Query()
for _, relayUrl := range relayUrls {
query.Add("relay", relayUrl)
}
query.Add("pubkey", *app.WalletPubkey)
if lightningAddress != "" && !app.Isolated {
query.Add("lud16", lightningAddress)
}
returnToUrl.RawQuery = query.Encode()
responseBody.ReturnTo = returnToUrl.String()
}
}
var lud16 string
if lightningAddress != "" && !app.Isolated {
lud16 = fmt.Sprintf("&lud16=%s", lightningAddress)
}
responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", *app.WalletPubkey, strings.Join(relayUrls, "&relay="), pairingSecretKey, lud16)
return responseBody, nil
}
func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error {
resolvedMaxAmountSat := ResolveToSat(updateAppRequest.MaxAmountSat, updateAppRequest.MaxAmountMsat, updateAppRequest.MaxAmount, nil)
err := api.db.Transaction(func(tx *gorm.DB) error {
// Initialize name with current app name, update if provided
name := userApp.Name
// Update app name if provided and different
if updateAppRequest.Name != nil {
name = *updateAppRequest.Name
if name == "" {
return fmt.Errorf("won't update an app to have no name")
}
if name != userApp.Name {
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("name", name).Error
if err != nil {
return err
}
}
}
// Update app isolation if provided and different
if updateAppRequest.Isolated != nil {
isolated := *updateAppRequest.Isolated
if isolated != userApp.Isolated {
if !isolated {
var existingMetadata Metadata
if userApp.Metadata != nil {
err := json.Unmarshal(userApp.Metadata, &existingMetadata)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": userApp.ID,
}).Error("Failed to deserialize app metadata")
return err
}
if existingMetadata[constants.METADATA_APPSTORE_APP_ID_KEY] == constants.SUBWALLET_APPSTORE_APP_ID {
return errors.New("Cannot update sub-wallet to be non-isolated")
}
}
}
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("isolated", isolated).Error
if err != nil {
return err
}
}
}
// Update the app metadata if provided
if updateAppRequest.Metadata != nil {
var metadataBytes []byte
var err error
metadataBytes, err = json.Marshal(*updateAppRequest.Metadata)
if err != nil {
logger.Logger.WithError(err).Error("Failed to serialize metadata")
return err
}
err = tx.Model(&db.App{}).Where("id", userApp.ID).Update("metadata", datatypes.JSON(metadataBytes)).Error
if err != nil {
return err
}
}
// Handle permissions updates only if any permission-related field is provided
if updateAppRequest.Scopes != nil || resolvedMaxAmountSat != nil ||
updateAppRequest.BudgetRenewal != nil || updateAppRequest.ExpiresAt != nil || updateAppRequest.UpdateExpiresAt {
// Get current values or use provided ones
var maxAmountSat uint64
var budgetRenewal string
var expiresAt *time.Time
// Get existing permissions to use as defaults
var existingPermissions []db.AppPermission
if err := tx.Where("app_id = ?", userApp.ID).Find(&existingPermissions).Error; err != nil {
return err
}
// Use existing values as defaults
if len(existingPermissions) > 0 {
// Find pay_invoice permission for budget-related fields
for _, perm := range existingPermissions {
if perm.Scope == constants.PAY_INVOICE_SCOPE {
maxAmountSat = uint64(perm.MaxAmountSat)
budgetRenewal = perm.BudgetRenewal
expiresAt = perm.ExpiresAt
break
}
}
}
// Override with provided values
if resolvedMaxAmountSat != nil {
maxAmountSat = *resolvedMaxAmountSat
}
if updateAppRequest.BudgetRenewal != nil {
budgetRenewal = *updateAppRequest.BudgetRenewal
}
if updateAppRequest.ExpiresAt != nil {
parsedExpiresAt, err := api.parseExpiresAt(*updateAppRequest.ExpiresAt)
if err != nil {
return fmt.Errorf("invalid expiresAt: %v", err)
}
expiresAt = parsedExpiresAt
}
if updateAppRequest.ExpiresAt == nil && updateAppRequest.UpdateExpiresAt {
expiresAt = nil
}
// Update existing permissions with new budget and expiry
err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{
"ExpiresAt": expiresAt,
"MaxAmountSat": maxAmountSat,
"BudgetRenewal": budgetRenewal,
}).Error
if err != nil {
return err
}
// Handle scope changes only if scopes were provided
if updateAppRequest.Scopes != nil {
if len(updateAppRequest.Scopes) == 0 {
return fmt.Errorf("won't update an app to have no request methods")
}
existingScopeMap := make(map[string]bool)
for _, perm := range existingPermissions {
existingScopeMap[perm.Scope] = true
}
if slices.Contains(updateAppRequest.Scopes, constants.SUPERUSER_SCOPE) && !existingScopeMap[constants.SUPERUSER_SCOPE] {
return fmt.Errorf("cannot update app to add superuser permission")
}
// Add new permissions
for _, scope := range updateAppRequest.Scopes {
if !existingScopeMap[scope] {
perm := db.AppPermission{
App: *userApp,
Scope: scope,
ExpiresAt: expiresAt,
MaxAmountSat: int(maxAmountSat),
BudgetRenewal: budgetRenewal,
}
if err := tx.Create(&perm).Error; err != nil {
return err
}
}
delete(existingScopeMap, scope)
}
// Remove old permissions
for scope := range existingScopeMap {
if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, scope).Delete(&db.AppPermission{}).Error; err != nil {
return err
}
}
}
}
// Publish update event
api.svc.GetEventPublisher().Publish(&events.Event{
Event: "nwc_app_updated",
Properties: map[string]interface{}{
"name": name,
"id": userApp.ID,
},
})
// commit transaction
return nil
})
return err
}
func (api *api) DeleteApp(userApp *db.App) error {
// Delete lightning address if one exists
if api.appsSvc.HasLightningAddress(userApp) {
err := api.DeleteLightningAddress(context.Background(), userApp.ID)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": userApp.ID,
}).Error("Failed to delete lightning address during app deletion")
}
}
return api.appsSvc.DeleteApp(userApp)
}
func (api *api) CreateLightningAddress(ctx context.Context, createLightningAddressRequest *CreateLightningAddressRequest) error {
app := api.appsSvc.GetAppById(createLightningAddressRequest.AppId)
if app == nil {
return errors.New("app not found")
}
var metadata map[string]interface{}
err := json.Unmarshal(app.Metadata, &metadata)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": app.ID,
}).Error("Failed to deserialize app metadata")
return err
}
createLightningAddressResponse, err := api.albyOAuthSvc.CreateLightningAddress(ctx, createLightningAddressRequest.Address, createLightningAddressRequest.AppId)
if err != nil {
logger.Logger.WithError(err).Error("Failed to create lightning address for app")
return err
}
metadata["lud16"] = createLightningAddressResponse.FullAddress
err = api.appsSvc.SetAppMetadata(app.ID, metadata)
if err != nil {
logger.Logger.WithError(err).Error("Failed to add lightning address to app metadata")
return err
}
return nil
}
func (api *api) DeleteLightningAddress(ctx context.Context, appId uint) error {
app := api.appsSvc.GetAppById(appId)
if app == nil {
return errors.New("app not found")
}
var metadata map[string]interface{}
err := json.Unmarshal(app.Metadata, &metadata)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": app.ID,
}).Error("Failed to deserialize app metadata")
return err
}
if metadata["lud16"] == nil {
return errors.New("no lightning address set")
}
lud16 := metadata["lud16"].(string)
if !strings.Contains(lud16, "@") {
return errors.New("invalid lightning address")
}
address := strings.Split(lud16, "@")[0]
// Call the Alby OAuth service to delete the lightning address
err = api.albyOAuthSvc.DeleteLightningAddress(ctx, address)
if err != nil {
logger.Logger.WithError(err).Error("Failed to delete lightning address for app")
return err
}
delete(metadata, "lud16")
err = api.appsSvc.SetAppMetadata(app.ID, metadata)
if err != nil {
logger.Logger.WithError(err).Error("Failed to remove lightning address from app metadata")
return err
}
return nil
}
func (api *api) GetApp(dbApp *db.App) (*App, error) {
paySpecificPermission := db.AppPermission{}
appPermissions := []db.AppPermission{}
var expiresAt *time.Time
if err := api.db.Where("app_id = ?", dbApp.ID).Find(&appPermissions).Error; err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": dbApp.ID,
}).Error("Failed to list app permissions")
return nil, err
}
requestMethods := []string{}
for _, appPerm := range appPermissions {
expiresAt = appPerm.ExpiresAt
if appPerm.Scope == constants.PAY_INVOICE_SCOPE {
// find the pay_invoice-specific permissions
paySpecificPermission = appPerm
}
requestMethods = append(requestMethods, appPerm.Scope)
}
// renewsIn := ""
maxAmountSat := uint64(paySpecificPermission.MaxAmountSat)
budgetUsageMsat, err := queries.GetBudgetUsageMsat(api.db, &paySpecificPermission)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": dbApp.ID,
}).Error("Failed to get budget usage for app")
return nil, err
}
var metadata Metadata
if dbApp.Metadata != nil {
jsonErr := json.Unmarshal(dbApp.Metadata, &metadata)
if jsonErr != nil {
logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
"app_id": dbApp.ID,
}).Error("Failed to deserialize app metadata")
}
}
walletPubkey := api.keys.GetNostrPublicKey()
uniqueWalletPubkey := false
if dbApp.WalletPubkey != nil {
walletPubkey = *dbApp.WalletPubkey
uniqueWalletPubkey = true
}
response := App{
ID: dbApp.ID,
Name: dbApp.Name,
Description: dbApp.Description,
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
AppPubkey: dbApp.AppPubkey,
ExpiresAt: expiresAt,
MaxAmount: maxAmountSat,
MaxAmountSat: maxAmountSat,
MaxAmountMsat: maxAmountSat * 1000,
Scopes: requestMethods,
BudgetUsage: budgetUsageMsat / 1000,
BudgetUsageSat: budgetUsageMsat / 1000,
BudgetUsageMsat: budgetUsageMsat,
BudgetRenewal: paySpecificPermission.BudgetRenewal,
Isolated: dbApp.Isolated,
Metadata: metadata,
WalletPubkey: walletPubkey,
UniqueWalletPubkey: uniqueWalletPubkey,
LastUsedAt: dbApp.LastUsedAt,
LastSettledTransactionAt: dbApp.LastSettledTransactionAt,
}
if dbApp.Isolated {
balanceMsat, err := queries.GetIsolatedBalanceMsat(api.db, dbApp.ID)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": dbApp.ID,
}).Error("Failed to get isolated app balance")
return nil, err
}
response.Balance = balanceMsat
response.BalanceSat = balanceMsat / 1000
response.BalanceMsat = balanceMsat
}
return &response, nil
}
func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error) {
// TODO: join dbApps and permissions
dbApps := []db.App{}
query := api.db
if filters.Name != "" {
// searching for "Damus" will return "Damus" and "Damus (1)"
// Use case-insensitive search for both SQLite and PostgreSQL
if api.db.Dialector.Name() == "postgres" {
query = query.Where("name ILIKE ?", filters.Name+"%")
} else {
query = query.Where("name LIKE ?", filters.Name+"%")
}
}
if filters.AppStoreAppId != "" {
query = query.Where(datatypes.JSONQuery("metadata").Equals(filters.AppStoreAppId, constants.METADATA_APPSTORE_APP_ID_KEY))
}
if filters.Unused {
// find unused non-subwallet apps not used in the past 60 days
query = query.Where("last_used_at IS NULL OR last_used_at < ?", time.Now().Add(-60*24*time.Hour))
}
if filters.SubWallets != nil {
if *filters.SubWallets {
query = query.Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY))
} else {
// exclude subwallets :scream:
if api.db.Dialector.Name() == "sqlite" {
query = query.Where(fmt.Sprintf("metadata is NULL OR JSON_EXTRACT(metadata, '$.%s') IS NULL OR JSON_EXTRACT(metadata, '$.%s') != ?", constants.METADATA_APPSTORE_APP_ID_KEY, constants.METADATA_APPSTORE_APP_ID_KEY), constants.SUBWALLET_APPSTORE_APP_ID)
} else {
query = query.Where(fmt.Sprintf("metadata IS NULL OR metadata->>'%s' IS NULL OR metadata->>'%s' != ?", constants.METADATA_APPSTORE_APP_ID_KEY, constants.METADATA_APPSTORE_APP_ID_KEY), constants.SUBWALLET_APPSTORE_APP_ID)
}
}
}
query = query.Order(resolveAppOrderBy(orderBy))
if limit == 0 {
limit = 100
}
var totalCount int64
result := query.Model(&db.App{}).Count(&totalCount)
if result.Error != nil {
logger.Logger.WithError(result.Error).Error("Failed to count DB apps")
return nil, result.Error
}
var totalBalance *int64
var totalBalanceSat *int64
if filters.SubWallets != nil && *filters.SubWallets {
totalBalanceMsat, err := queries.GetTotalSubwalletBalanceMsat(api.db)
if err != nil {
logger.Logger.WithError(err).Error("Failed to calculate total subwallet balance")
return nil, err
}
totalBalance = &totalBalanceMsat
totalBalanceSatVal := totalBalanceMsat / 1000
totalBalanceSat = &totalBalanceSatVal
}
query = query.Offset(int(offset)).Limit(int(limit))
err := query.Find(&dbApps).Error
if err != nil {
logger.Logger.WithError(err).Error("Failed to list apps")
return nil, err
}
appIds := []uint64{}
for _, app := range dbApps {
appIds = append(appIds, uint64(app.ID))
}
appPermissions := []db.AppPermission{}
err = api.db.Where("app_id IN ?", appIds).Find(&appPermissions).Error
if err != nil {
logger.Logger.WithError(err).Error("Failed to list app permissions")
return nil, err
}
permissionsMap := make(map[uint][]db.AppPermission)
for _, perm := range appPermissions {
permissionsMap[perm.AppId] = append(permissionsMap[perm.AppId], perm)
}
apiApps := []App{}
for _, dbApp := range dbApps {
walletPubkey := api.keys.GetNostrPublicKey()
uniqueWalletPubkey := false
if dbApp.WalletPubkey != nil {
walletPubkey = *dbApp.WalletPubkey
uniqueWalletPubkey = true
}
apiApp := App{
ID: dbApp.ID,
Name: dbApp.Name,
Description: dbApp.Description,
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
AppPubkey: dbApp.AppPubkey,
Isolated: dbApp.Isolated,
WalletPubkey: walletPubkey,
UniqueWalletPubkey: uniqueWalletPubkey,
LastUsedAt: dbApp.LastUsedAt,
LastSettledTransactionAt: dbApp.LastSettledTransactionAt,
}
if dbApp.Isolated {
balanceMsat, err := queries.GetIsolatedBalanceMsat(api.db, dbApp.ID)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": dbApp.ID,
}).Error("Failed to get isolated app balance")
return nil, err
}
apiApp.Balance = balanceMsat
apiApp.BalanceSat = balanceMsat / 1000
apiApp.BalanceMsat = balanceMsat
}
for _, appPermission := range permissionsMap[dbApp.ID] {
apiApp.Scopes = append(apiApp.Scopes, appPermission.Scope)
apiApp.ExpiresAt = appPermission.ExpiresAt
if appPermission.Scope == constants.PAY_INVOICE_SCOPE {
apiApp.BudgetRenewal = appPermission.BudgetRenewal
apiApp.MaxAmount = uint64(appPermission.MaxAmountSat)
apiApp.MaxAmountSat = uint64(appPermission.MaxAmountSat)
apiApp.MaxAmountMsat = uint64(appPermission.MaxAmountSat) * 1000
budgetUsageMsat, err := queries.GetBudgetUsageMsat(api.db, &appPermission)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": dbApp.ID,
}).Error("Failed to get budget usage for app")
return nil, err
}
apiApp.BudgetUsage = budgetUsageMsat / 1000
apiApp.BudgetUsageSat = budgetUsageMsat / 1000
apiApp.BudgetUsageMsat = budgetUsageMsat
}
}
var metadata Metadata
if dbApp.Metadata != nil {
jsonErr := json.Unmarshal(dbApp.Metadata, &metadata)
if jsonErr != nil {
logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
"app_id": dbApp.ID,
}).Error("Failed to deserialize app metadata")
}
apiApp.Metadata = metadata
}
apiApps = append(apiApps, apiApp)
}
return &ListAppsResponse{
Apps: apiApps,
TotalCount: uint64(totalCount),
TotalBalance: totalBalance,
TotalBalanceSat: totalBalanceSat,
TotalBalanceMsat: totalBalance,
}, nil
}
func resolveAppOrderBy(orderBy string) string {
switch orderBy {
case "created_at":
return "created_at DESC"
case "last_settled_transaction":
return "last_settled_transaction_at IS NULL, last_settled_transaction_at DESC"
default:
return "last_used_at IS NULL, last_used_at DESC"
}
}
func (api *api) ListChannels(ctx context.Context) ([]Channel, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
channels, err := lnClient.ListChannels(ctx)
if err != nil {
return nil, err
}
apiChannels := []Channel{}
for _, channel := range channels {
status := "offline"
if channel.Active {
status = "online"
} else if channel.Confirmations != nil && channel.ConfirmationsRequired != nil && *channel.ConfirmationsRequired > *channel.Confirmations {
status = "opening"
}
apiChannels = append(apiChannels, Channel{
LocalBalance: channel.LocalBalanceMsat,
LocalBalanceSat: channel.LocalBalanceMsat / 1000,
LocalBalanceMsat: channel.LocalBalanceMsat,
LocalSpendableBalance: channel.LocalSpendableBalanceMsat,
LocalSpendableBalanceSat: channel.LocalSpendableBalanceMsat / 1000,
LocalSpendableBalanceMsat: channel.LocalSpendableBalanceMsat,
RemoteBalance: channel.RemoteBalanceMsat,
RemoteBalanceSat: channel.RemoteBalanceMsat / 1000,
RemoteBalanceMsat: channel.RemoteBalanceMsat,
Id: channel.Id,
RemotePubkey: channel.RemotePubkey,
FundingTxId: channel.FundingTxId,
FundingTxVout: channel.FundingTxVout,
Active: channel.Active,
Public: channel.Public,
InternalChannel: channel.InternalChannel,
Confirmations: channel.Confirmations,
ConfirmationsRequired: channel.ConfirmationsRequired,
ForwardingFeeBaseMsat: channel.ForwardingFeeBaseMsat,
ForwardingFeeProportionalMillionths: channel.ForwardingFeeProportionalMillionths,
UnspendablePunishmentReserve: channel.UnspendablePunishmentReserveSat,
UnspendablePunishmentReserveSat: channel.UnspendablePunishmentReserveSat,
CounterpartyUnspendablePunishmentReserve: channel.CounterpartyUnspendablePunishmentReserveSat,
CounterpartyUnspendablePunishmentReserveSat: channel.CounterpartyUnspendablePunishmentReserveSat,
Error: channel.Error,
IsOutbound: channel.IsOutbound,
Status: status,
})
}
slices.SortFunc(apiChannels, func(a, b Channel) int {
// sort by channel size first
aSize := a.LocalBalance + a.RemoteBalance
bSize := b.LocalBalance + b.RemoteBalance
if aSize != bSize {
return int(bSize - aSize)
}
// then by local balance in the channel
if a.LocalBalance != b.LocalBalance {
return int(b.LocalBalance - a.LocalBalance)
}
// finally sort by channel ID to prevent sort randomly changing
return strings.Compare(b.Id, a.Id)
})
return apiChannels, nil
}
func (api *api) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error) {
return api.albySvc.GetChannelPeerSuggestions(ctx)
}
func (api *api) GetStories(ctx context.Context) ([]alby.Story, error) {
return api.albyOAuthSvc.GetStories(ctx)
}
func (api *api) GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error) {
return api.albyOAuthSvc.GetLSPChannelOffer(ctx)
}
func (api *api) ResetRouter(key string) error {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return ErrLNClientNotStarted
}
err := lnClient.ResetRouter(key)
if err != nil {
return err
}
// Because the above method has to stop the node to reset the router,
// We also need to stop the lnclient and ask the user to start it again
return api.Stop()
}
func (api *api) ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error {
if api.svc.GetLNClient() == nil {
return ErrLNClientNotStarted
}
autoUnlockPassword, err := api.cfg.Get("AutoUnlockPassword", "")
if err != nil {
return err
}
if autoUnlockPassword != "" {
return errors.New("please disable auto-unlock before using this feature")
}
err = api.cfg.ChangeUnlockPassword(changeUnlockPasswordRequest.CurrentUnlockPassword, changeUnlockPasswordRequest.NewUnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("failed to change unlock password")
return err
}
// Because all the encrypted fields have changed
// we also need to stop the lnclient and ask the user to start it again
return api.Stop()
}
func (api *api) SetAutoUnlockPassword(unlockPassword string) error {
if api.svc.GetLNClient() == nil {
return ErrLNClientNotStarted
}
err := api.cfg.SetAutoUnlockPassword(unlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("failed to set auto unlock password")
return err
}
return nil
}
func (api *api) Stop() error {
if !startMutex.TryLock() {
// do not allow to stop twice in case this is somehow called twice
return errors.New("app is busy")
}
defer startMutex.Unlock()
logger.Logger.Info("Running Stop command")
if api.svc.GetLNClient() == nil {
return ErrLNClientNotStarted
}
// stop the lnclient, nostr relay etc.
// The user will be forced to re-enter their unlock password to restart the node
api.svc.StopApp()
return nil
}
func (api *api) GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
info, err := lnClient.GetNodeConnectionInfo(ctx)
if err != nil {
return nil, err
}
return &NodeConnectionInfo{
Pubkey: info.Pubkey,
Address: info.Address,
Port: info.Port,
}, nil
}
func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error {
if api.svc.GetSwapsService() == nil {
return errors.New("SwapsService not started")
}
return api.svc.GetSwapsService().RefundSwap(refundSwapRequest.SwapId, refundSwapRequest.Address, false)
}
func (api *api) GetAutoSwapConfig() (*GetAutoSwapConfigResponse, error) {
if api.svc.GetSwapsService() == nil {
return nil, errors.New("SwapsService not started")
}
swapOutBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "")
swapOutAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "")
swapOutDestination, _ := api.cfg.Get(config.AutoSwapDestinationKey, "")
if xpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub(); xpub != "" {
swapOutDestination = xpub
}
swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != ""
var swapOutBalanceThresholdSat, swapOutAmountSat uint64
if swapOutEnabled {
var err error
if swapOutBalanceThresholdSat, err = strconv.ParseUint(swapOutBalanceThresholdStr, 10, 64); err != nil {
return nil, fmt.Errorf("invalid autoswap out balance threshold: %w", err)
}
if swapOutAmountSat, err = strconv.ParseUint(swapOutAmountStr, 10, 64); err != nil {
return nil, fmt.Errorf("invalid autoswap out amount: %w", err)
}
}
return &GetAutoSwapConfigResponse{
Type: constants.SWAP_TYPE_OUT,
Enabled: swapOutEnabled,
BalanceThreshold: swapOutBalanceThresholdSat,
BalanceThresholdSat: swapOutBalanceThresholdSat,
SwapAmount: swapOutAmountSat,
SwapAmountSat: swapOutAmountSat,
Destination: swapOutDestination,
}, nil
}
func (api *api) LookupSwap(swapId string) (*LookupSwapResponse, error) {
if api.svc.GetSwapsService() == nil {
return nil, errors.New("SwapsService not started")
}
dbSwap, err := api.svc.GetSwapsService().GetSwap(swapId)
if err != nil {
logger.Logger.WithError(err).Error("failed to fetch swap info")
return nil, err
}
return toApiSwap(dbSwap), nil
}
func (api *api) ListSwaps() (*ListSwapsResponse, error) {
if api.svc.GetSwapsService() == nil {
return nil, errors.New("SwapsService not started")
}
swaps, err := api.svc.GetSwapsService().ListSwaps()
if err != nil {
return nil, err
}
apiSwaps := []Swap{}
for _, swap := range swaps {
apiSwaps = append(apiSwaps, *toApiSwap(&swap))
}
return &ListSwapsResponse{
Swaps: apiSwaps,
}, nil
}
func toApiSwap(swap *swaps.Swap) *Swap {
return &Swap{
Id: swap.SwapId,
Type: swap.Type,
State: swap.State,
Invoice: swap.Invoice,
SendAmount: swap.SendAmountSat,
SendAmountSat: swap.SendAmountSat,
ReceiveAmount: swap.ReceiveAmountSat,
ReceiveAmountSat: swap.ReceiveAmountSat,
PaymentHash: swap.PaymentHash,
DestinationAddress: swap.DestinationAddress,
RefundAddress: swap.RefundAddress,
LockupAddress: swap.LockupAddress,
LockupTxId: swap.LockupTxId,
ClaimTxId: swap.ClaimTxId,
AutoSwap: swap.AutoSwap,
BoltzPubkey: swap.BoltzPubkey,
CreatedAt: swap.CreatedAt.Format(time.RFC3339),
UpdatedAt: swap.UpdatedAt.Format(time.RFC3339),
UsedXpub: swap.UsedXpub,
}
}
func (api *api) GetSwapInInfo() (*SwapInfoResponse, error) {
if api.svc.GetSwapsService() == nil {
return nil, errors.New("SwapsService not started")
}
swapInInfo, err := api.svc.GetSwapsService().GetSwapInInfo()
if err != nil {
logger.Logger.WithError(err).Error("failed to calculate fee info")
return nil, err
}
return &SwapInfoResponse{
AlbyServiceFee: swapInInfo.AlbyServiceFee,
BoltzServiceFee: swapInInfo.BoltzServiceFee,
BoltzNetworkFee: swapInInfo.BoltzNetworkFeeSat,
BoltzNetworkFeeSat: swapInInfo.BoltzNetworkFeeSat,
MinAmount: swapInInfo.MinAmountSat,
MinAmountSat: swapInInfo.MinAmountSat,
MaxAmount: swapInInfo.MaxAmountSat,
MaxAmountSat: swapInInfo.MaxAmountSat,
}, nil
}
func (api *api) GetSwapOutInfo() (*SwapInfoResponse, error) {
if api.svc.GetSwapsService() == nil {
return nil, errors.New("SwapsService not started")
}
swapOutInfo, err := api.svc.GetSwapsService().GetSwapOutInfo()
if err != nil {
logger.Logger.WithError(err).Error("failed to calculate fee info")
return nil, err
}
return &SwapInfoResponse{
AlbyServiceFee: swapOutInfo.AlbyServiceFee,
BoltzServiceFee: swapOutInfo.BoltzServiceFee,
BoltzNetworkFee: swapOutInfo.BoltzNetworkFeeSat,
BoltzNetworkFeeSat: swapOutInfo.BoltzNetworkFeeSat,
MinAmount: swapOutInfo.MinAmountSat,
MinAmountSat: swapOutInfo.MinAmountSat,
MaxAmount: swapOutInfo.MaxAmountSat,
MaxAmountSat: swapOutInfo.MaxAmountSat,
}, nil
}
func (api *api) InitiateSwapOut(ctx context.Context, initiateSwapOutRequest *InitiateSwapRequest) (*swaps.SwapResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
if api.svc.GetSwapsService() == nil {
return nil, errors.New("SwapsService not started")
}
amountSat := uint64(0)
resolvedAmountSat := ResolveToSat(initiateSwapOutRequest.SwapAmountSat, nil, initiateSwapOutRequest.SwapAmount, nil)
if resolvedAmountSat != nil {
amountSat = *resolvedAmountSat
}
destination := initiateSwapOutRequest.Destination
if amountSat == 0 {
return nil, errors.New("invalid swap amount")
}
swapOutResponse, err := api.svc.GetSwapsService().SwapOut(amountSat, destination, false, false)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"amount_sat": amountSat,
"destination": destination,
}).WithError(err).Error("Failed to initiate swap out")
return nil, err
}
return swapOutResponse, nil
}
func (api *api) InitiateSwapIn(ctx context.Context, initiateSwapInRequest *InitiateSwapRequest) (*swaps.SwapResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
if api.svc.GetSwapsService() == nil {
return nil, errors.New("SwapsService not started")
}
amountSat := uint64(0)
resolvedAmountSat := ResolveToSat(initiateSwapInRequest.SwapAmountSat, nil, initiateSwapInRequest.SwapAmount, nil)
if resolvedAmountSat != nil {
amountSat = *resolvedAmountSat
}
if amountSat == 0 {
return nil, errors.New("invalid swap amount")
}
swapInResponse, err := api.svc.GetSwapsService().SwapIn(amountSat, false)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"amount_sat": amountSat,
}).WithError(err).Error("Failed to initiate swap in")
return nil, err
}
return swapInResponse, nil
}
func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *EnableAutoSwapRequest) error {
if api.svc.GetSwapsService() == nil {
return errors.New("SwapsService not started")
}
encryptionKey := ""
if enableAutoSwapsRequest.Destination != "" {
switch enableAutoSwapsRequest.DestinationType {
case "address":
if err := api.svc.GetSwapsService().ValidateAddress(enableAutoSwapsRequest.Destination); err != nil {
return err
}
case "xpub":
if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) {
return errors.New("invalid unlock password")
}
if err := api.svc.GetSwapsService().ValidateXpub(enableAutoSwapsRequest.Destination); err != nil {
return err
}
encryptionKey = enableAutoSwapsRequest.UnlockPassword
default:
return errors.New("destination type must be address or xpub")
}
}
balanceThresholdSat := uint64(0)
resolvedBalanceThresholdSat := ResolveToSat(enableAutoSwapsRequest.BalanceThresholdSat, nil, enableAutoSwapsRequest.BalanceThreshold, nil)
if resolvedBalanceThresholdSat != nil {
balanceThresholdSat = *resolvedBalanceThresholdSat
}
err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, strconv.FormatUint(balanceThresholdSat, 10), "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to save autoswap balance threshold to config")
return err
}
swapAmountSat := uint64(0)
resolvedSwapAmountSat := ResolveToSat(enableAutoSwapsRequest.SwapAmountSat, nil, enableAutoSwapsRequest.SwapAmount, nil)
if resolvedSwapAmountSat != nil {
swapAmountSat = *resolvedSwapAmountSat
}
err = api.cfg.SetUpdate(config.AutoSwapAmountKey, strconv.FormatUint(swapAmountSat, 10), "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to save autoswap amount to config")
return err
}
err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, encryptionKey)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save autoswap destination to config")
return err
}
return api.svc.GetSwapsService().EnableAutoSwapOut(enableAutoSwapsRequest.UnlockPassword)
}
func (api *api) DisableAutoSwap() error {
keys := []string{config.AutoSwapBalanceThresholdKey, config.AutoSwapAmountKey, config.AutoSwapDestinationKey}
for _, key := range keys {
if err := api.cfg.SetUpdate(key, "", ""); err != nil {
logger.Logger.WithError(err).Errorf("Failed to remove autoswap config for key: %s", key)
return err
}
}
if api.svc.GetSwapsService() != nil {
api.svc.GetSwapsService().StopAutoSwapOut()
}
return nil
}
func (api *api) GetSwapMnemonic() string {
return api.keys.GetSwapMnemonic()
}
func (api *api) GetNodeStatus(ctx context.Context) (*NodeStatus, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
nodeStatus, err := lnClient.GetNodeStatus(ctx)
if err != nil {
return nil, err
}
if nodeStatus == nil {
return nil, nil
}
return toApiNodeStatus(nodeStatus), nil
}
func toApiNodeStatus(nodeStatus *lnclient.NodeStatus) *NodeStatus {
return &NodeStatus{
IsReady: nodeStatus.IsReady,
InternalNodeStatus: nodeStatus.InternalNodeStatus,
}
}
func (api *api) ListPeers(ctx context.Context) ([]PeerDetails, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
peers, err := lnClient.ListPeers(ctx)
if err != nil {
return nil, err
}
apiPeers := make([]PeerDetails, 0, len(peers))
for _, peer := range peers {
apiPeers = append(apiPeers, PeerDetails{
NodeId: peer.NodeId,
Address: peer.Address,
IsPersisted: peer.IsPersisted,
IsConnected: peer.IsConnected,
})
}
return apiPeers, nil
}
func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return ErrLNClientNotStarted
}
return lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
Pubkey: connectPeerRequest.Pubkey,
Address: connectPeerRequest.Address,
Port: connectPeerRequest.Port,
})
}
func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
resp, err := lnClient.OpenChannel(ctx, &lnclient.OpenChannelRequest{
Pubkey: openChannelRequest.Pubkey,
AmountSats: openChannelRequest.AmountSats,
Public: openChannelRequest.Public,
})
if err != nil {
return nil, err
}
return &OpenChannelResponse{
FundingTxId: resp.FundingTxId,
}, nil
}
func (api *api) DisconnectPeer(ctx context.Context, peerId string) error {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return ErrLNClientNotStarted
}
logger.Logger.WithFields(logrus.Fields{
"peer_id": peerId,
}).Info("Disconnecting peer")
return lnClient.DisconnectPeer(ctx, peerId)
}
func (api *api) CloseChannel(ctx context.Context, peerId, channelId string, force bool) (*CloseChannelResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
logger.Logger.WithFields(logrus.Fields{
"peer_id": peerId,
"channel_id": channelId,
"force": force,
}).Info("Closing channel")
err := lnClient.CloseChannel(ctx, &lnclient.CloseChannelRequest{
NodeId: peerId,
ChannelId: channelId,
Force: force,
})
if err != nil {
return nil, err
}
return &CloseChannelResponse{}, nil
}
func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateChannelRequest) error {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return ErrLNClientNotStarted
}
logger.Logger.WithFields(logrus.Fields{
"request": updateChannelRequest,
}).Info("updating channel")
return lnClient.UpdateChannel(ctx, &lnclient.UpdateChannelRequest{
ChannelId: updateChannelRequest.ChannelId,
NodeId: updateChannelRequest.NodeId,
ForwardingFeeBaseMsat: updateChannelRequest.ForwardingFeeBaseMsat,
ForwardingFeeProportionalMillionths: updateChannelRequest.ForwardingFeeProportionalMillionths,
MaxDustHtlcExposureFromFeeRateMultiplier: updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier,
})
}
func (api *api) MakeOffer(ctx context.Context, description string) (string, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return "", ErrLNClientNotStarted
}
offer, err := lnClient.MakeOffer(ctx, description)
if err != nil {
return "", err
}
return offer, nil
}
func (api *api) GetNewOnchainAddress(ctx context.Context) (string, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return "", ErrLNClientNotStarted
}
address, err := lnClient.GetNewOnchainAddress(ctx)
if err != nil {
return "", err
}
err = api.cfg.SetUpdate(config.OnchainAddressKey, address, "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to save new onchain address to config")
}
return address, nil
}
func (api *api) GetUnusedOnchainAddress(ctx context.Context) (string, error) {
if api.svc.GetLNClient() == nil {
return "", ErrLNClientNotStarted
}
currentAddress, err := api.cfg.Get(config.OnchainAddressKey, "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to get current address from config")
return "", err
}
if currentAddress != "" {
// check if address has any transactions
response, err := api.RequestEsploraApi(ctx, "/address/"+currentAddress+"/txs")
if err != nil {
logger.Logger.WithError(err).Error("Failed to get current address transactions")
return currentAddress, nil
}
transactions, ok := response.([]interface{})
if !ok {
logger.Logger.WithField("response", response).Error("Failed to cast esplora address txs response", response)
return currentAddress, nil
}
if len(transactions) == 0 {
// address has not been used yet
return currentAddress, nil
}
}
newAddress, err := api.GetNewOnchainAddress(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to retrieve new onchain address")
return "", err
}
return newAddress, nil
}
func (api *api) SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
signature, err := lnClient.SignMessage(ctx, message)
if err != nil {
return nil, err
}
return &SignMessageResponse{
Message: message,
Signature: signature,
}, nil
}
func (api *api) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
txId, err := lnClient.RedeemOnchainFunds(ctx, toAddress, amountSat, feeRate, sendAll)
if err != nil {
return nil, err
}
return &RedeemOnchainFundsResponse{
TxId: txId,
}, nil
}
func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
balances, err := lnClient.GetBalances(ctx, false)
if err != nil {
return nil, err
}
return toApiBalances(balances), nil
}
func toApiBalances(balances *lnclient.BalancesResponse) *BalancesResponse {
totalSpendableMsat := balances.Lightning.TotalSpendableMsat
totalReceivableMsat := balances.Lightning.TotalReceivableMsat
nextMaxSpendableMsat := balances.Lightning.NextMaxSpendableMsat
nextMaxReceivableMsat := balances.Lightning.NextMaxReceivableMsat
nextMaxSpendableMPPMsat := balances.Lightning.NextMaxSpendableMPPMsat
nextMaxReceivableMPPMsat := balances.Lightning.NextMaxReceivableMPPMsat
return &BalancesResponse{
Onchain: OnchainBalanceResponse{
Spendable: balances.Onchain.SpendableSat,
SpendableSat: balances.Onchain.SpendableSat,
Total: balances.Onchain.TotalSat,
TotalSat: balances.Onchain.TotalSat,
Reserved: balances.Onchain.ReservedSat,
ReservedSat: balances.Onchain.ReservedSat,
PendingBalancesFromChannelClosures: balances.Onchain.PendingBalancesFromChannelClosuresSat,
PendingBalancesFromChannelClosuresSat: balances.Onchain.PendingBalancesFromChannelClosuresSat,
PendingBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingBalancesDetails),
PendingSweepBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingSweepBalancesDetails),
InternalBalances: balances.Onchain.InternalBalances,
},
Lightning: LightningBalanceResponse{
TotalSpendable: totalSpendableMsat,
TotalSpendableSat: totalSpendableMsat / 1000,
TotalSpendableMsat: totalSpendableMsat,
TotalReceivable: totalReceivableMsat,
TotalReceivableSat: totalReceivableMsat / 1000,
TotalReceivableMsat: totalReceivableMsat,
NextMaxSpendable: nextMaxSpendableMsat,
NextMaxSpendableSat: nextMaxSpendableMsat / 1000,
NextMaxSpendableMsat: nextMaxSpendableMsat,
NextMaxReceivable: nextMaxReceivableMsat,
NextMaxReceivableSat: nextMaxReceivableMsat / 1000,
NextMaxReceivableMsat: nextMaxReceivableMsat,
NextMaxSpendableMPP: nextMaxSpendableMPPMsat,
NextMaxSpendableMPPSat: nextMaxSpendableMPPMsat / 1000,
NextMaxSpendableMPPMsat: nextMaxSpendableMPPMsat,
NextMaxReceivableMPP: nextMaxReceivableMPPMsat,
NextMaxReceivableMPPSat: nextMaxReceivableMPPMsat / 1000,
NextMaxReceivableMPPMsat: nextMaxReceivableMPPMsat,
},
}
}
func toApiPendingBalanceDetails(details []lnclient.PendingBalanceDetails) []PendingBalanceDetails {
if details == nil {
return nil
}
apiDetails := make([]PendingBalanceDetails, 0, len(details))
for _, d := range details {
apiDetails = append(apiDetails, PendingBalanceDetails{
ChannelId: d.ChannelId,
NodeId: d.NodeId,
Amount: d.AmountSat,
AmountSat: d.AmountSat,
FundingTxId: d.FundingTxId,
FundingTxVout: d.FundingTxVout,
})
}
return apiDetails
}
// TODO: remove dependency on this endpoint
func (api *api) RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error) {
url := api.cfg.GetEnv().MempoolApi + endpoint
client := http.Client{
Timeout: time.Second * 10,
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to create http request")
return nil, err
}
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to send request")
return nil, err
}
defer res.Body.Close()
body, readErr := io.ReadAll(res.Body)
if readErr != 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 != http.StatusOK {
logger.Logger.WithFields(logrus.Fields{
"endpoint": endpoint,
"status_code": res.StatusCode,
"body": string(body),
}).Error("Mempool endpoint returned non-success code")
return nil, fmt.Errorf("mempool endpoint returned non-success code: %s", string(body))
}
var jsonContent interface{}
jsonErr := json.Unmarshal(body, &jsonContent)
if jsonErr != nil {
logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to deserialize json")
return nil, fmt.Errorf("failed to deserialize json %s %s", url, string(body))
}
return jsonContent, nil
}
func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
info := InfoResponse{}
backendType, _ := api.cfg.Get("LNBackendType", "")
ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
jitChannelsEnabled, _ := api.cfg.Get("JitChannelsEnabled", "")
autoUnlockPassword, _ := api.cfg.Get("AutoUnlockPassword", "")
setupCompleted, err := api.cfg.SetupCompleted()
if err != nil {
logger.Logger.WithError(err).Error("Failed to check if setup is completed")
return nil, err
}
info.SetupCompleted = setupCompleted
info.Currency = api.cfg.GetCurrency()
info.BitcoinDisplayFormat = api.cfg.GetBitcoinDisplayFormat()
info.StartupState = api.svc.GetStartupState()
if api.startupError != nil {
info.StartupError = api.startupError.Error()
info.StartupErrorTime = api.startupErrorTime
}
lnClient := api.svc.GetLNClient()
info.Running = lnClient != nil
info.BackendType = backendType
info.AlbyAuthUrl = api.albyOAuthSvc.GetAuthUrl()
info.OAuthRedirect = !api.cfg.GetEnv().IsDefaultClientId()
info.Version = version.Tag
info.EnableAdvancedSetup = api.cfg.GetEnv().EnableAdvancedSetup
info.HideUpdateBanner = api.cfg.GetEnv().HideUpdateBanner
info.LdkVssEnabled = ldkVssEnabled == "true"
info.JitChannelsEnabled = jitChannelsEnabled != "false"
info.VssSupported = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKVssUrl != ""
info.LdkVssUrl = api.cfg.GetEnv().LDKVssUrl
info.DatabaseType = api.db.Dialector.Name()
info.SupportsBolt12 = backendType == config.LDKBackendType || backendType == config.CLNBackendType
info.AutoUnlockPasswordEnabled = autoUnlockPassword != ""
info.AutoUnlockPasswordSupported = api.cfg.GetEnv().IsDefaultClientId()
info.Relays = []InfoResponseRelay{}
for _, relayStatus := range api.svc.GetRelayStatuses() {
info.Relays = append(info.Relays, InfoResponseRelay{
Url: relayStatus.Url,
Online: relayStatus.Online,
})
}
info.MempoolUrl = api.cfg.GetMempoolUrl()
info.AlbyAccountConnected = api.albyOAuthSvc.IsConnected(ctx)
albyUserIdentifier, err := api.albyOAuthSvc.GetUserIdentifier()
if err != nil {
logger.Logger.WithError(err).Error("Failed to get alby user identifier")
return nil, err
}
info.AlbyUserIdentifier = albyUserIdentifier
if lnClient != nil {
nodeInfo, err := lnClient.GetInfo(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to get nodeInfo")
return nil, err
}
info.Network = nodeInfo.Network
if backendType == config.LDKBackendType {
// Only LDK supports this right now. Using a local interface here
// so we don't have to bloat the main LNClient interface for everyone else.
type chainSourceProvider interface {
GetChainDataSource() (string, string)
}
type lsps2SourceProvider interface {
GetLiquiditySourceLsps2() string
}
type lsps2MinPaymentSizeProvider interface {
GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64
}
type lsps2MaxPaymentSizeProvider interface {
GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64
}
if ldkService, ok := api.svc.GetLNClient().(chainSourceProvider); ok {
info.ChainDataSourceType, info.ChainDataSourceAddress = ldkService.GetChainDataSource()
}
if ldkService, ok := api.svc.GetLNClient().(lsps2SourceProvider); ok {
info.JitChannelsLiquiditySource = ldkService.GetLiquiditySourceLsps2()
}
if ldkService, ok := api.svc.GetLNClient().(lsps2MinPaymentSizeProvider); ok {
info.JitChannelsMinPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MinPaymentSizeMsat()
}
if ldkService, ok := api.svc.GetLNClient().(lsps2MaxPaymentSizeProvider); ok {
info.JitChannelsMaxPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MaxPaymentSizeMsat()
}
}
}
info.NextBackupReminder, _ = api.cfg.Get("NextBackupReminder", "")
info.NodeAlias, _ = api.cfg.Get("NodeAlias", "")
return &info, nil
}
func (api *api) setCurrency(currency string) error {
if currency == "" {
return fmt.Errorf("currency value cannot be empty")
}
err := api.cfg.SetCurrency(currency)
if err != nil {
logger.Logger.WithError(err).Error("Failed to update currency")
return err
}
return nil
}
func (api *api) setBitcoinDisplayFormat(format string) error {
if format != constants.BITCOIN_DISPLAY_FORMAT_SATS && format != constants.BITCOIN_DISPLAY_FORMAT_BIP177 {
return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177)
}
err := api.cfg.SetBitcoinDisplayFormat(format)
if err != nil {
logger.Logger.WithError(err).Error("Failed to update bitcoin display format")
return err
}
return nil
}
func (api *api) setJitChannelsEnabled(enabled bool) error {
value := "true"
if !enabled {
value = "false"
}
err := api.cfg.SetUpdate("JitChannelsEnabled", value, "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to update JIT channels setting")
return err
}
return nil
}
func (api *api) UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error {
if updateSettingsRequest.Currency != "" {
err := api.setCurrency(updateSettingsRequest.Currency)
if err != nil {
return fmt.Errorf("failed to set currency: %w", err)
}
}
if updateSettingsRequest.BitcoinDisplayFormat != "" {
err := api.setBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat)
if err != nil {
return fmt.Errorf("failed to set bitcoin display format: %w", err)
}
}
if updateSettingsRequest.JitChannelsEnabled != nil {
err := api.setJitChannelsEnabled(*updateSettingsRequest.JitChannelsEnabled)
if err != nil {
return fmt.Errorf("failed to set JIT channels setting: %w", err)
}
}
return nil
}
func (api *api) SetNodeAlias(nodeAlias string) error {
err := api.cfg.SetUpdate("NodeAlias", nodeAlias, "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to save node alias to config")
return err
}
return nil
}
func (api *api) GetMnemonic(unlockPassword string) (*MnemonicResponse, error) {
if !api.cfg.CheckUnlockPassword(unlockPassword) {
return nil, fmt.Errorf("wrong password")
}
mnemonic, err := api.cfg.Get("Mnemonic", unlockPassword)
if err != nil {
return nil, fmt.Errorf("failed to fetch encryption key: %w", err)
}
resp := MnemonicResponse{
Mnemonic: mnemonic,
}
return &resp, nil
}
func (api *api) SetNextBackupReminder(backupReminderRequest *BackupReminderRequest) error {
err := api.cfg.SetUpdate("NextBackupReminder", backupReminderRequest.NextBackupReminder, "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to save next backup reminder to config")
}
return nil
}
var startMutex sync.Mutex
func (api *api) Start(startRequest *StartRequest) {
api.startupError = nil
err := api.startInternal(startRequest)
if err != nil {
logger.Logger.WithError(err).Error("Failed to start node")
api.startupError = err
api.startupErrorTime = time.Now()
}
}
func (api *api) startInternal(startRequest *StartRequest) (err error) {
if !startMutex.TryLock() {
// do not allow to start twice in case this is somehow called twice
return errors.New("app is busy")
}
defer startMutex.Unlock()
return api.svc.StartApp(startRequest.UnlockPassword)
}
func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
if !startMutex.TryLock() {
// do not allow to start twice in case this is somehow called twice
return errors.New("app is busy")
}
defer startMutex.Unlock()
info, err := api.GetInfo(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to get info")
return err
}
if info.SetupCompleted {
logger.Logger.Error("Cannot re-setup node")
return errors.New("setup already completed")
}
if setupRequest.UnlockPassword == "" {
return errors.New("no unlock password provided")
}
// Bark and Cashu both store wallet state on local disk, so they cannot
// run in environments without persistent volumes (e.g. Alby Cloud). Bark
// can recover spendable VTXOs from the mnemonic alone, but in-flight
// payment checkpoints and wallet metadata are local-only, so persistent
// storage is still required. The default OAuth client ID identifies a
// local / self-hosted deployment.
if !api.cfg.GetEnv().IsDefaultClientId() {
switch setupRequest.LNBackendType {
case config.BarkBackendType, config.CashuBackendType:
return fmt.Errorf("%s backend is not supported in this environment (no persistent storage)", setupRequest.LNBackendType)
}
}
err = api.cfg.SaveUnlockPasswordCheck(setupRequest.UnlockPassword)
if err != nil {
return err
}
// update next backup reminder
err = api.cfg.SetUpdate("NextBackupReminder", setupRequest.NextBackupReminder, "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to save next backup reminder")
}
// only update non-empty values
if setupRequest.LNBackendType != "" {
err = api.cfg.SetUpdate("LNBackendType", setupRequest.LNBackendType, "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to save backend type")
return err
}
}
if setupRequest.Mnemonic != "" {
err = api.cfg.SetUpdate("Mnemonic", setupRequest.Mnemonic, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save encrypted mnemonic")
return err
}
}
if setupRequest.LNDAddress != "" {
err = api.cfg.SetUpdate("LNDAddress", setupRequest.LNDAddress, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save lnd address")
return err
}
}
if setupRequest.LNDCertFile != "" {
// The file path is provided by the (unauthenticated) setup request, so
// only persist the content if it parses as a certificate. Storing the
// re-encoded certificate(s) guarantees nothing but the parsed structure
// reaches the database - e.g. a private key bundled in the same PEM file
// is dropped rather than persisted.
certHex, err := readAndCanonicalizeLNDCert(setupRequest.LNDCertFile)
if err != nil {
// Return a generic error and log the detail server-side so the
// response is not a file existence/readability oracle.
logger.Logger.WithError(err).Error("Failed to process lnd cert file")
return errors.New("invalid LND certificate file")
}
err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save lnd cert hex")
return err
}
}
if setupRequest.LNDMacaroonFile != "" {
// The file path is provided by the (unauthenticated) setup request, so
// only persist the content if it parses as a macaroon. Storing the
// re-marshalled macaroon guarantees only the parsed structure reaches
// the database.
macaroonHex, err := readAndCanonicalizeLNDMacaroon(setupRequest.LNDMacaroonFile)
if err != nil {
// Return a generic error and log the detail server-side so the
// response is not a file existence/readability oracle.
logger.Logger.WithError(err).Error("Failed to process lnd macaroon file")
return errors.New("invalid LND macaroon file")
}
err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex")
return err
}
}
if setupRequest.PhoenixdAddress != "" {
err = api.cfg.SetUpdate("PhoenixdAddress", setupRequest.PhoenixdAddress, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save phoenix address")
return err
}
}
if setupRequest.PhoenixdAuthorization != "" {
err = api.cfg.SetUpdate("PhoenixdAuthorization", setupRequest.PhoenixdAuthorization, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save phoenix auth")
return err
}
}
if setupRequest.CashuMintUrl != "" {
err = api.cfg.SetUpdate("CashuMintUrl", setupRequest.CashuMintUrl, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save cashu mint url")
return err
}
}
if setupRequest.CLNAddress != "" {
err = api.cfg.SetUpdate("CLNAddress", setupRequest.CLNAddress, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save CLN address")
return err
}
}
if setupRequest.CLNLightningDir != "" {
// The directory path is provided by the (unauthenticated) setup request.
// Validate that it holds the expected CLN TLS credentials before saving,
// so the path cannot be used as an existence/readability oracle for
// arbitrary directories (the failure otherwise surfaces via startupError
// on the anonymous /api/info response).
if err := validateCLNLightningDir(setupRequest.CLNLightningDir, setupRequest.CLNAddressHold != ""); err != nil {
logger.Logger.WithError(err).Error("Failed to validate CLN lightning directory")
return errors.New("invalid CLN lightning directory")
}
err = api.cfg.SetUpdate("CLNLightningDir", setupRequest.CLNLightningDir, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save CLN Lightning directory path")
return err
}
}
if setupRequest.CLNAddressHold != "" {
err = api.cfg.SetUpdate("CLNAddressHold", setupRequest.CLNAddressHold, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save cln hold plugin address")
return err
}
}
return nil
}
// readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path,
// validates that it contains at least one parseable certificate, and returns
// the hex-encoded re-encoding of only the parsed certificate(s). Any non
// CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are
// never persisted. Callers must not reflect the returned error to the client.
func readAndCanonicalizeLNDCert(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read LND cert file: %w", err)
}
var canonical []byte
rest := raw
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse LND certificate: %w", err)
}
canonical = append(canonical, pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
})...)
}
if len(canonical) == 0 {
return "", errors.New("no valid certificate found in LND cert file")
}
return hex.EncodeToString(canonical), nil
}
// readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path,
// validates that it is a well-formed macaroon, and returns the hex-encoded
// re-marshalling so that only the parsed structure is persisted. Callers must
// not reflect the returned error to the client.
func readAndCanonicalizeLNDMacaroon(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read LND macaroon file: %w", err)
}
mac := &macaroon.Macaroon{}
if err := mac.UnmarshalBinary(raw); err != nil {
return "", fmt.Errorf("failed to parse LND macaroon: %w", err)
}
canonical, err := mac.MarshalBinary()
if err != nil {
return "", fmt.Errorf("failed to marshal LND macaroon: %w", err)
}
return hex.EncodeToString(canonical), nil
}
// validateCLNLightningDir checks that the given directory holds the CLN TLS
// credentials that will later be loaded at connect time (ca.pem, client.pem,
// client-key.pem), for each gRPC server name the config will use. This mirrors
// the parses performed by the CLN client's loadTLSCredentials so a directory
// that passes here is one CLN can actually use. Callers must not reflect the
// returned error to the client.
func validateCLNLightningDir(lightningDir string, hold bool) error {
// "cln" reads the directory directly; other server names are joined as a
// subdirectory, matching loadTLSCredentials in lnclient/cln.
serverNames := []string{"cln"}
if hold {
serverNames = append(serverNames, "hold")
}
for _, serverName := range serverNames {
dir := lightningDir
if serverName != "cln" {
dir = filepath.Join(dir, serverName)
}
caPEM, err := os.ReadFile(filepath.Join(dir, "ca.pem"))
if err != nil {
return fmt.Errorf("failed to read CLN CA cert (%s): %w", serverName, err)
}
if !x509.NewCertPool().AppendCertsFromPEM(caPEM) {
return fmt.Errorf("failed to parse CLN CA cert (%s)", serverName)
}
if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "client.pem"), filepath.Join(dir, "client-key.pem")); err != nil {
return fmt.Errorf("failed to load CLN client cert/key (%s): %w", serverName, err)
}
}
return nil
}
func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
methods := lnClient.GetSupportedNIP47Methods()
notificationTypes := lnClient.GetSupportedNIP47NotificationTypes()
scopes, err := permissions.RequestMethodsToScopes(methods)
if err != nil {
return nil, err
}
if len(notificationTypes) > 0 {
scopes = append(scopes, constants.NOTIFICATIONS_SCOPE)
}
return &WalletCapabilitiesResponse{
Methods: methods,
NotificationTypes: notificationTypes,
Scopes: scopes,
}, nil
}
func (api *api) MigrateNodeStorage(ctx context.Context, to string) error {
if api.svc.GetLNClient() == nil {
return ErrLNClientNotStarted
}
if to != "VSS" {
return fmt.Errorf("migration type not supported: %s", to)
}
ldkVssEnabled, err := api.cfg.Get("LdkVssEnabled", "")
if err != nil {
return err
}
if ldkVssEnabled == "true" {
return errors.New("VSS already enabled")
}
if api.cfg.GetEnv().LDKVssUrl == "" {
return errors.New("no VSS URL set")
}
api.cfg.SetUpdate("LdkVssEnabled", "true", "")
api.cfg.SetUpdate("LdkMigrateStorage", "VSS", "")
return api.Stop()
}
func (api *api) GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
return lnClient.GetNetworkGraph(ctx, nodeIds)
}
func (api *api) SyncWallet() error {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return ErrLNClientNotStarted
}
lnClient.UpdateLastWalletSyncRequest()
return nil
}
func (api *api) ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
transactions, err := lnClient.ListOnchainTransactions(ctx)
if err != nil {
return nil, err
}
apiTransactions := make([]OnchainTransaction, 0, len(transactions))
for _, t := range transactions {
apiTransactions = append(apiTransactions, OnchainTransaction{
AmountSat: t.AmountSat,
CreatedAt: t.CreatedAt,
State: t.State,
Type: t.Type,
NumConfirmations: t.NumConfirmations,
TxId: t.TxId,
})
}
return apiTransactions, nil
}
func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) {
var err error
var logData []byte
if logType == LogTypeNode {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
logData, err = lnClient.GetLogOutput(ctx, getLogRequest.MaxLen)
if err != nil {
return nil, err
}
} else if logType == LogTypeApp {
logFileName := logger.GetLogFilePath()
if logFileName == "" {
logData = []byte("file log is disabled")
} else {
logData, err = utils.ReadFileTail(logFileName, getLogRequest.MaxLen)
if err != nil {
return nil, err
}
}
} else {
return nil, fmt.Errorf("invalid log type: '%s'", logType)
}
return &GetLogOutputResponse{Log: string(logData)}, nil
}
func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
var alarms []HealthAlarm
albyInfo, err := api.albySvc.GetInfo(ctx)
if err != nil {
return nil, err
}
if !albyInfo.Healthy {
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindAlbyService, albyInfo.Incidents))
}
relayStatuses := api.svc.GetRelayStatuses()
if len(relayStatuses) > 0 {
isAnyNostrRelayOffline := false
offlineRelayUrls := []string{}
for _, relayStatus := range relayStatuses {
if !relayStatus.Online {
isAnyNostrRelayOffline = true
offlineRelayUrls = append(offlineRelayUrls, relayStatus.Url)
}
}
if isAnyNostrRelayOffline {
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNostrRelayOffline, offlineRelayUrls))
}
}
ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
if ldkVssEnabled == "true" {
albyMe, err := api.albyOAuthSvc.GetMe(ctx)
if err != nil {
return nil, err
}
if albyMe.Subscription.PlanCode == "" {
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindVssNoSubscription, nil))
}
}
lnClient := api.svc.GetLNClient()
if lnClient != nil {
nodeStatus, _ := lnClient.GetNodeStatus(ctx)
if nodeStatus == nil || !nodeStatus.IsReady {
var apiNodeStatus *NodeStatus
if nodeStatus != nil {
apiNodeStatus = toApiNodeStatus(nodeStatus)
}
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, apiNodeStatus))
}
channels, err := lnClient.ListChannels(ctx)
if err != nil {
return nil, err
}
offlineChannels := slices.DeleteFunc(channels, func(channel lnclient.Channel) bool {
if channel.Active {
return true
}
if channel.Confirmations == nil || channel.ConfirmationsRequired == nil {
return false
}
return *channel.Confirmations < *channel.ConfirmationsRequired
})
if len(offlineChannels) > 0 {
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindChannelsOffline, nil))
}
}
return &HealthResponse{Alarms: alarms}, nil
}
func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
commandDefs := make([]CustomNodeCommandDef, 0, len(allCommandDefs))
for _, commandDef := range allCommandDefs {
argDefs := make([]CustomNodeCommandArgDef, 0, len(commandDef.Args))
for _, argDef := range commandDef.Args {
argDefs = append(argDefs, CustomNodeCommandArgDef{
Name: argDef.Name,
Description: argDef.Description,
})
}
commandDefs = append(commandDefs, CustomNodeCommandDef{
Name: commandDef.Name,
Description: commandDef.Description,
Args: argDefs,
})
}
return &CustomNodeCommandsResponse{Commands: commandDefs}, nil
}
func (api *api) ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
// Split command line into arguments. Command name must be the first argument.
parsedArgs, err := utils.ParseCommandLine(command)
if err != nil {
return nil, fmt.Errorf("failed to parse node command: %w", err)
} else if len(parsedArgs) == 0 {
return nil, errors.New("no command provided")
}
// Look up the requested command definition.
allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.CustomNodeCommandDef) bool {
return def.Name == parsedArgs[0]
})
if commandDefIdx < 0 {
return nil, fmt.Errorf("unknown command: %q", parsedArgs[0])
}
// Build flag set.
commandDef := allCommandDefs[commandDefIdx]
flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError)
for _, argDef := range commandDef.Args {
flagSet.String(argDef.Name, "", argDef.Description)
}
if err = flagSet.Parse(parsedArgs[1:]); err != nil {
return nil, fmt.Errorf("failed to parse command arguments: %w", err)
}
// Collect flags that have been set.
argValues := make(map[string]string)
flagSet.Visit(func(f *flag.Flag) {
argValues[f.Name] = f.Value.String()
})
reqArgs := make([]lnclient.CustomNodeCommandArg, 0, len(argValues))
for _, argDef := range commandDef.Args {
if argValue, ok := argValues[argDef.Name]; ok {
reqArgs = append(reqArgs, lnclient.CustomNodeCommandArg{
Name: argDef.Name,
Value: argValue,
})
}
}
nodeResp, err := lnClient.ExecuteCustomNodeCommand(ctx, &lnclient.CustomNodeCommandRequest{
Name: commandDef.Name,
Args: reqArgs,
})
if err != nil {
return nil, fmt.Errorf("node failed to execute custom command: %w", err)
}
return nodeResp.Response, nil
}
func (api *api) SendEvent(event string, properties interface{}) {
api.svc.GetEventPublisher().Publish(&events.Event{
Event: event,
Properties: properties,
})
}
func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
var expiresAt *time.Time
if expiresAtString != "" {
var err error
expiresAtValue, err := time.Parse(time.RFC3339, expiresAtString)
if err != nil {
logger.Logger.WithField("expiresAt", expiresAtString).Error("Invalid expiresAt")
return nil, fmt.Errorf("invalid expiresAt: %v", err)
}
expiresAt = &expiresAtValue
}
return expiresAt, nil
}
func (api *api) GetForwards() (*GetForwardsResponse, error) {
var forwards []db.Forward
err := api.db.Find(&forwards).Error
if err != nil {
return nil, err
}
var totalOutboundAmountMsat uint64
var totalFeeEarnedMsat uint64
for _, forward := range forwards {
totalOutboundAmountMsat += forward.OutboundAmountForwardedMsat
totalFeeEarnedMsat += forward.TotalFeeEarnedMsat
}
numForwards := len(forwards)
return &GetForwardsResponse{
OutboundAmountForwardedSat: totalOutboundAmountMsat / 1000,
OutboundAmountForwardedMsat: totalOutboundAmountMsat,
TotalFeeEarnedSat: totalFeeEarnedMsat / 1000,
TotalFeeEarnedMsat: totalFeeEarnedMsat,
NumForwards: uint64(numForwards),
}, nil
}