mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
The setup API accepts file paths for the LND certificate and macaroon and for the CLN lightning directory. Previously the raw file contents were read and stored without any validation. Validate these inputs before persisting them: - LND cert: parse the PEM and store only the re-encoded certificate(s), discarding any other PEM blocks (e.g. a bundled private key). - LND macaroon: unmarshal and store the re-marshalled macaroon. - CLN lightning directory: verify it contains the TLS credentials (ca.pem, client.pem, client-key.pem) that CLN loads at connect time, including the hold subdirectory when configured. On failure, return a generic error to the client and log the detail server-side. File paths remain supported for Umbrel-style installs. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2328 lines
73 KiB
Go
2328 lines
73 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"gopkg.in/macaroon.v2"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/getAlby/hub/alby"
|
|
"github.com/getAlby/hub/apps"
|
|
"github.com/getAlby/hub/config"
|
|
"github.com/getAlby/hub/constants"
|
|
"github.com/getAlby/hub/db"
|
|
"github.com/getAlby/hub/db/queries"
|
|
"github.com/getAlby/hub/events"
|
|
"github.com/getAlby/hub/lnclient"
|
|
"github.com/getAlby/hub/logger"
|
|
permissions "github.com/getAlby/hub/nip47/permissions"
|
|
"github.com/getAlby/hub/service"
|
|
"github.com/getAlby/hub/service/keys"
|
|
"github.com/getAlby/hub/swaps"
|
|
"github.com/getAlby/hub/utils"
|
|
"github.com/getAlby/hub/version"
|
|
)
|
|
|
|
type api struct {
|
|
db *gorm.DB
|
|
appsSvc apps.AppsService
|
|
cfg config.Config
|
|
svc service.Service
|
|
permissionsSvc permissions.PermissionsService
|
|
keys keys.Keys
|
|
albyOAuthSvc alby.AlbyOAuthService
|
|
albySvc alby.AlbyService
|
|
startupError error
|
|
startupErrorTime time.Time
|
|
eventPublisher events.EventPublisher
|
|
// set after a migration file is created; the hub is halted at that point
|
|
// and the frontend should keep showing the migration success page
|
|
nodeMigrationFileCreated atomic.Bool
|
|
}
|
|
|
|
func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albySvc alby.AlbyService, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api {
|
|
return &api{
|
|
db: gormDB,
|
|
appsSvc: apps.NewAppsService(gormDB, eventPublisher, keys, config),
|
|
cfg: config,
|
|
svc: svc,
|
|
permissionsSvc: permissions.NewPermissionsService(gormDB, eventPublisher),
|
|
keys: keys,
|
|
albySvc: albySvc,
|
|
albyOAuthSvc: albyOAuthSvc,
|
|
eventPublisher: eventPublisher,
|
|
}
|
|
}
|
|
|
|
func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppResponse, error) {
|
|
if slices.Contains(createAppRequest.Scopes, constants.SUPERUSER_SCOPE) {
|
|
if !api.cfg.CheckUnlockPassword(createAppRequest.UnlockPassword) {
|
|
return nil, fmt.Errorf(
|
|
"incorrect unlock password to create app with superuser permission")
|
|
}
|
|
}
|
|
|
|
maxAmountSat := uint64(0)
|
|
resolvedMaxAmountSat := ResolveToSat(createAppRequest.MaxAmountSat, createAppRequest.MaxAmountMsat, createAppRequest.MaxAmount, nil)
|
|
if resolvedMaxAmountSat != nil {
|
|
maxAmountSat = *resolvedMaxAmountSat
|
|
}
|
|
|
|
if createAppRequest.Name == alby.ALBY_ACCOUNT_APP_NAME {
|
|
return nil, fmt.Errorf("Reserved app name: %s", alby.ALBY_ACCOUNT_APP_NAME)
|
|
}
|
|
|
|
expiresAt, err := api.parseExpiresAt(createAppRequest.ExpiresAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid expiresAt: %v", err)
|
|
}
|
|
|
|
for _, scope := range createAppRequest.Scopes {
|
|
if !slices.Contains(permissions.AllScopes(), scope) {
|
|
return nil, fmt.Errorf("did not recognize requested scope: %s", scope)
|
|
}
|
|
}
|
|
|
|
app, pairingSecretKey, err := api.appsSvc.CreateApp(
|
|
createAppRequest.Name,
|
|
createAppRequest.Pubkey,
|
|
maxAmountSat,
|
|
createAppRequest.BudgetRenewal,
|
|
expiresAt,
|
|
createAppRequest.Scopes,
|
|
createAppRequest.Isolated,
|
|
createAppRequest.Metadata,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
relayUrls := api.cfg.GetRelayUrls()
|
|
|
|
lightningAddress, err := api.albyOAuthSvc.GetLightningAddress()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
responseBody := &CreateAppResponse{}
|
|
responseBody.Id = app.ID
|
|
responseBody.Name = app.Name
|
|
responseBody.Pubkey = app.AppPubkey
|
|
responseBody.PairingSecret = pairingSecretKey
|
|
responseBody.WalletPubkey = *app.WalletPubkey
|
|
responseBody.RelayUrls = relayUrls
|
|
responseBody.Lud16 = lightningAddress
|
|
|
|
responseBody.ReturnTo = buildReturnToUrl(createAppRequest.ReturnTo, relayUrls, *app.WalletPubkey, lightningAddress, app.Isolated)
|
|
|
|
var lud16 string
|
|
if lightningAddress != "" && !app.Isolated {
|
|
lud16 = fmt.Sprintf("&lud16=%s", lightningAddress)
|
|
}
|
|
responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", *app.WalletPubkey, strings.Join(relayUrls, "&relay="), pairingSecretKey, lud16)
|
|
|
|
return responseBody, nil
|
|
}
|
|
|
|
// buildReturnToUrl adds the connection query parameters to the return_to
|
|
// URL the user will be redirected to. Only http and https URLs are accepted.
|
|
func buildReturnToUrl(returnTo string, relayUrls []string, walletPubkey string, lightningAddress string, isolated bool) string {
|
|
if returnTo == "" {
|
|
return ""
|
|
}
|
|
returnToUrl, err := url.Parse(returnTo)
|
|
if err != nil || (returnToUrl.Scheme != "http" && returnToUrl.Scheme != "https") {
|
|
return ""
|
|
}
|
|
query := returnToUrl.Query()
|
|
for _, relayUrl := range relayUrls {
|
|
query.Add("relay", relayUrl)
|
|
}
|
|
query.Add("pubkey", walletPubkey)
|
|
if lightningAddress != "" && !isolated {
|
|
query.Add("lud16", lightningAddress)
|
|
}
|
|
returnToUrl.RawQuery = query.Encode()
|
|
return returnToUrl.String()
|
|
}
|
|
|
|
func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error {
|
|
resolvedMaxAmountSat := ResolveToSat(updateAppRequest.MaxAmountSat, updateAppRequest.MaxAmountMsat, updateAppRequest.MaxAmount, nil)
|
|
|
|
err := api.db.Transaction(func(tx *gorm.DB) error {
|
|
// Initialize name with current app name, update if provided
|
|
name := userApp.Name
|
|
|
|
// Update app name if provided and different
|
|
if updateAppRequest.Name != nil {
|
|
name = *updateAppRequest.Name
|
|
|
|
if name == "" {
|
|
return fmt.Errorf("won't update an app to have no name")
|
|
}
|
|
if name != userApp.Name {
|
|
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("name", name).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update app isolation if provided and different
|
|
if updateAppRequest.Isolated != nil {
|
|
isolated := *updateAppRequest.Isolated
|
|
if isolated != userApp.Isolated {
|
|
if !isolated {
|
|
var existingMetadata Metadata
|
|
if userApp.Metadata != nil {
|
|
err := json.Unmarshal(userApp.Metadata, &existingMetadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": userApp.ID,
|
|
}).Error("Failed to deserialize app metadata")
|
|
return err
|
|
}
|
|
if existingMetadata[constants.METADATA_APPSTORE_APP_ID_KEY] == constants.SUBWALLET_APPSTORE_APP_ID {
|
|
return errors.New("Cannot update sub-wallet to be non-isolated")
|
|
}
|
|
}
|
|
}
|
|
|
|
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("isolated", isolated).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the app metadata if provided
|
|
if updateAppRequest.Metadata != nil {
|
|
var metadataBytes []byte
|
|
var err error
|
|
metadataBytes, err = json.Marshal(*updateAppRequest.Metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to serialize metadata")
|
|
return err
|
|
}
|
|
err = tx.Model(&db.App{}).Where("id", userApp.ID).Update("metadata", datatypes.JSON(metadataBytes)).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Handle permissions updates only if any permission-related field is provided
|
|
if updateAppRequest.Scopes != nil || resolvedMaxAmountSat != nil ||
|
|
updateAppRequest.BudgetRenewal != nil || updateAppRequest.ExpiresAt != nil || updateAppRequest.UpdateExpiresAt {
|
|
|
|
// Get current values or use provided ones
|
|
var maxAmountSat uint64
|
|
var budgetRenewal string
|
|
var expiresAt *time.Time
|
|
|
|
// Get existing permissions to use as defaults
|
|
var existingPermissions []db.AppPermission
|
|
if err := tx.Where("app_id = ?", userApp.ID).Find(&existingPermissions).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Use existing values as defaults
|
|
if len(existingPermissions) > 0 {
|
|
// Find pay_invoice permission for budget-related fields
|
|
for _, perm := range existingPermissions {
|
|
if perm.Scope == constants.PAY_INVOICE_SCOPE {
|
|
maxAmountSat = uint64(perm.MaxAmountSat)
|
|
budgetRenewal = perm.BudgetRenewal
|
|
expiresAt = perm.ExpiresAt
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Override with provided values
|
|
if resolvedMaxAmountSat != nil {
|
|
maxAmountSat = *resolvedMaxAmountSat
|
|
}
|
|
if updateAppRequest.BudgetRenewal != nil {
|
|
budgetRenewal = *updateAppRequest.BudgetRenewal
|
|
}
|
|
if updateAppRequest.ExpiresAt != nil {
|
|
parsedExpiresAt, err := api.parseExpiresAt(*updateAppRequest.ExpiresAt)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid expiresAt: %v", err)
|
|
}
|
|
expiresAt = parsedExpiresAt
|
|
}
|
|
if updateAppRequest.ExpiresAt == nil && updateAppRequest.UpdateExpiresAt {
|
|
expiresAt = nil
|
|
}
|
|
|
|
// Update existing permissions with new budget and expiry
|
|
err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{
|
|
"ExpiresAt": expiresAt,
|
|
"MaxAmountSat": maxAmountSat,
|
|
"BudgetRenewal": budgetRenewal,
|
|
}).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Handle scope changes only if scopes were provided
|
|
if updateAppRequest.Scopes != nil {
|
|
|
|
if len(updateAppRequest.Scopes) == 0 {
|
|
return fmt.Errorf("won't update an app to have no request methods")
|
|
}
|
|
|
|
existingScopeMap := make(map[string]bool)
|
|
for _, perm := range existingPermissions {
|
|
existingScopeMap[perm.Scope] = true
|
|
}
|
|
|
|
if slices.Contains(updateAppRequest.Scopes, constants.SUPERUSER_SCOPE) && !existingScopeMap[constants.SUPERUSER_SCOPE] {
|
|
return fmt.Errorf("cannot update app to add superuser permission")
|
|
}
|
|
|
|
// Add new permissions
|
|
for _, scope := range updateAppRequest.Scopes {
|
|
if !existingScopeMap[scope] {
|
|
perm := db.AppPermission{
|
|
App: *userApp,
|
|
Scope: scope,
|
|
ExpiresAt: expiresAt,
|
|
MaxAmountSat: int(maxAmountSat),
|
|
BudgetRenewal: budgetRenewal,
|
|
}
|
|
if err := tx.Create(&perm).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
delete(existingScopeMap, scope)
|
|
}
|
|
|
|
// Remove old permissions
|
|
for scope := range existingScopeMap {
|
|
if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, scope).Delete(&db.AppPermission{}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Publish update event
|
|
api.svc.GetEventPublisher().Publish(&events.Event{
|
|
Event: "nwc_app_updated",
|
|
Properties: map[string]interface{}{
|
|
"name": name,
|
|
"id": userApp.ID,
|
|
},
|
|
})
|
|
|
|
// commit transaction
|
|
return nil
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func (api *api) DeleteApp(userApp *db.App) error {
|
|
// Delete lightning address if one exists
|
|
if api.appsSvc.HasLightningAddress(userApp) {
|
|
err := api.DeleteLightningAddress(context.Background(), userApp.ID)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": userApp.ID,
|
|
}).Error("Failed to delete lightning address during app deletion")
|
|
}
|
|
}
|
|
|
|
return api.appsSvc.DeleteApp(userApp)
|
|
}
|
|
|
|
func (api *api) CreateLightningAddress(ctx context.Context, createLightningAddressRequest *CreateLightningAddressRequest) error {
|
|
app := api.appsSvc.GetAppById(createLightningAddressRequest.AppId)
|
|
if app == nil {
|
|
return errors.New("app not found")
|
|
}
|
|
|
|
var metadata map[string]interface{}
|
|
err := json.Unmarshal(app.Metadata, &metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": app.ID,
|
|
}).Error("Failed to deserialize app metadata")
|
|
return err
|
|
}
|
|
|
|
createLightningAddressResponse, err := api.albyOAuthSvc.CreateLightningAddress(ctx, createLightningAddressRequest.Address, createLightningAddressRequest.AppId)
|
|
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to create lightning address for app")
|
|
return err
|
|
}
|
|
|
|
metadata["lud16"] = createLightningAddressResponse.FullAddress
|
|
err = api.appsSvc.SetAppMetadata(app.ID, metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to add lightning address to app metadata")
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (api *api) DeleteLightningAddress(ctx context.Context, appId uint) error {
|
|
app := api.appsSvc.GetAppById(appId)
|
|
if app == nil {
|
|
return errors.New("app not found")
|
|
}
|
|
|
|
var metadata map[string]interface{}
|
|
err := json.Unmarshal(app.Metadata, &metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": app.ID,
|
|
}).Error("Failed to deserialize app metadata")
|
|
return err
|
|
}
|
|
|
|
if metadata["lud16"] == nil {
|
|
return errors.New("no lightning address set")
|
|
}
|
|
|
|
lud16 := metadata["lud16"].(string)
|
|
if !strings.Contains(lud16, "@") {
|
|
return errors.New("invalid lightning address")
|
|
}
|
|
address := strings.Split(lud16, "@")[0]
|
|
|
|
// Call the Alby OAuth service to delete the lightning address
|
|
err = api.albyOAuthSvc.DeleteLightningAddress(ctx, address)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to delete lightning address for app")
|
|
return err
|
|
}
|
|
|
|
delete(metadata, "lud16")
|
|
err = api.appsSvc.SetAppMetadata(app.ID, metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to remove lightning address from app metadata")
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) GetApp(dbApp *db.App) (*App, error) {
|
|
|
|
paySpecificPermission := db.AppPermission{}
|
|
appPermissions := []db.AppPermission{}
|
|
var expiresAt *time.Time
|
|
if err := api.db.Where("app_id = ?", dbApp.ID).Find(&appPermissions).Error; err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": dbApp.ID,
|
|
}).Error("Failed to list app permissions")
|
|
return nil, err
|
|
}
|
|
|
|
requestMethods := []string{}
|
|
for _, appPerm := range appPermissions {
|
|
expiresAt = appPerm.ExpiresAt
|
|
if appPerm.Scope == constants.PAY_INVOICE_SCOPE {
|
|
// find the pay_invoice-specific permissions
|
|
paySpecificPermission = appPerm
|
|
}
|
|
requestMethods = append(requestMethods, appPerm.Scope)
|
|
}
|
|
|
|
// renewsIn := ""
|
|
maxAmountSat := uint64(paySpecificPermission.MaxAmountSat)
|
|
budgetUsageMsat, err := queries.GetBudgetUsageMsat(api.db, &paySpecificPermission)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": dbApp.ID,
|
|
}).Error("Failed to get budget usage for app")
|
|
return nil, err
|
|
}
|
|
|
|
var metadata Metadata
|
|
if dbApp.Metadata != nil {
|
|
jsonErr := json.Unmarshal(dbApp.Metadata, &metadata)
|
|
if jsonErr != nil {
|
|
logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
|
|
"app_id": dbApp.ID,
|
|
}).Error("Failed to deserialize app metadata")
|
|
}
|
|
}
|
|
|
|
walletPubkey := api.keys.GetNostrPublicKey()
|
|
uniqueWalletPubkey := false
|
|
if dbApp.WalletPubkey != nil {
|
|
walletPubkey = *dbApp.WalletPubkey
|
|
uniqueWalletPubkey = true
|
|
}
|
|
|
|
response := App{
|
|
ID: dbApp.ID,
|
|
Name: dbApp.Name,
|
|
Description: dbApp.Description,
|
|
CreatedAt: dbApp.CreatedAt,
|
|
UpdatedAt: dbApp.UpdatedAt,
|
|
AppPubkey: dbApp.AppPubkey,
|
|
ExpiresAt: expiresAt,
|
|
MaxAmount: maxAmountSat,
|
|
MaxAmountSat: maxAmountSat,
|
|
MaxAmountMsat: maxAmountSat * 1000,
|
|
Scopes: requestMethods,
|
|
BudgetUsage: budgetUsageMsat / 1000,
|
|
BudgetUsageSat: budgetUsageMsat / 1000,
|
|
BudgetUsageMsat: budgetUsageMsat,
|
|
BudgetRenewal: paySpecificPermission.BudgetRenewal,
|
|
Isolated: dbApp.Isolated,
|
|
Metadata: metadata,
|
|
WalletPubkey: walletPubkey,
|
|
UniqueWalletPubkey: uniqueWalletPubkey,
|
|
LastUsedAt: dbApp.LastUsedAt,
|
|
LastSettledTransactionAt: dbApp.LastSettledTransactionAt,
|
|
}
|
|
|
|
if dbApp.Isolated {
|
|
balanceMsat, err := queries.GetIsolatedBalanceMsat(api.db, dbApp.ID)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": dbApp.ID,
|
|
}).Error("Failed to get isolated app balance")
|
|
return nil, err
|
|
}
|
|
response.Balance = balanceMsat
|
|
response.BalanceSat = balanceMsat / 1000
|
|
response.BalanceMsat = balanceMsat
|
|
}
|
|
|
|
return &response, nil
|
|
}
|
|
|
|
func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error) {
|
|
// TODO: join dbApps and permissions
|
|
dbApps := []db.App{}
|
|
query := api.db
|
|
|
|
if filters.Name != "" {
|
|
// searching for "Damus" will return "Damus" and "Damus (1)"
|
|
// Use case-insensitive search for both SQLite and PostgreSQL
|
|
if api.db.Dialector.Name() == "postgres" {
|
|
query = query.Where("name ILIKE ?", filters.Name+"%")
|
|
} else {
|
|
query = query.Where("name LIKE ?", filters.Name+"%")
|
|
}
|
|
}
|
|
|
|
if filters.AppStoreAppId != "" {
|
|
query = query.Where(datatypes.JSONQuery("metadata").Equals(filters.AppStoreAppId, constants.METADATA_APPSTORE_APP_ID_KEY))
|
|
}
|
|
|
|
if filters.Unused {
|
|
// find unused non-subwallet apps not used in the past 60 days
|
|
query = query.Where("last_used_at IS NULL OR last_used_at < ?", time.Now().Add(-60*24*time.Hour))
|
|
}
|
|
|
|
if filters.SubWallets != nil {
|
|
if *filters.SubWallets {
|
|
query = query.Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY))
|
|
} else {
|
|
// exclude subwallets :scream:
|
|
if api.db.Dialector.Name() == "sqlite" {
|
|
query = query.Where(fmt.Sprintf("metadata is NULL OR JSON_EXTRACT(metadata, '$.%s') IS NULL OR JSON_EXTRACT(metadata, '$.%s') != ?", constants.METADATA_APPSTORE_APP_ID_KEY, constants.METADATA_APPSTORE_APP_ID_KEY), constants.SUBWALLET_APPSTORE_APP_ID)
|
|
} else {
|
|
query = query.Where(fmt.Sprintf("metadata IS NULL OR metadata->>'%s' IS NULL OR metadata->>'%s' != ?", constants.METADATA_APPSTORE_APP_ID_KEY, constants.METADATA_APPSTORE_APP_ID_KEY), constants.SUBWALLET_APPSTORE_APP_ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
query = query.Order(resolveAppOrderBy(orderBy))
|
|
|
|
if limit == 0 {
|
|
limit = 100
|
|
}
|
|
var totalCount int64
|
|
result := query.Model(&db.App{}).Count(&totalCount)
|
|
if result.Error != nil {
|
|
logger.Logger.WithError(result.Error).Error("Failed to count DB apps")
|
|
return nil, result.Error
|
|
}
|
|
|
|
var totalBalance *int64
|
|
var totalBalanceSat *int64
|
|
if filters.SubWallets != nil && *filters.SubWallets {
|
|
totalBalanceMsat, err := queries.GetTotalSubwalletBalanceMsat(api.db)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to calculate total subwallet balance")
|
|
return nil, err
|
|
}
|
|
totalBalance = &totalBalanceMsat
|
|
totalBalanceSatVal := totalBalanceMsat / 1000
|
|
totalBalanceSat = &totalBalanceSatVal
|
|
}
|
|
|
|
query = query.Offset(int(offset)).Limit(int(limit))
|
|
|
|
err := query.Find(&dbApps).Error
|
|
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to list apps")
|
|
return nil, err
|
|
}
|
|
|
|
appIds := []uint64{}
|
|
for _, app := range dbApps {
|
|
appIds = append(appIds, uint64(app.ID))
|
|
}
|
|
|
|
appPermissions := []db.AppPermission{}
|
|
err = api.db.Where("app_id IN ?", appIds).Find(&appPermissions).Error
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to list app permissions")
|
|
return nil, err
|
|
}
|
|
|
|
permissionsMap := make(map[uint][]db.AppPermission)
|
|
for _, perm := range appPermissions {
|
|
permissionsMap[perm.AppId] = append(permissionsMap[perm.AppId], perm)
|
|
}
|
|
|
|
apiApps := []App{}
|
|
for _, dbApp := range dbApps {
|
|
walletPubkey := api.keys.GetNostrPublicKey()
|
|
uniqueWalletPubkey := false
|
|
if dbApp.WalletPubkey != nil {
|
|
walletPubkey = *dbApp.WalletPubkey
|
|
uniqueWalletPubkey = true
|
|
}
|
|
apiApp := App{
|
|
ID: dbApp.ID,
|
|
Name: dbApp.Name,
|
|
Description: dbApp.Description,
|
|
CreatedAt: dbApp.CreatedAt,
|
|
UpdatedAt: dbApp.UpdatedAt,
|
|
AppPubkey: dbApp.AppPubkey,
|
|
Isolated: dbApp.Isolated,
|
|
WalletPubkey: walletPubkey,
|
|
UniqueWalletPubkey: uniqueWalletPubkey,
|
|
LastUsedAt: dbApp.LastUsedAt,
|
|
LastSettledTransactionAt: dbApp.LastSettledTransactionAt,
|
|
}
|
|
|
|
if dbApp.Isolated {
|
|
balanceMsat, err := queries.GetIsolatedBalanceMsat(api.db, dbApp.ID)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": dbApp.ID,
|
|
}).Error("Failed to get isolated app balance")
|
|
return nil, err
|
|
}
|
|
apiApp.Balance = balanceMsat
|
|
apiApp.BalanceSat = balanceMsat / 1000
|
|
apiApp.BalanceMsat = balanceMsat
|
|
}
|
|
|
|
for _, appPermission := range permissionsMap[dbApp.ID] {
|
|
apiApp.Scopes = append(apiApp.Scopes, appPermission.Scope)
|
|
apiApp.ExpiresAt = appPermission.ExpiresAt
|
|
if appPermission.Scope == constants.PAY_INVOICE_SCOPE {
|
|
apiApp.BudgetRenewal = appPermission.BudgetRenewal
|
|
apiApp.MaxAmount = uint64(appPermission.MaxAmountSat)
|
|
apiApp.MaxAmountSat = uint64(appPermission.MaxAmountSat)
|
|
apiApp.MaxAmountMsat = uint64(appPermission.MaxAmountSat) * 1000
|
|
budgetUsageMsat, err := queries.GetBudgetUsageMsat(api.db, &appPermission)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"app_id": dbApp.ID,
|
|
}).Error("Failed to get budget usage for app")
|
|
return nil, err
|
|
}
|
|
apiApp.BudgetUsage = budgetUsageMsat / 1000
|
|
apiApp.BudgetUsageSat = budgetUsageMsat / 1000
|
|
apiApp.BudgetUsageMsat = budgetUsageMsat
|
|
}
|
|
}
|
|
|
|
var metadata Metadata
|
|
if dbApp.Metadata != nil {
|
|
jsonErr := json.Unmarshal(dbApp.Metadata, &metadata)
|
|
if jsonErr != nil {
|
|
logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
|
|
"app_id": dbApp.ID,
|
|
}).Error("Failed to deserialize app metadata")
|
|
}
|
|
apiApp.Metadata = metadata
|
|
}
|
|
|
|
apiApps = append(apiApps, apiApp)
|
|
}
|
|
return &ListAppsResponse{
|
|
Apps: apiApps,
|
|
TotalCount: uint64(totalCount),
|
|
TotalBalance: totalBalance,
|
|
TotalBalanceSat: totalBalanceSat,
|
|
TotalBalanceMsat: totalBalance,
|
|
}, nil
|
|
}
|
|
|
|
func resolveAppOrderBy(orderBy string) string {
|
|
switch orderBy {
|
|
case "created_at":
|
|
return "created_at DESC"
|
|
case "last_settled_transaction":
|
|
return "last_settled_transaction_at IS NULL, last_settled_transaction_at DESC"
|
|
default:
|
|
return "last_used_at IS NULL, last_used_at DESC"
|
|
}
|
|
}
|
|
|
|
func (api *api) ListChannels(ctx context.Context) ([]Channel, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
channels, err := lnClient.ListChannels(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
apiChannels := []Channel{}
|
|
for _, channel := range channels {
|
|
status := "offline"
|
|
if channel.Active {
|
|
status = "online"
|
|
} else if channel.Confirmations != nil && channel.ConfirmationsRequired != nil && *channel.ConfirmationsRequired > *channel.Confirmations {
|
|
status = "opening"
|
|
}
|
|
|
|
apiChannels = append(apiChannels, Channel{
|
|
LocalBalance: channel.LocalBalanceMsat,
|
|
LocalBalanceSat: channel.LocalBalanceMsat / 1000,
|
|
LocalBalanceMsat: channel.LocalBalanceMsat,
|
|
LocalSpendableBalance: channel.LocalSpendableBalanceMsat,
|
|
LocalSpendableBalanceSat: channel.LocalSpendableBalanceMsat / 1000,
|
|
LocalSpendableBalanceMsat: channel.LocalSpendableBalanceMsat,
|
|
RemoteBalance: channel.RemoteBalanceMsat,
|
|
RemoteBalanceSat: channel.RemoteBalanceMsat / 1000,
|
|
RemoteBalanceMsat: channel.RemoteBalanceMsat,
|
|
Id: channel.Id,
|
|
RemotePubkey: channel.RemotePubkey,
|
|
FundingTxId: channel.FundingTxId,
|
|
FundingTxVout: channel.FundingTxVout,
|
|
Active: channel.Active,
|
|
Public: channel.Public,
|
|
InternalChannel: channel.InternalChannel,
|
|
Confirmations: channel.Confirmations,
|
|
ConfirmationsRequired: channel.ConfirmationsRequired,
|
|
ForwardingFeeBaseMsat: channel.ForwardingFeeBaseMsat,
|
|
ForwardingFeeProportionalMillionths: channel.ForwardingFeeProportionalMillionths,
|
|
UnspendablePunishmentReserve: channel.UnspendablePunishmentReserveSat,
|
|
UnspendablePunishmentReserveSat: channel.UnspendablePunishmentReserveSat,
|
|
CounterpartyUnspendablePunishmentReserve: channel.CounterpartyUnspendablePunishmentReserveSat,
|
|
CounterpartyUnspendablePunishmentReserveSat: channel.CounterpartyUnspendablePunishmentReserveSat,
|
|
Error: channel.Error,
|
|
IsOutbound: channel.IsOutbound,
|
|
Status: status,
|
|
})
|
|
}
|
|
|
|
slices.SortFunc(apiChannels, func(a, b Channel) int {
|
|
// sort by channel size first
|
|
aSize := a.LocalBalance + a.RemoteBalance
|
|
bSize := b.LocalBalance + b.RemoteBalance
|
|
if aSize != bSize {
|
|
return int(bSize - aSize)
|
|
}
|
|
|
|
// then by local balance in the channel
|
|
if a.LocalBalance != b.LocalBalance {
|
|
return int(b.LocalBalance - a.LocalBalance)
|
|
}
|
|
|
|
// finally sort by channel ID to prevent sort randomly changing
|
|
return strings.Compare(b.Id, a.Id)
|
|
})
|
|
|
|
return apiChannels, nil
|
|
}
|
|
|
|
func (api *api) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error) {
|
|
return api.albySvc.GetChannelPeerSuggestions(ctx)
|
|
}
|
|
|
|
func (api *api) GetStories(ctx context.Context) ([]alby.Story, error) {
|
|
return api.albyOAuthSvc.GetStories(ctx)
|
|
}
|
|
|
|
func (api *api) GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error) {
|
|
return api.albyOAuthSvc.GetLSPChannelOffer(ctx)
|
|
}
|
|
|
|
func (api *api) ResetRouter(key string) error {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
err := lnClient.ResetRouter(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Because the above method has to stop the node to reset the router,
|
|
// We also need to stop the lnclient and ask the user to start it again
|
|
return api.Stop()
|
|
}
|
|
|
|
func (api *api) ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error {
|
|
if api.svc.GetLNClient() == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
|
|
autoUnlockPassword, err := api.cfg.Get("AutoUnlockPassword", "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if autoUnlockPassword != "" {
|
|
return errors.New("please disable auto-unlock before using this feature")
|
|
}
|
|
|
|
err = api.cfg.ChangeUnlockPassword(changeUnlockPasswordRequest.CurrentUnlockPassword, changeUnlockPasswordRequest.NewUnlockPassword)
|
|
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("failed to change unlock password")
|
|
return err
|
|
}
|
|
|
|
// Because all the encrypted fields have changed
|
|
// we also need to stop the lnclient and ask the user to start it again
|
|
return api.Stop()
|
|
}
|
|
|
|
func (api *api) SetAutoUnlockPassword(unlockPassword string) error {
|
|
if api.svc.GetLNClient() == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
|
|
err := api.cfg.SetAutoUnlockPassword(unlockPassword)
|
|
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("failed to set auto unlock password")
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) Stop() error {
|
|
if !startMutex.TryLock() {
|
|
// do not allow to stop twice in case this is somehow called twice
|
|
return errors.New("app is busy")
|
|
}
|
|
defer startMutex.Unlock()
|
|
|
|
logger.Logger.Info("Running Stop command")
|
|
if api.svc.GetLNClient() == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
|
|
// stop the lnclient, nostr relay etc.
|
|
// The user will be forced to re-enter their unlock password to restart the node
|
|
api.svc.StopApp()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
info, err := lnClient.GetNodeConnectionInfo(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &NodeConnectionInfo{
|
|
Pubkey: info.Pubkey,
|
|
Address: info.Address,
|
|
Port: info.Port,
|
|
}, nil
|
|
}
|
|
|
|
func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error {
|
|
if api.svc.GetSwapsService() == nil {
|
|
return errors.New("SwapsService not started")
|
|
}
|
|
return api.svc.GetSwapsService().RefundSwap(refundSwapRequest.SwapId, refundSwapRequest.Address, false)
|
|
}
|
|
|
|
func (api *api) GetAutoSwapConfig() (*GetAutoSwapConfigResponse, error) {
|
|
if api.svc.GetSwapsService() == nil {
|
|
return nil, errors.New("SwapsService not started")
|
|
}
|
|
|
|
swapOutBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "")
|
|
swapOutAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "")
|
|
swapOutDestination, _ := api.cfg.Get(config.AutoSwapDestinationKey, "")
|
|
|
|
if xpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub(); xpub != "" {
|
|
swapOutDestination = xpub
|
|
}
|
|
|
|
swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != ""
|
|
var swapOutBalanceThresholdSat, swapOutAmountSat uint64
|
|
if swapOutEnabled {
|
|
var err error
|
|
if swapOutBalanceThresholdSat, err = strconv.ParseUint(swapOutBalanceThresholdStr, 10, 64); err != nil {
|
|
return nil, fmt.Errorf("invalid autoswap out balance threshold: %w", err)
|
|
}
|
|
if swapOutAmountSat, err = strconv.ParseUint(swapOutAmountStr, 10, 64); err != nil {
|
|
return nil, fmt.Errorf("invalid autoswap out amount: %w", err)
|
|
}
|
|
}
|
|
|
|
return &GetAutoSwapConfigResponse{
|
|
Type: constants.SWAP_TYPE_OUT,
|
|
Enabled: swapOutEnabled,
|
|
BalanceThreshold: swapOutBalanceThresholdSat,
|
|
BalanceThresholdSat: swapOutBalanceThresholdSat,
|
|
SwapAmount: swapOutAmountSat,
|
|
SwapAmountSat: swapOutAmountSat,
|
|
Destination: swapOutDestination,
|
|
}, nil
|
|
}
|
|
|
|
func (api *api) LookupSwap(swapId string) (*LookupSwapResponse, error) {
|
|
if api.svc.GetSwapsService() == nil {
|
|
return nil, errors.New("SwapsService not started")
|
|
}
|
|
dbSwap, err := api.svc.GetSwapsService().GetSwap(swapId)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("failed to fetch swap info")
|
|
return nil, err
|
|
}
|
|
|
|
return toApiSwap(dbSwap), nil
|
|
}
|
|
|
|
func (api *api) ListSwaps() (*ListSwapsResponse, error) {
|
|
if api.svc.GetSwapsService() == nil {
|
|
return nil, errors.New("SwapsService not started")
|
|
}
|
|
swaps, err := api.svc.GetSwapsService().ListSwaps()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
apiSwaps := []Swap{}
|
|
for _, swap := range swaps {
|
|
apiSwaps = append(apiSwaps, *toApiSwap(&swap))
|
|
}
|
|
|
|
return &ListSwapsResponse{
|
|
Swaps: apiSwaps,
|
|
}, nil
|
|
}
|
|
|
|
func toApiSwap(swap *swaps.Swap) *Swap {
|
|
return &Swap{
|
|
Id: swap.SwapId,
|
|
Type: swap.Type,
|
|
State: swap.State,
|
|
Invoice: swap.Invoice,
|
|
SendAmount: swap.SendAmountSat,
|
|
SendAmountSat: swap.SendAmountSat,
|
|
ReceiveAmount: swap.ReceiveAmountSat,
|
|
ReceiveAmountSat: swap.ReceiveAmountSat,
|
|
PaymentHash: swap.PaymentHash,
|
|
DestinationAddress: swap.DestinationAddress,
|
|
RefundAddress: swap.RefundAddress,
|
|
LockupAddress: swap.LockupAddress,
|
|
LockupTxId: swap.LockupTxId,
|
|
ClaimTxId: swap.ClaimTxId,
|
|
AutoSwap: swap.AutoSwap,
|
|
BoltzPubkey: swap.BoltzPubkey,
|
|
CreatedAt: swap.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: swap.UpdatedAt.Format(time.RFC3339),
|
|
UsedXpub: swap.UsedXpub,
|
|
}
|
|
}
|
|
|
|
func (api *api) GetSwapInInfo() (*SwapInfoResponse, error) {
|
|
if api.svc.GetSwapsService() == nil {
|
|
return nil, errors.New("SwapsService not started")
|
|
}
|
|
swapInInfo, err := api.svc.GetSwapsService().GetSwapInInfo()
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("failed to calculate fee info")
|
|
return nil, err
|
|
}
|
|
|
|
return &SwapInfoResponse{
|
|
AlbyServiceFee: swapInInfo.AlbyServiceFee,
|
|
BoltzServiceFee: swapInInfo.BoltzServiceFee,
|
|
BoltzNetworkFee: swapInInfo.BoltzNetworkFeeSat,
|
|
BoltzNetworkFeeSat: swapInInfo.BoltzNetworkFeeSat,
|
|
MinAmount: swapInInfo.MinAmountSat,
|
|
MinAmountSat: swapInInfo.MinAmountSat,
|
|
MaxAmount: swapInInfo.MaxAmountSat,
|
|
MaxAmountSat: swapInInfo.MaxAmountSat,
|
|
}, nil
|
|
}
|
|
|
|
func (api *api) GetSwapOutInfo() (*SwapInfoResponse, error) {
|
|
if api.svc.GetSwapsService() == nil {
|
|
return nil, errors.New("SwapsService not started")
|
|
}
|
|
swapOutInfo, err := api.svc.GetSwapsService().GetSwapOutInfo()
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("failed to calculate fee info")
|
|
return nil, err
|
|
}
|
|
|
|
return &SwapInfoResponse{
|
|
AlbyServiceFee: swapOutInfo.AlbyServiceFee,
|
|
BoltzServiceFee: swapOutInfo.BoltzServiceFee,
|
|
BoltzNetworkFee: swapOutInfo.BoltzNetworkFeeSat,
|
|
BoltzNetworkFeeSat: swapOutInfo.BoltzNetworkFeeSat,
|
|
MinAmount: swapOutInfo.MinAmountSat,
|
|
MinAmountSat: swapOutInfo.MinAmountSat,
|
|
MaxAmount: swapOutInfo.MaxAmountSat,
|
|
MaxAmountSat: swapOutInfo.MaxAmountSat,
|
|
}, nil
|
|
}
|
|
|
|
func (api *api) InitiateSwapOut(ctx context.Context, initiateSwapOutRequest *InitiateSwapRequest) (*swaps.SwapResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
|
|
if api.svc.GetSwapsService() == nil {
|
|
return nil, errors.New("SwapsService not started")
|
|
}
|
|
|
|
amountSat := uint64(0)
|
|
resolvedAmountSat := ResolveToSat(initiateSwapOutRequest.SwapAmountSat, nil, initiateSwapOutRequest.SwapAmount, nil)
|
|
if resolvedAmountSat != nil {
|
|
amountSat = *resolvedAmountSat
|
|
}
|
|
destination := initiateSwapOutRequest.Destination
|
|
|
|
if amountSat == 0 {
|
|
return nil, errors.New("invalid swap amount")
|
|
}
|
|
|
|
swapOutResponse, err := api.svc.GetSwapsService().SwapOut(amountSat, destination, false, false)
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"amount_sat": amountSat,
|
|
"destination": destination,
|
|
}).WithError(err).Error("Failed to initiate swap out")
|
|
return nil, err
|
|
}
|
|
|
|
return swapOutResponse, nil
|
|
}
|
|
|
|
func (api *api) InitiateSwapIn(ctx context.Context, initiateSwapInRequest *InitiateSwapRequest) (*swaps.SwapResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
|
|
if api.svc.GetSwapsService() == nil {
|
|
return nil, errors.New("SwapsService not started")
|
|
}
|
|
|
|
amountSat := uint64(0)
|
|
resolvedAmountSat := ResolveToSat(initiateSwapInRequest.SwapAmountSat, nil, initiateSwapInRequest.SwapAmount, nil)
|
|
if resolvedAmountSat != nil {
|
|
amountSat = *resolvedAmountSat
|
|
}
|
|
|
|
if amountSat == 0 {
|
|
return nil, errors.New("invalid swap amount")
|
|
}
|
|
|
|
swapInResponse, err := api.svc.GetSwapsService().SwapIn(amountSat, false)
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"amount_sat": amountSat,
|
|
}).WithError(err).Error("Failed to initiate swap in")
|
|
return nil, err
|
|
}
|
|
|
|
return swapInResponse, nil
|
|
}
|
|
|
|
func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *EnableAutoSwapRequest) error {
|
|
if api.svc.GetSwapsService() == nil {
|
|
return errors.New("SwapsService not started")
|
|
}
|
|
|
|
encryptionKey := ""
|
|
if enableAutoSwapsRequest.Destination != "" {
|
|
switch enableAutoSwapsRequest.DestinationType {
|
|
case "address":
|
|
if err := api.svc.GetSwapsService().ValidateAddress(enableAutoSwapsRequest.Destination); err != nil {
|
|
return err
|
|
}
|
|
case "xpub":
|
|
if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) {
|
|
return errors.New("invalid unlock password")
|
|
}
|
|
if err := api.svc.GetSwapsService().ValidateXpub(enableAutoSwapsRequest.Destination); err != nil {
|
|
return err
|
|
}
|
|
encryptionKey = enableAutoSwapsRequest.UnlockPassword
|
|
default:
|
|
return errors.New("destination type must be address or xpub")
|
|
}
|
|
}
|
|
|
|
balanceThresholdSat := uint64(0)
|
|
resolvedBalanceThresholdSat := ResolveToSat(enableAutoSwapsRequest.BalanceThresholdSat, nil, enableAutoSwapsRequest.BalanceThreshold, nil)
|
|
if resolvedBalanceThresholdSat != nil {
|
|
balanceThresholdSat = *resolvedBalanceThresholdSat
|
|
}
|
|
|
|
err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, strconv.FormatUint(balanceThresholdSat, 10), "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save autoswap balance threshold to config")
|
|
return err
|
|
}
|
|
|
|
swapAmountSat := uint64(0)
|
|
resolvedSwapAmountSat := ResolveToSat(enableAutoSwapsRequest.SwapAmountSat, nil, enableAutoSwapsRequest.SwapAmount, nil)
|
|
if resolvedSwapAmountSat != nil {
|
|
swapAmountSat = *resolvedSwapAmountSat
|
|
}
|
|
|
|
err = api.cfg.SetUpdate(config.AutoSwapAmountKey, strconv.FormatUint(swapAmountSat, 10), "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save autoswap amount to config")
|
|
return err
|
|
}
|
|
|
|
err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, encryptionKey)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save autoswap destination to config")
|
|
return err
|
|
}
|
|
|
|
return api.svc.GetSwapsService().EnableAutoSwapOut(enableAutoSwapsRequest.UnlockPassword)
|
|
}
|
|
|
|
func (api *api) DisableAutoSwap() error {
|
|
keys := []string{config.AutoSwapBalanceThresholdKey, config.AutoSwapAmountKey, config.AutoSwapDestinationKey}
|
|
|
|
for _, key := range keys {
|
|
if err := api.cfg.SetUpdate(key, "", ""); err != nil {
|
|
logger.Logger.WithError(err).Errorf("Failed to remove autoswap config for key: %s", key)
|
|
return err
|
|
}
|
|
}
|
|
|
|
if api.svc.GetSwapsService() != nil {
|
|
api.svc.GetSwapsService().StopAutoSwapOut()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (api *api) GetSwapMnemonic() string {
|
|
return api.keys.GetSwapMnemonic()
|
|
}
|
|
|
|
func (api *api) GetNodeStatus(ctx context.Context) (*NodeStatus, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
nodeStatus, err := lnClient.GetNodeStatus(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if nodeStatus == nil {
|
|
return nil, nil
|
|
}
|
|
return toApiNodeStatus(nodeStatus), nil
|
|
}
|
|
|
|
func toApiNodeStatus(nodeStatus *lnclient.NodeStatus) *NodeStatus {
|
|
return &NodeStatus{
|
|
IsReady: nodeStatus.IsReady,
|
|
InternalNodeStatus: nodeStatus.InternalNodeStatus,
|
|
}
|
|
}
|
|
|
|
func (api *api) ListPeers(ctx context.Context) ([]PeerDetails, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
peers, err := lnClient.ListPeers(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
apiPeers := make([]PeerDetails, 0, len(peers))
|
|
for _, peer := range peers {
|
|
apiPeers = append(apiPeers, PeerDetails{
|
|
NodeId: peer.NodeId,
|
|
Address: peer.Address,
|
|
IsPersisted: peer.IsPersisted,
|
|
IsConnected: peer.IsConnected,
|
|
})
|
|
}
|
|
return apiPeers, nil
|
|
}
|
|
|
|
func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
return lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
|
|
Pubkey: connectPeerRequest.Pubkey,
|
|
Address: connectPeerRequest.Address,
|
|
Port: connectPeerRequest.Port,
|
|
})
|
|
}
|
|
|
|
func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
resp, err := lnClient.OpenChannel(ctx, &lnclient.OpenChannelRequest{
|
|
Pubkey: openChannelRequest.Pubkey,
|
|
AmountSats: openChannelRequest.AmountSats,
|
|
Public: openChannelRequest.Public,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &OpenChannelResponse{
|
|
FundingTxId: resp.FundingTxId,
|
|
}, nil
|
|
}
|
|
|
|
func (api *api) DisconnectPeer(ctx context.Context, peerId string) error {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"peer_id": peerId,
|
|
}).Info("Disconnecting peer")
|
|
return lnClient.DisconnectPeer(ctx, peerId)
|
|
}
|
|
|
|
func (api *api) CloseChannel(ctx context.Context, peerId, channelId string, force bool) (*CloseChannelResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"peer_id": peerId,
|
|
"channel_id": channelId,
|
|
"force": force,
|
|
}).Info("Closing channel")
|
|
err := lnClient.CloseChannel(ctx, &lnclient.CloseChannelRequest{
|
|
NodeId: peerId,
|
|
ChannelId: channelId,
|
|
Force: force,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CloseChannelResponse{}, nil
|
|
}
|
|
|
|
func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateChannelRequest) error {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"request": updateChannelRequest,
|
|
}).Info("updating channel")
|
|
return lnClient.UpdateChannel(ctx, &lnclient.UpdateChannelRequest{
|
|
ChannelId: updateChannelRequest.ChannelId,
|
|
NodeId: updateChannelRequest.NodeId,
|
|
ForwardingFeeBaseMsat: updateChannelRequest.ForwardingFeeBaseMsat,
|
|
ForwardingFeeProportionalMillionths: updateChannelRequest.ForwardingFeeProportionalMillionths,
|
|
MaxDustHtlcExposureFromFeeRateMultiplier: updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier,
|
|
})
|
|
}
|
|
|
|
func (api *api) MakeOffer(ctx context.Context, description string) (string, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return "", ErrLNClientNotStarted
|
|
}
|
|
offer, err := lnClient.MakeOffer(ctx, description)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return offer, nil
|
|
}
|
|
|
|
func (api *api) GetNewOnchainAddress(ctx context.Context) (string, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return "", ErrLNClientNotStarted
|
|
}
|
|
address, err := lnClient.GetNewOnchainAddress(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
err = api.cfg.SetUpdate(config.OnchainAddressKey, address, "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save new onchain address to config")
|
|
}
|
|
|
|
return address, nil
|
|
}
|
|
|
|
func (api *api) GetUnusedOnchainAddress(ctx context.Context) (string, error) {
|
|
if api.svc.GetLNClient() == nil {
|
|
return "", ErrLNClientNotStarted
|
|
}
|
|
|
|
currentAddress, err := api.cfg.Get(config.OnchainAddressKey, "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to get current address from config")
|
|
return "", err
|
|
}
|
|
|
|
if currentAddress != "" {
|
|
// check if address has any transactions
|
|
response, err := api.RequestEsploraApi(ctx, "/address/"+currentAddress+"/txs")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to get current address transactions")
|
|
return currentAddress, nil
|
|
}
|
|
|
|
transactions, ok := response.([]interface{})
|
|
if !ok {
|
|
logger.Logger.WithField("response", response).Error("Failed to cast esplora address txs response", response)
|
|
return currentAddress, nil
|
|
}
|
|
|
|
if len(transactions) == 0 {
|
|
// address has not been used yet
|
|
return currentAddress, nil
|
|
}
|
|
}
|
|
|
|
newAddress, err := api.GetNewOnchainAddress(ctx)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to retrieve new onchain address")
|
|
return "", err
|
|
}
|
|
return newAddress, nil
|
|
}
|
|
|
|
func (api *api) SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
signature, err := lnClient.SignMessage(ctx, message)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SignMessageResponse{
|
|
Message: message,
|
|
Signature: signature,
|
|
}, nil
|
|
}
|
|
|
|
func (api *api) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
txId, err := lnClient.RedeemOnchainFunds(ctx, toAddress, amountSat, feeRate, sendAll)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &RedeemOnchainFundsResponse{
|
|
TxId: txId,
|
|
}, nil
|
|
}
|
|
|
|
func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
balances, err := lnClient.GetBalances(ctx, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toApiBalances(balances), nil
|
|
}
|
|
|
|
func toApiBalances(balances *lnclient.BalancesResponse) *BalancesResponse {
|
|
totalSpendableMsat := balances.Lightning.TotalSpendableMsat
|
|
totalReceivableMsat := balances.Lightning.TotalReceivableMsat
|
|
nextMaxSpendableMsat := balances.Lightning.NextMaxSpendableMsat
|
|
nextMaxReceivableMsat := balances.Lightning.NextMaxReceivableMsat
|
|
nextMaxSpendableMPPMsat := balances.Lightning.NextMaxSpendableMPPMsat
|
|
nextMaxReceivableMPPMsat := balances.Lightning.NextMaxReceivableMPPMsat
|
|
|
|
return &BalancesResponse{
|
|
Onchain: OnchainBalanceResponse{
|
|
Spendable: balances.Onchain.SpendableSat,
|
|
SpendableSat: balances.Onchain.SpendableSat,
|
|
Total: balances.Onchain.TotalSat,
|
|
TotalSat: balances.Onchain.TotalSat,
|
|
Reserved: balances.Onchain.ReservedSat,
|
|
ReservedSat: balances.Onchain.ReservedSat,
|
|
PendingBalancesFromChannelClosures: balances.Onchain.PendingBalancesFromChannelClosuresSat,
|
|
PendingBalancesFromChannelClosuresSat: balances.Onchain.PendingBalancesFromChannelClosuresSat,
|
|
PendingBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingBalancesDetails),
|
|
PendingSweepBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingSweepBalancesDetails),
|
|
InternalBalances: balances.Onchain.InternalBalances,
|
|
},
|
|
Lightning: LightningBalanceResponse{
|
|
TotalSpendable: totalSpendableMsat,
|
|
TotalSpendableSat: totalSpendableMsat / 1000,
|
|
TotalSpendableMsat: totalSpendableMsat,
|
|
TotalReceivable: totalReceivableMsat,
|
|
TotalReceivableSat: totalReceivableMsat / 1000,
|
|
TotalReceivableMsat: totalReceivableMsat,
|
|
NextMaxSpendable: nextMaxSpendableMsat,
|
|
NextMaxSpendableSat: nextMaxSpendableMsat / 1000,
|
|
NextMaxSpendableMsat: nextMaxSpendableMsat,
|
|
NextMaxReceivable: nextMaxReceivableMsat,
|
|
NextMaxReceivableSat: nextMaxReceivableMsat / 1000,
|
|
NextMaxReceivableMsat: nextMaxReceivableMsat,
|
|
NextMaxSpendableMPP: nextMaxSpendableMPPMsat,
|
|
NextMaxSpendableMPPSat: nextMaxSpendableMPPMsat / 1000,
|
|
NextMaxSpendableMPPMsat: nextMaxSpendableMPPMsat,
|
|
NextMaxReceivableMPP: nextMaxReceivableMPPMsat,
|
|
NextMaxReceivableMPPSat: nextMaxReceivableMPPMsat / 1000,
|
|
NextMaxReceivableMPPMsat: nextMaxReceivableMPPMsat,
|
|
},
|
|
}
|
|
}
|
|
|
|
func toApiPendingBalanceDetails(details []lnclient.PendingBalanceDetails) []PendingBalanceDetails {
|
|
if details == nil {
|
|
return nil
|
|
}
|
|
apiDetails := make([]PendingBalanceDetails, 0, len(details))
|
|
for _, d := range details {
|
|
apiDetails = append(apiDetails, PendingBalanceDetails{
|
|
ChannelId: d.ChannelId,
|
|
NodeId: d.NodeId,
|
|
Amount: d.AmountSat,
|
|
AmountSat: d.AmountSat,
|
|
FundingTxId: d.FundingTxId,
|
|
FundingTxVout: d.FundingTxVout,
|
|
})
|
|
}
|
|
return apiDetails
|
|
}
|
|
|
|
// TODO: remove dependency on this endpoint
|
|
func (api *api) RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error) {
|
|
url := api.cfg.GetEnv().MempoolApi + endpoint
|
|
|
|
client := http.Client{
|
|
Timeout: time.Second * 10,
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"url": url,
|
|
}).Error("Failed to create http request")
|
|
return nil, err
|
|
}
|
|
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"url": url,
|
|
}).Error("Failed to send request")
|
|
return nil, err
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
body, readErr := io.ReadAll(res.Body)
|
|
if readErr != nil {
|
|
logger.Logger.WithError(err).WithFields(logrus.Fields{
|
|
"url": url,
|
|
}).Error("Failed to read response body")
|
|
return nil, errors.New("failed to read response body")
|
|
}
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"endpoint": endpoint,
|
|
"status_code": res.StatusCode,
|
|
"body": string(body),
|
|
}).Error("Mempool endpoint returned non-success code")
|
|
return nil, fmt.Errorf("mempool endpoint returned non-success code: %s", string(body))
|
|
}
|
|
|
|
var jsonContent interface{}
|
|
jsonErr := json.Unmarshal(body, &jsonContent)
|
|
if jsonErr != nil {
|
|
logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
|
|
"url": url,
|
|
}).Error("Failed to deserialize json")
|
|
return nil, fmt.Errorf("failed to deserialize json %s %s", url, string(body))
|
|
}
|
|
return jsonContent, nil
|
|
}
|
|
|
|
func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
|
|
info := InfoResponse{}
|
|
|
|
if api.nodeMigrationFileCreated.Load() {
|
|
// the hub is halted and the database is closed after a migration file
|
|
// is created, so return a minimal response without reading any config
|
|
// or node state; the frontend only needs the flag to keep showing the
|
|
// migration success page
|
|
info.NodeMigrationFileCreated = true
|
|
info.SetupCompleted = true
|
|
info.Version = version.Tag
|
|
info.Relays = []InfoResponseRelay{}
|
|
return &info, nil
|
|
}
|
|
|
|
backendType, _ := api.cfg.Get("LNBackendType", "")
|
|
ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
|
|
jitChannelsEnabled, _ := api.cfg.Get("JitChannelsEnabled", "")
|
|
autoUnlockPassword, _ := api.cfg.Get("AutoUnlockPassword", "")
|
|
setupCompleted, err := api.cfg.SetupCompleted()
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to check if setup is completed")
|
|
return nil, err
|
|
}
|
|
info.SetupCompleted = setupCompleted
|
|
info.Currency = api.cfg.GetCurrency()
|
|
info.BitcoinDisplayFormat = api.cfg.GetBitcoinDisplayFormat()
|
|
info.StartupState = api.svc.GetStartupState()
|
|
if api.startupError != nil {
|
|
info.StartupError = api.startupError.Error()
|
|
info.StartupErrorTime = api.startupErrorTime
|
|
}
|
|
lnClient := api.svc.GetLNClient()
|
|
info.Running = lnClient != nil
|
|
info.NodeMigrationFileCreated = api.nodeMigrationFileCreated.Load()
|
|
info.BackendType = backendType
|
|
info.AlbyAuthUrl = api.albyOAuthSvc.GetAuthUrl()
|
|
info.OAuthRedirect = !api.cfg.GetEnv().IsDefaultClientId()
|
|
info.Version = version.Tag
|
|
info.EnableAdvancedSetup = api.cfg.GetEnv().EnableAdvancedSetup
|
|
info.HideUpdateBanner = api.cfg.GetEnv().HideUpdateBanner
|
|
info.LdkVssEnabled = ldkVssEnabled == "true"
|
|
info.JitChannelsEnabled = jitChannelsEnabled != "false"
|
|
info.VssSupported = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKVssUrl != ""
|
|
info.LdkVssUrl = api.cfg.GetEnv().LDKVssUrl
|
|
info.DatabaseType = api.db.Dialector.Name()
|
|
info.SupportsBolt12 = backendType == config.LDKBackendType || backendType == config.CLNBackendType
|
|
info.AutoUnlockPasswordEnabled = autoUnlockPassword != ""
|
|
info.AutoUnlockPasswordSupported = api.cfg.GetEnv().IsDefaultClientId()
|
|
info.Relays = []InfoResponseRelay{}
|
|
for _, relayStatus := range api.svc.GetRelayStatuses() {
|
|
info.Relays = append(info.Relays, InfoResponseRelay{
|
|
Url: relayStatus.Url,
|
|
Online: relayStatus.Online,
|
|
})
|
|
}
|
|
|
|
info.MempoolUrl = api.cfg.GetMempoolUrl()
|
|
info.AlbyAccountConnected = api.albyOAuthSvc.IsConnected(ctx)
|
|
|
|
albyUserIdentifier, err := api.albyOAuthSvc.GetUserIdentifier()
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to get alby user identifier")
|
|
return nil, err
|
|
}
|
|
info.AlbyUserIdentifier = albyUserIdentifier
|
|
|
|
if lnClient != nil {
|
|
nodeInfo, err := lnClient.GetInfo(ctx)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to get nodeInfo")
|
|
return nil, err
|
|
}
|
|
|
|
info.Network = nodeInfo.Network
|
|
if backendType == config.LDKBackendType {
|
|
// Only LDK supports this right now. Using a local interface here
|
|
// so we don't have to bloat the main LNClient interface for everyone else.
|
|
type chainSourceProvider interface {
|
|
GetChainDataSource() (string, string)
|
|
}
|
|
type lsps2SourceProvider interface {
|
|
GetLiquiditySourceLsps2() string
|
|
}
|
|
type lsps2MinPaymentSizeProvider interface {
|
|
GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64
|
|
}
|
|
type lsps2MaxPaymentSizeProvider interface {
|
|
GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64
|
|
}
|
|
|
|
if ldkService, ok := api.svc.GetLNClient().(chainSourceProvider); ok {
|
|
info.ChainDataSourceType, info.ChainDataSourceAddress = ldkService.GetChainDataSource()
|
|
}
|
|
if ldkService, ok := api.svc.GetLNClient().(lsps2SourceProvider); ok {
|
|
info.JitChannelsLiquiditySource = ldkService.GetLiquiditySourceLsps2()
|
|
}
|
|
if ldkService, ok := api.svc.GetLNClient().(lsps2MinPaymentSizeProvider); ok {
|
|
info.JitChannelsMinPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MinPaymentSizeMsat()
|
|
}
|
|
if ldkService, ok := api.svc.GetLNClient().(lsps2MaxPaymentSizeProvider); ok {
|
|
info.JitChannelsMaxPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MaxPaymentSizeMsat()
|
|
}
|
|
}
|
|
}
|
|
|
|
info.NextBackupReminder, _ = api.cfg.Get("NextBackupReminder", "")
|
|
|
|
info.NodeAlias, _ = api.cfg.Get("NodeAlias", "")
|
|
|
|
return &info, nil
|
|
}
|
|
|
|
func (api *api) setCurrency(currency string) error {
|
|
if currency == "" {
|
|
return fmt.Errorf("currency value cannot be empty")
|
|
}
|
|
|
|
err := api.cfg.SetCurrency(currency)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to update currency")
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) setBitcoinDisplayFormat(format string) error {
|
|
if format != constants.BITCOIN_DISPLAY_FORMAT_SATS && format != constants.BITCOIN_DISPLAY_FORMAT_BIP177 {
|
|
return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177)
|
|
}
|
|
|
|
err := api.cfg.SetBitcoinDisplayFormat(format)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to update bitcoin display format")
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) setJitChannelsEnabled(enabled bool) error {
|
|
value := "true"
|
|
if !enabled {
|
|
value = "false"
|
|
}
|
|
|
|
err := api.cfg.SetUpdate("JitChannelsEnabled", value, "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to update JIT channels setting")
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error {
|
|
if updateSettingsRequest.Currency != "" {
|
|
err := api.setCurrency(updateSettingsRequest.Currency)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set currency: %w", err)
|
|
}
|
|
}
|
|
|
|
if updateSettingsRequest.BitcoinDisplayFormat != "" {
|
|
err := api.setBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set bitcoin display format: %w", err)
|
|
}
|
|
}
|
|
|
|
if updateSettingsRequest.JitChannelsEnabled != nil {
|
|
err := api.setJitChannelsEnabled(*updateSettingsRequest.JitChannelsEnabled)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set JIT channels setting: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) SetNodeAlias(nodeAlias string) error {
|
|
err := api.cfg.SetUpdate("NodeAlias", nodeAlias, "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save node alias to config")
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) GetMnemonic(unlockPassword string) (*MnemonicResponse, error) {
|
|
if !api.cfg.CheckUnlockPassword(unlockPassword) {
|
|
return nil, fmt.Errorf("wrong password")
|
|
}
|
|
|
|
mnemonic, err := api.cfg.Get("Mnemonic", unlockPassword)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch encryption key: %w", err)
|
|
}
|
|
|
|
resp := MnemonicResponse{
|
|
Mnemonic: mnemonic,
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
func (api *api) SetNextBackupReminder(backupReminderRequest *BackupReminderRequest) error {
|
|
err := api.cfg.SetUpdate("NextBackupReminder", backupReminderRequest.NextBackupReminder, "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save next backup reminder to config")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var startMutex sync.Mutex
|
|
|
|
func (api *api) Start(startRequest *StartRequest) {
|
|
api.startupError = nil
|
|
err := api.startInternal(startRequest)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to start node")
|
|
api.startupError = err
|
|
api.startupErrorTime = time.Now()
|
|
}
|
|
}
|
|
|
|
func (api *api) startInternal(startRequest *StartRequest) (err error) {
|
|
if !startMutex.TryLock() {
|
|
// do not allow to start twice in case this is somehow called twice
|
|
return errors.New("app is busy")
|
|
}
|
|
defer startMutex.Unlock()
|
|
return api.svc.StartApp(startRequest.UnlockPassword)
|
|
}
|
|
|
|
func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|
if !startMutex.TryLock() {
|
|
// do not allow to start twice in case this is somehow called twice
|
|
return errors.New("app is busy")
|
|
}
|
|
defer startMutex.Unlock()
|
|
info, err := api.GetInfo(ctx)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to get info")
|
|
return err
|
|
}
|
|
if info.SetupCompleted {
|
|
logger.Logger.Error("Cannot re-setup node")
|
|
return errors.New("setup already completed")
|
|
}
|
|
|
|
if setupRequest.UnlockPassword == "" {
|
|
return errors.New("no unlock password provided")
|
|
}
|
|
|
|
// Bark and Cashu both store wallet state on local disk, so they cannot
|
|
// run in environments without persistent volumes (e.g. Alby Cloud). Bark
|
|
// can recover spendable VTXOs from the mnemonic alone, but in-flight
|
|
// payment checkpoints and wallet metadata are local-only, so persistent
|
|
// storage is still required. The default OAuth client ID identifies a
|
|
// local / self-hosted deployment.
|
|
if !api.cfg.GetEnv().IsDefaultClientId() {
|
|
switch setupRequest.LNBackendType {
|
|
case config.BarkBackendType, config.CashuBackendType:
|
|
return fmt.Errorf("%s backend is not supported in this environment (no persistent storage)", setupRequest.LNBackendType)
|
|
}
|
|
}
|
|
|
|
err = api.cfg.SaveUnlockPasswordCheck(setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// update next backup reminder
|
|
err = api.cfg.SetUpdate("NextBackupReminder", setupRequest.NextBackupReminder, "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save next backup reminder")
|
|
}
|
|
|
|
// only update non-empty values
|
|
if setupRequest.LNBackendType != "" {
|
|
err = api.cfg.SetUpdate("LNBackendType", setupRequest.LNBackendType, "")
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save backend type")
|
|
return err
|
|
}
|
|
}
|
|
if setupRequest.Mnemonic != "" {
|
|
err = api.cfg.SetUpdate("Mnemonic", setupRequest.Mnemonic, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save encrypted mnemonic")
|
|
return err
|
|
}
|
|
}
|
|
if setupRequest.LNDAddress != "" {
|
|
err = api.cfg.SetUpdate("LNDAddress", setupRequest.LNDAddress, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save lnd address")
|
|
return err
|
|
}
|
|
}
|
|
if setupRequest.LNDCertFile != "" {
|
|
// The file path is provided by the (unauthenticated) setup request, so
|
|
// only persist the content if it parses as a certificate. Storing the
|
|
// re-encoded certificate(s) guarantees nothing but the parsed structure
|
|
// reaches the database - e.g. a private key bundled in the same PEM file
|
|
// is dropped rather than persisted.
|
|
certHex, err := readAndCanonicalizeLNDCert(setupRequest.LNDCertFile)
|
|
if err != nil {
|
|
// Return a generic error and log the detail server-side so the
|
|
// response is not a file existence/readability oracle.
|
|
logger.Logger.WithError(err).Error("Failed to process lnd cert file")
|
|
return errors.New("invalid LND certificate file")
|
|
}
|
|
err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save lnd cert hex")
|
|
return err
|
|
}
|
|
}
|
|
if setupRequest.LNDMacaroonFile != "" {
|
|
// The file path is provided by the (unauthenticated) setup request, so
|
|
// only persist the content if it parses as a macaroon. Storing the
|
|
// re-marshalled macaroon guarantees only the parsed structure reaches
|
|
// the database.
|
|
macaroonHex, err := readAndCanonicalizeLNDMacaroon(setupRequest.LNDMacaroonFile)
|
|
if err != nil {
|
|
// Return a generic error and log the detail server-side so the
|
|
// response is not a file existence/readability oracle.
|
|
logger.Logger.WithError(err).Error("Failed to process lnd macaroon file")
|
|
return errors.New("invalid LND macaroon file")
|
|
}
|
|
err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex")
|
|
return err
|
|
}
|
|
}
|
|
|
|
if setupRequest.PhoenixdAddress != "" {
|
|
err = api.cfg.SetUpdate("PhoenixdAddress", setupRequest.PhoenixdAddress, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save phoenix address")
|
|
return err
|
|
}
|
|
}
|
|
if setupRequest.PhoenixdAuthorization != "" {
|
|
err = api.cfg.SetUpdate("PhoenixdAuthorization", setupRequest.PhoenixdAuthorization, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save phoenix auth")
|
|
return err
|
|
}
|
|
}
|
|
|
|
if setupRequest.CashuMintUrl != "" {
|
|
err = api.cfg.SetUpdate("CashuMintUrl", setupRequest.CashuMintUrl, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save cashu mint url")
|
|
return err
|
|
}
|
|
}
|
|
|
|
if setupRequest.CLNAddress != "" {
|
|
err = api.cfg.SetUpdate("CLNAddress", setupRequest.CLNAddress, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save CLN address")
|
|
return err
|
|
}
|
|
}
|
|
|
|
if setupRequest.CLNLightningDir != "" {
|
|
// The directory path is provided by the (unauthenticated) setup request.
|
|
// Validate that it holds the expected CLN TLS credentials before saving,
|
|
// so the path cannot be used as an existence/readability oracle for
|
|
// arbitrary directories (the failure otherwise surfaces via startupError
|
|
// on the anonymous /api/info response).
|
|
if err := validateCLNLightningDir(setupRequest.CLNLightningDir, setupRequest.CLNAddressHold != ""); err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to validate CLN lightning directory")
|
|
return errors.New("invalid CLN lightning directory")
|
|
}
|
|
err = api.cfg.SetUpdate("CLNLightningDir", setupRequest.CLNLightningDir, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save CLN Lightning directory path")
|
|
return err
|
|
}
|
|
}
|
|
|
|
if setupRequest.CLNAddressHold != "" {
|
|
err = api.cfg.SetUpdate("CLNAddressHold", setupRequest.CLNAddressHold, setupRequest.UnlockPassword)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to save cln hold plugin address")
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path,
|
|
// validates that it contains at least one parseable certificate, and returns
|
|
// the hex-encoded re-encoding of only the parsed certificate(s). Any non
|
|
// CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are
|
|
// never persisted. Callers must not reflect the returned error to the client.
|
|
func readAndCanonicalizeLNDCert(path string) (string, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read LND cert file: %w", err)
|
|
}
|
|
|
|
var canonical []byte
|
|
rest := raw
|
|
for {
|
|
var block *pem.Block
|
|
block, rest = pem.Decode(rest)
|
|
if block == nil {
|
|
break
|
|
}
|
|
if block.Type != "CERTIFICATE" {
|
|
continue
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse LND certificate: %w", err)
|
|
}
|
|
canonical = append(canonical, pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: cert.Raw,
|
|
})...)
|
|
}
|
|
if len(canonical) == 0 {
|
|
return "", errors.New("no valid certificate found in LND cert file")
|
|
}
|
|
|
|
return hex.EncodeToString(canonical), nil
|
|
}
|
|
|
|
// readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path,
|
|
// validates that it is a well-formed macaroon, and returns the hex-encoded
|
|
// re-marshalling so that only the parsed structure is persisted. Callers must
|
|
// not reflect the returned error to the client.
|
|
func readAndCanonicalizeLNDMacaroon(path string) (string, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read LND macaroon file: %w", err)
|
|
}
|
|
|
|
mac := &macaroon.Macaroon{}
|
|
if err := mac.UnmarshalBinary(raw); err != nil {
|
|
return "", fmt.Errorf("failed to parse LND macaroon: %w", err)
|
|
}
|
|
canonical, err := mac.MarshalBinary()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal LND macaroon: %w", err)
|
|
}
|
|
|
|
return hex.EncodeToString(canonical), nil
|
|
}
|
|
|
|
// validateCLNLightningDir checks that the given directory holds the CLN TLS
|
|
// credentials that will later be loaded at connect time (ca.pem, client.pem,
|
|
// client-key.pem), for each gRPC server name the config will use. This mirrors
|
|
// the parses performed by the CLN client's loadTLSCredentials so a directory
|
|
// that passes here is one CLN can actually use. Callers must not reflect the
|
|
// returned error to the client.
|
|
func validateCLNLightningDir(lightningDir string, hold bool) error {
|
|
// "cln" reads the directory directly; other server names are joined as a
|
|
// subdirectory, matching loadTLSCredentials in lnclient/cln.
|
|
serverNames := []string{"cln"}
|
|
if hold {
|
|
serverNames = append(serverNames, "hold")
|
|
}
|
|
|
|
for _, serverName := range serverNames {
|
|
dir := lightningDir
|
|
if serverName != "cln" {
|
|
dir = filepath.Join(dir, serverName)
|
|
}
|
|
|
|
caPEM, err := os.ReadFile(filepath.Join(dir, "ca.pem"))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read CLN CA cert (%s): %w", serverName, err)
|
|
}
|
|
if !x509.NewCertPool().AppendCertsFromPEM(caPEM) {
|
|
return fmt.Errorf("failed to parse CLN CA cert (%s)", serverName)
|
|
}
|
|
if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "client.pem"), filepath.Join(dir, "client-key.pem")); err != nil {
|
|
return fmt.Errorf("failed to load CLN client cert/key (%s): %w", serverName, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
|
|
methods := lnClient.GetSupportedNIP47Methods()
|
|
notificationTypes := lnClient.GetSupportedNIP47NotificationTypes()
|
|
|
|
scopes, err := permissions.RequestMethodsToScopes(methods)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(notificationTypes) > 0 {
|
|
scopes = append(scopes, constants.NOTIFICATIONS_SCOPE)
|
|
}
|
|
|
|
return &WalletCapabilitiesResponse{
|
|
Methods: methods,
|
|
NotificationTypes: notificationTypes,
|
|
Scopes: scopes,
|
|
}, nil
|
|
}
|
|
|
|
func (api *api) MigrateNodeStorage(ctx context.Context, to string) error {
|
|
if api.svc.GetLNClient() == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
if to != "VSS" {
|
|
return fmt.Errorf("migration type not supported: %s", to)
|
|
}
|
|
|
|
ldkVssEnabled, err := api.cfg.Get("LdkVssEnabled", "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if ldkVssEnabled == "true" {
|
|
return errors.New("VSS already enabled")
|
|
}
|
|
|
|
if api.cfg.GetEnv().LDKVssUrl == "" {
|
|
return errors.New("no VSS URL set")
|
|
}
|
|
|
|
api.cfg.SetUpdate("LdkVssEnabled", "true", "")
|
|
api.cfg.SetUpdate("LdkMigrateStorage", "VSS", "")
|
|
return api.Stop()
|
|
}
|
|
|
|
func (api *api) GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
return lnClient.GetNetworkGraph(ctx, nodeIds)
|
|
}
|
|
|
|
func (api *api) SyncWallet() error {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return ErrLNClientNotStarted
|
|
}
|
|
lnClient.UpdateLastWalletSyncRequest()
|
|
return nil
|
|
}
|
|
func (api *api) ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
transactions, err := lnClient.ListOnchainTransactions(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
apiTransactions := make([]OnchainTransaction, 0, len(transactions))
|
|
for _, t := range transactions {
|
|
apiTransactions = append(apiTransactions, OnchainTransaction{
|
|
AmountSat: t.AmountSat,
|
|
CreatedAt: t.CreatedAt,
|
|
State: t.State,
|
|
Type: t.Type,
|
|
NumConfirmations: t.NumConfirmations,
|
|
TxId: t.TxId,
|
|
})
|
|
}
|
|
return apiTransactions, nil
|
|
}
|
|
|
|
func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) {
|
|
var err error
|
|
var logData []byte
|
|
|
|
if logType == LogTypeNode {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
|
|
logData, err = lnClient.GetLogOutput(ctx, getLogRequest.MaxLen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else if logType == LogTypeApp {
|
|
logFileName := logger.GetLogFilePath()
|
|
if logFileName == "" {
|
|
logData = []byte("file log is disabled")
|
|
} else {
|
|
logData, err = utils.ReadFileTail(logFileName, getLogRequest.MaxLen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("invalid log type: '%s'", logType)
|
|
}
|
|
|
|
return &GetLogOutputResponse{Log: string(logData)}, nil
|
|
}
|
|
|
|
func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
|
|
var alarms []HealthAlarm
|
|
|
|
albyInfo, err := api.albySvc.GetInfo(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !albyInfo.Healthy {
|
|
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindAlbyService, albyInfo.Incidents))
|
|
}
|
|
|
|
relayStatuses := api.svc.GetRelayStatuses()
|
|
if len(relayStatuses) > 0 {
|
|
isAnyNostrRelayOffline := false
|
|
offlineRelayUrls := []string{}
|
|
for _, relayStatus := range relayStatuses {
|
|
if !relayStatus.Online {
|
|
isAnyNostrRelayOffline = true
|
|
offlineRelayUrls = append(offlineRelayUrls, relayStatus.Url)
|
|
}
|
|
}
|
|
if isAnyNostrRelayOffline {
|
|
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNostrRelayOffline, offlineRelayUrls))
|
|
}
|
|
}
|
|
|
|
ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
|
|
if ldkVssEnabled == "true" {
|
|
albyMe, err := api.albyOAuthSvc.GetMe(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if albyMe.Subscription.PlanCode == "" {
|
|
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindVssNoSubscription, nil))
|
|
}
|
|
}
|
|
|
|
lnClient := api.svc.GetLNClient()
|
|
|
|
if lnClient != nil {
|
|
nodeStatus, _ := lnClient.GetNodeStatus(ctx)
|
|
if nodeStatus == nil || !nodeStatus.IsReady {
|
|
var apiNodeStatus *NodeStatus
|
|
if nodeStatus != nil {
|
|
apiNodeStatus = toApiNodeStatus(nodeStatus)
|
|
}
|
|
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, apiNodeStatus))
|
|
}
|
|
|
|
channels, err := lnClient.ListChannels(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
offlineChannels := slices.DeleteFunc(channels, func(channel lnclient.Channel) bool {
|
|
if channel.Active {
|
|
return true
|
|
}
|
|
if channel.Confirmations == nil || channel.ConfirmationsRequired == nil {
|
|
return false
|
|
}
|
|
return *channel.Confirmations < *channel.ConfirmationsRequired
|
|
})
|
|
|
|
if len(offlineChannels) > 0 {
|
|
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindChannelsOffline, nil))
|
|
}
|
|
}
|
|
|
|
return &HealthResponse{Alarms: alarms}, nil
|
|
}
|
|
|
|
func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
|
|
allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
|
|
commandDefs := make([]CustomNodeCommandDef, 0, len(allCommandDefs))
|
|
for _, commandDef := range allCommandDefs {
|
|
argDefs := make([]CustomNodeCommandArgDef, 0, len(commandDef.Args))
|
|
for _, argDef := range commandDef.Args {
|
|
argDefs = append(argDefs, CustomNodeCommandArgDef{
|
|
Name: argDef.Name,
|
|
Description: argDef.Description,
|
|
})
|
|
}
|
|
commandDefs = append(commandDefs, CustomNodeCommandDef{
|
|
Name: commandDef.Name,
|
|
Description: commandDef.Description,
|
|
Args: argDefs,
|
|
})
|
|
}
|
|
|
|
return &CustomNodeCommandsResponse{Commands: commandDefs}, nil
|
|
}
|
|
|
|
func (api *api) ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) {
|
|
lnClient := api.svc.GetLNClient()
|
|
if lnClient == nil {
|
|
return nil, ErrLNClientNotStarted
|
|
}
|
|
|
|
// Split command line into arguments. Command name must be the first argument.
|
|
parsedArgs, err := utils.ParseCommandLine(command)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse node command: %w", err)
|
|
} else if len(parsedArgs) == 0 {
|
|
return nil, errors.New("no command provided")
|
|
}
|
|
|
|
// Look up the requested command definition.
|
|
allCommandDefs := lnClient.GetCustomNodeCommandDefinitions()
|
|
commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.CustomNodeCommandDef) bool {
|
|
return def.Name == parsedArgs[0]
|
|
})
|
|
if commandDefIdx < 0 {
|
|
return nil, fmt.Errorf("unknown command: %q", parsedArgs[0])
|
|
}
|
|
|
|
// Build flag set.
|
|
commandDef := allCommandDefs[commandDefIdx]
|
|
flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError)
|
|
for _, argDef := range commandDef.Args {
|
|
flagSet.String(argDef.Name, "", argDef.Description)
|
|
}
|
|
|
|
if err = flagSet.Parse(parsedArgs[1:]); err != nil {
|
|
return nil, fmt.Errorf("failed to parse command arguments: %w", err)
|
|
}
|
|
|
|
// Collect flags that have been set.
|
|
argValues := make(map[string]string)
|
|
flagSet.Visit(func(f *flag.Flag) {
|
|
argValues[f.Name] = f.Value.String()
|
|
})
|
|
|
|
reqArgs := make([]lnclient.CustomNodeCommandArg, 0, len(argValues))
|
|
for _, argDef := range commandDef.Args {
|
|
if argValue, ok := argValues[argDef.Name]; ok {
|
|
reqArgs = append(reqArgs, lnclient.CustomNodeCommandArg{
|
|
Name: argDef.Name,
|
|
Value: argValue,
|
|
})
|
|
}
|
|
}
|
|
|
|
nodeResp, err := lnClient.ExecuteCustomNodeCommand(ctx, &lnclient.CustomNodeCommandRequest{
|
|
Name: commandDef.Name,
|
|
Args: reqArgs,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("node failed to execute custom command: %w", err)
|
|
}
|
|
|
|
return nodeResp.Response, nil
|
|
}
|
|
|
|
func (api *api) SendEvent(event string, properties interface{}) {
|
|
api.svc.GetEventPublisher().Publish(&events.Event{
|
|
Event: event,
|
|
Properties: properties,
|
|
})
|
|
}
|
|
|
|
func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
|
|
var expiresAt *time.Time
|
|
if expiresAtString != "" {
|
|
var err error
|
|
expiresAtValue, err := time.Parse(time.RFC3339, expiresAtString)
|
|
if err != nil {
|
|
logger.Logger.WithField("expiresAt", expiresAtString).Error("Invalid expiresAt")
|
|
return nil, fmt.Errorf("invalid expiresAt: %v", err)
|
|
}
|
|
expiresAt = &expiresAtValue
|
|
}
|
|
return expiresAt, nil
|
|
}
|
|
|
|
func (api *api) GetForwards() (*GetForwardsResponse, error) {
|
|
var forwards []db.Forward
|
|
err := api.db.Find(&forwards).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var totalOutboundAmountMsat uint64
|
|
var totalFeeEarnedMsat uint64
|
|
|
|
for _, forward := range forwards {
|
|
totalOutboundAmountMsat += forward.OutboundAmountForwardedMsat
|
|
totalFeeEarnedMsat += forward.TotalFeeEarnedMsat
|
|
}
|
|
|
|
numForwards := len(forwards)
|
|
|
|
return &GetForwardsResponse{
|
|
OutboundAmountForwardedSat: totalOutboundAmountMsat / 1000,
|
|
OutboundAmountForwardedMsat: totalOutboundAmountMsat,
|
|
TotalFeeEarnedSat: totalFeeEarnedMsat / 1000,
|
|
TotalFeeEarnedMsat: totalFeeEarnedMsat,
|
|
NumForwards: uint64(numForwards),
|
|
}, nil
|
|
}
|