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" "sync/atomic" "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 // set after a migration file is created; the hub is halted at that point // and the frontend should keep showing the migration success page nodeMigrationFileCreated atomic.Bool } 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 responseBody.ReturnTo = buildReturnToUrl(createAppRequest.ReturnTo, relayUrls, *app.WalletPubkey, lightningAddress, app.Isolated) 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 } // buildReturnToUrl adds the connection query parameters to the return_to // URL the user will be redirected to. Only http and https URLs are accepted. func buildReturnToUrl(returnTo string, relayUrls []string, walletPubkey string, lightningAddress string, isolated bool) string { if returnTo == "" { return "" } returnToUrl, err := url.Parse(returnTo) if err != nil || (returnToUrl.Scheme != "http" && returnToUrl.Scheme != "https") { return "" } query := returnToUrl.Query() for _, relayUrl := range relayUrls { query.Add("relay", relayUrl) } query.Add("pubkey", walletPubkey) if lightningAddress != "" && !isolated { query.Add("lud16", lightningAddress) } returnToUrl.RawQuery = query.Encode() return returnToUrl.String() } 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{} if api.nodeMigrationFileCreated.Load() { // the hub is halted and the database is closed after a migration file // is created, so return a minimal response without reading any config // or node state; the frontend only needs the flag to keep showing the // migration success page info.NodeMigrationFileCreated = true info.SetupCompleted = true info.Version = version.Tag info.Relays = []InfoResponseRelay{} return &info, nil } 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.NodeMigrationFileCreated = api.nodeMigrationFileCreated.Load() 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 }