- Deleted the custom JSON encoder/decoder implementation in favor of the standard library's `encoding/json` package. - Removed the `next.orly.dev/pkg/encoders/json` package and its associated files. - Updated `go.mod` to remove the `github.com/nostr-dev-kit/ndk` dependency and bump the version of `lol.mleku.dev` from v1.0.3 to v1.0.4. - Cleaned up import statements across various files to reflect the removal of the custom JSON package. - Ensured that all references to the old JSON encoding/decoding methods are replaced with the standard library equivalents.
965 lines
27 KiB
Go
965 lines
27 KiB
Go
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"
|
|
"next.orly.dev/pkg/crypto/p256k"
|
|
"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 := new(p256k.Signer)
|
|
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>
|
|
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>
|
|
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 := new(p256k.Signer)
|
|
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 := new(p256k.Signer)
|
|
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
|
|
if metadata, ok := notification["metadata"].(map[string]any); ok {
|
|
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 {
|
|
var signer p256k.Signer
|
|
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)
|
|
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")
|
|
}
|
|
|
|
if err := pp.db.ExtendSubscription(pubkey, days); err != nil {
|
|
return fmt.Errorf("failed to extend subscription: %w", err)
|
|
}
|
|
|
|
// 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 := new(p256k.Signer)
|
|
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 := new(p256k.Signer)
|
|
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
|
|
}
|
|
|
|
// 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 := new(p256k.Signer)
|
|
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)
|
|
}
|