payment_processor.go raw

   1  package app
   2  
   3  import (
   4  	"context"
   5  	// std hex not used; use project hex encoder instead
   6  	"fmt"
   7  	"strings"
   8  	"sync"
   9  	"time"
  10  
  11  	"encoding/json"
  12  
  13  	"github.com/dgraph-io/badger/v4"
  14  	"next.orly.dev/pkg/lol/chk"
  15  	"next.orly.dev/pkg/lol/log"
  16  	"next.orly.dev/app/config"
  17  	"next.orly.dev/pkg/acl"
  18  	"next.orly.dev/pkg/nostr/interfaces/signer/p8k"
  19  	"next.orly.dev/pkg/database"
  20  	"next.orly.dev/pkg/nostr/encoders/bech32encoding"
  21  	"next.orly.dev/pkg/nostr/encoders/event"
  22  	"next.orly.dev/pkg/nostr/encoders/hex"
  23  	"next.orly.dev/pkg/nostr/encoders/kind"
  24  	"next.orly.dev/pkg/nostr/encoders/tag"
  25  	"next.orly.dev/pkg/nostr/encoders/timestamp"
  26  	"next.orly.dev/pkg/protocol/nwc"
  27  )
  28  
  29  // PaymentProcessor handles NWC payment notifications and updates subscriptions
  30  type PaymentProcessor struct {
  31  	nwcClient    *nwc.Client
  32  	db           *database.D
  33  	config       *config.C
  34  	ctx          context.Context
  35  	cancel       context.CancelFunc
  36  	wg           sync.WaitGroup
  37  	dashboardURL string
  38  }
  39  
  40  // NewPaymentProcessor creates a new payment processor
  41  func NewPaymentProcessor(
  42  	ctx context.Context, cfg *config.C, db *database.D,
  43  ) (pp *PaymentProcessor, err error) {
  44  	if cfg.NWCUri == "" {
  45  		return nil, fmt.Errorf("NWC URI not configured")
  46  	}
  47  
  48  	var nwcClient *nwc.Client
  49  	if nwcClient, err = nwc.NewClient(cfg.NWCUri); chk.E(err) {
  50  		return nil, fmt.Errorf("failed to create NWC client: %w", err)
  51  	}
  52  
  53  	c, cancel := context.WithCancel(ctx)
  54  
  55  	pp = &PaymentProcessor{
  56  		nwcClient: nwcClient,
  57  		db:        db,
  58  		config:    cfg,
  59  		ctx:       c,
  60  		cancel:    cancel,
  61  	}
  62  
  63  	return pp, nil
  64  }
  65  
  66  // Start begins listening for payment notifications
  67  func (pp *PaymentProcessor) Start() error {
  68  	// start NWC notifications listener
  69  	pp.wg.Add(1)
  70  	go func() {
  71  		defer pp.wg.Done()
  72  		if err := pp.listenForPayments(); err != nil {
  73  			log.E.F("payment processor error: %v", err)
  74  		}
  75  	}()
  76  	// start periodic follow-list sync if subscriptions are enabled
  77  	if pp.config != nil && pp.config.SubscriptionEnabled {
  78  		pp.wg.Add(1)
  79  		go func() {
  80  			defer pp.wg.Done()
  81  			pp.runFollowSyncLoop()
  82  		}()
  83  		// start daily subscription checker
  84  		pp.wg.Add(1)
  85  		go func() {
  86  			defer pp.wg.Done()
  87  			pp.runDailySubscriptionChecker()
  88  		}()
  89  	}
  90  	return nil
  91  }
  92  
  93  // Stop gracefully stops the payment processor
  94  func (pp *PaymentProcessor) Stop() {
  95  	if pp.cancel != nil {
  96  		pp.cancel()
  97  	}
  98  	pp.wg.Wait()
  99  }
 100  
 101  // listenForPayments subscribes to NWC notifications and processes payments
 102  func (pp *PaymentProcessor) listenForPayments() error {
 103  	return pp.nwcClient.SubscribeNotifications(pp.ctx, pp.handleNotification)
 104  }
 105  
 106  // runFollowSyncLoop periodically syncs the relay identity follow list with active subscribers
 107  func (pp *PaymentProcessor) runFollowSyncLoop() {
 108  	t := time.NewTicker(10 * time.Minute)
 109  	defer t.Stop()
 110  	// do an initial sync shortly after start
 111  	_ = pp.syncFollowList()
 112  	for {
 113  		select {
 114  		case <-pp.ctx.Done():
 115  			return
 116  		case <-t.C:
 117  			if err := pp.syncFollowList(); err != nil {
 118  				log.W.F("follow list sync failed: %v", err)
 119  			}
 120  		}
 121  	}
 122  }
 123  
 124  // runDailySubscriptionChecker checks once daily for subscription expiry warnings and trial reminders
 125  func (pp *PaymentProcessor) runDailySubscriptionChecker() {
 126  	t := time.NewTicker(24 * time.Hour)
 127  	defer t.Stop()
 128  	// do an initial check shortly after start
 129  	_ = pp.checkSubscriptionStatus()
 130  	for {
 131  		select {
 132  		case <-pp.ctx.Done():
 133  			return
 134  		case <-t.C:
 135  			if err := pp.checkSubscriptionStatus(); err != nil {
 136  				log.W.F("subscription status check failed: %v", err)
 137  			}
 138  		}
 139  	}
 140  }
 141  
 142  // syncFollowList builds a kind-3 event from the relay identity containing only active subscribers
 143  func (pp *PaymentProcessor) syncFollowList() error {
 144  	// ensure we have a relay identity secret
 145  	skb, err := pp.db.GetRelayIdentitySecret()
 146  	if err != nil || len(skb) != 32 {
 147  		return nil // nothing to do if no identity
 148  	}
 149  	// collect active subscribers
 150  	actives, err := pp.getActiveSubscriberPubkeys()
 151  	if err != nil {
 152  		return err
 153  	}
 154  	// signer
 155  	sign := p8k.MustNew()
 156  	if err := sign.InitSec(skb); err != nil {
 157  		return err
 158  	}
 159  	// build follow list event
 160  	ev := event.New()
 161  	ev.Kind = kind.FollowList.K
 162  	ev.Pubkey = sign.Pub()
 163  	ev.CreatedAt = timestamp.Now().V
 164  	ev.Tags = tag.NewS()
 165  	for _, pk := range actives {
 166  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(pk)))
 167  	}
 168  	// sign and save
 169  	ev.Sign(sign)
 170  	if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
 171  		return err
 172  	}
 173  	log.I.F(
 174  		"updated relay follow list with %d active subscribers", len(actives),
 175  	)
 176  	return nil
 177  }
 178  
 179  // getActiveSubscriberPubkeys scans the subscription records and returns active ones
 180  func (pp *PaymentProcessor) getActiveSubscriberPubkeys() ([][]byte, error) {
 181  	prefix := []byte("sub:")
 182  	now := time.Now()
 183  	var out [][]byte
 184  	err := pp.db.DB.View(
 185  		func(txn *badger.Txn) error {
 186  			it := txn.NewIterator(badger.DefaultIteratorOptions)
 187  			defer it.Close()
 188  			for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
 189  				item := it.Item()
 190  				key := item.KeyCopy(nil)
 191  				// key format: sub:<hexpub>
 192  				hexpub := string(key[len(prefix):])
 193  				var sub database.Subscription
 194  				if err := item.Value(
 195  					func(val []byte) error {
 196  						return json.Unmarshal(val, &sub)
 197  					},
 198  				); err != nil {
 199  					return err
 200  				}
 201  				if now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)) {
 202  					if b, err := hex.Dec(hexpub); err == nil {
 203  						out = append(out, b)
 204  					}
 205  				}
 206  			}
 207  			return nil
 208  		},
 209  	)
 210  	return out, err
 211  }
 212  
 213  // checkSubscriptionStatus scans all subscriptions and creates warning/reminder notes
 214  func (pp *PaymentProcessor) checkSubscriptionStatus() error {
 215  	prefix := []byte("sub:")
 216  	now := time.Now()
 217  	sevenDaysFromNow := now.AddDate(0, 0, 7)
 218  
 219  	return pp.db.DB.View(
 220  		func(txn *badger.Txn) error {
 221  			it := txn.NewIterator(badger.DefaultIteratorOptions)
 222  			defer it.Close()
 223  			for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
 224  				item := it.Item()
 225  				key := item.KeyCopy(nil)
 226  				// key format: sub:<hexpub>
 227  				hexpub := string(key[len(prefix):])
 228  
 229  				var sub database.Subscription
 230  				if err := item.Value(
 231  					func(val []byte) error {
 232  						return json.Unmarshal(val, &sub)
 233  					},
 234  				); err != nil {
 235  					continue // skip invalid subscription records
 236  				}
 237  
 238  				pubkey, err := hex.Dec(hexpub)
 239  				if err != nil {
 240  					continue // skip invalid pubkey
 241  				}
 242  
 243  				// Check if paid subscription is expiring in 7 days
 244  				if !sub.PaidUntil.IsZero() {
 245  					// Format dates for comparison (ignore time component)
 246  					paidUntilDate := sub.PaidUntil.Truncate(24 * time.Hour)
 247  					sevenDaysDate := sevenDaysFromNow.Truncate(24 * time.Hour)
 248  
 249  					if paidUntilDate.Equal(sevenDaysDate) {
 250  						go pp.createExpiryWarningNote(pubkey, sub.PaidUntil)
 251  					}
 252  				}
 253  
 254  				// Check if user is on trial (no paid subscription, trial not expired)
 255  				if sub.PaidUntil.IsZero() && now.Before(sub.TrialEnd) {
 256  					go pp.createTrialReminderNote(pubkey, sub.TrialEnd)
 257  				}
 258  			}
 259  			return nil
 260  		},
 261  	)
 262  }
 263  
 264  // createExpiryWarningNote creates a warning note for users whose paid subscription expires in 7 days
 265  func (pp *PaymentProcessor) createExpiryWarningNote(
 266  	userPubkey []byte, expiryTime time.Time,
 267  ) error {
 268  	// Get relay identity secret to sign the note
 269  	skb, err := pp.db.GetRelayIdentitySecret()
 270  	if err != nil || len(skb) != 32 {
 271  		return fmt.Errorf("no relay identity configured")
 272  	}
 273  
 274  	// Initialize signer
 275  	sign := p8k.MustNew()
 276  	if err := sign.InitSec(skb); err != nil {
 277  		return fmt.Errorf("failed to initialize signer: %w", err)
 278  	}
 279  
 280  	monthlyPrice := pp.config.MonthlyPriceSats
 281  	if monthlyPrice <= 0 {
 282  		monthlyPrice = 6000
 283  	}
 284  
 285  	// Get relay npub for content link
 286  	relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub())
 287  	if err != nil {
 288  		return fmt.Errorf("failed to encode relay npub: %w", err)
 289  	}
 290  
 291  	// Create the warning note content
 292  	content := fmt.Sprintf(
 293  		`⚠️ Subscription Expiring Soon ⚠️
 294  
 295  Your paid subscription to this relay will expire in 7 days on %s.
 296  
 297  💰 To extend your subscription:
 298  - Monthly price: %d sats
 299  - Zap this note with your payment amount
 300  - Each %d sats = 30 days of access
 301  
 302  ⚡ Payment Instructions:
 303  1. Use any Lightning wallet that supports zaps
 304  2. Zap this note with your payment
 305  3. Your subscription will be automatically extended
 306  
 307  Don't lose access to your private relay! Extend your subscription today.
 308  
 309  Relay: nostr:%s
 310  
 311  Log in to the relay dashboard to access your configuration at: %s`,
 312  		expiryTime.Format("2006-01-02 15:04:05 UTC"), monthlyPrice,
 313  		monthlyPrice, string(relayNpubForContent), pp.getDashboardURL(),
 314  	)
 315  
 316  	// Build the event
 317  	ev := event.New()
 318  	ev.Kind = kind.TextNote.K // Kind 1 for text note
 319  	ev.Pubkey = sign.Pub()
 320  	ev.CreatedAt = timestamp.Now().V
 321  	ev.Content = []byte(content)
 322  	ev.Tags = tag.NewS()
 323  
 324  	// Add "p" tag for the user
 325  	*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey)))
 326  
 327  	// Add expiration tag (5 days from creation)
 328  	noteExpiry := time.Now().AddDate(0, 0, 5)
 329  	*ev.Tags = append(
 330  		*ev.Tags,
 331  		tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())),
 332  	)
 333  
 334  	// Add "private" tag with authorized npubs (user and relay)
 335  	var authorizedNpubs []string
 336  
 337  	// Add user npub
 338  	userNpub, err := bech32encoding.BinToNpub(userPubkey)
 339  	if err == nil {
 340  		authorizedNpubs = append(authorizedNpubs, string(userNpub))
 341  	}
 342  
 343  	// Add relay npub
 344  	relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
 345  	if err == nil {
 346  		authorizedNpubs = append(authorizedNpubs, string(relayNpub))
 347  	}
 348  
 349  	// Create the private tag with comma-separated npubs
 350  	if len(authorizedNpubs) > 0 {
 351  		privateTagValue := strings.Join(authorizedNpubs, ",")
 352  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
 353  		// Add protected "-" tag to mark this event as protected
 354  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("-", ""))
 355  	}
 356  
 357  	// Add a special tag to mark this as an expiry warning
 358  	*ev.Tags = append(
 359  		*ev.Tags, tag.NewFromAny("warning", "subscription-expiry"),
 360  	)
 361  
 362  	// Sign and save the event
 363  	ev.Sign(sign)
 364  	if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
 365  		return fmt.Errorf("failed to save expiry warning note: %w", err)
 366  	}
 367  
 368  	log.I.F(
 369  		"created expiry warning note for user %s (expires %s)",
 370  		hex.Enc(userPubkey), expiryTime.Format("2006-01-02"),
 371  	)
 372  	return nil
 373  }
 374  
 375  // createTrialReminderNote creates a reminder note for users on trial to support the relay
 376  func (pp *PaymentProcessor) createTrialReminderNote(
 377  	userPubkey []byte, trialEnd time.Time,
 378  ) error {
 379  	// Get relay identity secret to sign the note
 380  	skb, err := pp.db.GetRelayIdentitySecret()
 381  	if err != nil || len(skb) != 32 {
 382  		return fmt.Errorf("no relay identity configured")
 383  	}
 384  
 385  	// Initialize signer
 386  	sign := p8k.MustNew()
 387  	if err := sign.InitSec(skb); err != nil {
 388  		return fmt.Errorf("failed to initialize signer: %w", err)
 389  	}
 390  
 391  	monthlyPrice := pp.config.MonthlyPriceSats
 392  	if monthlyPrice <= 0 {
 393  		monthlyPrice = 6000
 394  	}
 395  
 396  	// Calculate daily rate
 397  	dailyRate := monthlyPrice / 30
 398  
 399  	// Get relay npub for content link
 400  	relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub())
 401  	if err != nil {
 402  		return fmt.Errorf("failed to encode relay npub: %w", err)
 403  	}
 404  
 405  	// Create the reminder note content
 406  	content := fmt.Sprintf(
 407  		`🆓 Free Trial Reminder 🆓
 408  
 409  You're currently using this relay for FREE! Your trial expires on %s.
 410  
 411  🙏 Support Relay Operations:
 412  This relay provides you with private, censorship-resistant communication. Please consider supporting its continued operation.
 413  
 414  💰 Subscription Details:
 415  - Monthly price: %d sats (%d sats/day)
 416  - Fair pricing for premium service
 417  - Helps keep the relay running 24/7
 418  
 419  ⚡ How to Subscribe:
 420  Simply zap this note with your payment amount:
 421  - Each %d sats = 30 days of access
 422  - Payment is processed automatically
 423  - No account setup required
 424  
 425  Thank you for considering supporting decentralized communication!
 426  
 427  Relay: nostr:%s
 428  
 429  Log in to the relay dashboard to access your configuration at: %s`,
 430  		trialEnd.Format("2006-01-02 15:04:05 UTC"), monthlyPrice, dailyRate,
 431  		monthlyPrice, string(relayNpubForContent), pp.getDashboardURL(),
 432  	)
 433  
 434  	// Build the event
 435  	ev := event.New()
 436  	ev.Kind = kind.TextNote.K // Kind 1 for text note
 437  	ev.Pubkey = sign.Pub()
 438  	ev.CreatedAt = timestamp.Now().V
 439  	ev.Content = []byte(content)
 440  	ev.Tags = tag.NewS()
 441  
 442  	// Add "p" tag for the user
 443  	*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey)))
 444  
 445  	// Add expiration tag (5 days from creation)
 446  	noteExpiry := time.Now().AddDate(0, 0, 5)
 447  	*ev.Tags = append(
 448  		*ev.Tags,
 449  		tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())),
 450  	)
 451  
 452  	// Add "private" tag with authorized npubs (user and relay)
 453  	var authorizedNpubs []string
 454  
 455  	// Add user npub
 456  	userNpub, err := bech32encoding.BinToNpub(userPubkey)
 457  	if err == nil {
 458  		authorizedNpubs = append(authorizedNpubs, string(userNpub))
 459  	}
 460  
 461  	// Add relay npub
 462  	relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
 463  	if err == nil {
 464  		authorizedNpubs = append(authorizedNpubs, string(relayNpub))
 465  	}
 466  
 467  	// Create the private tag with comma-separated npubs
 468  	if len(authorizedNpubs) > 0 {
 469  		privateTagValue := strings.Join(authorizedNpubs, ",")
 470  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
 471  		// Add protected "-" tag to mark this event as protected
 472  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("-", ""))
 473  	}
 474  
 475  	// Add a special tag to mark this as a trial reminder
 476  	*ev.Tags = append(*ev.Tags, tag.NewFromAny("reminder", "trial-support"))
 477  
 478  	// Sign and save the event
 479  	ev.Sign(sign)
 480  	if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
 481  		return fmt.Errorf("failed to save trial reminder note: %w", err)
 482  	}
 483  
 484  	log.I.F(
 485  		"created trial reminder note for user %s (trial ends %s)",
 486  		hex.Enc(userPubkey), trialEnd.Format("2006-01-02"),
 487  	)
 488  	return nil
 489  }
 490  
 491  // handleNotification processes incoming payment notifications
 492  func (pp *PaymentProcessor) handleNotification(
 493  	notificationType string, notification map[string]any,
 494  ) error {
 495  	// Only process payment_received notifications
 496  	if notificationType != "payment_received" {
 497  		return nil
 498  	}
 499  
 500  	amount, ok := notification["amount"].(float64)
 501  	if !ok {
 502  		return fmt.Errorf("invalid amount")
 503  	}
 504  
 505  	// Prefer explicit payer/relay pubkeys if provided in metadata
 506  	var payerPubkey []byte
 507  	var userNpub string
 508  	var metadata map[string]any
 509  	if md, ok := notification["metadata"].(map[string]any); ok {
 510  		metadata = md
 511  		if s, ok := metadata["payer_pubkey"].(string); ok && s != "" {
 512  			if pk, err := decodeAnyPubkey(s); err == nil {
 513  				payerPubkey = pk
 514  			}
 515  		}
 516  		if payerPubkey == nil {
 517  			if s, ok := metadata["sender_pubkey"].(string); ok && s != "" { // alias
 518  				if pk, err := decodeAnyPubkey(s); err == nil {
 519  					payerPubkey = pk
 520  				}
 521  			}
 522  		}
 523  		// Optional: the intended subscriber npub (for backwards compat)
 524  		if userNpub == "" {
 525  			if npubField, ok := metadata["npub"].(string); ok {
 526  				userNpub = npubField
 527  			}
 528  		}
 529  		// If relay identity pubkey is provided, verify it matches ours
 530  		if s, ok := metadata["relay_pubkey"].(string); ok && s != "" {
 531  			if rpk, err := decodeAnyPubkey(s); err == nil {
 532  				if skb, err := pp.db.GetRelayIdentitySecret(); err == nil && len(skb) == 32 {
 533  					signer := p8k.MustNew()
 534  					if err := signer.InitSec(skb); err == nil {
 535  						if !strings.EqualFold(
 536  							hex.Enc(rpk), hex.Enc(signer.Pub()),
 537  						) {
 538  							log.W.F(
 539  								"relay_pubkey in payment metadata does not match this relay identity: got %s want %s",
 540  								hex.Enc(rpk), hex.Enc(signer.Pub()),
 541  							)
 542  						}
 543  					}
 544  				}
 545  			}
 546  		}
 547  	}
 548  
 549  	// Fallback: extract npub from description or metadata
 550  	description, _ := notification["description"].(string)
 551  	if userNpub == "" {
 552  		userNpub = pp.extractNpubFromDescription(description)
 553  	}
 554  
 555  	var pubkey []byte
 556  	var err error
 557  	if payerPubkey != nil {
 558  		pubkey = payerPubkey
 559  	} else {
 560  		if userNpub == "" {
 561  			return fmt.Errorf("no payer_pubkey or npub provided in payment notification")
 562  		}
 563  		pubkey, err = pp.npubToPubkey(userNpub)
 564  		if err != nil {
 565  			return fmt.Errorf("invalid npub: %w", err)
 566  		}
 567  	}
 568  
 569  	satsReceived := int64(amount / 1000)
 570  	
 571  	// Parse zap memo for blossom service level
 572  	blossomLevel := pp.parseBlossomServiceLevel(description, metadata)
 573  	
 574  	// Calculate subscription days (for relay access)
 575  	monthlyPrice := pp.config.MonthlyPriceSats
 576  	if monthlyPrice <= 0 {
 577  		monthlyPrice = 6000
 578  	}
 579  
 580  	days := int((float64(satsReceived) / float64(monthlyPrice)) * 30)
 581  	if days < 1 {
 582  		return fmt.Errorf("payment amount too small")
 583  	}
 584  
 585  	// Extend relay subscription
 586  	if err := pp.db.ExtendSubscription(pubkey, days); err != nil {
 587  		return fmt.Errorf("failed to extend subscription: %w", err)
 588  	}
 589  
 590  	// If blossom service level specified, extend blossom subscription
 591  	if blossomLevel != "" {
 592  		if err := pp.extendBlossomSubscription(pubkey, satsReceived, blossomLevel, days); err != nil {
 593  			log.W.F("failed to extend blossom subscription: %v", err)
 594  			// Don't fail the payment if blossom subscription fails
 595  		}
 596  	}
 597  
 598  	// Record payment history
 599  	invoice, _ := notification["invoice"].(string)
 600  	preimage, _ := notification["preimage"].(string)
 601  	if err := pp.db.RecordPayment(
 602  		pubkey, satsReceived, invoice, preimage,
 603  	); err != nil {
 604  		log.E.F("failed to record payment: %v", err)
 605  	}
 606  
 607  	// Log helpful identifiers
 608  	var payerHex = hex.Enc(pubkey)
 609  	if userNpub == "" {
 610  		log.I.F(
 611  			"payment processed: payer %s %d sats -> %d days", payerHex,
 612  			satsReceived, days,
 613  		)
 614  	} else {
 615  		log.I.F(
 616  			"payment processed: %s (%s) %d sats -> %d days", userNpub, payerHex,
 617  			satsReceived, days,
 618  		)
 619  	}
 620  
 621  	// Update ACL follows cache and relay follow list immediately
 622  	if pp.config != nil && pp.config.ACLMode == "follows" {
 623  		acl.Registry.AddFollow(pubkey)
 624  	}
 625  	// Trigger an immediate follow-list sync in background (best-effort)
 626  	go func() { _ = pp.syncFollowList() }()
 627  
 628  	// Create a note with payment confirmation and private tag
 629  	if err := pp.createPaymentNote(pubkey, satsReceived, days); err != nil {
 630  		log.E.F("failed to create payment note: %v", err)
 631  	}
 632  
 633  	return nil
 634  }
 635  
 636  // createPaymentNote creates a note recording the payment with private tag for authorization
 637  func (pp *PaymentProcessor) createPaymentNote(
 638  	payerPubkey []byte, satsReceived int64, days int,
 639  ) error {
 640  	// Get relay identity secret to sign the note
 641  	skb, err := pp.db.GetRelayIdentitySecret()
 642  	if err != nil || len(skb) != 32 {
 643  		return fmt.Errorf("no relay identity configured")
 644  	}
 645  
 646  	// Initialize signer
 647  	sign := p8k.MustNew()
 648  	if err := sign.InitSec(skb); err != nil {
 649  		return fmt.Errorf("failed to initialize signer: %w", err)
 650  	}
 651  
 652  	// Get subscription info to determine expiry
 653  	sub, err := pp.db.GetSubscription(payerPubkey)
 654  	if err != nil {
 655  		return fmt.Errorf("failed to get subscription: %w", err)
 656  	}
 657  
 658  	var expiryTime time.Time
 659  	if sub != nil && !sub.PaidUntil.IsZero() {
 660  		expiryTime = sub.PaidUntil
 661  	} else {
 662  		expiryTime = time.Now().AddDate(0, 0, days)
 663  	}
 664  
 665  	// Get relay npub for content link
 666  	relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub())
 667  	if err != nil {
 668  		return fmt.Errorf("failed to encode relay npub: %w", err)
 669  	}
 670  
 671  	// Create the note content with nostr:npub link and dashboard link
 672  	content := fmt.Sprintf(
 673  		"Payment received: %d sats for %d days. Subscription expires: %s\n\nRelay: nostr:%s\n\nLog in to the relay dashboard to access your configuration at: %s",
 674  		satsReceived, days, expiryTime.Format("2006-01-02 15:04:05 UTC"),
 675  		string(relayNpubForContent), pp.getDashboardURL(),
 676  	)
 677  
 678  	// Build the event
 679  	ev := event.New()
 680  	ev.Kind = kind.TextNote.K // Kind 1 for text note
 681  	ev.Pubkey = sign.Pub()
 682  	ev.CreatedAt = timestamp.Now().V
 683  	ev.Content = []byte(content)
 684  	ev.Tags = tag.NewS()
 685  
 686  	// Add "p" tag for the payer
 687  	*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(payerPubkey)))
 688  
 689  	// Add expiration tag (5 days from creation)
 690  	noteExpiry := time.Now().AddDate(0, 0, 5)
 691  	*ev.Tags = append(
 692  		*ev.Tags,
 693  		tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())),
 694  	)
 695  
 696  	// Add "private" tag with authorized npubs (payer and relay)
 697  	var authorizedNpubs []string
 698  
 699  	// Add payer npub
 700  	payerNpub, err := bech32encoding.BinToNpub(payerPubkey)
 701  	if err == nil {
 702  		authorizedNpubs = append(authorizedNpubs, string(payerNpub))
 703  	}
 704  
 705  	// Add relay npub
 706  	relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
 707  	if err == nil {
 708  		authorizedNpubs = append(authorizedNpubs, string(relayNpub))
 709  	}
 710  
 711  	// Create the private tag with comma-separated npubs
 712  	if len(authorizedNpubs) > 0 {
 713  		privateTagValue := strings.Join(authorizedNpubs, ",")
 714  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
 715  		// Add protected "-" tag to mark this event as protected
 716  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("-", ""))
 717  	}
 718  
 719  	// Sign and save the event
 720  	ev.Sign(sign)
 721  	if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
 722  		return fmt.Errorf("failed to save payment note: %w", err)
 723  	}
 724  
 725  	log.I.F(
 726  		"created payment note for %s with private authorization",
 727  		hex.Enc(payerPubkey),
 728  	)
 729  	return nil
 730  }
 731  
 732  // CreateWelcomeNote creates a welcome note for first-time users with private tag for authorization
 733  func (pp *PaymentProcessor) CreateWelcomeNote(userPubkey []byte) error {
 734  	// Get relay identity secret to sign the note
 735  	skb, err := pp.db.GetRelayIdentitySecret()
 736  	if err != nil || len(skb) != 32 {
 737  		return fmt.Errorf("no relay identity configured")
 738  	}
 739  
 740  	// Initialize signer
 741  	sign := p8k.MustNew()
 742  	if err := sign.InitSec(skb); err != nil {
 743  		return fmt.Errorf("failed to initialize signer: %w", err)
 744  	}
 745  
 746  	monthlyPrice := pp.config.MonthlyPriceSats
 747  	if monthlyPrice <= 0 {
 748  		monthlyPrice = 6000
 749  	}
 750  
 751  	// Get relay npub for content link
 752  	relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub())
 753  	if err != nil {
 754  		return fmt.Errorf("failed to encode relay npub: %w", err)
 755  	}
 756  
 757  	// Get user npub for personalized greeting
 758  	userNpub, err := bech32encoding.BinToNpub(userPubkey)
 759  	if err != nil {
 760  		return fmt.Errorf("failed to encode user npub: %w", err)
 761  	}
 762  
 763  	// Create the welcome note content with privacy notice and personalized greeting
 764  	content := fmt.Sprintf(
 765  		`This note is only visible to you
 766  
 767  Hi nostr:%s
 768  
 769  Welcome to the relay! 🎉
 770  
 771  You have a FREE 30-day trial that started when you first logged in.
 772  
 773  💰 Subscription Details:
 774  - Monthly price: %d sats
 775  - Trial period: 30 days from first login
 776  
 777  💡 How to Subscribe:
 778  To extend your subscription after the trial ends, simply zap this note with the amount you want to pay. Each %d sats = 30 days of access.
 779  
 780  ⚡ Payment Instructions:
 781  1. Use any Lightning wallet that supports zaps
 782  2. Zap this note with your payment
 783  3. Your subscription will be automatically extended
 784  
 785  Relay: nostr:%s
 786  
 787  Log in to the relay dashboard to access your configuration at: %s
 788  
 789  Enjoy your time on the relay!`, string(userNpub), monthlyPrice, monthlyPrice,
 790  		string(relayNpubForContent), pp.getDashboardURL(),
 791  	)
 792  
 793  	// Build the event
 794  	ev := event.New()
 795  	ev.Kind = kind.TextNote.K // Kind 1 for text note
 796  	ev.Pubkey = sign.Pub()
 797  	ev.CreatedAt = timestamp.Now().V
 798  	ev.Content = []byte(content)
 799  	ev.Tags = tag.NewS()
 800  
 801  	// Add "p" tag for the user with mention in third field
 802  	*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey), "", "mention"))
 803  
 804  	// Add expiration tag (5 days from creation)
 805  	noteExpiry := time.Now().AddDate(0, 0, 5)
 806  	*ev.Tags = append(
 807  		*ev.Tags,
 808  		tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())),
 809  	)
 810  
 811  	// Add "private" tag with authorized npubs (user and relay)
 812  	var authorizedNpubs []string
 813  
 814  	// Add user npub (already encoded above)
 815  	authorizedNpubs = append(authorizedNpubs, string(userNpub))
 816  
 817  	// Add relay npub
 818  	relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
 819  	if err == nil {
 820  		authorizedNpubs = append(authorizedNpubs, string(relayNpub))
 821  	}
 822  
 823  	// Create the private tag with comma-separated npubs
 824  	if len(authorizedNpubs) > 0 {
 825  		privateTagValue := strings.Join(authorizedNpubs, ",")
 826  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
 827  		// Add protected "-" tag to mark this event as protected
 828  		*ev.Tags = append(*ev.Tags, tag.NewFromAny("-", ""))
 829  	}
 830  
 831  	// Add a special tag to mark this as a welcome note
 832  	*ev.Tags = append(*ev.Tags, tag.NewFromAny("welcome", "first-time-user"))
 833  
 834  	// Sign and save the event
 835  	ev.Sign(sign)
 836  	if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
 837  		return fmt.Errorf("failed to save welcome note: %w", err)
 838  	}
 839  
 840  	log.I.F("created welcome note for first-time user %s", hex.Enc(userPubkey))
 841  	return nil
 842  }
 843  
 844  // SetDashboardURL sets the dynamic dashboard URL based on HTTP request
 845  func (pp *PaymentProcessor) SetDashboardURL(url string) {
 846  	pp.dashboardURL = url
 847  }
 848  
 849  // getDashboardURL returns the dashboard URL for the relay
 850  func (pp *PaymentProcessor) getDashboardURL() string {
 851  	// Use dynamic URL if available
 852  	if pp.dashboardURL != "" {
 853  		return pp.dashboardURL
 854  	}
 855  	// Fallback to static config
 856  	if pp.config.RelayURL != "" {
 857  		return pp.config.RelayURL
 858  	}
 859  	// Default fallback if no URL is configured
 860  	return "https://your-relay.example.com"
 861  }
 862  
 863  // extractNpubFromDescription extracts an npub from the payment description
 864  func (pp *PaymentProcessor) extractNpubFromDescription(description string) string {
 865  	// check if the entire description is just an npub
 866  	description = strings.TrimSpace(description)
 867  	if strings.HasPrefix(description, "npub1") && len(description) == 63 {
 868  		return description
 869  	}
 870  
 871  	// Look for npub1... pattern in the description
 872  	parts := strings.Fields(description)
 873  	for _, part := range parts {
 874  		if strings.HasPrefix(part, "npub1") && len(part) == 63 {
 875  			return part
 876  		}
 877  	}
 878  
 879  	return ""
 880  }
 881  
 882  // npubToPubkey converts an npub string to pubkey bytes
 883  func (pp *PaymentProcessor) npubToPubkey(npubStr string) ([]byte, error) {
 884  	// Validate npub format
 885  	if !strings.HasPrefix(npubStr, "npub1") || len(npubStr) != 63 {
 886  		return nil, fmt.Errorf("invalid npub format")
 887  	}
 888  
 889  	// Decode using bech32encoding
 890  	prefix, value, err := bech32encoding.Decode([]byte(npubStr))
 891  	if err != nil {
 892  		return nil, fmt.Errorf("failed to decode npub: %w", err)
 893  	}
 894  
 895  	if !strings.EqualFold(string(prefix), "npub") {
 896  		return nil, fmt.Errorf("invalid prefix: %s", string(prefix))
 897  	}
 898  
 899  	pubkey, ok := value.([]byte)
 900  	if !ok {
 901  		return nil, fmt.Errorf("decoded value is not []byte")
 902  	}
 903  
 904  	return pubkey, nil
 905  }
 906  
 907  // parseBlossomServiceLevel parses the zap memo for a blossom service level specification
 908  // Format: "blossom:level" or "blossom:level:storage_mb" in description or metadata memo field
 909  func (pp *PaymentProcessor) parseBlossomServiceLevel(
 910  	description string, metadata map[string]any,
 911  ) string {
 912  	// Check metadata memo field first
 913  	if metadata != nil {
 914  		if memo, ok := metadata["memo"].(string); ok && memo != "" {
 915  			if level := pp.extractBlossomLevelFromMemo(memo); level != "" {
 916  				return level
 917  			}
 918  		}
 919  	}
 920  
 921  	// Check description
 922  	if description != "" {
 923  		if level := pp.extractBlossomLevelFromMemo(description); level != "" {
 924  			return level
 925  		}
 926  	}
 927  
 928  	return ""
 929  }
 930  
 931  // extractBlossomLevelFromMemo extracts blossom service level from memo text
 932  // Supports formats: "blossom:basic", "blossom:premium", "blossom:basic:100"
 933  func (pp *PaymentProcessor) extractBlossomLevelFromMemo(memo string) string {
 934  	// Look for "blossom:" prefix
 935  	parts := strings.Fields(memo)
 936  	for _, part := range parts {
 937  		if strings.HasPrefix(part, "blossom:") {
 938  			// Extract level name (e.g., "basic", "premium")
 939  			levelPart := strings.TrimPrefix(part, "blossom:")
 940  			// Remove any storage specification (e.g., ":100")
 941  			if colonIdx := strings.Index(levelPart, ":"); colonIdx > 0 {
 942  				levelPart = levelPart[:colonIdx]
 943  			}
 944  			// Validate level exists in config
 945  			if pp.isValidBlossomLevel(levelPart) {
 946  				return levelPart
 947  			}
 948  		}
 949  	}
 950  	return ""
 951  }
 952  
 953  // isValidBlossomLevel checks if a service level is configured
 954  func (pp *PaymentProcessor) isValidBlossomLevel(level string) bool {
 955  	if pp.config == nil || pp.config.BlossomServiceLevels == "" {
 956  		return false
 957  	}
 958  
 959  	// Parse service levels from config
 960  	levels := strings.Split(pp.config.BlossomServiceLevels, ",")
 961  	for _, l := range levels {
 962  		l = strings.TrimSpace(l)
 963  		if strings.HasPrefix(l, level+":") {
 964  			return true
 965  		}
 966  	}
 967  	return false
 968  }
 969  
 970  // parseServiceLevelStorage parses storage quota in MB per sat per month for a service level
 971  func (pp *PaymentProcessor) parseServiceLevelStorage(level string) (int64, error) {
 972  	if pp.config == nil || pp.config.BlossomServiceLevels == "" {
 973  		return 0, fmt.Errorf("blossom service levels not configured")
 974  	}
 975  
 976  	levels := strings.Split(pp.config.BlossomServiceLevels, ",")
 977  	for _, l := range levels {
 978  		l = strings.TrimSpace(l)
 979  		if strings.HasPrefix(l, level+":") {
 980  			parts := strings.Split(l, ":")
 981  			if len(parts) >= 2 {
 982  				var storageMB float64
 983  				if _, err := fmt.Sscanf(parts[1], "%f", &storageMB); err != nil {
 984  					return 0, fmt.Errorf("invalid storage format: %w", err)
 985  				}
 986  				return int64(storageMB), nil
 987  			}
 988  		}
 989  	}
 990  	return 0, fmt.Errorf("service level %s not found", level)
 991  }
 992  
 993  // extendBlossomSubscription extends or creates a blossom subscription with service level
 994  func (pp *PaymentProcessor) extendBlossomSubscription(
 995  	pubkey []byte, satsReceived int64, level string, days int,
 996  ) error {
 997  	// Get storage quota per sat per month for this level
 998  	storageMBPerSatPerMonth, err := pp.parseServiceLevelStorage(level)
 999  	if err != nil {
1000  		return fmt.Errorf("failed to parse service level storage: %w", err)
1001  	}
1002  
1003  	// Calculate storage quota: sats * storage_mb_per_sat_per_month * (days / 30)
1004  	storageMB := int64(float64(satsReceived) * float64(storageMBPerSatPerMonth) * (float64(days) / 30.0))
1005  
1006  	// Extend blossom subscription
1007  	if err := pp.db.ExtendBlossomSubscription(pubkey, level, storageMB, days); err != nil {
1008  		return fmt.Errorf("failed to extend blossom subscription: %w", err)
1009  	}
1010  
1011  	log.I.F(
1012  		"extended blossom subscription: level=%s, storage=%d MB, days=%d",
1013  		level, storageMB, days,
1014  	)
1015  
1016  	return nil
1017  }
1018  
1019  // UpdateRelayProfile creates or updates the relay's kind 0 profile with subscription information
1020  func (pp *PaymentProcessor) UpdateRelayProfile() error {
1021  	// Get relay identity secret to sign the profile
1022  	skb, err := pp.db.GetRelayIdentitySecret()
1023  	if err != nil || len(skb) != 32 {
1024  		return fmt.Errorf("no relay identity configured")
1025  	}
1026  
1027  	// Initialize signer
1028  	sign := p8k.MustNew()
1029  	if err := sign.InitSec(skb); err != nil {
1030  		return fmt.Errorf("failed to initialize signer: %w", err)
1031  	}
1032  
1033  	monthlyPrice := pp.config.MonthlyPriceSats
1034  	if monthlyPrice <= 0 {
1035  		monthlyPrice = 6000
1036  	}
1037  
1038  	// Calculate daily rate
1039  	dailyRate := monthlyPrice / 30
1040  
1041  	// Get relay wss:// URL - use dashboard URL but with wss:// scheme
1042  	relayURL := strings.Replace(pp.getDashboardURL(), "https://", "wss://", 1)
1043  
1044  	// Create profile content as JSON
1045  	profileContent := fmt.Sprintf(
1046  		`{
1047  	"name": "Relay Bot",
1048  	"about": "This relay requires a subscription to access. Zap any of my notes to pay for access. Monthly price: %d sats (%d sats/day). Relay: %s",
1049  	"lud16": "",
1050  	"nip05": "",
1051  	"website": "%s"
1052  }`, monthlyPrice, dailyRate, relayURL, pp.getDashboardURL(),
1053  	)
1054  
1055  	// Build the profile event
1056  	ev := event.New()
1057  	ev.Kind = kind.ProfileMetadata.K // Kind 0 for profile metadata
1058  	ev.Pubkey = sign.Pub()
1059  	ev.CreatedAt = timestamp.Now().V
1060  	ev.Content = []byte(profileContent)
1061  	ev.Tags = tag.NewS()
1062  
1063  	// Sign and save the event
1064  	ev.Sign(sign)
1065  	if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
1066  		return fmt.Errorf("failed to save relay profile: %w", err)
1067  	}
1068  
1069  	log.I.F("updated relay profile with subscription information")
1070  	return nil
1071  }
1072  
1073  // decodeAnyPubkey decodes a public key from either hex string or npub format
1074  func decodeAnyPubkey(s string) ([]byte, error) {
1075  	s = strings.TrimSpace(s)
1076  	if strings.HasPrefix(s, "npub1") {
1077  		prefix, value, err := bech32encoding.Decode([]byte(s))
1078  		if err != nil {
1079  			return nil, fmt.Errorf("failed to decode npub: %w", err)
1080  		}
1081  		if !strings.EqualFold(string(prefix), "npub") {
1082  			return nil, fmt.Errorf("invalid prefix: %s", string(prefix))
1083  		}
1084  		b, ok := value.([]byte)
1085  		if !ok {
1086  			return nil, fmt.Errorf("decoded value is not []byte")
1087  		}
1088  		return b, nil
1089  	}
1090  	// assume hex-encoded public key
1091  	return hex.Dec(s)
1092  }
1093