Compare commits

..

12 Commits

Author SHA1 Message Date
50179e44ed Add dashboard URL to relay description and bump version to v0.8.3.
Some checks failed
Go / build (push) Has been cancelled
- Updated relay description to include a dynamically constructed dashboard URL.
- Incremented version from v0.8.2 to v0.8.3.
2025-09-23 14:55:25 +01:00
34a3b1ba69 Add dynamic relay dashboard URL support and version increment to v0.8.2.
Some checks failed
Go / build (push) Has been cancelled
- Introduced configuration option `RelayURL` for relay dashboard base URL.
- Added dynamic dashboard URL functionality in `PaymentProcessor`.
- Updated payment notifications to include dashboard access link.
- Incremented version to v0.8.2.
2025-09-23 14:49:08 +01:00
093a19db29 Expand relay features and update version to v0.8.1.
Some checks failed
Go / build (push) Has been cancelled
- Enabled support for additional relay NIPs: Authentication, GenericTagQueries, ParameterizedReplaceableEvents, ExpirationTimestamp.
- Added `PaymentRequired` limitation based on configuration.
- Incremented version to v0.8.1.
2025-09-23 14:26:50 +01:00
2ba361c915 Add relay identity management and subscription enhancements.
Some checks failed
Go / build (push) Has been cancelled
- Introduced relay identity management for subscriptions and follow-list sync.
- Added `IdentityRequested` function to handle the `identity` subcommand.
- Implemented periodic follow-list synchronization for active subscribers.
- Enhanced payment handling to include payer pubkey and subscription updates.
- Added trial expiry and subscription expiry notifications.
2025-09-23 14:22:24 +01:00
7736bb7640 Add payment processing with NWC and subscription-based access control.
- Implemented `PaymentProcessor` to handle NWC payments and extend user subscriptions.
- Added configuration options for NWC URI, subscription pricing, and enablement.
- Updated server to initialize and manage the payment processor.
2025-09-22 17:36:05 +01:00
804e1c9649 Add NWC protocol handling and NIP-44 encryption and decryption functions. 2025-09-22 17:18:47 +01:00
81a6aade4e Bump version to v0.7.1; update relay icon URL.
Some checks failed
Go / build (push) Has been cancelled
2025-09-22 09:38:01 +01:00
fc9600f99d Bump version to v0.7.0; update docs image. 2025-09-22 09:33:04 +01:00
199f922208 Refactor deletion checks and error handling; bump version to v0.6.4.
Some checks failed
Go / build (push) Has been cancelled
2025-09-21 18:15:27 +01:00
405e223aa6 implement delete events 2025-09-21 18:06:11 +01:00
fc3a89a309 Remove unused JavaScript file index-tha189jf.js from dist directory.
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 17:17:31 +01:00
ba8166da07 Remove unused JavaScript file index-wnwvj11w.js from dist directory.
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 17:17:15 +01:00
37 changed files with 5102 additions and 315 deletions

7
.idea/jsLibraryMappings.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="file://$PROJECT_DIR$/../github.com/jumble" libraries="{jumble/node_modules}" />
<file url="file://$PROJECT_DIR$/../github.com/mleku/jumble" libraries="{jumble/node_modules}" />
</component>
</project>

View File

@@ -23,25 +23,29 @@ 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" usage:"set a name to display on information about the relay" default:"ORLY"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/share/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"`
HealthPort int `env:"ORLY_HEALTH_PORT" default:"0" usage:"optional health check HTTP port; 0 disables"`
EnableShutdown bool `env:"ORLY_ENABLE_SHUTDOWN" default:"false" usage:"if true, expose /shutdown on the health port to gracefully stop the process (for profiling)"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"relay log level: fatal error warn info debug trace"`
DBLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"database log level: fatal error warn info debug trace"`
LogToStdout bool `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
PprofPath string `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
PprofHTTP bool `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
OpenPprofWeb bool `env:"ORLY_OPEN_PPROF_WEB" default:"false" usage:"if true, automatically open the pprof web viewer when profiling is enabled"`
IPWhitelist []string `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"`
Admins []string `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"`
Owners []string `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"`
ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows,none" default:"none"`
SpiderMode string `env:"ORLY_SPIDER_MODE" usage:"spider mode: none,follow" default:"none"`
SpiderFrequency time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"spider frequency in seconds" default:"1h"`
AppName string `env:"ORLY_APP_NAME" usage:"set a name to display on information about the relay" default:"ORLY"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/share/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"`
HealthPort int `env:"ORLY_HEALTH_PORT" default:"0" usage:"optional health check HTTP port; 0 disables"`
EnableShutdown bool `env:"ORLY_ENABLE_SHUTDOWN" default:"false" usage:"if true, expose /shutdown on the health port to gracefully stop the process (for profiling)"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"relay log level: fatal error warn info debug trace"`
DBLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"database log level: fatal error warn info debug trace"`
LogToStdout bool `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
PprofPath string `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
PprofHTTP bool `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
OpenPprofWeb bool `env:"ORLY_OPEN_PPROF_WEB" default:"false" usage:"if true, automatically open the pprof web viewer when profiling is enabled"`
IPWhitelist []string `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"`
Admins []string `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"`
Owners []string `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"`
ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows,none" default:"none"`
SpiderMode string `env:"ORLY_SPIDER_MODE" usage:"spider mode: none,follow" default:"none"`
SpiderFrequency time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"spider frequency in seconds" default:"1h"`
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)"`
RelayURL string `env:"ORLY_RELAY_URL" usage:"base URL for the relay dashboard (e.g., https://relay.example.com)"`
// Web UI and dev mode settings
WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
@@ -136,6 +140,21 @@ func GetEnv() (requested bool) {
return
}
// IdentityRequested checks if the first command line argument is "identity" and returns
// whether the relay identity should be printed and the program should exit.
//
// Return Values
// - requested: true if the 'identity' subcommand was provided, false otherwise.
func IdentityRequested() (requested bool) {
if len(os.Args) > 1 {
switch strings.ToLower(os.Args[1]) {
case "identity":
requested = true
}
}
return
}
// KV is a key/value pair.
type KV struct{ Key, Value string }

View File

@@ -50,6 +50,34 @@ func (l *Listener) HandleAuth(b []byte) (err error) {
env.Event.Pubkey,
)
l.authedPubkey.Store(env.Event.Pubkey)
// Check if this is a first-time user and create welcome note
go l.handleFirstTimeUser(env.Event.Pubkey)
}
return
}
// handleFirstTimeUser checks if user is logging in for first time and creates welcome note
func (l *Listener) handleFirstTimeUser(pubkey []byte) {
// Check if this is a first-time user
isFirstTime, err := l.Server.D.IsFirstTimeUser(pubkey)
if err != nil {
log.E.F("failed to check first-time user status: %v", err)
return
}
if !isFirstTime {
return // Not a first-time user
}
// Get payment processor to create welcome note
if l.Server.paymentProcessor != nil {
// Set the dashboard URL based on the current HTTP request
dashboardURL := l.Server.DashboardURL(l.req)
l.Server.paymentProcessor.SetDashboardURL(dashboardURL)
if err := l.Server.paymentProcessor.CreateWelcomeNote(pubkey); err != nil {
log.E.F("failed to create welcome note for first-time user: %v", err)
}
}
}

View File

@@ -145,12 +145,10 @@ func (l *Listener) HandleDelete(env *eventenvelope.Submission) (err error) {
if ev, err = l.FetchEventBySerial(s); chk.E(err) {
continue
}
// check that the author is the same as the signer of the
// delete, for the e tag case the author is the signer of
// the event.
if !utils.FastEqual(env.E.Pubkey, ev.Pubkey) {
// allow deletion if the signer is the author OR an admin/owner
if !(ownerDelete || utils.FastEqual(env.E.Pubkey, ev.Pubkey)) {
log.W.F(
"HandleDelete: attempted deletion of event %s by different user - delete pubkey=%s, event pubkey=%s",
"HandleDelete: attempted deletion of event %s by unauthorized user - delete pubkey=%s, event pubkey=%s",
hex.Enc(ev.ID), hex.Enc(env.E.Pubkey),
hex.Enc(ev.Pubkey),
)

View File

@@ -31,11 +31,11 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
var info *relayinfo.T
supportedNIPs := relayinfo.GetList(
relayinfo.BasicProtocol,
// relayinfo.Authentication,
relayinfo.Authentication,
// relayinfo.EncryptedDirectMessage,
relayinfo.EventDeletion,
relayinfo.RelayInformationDocument,
// relayinfo.GenericTagQueries,
relayinfo.GenericTagQueries,
// relayinfo.NostrMarketplace,
relayinfo.EventTreatment,
// relayinfo.CommandResults,
@@ -51,29 +51,34 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
// relayinfo.EncryptedDirectMessage,
relayinfo.EventDeletion,
relayinfo.RelayInformationDocument,
// relayinfo.GenericTagQueries,
relayinfo.GenericTagQueries,
// relayinfo.NostrMarketplace,
relayinfo.EventTreatment,
// relayinfo.CommandResults,
// relayinfo.ParameterizedReplaceableEvents,
// relayinfo.ExpirationTimestamp,
relayinfo.ParameterizedReplaceableEvents,
relayinfo.ExpirationTimestamp,
relayinfo.ProtectedEvents,
relayinfo.RelayListMetadata,
)
}
sort.Sort(supportedNIPs)
log.T.Ln("supported NIPs", supportedNIPs)
// Construct description with dashboard URL
dashboardURL := s.DashboardURL(r)
description := version.Description + " dashboard: " + dashboardURL
info = &relayinfo.T{
Name: s.Config.AppName,
Description: version.Description,
Description: description,
Nips: supportedNIPs,
Software: version.URL,
Version: version.V,
Limitation: relayinfo.Limits{
AuthRequired: s.Config.ACLMode != "none",
RestrictedWrites: s.Config.ACLMode != "none",
PaymentRequired: s.Config.MonthlyPriceSats > 0,
},
Icon: "https://cdn.satellite.earth/ac9778868fbf23b63c47c769a74e163377e6ea94d3f0f31711931663d035c4f6.png",
Icon: "https://i.nostr.build/6wGXAn7Zaw9mHxFg.png",
}
if err := json.NewEncoder(w).Encode(info); chk.E(err) {
}

View File

@@ -4,12 +4,14 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
"next.orly.dev/pkg/encoders/envelopes/closedenvelope"
"next.orly.dev/pkg/encoders/envelopes/eoseenvelope"
@@ -143,6 +145,43 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
var tmp event.S
privCheck:
for _, ev := range events {
// Check for private tag first
privateTags := ev.Tags.GetAll([]byte("private"))
if len(privateTags) > 0 && accessLevel != "admin" {
pk := l.authedPubkey.Load()
if pk == nil {
continue // no auth, can't access private events
}
// Convert authenticated pubkey to npub for comparison
authedNpub, err := bech32encoding.BinToNpub(pk)
if err != nil {
continue // couldn't convert pubkey, skip
}
// Check if authenticated npub is in any private tag
authorized := false
for _, privateTag := range privateTags {
authorizedNpubs := strings.Split(string(privateTag.Value()), ",")
for _, npub := range authorizedNpubs {
if strings.TrimSpace(npub) == string(authedNpub) {
authorized = true
break
}
}
if authorized {
break
}
}
if !authorized {
continue // not authorized to see this private event
}
tmp = append(tmp, ev)
continue
}
if kind.IsPrivileged(ev.Kind) &&
accessLevel != "admin" { // admins can see all events
// log.T.C(

View File

@@ -8,7 +8,8 @@ import (
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/app/config"
database "next.orly.dev/pkg/database"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/protocol/publish"
)
@@ -47,6 +48,38 @@ func Run(
}
// Initialize the user interface
l.UserInterface()
// Ensure a relay identity secret key exists when subscriptions and NWC are enabled
if cfg.SubscriptionEnabled && cfg.NWCUri != "" {
if skb, e := db.GetOrCreateRelayIdentitySecret(); e != nil {
log.E.F("failed to ensure relay identity key: %v", e)
} else if pk, e2 := keys.SecretBytesToPubKeyHex(skb); e2 == nil {
log.I.F("relay identity loaded (pub=%s)", pk)
// ensure relay identity pubkey is considered an admin for ACL follows mode
found := false
for _, a := range cfg.Admins {
if a == pk {
found = true
break
}
}
if !found {
cfg.Admins = append(cfg.Admins, pk)
log.I.F("added relay identity to admins for follow-list whitelisting")
}
}
}
if l.paymentProcessor, err = NewPaymentProcessor(ctx, cfg, db); err != nil {
log.E.F("failed to create payment processor: %v", err)
// Continue without payment processor
} else {
if err = l.paymentProcessor.Start(); err != nil {
log.E.F("failed to start payment processor: %v", err)
} else {
log.I.F("payment processor started successfully")
}
}
addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port)
log.I.F("starting listener on http://%s", addr)
go func() {

842
app/payment_processor.go Normal file
View File

@@ -0,0 +1,842 @@
package app
import (
"context"
// std hex not used; use project hex encoder instead
"fmt"
"strings"
"sync"
"time"
"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/json"
"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 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 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))
}
// 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)
}
// Create the welcome note content with nostr:npub link
content := fmt.Sprintf(`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!`, 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 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
}
// 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)
}

View File

@@ -40,6 +40,8 @@ type Server struct {
// Challenge storage for HTTP UI authentication
challengeMutex sync.RWMutex
challenges map[string][]byte
paymentProcessor *PaymentProcessor
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -111,6 +113,15 @@ func (s *Server) ServiceURL(req *http.Request) (st string) {
return proto + "://" + host
}
// DashboardURL constructs HTTPS URL for the dashboard based on the HTTP request
func (s *Server) DashboardURL(req *http.Request) string {
host := req.Header.Get("X-Forwarded-Host")
if host == "" {
host = req.Host
}
return "https://" + host
}
// UserInterface sets up a basic Nostr NDK interface that allows users to log into the relay user interface
func (s *Server) UserInterface() {
if s.mux == nil {
@@ -406,13 +417,14 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
filename := "events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
w.Header().Set(
"Content-Disposition", "attachment; filename=\""+filename+"\"",
)
// Stream export
s.D.Export(s.Ctx, w, pks...)
}
// handleExportMine streams only the authenticated user's events as JSONL (NDJSON).
func (s *Server) handleExportMine(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -434,7 +446,9 @@ func (s *Server) handleExportMine(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
filename := "my-events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
w.Header().Set(
"Content-Disposition", "attachment; filename=\""+filename+"\"",
)
// Stream export for this user's pubkey only
s.D.Export(s.Ctx, w, pubkey)
@@ -482,7 +496,7 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Empty request body", http.StatusBadRequest)
return
}
s.D.Import(r.Body)
s.D.Import(r.Body)
}
w.Header().Set("Content-Type", "application/json")
@@ -552,7 +566,7 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) {
}
// Events are already sorted by QueryEvents in reverse chronological order
// Apply offset and limit manually since QueryEvents doesn't support offset
totalEvents := len(events)
start := offset
@@ -568,11 +582,11 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) {
// Convert events to JSON response format
type EventResponse struct {
ID string `json:"id"`
Kind int `json:"kind"`
CreatedAt int64 `json:"created_at"`
Content string `json:"content"`
RawJSON string `json:"raw_json"`
ID string `json:"id"`
Kind int `json:"kind"`
CreatedAt int64 `json:"created_at"`
Content string `json:"content"`
RawJSON string `json:"raw_json"`
}
response := struct {

File diff suppressed because one or more lines are too long

160
app/web/dist/index-w8zpqk4w.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nostr Relay</title>
<link rel="stylesheet" crossorigin href="./index-q4cwd1fy.css"><script type="module" crossorigin src="./index-tha189jf.js"></script></head>
<link rel="stylesheet" crossorigin href="./index-q4cwd1fy.css"><script type="module" crossorigin src="./index-w8zpqk4w.js"></script></head>
<body>
<script>
// Apply system theme preference immediately to avoid flash of wrong theme

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 485 KiB

1
go.mod
View File

@@ -15,6 +15,7 @@ require (
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b
go-simpler.org/env v0.12.0
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.41.0
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
golang.org/x/lint v0.0.0-20241112194109-818c5a804067
golang.org/x/net v0.43.0

2
go.sum
View File

@@ -76,6 +76,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=

31
main.go
View File

@@ -17,7 +17,9 @@ import (
"next.orly.dev/app"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/spider"
"next.orly.dev/pkg/version"
)
@@ -51,11 +53,32 @@ func main() {
runtime.GOMAXPROCS(runtime.NumCPU() * 4)
var err error
var cfg *config.C
if cfg, err = config.New(); chk.T(err) {
}
log.I.F("starting %s %s", cfg.AppName, version.V)
if cfg, err = config.New(); chk.T(err) {
}
log.I.F("starting %s %s", cfg.AppName, version.V)
// If OpenPprofWeb is true and profiling is enabled, we need to ensure HTTP profiling is also enabled
// Handle 'identity' subcommand: print relay identity secret and pubkey and exit
if config.IdentityRequested() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var db *database.D
if db, err = database.New(ctx, cancel, cfg.DataDir, cfg.DBLogLevel); chk.E(err) {
os.Exit(1)
}
defer db.Close()
skb, err := db.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
os.Exit(1)
}
pk, err := keys.SecretBytesToPubKeyHex(skb)
if chk.E(err) {
os.Exit(1)
}
fmt.Printf("identity secret: %s\nidentity pubkey: %s\n", hex.Enc(skb), pk)
os.Exit(0)
}
// If OpenPprofWeb is true and profiling is enabled, we need to ensure HTTP profiling is also enabled
if cfg.OpenPprofWeb && cfg.Pprof != "" && !cfg.PprofHTTP {
log.I.F("enabling HTTP pprof server to support web viewer")
cfg.PprofHTTP = true

View File

@@ -66,3 +66,15 @@ func (s *S) Type() (typ string) {
}
return
}
// AddFollow forwards a pubkey to the active ACL if it supports dynamic follows
func (s *S) AddFollow(pub []byte) {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
if f, ok := i.(*Follows); ok {
f.AddFollow(pub)
}
break
}
}
}

View File

@@ -1,6 +1,7 @@
package acl
import (
"bytes"
"context"
"reflect"
"strings"
@@ -370,6 +371,32 @@ func (f *Follows) GetFollowedPubkeys() [][]byte {
return followedPubkeys
}
// AddFollow appends a pubkey to the in-memory follows list if not already present
// and signals the syncer to refresh subscriptions.
func (f *Follows) AddFollow(pub []byte) {
if len(pub) == 0 {
return
}
f.followsMx.Lock()
defer f.followsMx.Unlock()
for _, p := range f.follows {
if bytes.Equal(p, pub) {
return
}
}
b := make([]byte, len(pub))
copy(b, pub)
f.follows = append(f.follows, b)
// notify syncer if initialized
if f.updated != nil {
select {
case f.updated <- struct{}{}:
default:
// if channel is full or not yet listened to, ignore
}
}
}
func init() {
log.T.F("registering follows ACL")
Registry.Register(new(Follows))

View File

@@ -0,0 +1 @@
Code copied from https://github.com/paulmillr/nip44/tree/e7aed61aaf77240ac10c325683eed14b22e7950f/go.

View File

@@ -0,0 +1,3 @@
// Package encryption contains the message encryption schemes defined in NIP-04
// and NIP-44, used for encrypting the content of nostr messages.
package encryption

View File

@@ -0,0 +1,88 @@
package encryption
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"lukechampine.com/frand"
)
// EncryptNip4 encrypts message with key using aes-256-cbc. key should be the shared secret generated by
// ComputeSharedSecret.
//
// Returns: base64(encrypted_bytes) + "?iv=" + base64(initialization_vector).
func EncryptNip4(msg, key []byte) (ct []byte, err error) {
// block size is 16 bytes
iv := make([]byte, 16)
if _, err = frand.Read(iv); chk.E(err) {
err = errorf.E("error creating initialization vector: %w", err)
return
}
// automatically picks aes-256 based on key length (32 bytes)
var block cipher.Block
if block, err = aes.NewCipher(key); chk.E(err) {
err = errorf.E("error creating block cipher: %w", err)
return
}
mode := cipher.NewCBCEncrypter(block, iv)
plaintext := []byte(msg)
// add padding
base := len(plaintext)
// this will be a number between 1 and 16 (inclusive), never 0
bs := block.BlockSize()
padding := bs - base%bs
// encode the padding in all the padding bytes themselves
padText := bytes.Repeat([]byte{byte(padding)}, padding)
paddedMsgBytes := append(plaintext, padText...)
ciphertext := make([]byte, len(paddedMsgBytes))
mode.CryptBlocks(ciphertext, paddedMsgBytes)
return []byte(base64.StdEncoding.EncodeToString(ciphertext) + "?iv=" +
base64.StdEncoding.EncodeToString(iv)), nil
}
// DecryptNip4 decrypts a content string using the shared secret key. The inverse operation to message ->
// EncryptNip4(message, key).
func DecryptNip4(content, key []byte) (msg []byte, err error) {
parts := bytes.Split(content, []byte("?iv="))
if len(parts) < 2 {
return nil, errorf.E(
"error parsing encrypted message: no initialization vector",
)
}
ciphertext := make([]byte, base64.StdEncoding.EncodedLen(len(parts[0])))
if _, err = base64.StdEncoding.Decode(ciphertext, parts[0]); chk.E(err) {
err = errorf.E("error decoding ciphertext from base64: %w", err)
return
}
iv := make([]byte, base64.StdEncoding.EncodedLen(len(parts[1])))
if _, err = base64.StdEncoding.Decode(iv, parts[1]); chk.E(err) {
err = errorf.E("error decoding iv from base64: %w", err)
return
}
var block cipher.Block
if block, err = aes.NewCipher(key); chk.E(err) {
err = errorf.E("error creating block cipher: %w", err)
return
}
mode := cipher.NewCBCDecrypter(block, iv)
msg = make([]byte, len(ciphertext))
mode.CryptBlocks(msg, ciphertext)
// remove padding
var (
plaintextLen = len(msg)
)
if plaintextLen > 0 {
// the padding amount is encoded in the padding bytes themselves
padding := int(msg[plaintextLen-1])
if padding > plaintextLen {
err = errorf.E("invalid padding amount: %d", padding)
return
}
msg = msg[0 : plaintextLen-padding]
}
return msg, nil
}

View File

@@ -0,0 +1,260 @@
package encryption
import (
"crypto/hmac"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"io"
"math"
"golang.org/x/crypto/chacha20"
"golang.org/x/crypto/hkdf"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/crypto/sha256"
"next.orly.dev/pkg/interfaces/signer"
"next.orly.dev/pkg/utils"
)
const (
version byte = 2
MinPlaintextSize = 0x0001 // 1b msg => padded to 32b
MaxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
)
type Opts struct {
err error
nonce []byte
}
// Deprecated: use WithCustomNonce instead of WithCustomSalt, so the naming is less confusing
var WithCustomSalt = WithCustomNonce
// WithCustomNonce enables using a custom nonce (salt) instead of using the
// system crypto/rand entropy source.
func WithCustomNonce(salt []byte) func(opts *Opts) {
return func(opts *Opts) {
if len(salt) != 32 {
opts.err = errorf.E("salt must be 32 bytes, got %d", len(salt))
}
opts.nonce = salt
}
}
// Encrypt data using a provided symmetric conversation key using NIP-44
// encryption (chacha20 cipher stream and sha256 HMAC).
func Encrypt(
plaintext, conversationKey []byte, applyOptions ...func(opts *Opts),
) (
cipherString []byte, err error,
) {
var o Opts
for _, apply := range applyOptions {
apply(&o)
}
if chk.E(o.err) {
err = o.err
return
}
if o.nonce == nil {
o.nonce = make([]byte, 32)
if _, err = rand.Read(o.nonce); chk.E(err) {
return
}
}
var enc, cc20nonce, auth []byte
if enc, cc20nonce, auth, err = getKeys(
conversationKey, o.nonce,
); chk.E(err) {
return
}
plain := plaintext
size := len(plain)
if size < MinPlaintextSize || size > MaxPlaintextSize {
err = errorf.E("plaintext should be between 1b and 64kB")
return
}
padding := CalcPadding(size)
padded := make([]byte, 2+padding)
binary.BigEndian.PutUint16(padded, uint16(size))
copy(padded[2:], plain)
var cipher []byte
if cipher, err = encrypt(enc, cc20nonce, padded); chk.E(err) {
return
}
var mac []byte
if mac, err = sha256Hmac(auth, cipher, o.nonce); chk.E(err) {
return
}
ct := make([]byte, 0, 1+32+len(cipher)+32)
ct = append(ct, version)
ct = append(ct, o.nonce...)
ct = append(ct, cipher...)
ct = append(ct, mac...)
cipherString = make([]byte, base64.StdEncoding.EncodedLen(len(ct)))
base64.StdEncoding.Encode(cipherString, ct)
return
}
// Decrypt data that has been encoded using a provided symmetric conversation
// key using NIP-44 encryption (chacha20 cipher stream and sha256 HMAC).
func Decrypt(b64ciphertextWrapped, conversationKey []byte) (
plaintext []byte,
err error,
) {
cLen := len(b64ciphertextWrapped)
if cLen < 132 || cLen > 87472 {
err = errorf.E("invalid payload length: %d", cLen)
return
}
if len(b64ciphertextWrapped) > 0 && b64ciphertextWrapped[0] == '#' {
err = errorf.E("unknown version")
return
}
var decoded []byte
if decoded, err = base64.StdEncoding.DecodeString(string(b64ciphertextWrapped)); chk.E(err) {
return
}
if decoded[0] != version {
err = errorf.E("unknown version %d", decoded[0])
return
}
dLen := len(decoded)
if dLen < 99 || dLen > 65603 {
err = errorf.E("invalid data length: %d", dLen)
return
}
nonce, ciphertext, givenMac := decoded[1:33], decoded[33:dLen-32], decoded[dLen-32:]
var enc, cc20nonce, auth []byte
if enc, cc20nonce, auth, err = getKeys(conversationKey, nonce); chk.E(err) {
return
}
var expectedMac []byte
if expectedMac, err = sha256Hmac(auth, ciphertext, nonce); chk.E(err) {
return
}
if !utils.FastEqual(givenMac, expectedMac) {
err = errorf.E("invalid hmac")
return
}
var padded []byte
if padded, err = encrypt(enc, cc20nonce, ciphertext); chk.E(err) {
return
}
unpaddedLen := binary.BigEndian.Uint16(padded[0:2])
if unpaddedLen < uint16(MinPlaintextSize) || unpaddedLen > uint16(MaxPlaintextSize) ||
len(padded) != 2+CalcPadding(int(unpaddedLen)) {
err = errorf.E("invalid padding")
return
}
unpadded := padded[2:][:unpaddedLen]
if len(unpadded) == 0 || len(unpadded) != int(unpaddedLen) {
err = errorf.E("invalid padding")
return
}
plaintext = unpadded
return
}
// GenerateConversationKeyFromHex performs an ECDH key generation hashed with the nip-44-v2 using hkdf.
func GenerateConversationKeyFromHex(pkh, skh string) (ck []byte, err error) {
if skh >= "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" ||
skh == "0000000000000000000000000000000000000000000000000000000000000000" {
err = errorf.E(
"invalid private key: x coordinate %s is not on the secp256k1 curve",
skh,
)
return
}
var sign signer.I
if sign, err = p256k.NewSecFromHex(skh); chk.E(err) {
return
}
var pk []byte
if pk, err = p256k.HexToBin(pkh); chk.E(err) {
return
}
var shared []byte
if shared, err = sign.ECDH(pk); chk.E(err) {
return
}
ck = hkdf.Extract(sha256.New, shared, []byte("nip44-v2"))
return
}
func GenerateConversationKeyWithSigner(sign signer.I, pk []byte) (
ck []byte, err error,
) {
var shared []byte
if shared, err = sign.ECDH(pk); chk.E(err) {
return
}
ck = hkdf.Extract(sha256.New, shared, []byte("nip44-v2"))
return
}
func encrypt(key, nonce, message []byte) (dst []byte, err error) {
var cipher *chacha20.Cipher
if cipher, err = chacha20.NewUnauthenticatedCipher(key, nonce); chk.E(err) {
return
}
dst = make([]byte, len(message))
cipher.XORKeyStream(dst, message)
return
}
func sha256Hmac(key, ciphertext, nonce []byte) (h []byte, err error) {
if len(nonce) != sha256.Size {
err = errorf.E("nonce aad must be 32 bytes")
return
}
hm := hmac.New(sha256.New, key)
hm.Write(nonce)
hm.Write(ciphertext)
h = hm.Sum(nil)
return
}
func getKeys(conversationKey, nonce []byte) (
enc, cc20nonce, auth []byte, err error,
) {
if len(conversationKey) != 32 {
err = errorf.E("conversation key must be 32 bytes")
return
}
if len(nonce) != 32 {
err = errorf.E("nonce must be 32 bytes")
return
}
r := hkdf.Expand(sha256.New, conversationKey, nonce)
enc = make([]byte, 32)
if _, err = io.ReadFull(r, enc); chk.E(err) {
return
}
cc20nonce = make([]byte, 12)
if _, err = io.ReadFull(r, cc20nonce); chk.E(err) {
return
}
auth = make([]byte, 32)
if _, err = io.ReadFull(r, auth); chk.E(err) {
return
}
return
}
// CalcPadding creates padding for the message payload that is precisely a power
// of two in order to reduce the chances of plaintext attack. This is plainly
// retarded because it could blow out the message size a lot when just a random few
// dozen bytes and a length prefix would achieve the same result.
func CalcPadding(sLen int) (l int) {
if sLen <= 32 {
return 32
}
nextPower := 1 << int(math.Floor(math.Log2(float64(sLen-1)))+1)
chunk := int(math.Max(32, float64(nextPower/8)))
l = chunk * int(math.Floor(float64((sLen-1)/chunk))+1)
return
}

File diff suppressed because it is too large Load Diff

83
pkg/crypto/keys/keys.go Normal file
View File

@@ -0,0 +1,83 @@
// Package keys is a set of helpers for generating and converting public/secret
// keys to hex and back to binary.
package keys
import (
"bytes"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/crypto/ec/schnorr"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/utils"
)
// GeneratePrivateKey - deprecated, use GenerateSecretKeyHex
var GeneratePrivateKey = func() string { return GenerateSecretKeyHex() }
// GenerateSecretKey creates a new secret key and returns the bytes of the secret.
func GenerateSecretKey() (skb []byte, err error) {
signer := &p256k.Signer{}
if err = signer.Generate(); chk.E(err) {
return
}
skb = signer.Sec()
return
}
// GenerateSecretKeyHex generates a secret key and encodes the bytes as hex.
func GenerateSecretKeyHex() (sks string) {
skb, err := GenerateSecretKey()
if chk.E(err) {
return
}
return hex.Enc(skb)
}
// GetPublicKeyHex generates a public key from a hex encoded secret key.
func GetPublicKeyHex(sk string) (pk string, err error) {
var b []byte
if b, err = hex.Dec(sk); chk.E(err) {
return
}
signer := &p256k.Signer{}
if err = signer.InitSec(b); chk.E(err) {
return
}
return hex.Enc(signer.Pub()), nil
}
// SecretBytesToPubKeyHex generates a public key from secret key bytes.
func SecretBytesToPubKeyHex(skb []byte) (pk string, err error) {
signer := &p256k.Signer{}
if err = signer.InitSec(skb); chk.E(err) {
return
}
return hex.Enc(signer.Pub()), nil
}
// IsValid32ByteHex checks that a hex string is a valid 32 bytes lower case hex encoded value as
// per nostr NIP-01 spec.
func IsValid32ByteHex[V []byte | string](pk V) bool {
if utils.FastEqual(bytes.ToLower([]byte(pk)), []byte(pk)) {
return false
}
var err error
dec := make([]byte, 32)
if _, err = hex.DecBytes(dec, []byte(pk)); chk.E(err) {
}
return len(dec) == 32
}
// IsValidPublicKey checks that a hex encoded public key is a valid BIP-340 public key.
func IsValidPublicKey[V []byte | string](pk V) bool {
v, _ := hex.Dec(string(pk))
_, err := schnorr.ParsePubKey(v)
return err == nil
}
// HexPubkeyToBytes decodes a pubkey from hex encoded string/bytes.
func HexPubkeyToBytes[V []byte | string](hpk V) (pkb []byte, err error) {
return hex.DecAppend(nil, []byte(hpk))
}

81
pkg/database/identity.go Normal file
View File

@@ -0,0 +1,81 @@
package database
import (
"errors"
"fmt"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/encoders/hex"
)
const relayIdentitySecretKey = "relay:identity:sk"
// GetRelayIdentitySecret returns the relay identity secret key bytes if present.
// If the key is not found, returns (nil, badger.ErrKeyNotFound).
func (d *D) GetRelayIdentitySecret() (skb []byte, err error) {
err = d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(relayIdentitySecretKey))
if errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
// value stored as hex string
b, err := hex.Dec(string(val))
if err != nil {
return err
}
skb = make([]byte, len(b))
copy(skb, b)
return nil
})
})
return
}
// SetRelayIdentitySecret stores the relay identity secret key bytes (expects 32 bytes).
func (d *D) SetRelayIdentitySecret(skb []byte) (err error) {
if len(skb) != 32 {
return fmt.Errorf("invalid secret key length: %d", len(skb))
}
val := []byte(hex.Enc(skb))
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Set([]byte(relayIdentitySecretKey), val)
})
}
// GetOrCreateRelayIdentitySecret retrieves the existing relay identity secret
// key or creates and stores a new one if none exists.
func (d *D) GetOrCreateRelayIdentitySecret() (skb []byte, err error) {
// Try get fast path
if skb, err = d.GetRelayIdentitySecret(); err == nil && len(skb) == 32 {
return skb, nil
}
if err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return nil, err
}
// Create new key and store atomically
var gen []byte
if gen, err = keys.GenerateSecretKey(); chk.E(err) {
return nil, err
}
if err = d.SetRelayIdentitySecret(gen); chk.E(err) {
return nil, err
}
log.I.F("generated new relay identity key (pub=%s)", mustPub(gen))
return gen, nil
}
func mustPub(skb []byte) string {
pk, err := keys.SecretBytesToPubKeyHex(skb)
if err != nil {
return ""
}
return pk
}

View File

@@ -173,10 +173,10 @@ func (d *D) CheckForDeleted(ev *event.E, admins [][]byte) (err error) {
}
}
if ev.CreatedAt < maxTs {
// err = fmt.Errorf(
// "blocked: was deleted by address %s: event is older than the delete: event: %d delete: %d",
// at, ev.CreatedAt, maxTs,
// )
err = errorf.E(
"blocked: %0x was deleted by address %s because it is older than the delete: event: %d delete: %d",
ev.ID, at, ev.CreatedAt, maxTs,
)
return
}
return
@@ -203,22 +203,14 @@ func (d *D) CheckForDeleted(ev *event.E, admins [][]byte) (err error) {
return
}
if len(s) > 0 {
// For e-tag deletions (delete by ID), any deletion event means the event cannot be resubmitted
// regardless of timestamp, since it's a specific deletion of this exact event
// err = errorf.E(
// "blocked: was deleted by ID and cannot be resubmitted",
// // ev.ID,
// )
// Any e-tag deletion found means the exact event was deleted and cannot be resubmitted
err = errorf.E("blocked: %0x has been deleted", ev.ID)
return
}
}
if len(sers) > 0 {
// For e-tag deletions (delete by ID), any deletion event means the event cannot be resubmitted
// regardless of timestamp, since it's a specific deletion of this exact event
// err = errorf.E(
// "blocked: was deleted by ID and cannot be resubmitted",
// // ev.ID,
// )
// Any e-tag deletion found means the exact event was deleted and cannot be resubmitted
err = errorf.E("blocked: %0x has been deleted", ev.ID)
return
}

View File

@@ -188,3 +188,30 @@ func (d *D) GetPaymentHistory(pubkey []byte) ([]Payment, error) {
return payments, err
}
// IsFirstTimeUser checks if a user is logging in for the first time and marks them as seen
func (d *D) IsFirstTimeUser(pubkey []byte) (bool, error) {
key := fmt.Sprintf("firstlogin:%s", hex.EncodeToString(pubkey))
isFirstTime := false
err := d.DB.Update(
func(txn *badger.Txn) error {
_, err := txn.Get([]byte(key))
if errors.Is(err, badger.ErrKeyNotFound) {
// First time - record the login
isFirstTime = true
now := time.Now()
data, err := json.Marshal(map[string]interface{}{
"first_login": now,
})
if err != nil {
return err
}
return txn.Set([]byte(key), data)
}
return err // Return any other error as-is
},
)
return isFirstTime, err
}

View File

@@ -8,7 +8,7 @@ import (
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/text"
utils "next.orly.dev/pkg/utils"
"next.orly.dev/pkg/utils"
)
// The tag position meanings, so they are clear when reading.

View File

@@ -166,3 +166,11 @@ func (s *S) GetAll(t []byte) (all []*T) {
}
return
}
func (s *S) GetTagElement(i int) (t *T) {
if s == nil || len(*s) < i {
return
}
t = (*s)[i]
return
}

View File

@@ -0,0 +1,56 @@
# NWC Client
Nostr Wallet Connect (NIP-47) client implementation.
## Usage
```go
import "orly.dev/pkg/protocol/nwc"
// Create client from NWC connection URI
client, err := nwc.NewClient("nostr+walletconnect://...")
if err != nil {
log.Fatal(err)
}
// Make requests
var info map[string]any
err = client.Request(ctx, "get_info", nil, &info)
var balance map[string]any
err = client.Request(ctx, "get_balance", nil, &balance)
var invoice map[string]any
params := map[string]any{"amount": 1000, "description": "test"}
err = client.Request(ctx, "make_invoice", params, &invoice)
```
## Methods
- `get_info` - Get wallet info
- `get_balance` - Get wallet balance
- `make_invoice` - Create 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

265
pkg/protocol/nwc/client.go Normal file
View File

@@ -0,0 +1,265 @@
package nwc
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/crypto/encryption"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"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/interfaces/signer"
"next.orly.dev/pkg/protocol/ws"
"next.orly.dev/pkg/utils/values"
)
type Client struct {
relay string
clientSecretKey signer.I
walletPublicKey []byte
conversationKey []byte
}
func NewClient(connectionURI string) (cl *Client, err error) {
var parts *ConnectionParams
if parts, err = ParseConnectionURI(connectionURI); chk.E(err) {
return
}
cl = &Client{
relay: parts.relay,
clientSecretKey: parts.clientSecretKey,
walletPublicKey: parts.walletPublicKey,
conversationKey: parts.conversationKey,
}
return
}
func (cl *Client) Request(
c context.Context, method string, params, result any,
) (err error) {
ctx, cancel := context.WithTimeout(c, 10*time.Second)
defer cancel()
request := map[string]any{"method": method}
if params != nil {
request["params"] = params
}
var req []byte
if req, err = json.Marshal(request); chk.E(err) {
return
}
var content []byte
if content, err = encryption.Encrypt(req, cl.conversationKey); chk.E(err) {
return
}
ev := &event.E{
Content: content,
CreatedAt: time.Now().Unix(),
Kind: 23194,
Tags: tag.NewS(
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("p", hex.Enc(cl.walletPublicKey)),
),
}
if err = ev.Sign(cl.clientSecretKey); chk.E(err) {
return
}
var rc *ws.Client
if rc, err = ws.RelayConnect(ctx, cl.relay); chk.E(err) {
return
}
defer rc.Close()
var sub *ws.Subscription
if sub, err = rc.Subscribe(
ctx, filter.NewS(
&filter.F{
Limit: values.ToUintPointer(1),
Kinds: kind.NewS(kind.New(23195)),
Since: &timestamp.T{V: time.Now().Unix()},
},
),
); chk.E(err) {
return
}
defer sub.Unsub()
if err = rc.Publish(ctx, ev); chk.E(err) {
return fmt.Errorf("publish failed: %w", err)
}
select {
case <-ctx.Done():
return fmt.Errorf("no response from wallet (connection may be inactive)")
case e := <-sub.Events:
if e == nil {
return fmt.Errorf("subscription closed (wallet connection inactive)")
}
if len(e.Content) == 0 {
return fmt.Errorf("empty response content")
}
var raw []byte
if raw, err = encryption.Decrypt(
e.Content, cl.conversationKey,
); chk.E(err) {
return fmt.Errorf(
"decryption failed (invalid conversation key): %w", err,
)
}
var resp map[string]any
if err = json.Unmarshal(raw, &resp); chk.E(err) {
return
}
if errData, ok := resp["error"].(map[string]any); ok {
code, _ := errData["code"].(string)
msg, _ := errData["message"].(string)
return fmt.Errorf("%s: %s", code, msg)
}
if result != nil && resp["result"] != nil {
var resultBytes []byte
if resultBytes, err = json.Marshal(resp["result"]); chk.E(err) {
return
}
if err = json.Unmarshal(resultBytes, result); chk.E(err) {
return
}
}
}
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.Context, handler NotificationHandler,
) (err error) {
delay := time.Second
for {
if err = cl.subscribeNotificationsOnce(c, handler); err != nil {
if errors.Is(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.Context, 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, filter.NewS(
&filter.F{
Kinds: kind.NewS(kind.New(23197), kind.New(23196)),
Tags: tag.NewS(
tag.NewFromAny("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

@@ -0,0 +1,188 @@
package nwc_test
import (
"encoding/json"
"testing"
"time"
"next.orly.dev/pkg/crypto/encryption"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nwc"
"next.orly.dev/pkg/utils"
)
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")
}
}
// 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,
)
}
// Test passed
}
func TestNWCEventCreation(t *testing.T) {
secretBytes, err := hex.Dec("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
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,
CreatedAt: time.Now().Unix(),
Kind: 23194,
Tags: tag.NewS(
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("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
for i := 0; i < ev.Tags.Len(); i++ {
tag := ev.Tags.GetTagElement(i)
if tag.Len() >= 2 {
if utils.FastEqual(
tag.T[0], "encryption",
) && utils.FastEqual(tag.T[1], "nip44_v2") {
hasEncryption = true
}
if utils.FastEqual(
tag.T[0], "p",
) && utils.FastEqual(tag.T[1], hex.Enc(walletPubkey)) {
hasP = true
}
}
}
if !hasEncryption {
t.Fatal("event missing encryption tag")
}
if !hasP {
t.Fatal("event missing p tag")
}
// Test passed
}

View File

@@ -0,0 +1,495 @@
package nwc
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"sync"
"time"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/crypto/encryption"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"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/interfaces/signer"
"next.orly.dev/pkg/protocol/ws"
)
// 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.Context
cancel context.CancelFunc
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.WithCancel(context.Background())
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: time.Now().Unix(),
Kind: 13194,
Tags: tag.NewS(),
}
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, filter.NewS(
&filter.F{
Kinds: kind.NewS(kind.New(23194)),
Tags: tag.NewS(
tag.NewFromAny("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: time.Now().Unix(),
Kind: 23195,
Tags: tag.NewS(
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("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: time.Now().Unix(),
Kind: 23197,
Tags: tag.NewS(
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("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

@@ -0,0 +1,176 @@
package nwc_test
import (
"context"
"testing"
"time"
"next.orly.dev/pkg/protocol/nwc"
"next.orly.dev/pkg/protocol/ws"
)
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")
}
}
func TestNWCInvalidURI(t *testing.T) {
invalidURIs := []string{
"invalid://test",
"nostr+walletconnect://",
"nostr+walletconnect://invalid",
"nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b",
"nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=invalid",
}
for _, uri := range invalidURIs {
_, err := nwc.NewClient(uri)
if err == nil {
t.Fatalf("expected error for invalid URI: %s", uri)
}
}
}
func TestNWCRelayConnection(t *testing.T) {
ctx, cancel := context.WithTimeout(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.WithTimeout(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("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) {
errorFound = true
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))))
}
func findInString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
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
// check conversation key generation
if c == nil {
t.Fatal("client creation should succeed with valid URI")
}
// 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 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.WithTimeout(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 NWC event
if err == nil {
t.Log("wallet responded")
return
}
// Verify it failed for the right reason (connection/response issue, not formatting)
validFailures := []string{
"subscription closed",
"no response from wallet",
"context deadline exceeded",
"timeout waiting for response",
}
validFailure := false
for _, failure := range validFailures {
if contains(err.Error(), failure) {
validFailure = true
break
}
}
if !validFailure {
t.Fatalf("unexpected error type (suggests formatting issue): %v", err)
}
// Test passed
}

81
pkg/protocol/nwc/uri.go Normal file
View File

@@ -0,0 +1,81 @@
package nwc
import (
"errors"
"net/url"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/crypto/encryption"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/interfaces/signer"
)
type ConnectionParams struct {
clientSecretKey signer.I
walletPublicKey []byte
conversationKey []byte
relay string
}
// GetWalletPublicKey returns the wallet public key from the ConnectionParams.
func (c *ConnectionParams) GetWalletPublicKey() []byte {
return c.walletPublicKey
}
// GetConversationKey returns the conversation key from the ConnectionParams.
func (c *ConnectionParams) GetConversationKey() []byte {
return c.conversationKey
}
func ParseConnectionURI(nwcUri string) (parts *ConnectionParams, err error) {
var p *url.URL
if p, err = url.Parse(nwcUri); chk.E(err) {
return
}
if p == nil {
err = errors.New("invalid uri")
return
}
parts = &ConnectionParams{}
if p.Scheme != "nostr+walletconnect" {
err = errors.New("incorrect scheme")
return
}
if parts.walletPublicKey, err = p256k.HexToBin(p.Host); chk.E(err) {
err = errors.New("invalid public key")
return
}
query := p.Query()
var ok bool
var relay []string
if relay, ok = query["relay"]; !ok {
err = errors.New("missing relay parameter")
return
}
if len(relay) == 0 {
return nil, errors.New("no relays")
}
parts.relay = relay[0]
var secret string
if secret = query.Get("secret"); secret == "" {
err = errors.New("missing secret parameter")
return
}
var secretBytes []byte
if secretBytes, err = p256k.HexToBin(secret); chk.E(err) {
err = errors.New("invalid secret")
return
}
clientKey := &p256k.Signer{}
if err = clientKey.InitSec(secretBytes); chk.E(err) {
return
}
parts.clientSecretKey = clientKey
if parts.conversationKey, err = encryption.GenerateConversationKeyWithSigner(
clientKey,
parts.walletPublicKey,
); chk.E(err) {
return
}
return
}

View File

@@ -1 +1 @@
v0.6.3
v0.8.3