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