package app import ( "context" // std hex not used; use project hex encoder instead "fmt" "strings" "sync" "time" "encoding/json" "github.com/dgraph-io/badger/v4" "lol.mleku.dev/chk" "lol.mleku.dev/log" "next.orly.dev/app/config" "next.orly.dev/pkg/acl" p256k1signer "p256k1.mleku.dev/signer" "next.orly.dev/pkg/database" "next.orly.dev/pkg/encoders/bech32encoding" "next.orly.dev/pkg/encoders/event" "next.orly.dev/pkg/encoders/hex" "next.orly.dev/pkg/encoders/kind" "next.orly.dev/pkg/encoders/tag" "next.orly.dev/pkg/encoders/timestamp" "next.orly.dev/pkg/protocol/nwc" ) // PaymentProcessor handles NWC payment notifications and updates subscriptions type PaymentProcessor struct { nwcClient *nwc.Client db *database.D config *config.C ctx context.Context cancel context.CancelFunc wg sync.WaitGroup dashboardURL string } // NewPaymentProcessor creates a new payment processor func NewPaymentProcessor( ctx context.Context, cfg *config.C, db *database.D, ) (pp *PaymentProcessor, err error) { if cfg.NWCUri == "" { return nil, fmt.Errorf("NWC URI not configured") } var nwcClient *nwc.Client if nwcClient, err = nwc.NewClient(cfg.NWCUri); chk.E(err) { return nil, fmt.Errorf("failed to create NWC client: %w", err) } c, cancel := context.WithCancel(ctx) pp = &PaymentProcessor{ nwcClient: nwcClient, db: db, config: cfg, ctx: c, cancel: cancel, } return pp, nil } // Start begins listening for payment notifications func (pp *PaymentProcessor) Start() error { // start NWC notifications listener pp.wg.Add(1) go func() { defer pp.wg.Done() if err := pp.listenForPayments(); err != nil { log.E.F("payment processor error: %v", err) } }() // start periodic follow-list sync if subscriptions are enabled if pp.config != nil && pp.config.SubscriptionEnabled { pp.wg.Add(1) go func() { defer pp.wg.Done() pp.runFollowSyncLoop() }() // start daily subscription checker pp.wg.Add(1) go func() { defer pp.wg.Done() pp.runDailySubscriptionChecker() }() } return nil } // Stop gracefully stops the payment processor func (pp *PaymentProcessor) Stop() { if pp.cancel != nil { pp.cancel() } pp.wg.Wait() } // listenForPayments subscribes to NWC notifications and processes payments func (pp *PaymentProcessor) listenForPayments() error { return pp.nwcClient.SubscribeNotifications(pp.ctx, pp.handleNotification) } // runFollowSyncLoop periodically syncs the relay identity follow list with active subscribers func (pp *PaymentProcessor) runFollowSyncLoop() { t := time.NewTicker(10 * time.Minute) defer t.Stop() // do an initial sync shortly after start _ = pp.syncFollowList() for { select { case <-pp.ctx.Done(): return case <-t.C: if err := pp.syncFollowList(); err != nil { log.W.F("follow list sync failed: %v", err) } } } } // runDailySubscriptionChecker checks once daily for subscription expiry warnings and trial reminders func (pp *PaymentProcessor) runDailySubscriptionChecker() { t := time.NewTicker(24 * time.Hour) defer t.Stop() // do an initial check shortly after start _ = pp.checkSubscriptionStatus() for { select { case <-pp.ctx.Done(): return case <-t.C: if err := pp.checkSubscriptionStatus(); err != nil { log.W.F("subscription status check failed: %v", err) } } } } // syncFollowList builds a kind-3 event from the relay identity containing only active subscribers func (pp *PaymentProcessor) syncFollowList() error { // ensure we have a relay identity secret skb, err := pp.db.GetRelayIdentitySecret() if err != nil || len(skb) != 32 { return nil // nothing to do if no identity } // collect active subscribers actives, err := pp.getActiveSubscriberPubkeys() if err != nil { return err } // signer sign := p256k1signer.NewP256K1Signer() if err := sign.InitSec(skb); err != nil { return err } // build follow list event ev := event.New() ev.Kind = kind.FollowList.K ev.Pubkey = sign.Pub() ev.CreatedAt = timestamp.Now().V ev.Tags = tag.NewS() for _, pk := range actives { *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(pk))) } // sign and save ev.Sign(sign) if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { return err } log.I.F( "updated relay follow list with %d active subscribers", len(actives), ) return nil } // getActiveSubscriberPubkeys scans the subscription records and returns active ones func (pp *PaymentProcessor) getActiveSubscriberPubkeys() ([][]byte, error) { prefix := []byte("sub:") now := time.Now() var out [][]byte err := pp.db.DB.View( func(txn *badger.Txn) error { it := txn.NewIterator(badger.DefaultIteratorOptions) defer it.Close() for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { item := it.Item() key := item.KeyCopy(nil) // key format: sub: hexpub := string(key[len(prefix):]) var sub database.Subscription if err := item.Value( func(val []byte) error { return json.Unmarshal(val, &sub) }, ); err != nil { return err } if now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)) { if b, err := hex.Dec(hexpub); err == nil { out = append(out, b) } } } return nil }, ) return out, err } // checkSubscriptionStatus scans all subscriptions and creates warning/reminder notes func (pp *PaymentProcessor) checkSubscriptionStatus() error { prefix := []byte("sub:") now := time.Now() sevenDaysFromNow := now.AddDate(0, 0, 7) return pp.db.DB.View( func(txn *badger.Txn) error { it := txn.NewIterator(badger.DefaultIteratorOptions) defer it.Close() for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { item := it.Item() key := item.KeyCopy(nil) // key format: sub: hexpub := string(key[len(prefix):]) var sub database.Subscription if err := item.Value( func(val []byte) error { return json.Unmarshal(val, &sub) }, ); err != nil { continue // skip invalid subscription records } pubkey, err := hex.Dec(hexpub) if err != nil { continue // skip invalid pubkey } // Check if paid subscription is expiring in 7 days if !sub.PaidUntil.IsZero() { // Format dates for comparison (ignore time component) paidUntilDate := sub.PaidUntil.Truncate(24 * time.Hour) sevenDaysDate := sevenDaysFromNow.Truncate(24 * time.Hour) if paidUntilDate.Equal(sevenDaysDate) { go pp.createExpiryWarningNote(pubkey, sub.PaidUntil) } } // Check if user is on trial (no paid subscription, trial not expired) if sub.PaidUntil.IsZero() && now.Before(sub.TrialEnd) { go pp.createTrialReminderNote(pubkey, sub.TrialEnd) } } return nil }, ) } // createExpiryWarningNote creates a warning note for users whose paid subscription expires in 7 days func (pp *PaymentProcessor) createExpiryWarningNote( userPubkey []byte, expiryTime time.Time, ) error { // Get relay identity secret to sign the note skb, err := pp.db.GetRelayIdentitySecret() if err != nil || len(skb) != 32 { return fmt.Errorf("no relay identity configured") } // Initialize signer sign := p256k1signer.NewP256K1Signer() if err := sign.InitSec(skb); err != nil { return fmt.Errorf("failed to initialize signer: %w", err) } monthlyPrice := pp.config.MonthlyPriceSats if monthlyPrice <= 0 { monthlyPrice = 6000 } // Get relay npub for content link relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub()) if err != nil { return fmt.Errorf("failed to encode relay npub: %w", err) } // Create the warning note content content := fmt.Sprintf( `⚠️ Subscription Expiring Soon ⚠️ Your paid subscription to this relay will expire in 7 days on %s. 💰 To extend your subscription: - Monthly price: %d sats - Zap this note with your payment amount - Each %d sats = 30 days of access ⚡ Payment Instructions: 1. Use any Lightning wallet that supports zaps 2. Zap this note with your payment 3. Your subscription will be automatically extended Don't lose access to your private relay! Extend your subscription today. Relay: nostr:%s Log in to the relay dashboard to access your configuration at: %s`, expiryTime.Format("2006-01-02 15:04:05 UTC"), monthlyPrice, monthlyPrice, string(relayNpubForContent), pp.getDashboardURL(), ) // Build the event ev := event.New() ev.Kind = kind.TextNote.K // Kind 1 for text note ev.Pubkey = sign.Pub() ev.CreatedAt = timestamp.Now().V ev.Content = []byte(content) ev.Tags = tag.NewS() // Add "p" tag for the user *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey))) // Add expiration tag (5 days from creation) noteExpiry := time.Now().AddDate(0, 0, 5) *ev.Tags = append( *ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())), ) // Add "private" tag with authorized npubs (user and relay) var authorizedNpubs []string // Add user npub userNpub, err := bech32encoding.BinToNpub(userPubkey) if err == nil { authorizedNpubs = append(authorizedNpubs, string(userNpub)) } // Add relay npub relayNpub, err := bech32encoding.BinToNpub(sign.Pub()) if err == nil { authorizedNpubs = append(authorizedNpubs, string(relayNpub)) } // Create the private tag with comma-separated npubs if len(authorizedNpubs) > 0 { privateTagValue := strings.Join(authorizedNpubs, ",") *ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue)) // Add protected "-" tag to mark this event as protected *ev.Tags = append(*ev.Tags, tag.NewFromAny("-", "")) } // Add a special tag to mark this as an expiry warning *ev.Tags = append( *ev.Tags, tag.NewFromAny("warning", "subscription-expiry"), ) // Sign and save the event ev.Sign(sign) if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { return fmt.Errorf("failed to save expiry warning note: %w", err) } log.I.F( "created expiry warning note for user %s (expires %s)", hex.Enc(userPubkey), expiryTime.Format("2006-01-02"), ) return nil } // createTrialReminderNote creates a reminder note for users on trial to support the relay func (pp *PaymentProcessor) createTrialReminderNote( userPubkey []byte, trialEnd time.Time, ) error { // Get relay identity secret to sign the note skb, err := pp.db.GetRelayIdentitySecret() if err != nil || len(skb) != 32 { return fmt.Errorf("no relay identity configured") } // Initialize signer sign := p256k1signer.NewP256K1Signer() if err := sign.InitSec(skb); err != nil { return fmt.Errorf("failed to initialize signer: %w", err) } monthlyPrice := pp.config.MonthlyPriceSats if monthlyPrice <= 0 { monthlyPrice = 6000 } // Calculate daily rate dailyRate := monthlyPrice / 30 // Get relay npub for content link relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub()) if err != nil { return fmt.Errorf("failed to encode relay npub: %w", err) } // Create the reminder note content content := fmt.Sprintf( `🆓 Free Trial Reminder 🆓 You're currently using this relay for FREE! Your trial expires on %s. 🙏 Support Relay Operations: This relay provides you with private, censorship-resistant communication. Please consider supporting its continued operation. 💰 Subscription Details: - Monthly price: %d sats (%d sats/day) - Fair pricing for premium service - Helps keep the relay running 24/7 ⚡ How to Subscribe: Simply zap this note with your payment amount: - Each %d sats = 30 days of access - Payment is processed automatically - No account setup required Thank you for considering supporting decentralized communication! Relay: nostr:%s Log in to the relay dashboard to access your configuration at: %s`, trialEnd.Format("2006-01-02 15:04:05 UTC"), monthlyPrice, dailyRate, monthlyPrice, string(relayNpubForContent), pp.getDashboardURL(), ) // Build the event ev := event.New() ev.Kind = kind.TextNote.K // Kind 1 for text note ev.Pubkey = sign.Pub() ev.CreatedAt = timestamp.Now().V ev.Content = []byte(content) ev.Tags = tag.NewS() // Add "p" tag for the user *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey))) // Add expiration tag (5 days from creation) noteExpiry := time.Now().AddDate(0, 0, 5) *ev.Tags = append( *ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())), ) // Add "private" tag with authorized npubs (user and relay) var authorizedNpubs []string // Add user npub userNpub, err := bech32encoding.BinToNpub(userPubkey) if err == nil { authorizedNpubs = append(authorizedNpubs, string(userNpub)) } // Add relay npub relayNpub, err := bech32encoding.BinToNpub(sign.Pub()) if err == nil { authorizedNpubs = append(authorizedNpubs, string(relayNpub)) } // Create the private tag with comma-separated npubs if len(authorizedNpubs) > 0 { privateTagValue := strings.Join(authorizedNpubs, ",") *ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue)) // Add protected "-" tag to mark this event as protected *ev.Tags = append(*ev.Tags, tag.NewFromAny("-", "")) } // Add a special tag to mark this as a trial reminder *ev.Tags = append(*ev.Tags, tag.NewFromAny("reminder", "trial-support")) // Sign and save the event ev.Sign(sign) if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { return fmt.Errorf("failed to save trial reminder note: %w", err) } log.I.F( "created trial reminder note for user %s (trial ends %s)", hex.Enc(userPubkey), trialEnd.Format("2006-01-02"), ) return nil } // handleNotification processes incoming payment notifications func (pp *PaymentProcessor) handleNotification( notificationType string, notification map[string]any, ) error { // Only process payment_received notifications if notificationType != "payment_received" { return nil } amount, ok := notification["amount"].(float64) if !ok { return fmt.Errorf("invalid amount") } // Prefer explicit payer/relay pubkeys if provided in metadata var payerPubkey []byte var userNpub string var metadata map[string]any if md, ok := notification["metadata"].(map[string]any); ok { metadata = md if s, ok := metadata["payer_pubkey"].(string); ok && s != "" { if pk, err := decodeAnyPubkey(s); err == nil { payerPubkey = pk } } if payerPubkey == nil { if s, ok := metadata["sender_pubkey"].(string); ok && s != "" { // alias if pk, err := decodeAnyPubkey(s); err == nil { payerPubkey = pk } } } // Optional: the intended subscriber npub (for backwards compat) if userNpub == "" { if npubField, ok := metadata["npub"].(string); ok { userNpub = npubField } } // If relay identity pubkey is provided, verify it matches ours if s, ok := metadata["relay_pubkey"].(string); ok && s != "" { if rpk, err := decodeAnyPubkey(s); err == nil { if skb, err := pp.db.GetRelayIdentitySecret(); err == nil && len(skb) == 32 { signer := p256k1signer.NewP256K1Signer() if err := signer.InitSec(skb); err == nil { if !strings.EqualFold( hex.Enc(rpk), hex.Enc(signer.Pub()), ) { log.W.F( "relay_pubkey in payment metadata does not match this relay identity: got %s want %s", hex.Enc(rpk), hex.Enc(signer.Pub()), ) } } } } } } // Fallback: extract npub from description or metadata description, _ := notification["description"].(string) if userNpub == "" { userNpub = pp.extractNpubFromDescription(description) } var pubkey []byte var err error if payerPubkey != nil { pubkey = payerPubkey } else { if userNpub == "" { return fmt.Errorf("no payer_pubkey or npub provided in payment notification") } pubkey, err = pp.npubToPubkey(userNpub) if err != nil { return fmt.Errorf("invalid npub: %w", err) } } satsReceived := int64(amount / 1000) // Parse zap memo for blossom service level blossomLevel := pp.parseBlossomServiceLevel(description, metadata) // Calculate subscription days (for relay access) monthlyPrice := pp.config.MonthlyPriceSats if monthlyPrice <= 0 { monthlyPrice = 6000 } days := int((float64(satsReceived) / float64(monthlyPrice)) * 30) if days < 1 { return fmt.Errorf("payment amount too small") } // Extend relay subscription if err := pp.db.ExtendSubscription(pubkey, days); err != nil { return fmt.Errorf("failed to extend subscription: %w", err) } // If blossom service level specified, extend blossom subscription if blossomLevel != "" { if err := pp.extendBlossomSubscription(pubkey, satsReceived, blossomLevel, days); err != nil { log.W.F("failed to extend blossom subscription: %v", err) // Don't fail the payment if blossom subscription fails } } // Record payment history invoice, _ := notification["invoice"].(string) preimage, _ := notification["preimage"].(string) if err := pp.db.RecordPayment( pubkey, satsReceived, invoice, preimage, ); err != nil { log.E.F("failed to record payment: %v", err) } // Log helpful identifiers var payerHex = hex.Enc(pubkey) if userNpub == "" { log.I.F( "payment processed: payer %s %d sats -> %d days", payerHex, satsReceived, days, ) } else { log.I.F( "payment processed: %s (%s) %d sats -> %d days", userNpub, payerHex, satsReceived, days, ) } // Update ACL follows cache and relay follow list immediately if pp.config != nil && pp.config.ACLMode == "follows" { acl.Registry.AddFollow(pubkey) } // Trigger an immediate follow-list sync in background (best-effort) go func() { _ = pp.syncFollowList() }() // Create a note with payment confirmation and private tag if err := pp.createPaymentNote(pubkey, satsReceived, days); err != nil { log.E.F("failed to create payment note: %v", err) } return nil } // createPaymentNote creates a note recording the payment with private tag for authorization func (pp *PaymentProcessor) createPaymentNote( payerPubkey []byte, satsReceived int64, days int, ) error { // Get relay identity secret to sign the note skb, err := pp.db.GetRelayIdentitySecret() if err != nil || len(skb) != 32 { return fmt.Errorf("no relay identity configured") } // Initialize signer sign := p256k1signer.NewP256K1Signer() if err := sign.InitSec(skb); err != nil { return fmt.Errorf("failed to initialize signer: %w", err) } // Get subscription info to determine expiry sub, err := pp.db.GetSubscription(payerPubkey) if err != nil { return fmt.Errorf("failed to get subscription: %w", err) } var expiryTime time.Time if sub != nil && !sub.PaidUntil.IsZero() { expiryTime = sub.PaidUntil } else { expiryTime = time.Now().AddDate(0, 0, days) } // Get relay npub for content link relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub()) if err != nil { return fmt.Errorf("failed to encode relay npub: %w", err) } // Create the note content with nostr:npub link and dashboard link content := fmt.Sprintf( "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", satsReceived, days, expiryTime.Format("2006-01-02 15:04:05 UTC"), string(relayNpubForContent), pp.getDashboardURL(), ) // Build the event ev := event.New() ev.Kind = kind.TextNote.K // Kind 1 for text note ev.Pubkey = sign.Pub() ev.CreatedAt = timestamp.Now().V ev.Content = []byte(content) ev.Tags = tag.NewS() // Add "p" tag for the payer *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(payerPubkey))) // Add expiration tag (5 days from creation) noteExpiry := time.Now().AddDate(0, 0, 5) *ev.Tags = append( *ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())), ) // Add "private" tag with authorized npubs (payer and relay) var authorizedNpubs []string // Add payer npub payerNpub, err := bech32encoding.BinToNpub(payerPubkey) if err == nil { authorizedNpubs = append(authorizedNpubs, string(payerNpub)) } // Add relay npub relayNpub, err := bech32encoding.BinToNpub(sign.Pub()) if err == nil { authorizedNpubs = append(authorizedNpubs, string(relayNpub)) } // Create the private tag with comma-separated npubs if len(authorizedNpubs) > 0 { privateTagValue := strings.Join(authorizedNpubs, ",") *ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue)) // Add protected "-" tag to mark this event as protected *ev.Tags = append(*ev.Tags, tag.NewFromAny("-", "")) } // Sign and save the event ev.Sign(sign) if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { return fmt.Errorf("failed to save payment note: %w", err) } log.I.F( "created payment note for %s with private authorization", hex.Enc(payerPubkey), ) return nil } // CreateWelcomeNote creates a welcome note for first-time users with private tag for authorization func (pp *PaymentProcessor) CreateWelcomeNote(userPubkey []byte) error { // Get relay identity secret to sign the note skb, err := pp.db.GetRelayIdentitySecret() if err != nil || len(skb) != 32 { return fmt.Errorf("no relay identity configured") } // Initialize signer sign := p256k1signer.NewP256K1Signer() if err := sign.InitSec(skb); err != nil { return fmt.Errorf("failed to initialize signer: %w", err) } monthlyPrice := pp.config.MonthlyPriceSats if monthlyPrice <= 0 { monthlyPrice = 6000 } // Get relay npub for content link relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub()) if err != nil { return fmt.Errorf("failed to encode relay npub: %w", err) } // Get user npub for personalized greeting userNpub, err := bech32encoding.BinToNpub(userPubkey) if err != nil { return fmt.Errorf("failed to encode user npub: %w", err) } // Create the welcome note content with privacy notice and personalized greeting content := fmt.Sprintf( `This note is only visible to you Hi nostr:%s Welcome to the relay! 🎉 You have a FREE 30-day trial that started when you first logged in. 💰 Subscription Details: - Monthly price: %d sats - Trial period: 30 days from first login 💡 How to Subscribe: 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. ⚡ Payment Instructions: 1. Use any Lightning wallet that supports zaps 2. Zap this note with your payment 3. Your subscription will be automatically extended Relay: nostr:%s Log in to the relay dashboard to access your configuration at: %s Enjoy your time on the relay!`, string(userNpub), monthlyPrice, monthlyPrice, string(relayNpubForContent), pp.getDashboardURL(), ) // Build the event ev := event.New() ev.Kind = kind.TextNote.K // Kind 1 for text note ev.Pubkey = sign.Pub() ev.CreatedAt = timestamp.Now().V ev.Content = []byte(content) ev.Tags = tag.NewS() // Add "p" tag for the user with mention in third field *ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey), "", "mention")) // Add expiration tag (5 days from creation) noteExpiry := time.Now().AddDate(0, 0, 5) *ev.Tags = append( *ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())), ) // Add "private" tag with authorized npubs (user and relay) var authorizedNpubs []string // Add user npub (already encoded above) authorizedNpubs = append(authorizedNpubs, string(userNpub)) // Add relay npub relayNpub, err := bech32encoding.BinToNpub(sign.Pub()) if err == nil { authorizedNpubs = append(authorizedNpubs, string(relayNpub)) } // Create the private tag with comma-separated npubs if len(authorizedNpubs) > 0 { privateTagValue := strings.Join(authorizedNpubs, ",") *ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue)) // Add protected "-" tag to mark this event as protected *ev.Tags = append(*ev.Tags, tag.NewFromAny("-", "")) } // Add a special tag to mark this as a welcome note *ev.Tags = append(*ev.Tags, tag.NewFromAny("welcome", "first-time-user")) // Sign and save the event ev.Sign(sign) if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { return fmt.Errorf("failed to save welcome note: %w", err) } log.I.F("created welcome note for first-time user %s", hex.Enc(userPubkey)) return nil } // SetDashboardURL sets the dynamic dashboard URL based on HTTP request func (pp *PaymentProcessor) SetDashboardURL(url string) { pp.dashboardURL = url } // getDashboardURL returns the dashboard URL for the relay func (pp *PaymentProcessor) getDashboardURL() string { // Use dynamic URL if available if pp.dashboardURL != "" { return pp.dashboardURL } // Fallback to static config if pp.config.RelayURL != "" { return pp.config.RelayURL } // Default fallback if no URL is configured return "https://your-relay.example.com" } // extractNpubFromDescription extracts an npub from the payment description func (pp *PaymentProcessor) extractNpubFromDescription(description string) string { // check if the entire description is just an npub description = strings.TrimSpace(description) if strings.HasPrefix(description, "npub1") && len(description) == 63 { return description } // Look for npub1... pattern in the description parts := strings.Fields(description) for _, part := range parts { if strings.HasPrefix(part, "npub1") && len(part) == 63 { return part } } return "" } // npubToPubkey converts an npub string to pubkey bytes func (pp *PaymentProcessor) npubToPubkey(npubStr string) ([]byte, error) { // Validate npub format if !strings.HasPrefix(npubStr, "npub1") || len(npubStr) != 63 { return nil, fmt.Errorf("invalid npub format") } // Decode using bech32encoding prefix, value, err := bech32encoding.Decode([]byte(npubStr)) if err != nil { return nil, fmt.Errorf("failed to decode npub: %w", err) } if !strings.EqualFold(string(prefix), "npub") { return nil, fmt.Errorf("invalid prefix: %s", string(prefix)) } pubkey, ok := value.([]byte) if !ok { return nil, fmt.Errorf("decoded value is not []byte") } return pubkey, nil } // parseBlossomServiceLevel parses the zap memo for a blossom service level specification // Format: "blossom:level" or "blossom:level:storage_mb" in description or metadata memo field func (pp *PaymentProcessor) parseBlossomServiceLevel( description string, metadata map[string]any, ) string { // Check metadata memo field first if metadata != nil { if memo, ok := metadata["memo"].(string); ok && memo != "" { if level := pp.extractBlossomLevelFromMemo(memo); level != "" { return level } } } // Check description if description != "" { if level := pp.extractBlossomLevelFromMemo(description); level != "" { return level } } return "" } // extractBlossomLevelFromMemo extracts blossom service level from memo text // Supports formats: "blossom:basic", "blossom:premium", "blossom:basic:100" func (pp *PaymentProcessor) extractBlossomLevelFromMemo(memo string) string { // Look for "blossom:" prefix parts := strings.Fields(memo) for _, part := range parts { if strings.HasPrefix(part, "blossom:") { // Extract level name (e.g., "basic", "premium") levelPart := strings.TrimPrefix(part, "blossom:") // Remove any storage specification (e.g., ":100") if colonIdx := strings.Index(levelPart, ":"); colonIdx > 0 { levelPart = levelPart[:colonIdx] } // Validate level exists in config if pp.isValidBlossomLevel(levelPart) { return levelPart } } } return "" } // isValidBlossomLevel checks if a service level is configured func (pp *PaymentProcessor) isValidBlossomLevel(level string) bool { if pp.config == nil || pp.config.BlossomServiceLevels == "" { return false } // Parse service levels from config levels := strings.Split(pp.config.BlossomServiceLevels, ",") for _, l := range levels { l = strings.TrimSpace(l) if strings.HasPrefix(l, level+":") { return true } } return false } // parseServiceLevelStorage parses storage quota in MB per sat per month for a service level func (pp *PaymentProcessor) parseServiceLevelStorage(level string) (int64, error) { if pp.config == nil || pp.config.BlossomServiceLevels == "" { return 0, fmt.Errorf("blossom service levels not configured") } levels := strings.Split(pp.config.BlossomServiceLevels, ",") for _, l := range levels { l = strings.TrimSpace(l) if strings.HasPrefix(l, level+":") { parts := strings.Split(l, ":") if len(parts) >= 2 { var storageMB float64 if _, err := fmt.Sscanf(parts[1], "%f", &storageMB); err != nil { return 0, fmt.Errorf("invalid storage format: %w", err) } return int64(storageMB), nil } } } return 0, fmt.Errorf("service level %s not found", level) } // extendBlossomSubscription extends or creates a blossom subscription with service level func (pp *PaymentProcessor) extendBlossomSubscription( pubkey []byte, satsReceived int64, level string, days int, ) error { // Get storage quota per sat per month for this level storageMBPerSatPerMonth, err := pp.parseServiceLevelStorage(level) if err != nil { return fmt.Errorf("failed to parse service level storage: %w", err) } // Calculate storage quota: sats * storage_mb_per_sat_per_month * (days / 30) storageMB := int64(float64(satsReceived) * float64(storageMBPerSatPerMonth) * (float64(days) / 30.0)) // Extend blossom subscription if err := pp.db.ExtendBlossomSubscription(pubkey, level, storageMB, days); err != nil { return fmt.Errorf("failed to extend blossom subscription: %w", err) } log.I.F( "extended blossom subscription: level=%s, storage=%d MB, days=%d", level, storageMB, days, ) return nil } // UpdateRelayProfile creates or updates the relay's kind 0 profile with subscription information func (pp *PaymentProcessor) UpdateRelayProfile() error { // Get relay identity secret to sign the profile skb, err := pp.db.GetRelayIdentitySecret() if err != nil || len(skb) != 32 { return fmt.Errorf("no relay identity configured") } // Initialize signer sign := p256k1signer.NewP256K1Signer() if err := sign.InitSec(skb); err != nil { return fmt.Errorf("failed to initialize signer: %w", err) } monthlyPrice := pp.config.MonthlyPriceSats if monthlyPrice <= 0 { monthlyPrice = 6000 } // Calculate daily rate dailyRate := monthlyPrice / 30 // Get relay wss:// URL - use dashboard URL but with wss:// scheme relayURL := strings.Replace(pp.getDashboardURL(), "https://", "wss://", 1) // Create profile content as JSON profileContent := fmt.Sprintf( `{ "name": "Relay Bot", "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", "lud16": "", "nip05": "", "website": "%s" }`, monthlyPrice, dailyRate, relayURL, pp.getDashboardURL(), ) // Build the profile event ev := event.New() ev.Kind = kind.ProfileMetadata.K // Kind 0 for profile metadata ev.Pubkey = sign.Pub() ev.CreatedAt = timestamp.Now().V ev.Content = []byte(profileContent) ev.Tags = tag.NewS() // Sign and save the event ev.Sign(sign) if _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil { return fmt.Errorf("failed to save relay profile: %w", err) } log.I.F("updated relay profile with subscription information") return nil } // decodeAnyPubkey decodes a public key from either hex string or npub format func decodeAnyPubkey(s string) ([]byte, error) { s = strings.TrimSpace(s) if strings.HasPrefix(s, "npub1") { prefix, value, err := bech32encoding.Decode([]byte(s)) if err != nil { return nil, fmt.Errorf("failed to decode npub: %w", err) } if !strings.EqualFold(string(prefix), "npub") { return nil, fmt.Errorf("invalid prefix: %s", string(prefix)) } b, ok := value.([]byte) if !ok { return nil, fmt.Errorf("decoded value is not []byte") } return b, nil } // assume hex-encoded public key return hex.Dec(s) }