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