api.go raw

   1  package api
   2  
   3  import (
   4  	"context"
   5  	"crypto/tls"
   6  	"crypto/x509"
   7  	"encoding/hex"
   8  	"encoding/json"
   9  	"encoding/pem"
  10  	"errors"
  11  	"flag"
  12  	"fmt"
  13  	"io"
  14  	"net/http"
  15  	"net/url"
  16  	"os"
  17  	"path/filepath"
  18  	"slices"
  19  	"strconv"
  20  	"strings"
  21  	"sync"
  22  	"sync/atomic"
  23  	"time"
  24  
  25  	"github.com/sirupsen/logrus"
  26  	"gopkg.in/macaroon.v2"
  27  	"gorm.io/datatypes"
  28  	"gorm.io/gorm"
  29  
  30  	"github.com/getAlby/hub/alby"
  31  	"github.com/getAlby/hub/apps"
  32  	"github.com/getAlby/hub/config"
  33  	"github.com/getAlby/hub/constants"
  34  	"github.com/getAlby/hub/db"
  35  	"github.com/getAlby/hub/db/queries"
  36  	"github.com/getAlby/hub/events"
  37  	"github.com/getAlby/hub/lnclient"
  38  	"github.com/getAlby/hub/logger"
  39  	permissions "github.com/getAlby/hub/nip47/permissions"
  40  	"github.com/getAlby/hub/service"
  41  	"github.com/getAlby/hub/service/keys"
  42  	"github.com/getAlby/hub/swaps"
  43  	"github.com/getAlby/hub/utils"
  44  	"github.com/getAlby/hub/version"
  45  )
  46  
  47  type api struct {
  48  	db               *gorm.DB
  49  	appsSvc          apps.AppsService
  50  	cfg              config.Config
  51  	svc              service.Service
  52  	permissionsSvc   permissions.PermissionsService
  53  	keys             keys.Keys
  54  	albyOAuthSvc     alby.AlbyOAuthService
  55  	albySvc          alby.AlbyService
  56  	startupError     error
  57  	startupErrorTime time.Time
  58  	eventPublisher   events.EventPublisher
  59  	// set after a migration file is created; the hub is halted at that point
  60  	// and the frontend should keep showing the migration success page
  61  	nodeMigrationFileCreated atomic.Bool
  62  }
  63  
  64  func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albySvc alby.AlbyService, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api {
  65  	return &api{
  66  		db:             gormDB,
  67  		appsSvc:        apps.NewAppsService(gormDB, eventPublisher, keys, config),
  68  		cfg:            config,
  69  		svc:            svc,
  70  		permissionsSvc: permissions.NewPermissionsService(gormDB, eventPublisher),
  71  		keys:           keys,
  72  		albySvc:        albySvc,
  73  		albyOAuthSvc:   albyOAuthSvc,
  74  		eventPublisher: eventPublisher,
  75  	}
  76  }
  77  
  78  func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppResponse, error) {
  79  	if slices.Contains(createAppRequest.Scopes, constants.SUPERUSER_SCOPE) {
  80  		if !api.cfg.CheckUnlockPassword(createAppRequest.UnlockPassword) {
  81  			return nil, fmt.Errorf(
  82  				"incorrect unlock password to create app with superuser permission")
  83  		}
  84  	}
  85  
  86  	maxAmountSat := uint64(0)
  87  	resolvedMaxAmountSat := ResolveToSat(createAppRequest.MaxAmountSat, createAppRequest.MaxAmountMsat, createAppRequest.MaxAmount, nil)
  88  	if resolvedMaxAmountSat != nil {
  89  		maxAmountSat = *resolvedMaxAmountSat
  90  	}
  91  
  92  	if createAppRequest.Name == alby.ALBY_ACCOUNT_APP_NAME {
  93  		return nil, fmt.Errorf("Reserved app name: %s", alby.ALBY_ACCOUNT_APP_NAME)
  94  	}
  95  
  96  	expiresAt, err := api.parseExpiresAt(createAppRequest.ExpiresAt)
  97  	if err != nil {
  98  		return nil, fmt.Errorf("invalid expiresAt: %v", err)
  99  	}
 100  
 101  	for _, scope := range createAppRequest.Scopes {
 102  		if !slices.Contains(permissions.AllScopes(), scope) {
 103  			return nil, fmt.Errorf("did not recognize requested scope: %s", scope)
 104  		}
 105  	}
 106  
 107  	app, pairingSecretKey, err := api.appsSvc.CreateApp(
 108  		createAppRequest.Name,
 109  		createAppRequest.Pubkey,
 110  		maxAmountSat,
 111  		createAppRequest.BudgetRenewal,
 112  		expiresAt,
 113  		createAppRequest.Scopes,
 114  		createAppRequest.Isolated,
 115  		createAppRequest.Metadata,
 116  	)
 117  
 118  	if err != nil {
 119  		return nil, err
 120  	}
 121  
 122  	relayUrls := api.cfg.GetRelayUrls()
 123  
 124  	lightningAddress, err := api.albyOAuthSvc.GetLightningAddress()
 125  	if err != nil {
 126  		return nil, err
 127  	}
 128  
 129  	responseBody := &CreateAppResponse{}
 130  	responseBody.Id = app.ID
 131  	responseBody.Name = app.Name
 132  	responseBody.Pubkey = app.AppPubkey
 133  	responseBody.PairingSecret = pairingSecretKey
 134  	responseBody.WalletPubkey = *app.WalletPubkey
 135  	responseBody.RelayUrls = relayUrls
 136  	responseBody.Lud16 = lightningAddress
 137  
 138  	responseBody.ReturnTo = buildReturnToUrl(createAppRequest.ReturnTo, relayUrls, *app.WalletPubkey, lightningAddress, app.Isolated)
 139  
 140  	var lud16 string
 141  	if lightningAddress != "" && !app.Isolated {
 142  		lud16 = fmt.Sprintf("&lud16=%s", lightningAddress)
 143  	}
 144  	responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", *app.WalletPubkey, strings.Join(relayUrls, "&relay="), pairingSecretKey, lud16)
 145  
 146  	return responseBody, nil
 147  }
 148  
 149  // buildReturnToUrl adds the connection query parameters to the return_to
 150  // URL the user will be redirected to. Only http and https URLs are accepted.
 151  func buildReturnToUrl(returnTo string, relayUrls []string, walletPubkey string, lightningAddress string, isolated bool) string {
 152  	if returnTo == "" {
 153  		return ""
 154  	}
 155  	returnToUrl, err := url.Parse(returnTo)
 156  	if err != nil || (returnToUrl.Scheme != "http" && returnToUrl.Scheme != "https") {
 157  		return ""
 158  	}
 159  	query := returnToUrl.Query()
 160  	for _, relayUrl := range relayUrls {
 161  		query.Add("relay", relayUrl)
 162  	}
 163  	query.Add("pubkey", walletPubkey)
 164  	if lightningAddress != "" && !isolated {
 165  		query.Add("lud16", lightningAddress)
 166  	}
 167  	returnToUrl.RawQuery = query.Encode()
 168  	return returnToUrl.String()
 169  }
 170  
 171  func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error {
 172  	resolvedMaxAmountSat := ResolveToSat(updateAppRequest.MaxAmountSat, updateAppRequest.MaxAmountMsat, updateAppRequest.MaxAmount, nil)
 173  
 174  	err := api.db.Transaction(func(tx *gorm.DB) error {
 175  		// Initialize name with current app name, update if provided
 176  		name := userApp.Name
 177  
 178  		// Update app name if provided and different
 179  		if updateAppRequest.Name != nil {
 180  			name = *updateAppRequest.Name
 181  
 182  			if name == "" {
 183  				return fmt.Errorf("won't update an app to have no name")
 184  			}
 185  			if name != userApp.Name {
 186  				err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("name", name).Error
 187  				if err != nil {
 188  					return err
 189  				}
 190  			}
 191  		}
 192  
 193  		// Update app isolation if provided and different
 194  		if updateAppRequest.Isolated != nil {
 195  			isolated := *updateAppRequest.Isolated
 196  			if isolated != userApp.Isolated {
 197  				if !isolated {
 198  					var existingMetadata Metadata
 199  					if userApp.Metadata != nil {
 200  						err := json.Unmarshal(userApp.Metadata, &existingMetadata)
 201  						if err != nil {
 202  							logger.Logger.WithError(err).WithFields(logrus.Fields{
 203  								"app_id": userApp.ID,
 204  							}).Error("Failed to deserialize app metadata")
 205  							return err
 206  						}
 207  						if existingMetadata[constants.METADATA_APPSTORE_APP_ID_KEY] == constants.SUBWALLET_APPSTORE_APP_ID {
 208  							return errors.New("Cannot update sub-wallet to be non-isolated")
 209  						}
 210  					}
 211  				}
 212  
 213  				err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("isolated", isolated).Error
 214  				if err != nil {
 215  					return err
 216  				}
 217  			}
 218  		}
 219  
 220  		// Update the app metadata if provided
 221  		if updateAppRequest.Metadata != nil {
 222  			var metadataBytes []byte
 223  			var err error
 224  			metadataBytes, err = json.Marshal(*updateAppRequest.Metadata)
 225  			if err != nil {
 226  				logger.Logger.WithError(err).Error("Failed to serialize metadata")
 227  				return err
 228  			}
 229  			err = tx.Model(&db.App{}).Where("id", userApp.ID).Update("metadata", datatypes.JSON(metadataBytes)).Error
 230  			if err != nil {
 231  				return err
 232  			}
 233  		}
 234  
 235  		// Handle permissions updates only if any permission-related field is provided
 236  		if updateAppRequest.Scopes != nil || resolvedMaxAmountSat != nil ||
 237  			updateAppRequest.BudgetRenewal != nil || updateAppRequest.ExpiresAt != nil || updateAppRequest.UpdateExpiresAt {
 238  
 239  			// Get current values or use provided ones
 240  			var maxAmountSat uint64
 241  			var budgetRenewal string
 242  			var expiresAt *time.Time
 243  
 244  			// Get existing permissions to use as defaults
 245  			var existingPermissions []db.AppPermission
 246  			if err := tx.Where("app_id = ?", userApp.ID).Find(&existingPermissions).Error; err != nil {
 247  				return err
 248  			}
 249  
 250  			// Use existing values as defaults
 251  			if len(existingPermissions) > 0 {
 252  				// Find pay_invoice permission for budget-related fields
 253  				for _, perm := range existingPermissions {
 254  					if perm.Scope == constants.PAY_INVOICE_SCOPE {
 255  						maxAmountSat = uint64(perm.MaxAmountSat)
 256  						budgetRenewal = perm.BudgetRenewal
 257  						expiresAt = perm.ExpiresAt
 258  						break
 259  					}
 260  				}
 261  			}
 262  
 263  			// Override with provided values
 264  			if resolvedMaxAmountSat != nil {
 265  				maxAmountSat = *resolvedMaxAmountSat
 266  			}
 267  			if updateAppRequest.BudgetRenewal != nil {
 268  				budgetRenewal = *updateAppRequest.BudgetRenewal
 269  			}
 270  			if updateAppRequest.ExpiresAt != nil {
 271  				parsedExpiresAt, err := api.parseExpiresAt(*updateAppRequest.ExpiresAt)
 272  				if err != nil {
 273  					return fmt.Errorf("invalid expiresAt: %v", err)
 274  				}
 275  				expiresAt = parsedExpiresAt
 276  			}
 277  			if updateAppRequest.ExpiresAt == nil && updateAppRequest.UpdateExpiresAt {
 278  				expiresAt = nil
 279  			}
 280  
 281  			// Update existing permissions with new budget and expiry
 282  			err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{
 283  				"ExpiresAt":     expiresAt,
 284  				"MaxAmountSat":  maxAmountSat,
 285  				"BudgetRenewal": budgetRenewal,
 286  			}).Error
 287  			if err != nil {
 288  				return err
 289  			}
 290  
 291  			// Handle scope changes only if scopes were provided
 292  			if updateAppRequest.Scopes != nil {
 293  
 294  				if len(updateAppRequest.Scopes) == 0 {
 295  					return fmt.Errorf("won't update an app to have no request methods")
 296  				}
 297  
 298  				existingScopeMap := make(map[string]bool)
 299  				for _, perm := range existingPermissions {
 300  					existingScopeMap[perm.Scope] = true
 301  				}
 302  
 303  				if slices.Contains(updateAppRequest.Scopes, constants.SUPERUSER_SCOPE) && !existingScopeMap[constants.SUPERUSER_SCOPE] {
 304  					return fmt.Errorf("cannot update app to add superuser permission")
 305  				}
 306  
 307  				// Add new permissions
 308  				for _, scope := range updateAppRequest.Scopes {
 309  					if !existingScopeMap[scope] {
 310  						perm := db.AppPermission{
 311  							App:           *userApp,
 312  							Scope:         scope,
 313  							ExpiresAt:     expiresAt,
 314  							MaxAmountSat:  int(maxAmountSat),
 315  							BudgetRenewal: budgetRenewal,
 316  						}
 317  						if err := tx.Create(&perm).Error; err != nil {
 318  							return err
 319  						}
 320  					}
 321  					delete(existingScopeMap, scope)
 322  				}
 323  
 324  				// Remove old permissions
 325  				for scope := range existingScopeMap {
 326  					if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, scope).Delete(&db.AppPermission{}).Error; err != nil {
 327  						return err
 328  					}
 329  				}
 330  			}
 331  		}
 332  
 333  		// Publish update event
 334  		api.svc.GetEventPublisher().Publish(&events.Event{
 335  			Event: "nwc_app_updated",
 336  			Properties: map[string]interface{}{
 337  				"name": name,
 338  				"id":   userApp.ID,
 339  			},
 340  		})
 341  
 342  		// commit transaction
 343  		return nil
 344  	})
 345  
 346  	return err
 347  }
 348  
 349  func (api *api) DeleteApp(userApp *db.App) error {
 350  	// Delete lightning address if one exists
 351  	if api.appsSvc.HasLightningAddress(userApp) {
 352  		err := api.DeleteLightningAddress(context.Background(), userApp.ID)
 353  		if err != nil {
 354  			logger.Logger.WithError(err).WithFields(logrus.Fields{
 355  				"app_id": userApp.ID,
 356  			}).Error("Failed to delete lightning address during app deletion")
 357  		}
 358  	}
 359  
 360  	return api.appsSvc.DeleteApp(userApp)
 361  }
 362  
 363  func (api *api) CreateLightningAddress(ctx context.Context, createLightningAddressRequest *CreateLightningAddressRequest) error {
 364  	app := api.appsSvc.GetAppById(createLightningAddressRequest.AppId)
 365  	if app == nil {
 366  		return errors.New("app not found")
 367  	}
 368  
 369  	var metadata map[string]interface{}
 370  	err := json.Unmarshal(app.Metadata, &metadata)
 371  	if err != nil {
 372  		logger.Logger.WithError(err).WithFields(logrus.Fields{
 373  			"app_id": app.ID,
 374  		}).Error("Failed to deserialize app metadata")
 375  		return err
 376  	}
 377  
 378  	createLightningAddressResponse, err := api.albyOAuthSvc.CreateLightningAddress(ctx, createLightningAddressRequest.Address, createLightningAddressRequest.AppId)
 379  
 380  	if err != nil {
 381  		logger.Logger.WithError(err).Error("Failed to create lightning address for app")
 382  		return err
 383  	}
 384  
 385  	metadata["lud16"] = createLightningAddressResponse.FullAddress
 386  	err = api.appsSvc.SetAppMetadata(app.ID, metadata)
 387  	if err != nil {
 388  		logger.Logger.WithError(err).Error("Failed to add lightning address to app metadata")
 389  		return err
 390  	}
 391  	return nil
 392  }
 393  
 394  func (api *api) DeleteLightningAddress(ctx context.Context, appId uint) error {
 395  	app := api.appsSvc.GetAppById(appId)
 396  	if app == nil {
 397  		return errors.New("app not found")
 398  	}
 399  
 400  	var metadata map[string]interface{}
 401  	err := json.Unmarshal(app.Metadata, &metadata)
 402  	if err != nil {
 403  		logger.Logger.WithError(err).WithFields(logrus.Fields{
 404  			"app_id": app.ID,
 405  		}).Error("Failed to deserialize app metadata")
 406  		return err
 407  	}
 408  
 409  	if metadata["lud16"] == nil {
 410  		return errors.New("no lightning address set")
 411  	}
 412  
 413  	lud16 := metadata["lud16"].(string)
 414  	if !strings.Contains(lud16, "@") {
 415  		return errors.New("invalid lightning address")
 416  	}
 417  	address := strings.Split(lud16, "@")[0]
 418  
 419  	// Call the Alby OAuth service to delete the lightning address
 420  	err = api.albyOAuthSvc.DeleteLightningAddress(ctx, address)
 421  	if err != nil {
 422  		logger.Logger.WithError(err).Error("Failed to delete lightning address for app")
 423  		return err
 424  	}
 425  
 426  	delete(metadata, "lud16")
 427  	err = api.appsSvc.SetAppMetadata(app.ID, metadata)
 428  	if err != nil {
 429  		logger.Logger.WithError(err).Error("Failed to remove lightning address from app metadata")
 430  		return err
 431  	}
 432  
 433  	return nil
 434  }
 435  
 436  func (api *api) GetApp(dbApp *db.App) (*App, error) {
 437  
 438  	paySpecificPermission := db.AppPermission{}
 439  	appPermissions := []db.AppPermission{}
 440  	var expiresAt *time.Time
 441  	if err := api.db.Where("app_id = ?", dbApp.ID).Find(&appPermissions).Error; err != nil {
 442  		logger.Logger.WithError(err).WithFields(logrus.Fields{
 443  			"app_id": dbApp.ID,
 444  		}).Error("Failed to list app permissions")
 445  		return nil, err
 446  	}
 447  
 448  	requestMethods := []string{}
 449  	for _, appPerm := range appPermissions {
 450  		expiresAt = appPerm.ExpiresAt
 451  		if appPerm.Scope == constants.PAY_INVOICE_SCOPE {
 452  			// find the pay_invoice-specific permissions
 453  			paySpecificPermission = appPerm
 454  		}
 455  		requestMethods = append(requestMethods, appPerm.Scope)
 456  	}
 457  
 458  	// renewsIn := ""
 459  	maxAmountSat := uint64(paySpecificPermission.MaxAmountSat)
 460  	budgetUsageMsat, err := queries.GetBudgetUsageMsat(api.db, &paySpecificPermission)
 461  	if err != nil {
 462  		logger.Logger.WithError(err).WithFields(logrus.Fields{
 463  			"app_id": dbApp.ID,
 464  		}).Error("Failed to get budget usage for app")
 465  		return nil, err
 466  	}
 467  
 468  	var metadata Metadata
 469  	if dbApp.Metadata != nil {
 470  		jsonErr := json.Unmarshal(dbApp.Metadata, &metadata)
 471  		if jsonErr != nil {
 472  			logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
 473  				"app_id": dbApp.ID,
 474  			}).Error("Failed to deserialize app metadata")
 475  		}
 476  	}
 477  
 478  	walletPubkey := api.keys.GetNostrPublicKey()
 479  	uniqueWalletPubkey := false
 480  	if dbApp.WalletPubkey != nil {
 481  		walletPubkey = *dbApp.WalletPubkey
 482  		uniqueWalletPubkey = true
 483  	}
 484  
 485  	response := App{
 486  		ID:                       dbApp.ID,
 487  		Name:                     dbApp.Name,
 488  		Description:              dbApp.Description,
 489  		CreatedAt:                dbApp.CreatedAt,
 490  		UpdatedAt:                dbApp.UpdatedAt,
 491  		AppPubkey:                dbApp.AppPubkey,
 492  		ExpiresAt:                expiresAt,
 493  		MaxAmount:                maxAmountSat,
 494  		MaxAmountSat:             maxAmountSat,
 495  		MaxAmountMsat:            maxAmountSat * 1000,
 496  		Scopes:                   requestMethods,
 497  		BudgetUsage:              budgetUsageMsat / 1000,
 498  		BudgetUsageSat:           budgetUsageMsat / 1000,
 499  		BudgetUsageMsat:          budgetUsageMsat,
 500  		BudgetRenewal:            paySpecificPermission.BudgetRenewal,
 501  		Isolated:                 dbApp.Isolated,
 502  		Metadata:                 metadata,
 503  		WalletPubkey:             walletPubkey,
 504  		UniqueWalletPubkey:       uniqueWalletPubkey,
 505  		LastUsedAt:               dbApp.LastUsedAt,
 506  		LastSettledTransactionAt: dbApp.LastSettledTransactionAt,
 507  	}
 508  
 509  	if dbApp.Isolated {
 510  		balanceMsat, err := queries.GetIsolatedBalanceMsat(api.db, dbApp.ID)
 511  		if err != nil {
 512  			logger.Logger.WithError(err).WithFields(logrus.Fields{
 513  				"app_id": dbApp.ID,
 514  			}).Error("Failed to get isolated app balance")
 515  			return nil, err
 516  		}
 517  		response.Balance = balanceMsat
 518  		response.BalanceSat = balanceMsat / 1000
 519  		response.BalanceMsat = balanceMsat
 520  	}
 521  
 522  	return &response, nil
 523  }
 524  
 525  func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error) {
 526  	// TODO: join dbApps and permissions
 527  	dbApps := []db.App{}
 528  	query := api.db
 529  
 530  	if filters.Name != "" {
 531  		// searching for "Damus" will return "Damus" and "Damus (1)"
 532  		// Use case-insensitive search for both SQLite and PostgreSQL
 533  		if api.db.Dialector.Name() == "postgres" {
 534  			query = query.Where("name ILIKE ?", filters.Name+"%")
 535  		} else {
 536  			query = query.Where("name LIKE ?", filters.Name+"%")
 537  		}
 538  	}
 539  
 540  	if filters.AppStoreAppId != "" {
 541  		query = query.Where(datatypes.JSONQuery("metadata").Equals(filters.AppStoreAppId, constants.METADATA_APPSTORE_APP_ID_KEY))
 542  	}
 543  
 544  	if filters.Unused {
 545  		// find unused non-subwallet apps not used in the past 60 days
 546  		query = query.Where("last_used_at IS NULL OR last_used_at < ?", time.Now().Add(-60*24*time.Hour))
 547  	}
 548  
 549  	if filters.SubWallets != nil {
 550  		if *filters.SubWallets {
 551  			query = query.Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY))
 552  		} else {
 553  			// exclude subwallets :scream:
 554  			if api.db.Dialector.Name() == "sqlite" {
 555  				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)
 556  			} else {
 557  				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)
 558  			}
 559  		}
 560  	}
 561  
 562  	query = query.Order(resolveAppOrderBy(orderBy))
 563  
 564  	if limit == 0 {
 565  		limit = 100
 566  	}
 567  	var totalCount int64
 568  	result := query.Model(&db.App{}).Count(&totalCount)
 569  	if result.Error != nil {
 570  		logger.Logger.WithError(result.Error).Error("Failed to count DB apps")
 571  		return nil, result.Error
 572  	}
 573  
 574  	var totalBalance *int64
 575  	var totalBalanceSat *int64
 576  	if filters.SubWallets != nil && *filters.SubWallets {
 577  		totalBalanceMsat, err := queries.GetTotalSubwalletBalanceMsat(api.db)
 578  		if err != nil {
 579  			logger.Logger.WithError(err).Error("Failed to calculate total subwallet balance")
 580  			return nil, err
 581  		}
 582  		totalBalance = &totalBalanceMsat
 583  		totalBalanceSatVal := totalBalanceMsat / 1000
 584  		totalBalanceSat = &totalBalanceSatVal
 585  	}
 586  
 587  	query = query.Offset(int(offset)).Limit(int(limit))
 588  
 589  	err := query.Find(&dbApps).Error
 590  
 591  	if err != nil {
 592  		logger.Logger.WithError(err).Error("Failed to list apps")
 593  		return nil, err
 594  	}
 595  
 596  	appIds := []uint64{}
 597  	for _, app := range dbApps {
 598  		appIds = append(appIds, uint64(app.ID))
 599  	}
 600  
 601  	appPermissions := []db.AppPermission{}
 602  	err = api.db.Where("app_id IN ?", appIds).Find(&appPermissions).Error
 603  	if err != nil {
 604  		logger.Logger.WithError(err).Error("Failed to list app permissions")
 605  		return nil, err
 606  	}
 607  
 608  	permissionsMap := make(map[uint][]db.AppPermission)
 609  	for _, perm := range appPermissions {
 610  		permissionsMap[perm.AppId] = append(permissionsMap[perm.AppId], perm)
 611  	}
 612  
 613  	apiApps := []App{}
 614  	for _, dbApp := range dbApps {
 615  		walletPubkey := api.keys.GetNostrPublicKey()
 616  		uniqueWalletPubkey := false
 617  		if dbApp.WalletPubkey != nil {
 618  			walletPubkey = *dbApp.WalletPubkey
 619  			uniqueWalletPubkey = true
 620  		}
 621  		apiApp := App{
 622  			ID:                       dbApp.ID,
 623  			Name:                     dbApp.Name,
 624  			Description:              dbApp.Description,
 625  			CreatedAt:                dbApp.CreatedAt,
 626  			UpdatedAt:                dbApp.UpdatedAt,
 627  			AppPubkey:                dbApp.AppPubkey,
 628  			Isolated:                 dbApp.Isolated,
 629  			WalletPubkey:             walletPubkey,
 630  			UniqueWalletPubkey:       uniqueWalletPubkey,
 631  			LastUsedAt:               dbApp.LastUsedAt,
 632  			LastSettledTransactionAt: dbApp.LastSettledTransactionAt,
 633  		}
 634  
 635  		if dbApp.Isolated {
 636  			balanceMsat, err := queries.GetIsolatedBalanceMsat(api.db, dbApp.ID)
 637  			if err != nil {
 638  				logger.Logger.WithError(err).WithFields(logrus.Fields{
 639  					"app_id": dbApp.ID,
 640  				}).Error("Failed to get isolated app balance")
 641  				return nil, err
 642  			}
 643  			apiApp.Balance = balanceMsat
 644  			apiApp.BalanceSat = balanceMsat / 1000
 645  			apiApp.BalanceMsat = balanceMsat
 646  		}
 647  
 648  		for _, appPermission := range permissionsMap[dbApp.ID] {
 649  			apiApp.Scopes = append(apiApp.Scopes, appPermission.Scope)
 650  			apiApp.ExpiresAt = appPermission.ExpiresAt
 651  			if appPermission.Scope == constants.PAY_INVOICE_SCOPE {
 652  				apiApp.BudgetRenewal = appPermission.BudgetRenewal
 653  				apiApp.MaxAmount = uint64(appPermission.MaxAmountSat)
 654  				apiApp.MaxAmountSat = uint64(appPermission.MaxAmountSat)
 655  				apiApp.MaxAmountMsat = uint64(appPermission.MaxAmountSat) * 1000
 656  				budgetUsageMsat, err := queries.GetBudgetUsageMsat(api.db, &appPermission)
 657  				if err != nil {
 658  					logger.Logger.WithError(err).WithFields(logrus.Fields{
 659  						"app_id": dbApp.ID,
 660  					}).Error("Failed to get budget usage for app")
 661  					return nil, err
 662  				}
 663  				apiApp.BudgetUsage = budgetUsageMsat / 1000
 664  				apiApp.BudgetUsageSat = budgetUsageMsat / 1000
 665  				apiApp.BudgetUsageMsat = budgetUsageMsat
 666  			}
 667  		}
 668  
 669  		var metadata Metadata
 670  		if dbApp.Metadata != nil {
 671  			jsonErr := json.Unmarshal(dbApp.Metadata, &metadata)
 672  			if jsonErr != nil {
 673  				logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
 674  					"app_id": dbApp.ID,
 675  				}).Error("Failed to deserialize app metadata")
 676  			}
 677  			apiApp.Metadata = metadata
 678  		}
 679  
 680  		apiApps = append(apiApps, apiApp)
 681  	}
 682  	return &ListAppsResponse{
 683  		Apps:             apiApps,
 684  		TotalCount:       uint64(totalCount),
 685  		TotalBalance:     totalBalance,
 686  		TotalBalanceSat:  totalBalanceSat,
 687  		TotalBalanceMsat: totalBalance,
 688  	}, nil
 689  }
 690  
 691  func resolveAppOrderBy(orderBy string) string {
 692  	switch orderBy {
 693  	case "created_at":
 694  		return "created_at DESC"
 695  	case "last_settled_transaction":
 696  		return "last_settled_transaction_at IS NULL, last_settled_transaction_at DESC"
 697  	default:
 698  		return "last_used_at IS NULL, last_used_at DESC"
 699  	}
 700  }
 701  
 702  func (api *api) ListChannels(ctx context.Context) ([]Channel, error) {
 703  	lnClient := api.svc.GetLNClient()
 704  	if lnClient == nil {
 705  		return nil, ErrLNClientNotStarted
 706  	}
 707  	channels, err := lnClient.ListChannels(ctx)
 708  	if err != nil {
 709  		return nil, err
 710  	}
 711  
 712  	apiChannels := []Channel{}
 713  	for _, channel := range channels {
 714  		status := "offline"
 715  		if channel.Active {
 716  			status = "online"
 717  		} else if channel.Confirmations != nil && channel.ConfirmationsRequired != nil && *channel.ConfirmationsRequired > *channel.Confirmations {
 718  			status = "opening"
 719  		}
 720  
 721  		apiChannels = append(apiChannels, Channel{
 722  			LocalBalance:                             channel.LocalBalanceMsat,
 723  			LocalBalanceSat:                          channel.LocalBalanceMsat / 1000,
 724  			LocalBalanceMsat:                         channel.LocalBalanceMsat,
 725  			LocalSpendableBalance:                    channel.LocalSpendableBalanceMsat,
 726  			LocalSpendableBalanceSat:                 channel.LocalSpendableBalanceMsat / 1000,
 727  			LocalSpendableBalanceMsat:                channel.LocalSpendableBalanceMsat,
 728  			RemoteBalance:                            channel.RemoteBalanceMsat,
 729  			RemoteBalanceSat:                         channel.RemoteBalanceMsat / 1000,
 730  			RemoteBalanceMsat:                        channel.RemoteBalanceMsat,
 731  			Id:                                       channel.Id,
 732  			RemotePubkey:                             channel.RemotePubkey,
 733  			FundingTxId:                              channel.FundingTxId,
 734  			FundingTxVout:                            channel.FundingTxVout,
 735  			Active:                                   channel.Active,
 736  			Public:                                   channel.Public,
 737  			InternalChannel:                          channel.InternalChannel,
 738  			Confirmations:                            channel.Confirmations,
 739  			ConfirmationsRequired:                    channel.ConfirmationsRequired,
 740  			ForwardingFeeBaseMsat:                    channel.ForwardingFeeBaseMsat,
 741  			ForwardingFeeProportionalMillionths:      channel.ForwardingFeeProportionalMillionths,
 742  			UnspendablePunishmentReserve:             channel.UnspendablePunishmentReserveSat,
 743  			UnspendablePunishmentReserveSat:          channel.UnspendablePunishmentReserveSat,
 744  			CounterpartyUnspendablePunishmentReserve: channel.CounterpartyUnspendablePunishmentReserveSat,
 745  			CounterpartyUnspendablePunishmentReserveSat: channel.CounterpartyUnspendablePunishmentReserveSat,
 746  			Error:      channel.Error,
 747  			IsOutbound: channel.IsOutbound,
 748  			Status:     status,
 749  		})
 750  	}
 751  
 752  	slices.SortFunc(apiChannels, func(a, b Channel) int {
 753  		// sort by channel size first
 754  		aSize := a.LocalBalance + a.RemoteBalance
 755  		bSize := b.LocalBalance + b.RemoteBalance
 756  		if aSize != bSize {
 757  			return int(bSize - aSize)
 758  		}
 759  
 760  		// then by local balance in the channel
 761  		if a.LocalBalance != b.LocalBalance {
 762  			return int(b.LocalBalance - a.LocalBalance)
 763  		}
 764  
 765  		// finally sort by channel ID to prevent sort randomly changing
 766  		return strings.Compare(b.Id, a.Id)
 767  	})
 768  
 769  	return apiChannels, nil
 770  }
 771  
 772  func (api *api) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error) {
 773  	return api.albySvc.GetChannelPeerSuggestions(ctx)
 774  }
 775  
 776  func (api *api) GetStories(ctx context.Context) ([]alby.Story, error) {
 777  	return api.albyOAuthSvc.GetStories(ctx)
 778  }
 779  
 780  func (api *api) GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error) {
 781  	return api.albyOAuthSvc.GetLSPChannelOffer(ctx)
 782  }
 783  
 784  func (api *api) ResetRouter(key string) error {
 785  	lnClient := api.svc.GetLNClient()
 786  	if lnClient == nil {
 787  		return ErrLNClientNotStarted
 788  	}
 789  	err := lnClient.ResetRouter(key)
 790  	if err != nil {
 791  		return err
 792  	}
 793  
 794  	// Because the above method has to stop the node to reset the router,
 795  	// We also need to stop the lnclient and ask the user to start it again
 796  	return api.Stop()
 797  }
 798  
 799  func (api *api) ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error {
 800  	if api.svc.GetLNClient() == nil {
 801  		return ErrLNClientNotStarted
 802  	}
 803  
 804  	autoUnlockPassword, err := api.cfg.Get("AutoUnlockPassword", "")
 805  	if err != nil {
 806  		return err
 807  	}
 808  	if autoUnlockPassword != "" {
 809  		return errors.New("please disable auto-unlock before using this feature")
 810  	}
 811  
 812  	err = api.cfg.ChangeUnlockPassword(changeUnlockPasswordRequest.CurrentUnlockPassword, changeUnlockPasswordRequest.NewUnlockPassword)
 813  
 814  	if err != nil {
 815  		logger.Logger.WithError(err).Error("failed to change unlock password")
 816  		return err
 817  	}
 818  
 819  	// Because all the encrypted fields have changed
 820  	// we also need to stop the lnclient and ask the user to start it again
 821  	return api.Stop()
 822  }
 823  
 824  func (api *api) SetAutoUnlockPassword(unlockPassword string) error {
 825  	if api.svc.GetLNClient() == nil {
 826  		return ErrLNClientNotStarted
 827  	}
 828  
 829  	err := api.cfg.SetAutoUnlockPassword(unlockPassword)
 830  
 831  	if err != nil {
 832  		logger.Logger.WithError(err).Error("failed to set auto unlock password")
 833  		return err
 834  	}
 835  
 836  	return nil
 837  }
 838  
 839  func (api *api) Stop() error {
 840  	if !startMutex.TryLock() {
 841  		// do not allow to stop twice in case this is somehow called twice
 842  		return errors.New("app is busy")
 843  	}
 844  	defer startMutex.Unlock()
 845  
 846  	logger.Logger.Info("Running Stop command")
 847  	if api.svc.GetLNClient() == nil {
 848  		return ErrLNClientNotStarted
 849  	}
 850  
 851  	// stop the lnclient, nostr relay etc.
 852  	// The user will be forced to re-enter their unlock password to restart the node
 853  	api.svc.StopApp()
 854  
 855  	return nil
 856  }
 857  
 858  func (api *api) GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error) {
 859  	lnClient := api.svc.GetLNClient()
 860  	if lnClient == nil {
 861  		return nil, ErrLNClientNotStarted
 862  	}
 863  	info, err := lnClient.GetNodeConnectionInfo(ctx)
 864  	if err != nil {
 865  		return nil, err
 866  	}
 867  	return &NodeConnectionInfo{
 868  		Pubkey:  info.Pubkey,
 869  		Address: info.Address,
 870  		Port:    info.Port,
 871  	}, nil
 872  }
 873  
 874  func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error {
 875  	if api.svc.GetSwapsService() == nil {
 876  		return errors.New("SwapsService not started")
 877  	}
 878  	return api.svc.GetSwapsService().RefundSwap(refundSwapRequest.SwapId, refundSwapRequest.Address, false)
 879  }
 880  
 881  func (api *api) GetAutoSwapConfig() (*GetAutoSwapConfigResponse, error) {
 882  	if api.svc.GetSwapsService() == nil {
 883  		return nil, errors.New("SwapsService not started")
 884  	}
 885  
 886  	swapOutBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "")
 887  	swapOutAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "")
 888  	swapOutDestination, _ := api.cfg.Get(config.AutoSwapDestinationKey, "")
 889  
 890  	if xpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub(); xpub != "" {
 891  		swapOutDestination = xpub
 892  	}
 893  
 894  	swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != ""
 895  	var swapOutBalanceThresholdSat, swapOutAmountSat uint64
 896  	if swapOutEnabled {
 897  		var err error
 898  		if swapOutBalanceThresholdSat, err = strconv.ParseUint(swapOutBalanceThresholdStr, 10, 64); err != nil {
 899  			return nil, fmt.Errorf("invalid autoswap out balance threshold: %w", err)
 900  		}
 901  		if swapOutAmountSat, err = strconv.ParseUint(swapOutAmountStr, 10, 64); err != nil {
 902  			return nil, fmt.Errorf("invalid autoswap out amount: %w", err)
 903  		}
 904  	}
 905  
 906  	return &GetAutoSwapConfigResponse{
 907  		Type:                constants.SWAP_TYPE_OUT,
 908  		Enabled:             swapOutEnabled,
 909  		BalanceThreshold:    swapOutBalanceThresholdSat,
 910  		BalanceThresholdSat: swapOutBalanceThresholdSat,
 911  		SwapAmount:          swapOutAmountSat,
 912  		SwapAmountSat:       swapOutAmountSat,
 913  		Destination:         swapOutDestination,
 914  	}, nil
 915  }
 916  
 917  func (api *api) LookupSwap(swapId string) (*LookupSwapResponse, error) {
 918  	if api.svc.GetSwapsService() == nil {
 919  		return nil, errors.New("SwapsService not started")
 920  	}
 921  	dbSwap, err := api.svc.GetSwapsService().GetSwap(swapId)
 922  	if err != nil {
 923  		logger.Logger.WithError(err).Error("failed to fetch swap info")
 924  		return nil, err
 925  	}
 926  
 927  	return toApiSwap(dbSwap), nil
 928  }
 929  
 930  func (api *api) ListSwaps() (*ListSwapsResponse, error) {
 931  	if api.svc.GetSwapsService() == nil {
 932  		return nil, errors.New("SwapsService not started")
 933  	}
 934  	swaps, err := api.svc.GetSwapsService().ListSwaps()
 935  	if err != nil {
 936  		return nil, err
 937  	}
 938  
 939  	apiSwaps := []Swap{}
 940  	for _, swap := range swaps {
 941  		apiSwaps = append(apiSwaps, *toApiSwap(&swap))
 942  	}
 943  
 944  	return &ListSwapsResponse{
 945  		Swaps: apiSwaps,
 946  	}, nil
 947  }
 948  
 949  func toApiSwap(swap *swaps.Swap) *Swap {
 950  	return &Swap{
 951  		Id:                 swap.SwapId,
 952  		Type:               swap.Type,
 953  		State:              swap.State,
 954  		Invoice:            swap.Invoice,
 955  		SendAmount:         swap.SendAmountSat,
 956  		SendAmountSat:      swap.SendAmountSat,
 957  		ReceiveAmount:      swap.ReceiveAmountSat,
 958  		ReceiveAmountSat:   swap.ReceiveAmountSat,
 959  		PaymentHash:        swap.PaymentHash,
 960  		DestinationAddress: swap.DestinationAddress,
 961  		RefundAddress:      swap.RefundAddress,
 962  		LockupAddress:      swap.LockupAddress,
 963  		LockupTxId:         swap.LockupTxId,
 964  		ClaimTxId:          swap.ClaimTxId,
 965  		AutoSwap:           swap.AutoSwap,
 966  		BoltzPubkey:        swap.BoltzPubkey,
 967  		CreatedAt:          swap.CreatedAt.Format(time.RFC3339),
 968  		UpdatedAt:          swap.UpdatedAt.Format(time.RFC3339),
 969  		UsedXpub:           swap.UsedXpub,
 970  	}
 971  }
 972  
 973  func (api *api) GetSwapInInfo() (*SwapInfoResponse, error) {
 974  	if api.svc.GetSwapsService() == nil {
 975  		return nil, errors.New("SwapsService not started")
 976  	}
 977  	swapInInfo, err := api.svc.GetSwapsService().GetSwapInInfo()
 978  	if err != nil {
 979  		logger.Logger.WithError(err).Error("failed to calculate fee info")
 980  		return nil, err
 981  	}
 982  
 983  	return &SwapInfoResponse{
 984  		AlbyServiceFee:     swapInInfo.AlbyServiceFee,
 985  		BoltzServiceFee:    swapInInfo.BoltzServiceFee,
 986  		BoltzNetworkFee:    swapInInfo.BoltzNetworkFeeSat,
 987  		BoltzNetworkFeeSat: swapInInfo.BoltzNetworkFeeSat,
 988  		MinAmount:          swapInInfo.MinAmountSat,
 989  		MinAmountSat:       swapInInfo.MinAmountSat,
 990  		MaxAmount:          swapInInfo.MaxAmountSat,
 991  		MaxAmountSat:       swapInInfo.MaxAmountSat,
 992  	}, nil
 993  }
 994  
 995  func (api *api) GetSwapOutInfo() (*SwapInfoResponse, error) {
 996  	if api.svc.GetSwapsService() == nil {
 997  		return nil, errors.New("SwapsService not started")
 998  	}
 999  	swapOutInfo, err := api.svc.GetSwapsService().GetSwapOutInfo()
1000  	if err != nil {
1001  		logger.Logger.WithError(err).Error("failed to calculate fee info")
1002  		return nil, err
1003  	}
1004  
1005  	return &SwapInfoResponse{
1006  		AlbyServiceFee:     swapOutInfo.AlbyServiceFee,
1007  		BoltzServiceFee:    swapOutInfo.BoltzServiceFee,
1008  		BoltzNetworkFee:    swapOutInfo.BoltzNetworkFeeSat,
1009  		BoltzNetworkFeeSat: swapOutInfo.BoltzNetworkFeeSat,
1010  		MinAmount:          swapOutInfo.MinAmountSat,
1011  		MinAmountSat:       swapOutInfo.MinAmountSat,
1012  		MaxAmount:          swapOutInfo.MaxAmountSat,
1013  		MaxAmountSat:       swapOutInfo.MaxAmountSat,
1014  	}, nil
1015  }
1016  
1017  func (api *api) InitiateSwapOut(ctx context.Context, initiateSwapOutRequest *InitiateSwapRequest) (*swaps.SwapResponse, error) {
1018  	lnClient := api.svc.GetLNClient()
1019  	if lnClient == nil {
1020  		return nil, ErrLNClientNotStarted
1021  	}
1022  
1023  	if api.svc.GetSwapsService() == nil {
1024  		return nil, errors.New("SwapsService not started")
1025  	}
1026  
1027  	amountSat := uint64(0)
1028  	resolvedAmountSat := ResolveToSat(initiateSwapOutRequest.SwapAmountSat, nil, initiateSwapOutRequest.SwapAmount, nil)
1029  	if resolvedAmountSat != nil {
1030  		amountSat = *resolvedAmountSat
1031  	}
1032  	destination := initiateSwapOutRequest.Destination
1033  
1034  	if amountSat == 0 {
1035  		return nil, errors.New("invalid swap amount")
1036  	}
1037  
1038  	swapOutResponse, err := api.svc.GetSwapsService().SwapOut(amountSat, destination, false, false)
1039  	if err != nil {
1040  		logger.Logger.WithFields(logrus.Fields{
1041  			"amount_sat":  amountSat,
1042  			"destination": destination,
1043  		}).WithError(err).Error("Failed to initiate swap out")
1044  		return nil, err
1045  	}
1046  
1047  	return swapOutResponse, nil
1048  }
1049  
1050  func (api *api) InitiateSwapIn(ctx context.Context, initiateSwapInRequest *InitiateSwapRequest) (*swaps.SwapResponse, error) {
1051  	lnClient := api.svc.GetLNClient()
1052  	if lnClient == nil {
1053  		return nil, ErrLNClientNotStarted
1054  	}
1055  
1056  	if api.svc.GetSwapsService() == nil {
1057  		return nil, errors.New("SwapsService not started")
1058  	}
1059  
1060  	amountSat := uint64(0)
1061  	resolvedAmountSat := ResolveToSat(initiateSwapInRequest.SwapAmountSat, nil, initiateSwapInRequest.SwapAmount, nil)
1062  	if resolvedAmountSat != nil {
1063  		amountSat = *resolvedAmountSat
1064  	}
1065  
1066  	if amountSat == 0 {
1067  		return nil, errors.New("invalid swap amount")
1068  	}
1069  
1070  	swapInResponse, err := api.svc.GetSwapsService().SwapIn(amountSat, false)
1071  	if err != nil {
1072  		logger.Logger.WithFields(logrus.Fields{
1073  			"amount_sat": amountSat,
1074  		}).WithError(err).Error("Failed to initiate swap in")
1075  		return nil, err
1076  	}
1077  
1078  	return swapInResponse, nil
1079  }
1080  
1081  func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *EnableAutoSwapRequest) error {
1082  	if api.svc.GetSwapsService() == nil {
1083  		return errors.New("SwapsService not started")
1084  	}
1085  
1086  	encryptionKey := ""
1087  	if enableAutoSwapsRequest.Destination != "" {
1088  		switch enableAutoSwapsRequest.DestinationType {
1089  		case "address":
1090  			if err := api.svc.GetSwapsService().ValidateAddress(enableAutoSwapsRequest.Destination); err != nil {
1091  				return err
1092  			}
1093  		case "xpub":
1094  			if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) {
1095  				return errors.New("invalid unlock password")
1096  			}
1097  			if err := api.svc.GetSwapsService().ValidateXpub(enableAutoSwapsRequest.Destination); err != nil {
1098  				return err
1099  			}
1100  			encryptionKey = enableAutoSwapsRequest.UnlockPassword
1101  		default:
1102  			return errors.New("destination type must be address or xpub")
1103  		}
1104  	}
1105  
1106  	balanceThresholdSat := uint64(0)
1107  	resolvedBalanceThresholdSat := ResolveToSat(enableAutoSwapsRequest.BalanceThresholdSat, nil, enableAutoSwapsRequest.BalanceThreshold, nil)
1108  	if resolvedBalanceThresholdSat != nil {
1109  		balanceThresholdSat = *resolvedBalanceThresholdSat
1110  	}
1111  
1112  	err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, strconv.FormatUint(balanceThresholdSat, 10), "")
1113  	if err != nil {
1114  		logger.Logger.WithError(err).Error("Failed to save autoswap balance threshold to config")
1115  		return err
1116  	}
1117  
1118  	swapAmountSat := uint64(0)
1119  	resolvedSwapAmountSat := ResolveToSat(enableAutoSwapsRequest.SwapAmountSat, nil, enableAutoSwapsRequest.SwapAmount, nil)
1120  	if resolvedSwapAmountSat != nil {
1121  		swapAmountSat = *resolvedSwapAmountSat
1122  	}
1123  
1124  	err = api.cfg.SetUpdate(config.AutoSwapAmountKey, strconv.FormatUint(swapAmountSat, 10), "")
1125  	if err != nil {
1126  		logger.Logger.WithError(err).Error("Failed to save autoswap amount to config")
1127  		return err
1128  	}
1129  
1130  	err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, encryptionKey)
1131  	if err != nil {
1132  		logger.Logger.WithError(err).Error("Failed to save autoswap destination to config")
1133  		return err
1134  	}
1135  
1136  	return api.svc.GetSwapsService().EnableAutoSwapOut(enableAutoSwapsRequest.UnlockPassword)
1137  }
1138  
1139  func (api *api) DisableAutoSwap() error {
1140  	keys := []string{config.AutoSwapBalanceThresholdKey, config.AutoSwapAmountKey, config.AutoSwapDestinationKey}
1141  
1142  	for _, key := range keys {
1143  		if err := api.cfg.SetUpdate(key, "", ""); err != nil {
1144  			logger.Logger.WithError(err).Errorf("Failed to remove autoswap config for key: %s", key)
1145  			return err
1146  		}
1147  	}
1148  
1149  	if api.svc.GetSwapsService() != nil {
1150  		api.svc.GetSwapsService().StopAutoSwapOut()
1151  	}
1152  	return nil
1153  }
1154  
1155  func (api *api) GetSwapMnemonic() string {
1156  	return api.keys.GetSwapMnemonic()
1157  }
1158  
1159  func (api *api) GetNodeStatus(ctx context.Context) (*NodeStatus, error) {
1160  	lnClient := api.svc.GetLNClient()
1161  	if lnClient == nil {
1162  		return nil, ErrLNClientNotStarted
1163  	}
1164  	nodeStatus, err := lnClient.GetNodeStatus(ctx)
1165  	if err != nil {
1166  		return nil, err
1167  	}
1168  	if nodeStatus == nil {
1169  		return nil, nil
1170  	}
1171  	return toApiNodeStatus(nodeStatus), nil
1172  }
1173  
1174  func toApiNodeStatus(nodeStatus *lnclient.NodeStatus) *NodeStatus {
1175  	return &NodeStatus{
1176  		IsReady:            nodeStatus.IsReady,
1177  		InternalNodeStatus: nodeStatus.InternalNodeStatus,
1178  	}
1179  }
1180  
1181  func (api *api) ListPeers(ctx context.Context) ([]PeerDetails, error) {
1182  	lnClient := api.svc.GetLNClient()
1183  	if lnClient == nil {
1184  		return nil, ErrLNClientNotStarted
1185  	}
1186  	peers, err := lnClient.ListPeers(ctx)
1187  	if err != nil {
1188  		return nil, err
1189  	}
1190  	apiPeers := make([]PeerDetails, 0, len(peers))
1191  	for _, peer := range peers {
1192  		apiPeers = append(apiPeers, PeerDetails{
1193  			NodeId:      peer.NodeId,
1194  			Address:     peer.Address,
1195  			IsPersisted: peer.IsPersisted,
1196  			IsConnected: peer.IsConnected,
1197  		})
1198  	}
1199  	return apiPeers, nil
1200  }
1201  
1202  func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error {
1203  	lnClient := api.svc.GetLNClient()
1204  	if lnClient == nil {
1205  		return ErrLNClientNotStarted
1206  	}
1207  	return lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
1208  		Pubkey:  connectPeerRequest.Pubkey,
1209  		Address: connectPeerRequest.Address,
1210  		Port:    connectPeerRequest.Port,
1211  	})
1212  }
1213  
1214  func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error) {
1215  	lnClient := api.svc.GetLNClient()
1216  	if lnClient == nil {
1217  		return nil, ErrLNClientNotStarted
1218  	}
1219  	resp, err := lnClient.OpenChannel(ctx, &lnclient.OpenChannelRequest{
1220  		Pubkey:     openChannelRequest.Pubkey,
1221  		AmountSats: openChannelRequest.AmountSats,
1222  		Public:     openChannelRequest.Public,
1223  	})
1224  	if err != nil {
1225  		return nil, err
1226  	}
1227  	return &OpenChannelResponse{
1228  		FundingTxId: resp.FundingTxId,
1229  	}, nil
1230  }
1231  
1232  func (api *api) DisconnectPeer(ctx context.Context, peerId string) error {
1233  	lnClient := api.svc.GetLNClient()
1234  	if lnClient == nil {
1235  		return ErrLNClientNotStarted
1236  	}
1237  	logger.Logger.WithFields(logrus.Fields{
1238  		"peer_id": peerId,
1239  	}).Info("Disconnecting peer")
1240  	return lnClient.DisconnectPeer(ctx, peerId)
1241  }
1242  
1243  func (api *api) CloseChannel(ctx context.Context, peerId, channelId string, force bool) (*CloseChannelResponse, error) {
1244  	lnClient := api.svc.GetLNClient()
1245  	if lnClient == nil {
1246  		return nil, ErrLNClientNotStarted
1247  	}
1248  	logger.Logger.WithFields(logrus.Fields{
1249  		"peer_id":    peerId,
1250  		"channel_id": channelId,
1251  		"force":      force,
1252  	}).Info("Closing channel")
1253  	err := lnClient.CloseChannel(ctx, &lnclient.CloseChannelRequest{
1254  		NodeId:    peerId,
1255  		ChannelId: channelId,
1256  		Force:     force,
1257  	})
1258  	if err != nil {
1259  		return nil, err
1260  	}
1261  	return &CloseChannelResponse{}, nil
1262  }
1263  
1264  func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateChannelRequest) error {
1265  	lnClient := api.svc.GetLNClient()
1266  	if lnClient == nil {
1267  		return ErrLNClientNotStarted
1268  	}
1269  	logger.Logger.WithFields(logrus.Fields{
1270  		"request": updateChannelRequest,
1271  	}).Info("updating channel")
1272  	return lnClient.UpdateChannel(ctx, &lnclient.UpdateChannelRequest{
1273  		ChannelId:                                updateChannelRequest.ChannelId,
1274  		NodeId:                                   updateChannelRequest.NodeId,
1275  		ForwardingFeeBaseMsat:                    updateChannelRequest.ForwardingFeeBaseMsat,
1276  		ForwardingFeeProportionalMillionths:      updateChannelRequest.ForwardingFeeProportionalMillionths,
1277  		MaxDustHtlcExposureFromFeeRateMultiplier: updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier,
1278  	})
1279  }
1280  
1281  func (api *api) MakeOffer(ctx context.Context, description string) (string, error) {
1282  	lnClient := api.svc.GetLNClient()
1283  	if lnClient == nil {
1284  		return "", ErrLNClientNotStarted
1285  	}
1286  	offer, err := lnClient.MakeOffer(ctx, description)
1287  	if err != nil {
1288  		return "", err
1289  	}
1290  
1291  	return offer, nil
1292  }
1293  
1294  func (api *api) GetNewOnchainAddress(ctx context.Context) (string, error) {
1295  	lnClient := api.svc.GetLNClient()
1296  	if lnClient == nil {
1297  		return "", ErrLNClientNotStarted
1298  	}
1299  	address, err := lnClient.GetNewOnchainAddress(ctx)
1300  	if err != nil {
1301  		return "", err
1302  	}
1303  
1304  	err = api.cfg.SetUpdate(config.OnchainAddressKey, address, "")
1305  	if err != nil {
1306  		logger.Logger.WithError(err).Error("Failed to save new onchain address to config")
1307  	}
1308  
1309  	return address, nil
1310  }
1311  
1312  func (api *api) GetUnusedOnchainAddress(ctx context.Context) (string, error) {
1313  	if api.svc.GetLNClient() == nil {
1314  		return "", ErrLNClientNotStarted
1315  	}
1316  
1317  	currentAddress, err := api.cfg.Get(config.OnchainAddressKey, "")
1318  	if err != nil {
1319  		logger.Logger.WithError(err).Error("Failed to get current address from config")
1320  		return "", err
1321  	}
1322  
1323  	if currentAddress != "" {
1324  		// check if address has any transactions
1325  		response, err := api.RequestEsploraApi(ctx, "/address/"+currentAddress+"/txs")
1326  		if err != nil {
1327  			logger.Logger.WithError(err).Error("Failed to get current address transactions")
1328  			return currentAddress, nil
1329  		}
1330  
1331  		transactions, ok := response.([]interface{})
1332  		if !ok {
1333  			logger.Logger.WithField("response", response).Error("Failed to cast esplora address txs response", response)
1334  			return currentAddress, nil
1335  		}
1336  
1337  		if len(transactions) == 0 {
1338  			// address has not been used yet
1339  			return currentAddress, nil
1340  		}
1341  	}
1342  
1343  	newAddress, err := api.GetNewOnchainAddress(ctx)
1344  	if err != nil {
1345  		logger.Logger.WithError(err).Error("Failed to retrieve new onchain address")
1346  		return "", err
1347  	}
1348  	return newAddress, nil
1349  }
1350  
1351  func (api *api) SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) {
1352  	lnClient := api.svc.GetLNClient()
1353  	if lnClient == nil {
1354  		return nil, ErrLNClientNotStarted
1355  	}
1356  	signature, err := lnClient.SignMessage(ctx, message)
1357  	if err != nil {
1358  		return nil, err
1359  	}
1360  	return &SignMessageResponse{
1361  		Message:   message,
1362  		Signature: signature,
1363  	}, nil
1364  }
1365  
1366  func (api *api) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) {
1367  	lnClient := api.svc.GetLNClient()
1368  	if lnClient == nil {
1369  		return nil, ErrLNClientNotStarted
1370  	}
1371  	txId, err := lnClient.RedeemOnchainFunds(ctx, toAddress, amountSat, feeRate, sendAll)
1372  	if err != nil {
1373  		return nil, err
1374  	}
1375  	return &RedeemOnchainFundsResponse{
1376  		TxId: txId,
1377  	}, nil
1378  }
1379  
1380  func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) {
1381  	lnClient := api.svc.GetLNClient()
1382  	if lnClient == nil {
1383  		return nil, ErrLNClientNotStarted
1384  	}
1385  	balances, err := lnClient.GetBalances(ctx, false)
1386  	if err != nil {
1387  		return nil, err
1388  	}
1389  	return toApiBalances(balances), nil
1390  }
1391  
1392  func toApiBalances(balances *lnclient.BalancesResponse) *BalancesResponse {
1393  	totalSpendableMsat := balances.Lightning.TotalSpendableMsat
1394  	totalReceivableMsat := balances.Lightning.TotalReceivableMsat
1395  	nextMaxSpendableMsat := balances.Lightning.NextMaxSpendableMsat
1396  	nextMaxReceivableMsat := balances.Lightning.NextMaxReceivableMsat
1397  	nextMaxSpendableMPPMsat := balances.Lightning.NextMaxSpendableMPPMsat
1398  	nextMaxReceivableMPPMsat := balances.Lightning.NextMaxReceivableMPPMsat
1399  
1400  	return &BalancesResponse{
1401  		Onchain: OnchainBalanceResponse{
1402  			Spendable:                             balances.Onchain.SpendableSat,
1403  			SpendableSat:                          balances.Onchain.SpendableSat,
1404  			Total:                                 balances.Onchain.TotalSat,
1405  			TotalSat:                              balances.Onchain.TotalSat,
1406  			Reserved:                              balances.Onchain.ReservedSat,
1407  			ReservedSat:                           balances.Onchain.ReservedSat,
1408  			PendingBalancesFromChannelClosures:    balances.Onchain.PendingBalancesFromChannelClosuresSat,
1409  			PendingBalancesFromChannelClosuresSat: balances.Onchain.PendingBalancesFromChannelClosuresSat,
1410  			PendingBalancesDetails:                toApiPendingBalanceDetails(balances.Onchain.PendingBalancesDetails),
1411  			PendingSweepBalancesDetails:           toApiPendingBalanceDetails(balances.Onchain.PendingSweepBalancesDetails),
1412  			InternalBalances:                      balances.Onchain.InternalBalances,
1413  		},
1414  		Lightning: LightningBalanceResponse{
1415  			TotalSpendable:           totalSpendableMsat,
1416  			TotalSpendableSat:        totalSpendableMsat / 1000,
1417  			TotalSpendableMsat:       totalSpendableMsat,
1418  			TotalReceivable:          totalReceivableMsat,
1419  			TotalReceivableSat:       totalReceivableMsat / 1000,
1420  			TotalReceivableMsat:      totalReceivableMsat,
1421  			NextMaxSpendable:         nextMaxSpendableMsat,
1422  			NextMaxSpendableSat:      nextMaxSpendableMsat / 1000,
1423  			NextMaxSpendableMsat:     nextMaxSpendableMsat,
1424  			NextMaxReceivable:        nextMaxReceivableMsat,
1425  			NextMaxReceivableSat:     nextMaxReceivableMsat / 1000,
1426  			NextMaxReceivableMsat:    nextMaxReceivableMsat,
1427  			NextMaxSpendableMPP:      nextMaxSpendableMPPMsat,
1428  			NextMaxSpendableMPPSat:   nextMaxSpendableMPPMsat / 1000,
1429  			NextMaxSpendableMPPMsat:  nextMaxSpendableMPPMsat,
1430  			NextMaxReceivableMPP:     nextMaxReceivableMPPMsat,
1431  			NextMaxReceivableMPPSat:  nextMaxReceivableMPPMsat / 1000,
1432  			NextMaxReceivableMPPMsat: nextMaxReceivableMPPMsat,
1433  		},
1434  	}
1435  }
1436  
1437  func toApiPendingBalanceDetails(details []lnclient.PendingBalanceDetails) []PendingBalanceDetails {
1438  	if details == nil {
1439  		return nil
1440  	}
1441  	apiDetails := make([]PendingBalanceDetails, 0, len(details))
1442  	for _, d := range details {
1443  		apiDetails = append(apiDetails, PendingBalanceDetails{
1444  			ChannelId:     d.ChannelId,
1445  			NodeId:        d.NodeId,
1446  			Amount:        d.AmountSat,
1447  			AmountSat:     d.AmountSat,
1448  			FundingTxId:   d.FundingTxId,
1449  			FundingTxVout: d.FundingTxVout,
1450  		})
1451  	}
1452  	return apiDetails
1453  }
1454  
1455  // TODO: remove dependency on this endpoint
1456  func (api *api) RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error) {
1457  	url := api.cfg.GetEnv().MempoolApi + endpoint
1458  
1459  	client := http.Client{
1460  		Timeout: time.Second * 10,
1461  	}
1462  
1463  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
1464  	if err != nil {
1465  		logger.Logger.WithError(err).WithFields(logrus.Fields{
1466  			"url": url,
1467  		}).Error("Failed to create http request")
1468  		return nil, err
1469  	}
1470  
1471  	res, err := client.Do(req)
1472  	if err != nil {
1473  		logger.Logger.WithError(err).WithFields(logrus.Fields{
1474  			"url": url,
1475  		}).Error("Failed to send request")
1476  		return nil, err
1477  	}
1478  
1479  	defer res.Body.Close()
1480  
1481  	body, readErr := io.ReadAll(res.Body)
1482  	if readErr != nil {
1483  		logger.Logger.WithError(err).WithFields(logrus.Fields{
1484  			"url": url,
1485  		}).Error("Failed to read response body")
1486  		return nil, errors.New("failed to read response body")
1487  	}
1488  
1489  	if res.StatusCode != http.StatusOK {
1490  		logger.Logger.WithFields(logrus.Fields{
1491  			"endpoint":    endpoint,
1492  			"status_code": res.StatusCode,
1493  			"body":        string(body),
1494  		}).Error("Mempool endpoint returned non-success code")
1495  		return nil, fmt.Errorf("mempool endpoint returned non-success code: %s", string(body))
1496  	}
1497  
1498  	var jsonContent interface{}
1499  	jsonErr := json.Unmarshal(body, &jsonContent)
1500  	if jsonErr != nil {
1501  		logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
1502  			"url": url,
1503  		}).Error("Failed to deserialize json")
1504  		return nil, fmt.Errorf("failed to deserialize json %s %s", url, string(body))
1505  	}
1506  	return jsonContent, nil
1507  }
1508  
1509  func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
1510  	info := InfoResponse{}
1511  
1512  	if api.nodeMigrationFileCreated.Load() {
1513  		// the hub is halted and the database is closed after a migration file
1514  		// is created, so return a minimal response without reading any config
1515  		// or node state; the frontend only needs the flag to keep showing the
1516  		// migration success page
1517  		info.NodeMigrationFileCreated = true
1518  		info.SetupCompleted = true
1519  		info.Version = version.Tag
1520  		info.Relays = []InfoResponseRelay{}
1521  		return &info, nil
1522  	}
1523  
1524  	backendType, _ := api.cfg.Get("LNBackendType", "")
1525  	ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
1526  	jitChannelsEnabled, _ := api.cfg.Get("JitChannelsEnabled", "")
1527  	autoUnlockPassword, _ := api.cfg.Get("AutoUnlockPassword", "")
1528  	setupCompleted, err := api.cfg.SetupCompleted()
1529  	if err != nil {
1530  		logger.Logger.WithError(err).Error("Failed to check if setup is completed")
1531  		return nil, err
1532  	}
1533  	info.SetupCompleted = setupCompleted
1534  	info.Currency = api.cfg.GetCurrency()
1535  	info.BitcoinDisplayFormat = api.cfg.GetBitcoinDisplayFormat()
1536  	info.StartupState = api.svc.GetStartupState()
1537  	if api.startupError != nil {
1538  		info.StartupError = api.startupError.Error()
1539  		info.StartupErrorTime = api.startupErrorTime
1540  	}
1541  	lnClient := api.svc.GetLNClient()
1542  	info.Running = lnClient != nil
1543  	info.NodeMigrationFileCreated = api.nodeMigrationFileCreated.Load()
1544  	info.BackendType = backendType
1545  	info.AlbyAuthUrl = api.albyOAuthSvc.GetAuthUrl()
1546  	info.OAuthRedirect = !api.cfg.GetEnv().IsDefaultClientId()
1547  	info.Version = version.Tag
1548  	info.EnableAdvancedSetup = api.cfg.GetEnv().EnableAdvancedSetup
1549  	info.HideUpdateBanner = api.cfg.GetEnv().HideUpdateBanner
1550  	info.LdkVssEnabled = ldkVssEnabled == "true"
1551  	info.JitChannelsEnabled = jitChannelsEnabled != "false"
1552  	info.VssSupported = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKVssUrl != ""
1553  	info.LdkVssUrl = api.cfg.GetEnv().LDKVssUrl
1554  	info.DatabaseType = api.db.Dialector.Name()
1555  	info.SupportsBolt12 = backendType == config.LDKBackendType || backendType == config.CLNBackendType
1556  	info.AutoUnlockPasswordEnabled = autoUnlockPassword != ""
1557  	info.AutoUnlockPasswordSupported = api.cfg.GetEnv().IsDefaultClientId()
1558  	info.Relays = []InfoResponseRelay{}
1559  	for _, relayStatus := range api.svc.GetRelayStatuses() {
1560  		info.Relays = append(info.Relays, InfoResponseRelay{
1561  			Url:    relayStatus.Url,
1562  			Online: relayStatus.Online,
1563  		})
1564  	}
1565  
1566  	info.MempoolUrl = api.cfg.GetMempoolUrl()
1567  	info.AlbyAccountConnected = api.albyOAuthSvc.IsConnected(ctx)
1568  
1569  	albyUserIdentifier, err := api.albyOAuthSvc.GetUserIdentifier()
1570  	if err != nil {
1571  		logger.Logger.WithError(err).Error("Failed to get alby user identifier")
1572  		return nil, err
1573  	}
1574  	info.AlbyUserIdentifier = albyUserIdentifier
1575  
1576  	if lnClient != nil {
1577  		nodeInfo, err := lnClient.GetInfo(ctx)
1578  		if err != nil {
1579  			logger.Logger.WithError(err).Error("Failed to get nodeInfo")
1580  			return nil, err
1581  		}
1582  
1583  		info.Network = nodeInfo.Network
1584  		if backendType == config.LDKBackendType {
1585  			// Only LDK supports this right now. Using a local interface here
1586  			// so we don't have to bloat the main LNClient interface for everyone else.
1587  			type chainSourceProvider interface {
1588  				GetChainDataSource() (string, string)
1589  			}
1590  			type lsps2SourceProvider interface {
1591  				GetLiquiditySourceLsps2() string
1592  			}
1593  			type lsps2MinPaymentSizeProvider interface {
1594  				GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64
1595  			}
1596  			type lsps2MaxPaymentSizeProvider interface {
1597  				GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64
1598  			}
1599  
1600  			if ldkService, ok := api.svc.GetLNClient().(chainSourceProvider); ok {
1601  				info.ChainDataSourceType, info.ChainDataSourceAddress = ldkService.GetChainDataSource()
1602  			}
1603  			if ldkService, ok := api.svc.GetLNClient().(lsps2SourceProvider); ok {
1604  				info.JitChannelsLiquiditySource = ldkService.GetLiquiditySourceLsps2()
1605  			}
1606  			if ldkService, ok := api.svc.GetLNClient().(lsps2MinPaymentSizeProvider); ok {
1607  				info.JitChannelsMinPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MinPaymentSizeMsat()
1608  			}
1609  			if ldkService, ok := api.svc.GetLNClient().(lsps2MaxPaymentSizeProvider); ok {
1610  				info.JitChannelsMaxPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MaxPaymentSizeMsat()
1611  			}
1612  		}
1613  	}
1614  
1615  	info.NextBackupReminder, _ = api.cfg.Get("NextBackupReminder", "")
1616  
1617  	info.NodeAlias, _ = api.cfg.Get("NodeAlias", "")
1618  
1619  	return &info, nil
1620  }
1621  
1622  func (api *api) setCurrency(currency string) error {
1623  	if currency == "" {
1624  		return fmt.Errorf("currency value cannot be empty")
1625  	}
1626  
1627  	err := api.cfg.SetCurrency(currency)
1628  	if err != nil {
1629  		logger.Logger.WithError(err).Error("Failed to update currency")
1630  		return err
1631  	}
1632  
1633  	return nil
1634  }
1635  
1636  func (api *api) setBitcoinDisplayFormat(format string) error {
1637  	if format != constants.BITCOIN_DISPLAY_FORMAT_SATS && format != constants.BITCOIN_DISPLAY_FORMAT_BIP177 {
1638  		return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177)
1639  	}
1640  
1641  	err := api.cfg.SetBitcoinDisplayFormat(format)
1642  	if err != nil {
1643  		logger.Logger.WithError(err).Error("Failed to update bitcoin display format")
1644  		return err
1645  	}
1646  
1647  	return nil
1648  }
1649  
1650  func (api *api) setJitChannelsEnabled(enabled bool) error {
1651  	value := "true"
1652  	if !enabled {
1653  		value = "false"
1654  	}
1655  
1656  	err := api.cfg.SetUpdate("JitChannelsEnabled", value, "")
1657  	if err != nil {
1658  		logger.Logger.WithError(err).Error("Failed to update JIT channels setting")
1659  		return err
1660  	}
1661  
1662  	return nil
1663  }
1664  
1665  func (api *api) UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error {
1666  	if updateSettingsRequest.Currency != "" {
1667  		err := api.setCurrency(updateSettingsRequest.Currency)
1668  		if err != nil {
1669  			return fmt.Errorf("failed to set currency: %w", err)
1670  		}
1671  	}
1672  
1673  	if updateSettingsRequest.BitcoinDisplayFormat != "" {
1674  		err := api.setBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat)
1675  		if err != nil {
1676  			return fmt.Errorf("failed to set bitcoin display format: %w", err)
1677  		}
1678  	}
1679  
1680  	if updateSettingsRequest.JitChannelsEnabled != nil {
1681  		err := api.setJitChannelsEnabled(*updateSettingsRequest.JitChannelsEnabled)
1682  		if err != nil {
1683  			return fmt.Errorf("failed to set JIT channels setting: %w", err)
1684  		}
1685  	}
1686  
1687  	return nil
1688  }
1689  
1690  func (api *api) SetNodeAlias(nodeAlias string) error {
1691  	err := api.cfg.SetUpdate("NodeAlias", nodeAlias, "")
1692  	if err != nil {
1693  		logger.Logger.WithError(err).Error("Failed to save node alias to config")
1694  		return err
1695  	}
1696  
1697  	return nil
1698  }
1699  
1700  func (api *api) GetMnemonic(unlockPassword string) (*MnemonicResponse, error) {
1701  	if !api.cfg.CheckUnlockPassword(unlockPassword) {
1702  		return nil, fmt.Errorf("wrong password")
1703  	}
1704  
1705  	mnemonic, err := api.cfg.Get("Mnemonic", unlockPassword)
1706  	if err != nil {
1707  		return nil, fmt.Errorf("failed to fetch encryption key: %w", err)
1708  	}
1709  
1710  	resp := MnemonicResponse{
1711  		Mnemonic: mnemonic,
1712  	}
1713  
1714  	return &resp, nil
1715  }
1716  
1717  func (api *api) SetNextBackupReminder(backupReminderRequest *BackupReminderRequest) error {
1718  	err := api.cfg.SetUpdate("NextBackupReminder", backupReminderRequest.NextBackupReminder, "")
1719  	if err != nil {
1720  		logger.Logger.WithError(err).Error("Failed to save next backup reminder to config")
1721  	}
1722  	return nil
1723  }
1724  
1725  var startMutex sync.Mutex
1726  
1727  func (api *api) Start(startRequest *StartRequest) {
1728  	api.startupError = nil
1729  	err := api.startInternal(startRequest)
1730  	if err != nil {
1731  		logger.Logger.WithError(err).Error("Failed to start node")
1732  		api.startupError = err
1733  		api.startupErrorTime = time.Now()
1734  	}
1735  }
1736  
1737  func (api *api) startInternal(startRequest *StartRequest) (err error) {
1738  	if !startMutex.TryLock() {
1739  		// do not allow to start twice in case this is somehow called twice
1740  		return errors.New("app is busy")
1741  	}
1742  	defer startMutex.Unlock()
1743  	return api.svc.StartApp(startRequest.UnlockPassword)
1744  }
1745  
1746  func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
1747  	if !startMutex.TryLock() {
1748  		// do not allow to start twice in case this is somehow called twice
1749  		return errors.New("app is busy")
1750  	}
1751  	defer startMutex.Unlock()
1752  	info, err := api.GetInfo(ctx)
1753  	if err != nil {
1754  		logger.Logger.WithError(err).Error("Failed to get info")
1755  		return err
1756  	}
1757  	if info.SetupCompleted {
1758  		logger.Logger.Error("Cannot re-setup node")
1759  		return errors.New("setup already completed")
1760  	}
1761  
1762  	if setupRequest.UnlockPassword == "" {
1763  		return errors.New("no unlock password provided")
1764  	}
1765  
1766  	// Bark and Cashu both store wallet state on local disk, so they cannot
1767  	// run in environments without persistent volumes (e.g. Alby Cloud). Bark
1768  	// can recover spendable VTXOs from the mnemonic alone, but in-flight
1769  	// payment checkpoints and wallet metadata are local-only, so persistent
1770  	// storage is still required. The default OAuth client ID identifies a
1771  	// local / self-hosted deployment.
1772  	if !api.cfg.GetEnv().IsDefaultClientId() {
1773  		switch setupRequest.LNBackendType {
1774  		case config.BarkBackendType, config.CashuBackendType:
1775  			return fmt.Errorf("%s backend is not supported in this environment (no persistent storage)", setupRequest.LNBackendType)
1776  		}
1777  	}
1778  
1779  	err = api.cfg.SaveUnlockPasswordCheck(setupRequest.UnlockPassword)
1780  	if err != nil {
1781  		return err
1782  	}
1783  
1784  	// update next backup reminder
1785  	err = api.cfg.SetUpdate("NextBackupReminder", setupRequest.NextBackupReminder, "")
1786  	if err != nil {
1787  		logger.Logger.WithError(err).Error("Failed to save next backup reminder")
1788  	}
1789  
1790  	// only update non-empty values
1791  	if setupRequest.LNBackendType != "" {
1792  		err = api.cfg.SetUpdate("LNBackendType", setupRequest.LNBackendType, "")
1793  		if err != nil {
1794  			logger.Logger.WithError(err).Error("Failed to save backend type")
1795  			return err
1796  		}
1797  	}
1798  	if setupRequest.Mnemonic != "" {
1799  		err = api.cfg.SetUpdate("Mnemonic", setupRequest.Mnemonic, setupRequest.UnlockPassword)
1800  		if err != nil {
1801  			logger.Logger.WithError(err).Error("Failed to save encrypted mnemonic")
1802  			return err
1803  		}
1804  	}
1805  	if setupRequest.LNDAddress != "" {
1806  		err = api.cfg.SetUpdate("LNDAddress", setupRequest.LNDAddress, setupRequest.UnlockPassword)
1807  		if err != nil {
1808  			logger.Logger.WithError(err).Error("Failed to save lnd address")
1809  			return err
1810  		}
1811  	}
1812  	if setupRequest.LNDCertFile != "" {
1813  		// The file path is provided by the (unauthenticated) setup request, so
1814  		// only persist the content if it parses as a certificate. Storing the
1815  		// re-encoded certificate(s) guarantees nothing but the parsed structure
1816  		// reaches the database - e.g. a private key bundled in the same PEM file
1817  		// is dropped rather than persisted.
1818  		certHex, err := readAndCanonicalizeLNDCert(setupRequest.LNDCertFile)
1819  		if err != nil {
1820  			// Return a generic error and log the detail server-side so the
1821  			// response is not a file existence/readability oracle.
1822  			logger.Logger.WithError(err).Error("Failed to process lnd cert file")
1823  			return errors.New("invalid LND certificate file")
1824  		}
1825  		err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword)
1826  		if err != nil {
1827  			logger.Logger.WithError(err).Error("Failed to save lnd cert hex")
1828  			return err
1829  		}
1830  	}
1831  	if setupRequest.LNDMacaroonFile != "" {
1832  		// The file path is provided by the (unauthenticated) setup request, so
1833  		// only persist the content if it parses as a macaroon. Storing the
1834  		// re-marshalled macaroon guarantees only the parsed structure reaches
1835  		// the database.
1836  		macaroonHex, err := readAndCanonicalizeLNDMacaroon(setupRequest.LNDMacaroonFile)
1837  		if err != nil {
1838  			// Return a generic error and log the detail server-side so the
1839  			// response is not a file existence/readability oracle.
1840  			logger.Logger.WithError(err).Error("Failed to process lnd macaroon file")
1841  			return errors.New("invalid LND macaroon file")
1842  		}
1843  		err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword)
1844  		if err != nil {
1845  			logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex")
1846  			return err
1847  		}
1848  	}
1849  
1850  	if setupRequest.PhoenixdAddress != "" {
1851  		err = api.cfg.SetUpdate("PhoenixdAddress", setupRequest.PhoenixdAddress, setupRequest.UnlockPassword)
1852  		if err != nil {
1853  			logger.Logger.WithError(err).Error("Failed to save phoenix address")
1854  			return err
1855  		}
1856  	}
1857  	if setupRequest.PhoenixdAuthorization != "" {
1858  		err = api.cfg.SetUpdate("PhoenixdAuthorization", setupRequest.PhoenixdAuthorization, setupRequest.UnlockPassword)
1859  		if err != nil {
1860  			logger.Logger.WithError(err).Error("Failed to save phoenix auth")
1861  			return err
1862  		}
1863  	}
1864  
1865  	if setupRequest.CashuMintUrl != "" {
1866  		err = api.cfg.SetUpdate("CashuMintUrl", setupRequest.CashuMintUrl, setupRequest.UnlockPassword)
1867  		if err != nil {
1868  			logger.Logger.WithError(err).Error("Failed to save cashu mint url")
1869  			return err
1870  		}
1871  	}
1872  
1873  	if setupRequest.CLNAddress != "" {
1874  		err = api.cfg.SetUpdate("CLNAddress", setupRequest.CLNAddress, setupRequest.UnlockPassword)
1875  		if err != nil {
1876  			logger.Logger.WithError(err).Error("Failed to save CLN address")
1877  			return err
1878  		}
1879  	}
1880  
1881  	if setupRequest.CLNLightningDir != "" {
1882  		// The directory path is provided by the (unauthenticated) setup request.
1883  		// Validate that it holds the expected CLN TLS credentials before saving,
1884  		// so the path cannot be used as an existence/readability oracle for
1885  		// arbitrary directories (the failure otherwise surfaces via startupError
1886  		// on the anonymous /api/info response).
1887  		if err := validateCLNLightningDir(setupRequest.CLNLightningDir, setupRequest.CLNAddressHold != ""); err != nil {
1888  			logger.Logger.WithError(err).Error("Failed to validate CLN lightning directory")
1889  			return errors.New("invalid CLN lightning directory")
1890  		}
1891  		err = api.cfg.SetUpdate("CLNLightningDir", setupRequest.CLNLightningDir, setupRequest.UnlockPassword)
1892  		if err != nil {
1893  			logger.Logger.WithError(err).Error("Failed to save CLN Lightning directory path")
1894  			return err
1895  		}
1896  	}
1897  
1898  	if setupRequest.CLNAddressHold != "" {
1899  		err = api.cfg.SetUpdate("CLNAddressHold", setupRequest.CLNAddressHold, setupRequest.UnlockPassword)
1900  		if err != nil {
1901  			logger.Logger.WithError(err).Error("Failed to save cln hold plugin address")
1902  			return err
1903  		}
1904  	}
1905  
1906  	return nil
1907  }
1908  
1909  // readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path,
1910  // validates that it contains at least one parseable certificate, and returns
1911  // the hex-encoded re-encoding of only the parsed certificate(s). Any non
1912  // CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are
1913  // never persisted. Callers must not reflect the returned error to the client.
1914  func readAndCanonicalizeLNDCert(path string) (string, error) {
1915  	raw, err := os.ReadFile(path)
1916  	if err != nil {
1917  		return "", fmt.Errorf("failed to read LND cert file: %w", err)
1918  	}
1919  
1920  	var canonical []byte
1921  	rest := raw
1922  	for {
1923  		var block *pem.Block
1924  		block, rest = pem.Decode(rest)
1925  		if block == nil {
1926  			break
1927  		}
1928  		if block.Type != "CERTIFICATE" {
1929  			continue
1930  		}
1931  		cert, err := x509.ParseCertificate(block.Bytes)
1932  		if err != nil {
1933  			return "", fmt.Errorf("failed to parse LND certificate: %w", err)
1934  		}
1935  		canonical = append(canonical, pem.EncodeToMemory(&pem.Block{
1936  			Type:  "CERTIFICATE",
1937  			Bytes: cert.Raw,
1938  		})...)
1939  	}
1940  	if len(canonical) == 0 {
1941  		return "", errors.New("no valid certificate found in LND cert file")
1942  	}
1943  
1944  	return hex.EncodeToString(canonical), nil
1945  }
1946  
1947  // readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path,
1948  // validates that it is a well-formed macaroon, and returns the hex-encoded
1949  // re-marshalling so that only the parsed structure is persisted. Callers must
1950  // not reflect the returned error to the client.
1951  func readAndCanonicalizeLNDMacaroon(path string) (string, error) {
1952  	raw, err := os.ReadFile(path)
1953  	if err != nil {
1954  		return "", fmt.Errorf("failed to read LND macaroon file: %w", err)
1955  	}
1956  
1957  	mac := &macaroon.Macaroon{}
1958  	if err := mac.UnmarshalBinary(raw); err != nil {
1959  		return "", fmt.Errorf("failed to parse LND macaroon: %w", err)
1960  	}
1961  	canonical, err := mac.MarshalBinary()
1962  	if err != nil {
1963  		return "", fmt.Errorf("failed to marshal LND macaroon: %w", err)
1964  	}
1965  
1966  	return hex.EncodeToString(canonical), nil
1967  }
1968  
1969  // validateCLNLightningDir checks that the given directory holds the CLN TLS
1970  // credentials that will later be loaded at connect time (ca.pem, client.pem,
1971  // client-key.pem), for each gRPC server name the config will use. This mirrors
1972  // the parses performed by the CLN client's loadTLSCredentials so a directory
1973  // that passes here is one CLN can actually use. Callers must not reflect the
1974  // returned error to the client.
1975  func validateCLNLightningDir(lightningDir string, hold bool) error {
1976  	// "cln" reads the directory directly; other server names are joined as a
1977  	// subdirectory, matching loadTLSCredentials in lnclient/cln.
1978  	serverNames := []string{"cln"}
1979  	if hold {
1980  		serverNames = append(serverNames, "hold")
1981  	}
1982  
1983  	for _, serverName := range serverNames {
1984  		dir := lightningDir
1985  		if serverName != "cln" {
1986  			dir = filepath.Join(dir, serverName)
1987  		}
1988  
1989  		caPEM, err := os.ReadFile(filepath.Join(dir, "ca.pem"))
1990  		if err != nil {
1991  			return fmt.Errorf("failed to read CLN CA cert (%s): %w", serverName, err)
1992  		}
1993  		if !x509.NewCertPool().AppendCertsFromPEM(caPEM) {
1994  			return fmt.Errorf("failed to parse CLN CA cert (%s)", serverName)
1995  		}
1996  		if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "client.pem"), filepath.Join(dir, "client-key.pem")); err != nil {
1997  			return fmt.Errorf("failed to load CLN client cert/key (%s): %w", serverName, err)
1998  		}
1999  	}
2000  
2001  	return nil
2002  }
2003  
2004  func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) {
2005  	lnClient := api.svc.GetLNClient()
2006  	if lnClient == nil {
2007  		return nil, ErrLNClientNotStarted
2008  	}
2009  
2010  	methods := lnClient.GetSupportedNIP47Methods()
2011  	notificationTypes := lnClient.GetSupportedNIP47NotificationTypes()
2012  
2013  	scopes, err := permissions.RequestMethodsToScopes(methods)
2014  	if err != nil {
2015  		return nil, err
2016  	}
2017  	if len(notificationTypes) > 0 {
2018  		scopes = append(scopes, constants.NOTIFICATIONS_SCOPE)
2019  	}
2020  
2021  	return &WalletCapabilitiesResponse{
2022  		Methods:           methods,
2023  		NotificationTypes: notificationTypes,
2024  		Scopes:            scopes,
2025  	}, nil
2026  }
2027  
2028  func (api *api) MigrateNodeStorage(ctx context.Context, to string) error {
2029  	if api.svc.GetLNClient() == nil {
2030  		return ErrLNClientNotStarted
2031  	}
2032  	if to != "VSS" {
2033  		return fmt.Errorf("migration type not supported: %s", to)
2034  	}
2035  
2036  	ldkVssEnabled, err := api.cfg.Get("LdkVssEnabled", "")
2037  	if err != nil {
2038  		return err
2039  	}
2040  
2041  	if ldkVssEnabled == "true" {
2042  		return errors.New("VSS already enabled")
2043  	}
2044  
2045  	if api.cfg.GetEnv().LDKVssUrl == "" {
2046  		return errors.New("no VSS URL set")
2047  	}
2048  
2049  	api.cfg.SetUpdate("LdkVssEnabled", "true", "")
2050  	api.cfg.SetUpdate("LdkMigrateStorage", "VSS", "")
2051  	return api.Stop()
2052  }
2053  
2054  func (api *api) GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error) {
2055  	lnClient := api.svc.GetLNClient()
2056  	if lnClient == nil {
2057  		return nil, ErrLNClientNotStarted
2058  	}
2059  	return lnClient.GetNetworkGraph(ctx, nodeIds)
2060  }
2061  
2062  func (api *api) SyncWallet() error {
2063  	lnClient := api.svc.GetLNClient()
2064  	if lnClient == nil {
2065  		return ErrLNClientNotStarted
2066  	}
2067  	lnClient.UpdateLastWalletSyncRequest()
2068  	return nil
2069  }
2070  func (api *api) ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) {
2071  	lnClient := api.svc.GetLNClient()
2072  	if lnClient == nil {
2073  		return nil, ErrLNClientNotStarted
2074  	}
2075  	transactions, err := lnClient.ListOnchainTransactions(ctx)
2076  	if err != nil {
2077  		return nil, err
2078  	}
2079  	apiTransactions := make([]OnchainTransaction, 0, len(transactions))
2080  	for _, t := range transactions {
2081  		apiTransactions = append(apiTransactions, OnchainTransaction{
2082  			AmountSat:        t.AmountSat,
2083  			CreatedAt:        t.CreatedAt,
2084  			State:            t.State,
2085  			Type:             t.Type,
2086  			NumConfirmations: t.NumConfirmations,
2087  			TxId:             t.TxId,
2088  		})
2089  	}
2090  	return apiTransactions, nil
2091  }
2092  
2093  func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) {
2094  	var err error
2095  	var logData []byte
2096  
2097  	if logType == LogTypeNode {
2098  		lnClient := api.svc.GetLNClient()
2099  		if lnClient == nil {
2100  			return nil, ErrLNClientNotStarted
2101  		}
2102  
2103  		logData, err = lnClient.GetLogOutput(ctx, getLogRequest.MaxLen)
2104  		if err != nil {
2105  			return nil, err
2106  		}
2107  	} else if logType == LogTypeApp {
2108  		logFileName := logger.GetLogFilePath()
2109  		if logFileName == "" {
2110  			logData = []byte("file log is disabled")
2111  		} else {
2112  			logData, err = utils.ReadFileTail(logFileName, getLogRequest.MaxLen)
2113  			if err != nil {
2114  				return nil, err
2115  			}
2116  		}
2117  	} else {
2118  		return nil, fmt.Errorf("invalid log type: '%s'", logType)
2119  	}
2120  
2121  	return &GetLogOutputResponse{Log: string(logData)}, nil
2122  }
2123  
2124  func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
2125  	var alarms []HealthAlarm
2126  
2127  	albyInfo, err := api.albySvc.GetInfo(ctx)
2128  	if err != nil {
2129  		return nil, err
2130  	}
2131  	if !albyInfo.Healthy {
2132  		alarms = append(alarms, NewHealthAlarm(HealthAlarmKindAlbyService, albyInfo.Incidents))
2133  	}
2134  
2135  	relayStatuses := api.svc.GetRelayStatuses()
2136  	if len(relayStatuses) > 0 {
2137  		isAnyNostrRelayOffline := false
2138  		offlineRelayUrls := []string{}
2139  		for _, relayStatus := range relayStatuses {
2140  			if !relayStatus.Online {
2141  				isAnyNostrRelayOffline = true
2142  				offlineRelayUrls = append(offlineRelayUrls, relayStatus.Url)
2143  			}
2144  		}
2145  		if isAnyNostrRelayOffline {
2146  			alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNostrRelayOffline, offlineRelayUrls))
2147  		}
2148  	}
2149  
2150  	ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
2151  	if ldkVssEnabled == "true" {
2152  		albyMe, err := api.albyOAuthSvc.GetMe(ctx)
2153  		if err != nil {
2154  			return nil, err
2155  		}
2156  		if albyMe.Subscription.PlanCode == "" {
2157  			alarms = append(alarms, NewHealthAlarm(HealthAlarmKindVssNoSubscription, nil))
2158  		}
2159  	}
2160  
2161  	lnClient := api.svc.GetLNClient()
2162  
2163  	if lnClient != nil {
2164  		nodeStatus, _ := lnClient.GetNodeStatus(ctx)
2165  		if nodeStatus == nil || !nodeStatus.IsReady {
2166  			var apiNodeStatus *NodeStatus
2167  			if nodeStatus != nil {
2168  				apiNodeStatus = toApiNodeStatus(nodeStatus)
2169  			}
2170  			alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, apiNodeStatus))
2171  		}
2172  
2173  		channels, err := lnClient.ListChannels(ctx)
2174  		if err != nil {
2175  			return nil, err
2176  		}
2177  
2178  		offlineChannels := slices.DeleteFunc(channels, func(channel lnclient.Channel) bool {
2179  			if channel.Active {
2180  				return true
2181  			}
2182  			if channel.Confirmations == nil || channel.ConfirmationsRequired == nil {
2183  				return false
2184  			}
2185  			return *channel.Confirmations < *channel.ConfirmationsRequired
2186  		})
2187  
2188  		if len(offlineChannels) > 0 {
2189  			alarms = append(alarms, NewHealthAlarm(HealthAlarmKindChannelsOffline, nil))
2190  		}
2191  	}
2192  
2193  	return &HealthResponse{Alarms: alarms}, nil
2194  }
2195  
2196  func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) {
2197  	lnClient := api.svc.GetLNClient()
2198  	if lnClient == nil {
2199  		return nil, ErrLNClientNotStarted
2200  	}
2201  
2202  	allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
2203  	commandDefs := make([]CustomNodeCommandDef, 0, len(allCommandDefs))
2204  	for _, commandDef := range allCommandDefs {
2205  		argDefs := make([]CustomNodeCommandArgDef, 0, len(commandDef.Args))
2206  		for _, argDef := range commandDef.Args {
2207  			argDefs = append(argDefs, CustomNodeCommandArgDef{
2208  				Name:        argDef.Name,
2209  				Description: argDef.Description,
2210  			})
2211  		}
2212  		commandDefs = append(commandDefs, CustomNodeCommandDef{
2213  			Name:        commandDef.Name,
2214  			Description: commandDef.Description,
2215  			Args:        argDefs,
2216  		})
2217  	}
2218  
2219  	return &CustomNodeCommandsResponse{Commands: commandDefs}, nil
2220  }
2221  
2222  func (api *api) ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) {
2223  	lnClient := api.svc.GetLNClient()
2224  	if lnClient == nil {
2225  		return nil, ErrLNClientNotStarted
2226  	}
2227  
2228  	// Split command line into arguments. Command name must be the first argument.
2229  	parsedArgs, err := utils.ParseCommandLine(command)
2230  	if err != nil {
2231  		return nil, fmt.Errorf("failed to parse node command: %w", err)
2232  	} else if len(parsedArgs) == 0 {
2233  		return nil, errors.New("no command provided")
2234  	}
2235  
2236  	// Look up the requested command definition.
2237  	allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
2238  	commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.CustomNodeCommandDef) bool {
2239  		return def.Name == parsedArgs[0]
2240  	})
2241  	if commandDefIdx < 0 {
2242  		return nil, fmt.Errorf("unknown command: %q", parsedArgs[0])
2243  	}
2244  
2245  	// Build flag set.
2246  	commandDef := allCommandDefs[commandDefIdx]
2247  	flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError)
2248  	for _, argDef := range commandDef.Args {
2249  		flagSet.String(argDef.Name, "", argDef.Description)
2250  	}
2251  
2252  	if err = flagSet.Parse(parsedArgs[1:]); err != nil {
2253  		return nil, fmt.Errorf("failed to parse command arguments: %w", err)
2254  	}
2255  
2256  	// Collect flags that have been set.
2257  	argValues := make(map[string]string)
2258  	flagSet.Visit(func(f *flag.Flag) {
2259  		argValues[f.Name] = f.Value.String()
2260  	})
2261  
2262  	reqArgs := make([]lnclient.CustomNodeCommandArg, 0, len(argValues))
2263  	for _, argDef := range commandDef.Args {
2264  		if argValue, ok := argValues[argDef.Name]; ok {
2265  			reqArgs = append(reqArgs, lnclient.CustomNodeCommandArg{
2266  				Name:  argDef.Name,
2267  				Value: argValue,
2268  			})
2269  		}
2270  	}
2271  
2272  	nodeResp, err := lnClient.ExecuteCustomNodeCommand(ctx, &lnclient.CustomNodeCommandRequest{
2273  		Name: commandDef.Name,
2274  		Args: reqArgs,
2275  	})
2276  	if err != nil {
2277  		return nil, fmt.Errorf("node failed to execute custom command: %w", err)
2278  	}
2279  
2280  	return nodeResp.Response, nil
2281  }
2282  
2283  func (api *api) SendEvent(event string, properties interface{}) {
2284  	api.svc.GetEventPublisher().Publish(&events.Event{
2285  		Event:      event,
2286  		Properties: properties,
2287  	})
2288  }
2289  
2290  func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
2291  	var expiresAt *time.Time
2292  	if expiresAtString != "" {
2293  		var err error
2294  		expiresAtValue, err := time.Parse(time.RFC3339, expiresAtString)
2295  		if err != nil {
2296  			logger.Logger.WithField("expiresAt", expiresAtString).Error("Invalid expiresAt")
2297  			return nil, fmt.Errorf("invalid expiresAt: %v", err)
2298  		}
2299  		expiresAt = &expiresAtValue
2300  	}
2301  	return expiresAt, nil
2302  }
2303  
2304  func (api *api) GetForwards() (*GetForwardsResponse, error) {
2305  	var forwards []db.Forward
2306  	err := api.db.Find(&forwards).Error
2307  	if err != nil {
2308  		return nil, err
2309  	}
2310  
2311  	var totalOutboundAmountMsat uint64
2312  	var totalFeeEarnedMsat uint64
2313  
2314  	for _, forward := range forwards {
2315  		totalOutboundAmountMsat += forward.OutboundAmountForwardedMsat
2316  		totalFeeEarnedMsat += forward.TotalFeeEarnedMsat
2317  	}
2318  
2319  	numForwards := len(forwards)
2320  
2321  	return &GetForwardsResponse{
2322  		OutboundAmountForwardedSat:  totalOutboundAmountMsat / 1000,
2323  		OutboundAmountForwardedMsat: totalOutboundAmountMsat,
2324  		TotalFeeEarnedSat:           totalFeeEarnedMsat / 1000,
2325  		TotalFeeEarnedMsat:          totalFeeEarnedMsat,
2326  		NumForwards:                 uint64(numForwards),
2327  	}, nil
2328  }
2329