apps_service.go raw

   1  package apps
   2  
   3  import (
   4  	"encoding/hex"
   5  	"encoding/json"
   6  	"errors"
   7  	"fmt"
   8  	"slices"
   9  	"strings"
  10  	"time"
  11  
  12  	"github.com/getAlby/go-nostr"
  13  	"github.com/getAlby/hub/config"
  14  	"github.com/getAlby/hub/constants"
  15  	"github.com/getAlby/hub/db"
  16  	"github.com/getAlby/hub/events"
  17  	"github.com/getAlby/hub/logger"
  18  	"github.com/getAlby/hub/service/keys"
  19  	"gorm.io/datatypes"
  20  	"gorm.io/gorm"
  21  )
  22  
  23  type AppsService interface {
  24  	CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error)
  25  	DeleteApp(app *db.App) error
  26  	GetAppByPubkey(pubkey string) *db.App
  27  	GetAppById(id uint) *db.App
  28  	SetAppMetadata(appId uint, metadata map[string]interface{}) error
  29  	HasLightningAddress(app *db.App) bool
  30  }
  31  
  32  type appsService struct {
  33  	db             *gorm.DB
  34  	eventPublisher events.EventPublisher
  35  	keys           keys.Keys
  36  	cfg            config.Config
  37  }
  38  
  39  func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys.Keys, cfg config.Config) *appsService {
  40  	return &appsService{
  41  		db:             db,
  42  		eventPublisher: eventPublisher,
  43  		keys:           keys,
  44  		cfg:            cfg,
  45  	}
  46  }
  47  
  48  func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error) {
  49  	if name == "" {
  50  		return nil, "", errors.New("no app name provided")
  51  	}
  52  	if isolated {
  53  		if slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE) {
  54  			// cannot sign messages because the isolated app is a custodial sub-wallet
  55  			return nil, "", errors.New("Sub-wallet app connection cannot have sign_message scope")
  56  		}
  57  
  58  		backendType, _ := svc.cfg.Get("LNBackendType", "")
  59  		if backendType != config.LDKBackendType &&
  60  			backendType != config.LNDBackendType &&
  61  			backendType != config.PhoenixBackendType &&
  62  			backendType != config.BarkBackendType &&
  63  			backendType != config.CLNBackendType {
  64  			return nil, "", fmt.Errorf(
  65  				"sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN")
  66  		}
  67  	}
  68  
  69  	if budgetRenewal == "" {
  70  		budgetRenewal = constants.BUDGET_RENEWAL_NEVER
  71  	}
  72  
  73  	if !slices.Contains(constants.GetBudgetRenewals(), budgetRenewal) {
  74  		return nil, "", fmt.Errorf("invalid budget renewal. Must be one of %s", strings.Join(constants.GetBudgetRenewals(), ","))
  75  	}
  76  
  77  	// ensure there is at least one scope
  78  	if len(scopes) == 0 {
  79  		return nil, "", errors.New("no scopes provided")
  80  	}
  81  
  82  	var pairingPublicKey string
  83  	var pairingSecretKey string
  84  	var err error
  85  	if pubkey == "" {
  86  		pairingSecretKey = nostr.GeneratePrivateKey()
  87  		pairingPublicKey, err = nostr.GetPublicKey(pairingSecretKey)
  88  		if err != nil {
  89  			return nil, "", err
  90  		}
  91  	} else {
  92  		pairingPublicKey = pubkey
  93  		//validate public key
  94  		decoded, err := hex.DecodeString(pairingPublicKey)
  95  		if err != nil || len(decoded) != 32 {
  96  			logger.Logger.WithField("pairingPublicKey", pairingPublicKey).Error("Invalid public key format")
  97  			return nil, "", fmt.Errorf("invalid public key format: %s", pairingPublicKey)
  98  		}
  99  	}
 100  
 101  	var metadataBytes []byte
 102  	if metadata != nil {
 103  		var err error
 104  		metadataBytes, err = json.Marshal(metadata)
 105  		if err != nil {
 106  			logger.Logger.WithError(err).Error("Failed to serialize metadata")
 107  			return nil, "", err
 108  		}
 109  	}
 110  
 111  	// use a suffix to avoid duplicate names
 112  	nameIndex := 0
 113  	var freeName string
 114  	for ; ; nameIndex++ {
 115  		freeName = name
 116  		if nameIndex > 0 {
 117  			freeName += fmt.Sprintf(" (%d)", nameIndex)
 118  		}
 119  		existingApp := svc.GetAppByName(freeName)
 120  		if existingApp == nil {
 121  			break
 122  		}
 123  	}
 124  
 125  	app := db.App{Name: freeName, AppPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)}
 126  
 127  	err = svc.db.Transaction(func(tx *gorm.DB) error {
 128  		err := tx.Save(&app).Error
 129  		if err != nil {
 130  			return err
 131  		}
 132  
 133  		for _, scope := range scopes {
 134  			appPermission := db.AppPermission{
 135  				App:       app,
 136  				Scope:     scope,
 137  				ExpiresAt: expiresAt,
 138  				//these fields are only relevant for pay_invoice
 139  				MaxAmountSat:  int(maxAmountSat),
 140  				BudgetRenewal: budgetRenewal,
 141  			}
 142  			err = tx.Create(&appPermission).Error
 143  			if err != nil {
 144  				return err
 145  			}
 146  		}
 147  
 148  		appWalletPrivKey, err := svc.keys.GetAppWalletKey(app.ID)
 149  		if err != nil {
 150  			return fmt.Errorf("error generating wallet child private key: %w", err)
 151  		}
 152  
 153  		appWalletPubkey, err := nostr.GetPublicKey(appWalletPrivKey)
 154  		if err != nil {
 155  			return fmt.Errorf("error generating wallet child public key: %w", err)
 156  		}
 157  
 158  		err = tx.Model(&app).Update("wallet_pubkey", appWalletPubkey).Error
 159  		if err != nil {
 160  			return err
 161  		}
 162  
 163  		// commit transaction
 164  		return nil
 165  	})
 166  
 167  	if err != nil {
 168  		logger.Logger.WithError(err).Error("Failed to save app")
 169  		return nil, "", err
 170  	}
 171  
 172  	svc.eventPublisher.Publish(&events.Event{
 173  		Event: "nwc_app_created",
 174  		Properties: map[string]interface{}{
 175  			"name": name,
 176  			"id":   app.ID,
 177  		},
 178  	})
 179  
 180  	return &app, pairingSecretKey, nil
 181  }
 182  
 183  func (svc *appsService) DeleteApp(app *db.App) error {
 184  
 185  	err := svc.db.Delete(app).Error
 186  	if err != nil {
 187  		return err
 188  	}
 189  	walletPubkey := ""
 190  	if app.WalletPubkey != nil {
 191  		// only exists for non-legacy apps
 192  		walletPubkey = *app.WalletPubkey
 193  	}
 194  	svc.eventPublisher.Publish(&events.Event{
 195  		Event: "nwc_app_deleted",
 196  		Properties: map[string]interface{}{
 197  			"name":         app.Name,
 198  			"id":           app.ID,
 199  			"walletPubkey": walletPubkey,
 200  		},
 201  	})
 202  	return nil
 203  }
 204  
 205  func (svc *appsService) GetAppByPubkey(pubkey string) *db.App {
 206  	dbApp := db.App{}
 207  	findResult := svc.db.Where("app_pubkey = ?", pubkey).First(&dbApp)
 208  	if findResult.RowsAffected == 0 {
 209  		return nil
 210  	}
 211  	return &dbApp
 212  }
 213  
 214  func (svc *appsService) GetAppById(id uint) *db.App {
 215  	dbApp := db.App{}
 216  	findResult := svc.db.Where("id = ?", id).First(&dbApp)
 217  	if findResult.RowsAffected == 0 {
 218  		return nil
 219  	}
 220  	return &dbApp
 221  }
 222  
 223  func (svc *appsService) GetAppByName(name string) *db.App {
 224  	dbApp := db.App{}
 225  	findResult := svc.db.Where("name = ?", name).First(&dbApp)
 226  	if findResult.RowsAffected == 0 {
 227  		return nil
 228  	}
 229  	return &dbApp
 230  }
 231  
 232  func (svc *appsService) SetAppMetadata(id uint, metadata map[string]interface{}) error {
 233  	var metadataBytes []byte
 234  	metadataBytes, err := json.Marshal(metadata)
 235  	if err != nil {
 236  		logger.Logger.WithError(err).Error("Failed to serialize metadata")
 237  		return err
 238  	}
 239  
 240  	err = svc.db.Model(&db.App{}).Where("id", id).Update("metadata", datatypes.JSON(metadataBytes)).Error
 241  	if err != nil {
 242  		logger.Logger.WithError(err).WithField("metadata", metadata).Error("failed to update transaction metadata")
 243  		return err
 244  	}
 245  
 246  	return nil
 247  }
 248  
 249  func (svc *appsService) HasLightningAddress(app *db.App) bool {
 250  	if app.Metadata == nil {
 251  		return false
 252  	}
 253  
 254  	var metadata map[string]interface{}
 255  	err := json.Unmarshal(app.Metadata, &metadata)
 256  	if err != nil {
 257  		return false
 258  	}
 259  
 260  	lud16, exists := metadata["lud16"]
 261  	return exists && lud16 != nil
 262  }
 263