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.Nip07AuthEnabled = api.cfg.GetNip07OwnerPubkey() != ""
1559 info.Relays = []InfoResponseRelay{}
1560 for _, relayStatus := range api.svc.GetRelayStatuses() {
1561 info.Relays = append(info.Relays, InfoResponseRelay{
1562 Url: relayStatus.Url,
1563 Online: relayStatus.Online,
1564 })
1565 }
1566
1567 info.MempoolUrl = api.cfg.GetMempoolUrl()
1568 info.AlbyAccountConnected = api.albyOAuthSvc.IsConnected(ctx)
1569
1570 albyUserIdentifier, err := api.albyOAuthSvc.GetUserIdentifier()
1571 if err != nil {
1572 logger.Logger.WithError(err).Error("Failed to get alby user identifier")
1573 return nil, err
1574 }
1575 info.AlbyUserIdentifier = albyUserIdentifier
1576
1577 if lnClient != nil {
1578 nodeInfo, err := lnClient.GetInfo(ctx)
1579 if err != nil {
1580 logger.Logger.WithError(err).Error("Failed to get nodeInfo")
1581 return nil, err
1582 }
1583
1584 info.Network = nodeInfo.Network
1585 if backendType == config.LDKBackendType {
1586 // Only LDK supports this right now. Using a local interface here
1587 // so we don't have to bloat the main LNClient interface for everyone else.
1588 type chainSourceProvider interface {
1589 GetChainDataSource() (string, string)
1590 }
1591 type lsps2SourceProvider interface {
1592 GetLiquiditySourceLsps2() string
1593 }
1594 type lsps2MinPaymentSizeProvider interface {
1595 GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64
1596 }
1597 type lsps2MaxPaymentSizeProvider interface {
1598 GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64
1599 }
1600
1601 if ldkService, ok := api.svc.GetLNClient().(chainSourceProvider); ok {
1602 info.ChainDataSourceType, info.ChainDataSourceAddress = ldkService.GetChainDataSource()
1603 }
1604 if ldkService, ok := api.svc.GetLNClient().(lsps2SourceProvider); ok {
1605 info.JitChannelsLiquiditySource = ldkService.GetLiquiditySourceLsps2()
1606 }
1607 if ldkService, ok := api.svc.GetLNClient().(lsps2MinPaymentSizeProvider); ok {
1608 info.JitChannelsMinPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MinPaymentSizeMsat()
1609 }
1610 if ldkService, ok := api.svc.GetLNClient().(lsps2MaxPaymentSizeProvider); ok {
1611 info.JitChannelsMaxPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MaxPaymentSizeMsat()
1612 }
1613 }
1614 }
1615
1616 info.NextBackupReminder, _ = api.cfg.Get("NextBackupReminder", "")
1617
1618 info.NodeAlias, _ = api.cfg.Get("NodeAlias", "")
1619
1620 return &info, nil
1621 }
1622
1623 func (api *api) setCurrency(currency string) error {
1624 if currency == "" {
1625 return fmt.Errorf("currency value cannot be empty")
1626 }
1627
1628 err := api.cfg.SetCurrency(currency)
1629 if err != nil {
1630 logger.Logger.WithError(err).Error("Failed to update currency")
1631 return err
1632 }
1633
1634 return nil
1635 }
1636
1637 func (api *api) setBitcoinDisplayFormat(format string) error {
1638 if format != constants.BITCOIN_DISPLAY_FORMAT_SATS && format != constants.BITCOIN_DISPLAY_FORMAT_BIP177 {
1639 return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177)
1640 }
1641
1642 err := api.cfg.SetBitcoinDisplayFormat(format)
1643 if err != nil {
1644 logger.Logger.WithError(err).Error("Failed to update bitcoin display format")
1645 return err
1646 }
1647
1648 return nil
1649 }
1650
1651 func (api *api) setJitChannelsEnabled(enabled bool) error {
1652 value := "true"
1653 if !enabled {
1654 value = "false"
1655 }
1656
1657 err := api.cfg.SetUpdate("JitChannelsEnabled", value, "")
1658 if err != nil {
1659 logger.Logger.WithError(err).Error("Failed to update JIT channels setting")
1660 return err
1661 }
1662
1663 return nil
1664 }
1665
1666 func (api *api) UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error {
1667 if updateSettingsRequest.Currency != "" {
1668 err := api.setCurrency(updateSettingsRequest.Currency)
1669 if err != nil {
1670 return fmt.Errorf("failed to set currency: %w", err)
1671 }
1672 }
1673
1674 if updateSettingsRequest.BitcoinDisplayFormat != "" {
1675 err := api.setBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat)
1676 if err != nil {
1677 return fmt.Errorf("failed to set bitcoin display format: %w", err)
1678 }
1679 }
1680
1681 if updateSettingsRequest.JitChannelsEnabled != nil {
1682 err := api.setJitChannelsEnabled(*updateSettingsRequest.JitChannelsEnabled)
1683 if err != nil {
1684 return fmt.Errorf("failed to set JIT channels setting: %w", err)
1685 }
1686 }
1687
1688 return nil
1689 }
1690
1691 func (api *api) SetNodeAlias(nodeAlias string) error {
1692 err := api.cfg.SetUpdate("NodeAlias", nodeAlias, "")
1693 if err != nil {
1694 logger.Logger.WithError(err).Error("Failed to save node alias to config")
1695 return err
1696 }
1697
1698 return nil
1699 }
1700
1701 func (api *api) GetMnemonic(unlockPassword string) (*MnemonicResponse, error) {
1702 if !api.cfg.CheckUnlockPassword(unlockPassword) {
1703 return nil, fmt.Errorf("wrong password")
1704 }
1705
1706 mnemonic, err := api.cfg.Get("Mnemonic", unlockPassword)
1707 if err != nil {
1708 return nil, fmt.Errorf("failed to fetch encryption key: %w", err)
1709 }
1710
1711 resp := MnemonicResponse{
1712 Mnemonic: mnemonic,
1713 }
1714
1715 return &resp, nil
1716 }
1717
1718 func (api *api) SetNextBackupReminder(backupReminderRequest *BackupReminderRequest) error {
1719 err := api.cfg.SetUpdate("NextBackupReminder", backupReminderRequest.NextBackupReminder, "")
1720 if err != nil {
1721 logger.Logger.WithError(err).Error("Failed to save next backup reminder to config")
1722 }
1723 return nil
1724 }
1725
1726 var startMutex sync.Mutex
1727
1728 func (api *api) Start(startRequest *StartRequest) {
1729 api.startupError = nil
1730 err := api.startInternal(startRequest)
1731 if err != nil {
1732 logger.Logger.WithError(err).Error("Failed to start node")
1733 api.startupError = err
1734 api.startupErrorTime = time.Now()
1735 }
1736 }
1737
1738 func (api *api) startInternal(startRequest *StartRequest) (err error) {
1739 if !startMutex.TryLock() {
1740 // do not allow to start twice in case this is somehow called twice
1741 return errors.New("app is busy")
1742 }
1743 defer startMutex.Unlock()
1744 return api.svc.StartApp(startRequest.UnlockPassword)
1745 }
1746
1747 func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
1748 if !startMutex.TryLock() {
1749 // do not allow to start twice in case this is somehow called twice
1750 return errors.New("app is busy")
1751 }
1752 defer startMutex.Unlock()
1753 info, err := api.GetInfo(ctx)
1754 if err != nil {
1755 logger.Logger.WithError(err).Error("Failed to get info")
1756 return err
1757 }
1758 if info.SetupCompleted {
1759 logger.Logger.Error("Cannot re-setup node")
1760 return errors.New("setup already completed")
1761 }
1762
1763 if setupRequest.UnlockPassword == "" {
1764 return errors.New("no unlock password provided")
1765 }
1766
1767 // Bark and Cashu both store wallet state on local disk, so they cannot
1768 // run in environments without persistent volumes (e.g. Alby Cloud). Bark
1769 // can recover spendable VTXOs from the mnemonic alone, but in-flight
1770 // payment checkpoints and wallet metadata are local-only, so persistent
1771 // storage is still required. The default OAuth client ID identifies a
1772 // local / self-hosted deployment.
1773 if !api.cfg.GetEnv().IsDefaultClientId() {
1774 switch setupRequest.LNBackendType {
1775 case config.BarkBackendType, config.CashuBackendType:
1776 return fmt.Errorf("%s backend is not supported in this environment (no persistent storage)", setupRequest.LNBackendType)
1777 }
1778 }
1779
1780 err = api.cfg.SaveUnlockPasswordCheck(setupRequest.UnlockPassword)
1781 if err != nil {
1782 return err
1783 }
1784
1785 // update next backup reminder
1786 err = api.cfg.SetUpdate("NextBackupReminder", setupRequest.NextBackupReminder, "")
1787 if err != nil {
1788 logger.Logger.WithError(err).Error("Failed to save next backup reminder")
1789 }
1790
1791 // only update non-empty values
1792 if setupRequest.LNBackendType != "" {
1793 err = api.cfg.SetUpdate("LNBackendType", setupRequest.LNBackendType, "")
1794 if err != nil {
1795 logger.Logger.WithError(err).Error("Failed to save backend type")
1796 return err
1797 }
1798 }
1799 if setupRequest.Mnemonic != "" {
1800 err = api.cfg.SetUpdate("Mnemonic", setupRequest.Mnemonic, setupRequest.UnlockPassword)
1801 if err != nil {
1802 logger.Logger.WithError(err).Error("Failed to save encrypted mnemonic")
1803 return err
1804 }
1805 }
1806 if setupRequest.LNDAddress != "" {
1807 err = api.cfg.SetUpdate("LNDAddress", setupRequest.LNDAddress, setupRequest.UnlockPassword)
1808 if err != nil {
1809 logger.Logger.WithError(err).Error("Failed to save lnd address")
1810 return err
1811 }
1812 }
1813 if setupRequest.LNDCertFile != "" {
1814 // The file path is provided by the (unauthenticated) setup request, so
1815 // only persist the content if it parses as a certificate. Storing the
1816 // re-encoded certificate(s) guarantees nothing but the parsed structure
1817 // reaches the database - e.g. a private key bundled in the same PEM file
1818 // is dropped rather than persisted.
1819 certHex, err := readAndCanonicalizeLNDCert(setupRequest.LNDCertFile)
1820 if err != nil {
1821 // Return a generic error and log the detail server-side so the
1822 // response is not a file existence/readability oracle.
1823 logger.Logger.WithError(err).Error("Failed to process lnd cert file")
1824 return errors.New("invalid LND certificate file")
1825 }
1826 err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword)
1827 if err != nil {
1828 logger.Logger.WithError(err).Error("Failed to save lnd cert hex")
1829 return err
1830 }
1831 }
1832 if setupRequest.LNDMacaroonFile != "" {
1833 // The file path is provided by the (unauthenticated) setup request, so
1834 // only persist the content if it parses as a macaroon. Storing the
1835 // re-marshalled macaroon guarantees only the parsed structure reaches
1836 // the database.
1837 macaroonHex, err := readAndCanonicalizeLNDMacaroon(setupRequest.LNDMacaroonFile)
1838 if err != nil {
1839 // Return a generic error and log the detail server-side so the
1840 // response is not a file existence/readability oracle.
1841 logger.Logger.WithError(err).Error("Failed to process lnd macaroon file")
1842 return errors.New("invalid LND macaroon file")
1843 }
1844 err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword)
1845 if err != nil {
1846 logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex")
1847 return err
1848 }
1849 }
1850
1851 if setupRequest.PhoenixdAddress != "" {
1852 err = api.cfg.SetUpdate("PhoenixdAddress", setupRequest.PhoenixdAddress, setupRequest.UnlockPassword)
1853 if err != nil {
1854 logger.Logger.WithError(err).Error("Failed to save phoenix address")
1855 return err
1856 }
1857 }
1858 if setupRequest.PhoenixdAuthorization != "" {
1859 err = api.cfg.SetUpdate("PhoenixdAuthorization", setupRequest.PhoenixdAuthorization, setupRequest.UnlockPassword)
1860 if err != nil {
1861 logger.Logger.WithError(err).Error("Failed to save phoenix auth")
1862 return err
1863 }
1864 }
1865
1866 if setupRequest.CashuMintUrl != "" {
1867 err = api.cfg.SetUpdate("CashuMintUrl", setupRequest.CashuMintUrl, setupRequest.UnlockPassword)
1868 if err != nil {
1869 logger.Logger.WithError(err).Error("Failed to save cashu mint url")
1870 return err
1871 }
1872 }
1873
1874 if setupRequest.CLNAddress != "" {
1875 err = api.cfg.SetUpdate("CLNAddress", setupRequest.CLNAddress, setupRequest.UnlockPassword)
1876 if err != nil {
1877 logger.Logger.WithError(err).Error("Failed to save CLN address")
1878 return err
1879 }
1880 }
1881
1882 if setupRequest.CLNLightningDir != "" {
1883 // The directory path is provided by the (unauthenticated) setup request.
1884 // Validate that it holds the expected CLN TLS credentials before saving,
1885 // so the path cannot be used as an existence/readability oracle for
1886 // arbitrary directories (the failure otherwise surfaces via startupError
1887 // on the anonymous /api/info response).
1888 if err := validateCLNLightningDir(setupRequest.CLNLightningDir, setupRequest.CLNAddressHold != ""); err != nil {
1889 logger.Logger.WithError(err).Error("Failed to validate CLN lightning directory")
1890 return errors.New("invalid CLN lightning directory")
1891 }
1892 err = api.cfg.SetUpdate("CLNLightningDir", setupRequest.CLNLightningDir, setupRequest.UnlockPassword)
1893 if err != nil {
1894 logger.Logger.WithError(err).Error("Failed to save CLN Lightning directory path")
1895 return err
1896 }
1897 }
1898
1899 if setupRequest.CLNAddressHold != "" {
1900 err = api.cfg.SetUpdate("CLNAddressHold", setupRequest.CLNAddressHold, setupRequest.UnlockPassword)
1901 if err != nil {
1902 logger.Logger.WithError(err).Error("Failed to save cln hold plugin address")
1903 return err
1904 }
1905 }
1906
1907 return nil
1908 }
1909
1910 // readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path,
1911 // validates that it contains at least one parseable certificate, and returns
1912 // the hex-encoded re-encoding of only the parsed certificate(s). Any non
1913 // CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are
1914 // never persisted. Callers must not reflect the returned error to the client.
1915 func readAndCanonicalizeLNDCert(path string) (string, error) {
1916 raw, err := os.ReadFile(path)
1917 if err != nil {
1918 return "", fmt.Errorf("failed to read LND cert file: %w", err)
1919 }
1920
1921 var canonical []byte
1922 rest := raw
1923 for {
1924 var block *pem.Block
1925 block, rest = pem.Decode(rest)
1926 if block == nil {
1927 break
1928 }
1929 if block.Type != "CERTIFICATE" {
1930 continue
1931 }
1932 cert, err := x509.ParseCertificate(block.Bytes)
1933 if err != nil {
1934 return "", fmt.Errorf("failed to parse LND certificate: %w", err)
1935 }
1936 canonical = append(canonical, pem.EncodeToMemory(&pem.Block{
1937 Type: "CERTIFICATE",
1938 Bytes: cert.Raw,
1939 })...)
1940 }
1941 if len(canonical) == 0 {
1942 return "", errors.New("no valid certificate found in LND cert file")
1943 }
1944
1945 return hex.EncodeToString(canonical), nil
1946 }
1947
1948 // readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path,
1949 // validates that it is a well-formed macaroon, and returns the hex-encoded
1950 // re-marshalling so that only the parsed structure is persisted. Callers must
1951 // not reflect the returned error to the client.
1952 func readAndCanonicalizeLNDMacaroon(path string) (string, error) {
1953 raw, err := os.ReadFile(path)
1954 if err != nil {
1955 return "", fmt.Errorf("failed to read LND macaroon file: %w", err)
1956 }
1957
1958 mac := &macaroon.Macaroon{}
1959 if err := mac.UnmarshalBinary(raw); err != nil {
1960 return "", fmt.Errorf("failed to parse LND macaroon: %w", err)
1961 }
1962 canonical, err := mac.MarshalBinary()
1963 if err != nil {
1964 return "", fmt.Errorf("failed to marshal LND macaroon: %w", err)
1965 }
1966
1967 return hex.EncodeToString(canonical), nil
1968 }
1969
1970 // validateCLNLightningDir checks that the given directory holds the CLN TLS
1971 // credentials that will later be loaded at connect time (ca.pem, client.pem,
1972 // client-key.pem), for each gRPC server name the config will use. This mirrors
1973 // the parses performed by the CLN client's loadTLSCredentials so a directory
1974 // that passes here is one CLN can actually use. Callers must not reflect the
1975 // returned error to the client.
1976 func validateCLNLightningDir(lightningDir string, hold bool) error {
1977 // "cln" reads the directory directly; other server names are joined as a
1978 // subdirectory, matching loadTLSCredentials in lnclient/cln.
1979 serverNames := []string{"cln"}
1980 if hold {
1981 serverNames = append(serverNames, "hold")
1982 }
1983
1984 for _, serverName := range serverNames {
1985 dir := lightningDir
1986 if serverName != "cln" {
1987 dir = filepath.Join(dir, serverName)
1988 }
1989
1990 caPEM, err := os.ReadFile(filepath.Join(dir, "ca.pem"))
1991 if err != nil {
1992 return fmt.Errorf("failed to read CLN CA cert (%s): %w", serverName, err)
1993 }
1994 if !x509.NewCertPool().AppendCertsFromPEM(caPEM) {
1995 return fmt.Errorf("failed to parse CLN CA cert (%s)", serverName)
1996 }
1997 if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "client.pem"), filepath.Join(dir, "client-key.pem")); err != nil {
1998 return fmt.Errorf("failed to load CLN client cert/key (%s): %w", serverName, err)
1999 }
2000 }
2001
2002 return nil
2003 }
2004
2005 func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) {
2006 lnClient := api.svc.GetLNClient()
2007 if lnClient == nil {
2008 return nil, ErrLNClientNotStarted
2009 }
2010
2011 methods := lnClient.GetSupportedNIP47Methods()
2012 notificationTypes := lnClient.GetSupportedNIP47NotificationTypes()
2013
2014 scopes, err := permissions.RequestMethodsToScopes(methods)
2015 if err != nil {
2016 return nil, err
2017 }
2018 if len(notificationTypes) > 0 {
2019 scopes = append(scopes, constants.NOTIFICATIONS_SCOPE)
2020 }
2021
2022 return &WalletCapabilitiesResponse{
2023 Methods: methods,
2024 NotificationTypes: notificationTypes,
2025 Scopes: scopes,
2026 }, nil
2027 }
2028
2029 func (api *api) MigrateNodeStorage(ctx context.Context, to string) error {
2030 if api.svc.GetLNClient() == nil {
2031 return ErrLNClientNotStarted
2032 }
2033 if to != "VSS" {
2034 return fmt.Errorf("migration type not supported: %s", to)
2035 }
2036
2037 ldkVssEnabled, err := api.cfg.Get("LdkVssEnabled", "")
2038 if err != nil {
2039 return err
2040 }
2041
2042 if ldkVssEnabled == "true" {
2043 return errors.New("VSS already enabled")
2044 }
2045
2046 if api.cfg.GetEnv().LDKVssUrl == "" {
2047 return errors.New("no VSS URL set")
2048 }
2049
2050 api.cfg.SetUpdate("LdkVssEnabled", "true", "")
2051 api.cfg.SetUpdate("LdkMigrateStorage", "VSS", "")
2052 return api.Stop()
2053 }
2054
2055 func (api *api) GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error) {
2056 lnClient := api.svc.GetLNClient()
2057 if lnClient == nil {
2058 return nil, ErrLNClientNotStarted
2059 }
2060 return lnClient.GetNetworkGraph(ctx, nodeIds)
2061 }
2062
2063 func (api *api) SyncWallet() error {
2064 lnClient := api.svc.GetLNClient()
2065 if lnClient == nil {
2066 return ErrLNClientNotStarted
2067 }
2068 lnClient.UpdateLastWalletSyncRequest()
2069 return nil
2070 }
2071 func (api *api) ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) {
2072 lnClient := api.svc.GetLNClient()
2073 if lnClient == nil {
2074 return nil, ErrLNClientNotStarted
2075 }
2076 transactions, err := lnClient.ListOnchainTransactions(ctx)
2077 if err != nil {
2078 return nil, err
2079 }
2080 apiTransactions := make([]OnchainTransaction, 0, len(transactions))
2081 for _, t := range transactions {
2082 apiTransactions = append(apiTransactions, OnchainTransaction{
2083 AmountSat: t.AmountSat,
2084 CreatedAt: t.CreatedAt,
2085 State: t.State,
2086 Type: t.Type,
2087 NumConfirmations: t.NumConfirmations,
2088 TxId: t.TxId,
2089 })
2090 }
2091 return apiTransactions, nil
2092 }
2093
2094 func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) {
2095 var err error
2096 var logData []byte
2097
2098 if logType == LogTypeNode {
2099 lnClient := api.svc.GetLNClient()
2100 if lnClient == nil {
2101 return nil, ErrLNClientNotStarted
2102 }
2103
2104 logData, err = lnClient.GetLogOutput(ctx, getLogRequest.MaxLen)
2105 if err != nil {
2106 return nil, err
2107 }
2108 } else if logType == LogTypeApp {
2109 logFileName := logger.GetLogFilePath()
2110 if logFileName == "" {
2111 logData = []byte("file log is disabled")
2112 } else {
2113 logData, err = utils.ReadFileTail(logFileName, getLogRequest.MaxLen)
2114 if err != nil {
2115 return nil, err
2116 }
2117 }
2118 } else {
2119 return nil, fmt.Errorf("invalid log type: '%s'", logType)
2120 }
2121
2122 return &GetLogOutputResponse{Log: string(logData)}, nil
2123 }
2124
2125 func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
2126 var alarms []HealthAlarm
2127
2128 albyInfo, err := api.albySvc.GetInfo(ctx)
2129 if err != nil {
2130 return nil, err
2131 }
2132 if !albyInfo.Healthy {
2133 alarms = append(alarms, NewHealthAlarm(HealthAlarmKindAlbyService, albyInfo.Incidents))
2134 }
2135
2136 relayStatuses := api.svc.GetRelayStatuses()
2137 if len(relayStatuses) > 0 {
2138 isAnyNostrRelayOffline := false
2139 offlineRelayUrls := []string{}
2140 for _, relayStatus := range relayStatuses {
2141 if !relayStatus.Online {
2142 isAnyNostrRelayOffline = true
2143 offlineRelayUrls = append(offlineRelayUrls, relayStatus.Url)
2144 }
2145 }
2146 if isAnyNostrRelayOffline {
2147 alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNostrRelayOffline, offlineRelayUrls))
2148 }
2149 }
2150
2151 ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
2152 if ldkVssEnabled == "true" {
2153 albyMe, err := api.albyOAuthSvc.GetMe(ctx)
2154 if err != nil {
2155 return nil, err
2156 }
2157 if albyMe.Subscription.PlanCode == "" {
2158 alarms = append(alarms, NewHealthAlarm(HealthAlarmKindVssNoSubscription, nil))
2159 }
2160 }
2161
2162 lnClient := api.svc.GetLNClient()
2163
2164 if lnClient != nil {
2165 nodeStatus, _ := lnClient.GetNodeStatus(ctx)
2166 if nodeStatus == nil || !nodeStatus.IsReady {
2167 var apiNodeStatus *NodeStatus
2168 if nodeStatus != nil {
2169 apiNodeStatus = toApiNodeStatus(nodeStatus)
2170 }
2171 alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, apiNodeStatus))
2172 }
2173
2174 channels, err := lnClient.ListChannels(ctx)
2175 if err != nil {
2176 return nil, err
2177 }
2178
2179 offlineChannels := slices.DeleteFunc(channels, func(channel lnclient.Channel) bool {
2180 if channel.Active {
2181 return true
2182 }
2183 if channel.Confirmations == nil || channel.ConfirmationsRequired == nil {
2184 return false
2185 }
2186 return *channel.Confirmations < *channel.ConfirmationsRequired
2187 })
2188
2189 if len(offlineChannels) > 0 {
2190 alarms = append(alarms, NewHealthAlarm(HealthAlarmKindChannelsOffline, nil))
2191 }
2192 }
2193
2194 return &HealthResponse{Alarms: alarms}, nil
2195 }
2196
2197 func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) {
2198 lnClient := api.svc.GetLNClient()
2199 if lnClient == nil {
2200 return nil, ErrLNClientNotStarted
2201 }
2202
2203 allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
2204 commandDefs := make([]CustomNodeCommandDef, 0, len(allCommandDefs))
2205 for _, commandDef := range allCommandDefs {
2206 argDefs := make([]CustomNodeCommandArgDef, 0, len(commandDef.Args))
2207 for _, argDef := range commandDef.Args {
2208 argDefs = append(argDefs, CustomNodeCommandArgDef{
2209 Name: argDef.Name,
2210 Description: argDef.Description,
2211 })
2212 }
2213 commandDefs = append(commandDefs, CustomNodeCommandDef{
2214 Name: commandDef.Name,
2215 Description: commandDef.Description,
2216 Args: argDefs,
2217 })
2218 }
2219
2220 return &CustomNodeCommandsResponse{Commands: commandDefs}, nil
2221 }
2222
2223 func (api *api) ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) {
2224 lnClient := api.svc.GetLNClient()
2225 if lnClient == nil {
2226 return nil, ErrLNClientNotStarted
2227 }
2228
2229 // Split command line into arguments. Command name must be the first argument.
2230 parsedArgs, err := utils.ParseCommandLine(command)
2231 if err != nil {
2232 return nil, fmt.Errorf("failed to parse node command: %w", err)
2233 } else if len(parsedArgs) == 0 {
2234 return nil, errors.New("no command provided")
2235 }
2236
2237 // Look up the requested command definition.
2238 allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
2239 commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.CustomNodeCommandDef) bool {
2240 return def.Name == parsedArgs[0]
2241 })
2242 if commandDefIdx < 0 {
2243 return nil, fmt.Errorf("unknown command: %q", parsedArgs[0])
2244 }
2245
2246 // Build flag set.
2247 commandDef := allCommandDefs[commandDefIdx]
2248 flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError)
2249 for _, argDef := range commandDef.Args {
2250 flagSet.String(argDef.Name, "", argDef.Description)
2251 }
2252
2253 if err = flagSet.Parse(parsedArgs[1:]); err != nil {
2254 return nil, fmt.Errorf("failed to parse command arguments: %w", err)
2255 }
2256
2257 // Collect flags that have been set.
2258 argValues := make(map[string]string)
2259 flagSet.Visit(func(f *flag.Flag) {
2260 argValues[f.Name] = f.Value.String()
2261 })
2262
2263 reqArgs := make([]lnclient.CustomNodeCommandArg, 0, len(argValues))
2264 for _, argDef := range commandDef.Args {
2265 if argValue, ok := argValues[argDef.Name]; ok {
2266 reqArgs = append(reqArgs, lnclient.CustomNodeCommandArg{
2267 Name: argDef.Name,
2268 Value: argValue,
2269 })
2270 }
2271 }
2272
2273 nodeResp, err := lnClient.ExecuteCustomNodeCommand(ctx, &lnclient.CustomNodeCommandRequest{
2274 Name: commandDef.Name,
2275 Args: reqArgs,
2276 })
2277 if err != nil {
2278 return nil, fmt.Errorf("node failed to execute custom command: %w", err)
2279 }
2280
2281 return nodeResp.Response, nil
2282 }
2283
2284 func (api *api) SendEvent(event string, properties interface{}) {
2285 api.svc.GetEventPublisher().Publish(&events.Event{
2286 Event: event,
2287 Properties: properties,
2288 })
2289 }
2290
2291 func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
2292 var expiresAt *time.Time
2293 if expiresAtString != "" {
2294 var err error
2295 expiresAtValue, err := time.Parse(time.RFC3339, expiresAtString)
2296 if err != nil {
2297 logger.Logger.WithField("expiresAt", expiresAtString).Error("Invalid expiresAt")
2298 return nil, fmt.Errorf("invalid expiresAt: %v", err)
2299 }
2300 expiresAt = &expiresAtValue
2301 }
2302 return expiresAt, nil
2303 }
2304
2305 func (api *api) GetForwards() (*GetForwardsResponse, error) {
2306 var forwards []db.Forward
2307 err := api.db.Find(&forwards).Error
2308 if err != nil {
2309 return nil, err
2310 }
2311
2312 var totalOutboundAmountMsat uint64
2313 var totalFeeEarnedMsat uint64
2314
2315 for _, forward := range forwards {
2316 totalOutboundAmountMsat += forward.OutboundAmountForwardedMsat
2317 totalFeeEarnedMsat += forward.TotalFeeEarnedMsat
2318 }
2319
2320 numForwards := len(forwards)
2321
2322 return &GetForwardsResponse{
2323 OutboundAmountForwardedSat: totalOutboundAmountMsat / 1000,
2324 OutboundAmountForwardedMsat: totalOutboundAmountMsat,
2325 TotalFeeEarnedSat: totalFeeEarnedMsat / 1000,
2326 TotalFeeEarnedMsat: totalFeeEarnedMsat,
2327 NumForwards: uint64(numForwards),
2328 }, nil
2329 }
2330