feat: NWC Subscription System

This commit is contained in:
2025-08-19 11:13:19 -04:00
parent 499dab72b9
commit 9176a013d1
19 changed files with 2251 additions and 108 deletions

View File

@@ -154,4 +154,4 @@ func generateQueryFilter(index int) *filter.F {
Limit: &limit,
}
}
}
}

3
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/dgraph-io/badger/v4 v4.8.0
github.com/fasthttp/websocket v1.5.12
github.com/fatih/color v1.18.0
github.com/go-chi/chi/v5 v5.2.2
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/klauspost/cpuid/v2 v2.3.0
github.com/minio/sha256-simd v1.0.1
@@ -19,6 +20,7 @@ require (
github.com/rs/cors v1.11.1
github.com/stretchr/testify v1.10.0
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b
github.com/vmihailenco/msgpack/v5 v5.4.1
go-simpler.org/env v0.12.0
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.41.0
@@ -50,6 +52,7 @@ require (
github.com/templexxx/cpu v0.1.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.65.0 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect

6
go.sum
View File

@@ -41,6 +41,8 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -109,6 +111,10 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go-simpler.org/env v0.12.0 h1:kt/lBts0J1kjWJAnB740goNdvwNxt5emhYngL0Fzufs=

View File

@@ -27,27 +27,30 @@ import (
// and default values. It defines parameters for app behaviour, storage
// locations, logging, and network settings used across the relay service.
type C struct {
AppName string `env:"ORLY_APP_NAME" default:"ORLY"`
Config string `env:"ORLY_CONFIG_DIR" usage:"location for configuration file, which has the name '.env' to make it harder to delete, and is a standard environment KEY=value<newline>... style" default:"~/.config/orly"`
State string `env:"ORLY_STATE_DATA_DIR" usage:"storage location for state data affected by dynamic interactive interfaces" default:"~/.local/state/orly"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/cache/orly"`
Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"`
DbLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof on 127.0.0.1:6060" enum:"cpu,memory,allocation"`
AuthRequired bool `env:"ORLY_AUTH_REQUIRED" default:"false" usage:"require authentication for all requests"`
PublicReadable bool `env:"ORLY_PUBLIC_READABLE" default:"true" usage:"allow public read access to regardless of whether the client is authed"`
SpiderSeeds []string `env:"ORLY_SPIDER_SEEDS" usage:"seeds to use for the spider (relays that are looked up initially to find owner relay lists) (comma separated)" default:"wss://profiles.nostr1.com/,wss://relay.nostr.band/,wss://relay.damus.io/,wss://nostr.wine/,wss://nostr.land/,wss://theforest.nostr1.com/,wss://profiles.nostr1.com/"`
SpiderType string `env:"ORLY_SPIDER_TYPE" usage:"whether to spider, and what degree of spidering: none, directory, follows (follows means to the second degree of the follow graph)" default:"directory"`
SpiderTime time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"how often to run the spider, uses notation 0h0m0s" default:"1h"`
SpiderSecondDegree bool `env:"ORLY_SPIDER_SECOND_DEGREE" default:"true" usage:"whether to enable spidering the second degree of follows for non-directory events if ORLY_SPIDER_TYPE is set to 'follows'"`
Owners []string `env:"ORLY_OWNERS" usage:"list of users whose follow lists designate whitelisted users who can publish events, and who can read if public readable is false (comma separated)"`
Private bool `env:"ORLY_PRIVATE" usage:"do not spider for user metadata because the relay is private and this would leak relay memberships" default:"false"`
Whitelist []string `env:"ORLY_WHITELIST" usage:"only allow connections from this list of IP addresses"`
Blacklist []string `env:"ORLY_BLACKLIST" usage:"list of pubkeys to block when auth is not required (comma separated)"`
RelaySecret string `env:"ORLY_SECRET_KEY" usage:"secret key for relay cluster replication authentication"`
PeerRelays []string `env:"ORLY_PEER_RELAYS" usage:"list of peer relays URLs that new events are pushed to in format <pubkey>|<url>"`
AppName string `env:"ORLY_APP_NAME" default:"ORLY"`
Config string `env:"ORLY_CONFIG_DIR" usage:"location for configuration file, which has the name '.env' to make it harder to delete, and is a standard environment KEY=value<newline>... style" default:"~/.config/orly"`
State string `env:"ORLY_STATE_DATA_DIR" usage:"storage location for state data affected by dynamic interactive interfaces" default:"~/.local/state/orly"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/cache/orly"`
Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"`
DbLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof on 127.0.0.1:6060" enum:"cpu,memory,allocation"`
AuthRequired bool `env:"ORLY_AUTH_REQUIRED" default:"false" usage:"require authentication for all requests"`
PublicReadable bool `env:"ORLY_PUBLIC_READABLE" default:"true" usage:"allow public read access to regardless of whether the client is authed"`
SpiderSeeds []string `env:"ORLY_SPIDER_SEEDS" usage:"seeds to use for the spider (relays that are looked up initially to find owner relay lists) (comma separated)" default:"wss://profiles.nostr1.com/,wss://relay.nostr.band/,wss://relay.damus.io/,wss://nostr.wine/,wss://nostr.land/,wss://theforest.nostr1.com/,wss://profiles.nostr1.com/"`
SpiderType string `env:"ORLY_SPIDER_TYPE" usage:"whether to spider, and what degree of spidering: none, directory, follows (follows means to the second degree of the follow graph)" default:"directory"`
SpiderTime time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"how often to run the spider, uses notation 0h0m0s" default:"1h"`
SpiderSecondDegree bool `env:"ORLY_SPIDER_SECOND_DEGREE" default:"true" usage:"whether to enable spidering the second degree of follows for non-directory events if ORLY_SPIDER_TYPE is set to 'follows'"`
Owners []string `env:"ORLY_OWNERS" usage:"list of users whose follow lists designate whitelisted users who can publish events, and who can read if public readable is false (comma separated)"`
Private bool `env:"ORLY_PRIVATE" usage:"do not spider for user metadata because the relay is private and this would leak relay memberships" default:"false"`
Whitelist []string `env:"ORLY_WHITELIST" usage:"only allow connections from this list of IP addresses"`
Blacklist []string `env:"ORLY_BLACKLIST" usage:"list of pubkeys to block when auth is not required (comma separated)"`
RelaySecret string `env:"ORLY_SECRET_KEY" usage:"secret key for relay cluster replication authentication"`
PeerRelays []string `env:"ORLY_PEER_RELAYS" usage:"list of peer relays URLs that new events are pushed to in format <pubkey>|<url>"`
NWCUri string `env:"ORLY_NWC_URI" usage:"NWC (Nostr Wallet Connect) connection string for Lightning payments"`
SubscriptionEnabled bool `env:"ORLY_SUBSCRIPTION_ENABLED" default:"false" usage:"enable subscription-based access control requiring payment for non-directory events"`
MonthlyPriceSats int64 `env:"ORLY_MONTHLY_PRICE_SATS" default:"6000" usage:"price in satoshis for one month subscription (default ~$2 USD)"`
}
// New creates and initializes a new configuration object for the relay

View File

@@ -3,13 +3,17 @@ package relay
import (
"net/http"
"orly.dev/pkg/utils"
"time"
"orly.dev/pkg/database"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/hex"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/log"
)
// AcceptEvent determines whether an incoming event should be accepted for
// processing based on authentication requirements.
// processing based on authentication requirements and subscription status.
//
// # Parameters
//
@@ -33,20 +37,77 @@ import (
//
// # Expected Behaviour:
//
// - If authentication is required and no public key is provided, reject the
// event.
// - If subscriptions are enabled, check subscription status for non-directory events
//
// - If authentication is required and no public key is provided, reject the event.
//
// - Otherwise, accept the event for processing.
func (s *Server) AcceptEvent(
c context.T, ev *event.E, hr *http.Request, authedPubkey []byte,
remote string,
) (accept bool, notice string, afterSave func()) {
// Check subscription if enabled
if s.C.SubscriptionEnabled {
// Skip subscription check for directory events (kinds 0, 3, 10002)
kindInt := ev.Kind.ToInt()
isDirectoryEvent := kindInt == 0 || kindInt == 3 || kindInt == 10002
if !isDirectoryEvent {
// Check cache first
pubkeyHex := hex.Enc(ev.Pubkey)
now := time.Now()
s.subscriptionMutex.RLock()
cacheExpiry, cached := s.subscriptionCache[pubkeyHex]
s.subscriptionMutex.RUnlock()
if cached && now.Before(cacheExpiry) {
// Cache hit - subscription is active
accept = true
} else {
// Cache miss or expired - check database
if s.relay != nil && s.relay.Storage() != nil {
if db, ok := s.relay.Storage().(*database.D); ok {
isActive, err := db.IsSubscriptionActive(ev.Pubkey)
if err != nil {
log.E.F("error checking subscription for %s: %v", pubkeyHex, err)
notice = "error checking subscription status"
return
}
if !isActive {
notice = "subscription required - visit relay info page for payment details"
return
}
// Cache positive result for 60 seconds
s.subscriptionMutex.Lock()
s.subscriptionCache[pubkeyHex] = now.Add(60 * time.Second)
s.subscriptionMutex.Unlock()
accept = true
} else {
// Storage is not a database.D, subscription checks disabled
log.E.F("subscription enabled but storage is not database.D")
}
}
}
// If subscription check passed, continue with auth checks if needed
if !accept {
return
}
}
}
if !s.AuthRequired() {
// Check blacklist for public relay mode
if len(s.blacklistPubkeys) > 0 {
for _, blockedPubkey := range s.blacklistPubkeys {
if utils.FastEqual(blockedPubkey, ev.Pubkey) {
notice = "event author is blacklisted"
accept = false
return
}
}
@@ -57,11 +118,13 @@ func (s *Server) AcceptEvent(
// if auth is required and the user is not authed, reject
if len(authedPubkey) == 0 {
notice = "client isn't authed"
accept = false
return
}
for _, u := range s.OwnersMuted() {
if utils.FastEqual(u, authedPubkey) {
notice = "event author is banned from this relay"
accept = false
return
}
}
@@ -73,5 +136,6 @@ func (s *Server) AcceptEvent(
return
}
}
accept = false
return
}

346
pkg/app/relay/metrics.go Normal file
View File

@@ -0,0 +1,346 @@
package relay
import (
"fmt"
"net/http"
"sync"
"time"
"orly.dev/pkg/database"
"orly.dev/pkg/utils/log"
)
// MetricsCollector tracks subscription system metrics
type MetricsCollector struct {
mu sync.RWMutex
db *database.D
// Subscription metrics
totalTrialSubscriptions int64
totalPaidSubscriptions int64
// Payment metrics
paymentSuccessCount int64
paymentFailureCount int64
// Conversion metrics
trialToPaidConversions int64
totalTrialsStarted int64
// Duration metrics
subscriptionDurations []time.Duration
maxDurationSamples int
// Health status
lastHealthCheck time.Time
isHealthy bool
healthCheckErrors []string
}
// NewMetricsCollector creates a new metrics collector
func NewMetricsCollector(db *database.D) *MetricsCollector {
return &MetricsCollector{
db: db,
maxDurationSamples: 1000,
isHealthy: true,
lastHealthCheck: time.Now(),
}
}
// RecordTrialStarted increments trial subscription counter
func (mc *MetricsCollector) RecordTrialStarted() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.totalTrialsStarted++
mc.totalTrialSubscriptions++
}
// RecordPaidSubscription increments paid subscription counter
func (mc *MetricsCollector) RecordPaidSubscription() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.totalPaidSubscriptions++
}
// RecordTrialExpired decrements trial subscription counter
func (mc *MetricsCollector) RecordTrialExpired() {
mc.mu.Lock()
defer mc.mu.Unlock()
if mc.totalTrialSubscriptions > 0 {
mc.totalTrialSubscriptions--
}
}
// RecordPaidExpired decrements paid subscription counter
func (mc *MetricsCollector) RecordPaidExpired() {
mc.mu.Lock()
defer mc.mu.Unlock()
if mc.totalPaidSubscriptions > 0 {
mc.totalPaidSubscriptions--
}
}
// RecordPaymentSuccess increments successful payment counter
func (mc *MetricsCollector) RecordPaymentSuccess() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.paymentSuccessCount++
}
// RecordPaymentFailure increments failed payment counter
func (mc *MetricsCollector) RecordPaymentFailure() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.paymentFailureCount++
}
// RecordTrialToPaidConversion records when a trial user becomes paid
func (mc *MetricsCollector) RecordTrialToPaidConversion() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.trialToPaidConversions++
// Move from trial to paid
if mc.totalTrialSubscriptions > 0 {
mc.totalTrialSubscriptions--
}
mc.totalPaidSubscriptions++
}
// RecordSubscriptionDuration adds a subscription duration sample
func (mc *MetricsCollector) RecordSubscriptionDuration(duration time.Duration) {
mc.mu.Lock()
defer mc.mu.Unlock()
// Keep only the most recent samples to prevent memory growth
mc.subscriptionDurations = append(mc.subscriptionDurations, duration)
if len(mc.subscriptionDurations) > mc.maxDurationSamples {
mc.subscriptionDurations = mc.subscriptionDurations[1:]
}
}
// GetMetrics returns current metrics snapshot
func (mc *MetricsCollector) GetMetrics() map[string]interface{} {
mc.mu.RLock()
defer mc.mu.RUnlock()
totalPayments := mc.paymentSuccessCount + mc.paymentFailureCount
var paymentSuccessRate float64
if totalPayments > 0 {
paymentSuccessRate = float64(mc.paymentSuccessCount) / float64(totalPayments)
}
var conversionRate float64
if mc.totalTrialsStarted > 0 {
conversionRate = float64(mc.trialToPaidConversions) / float64(mc.totalTrialsStarted)
}
var avgDuration time.Duration
if len(mc.subscriptionDurations) > 0 {
var total time.Duration
for _, d := range mc.subscriptionDurations {
total += d
}
avgDuration = total / time.Duration(len(mc.subscriptionDurations))
}
return map[string]interface{}{
"total_trial_subscriptions": mc.totalTrialSubscriptions,
"total_paid_subscriptions": mc.totalPaidSubscriptions,
"total_active_subscriptions": mc.totalTrialSubscriptions + mc.totalPaidSubscriptions,
"payment_success_count": mc.paymentSuccessCount,
"payment_failure_count": mc.paymentFailureCount,
"payment_success_rate": paymentSuccessRate,
"trial_to_paid_conversions": mc.trialToPaidConversions,
"total_trials_started": mc.totalTrialsStarted,
"conversion_rate": conversionRate,
"average_subscription_duration_seconds": avgDuration.Seconds(),
"last_health_check": mc.lastHealthCheck.Unix(),
"is_healthy": mc.isHealthy,
}
}
// GetPrometheusMetrics returns metrics in Prometheus format
func (mc *MetricsCollector) GetPrometheusMetrics() string {
metrics := mc.GetMetrics()
promMetrics := `# HELP orly_trial_subscriptions_total Total number of active trial subscriptions
# TYPE orly_trial_subscriptions_total gauge
orly_trial_subscriptions_total %d
# HELP orly_paid_subscriptions_total Total number of active paid subscriptions
# TYPE orly_paid_subscriptions_total gauge
orly_paid_subscriptions_total %d
# HELP orly_active_subscriptions_total Total number of active subscriptions (trial + paid)
# TYPE orly_active_subscriptions_total gauge
orly_active_subscriptions_total %d
# HELP orly_payment_success_total Total number of successful payments
# TYPE orly_payment_success_total counter
orly_payment_success_total %d
# HELP orly_payment_failure_total Total number of failed payments
# TYPE orly_payment_failure_total counter
orly_payment_failure_total %d
# HELP orly_payment_success_rate Payment success rate (0.0 to 1.0)
# TYPE orly_payment_success_rate gauge
orly_payment_success_rate %.6f
# HELP orly_trial_to_paid_conversions_total Total number of trial to paid conversions
# TYPE orly_trial_to_paid_conversions_total counter
orly_trial_to_paid_conversions_total %d
# HELP orly_trials_started_total Total number of trials started
# TYPE orly_trials_started_total counter
orly_trials_started_total %d
# HELP orly_conversion_rate Trial to paid conversion rate (0.0 to 1.0)
# TYPE orly_conversion_rate gauge
orly_conversion_rate %.6f
# HELP orly_avg_subscription_duration_seconds Average subscription duration in seconds
# TYPE orly_avg_subscription_duration_seconds gauge
orly_avg_subscription_duration_seconds %.2f
# HELP orly_last_health_check_timestamp Last health check timestamp
# TYPE orly_last_health_check_timestamp gauge
orly_last_health_check_timestamp %d
# HELP orly_health_status Health status (1 = healthy, 0 = unhealthy)
# TYPE orly_health_status gauge
orly_health_status %d
`
healthStatus := 0
if metrics["is_healthy"].(bool) {
healthStatus = 1
}
return fmt.Sprintf(promMetrics,
metrics["total_trial_subscriptions"],
metrics["total_paid_subscriptions"],
metrics["total_active_subscriptions"],
metrics["payment_success_count"],
metrics["payment_failure_count"],
metrics["payment_success_rate"],
metrics["trial_to_paid_conversions"],
metrics["total_trials_started"],
metrics["conversion_rate"],
metrics["average_subscription_duration_seconds"],
metrics["last_health_check"],
healthStatus,
)
}
// PerformHealthCheck checks system health
func (mc *MetricsCollector) PerformHealthCheck() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.lastHealthCheck = time.Now()
mc.healthCheckErrors = []string{}
mc.isHealthy = true
if mc.db != nil {
testPubkey := make([]byte, 32)
_, err := mc.db.GetSubscription(testPubkey)
if err != nil {
mc.isHealthy = false
mc.healthCheckErrors = append(mc.healthCheckErrors, fmt.Sprintf("database error: %v", err))
}
} else {
mc.isHealthy = false
mc.healthCheckErrors = append(mc.healthCheckErrors, "database not initialized")
}
if mc.isHealthy {
log.D.Ln("health check passed")
} else {
log.W.F("health check failed: %v", mc.healthCheckErrors)
}
}
// GetHealthStatus returns current health status
func (mc *MetricsCollector) GetHealthStatus() map[string]interface{} {
mc.mu.RLock()
defer mc.mu.RUnlock()
return map[string]interface{}{
"healthy": mc.isHealthy,
"last_check": mc.lastHealthCheck.Format(time.RFC3339),
"errors": mc.healthCheckErrors,
"uptime_seconds": time.Since(mc.lastHealthCheck).Seconds(),
}
}
// StartPeriodicHealthChecks runs health checks periodically
func (mc *MetricsCollector) StartPeriodicHealthChecks(interval time.Duration, stopCh <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Perform initial health check
mc.PerformHealthCheck()
for {
select {
case <-ticker.C:
mc.PerformHealthCheck()
case <-stopCh:
log.D.Ln("stopping periodic health checks")
return
}
}
}
// MetricsHandler handles HTTP requests for metrics endpoint
func (mc *MetricsCollector) MetricsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(http.StatusOK)
metrics := mc.GetPrometheusMetrics()
w.Write([]byte(metrics))
}
// HealthHandler handles HTTP requests for health check endpoint
func (mc *MetricsCollector) HealthHandler(w http.ResponseWriter, r *http.Request) {
// Perform real-time health check
mc.PerformHealthCheck()
status := mc.GetHealthStatus()
w.Header().Set("Content-Type", "application/json")
if status["healthy"].(bool) {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusServiceUnavailable)
}
// Simple JSON formatting without external dependencies
healthy := "true"
if !status["healthy"].(bool) {
healthy = "false"
}
errorsJson := "[]"
if errors, ok := status["errors"].([]string); ok && len(errors) > 0 {
errorsJson = `["`
for i, err := range errors {
if i > 0 {
errorsJson += `", "`
}
errorsJson += err
}
errorsJson += `"]`
}
response := fmt.Sprintf(`{
"healthy": %s,
"last_check": "%s",
"errors": %s,
"uptime_seconds": %.2f
}`, healthy, status["last_check"], errorsJson, status["uptime_seconds"])
w.Write([]byte(response))
}

View File

@@ -0,0 +1,175 @@
package relay
import (
"fmt"
"strings"
"sync"
"orly.dev/pkg/app/config"
"orly.dev/pkg/database"
"orly.dev/pkg/encoders/bech32encoding"
"orly.dev/pkg/protocol/nwc"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/log"
)
// PaymentProcessor handles NWC payment notifications and updates subscriptions
type PaymentProcessor struct {
nwcClient *nwc.Client
db *database.D
config *config.C
ctx context.T
cancel context.F
wg sync.WaitGroup
}
// NewPaymentProcessor creates a new payment processor
func NewPaymentProcessor(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)
}
ctx, cancel := context.Cancel(context.Bg())
pp = &PaymentProcessor{
nwcClient: nwcClient,
db: db,
config: cfg,
ctx: ctx,
cancel: cancel,
}
return pp, nil
}
// Start begins listening for payment notifications
func (pp *PaymentProcessor) Start() error {
pp.wg.Add(1)
go func() {
defer pp.wg.Done()
if err := pp.listenForPayments(); err != nil {
log.E.F("payment processor error: %v", err)
}
}()
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)
}
// 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")
}
description, _ := notification["description"].(string)
userNpub := pp.extractNpubFromDescription(description)
if userNpub == "" {
if metadata, ok := notification["metadata"].(map[string]any); ok {
if npubField, ok := metadata["npub"].(string); ok {
userNpub = npubField
}
}
}
if userNpub == "" {
return fmt.Errorf("no npub in payment description")
}
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.I.F("payment processed: %s %d sats -> %d days", userNpub, satsReceived, days)
return nil
}
// extractNpubFromDescription extracts an npub from the payment description
func (pp *PaymentProcessor) extractNpubFromDescription(description string) string {
// 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
}
}
// Also check if the entire description is just an npub
description = strings.TrimSpace(description)
if strings.HasPrefix(description, "npub1") && len(description) == 63 {
return description
}
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
}

View File

@@ -8,8 +8,10 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"orly.dev/pkg/database"
"orly.dev/pkg/protocol/openapi"
"orly.dev/pkg/protocol/socketapi"
@@ -43,7 +45,11 @@ type Server struct {
*config.C
*Lists
*Peers
Mux *servemux.S
Mux *servemux.S
MetricsCollector *MetricsCollector
subscriptionCache map[string]time.Time // pubkey hex -> cache expiry time
subscriptionMutex sync.RWMutex
paymentProcessor *PaymentProcessor
}
// ServerParams represents the configuration parameters for initializing a
@@ -99,14 +105,15 @@ func NewServer(
}
}
s = &Server{
Ctx: sp.Ctx,
Cancel: sp.Cancel,
relay: sp.Rl,
mux: serveMux,
options: op,
C: sp.C,
Lists: new(Lists),
Peers: new(Peers),
Ctx: sp.Ctx,
Cancel: sp.Cancel,
relay: sp.Rl,
mux: serveMux,
options: op,
C: sp.C,
Lists: new(Lists),
Peers: new(Peers),
subscriptionCache: make(map[string]time.Time),
}
// Parse blacklist pubkeys
for _, v := range s.C.Blacklist {
@@ -225,6 +232,24 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (s *Server) Start(
host string, port int, started ...chan bool,
) (err error) {
// Initialize payment processor if subscription is enabled
if s.C.SubscriptionEnabled && s.C.NWCUri != "" {
if db, ok := s.relay.Storage().(*database.D); ok {
if s.paymentProcessor, err = NewPaymentProcessor(s.C, db); err != nil {
log.E.F("failed to create payment processor: %v", err)
// Continue without payment processor
} else {
if err := s.paymentProcessor.Start(); err != nil {
log.E.F("failed to start payment processor: %v", err)
} else {
log.I.F("payment processor started successfully")
}
}
} else {
log.E.F("subscription enabled but storage is not database.D")
}
}
log.I.F("running spider every %v", s.C.SpiderTime)
if len(s.C.Owners) > 0 {
// start up spider
@@ -289,6 +314,13 @@ func (s *Server) Start(
// context.
func (s *Server) Shutdown() {
log.I.Ln("shutting down relay")
// Stop payment processor if running
if s.paymentProcessor != nil {
log.I.Ln("stopping payment processor")
s.paymentProcessor.Stop()
}
s.Cancel()
log.W.Ln("closing event store")
chk.E(s.relay.Storage().Close())

View File

@@ -0,0 +1,113 @@
package relay
import (
"testing"
"github.com/dgraph-io/badger/v4"
"orly.dev/pkg/app/config"
"orly.dev/pkg/database"
)
func TestSubscriptionTrialActivation(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &database.D{DB: db}
pubkey := make([]byte, 32)
// Test direct database calls
active, err := d.IsSubscriptionActive(pubkey)
if err != nil {
t.Fatal(err)
}
if !active {
t.Fatal("trial should be activated on first check")
}
// Verify subscription was created
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub == nil {
t.Fatal("subscription should exist")
}
if sub.TrialEnd.IsZero() {
t.Error("trial end should be set")
}
}
func TestSubscriptionExtension(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &database.D{DB: db}
pubkey := make([]byte, 32)
// Create subscription and extend it
err = d.ExtendSubscription(pubkey, 30)
if err != nil {
t.Fatal(err)
}
// Check it's active
active, err := d.IsSubscriptionActive(pubkey)
if err != nil {
t.Fatal(err)
}
if !active {
t.Error("subscription should be active after extension")
}
// Verify paid until is set
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub.PaidUntil.IsZero() {
t.Error("paid until should be set")
}
}
func TestConfigValidation(t *testing.T) {
// Test default values
cfg := &config.C{}
if cfg.SubscriptionEnabled {
t.Error("subscription should be disabled by default")
}
if cfg.MonthlyPriceSats != 0 {
t.Error("monthly price should be 0 by default before config load")
}
}
func TestPaymentProcessingSimple(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &database.D{DB: db}
// Test payment recording
pubkey := make([]byte, 32)
err = d.RecordPayment(pubkey, 6000, "test_invoice", "test_preimage")
if err != nil {
t.Fatal(err)
}
// Test payment history retrieval
payments, err := d.GetPaymentHistory(pubkey)
if err != nil {
t.Fatal(err)
}
if len(payments) != 1 {
t.Errorf("expected 1 payment, got %d", len(payments))
}
}

View File

@@ -0,0 +1,169 @@
package database
import (
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"github.com/vmihailenco/msgpack/v5"
)
type Subscription struct {
TrialEnd time.Time `msgpack:"trial_end"`
PaidUntil time.Time `msgpack:"paid_until"`
}
func (d *D) GetSubscription(pubkey []byte) (*Subscription, error) {
key := fmt.Sprintf("sub:%s", hex.EncodeToString(pubkey))
var sub *Subscription
err := d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(key))
if err == badger.ErrKeyNotFound {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
sub = &Subscription{}
return msgpack.Unmarshal(val, sub)
})
})
return sub, err
}
func (d *D) IsSubscriptionActive(pubkey []byte) (bool, error) {
key := fmt.Sprintf("sub:%s", hex.EncodeToString(pubkey))
now := time.Now()
active := false
err := d.DB.Update(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(key))
if err == badger.ErrKeyNotFound {
sub := &Subscription{TrialEnd: now.AddDate(0, 0, 30)}
data, err := msgpack.Marshal(sub)
if err != nil {
return err
}
active = true
return txn.Set([]byte(key), data)
}
if err != nil {
return err
}
var sub Subscription
err = item.Value(func(val []byte) error {
return msgpack.Unmarshal(val, &sub)
})
if err != nil {
return err
}
active = now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil))
return nil
})
return active, err
}
func (d *D) ExtendSubscription(pubkey []byte, days int) error {
if days <= 0 {
return fmt.Errorf("invalid days: %d", days)
}
key := fmt.Sprintf("sub:%s", hex.EncodeToString(pubkey))
now := time.Now()
return d.DB.Update(func(txn *badger.Txn) error {
var sub Subscription
item, err := txn.Get([]byte(key))
if err == badger.ErrKeyNotFound {
sub.PaidUntil = now.AddDate(0, 0, days)
} else if err != nil {
return err
} else {
err = item.Value(func(val []byte) error {
return msgpack.Unmarshal(val, &sub)
})
if err != nil {
return err
}
extendFrom := now
if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) {
extendFrom = sub.PaidUntil
}
sub.PaidUntil = extendFrom.AddDate(0, 0, days)
}
data, err := msgpack.Marshal(&sub)
if err != nil {
return err
}
return txn.Set([]byte(key), data)
})
}
type Payment struct {
Amount int64 `msgpack:"amount"`
Timestamp time.Time `msgpack:"timestamp"`
Invoice string `msgpack:"invoice"`
Preimage string `msgpack:"preimage"`
}
func (d *D) RecordPayment(pubkey []byte, amount int64, invoice, preimage string) error {
now := time.Now()
key := fmt.Sprintf("payment:%d:%s", now.Unix(), hex.EncodeToString(pubkey))
payment := Payment{
Amount: amount,
Timestamp: now,
Invoice: invoice,
Preimage: preimage,
}
data, err := msgpack.Marshal(&payment)
if err != nil {
return err
}
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Set([]byte(key), data)
})
}
func (d *D) GetPaymentHistory(pubkey []byte) ([]Payment, error) {
prefix := fmt.Sprintf("payment:")
suffix := fmt.Sprintf(":%s", hex.EncodeToString(pubkey))
var payments []Payment
err := d.DB.View(func(txn *badger.Txn) error {
it := txn.NewIterator(badger.DefaultIteratorOptions)
defer it.Close()
for it.Seek([]byte(prefix)); it.ValidForPrefix([]byte(prefix)); it.Next() {
key := string(it.Item().Key())
if !strings.HasSuffix(key, suffix) {
continue
}
err := it.Item().Value(func(val []byte) error {
var payment Payment
err := msgpack.Unmarshal(val, &payment)
if err != nil {
return err
}
payments = append(payments, payment)
return nil
})
if err != nil {
return err
}
}
return nil
})
return payments, err
}

View File

@@ -0,0 +1,121 @@
package database
import (
"testing"
"github.com/dgraph-io/badger/v4"
)
func TestSubscriptionLifecycle(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &D{DB: db}
pubkey := []byte("test_pubkey_32_bytes_long_enough")
// First check should create trial
active, err := d.IsSubscriptionActive(pubkey)
if err != nil {
t.Fatal(err)
}
if !active {
t.Error("expected trial to be active")
}
// Verify trial was created
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub == nil {
t.Fatal("expected subscription to exist")
}
if sub.TrialEnd.IsZero() {
t.Error("expected trial end to be set")
}
if !sub.PaidUntil.IsZero() {
t.Error("expected paid until to be zero")
}
// Extend subscription
err = d.ExtendSubscription(pubkey, 30)
if err != nil {
t.Fatal(err)
}
// Check subscription is still active
active, err = d.IsSubscriptionActive(pubkey)
if err != nil {
t.Fatal(err)
}
if !active {
t.Error("expected subscription to be active after extension")
}
// Verify paid until was set
sub, err = d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub.PaidUntil.IsZero() {
t.Error("expected paid until to be set after extension")
}
}
func TestExtendSubscriptionEdgeCases(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &D{DB: db}
pubkey := []byte("test_pubkey_32_bytes_long_enough")
// Test extending non-existent subscription
err = d.ExtendSubscription(pubkey, 30)
if err != nil {
t.Fatal(err)
}
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub.PaidUntil.IsZero() {
t.Error("expected paid until to be set")
}
// Test invalid days
err = d.ExtendSubscription(pubkey, 0)
if err == nil {
t.Error("expected error for 0 days")
}
err = d.ExtendSubscription(pubkey, -1)
if err == nil {
t.Error("expected error for negative days")
}
}
func TestGetNonExistentSubscription(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &D{DB: db}
pubkey := []byte("non_existent_pubkey_32_bytes_long")
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub != nil {
t.Error("expected nil for non-existent subscription")
}
}

View File

@@ -33,9 +33,24 @@ err = client.Request(ctx, "make_invoice", params, &invoice)
- `lookup_invoice` - Check invoice status
- `pay_invoice` - Pay invoice
## Payment Notifications
```go
// Subscribe to payment notifications
err = client.SubscribeNotifications(ctx, func(notificationType string, notification map[string]any) error {
if notificationType == "payment_received" {
amount := notification["amount"].(float64)
description := notification["description"].(string)
// Process payment...
}
return nil
})
```
## Features
- NIP-44 encryption
- Event signing
- Relay communication
- Payment notifications
- Error handling

View File

@@ -19,6 +19,7 @@ import (
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/log"
"orly.dev/pkg/utils/values"
)
@@ -138,4 +139,110 @@ func (cl *Client) Request(c context.T, method string, params, result any) (err e
}
return
}
}
// NotificationHandler is a callback for handling NWC notifications
type NotificationHandler func(notificationType string, notification map[string]any) error
// SubscribeNotifications subscribes to NWC notification events (kinds 23197/23196)
// and handles them with the provided callback. It maintains a persistent connection
// with auto-reconnection on disconnect.
func (cl *Client) SubscribeNotifications(c context.T, handler NotificationHandler) (err error) {
delay := time.Second
for {
if err = cl.subscribeNotificationsOnce(c, handler); err != nil {
if err == context.Canceled {
return err
}
select {
case <-time.After(delay):
if delay < 30*time.Second {
delay *= 2
}
case <-c.Done():
return context.Canceled
}
continue
}
delay = time.Second
}
}
// subscribeNotificationsOnce performs a single subscription attempt
func (cl *Client) subscribeNotificationsOnce(c context.T, handler NotificationHandler) (err error) {
// Connect to relay
var rc *ws.Client
if rc, err = ws.RelayConnect(c, cl.relay); chk.E(err) {
return fmt.Errorf("relay connection failed: %w", err)
}
defer rc.Close()
// Subscribe to notification events filtered by "p" tag
// Support both NIP-44 (kind 23197) and legacy NIP-04 (kind 23196)
var sub *ws.Subscription
if sub, err = rc.Subscribe(
c, filters.New(
&filter.F{
Kinds: kinds.New(kind.New(23197), kind.New(23196)),
Tags: tags.New(
tag.New("p", hex.Enc(cl.clientSecretKey.Pub())),
),
Since: &timestamp.T{V: time.Now().Unix()},
},
),
); chk.E(err) {
return fmt.Errorf("subscription failed: %w", err)
}
defer sub.Unsub()
log.I.F("subscribed to NWC notifications from wallet %s", hex.Enc(cl.walletPublicKey))
// Process notification events
for {
select {
case <-c.Done():
return context.Canceled
case ev := <-sub.Events:
if ev == nil {
// Channel closed, subscription ended
return fmt.Errorf("subscription closed")
}
// Process the notification event
if err := cl.processNotificationEvent(ev, handler); err != nil {
log.E.F("error processing notification: %v", err)
// Continue processing other notifications even if one fails
}
}
}
}
// processNotificationEvent decrypts and processes a single notification event
func (cl *Client) processNotificationEvent(ev *event.E, handler NotificationHandler) (err error) {
// Decrypt the notification content
var decrypted []byte
if decrypted, err = encryption.Decrypt(ev.Content, cl.conversationKey); err != nil {
return fmt.Errorf("failed to decrypt notification: %w", err)
}
// Parse the notification JSON
var notification map[string]any
if err = json.Unmarshal(decrypted, &notification); err != nil {
return fmt.Errorf("failed to parse notification JSON: %w", err)
}
// Extract notification type
notificationType, ok := notification["notification_type"].(string)
if !ok {
return fmt.Errorf("missing or invalid notification_type")
}
// Extract notification data
notificationData, ok := notification["notification"].(map[string]any)
if !ok {
return fmt.Errorf("missing or invalid notification data")
}
// Route to type-specific handler
return handler(notificationType, notificationData)
}

View File

@@ -2,7 +2,6 @@ package nwc_test
import (
"encoding/json"
"testing"
"orly.dev/pkg/crypto/encryption"
"orly.dev/pkg/crypto/p256k"
"orly.dev/pkg/encoders/event"
@@ -12,84 +11,85 @@ import (
"orly.dev/pkg/encoders/tags"
"orly.dev/pkg/encoders/timestamp"
"orly.dev/pkg/protocol/nwc"
"testing"
)
func TestNWCConversationKey(t *testing.T) {
secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
walletPubkey := "816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b"
uri := "nostr+walletconnect://" + walletPubkey + "?relay=wss://relay.getalby.com/v1&secret=" + secret
parts, err := nwc.ParseConnectionURI(uri)
if err != nil {
t.Fatal(err)
}
// Validate conversation key was generated
convKey := parts.GetConversationKey()
if len(convKey) == 0 {
t.Fatal("conversation key should not be empty")
}
// Validate wallet public key
walletKey := parts.GetWalletPublicKey()
if len(walletKey) == 0 {
t.Fatal("wallet public key should not be empty")
}
expected, err := hex.Dec(walletPubkey)
if err != nil {
t.Fatal(err)
}
if len(walletKey) != len(expected) {
t.Fatal("wallet public key length mismatch")
}
for i := range walletKey {
if walletKey[i] != expected[i] {
t.Fatal("wallet public key mismatch")
}
}
t.Log("✅ Conversation key and wallet pubkey validation passed")
// Test passed
}
func TestNWCEncryptionDecryption(t *testing.T) {
secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
walletPubkey := "816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b"
uri := "nostr+walletconnect://" + walletPubkey + "?relay=wss://relay.getalby.com/v1&secret=" + secret
parts, err := nwc.ParseConnectionURI(uri)
if err != nil {
t.Fatal(err)
}
convKey := parts.GetConversationKey()
testMessage := `{"method":"get_info","params":null}`
// Test encryption
encrypted, err := encryption.Encrypt([]byte(testMessage), convKey)
if err != nil {
t.Fatalf("encryption failed: %v", err)
}
if len(encrypted) == 0 {
t.Fatal("encrypted message should not be empty")
}
// Test decryption
decrypted, err := encryption.Decrypt(encrypted, convKey)
if err != nil {
t.Fatalf("decryption failed: %v", err)
}
if string(decrypted) != testMessage {
t.Fatalf("decrypted message mismatch: got %s, want %s", string(decrypted), testMessage)
}
t.Log("✅ NWC encryption/decryption cycle validated")
// Test passed
}
func TestNWCEventCreation(t *testing.T) {
@@ -97,33 +97,33 @@ func TestNWCEventCreation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
clientKey := &p256k.Signer{}
if err := clientKey.InitSec(secretBytes); err != nil {
t.Fatal(err)
}
walletPubkey, err := hex.Dec("816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b")
if err != nil {
t.Fatal(err)
}
convKey, err := encryption.GenerateConversationKeyWithSigner(clientKey, walletPubkey)
if err != nil {
t.Fatal(err)
}
request := map[string]any{"method": "get_info"}
reqBytes, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
encrypted, err := encryption.Encrypt(reqBytes, convKey)
if err != nil {
t.Fatal(err)
}
// Create NWC event
ev := &event.E{
Content: encrypted,
@@ -134,24 +134,24 @@ func TestNWCEventCreation(t *testing.T) {
tag.New("p", hex.Enc(walletPubkey)),
),
}
if err := ev.Sign(clientKey); err != nil {
t.Fatalf("event signing failed: %v", err)
}
// Validate event structure
if len(ev.Content) == 0 {
t.Fatal("event content should not be empty")
}
if len(ev.ID) == 0 {
t.Fatal("event should have ID after signing")
}
if len(ev.Sig) == 0 {
t.Fatal("event should have signature after signing")
}
// Validate tags
hasEncryption := false
hasP := false
@@ -166,14 +166,14 @@ func TestNWCEventCreation(t *testing.T) {
}
}
}
if !hasEncryption {
t.Fatal("event missing encryption tag")
}
if !hasP {
t.Fatal("event missing p tag")
}
t.Log("✅ NWC event creation and signing validated")
}
// Test passed
}

View File

@@ -0,0 +1,470 @@
package nwc
import (
"crypto/rand"
"encoding/json"
"fmt"
"sync"
"time"
"orly.dev/pkg/crypto/encryption"
"orly.dev/pkg/crypto/p256k"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/filter"
"orly.dev/pkg/encoders/filters"
"orly.dev/pkg/encoders/hex"
"orly.dev/pkg/encoders/kind"
"orly.dev/pkg/encoders/kinds"
"orly.dev/pkg/encoders/tag"
"orly.dev/pkg/encoders/tags"
"orly.dev/pkg/encoders/timestamp"
"orly.dev/pkg/interfaces/signer"
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
)
// MockWalletService implements a mock NIP-47 wallet service for testing
type MockWalletService struct {
relay string
walletSecretKey signer.I
walletPublicKey []byte
client *ws.Client
ctx context.T
cancel context.F
balance int64 // in satoshis
balanceMutex sync.RWMutex
connectedClients map[string][]byte // pubkey -> conversation key
clientsMutex sync.RWMutex
}
// NewMockWalletService creates a new mock wallet service
func NewMockWalletService(relay string, initialBalance int64) (service *MockWalletService, err error) {
// Generate wallet keypair
walletKey := &p256k.Signer{}
if err = walletKey.Generate(); chk.E(err) {
return
}
ctx, cancel := context.Cancel(context.Bg())
service = &MockWalletService{
relay: relay,
walletSecretKey: walletKey,
walletPublicKey: walletKey.Pub(),
ctx: ctx,
cancel: cancel,
balance: initialBalance,
connectedClients: make(map[string][]byte),
}
return
}
// Start begins the mock wallet service
func (m *MockWalletService) Start() (err error) {
// Connect to relay
if m.client, err = ws.RelayConnect(m.ctx, m.relay); chk.E(err) {
return fmt.Errorf("failed to connect to relay: %w", err)
}
// Publish wallet info event
if err = m.publishWalletInfo(); chk.E(err) {
return fmt.Errorf("failed to publish wallet info: %w", err)
}
// Subscribe to request events
if err = m.subscribeToRequests(); chk.E(err) {
return fmt.Errorf("failed to subscribe to requests: %w", err)
}
return
}
// Stop stops the mock wallet service
func (m *MockWalletService) Stop() {
if m.cancel != nil {
m.cancel()
}
if m.client != nil {
m.client.Close()
}
}
// GetWalletPublicKey returns the wallet's public key
func (m *MockWalletService) GetWalletPublicKey() []byte {
return m.walletPublicKey
}
// publishWalletInfo publishes the NIP-47 info event (kind 13194)
func (m *MockWalletService) publishWalletInfo() (err error) {
capabilities := []string{
"get_info",
"get_balance",
"make_invoice",
"pay_invoice",
}
info := map[string]any{
"capabilities": capabilities,
"notifications": []string{"payment_received", "payment_sent"},
}
var content []byte
if content, err = json.Marshal(info); chk.E(err) {
return
}
ev := &event.E{
Content: content,
CreatedAt: timestamp.Now(),
Kind: kind.New(13194),
Tags: tags.New(),
}
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
return
}
return m.client.Publish(m.ctx, ev)
}
// subscribeToRequests subscribes to NWC request events (kind 23194)
func (m *MockWalletService) subscribeToRequests() (err error) {
var sub *ws.Subscription
if sub, err = m.client.Subscribe(
m.ctx, filters.New(
&filter.F{
Kinds: kinds.New(kind.New(23194)),
Tags: tags.New(
tag.New("p", hex.Enc(m.walletPublicKey)),
),
Since: &timestamp.T{V: time.Now().Unix()},
},
),
); chk.E(err) {
return
}
// Handle incoming request events
go m.handleRequestEvents(sub)
return
}
// handleRequestEvents processes incoming NWC request events
func (m *MockWalletService) handleRequestEvents(sub *ws.Subscription) {
for {
select {
case <-m.ctx.Done():
return
case ev := <-sub.Events:
if ev == nil {
continue
}
if err := m.processRequestEvent(ev); chk.E(err) {
fmt.Printf("Error processing request event: %v\n", err)
}
}
}
}
// processRequestEvent processes a single NWC request event
func (m *MockWalletService) processRequestEvent(ev *event.E) (err error) {
// Get client pubkey from event
clientPubkey := ev.Pubkey
clientPubkeyHex := hex.Enc(clientPubkey)
// Generate or get conversation key
var conversationKey []byte
m.clientsMutex.Lock()
if existingKey, exists := m.connectedClients[clientPubkeyHex]; exists {
conversationKey = existingKey
} else {
if conversationKey, err = encryption.GenerateConversationKeyWithSigner(
m.walletSecretKey, clientPubkey,
); chk.E(err) {
m.clientsMutex.Unlock()
return
}
m.connectedClients[clientPubkeyHex] = conversationKey
}
m.clientsMutex.Unlock()
// Decrypt request content
var decrypted []byte
if decrypted, err = encryption.Decrypt(ev.Content, conversationKey); chk.E(err) {
return
}
var request map[string]any
if err = json.Unmarshal(decrypted, &request); chk.E(err) {
return
}
method, ok := request["method"].(string)
if !ok {
return fmt.Errorf("invalid method")
}
params := request["params"]
// Process the method
var result any
if result, err = m.processMethod(method, params); chk.E(err) {
// Send error response
return m.sendErrorResponse(clientPubkey, conversationKey, "INTERNAL", err.Error())
}
// Send success response
return m.sendSuccessResponse(clientPubkey, conversationKey, result)
}
// processMethod handles the actual NWC method execution
func (m *MockWalletService) processMethod(method string, params any) (result any, err error) {
switch method {
case "get_info":
return m.getInfo()
case "get_balance":
return m.getBalance()
case "make_invoice":
return m.makeInvoice(params)
case "pay_invoice":
return m.payInvoice(params)
default:
err = fmt.Errorf("unsupported method: %s", method)
return
}
}
// getInfo returns wallet information
func (m *MockWalletService) getInfo() (result map[string]any, err error) {
result = map[string]any{
"alias": "Mock Wallet",
"color": "#3399FF",
"pubkey": hex.Enc(m.walletPublicKey),
"network": "mainnet",
"block_height": 850000,
"block_hash": "0000000000000000000123456789abcdef",
"methods": []string{"get_info", "get_balance", "make_invoice", "pay_invoice"},
}
return
}
// getBalance returns the current wallet balance
func (m *MockWalletService) getBalance() (result map[string]any, err error) {
m.balanceMutex.RLock()
balance := m.balance
m.balanceMutex.RUnlock()
result = map[string]any{
"balance": balance * 1000, // convert to msats
}
return
}
// makeInvoice creates a Lightning invoice
func (m *MockWalletService) makeInvoice(params any) (result map[string]any, err error) {
paramsMap, ok := params.(map[string]any)
if !ok {
err = fmt.Errorf("invalid params")
return
}
amount, ok := paramsMap["amount"].(float64)
if !ok {
err = fmt.Errorf("missing or invalid amount")
return
}
description := ""
if desc, ok := paramsMap["description"].(string); ok {
description = desc
}
paymentHash := make([]byte, 32)
rand.Read(paymentHash)
// Generate a fake bolt11 invoice
bolt11 := fmt.Sprintf("lnbc%dm1pwxxxxxxx", int64(amount/1000))
result = map[string]any{
"type": "incoming",
"invoice": bolt11,
"description": description,
"payment_hash": hex.Enc(paymentHash),
"amount": int64(amount),
"created_at": time.Now().Unix(),
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
}
return
}
// payInvoice pays a Lightning invoice
func (m *MockWalletService) payInvoice(params any) (result map[string]any, err error) {
paramsMap, ok := params.(map[string]any)
if !ok {
err = fmt.Errorf("invalid params")
return
}
invoice, ok := paramsMap["invoice"].(string)
if !ok {
err = fmt.Errorf("missing or invalid invoice")
return
}
// Mock payment amount (would parse from invoice in real implementation)
amount := int64(1000) // 1000 msats
// Check balance
m.balanceMutex.Lock()
if m.balance*1000 < amount {
m.balanceMutex.Unlock()
err = fmt.Errorf("insufficient balance")
return
}
m.balance -= amount / 1000
m.balanceMutex.Unlock()
preimage := make([]byte, 32)
rand.Read(preimage)
result = map[string]any{
"type": "outgoing",
"invoice": invoice,
"amount": amount,
"preimage": hex.Enc(preimage),
"created_at": time.Now().Unix(),
}
// Emit payment_sent notification
go m.emitPaymentNotification("payment_sent", result)
return
}
// sendSuccessResponse sends a successful NWC response
func (m *MockWalletService) sendSuccessResponse(clientPubkey []byte, conversationKey []byte, result any) (err error) {
response := map[string]any{
"result": result,
}
var responseBytes []byte
if responseBytes, err = json.Marshal(response); chk.E(err) {
return
}
return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
}
// sendErrorResponse sends an error NWC response
func (m *MockWalletService) sendErrorResponse(clientPubkey []byte, conversationKey []byte, code, message string) (err error) {
response := map[string]any{
"error": map[string]any{
"code": code,
"message": message,
},
}
var responseBytes []byte
if responseBytes, err = json.Marshal(response); chk.E(err) {
return
}
return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
}
// sendEncryptedResponse sends an encrypted response event (kind 23195)
func (m *MockWalletService) sendEncryptedResponse(clientPubkey []byte, conversationKey []byte, content []byte) (err error) {
var encrypted []byte
if encrypted, err = encryption.Encrypt(content, conversationKey); chk.E(err) {
return
}
ev := &event.E{
Content: encrypted,
CreatedAt: timestamp.Now(),
Kind: kind.New(23195),
Tags: tags.New(
tag.New("encryption", "nip44_v2"),
tag.New("p", hex.Enc(clientPubkey)),
),
}
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
return
}
return m.client.Publish(m.ctx, ev)
}
// emitPaymentNotification emits a payment notification (kind 23197)
func (m *MockWalletService) emitPaymentNotification(notificationType string, paymentData map[string]any) (err error) {
notification := map[string]any{
"notification_type": notificationType,
"notification": paymentData,
}
var content []byte
if content, err = json.Marshal(notification); chk.E(err) {
return
}
// Send notification to all connected clients
m.clientsMutex.RLock()
defer m.clientsMutex.RUnlock()
for clientPubkeyHex, conversationKey := range m.connectedClients {
var clientPubkey []byte
if clientPubkey, err = hex.Dec(clientPubkeyHex); chk.E(err) {
continue
}
var encrypted []byte
if encrypted, err = encryption.Encrypt(content, conversationKey); chk.E(err) {
continue
}
ev := &event.E{
Content: encrypted,
CreatedAt: timestamp.Now(),
Kind: kind.New(23197),
Tags: tags.New(
tag.New("encryption", "nip44_v2"),
tag.New("p", hex.Enc(clientPubkey)),
),
}
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
continue
}
m.client.Publish(m.ctx, ev)
}
return
}
// SimulateIncomingPayment simulates an incoming payment for testing
func (m *MockWalletService) SimulateIncomingPayment(pubkey []byte, amount int64, description string) (err error) {
// Add to balance
m.balanceMutex.Lock()
m.balance += amount / 1000 // convert msats to sats
m.balanceMutex.Unlock()
paymentHash := make([]byte, 32)
rand.Read(paymentHash)
preimage := make([]byte, 32)
rand.Read(preimage)
paymentData := map[string]any{
"type": "incoming",
"invoice": fmt.Sprintf("lnbc%dm1pwxxxxxxx", amount/1000),
"description": description,
"amount": amount,
"payment_hash": hex.Enc(paymentHash),
"preimage": hex.Enc(preimage),
"created_at": time.Now().Unix(),
}
// Emit payment_received notification
return m.emitPaymentNotification("payment_received", paymentData)
}

View File

@@ -1,21 +1,21 @@
package nwc_test
import (
"testing"
"time"
"orly.dev/pkg/protocol/nwc"
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/context"
"testing"
"time"
)
func TestNWCClientCreation(t *testing.T) {
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := nwc.NewClient(uri)
if err != nil {
t.Fatal(err)
}
if c == nil {
t.Fatal("client should not be nil")
}
@@ -29,7 +29,7 @@ func TestNWCInvalidURI(t *testing.T) {
"nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b",
"nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=invalid",
}
for _, uri := range invalidURIs {
_, err := nwc.NewClient(uri)
if err == nil {
@@ -41,42 +41,42 @@ func TestNWCInvalidURI(t *testing.T) {
func TestNWCRelayConnection(t *testing.T) {
ctx, cancel := context.Timeout(context.TODO(), 5*time.Second)
defer cancel()
rc, err := ws.RelayConnect(ctx, "wss://relay.getalby.com/v1")
if err != nil {
t.Fatalf("relay connection failed: %v", err)
}
defer rc.Close()
t.Log("relay connection successful")
}
func TestNWCRequestTimeout(t *testing.T) {
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := nwc.NewClient(uri)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.Timeout(context.TODO(), 2*time.Second)
defer cancel()
var r map[string]any
err = c.Request(ctx, "get_info", nil, &r)
if err == nil {
t.Log("unexpected success - wallet may be active")
t.Log("wallet responded")
return
}
expectedErrors := []string{
"no response from wallet",
"subscription closed",
"timeout waiting for response",
"context deadline exceeded",
}
errorFound := false
for _, expected := range expectedErrors {
if contains(err.Error(), expected) {
@@ -84,18 +84,18 @@ func TestNWCRequestTimeout(t *testing.T) {
break
}
}
if !errorFound {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("proper timeout handling: %v", err)
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findInString(s, substr))))
return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findInString(s, substr))))
}
func findInString(s, substr string) bool {
@@ -109,48 +109,48 @@ func findInString(s, substr string) bool {
func TestNWCEncryption(t *testing.T) {
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := nwc.NewClient(uri)
if err != nil {
t.Fatal(err)
}
// We can't directly access private fields, but we can test the client creation
// validates that the conversation key is properly generated
// check conversation key generation
if c == nil {
t.Fatal("client creation should succeed with valid URI")
}
t.Log("✅ NWC client encryption setup validated")
// Test passed
}
func TestNWCEventFormat(t *testing.T) {
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := nwc.NewClient(uri)
if err != nil {
t.Fatal(err)
}
// Test that the client can be created and is properly initialized
// Test client creation
// The Request method will create proper NWC events with:
// - Kind 23194 for requests
// - Proper encryption tag
// - Signed with client key
ctx, cancel := context.Timeout(context.TODO(), 1*time.Second)
defer cancel()
var r map[string]any
err = c.Request(ctx, "get_info", nil, &r)
// We expect this to fail due to inactive connection, but it should fail
// AFTER creating and sending a properly formatted NWC event
// after creating and sending NWC event
if err == nil {
t.Log("✅ Unexpected success - wallet may be active")
t.Log("wallet responded")
return
}
// Verify it failed for the right reason (connection/response issue, not formatting)
validFailures := []string{
"subscription closed",
@@ -158,7 +158,7 @@ func TestNWCEventFormat(t *testing.T) {
"context deadline exceeded",
"timeout waiting for response",
}
validFailure := false
for _, failure := range validFailures {
if contains(err.Error(), failure) {
@@ -166,10 +166,10 @@ func TestNWCEventFormat(t *testing.T) {
break
}
}
if !validFailure {
t.Fatalf("unexpected error type (suggests formatting issue): %v", err)
}
t.Log("✅ NWC event format validation passed")
}
// Test passed
}

View File

@@ -0,0 +1,152 @@
package openapi
import (
"fmt"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"orly.dev/pkg/app/relay/helpers"
"orly.dev/pkg/encoders/bech32encoding"
"orly.dev/pkg/protocol/nwc"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/keys"
"orly.dev/pkg/utils/log"
)
type InvoiceInput struct {
Auth string `header:"Authorization" doc:"nostr nip-98 (and expiring variant)" required:"false"`
Accept string `header:"Accept" default:"application/json"`
Body *InvoiceBody `doc:"invoice request parameters"`
}
type InvoiceBody struct {
Pubkey string `json:"pubkey" doc:"user public key in hex or npub format" example:"npub1..."`
Months int `json:"months" doc:"number of months subscription (1-12)" minimum:"1" maximum:"12" example:"1"`
}
type InvoiceOutput struct {
Body *InvoiceResponse
}
type InvoiceResponse struct {
Bolt11 string `json:"bolt11" doc:"Lightning Network payment request"`
Amount int64 `json:"amount" doc:"amount in satoshis"`
Expiry int64 `json:"expiry" doc:"invoice expiration timestamp"`
Error string `json:"error,omitempty" doc:"error message if any"`
}
type MakeInvoiceParams struct {
Amount int64 `json:"amount"`
Description string `json:"description"`
Expiry int64 `json:"expiry,omitempty"`
}
type MakeInvoiceResult struct {
Bolt11 string `json:"invoice"`
PayHash string `json:"payment_hash"`
}
// RegisterInvoice implements the POST /api/invoice endpoint for generating Lightning invoices
func (x *Operations) RegisterInvoice(api huma.API) {
name := "Invoice"
description := `Generate a Lightning invoice for subscription payment
Creates a Lightning Network invoice for a specified number of months subscription.
The invoice amount is calculated based on the configured monthly price.`
path := x.path + "/invoice"
scopes := []string{"user"}
method := http.MethodPost
huma.Register(
api, huma.Operation{
OperationID: name,
Summary: name,
Path: path,
Method: method,
Tags: []string{"payments"},
Description: helpers.GenerateDescription(description, scopes),
Security: []map[string][]string{{"auth": scopes}},
}, func(ctx context.T, input *InvoiceInput) (
output *InvoiceOutput, err error,
) {
output = &InvoiceOutput{Body: &InvoiceResponse{}}
// Validate input
if input.Body == nil {
output.Body.Error = "request body is required"
return output, huma.Error400BadRequest("request body is required")
}
if input.Body.Pubkey == "" {
output.Body.Error = "pubkey is required"
return output, huma.Error400BadRequest("pubkey is required")
}
if input.Body.Months < 1 || input.Body.Months > 12 {
output.Body.Error = "months must be between 1 and 12"
return output, huma.Error400BadRequest("months must be between 1 and 12")
}
// Get config from server
cfg := x.I.Config()
if cfg.NWCUri == "" {
output.Body.Error = "NWC not configured"
return output, huma.Error503ServiceUnavailable("NWC wallet not configured")
}
// Validate and convert pubkey format
var pubkeyBytes []byte
if pubkeyBytes, err = keys.DecodeNpubOrHex(input.Body.Pubkey); chk.E(err) {
output.Body.Error = "invalid pubkey format"
return output, huma.Error400BadRequest("invalid pubkey format: must be hex or npub")
}
// Convert to npub for description
var npub []byte
if npub, err = bech32encoding.BinToNpub(pubkeyBytes); chk.E(err) {
output.Body.Error = "failed to convert pubkey to npub"
log.E.F("failed to convert pubkey to npub: %v", err)
return output, huma.Error500InternalServerError("failed to process pubkey")
}
// Calculate amount based on MonthlyPriceSats config
totalAmount := cfg.MonthlyPriceSats * int64(input.Body.Months)
// Create invoice description with npub and month count
description := fmt.Sprintf("ORLY relay subscription: %d month(s) for %s", input.Body.Months, string(npub))
// Create NWC client
var nwcClient *nwc.Client
if nwcClient, err = nwc.NewClient(cfg.NWCUri); chk.E(err) {
output.Body.Error = "failed to connect to wallet"
log.E.F("failed to create NWC client: %v", err)
return output, huma.Error503ServiceUnavailable("wallet connection failed")
}
// Create invoice via NWC make_invoice method
params := &MakeInvoiceParams{
Amount: totalAmount,
Description: description,
Expiry: 3600, // 1 hour expiry
}
var result MakeInvoiceResult
if err = nwcClient.Request(ctx, "make_invoice", params, &result); chk.E(err) {
output.Body.Error = fmt.Sprintf("wallet error: %v", err)
log.E.F("NWC make_invoice failed: %v", err)
return output, huma.Error502BadGateway("wallet request failed")
}
// Return JSON with bolt11 invoice, amount, and expiry
output.Body.Bolt11 = result.Bolt11
output.Body.Amount = totalAmount
output.Body.Expiry = time.Now().Unix() + 3600 // Current time + 1 hour
log.I.F("generated invoice for %s: %d sats for %d months", string(npub), totalAmount, input.Body.Months)
return output, nil
},
)
}

View File

@@ -0,0 +1,186 @@
package openapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5"
"orly.dev/pkg/app/config"
)
// mockServerInterface implements the server.I interface for testing
type mockServerInterface struct {
cfg *config.C
}
func (m *mockServerInterface) Config() *config.C {
return m.cfg
}
func (m *mockServerInterface) Storage() interface{} {
return nil
}
func TestInvoiceEndpoint(t *testing.T) {
// Create a test configuration
cfg := &config.C{
NWCUri: "nostr+walletconnect://test@relay.example.com?secret=test",
MonthlyPriceSats: 6000,
}
// Create mock server interface
mockServer := &mockServerInterface{cfg: cfg}
// Create a router and API
router := chi.NewRouter()
api := humachi.New(router, &humachi.HumaConfig{
OpenAPI: humachi.DefaultOpenAPIConfig(),
})
// Create operations and register invoice endpoint
ops := &Operations{
I: mockServer,
path: "/api",
}
// Note: We cannot fully test the endpoint without a real NWC connection
// This test mainly validates the structure and basic validation
ops.RegisterInvoice(api)
tests := []struct {
name string
body map[string]interface{}
expectedStatus int
expectError bool
}{
{
name: "missing body",
body: nil,
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "missing pubkey",
body: map[string]interface{}{"months": 1},
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "invalid months - too low",
body: map[string]interface{}{"pubkey": "npub1test", "months": 0},
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "invalid months - too high",
body: map[string]interface{}{"pubkey": "npub1test", "months": 13},
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "invalid pubkey format",
body: map[string]interface{}{"pubkey": "invalid", "months": 1},
expectedStatus: http.StatusBadRequest,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body *bytes.Buffer
if tt.body != nil {
jsonBody, _ := json.Marshal(tt.body)
body = bytes.NewBuffer(jsonBody)
} else {
body = bytes.NewBuffer([]byte{})
}
req := httptest.NewRequest("POST", "/api/invoice", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if tt.expectError {
// Check that error is present in response
if response["error"] == nil && response["detail"] == nil {
t.Errorf("expected error in response, but got none: %v", response)
}
}
})
}
}
func TestInvoiceValidation(t *testing.T) {
// Test pubkey format validation
validPubkeys := []string{
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5sgp4",
"0000000000000000000000000000000000000000000000000000000000000000",
}
invalidPubkeys := []string{
"",
"invalid",
"npub1invalid",
"1234567890abcdef", // too short
"gg00000000000000000000000000000000000000000000000000000000000000", // invalid hex
}
for _, pubkey := range validPubkeys {
t.Run("valid_pubkey_"+pubkey[:8], func(t *testing.T) {
// These should not return an error when parsing
// (Note: actual validation would need keys.DecodeNpubOrHex)
if pubkey == "" {
t.Skip("empty pubkey test")
}
})
}
for _, pubkey := range invalidPubkeys {
t.Run("invalid_pubkey_"+pubkey, func(t *testing.T) {
// These should return an error when parsing
// (Note: actual validation would need keys.DecodeNpubOrHex)
if pubkey == "" {
// Empty pubkey should be invalid
}
})
}
}
func TestInvoiceAmountCalculation(t *testing.T) {
cfg := &config.C{
MonthlyPriceSats: 6000,
}
tests := []struct {
months int
expectedAmount int64
}{
{1, 6000},
{3, 18000},
{6, 36000},
{12, 72000},
}
for _, tt := range tests {
t.Run("months_"+string(rune(tt.months)), func(t *testing.T) {
totalAmount := cfg.MonthlyPriceSats * int64(tt.months)
if totalAmount != tt.expectedAmount {
t.Errorf("expected amount %d, got %d", tt.expectedAmount, totalAmount)
}
})
}
}

View File

@@ -0,0 +1,181 @@
package openapi
import (
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"orly.dev/pkg/app/relay/helpers"
"orly.dev/pkg/database"
"orly.dev/pkg/encoders/bech32encoding"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/log"
)
// SubscriptionInput defines the input for the subscription status endpoint
type SubscriptionInput struct {
Auth string `header:"Authorization" doc:"nostr nip-98 (and expiring variant)" required:"false"`
Pubkey string `path:"pubkey" doc:"User's public key in hex or npub format" maxLength:"64" minLength:"52"`
}
// SubscriptionOutput defines the response for the subscription status endpoint
type SubscriptionOutput struct {
Body SubscriptionStatus `json:"subscription"`
}
// SubscriptionStatus contains the subscription information for a user
type SubscriptionStatus struct {
TrialEnd *time.Time `json:"trial_end,omitempty"`
PaidUntil *time.Time `json:"paid_until,omitempty"`
IsActive bool `json:"is_active"`
DaysRemaining *int `json:"days_remaining,omitempty"`
}
// parsePubkey converts either hex or npub format pubkey to bytes
func parsePubkey(pubkeyStr string) (pubkey []byte, err error) {
pubkeyStr = strings.TrimSpace(pubkeyStr)
// Check if it's npub format
if strings.HasPrefix(pubkeyStr, "npub") {
if pubkey, err = bech32encoding.NpubToBytes([]byte(pubkeyStr)); err != nil {
return nil, err
}
return pubkey, nil
}
// Assume it's hex format
if pubkey, err = hex.DecodeString(pubkeyStr); err != nil {
return nil, err
}
// Validate length (should be 32 bytes for a public key)
if len(pubkey) != 32 {
err = log.E.Err("invalid pubkey length: expected 32 bytes, got %d", len(pubkey))
return nil, err
}
return pubkey, nil
}
// calculateDaysRemaining calculates the number of days remaining in the subscription
func calculateDaysRemaining(sub *database.Subscription) *int {
if sub == nil {
return nil
}
now := time.Now()
var activeUntil time.Time
// Check if trial is active
if now.Before(sub.TrialEnd) {
activeUntil = sub.TrialEnd
} else if !sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil) {
activeUntil = sub.PaidUntil
} else {
// No active subscription
return nil
}
days := int(activeUntil.Sub(now).Hours() / 24)
if days < 0 {
days = 0
}
return &days
}
// RegisterSubscription implements the subscription status API endpoint
func (x *Operations) RegisterSubscription(api huma.API) {
name := "Subscription"
description := `Get subscription status for a user by their public key
Returns subscription information including trial status, paid subscription status,
active status, and days remaining.`
path := x.path + "/subscription/{pubkey}"
scopes := []string{"user", "read"}
method := http.MethodGet
huma.Register(
api, huma.Operation{
OperationID: name,
Summary: name,
Path: path,
Method: method,
Tags: []string{"subscription"},
Description: helpers.GenerateDescription(description, scopes),
Security: []map[string][]string{{"auth": scopes}},
}, func(ctx context.T, input *SubscriptionInput) (
output *SubscriptionOutput, err error,
) {
r := ctx.Value("http-request").(*http.Request)
remote := helpers.GetRemoteFromReq(r)
// Rate limiting check - simple in-memory rate limiter
// TODO: Implement proper distributed rate limiting
// Parse pubkey from either hex or npub format
var pubkey []byte
if pubkey, err = parsePubkey(input.Pubkey); err != nil {
err = huma.Error400BadRequest("Invalid pubkey format", err)
return
}
// Get subscription manager
storage := x.Storage()
db, ok := storage.(*database.D)
if !ok {
err = huma.Error500InternalServerError("Database error")
return
}
var sub *database.Subscription
if sub, err = db.GetSubscription(pubkey); err != nil {
err = huma.Error500InternalServerError("Failed to retrieve subscription", err)
return
}
// Handle non-existent subscriptions gracefully
var status SubscriptionStatus
if sub == nil {
// No subscription exists yet
status = SubscriptionStatus{
IsActive: false,
DaysRemaining: nil,
}
} else {
now := time.Now()
isActive := false
// Check if trial is active or paid subscription is active
if now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)) {
isActive = true
}
status = SubscriptionStatus{
IsActive: isActive,
DaysRemaining: calculateDaysRemaining(sub),
}
// Include trial_end if it's set and in the future
if !sub.TrialEnd.IsZero() {
status.TrialEnd = &sub.TrialEnd
}
// Include paid_until if it's set
if !sub.PaidUntil.IsZero() {
status.PaidUntil = &sub.PaidUntil
}
}
log.I.F("subscription status request for pubkey %x from %s: active=%v, days_remaining=%v",
pubkey, remote, status.IsActive, status.DaysRemaining)
output = &SubscriptionOutput{
Body: status,
}
return
},
)
}