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