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: ×tamp.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: ×tamp.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), ¬ification); 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