client.go raw

   1  package nwc
   2  
   3  import (
   4  	"context"
   5  	"encoding/json"
   6  	"errors"
   7  	"fmt"
   8  	"time"
   9  
  10  	"next.orly.dev/pkg/lol/chk"
  11  	"next.orly.dev/pkg/lol/log"
  12  	"next.orly.dev/pkg/nostr/crypto/encryption"
  13  	"next.orly.dev/pkg/nostr/encoders/event"
  14  	"next.orly.dev/pkg/nostr/encoders/filter"
  15  	"next.orly.dev/pkg/nostr/encoders/hex"
  16  	"next.orly.dev/pkg/nostr/encoders/kind"
  17  	"next.orly.dev/pkg/nostr/encoders/tag"
  18  	"next.orly.dev/pkg/nostr/encoders/timestamp"
  19  	"next.orly.dev/pkg/nostr/interfaces/signer"
  20  	"next.orly.dev/pkg/nostr/ws"
  21  )
  22  
  23  type Client struct {
  24  	relay           string
  25  	clientSecretKey signer.I
  26  	walletPublicKey []byte
  27  	conversationKey []byte
  28  }
  29  
  30  func NewClient(connectionURI string) (cl *Client, err error) {
  31  	var parts *ConnectionParams
  32  	if parts, err = ParseConnectionURI(connectionURI); chk.E(err) {
  33  		return
  34  	}
  35  	cl = &Client{
  36  		relay:           parts.relay,
  37  		clientSecretKey: parts.clientSecretKey,
  38  		walletPublicKey: parts.walletPublicKey,
  39  		conversationKey: parts.conversationKey,
  40  	}
  41  	return
  42  }
  43  
  44  func (cl *Client) Request(
  45  	c context.Context, method string, params, result any,
  46  ) (err error) {
  47  	delay := time.Second
  48  	const maxRetries = 3
  49  	for attempt := 0; attempt < maxRetries; attempt++ {
  50  		if attempt > 0 {
  51  			log.D.F("NWC request %s retry %d/%d (delay %v)",
  52  				method, attempt, maxRetries-1, delay)
  53  			select {
  54  			case <-time.After(delay):
  55  				delay *= 2
  56  			case <-c.Done():
  57  				return c.Err()
  58  			}
  59  		}
  60  		err = cl.requestOnce(c, method, params, result)
  61  		if err == nil {
  62  			return nil
  63  		}
  64  		log.W.F("NWC request %s attempt %d failed: %v", method, attempt+1, err)
  65  	}
  66  	return
  67  }
  68  
  69  func (cl *Client) requestOnce(
  70  	c context.Context, method string, params, result any,
  71  ) (err error) {
  72  	ctx, cancel := context.WithTimeout(c, 30*time.Second)
  73  	defer cancel()
  74  
  75  	request := map[string]any{"method": method}
  76  	if params != nil {
  77  		request["params"] = params
  78  	}
  79  
  80  	var req []byte
  81  	if req, err = json.Marshal(request); chk.E(err) {
  82  		return
  83  	}
  84  
  85  	var content string
  86  	if content, err = encryption.Encrypt(cl.conversationKey, req, nil); chk.E(err) {
  87  		return
  88  	}
  89  
  90  	ev := &event.E{
  91  		Content:   []byte(content),
  92  		CreatedAt: time.Now().Unix(),
  93  		Kind:      23194,
  94  		Tags: tag.NewS(
  95  			tag.NewFromAny("encryption", "nip44_v2"),
  96  			tag.NewFromAny("p", hex.Enc(cl.walletPublicKey)),
  97  		),
  98  	}
  99  
 100  	if err = ev.Sign(cl.clientSecretKey); chk.E(err) {
 101  		return
 102  	}
 103  
 104  	var rc *ws.Client
 105  	if rc, err = ws.RelayConnect(ctx, cl.relay); chk.E(err) {
 106  		return
 107  	}
 108  	defer rc.Close()
 109  
 110  	// Filter must include authors (wallet pubkey) and #p tag (our client
 111  	// pubkey) — NWC relays like Alby require this and will CLOSE the
 112  	// subscription without it. Use Since 5 seconds ago to avoid missing
 113  	// fast responses.
 114  	since := time.Now().Unix() - 5
 115  	var sub *ws.Subscription
 116  	if sub, err = rc.Subscribe(
 117  		ctx, filter.NewS(
 118  			&filter.F{
 119  				Kinds:   kind.NewS(kind.New(23195)),
 120  				Authors: tag.NewFromAny(cl.walletPublicKey),
 121  				Tags: tag.NewS(
 122  					tag.NewFromAny("p", hex.Enc(cl.clientSecretKey.Pub())),
 123  				),
 124  				Since: &timestamp.T{V: since},
 125  			},
 126  		),
 127  	); chk.E(err) {
 128  		return
 129  	}
 130  	defer sub.Unsub()
 131  
 132  	if err = rc.Publish(ctx, ev); chk.E(err) {
 133  		return fmt.Errorf("publish failed: %w", err)
 134  	}
 135  
 136  	select {
 137  	case <-ctx.Done():
 138  		return fmt.Errorf("no response from wallet (connection may be inactive)")
 139  	case reason := <-sub.ClosedReason:
 140  		return fmt.Errorf("relay closed subscription: %s", reason)
 141  	case e := <-sub.Events:
 142  		if e == nil {
 143  			return fmt.Errorf("subscription closed (wallet connection inactive)")
 144  		}
 145  		if len(e.Content) == 0 {
 146  			return fmt.Errorf("empty response content")
 147  		}
 148  		var raw string
 149  		if raw, err = encryption.Decrypt(
 150  			cl.conversationKey, string(e.Content),
 151  		); chk.E(err) {
 152  			return fmt.Errorf(
 153  				"decryption failed (invalid conversation key): %w", err,
 154  			)
 155  		}
 156  
 157  		var resp map[string]any
 158  		if err = json.Unmarshal([]byte(raw), &resp); chk.E(err) {
 159  			return
 160  		}
 161  
 162  		if errData, ok := resp["error"].(map[string]any); ok {
 163  			code, _ := errData["code"].(string)
 164  			msg, _ := errData["message"].(string)
 165  			return fmt.Errorf("%s: %s", code, msg)
 166  		}
 167  
 168  		if result != nil && resp["result"] != nil {
 169  			var resultBytes []byte
 170  			if resultBytes, err = json.Marshal(resp["result"]); chk.E(err) {
 171  				return
 172  			}
 173  			if err = json.Unmarshal(resultBytes, result); chk.E(err) {
 174  				return
 175  			}
 176  		}
 177  	}
 178  
 179  	return
 180  }
 181  
 182  // NotificationHandler is a callback for handling NWC notifications
 183  type NotificationHandler func(
 184  	notificationType string, notification map[string]any,
 185  ) error
 186  
 187  // SubscribeNotifications subscribes to NWC notification events (kinds 23197/23196)
 188  // and handles them with the provided callback. It maintains a persistent connection
 189  // with auto-reconnection on disconnect.
 190  func (cl *Client) SubscribeNotifications(
 191  	c context.Context, handler NotificationHandler,
 192  ) (err error) {
 193  	delay := time.Second
 194  	for {
 195  		if err = cl.subscribeNotificationsOnce(c, handler); err != nil {
 196  			if errors.Is(err, context.Canceled) {
 197  				return err
 198  			}
 199  			select {
 200  			case <-time.After(delay):
 201  				if delay < 30*time.Second {
 202  					delay *= 2
 203  				}
 204  			case <-c.Done():
 205  				return context.Canceled
 206  			}
 207  			continue
 208  		}
 209  		delay = time.Second
 210  	}
 211  }
 212  
 213  // subscribeNotificationsOnce performs a single subscription attempt
 214  func (cl *Client) subscribeNotificationsOnce(
 215  	c context.Context, handler NotificationHandler,
 216  ) (err error) {
 217  	// Connect to relay
 218  	var rc *ws.Client
 219  	if rc, err = ws.RelayConnect(c, cl.relay); chk.E(err) {
 220  		return fmt.Errorf("relay connection failed: %w", err)
 221  	}
 222  	defer rc.Close()
 223  
 224  	// Subscribe to notification events. Filter must include authors (wallet
 225  	// pubkey) and #p tag (our client pubkey) — NWC relays require this.
 226  	var sub *ws.Subscription
 227  	if sub, err = rc.Subscribe(
 228  		c, filter.NewS(
 229  			&filter.F{
 230  				Kinds:   kind.NewS(kind.New(23197), kind.New(23196)),
 231  				Authors: tag.NewFromAny(cl.walletPublicKey),
 232  				Tags: tag.NewS(
 233  					tag.NewFromAny("p", hex.Enc(cl.clientSecretKey.Pub())),
 234  				),
 235  				Since: &timestamp.T{V: time.Now().Unix() - 5},
 236  			},
 237  		),
 238  	); chk.E(err) {
 239  		return fmt.Errorf("subscription failed: %w", err)
 240  	}
 241  	defer sub.Unsub()
 242  
 243  	log.D.F(
 244  		"subscribed to NWC notifications from wallet %s",
 245  		hex.Enc(cl.walletPublicKey),
 246  	)
 247  
 248  	// Process notification events
 249  	for {
 250  		select {
 251  		case <-c.Done():
 252  			return context.Canceled
 253  		case ev := <-sub.Events:
 254  			if ev == nil {
 255  				// Channel closed, subscription ended
 256  				return fmt.Errorf("subscription closed")
 257  			}
 258  
 259  			// Process the notification event
 260  			if err := cl.processNotificationEvent(ev, handler); err != nil {
 261  				log.E.F("error processing notification: %v", err)
 262  				// Continue processing other notifications even if one fails
 263  			}
 264  		}
 265  	}
 266  }
 267  
 268  // processNotificationEvent decrypts and processes a single notification event
 269  func (cl *Client) processNotificationEvent(
 270  	ev *event.E, handler NotificationHandler,
 271  ) (err error) {
 272  	// Decrypt the notification content
 273  	var decrypted string
 274  	if decrypted, err = encryption.Decrypt(
 275  		cl.conversationKey, string(ev.Content),
 276  	); err != nil {
 277  		return fmt.Errorf("failed to decrypt notification: %w", err)
 278  	}
 279  
 280  	// Parse the notification JSON
 281  	var notification map[string]any
 282  	if err = json.Unmarshal([]byte(decrypted), &notification); err != nil {
 283  		return fmt.Errorf("failed to parse notification JSON: %w", err)
 284  	}
 285  
 286  	// Extract notification type
 287  	notificationType, ok := notification["notification_type"].(string)
 288  	if !ok {
 289  		return fmt.Errorf("missing or invalid notification_type")
 290  	}
 291  
 292  	// Extract notification data
 293  	notificationData, ok := notification["notification"].(map[string]any)
 294  	if !ok {
 295  		return fmt.Errorf("missing or invalid notification data")
 296  	}
 297  
 298  	// Route to type-specific handler
 299  	return handler(notificationType, notificationData)
 300  }
 301