mock_wallet_service.go raw
1 package nwc
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/json"
7 "fmt"
8 "sync"
9 "time"
10
11 "next.orly.dev/pkg/lol/chk"
12 "next.orly.dev/pkg/nostr/crypto/encryption"
13 "next.orly.dev/pkg/nostr/interfaces/signer/p8k"
14 "next.orly.dev/pkg/nostr/encoders/event"
15 "next.orly.dev/pkg/nostr/encoders/filter"
16 "next.orly.dev/pkg/nostr/encoders/hex"
17 "next.orly.dev/pkg/nostr/encoders/kind"
18 "next.orly.dev/pkg/nostr/encoders/tag"
19 "next.orly.dev/pkg/nostr/encoders/timestamp"
20 "next.orly.dev/pkg/nostr/interfaces/signer"
21 "next.orly.dev/pkg/nostr/ws"
22 )
23
24 // MockWalletService implements a mock NIP-47 wallet service for testing
25 type MockWalletService struct {
26 relay string
27 walletSecretKey signer.I
28 walletPublicKey []byte
29 client *ws.Client
30 ctx context.Context
31 cancel context.CancelFunc
32 balance int64 // in satoshis
33 balanceMutex sync.RWMutex
34 connectedClients map[string][]byte // pubkey -> conversation key
35 clientsMutex sync.RWMutex
36 }
37
38 // NewMockWalletService creates a new mock wallet service
39 func NewMockWalletService(
40 relay string, initialBalance int64,
41 ) (service *MockWalletService, err error) {
42 // Generate wallet keypair
43 var walletKey *p8k.Signer
44 if walletKey, err = p8k.New(); chk.E(err) {
45 return
46 }
47 if err = walletKey.Generate(); chk.E(err) {
48 return
49 }
50
51 ctx, cancel := context.WithCancel(context.Background())
52
53 service = &MockWalletService{
54 relay: relay,
55 walletSecretKey: walletKey,
56 walletPublicKey: walletKey.Pub(),
57 ctx: ctx,
58 cancel: cancel,
59 balance: initialBalance,
60 connectedClients: make(map[string][]byte),
61 }
62 return
63 }
64
65 // Start begins the mock wallet service
66 func (m *MockWalletService) Start() (err error) {
67 // Connect to relay
68 if m.client, err = ws.RelayConnect(m.ctx, m.relay); chk.E(err) {
69 return fmt.Errorf("failed to connect to relay: %w", err)
70 }
71
72 // Publish wallet info event
73 if err = m.publishWalletInfo(); chk.E(err) {
74 return fmt.Errorf("failed to publish wallet info: %w", err)
75 }
76
77 // Subscribe to request events
78 if err = m.subscribeToRequests(); chk.E(err) {
79 return fmt.Errorf("failed to subscribe to requests: %w", err)
80 }
81
82 return
83 }
84
85 // Stop stops the mock wallet service
86 func (m *MockWalletService) Stop() {
87 if m.cancel != nil {
88 m.cancel()
89 }
90 if m.client != nil {
91 m.client.Close()
92 }
93 }
94
95 // GetWalletPublicKey returns the wallet's public key
96 func (m *MockWalletService) GetWalletPublicKey() []byte {
97 return m.walletPublicKey
98 }
99
100 // publishWalletInfo publishes the NIP-47 info event (kind 13194)
101 func (m *MockWalletService) publishWalletInfo() (err error) {
102 capabilities := []string{
103 "get_info",
104 "get_balance",
105 "make_invoice",
106 "pay_invoice",
107 }
108
109 info := map[string]any{
110 "capabilities": capabilities,
111 "notifications": []string{"payment_received", "payment_sent"},
112 }
113
114 var content []byte
115 if content, err = json.Marshal(info); chk.E(err) {
116 return
117 }
118
119 ev := &event.E{
120 Content: content,
121 CreatedAt: time.Now().Unix(),
122 Kind: 13194,
123 Tags: tag.NewS(),
124 }
125
126 if err = ev.Sign(m.walletSecretKey); chk.E(err) {
127 return
128 }
129
130 return m.client.Publish(m.ctx, ev)
131 }
132
133 // subscribeToRequests subscribes to NWC request events (kind 23194)
134 func (m *MockWalletService) subscribeToRequests() (err error) {
135 var sub *ws.Subscription
136 if sub, err = m.client.Subscribe(
137 m.ctx, filter.NewS(
138 &filter.F{
139 Kinds: kind.NewS(kind.New(23194)),
140 Tags: tag.NewS(
141 tag.NewFromAny("p", hex.Enc(m.walletPublicKey)),
142 ),
143 Since: ×tamp.T{V: time.Now().Unix()},
144 },
145 ),
146 ); chk.E(err) {
147 return
148 }
149
150 // Handle incoming request events
151 go m.handleRequestEvents(sub)
152 return
153 }
154
155 // handleRequestEvents processes incoming NWC request events
156 func (m *MockWalletService) handleRequestEvents(sub *ws.Subscription) {
157 for {
158 select {
159 case <-m.ctx.Done():
160 return
161 case ev := <-sub.Events:
162 if ev == nil {
163 continue
164 }
165 if err := m.processRequestEvent(ev); chk.E(err) {
166 fmt.Printf("Error processing request event: %v\n", err)
167 }
168 }
169 }
170 }
171
172 // processRequestEvent processes a single NWC request event
173 func (m *MockWalletService) processRequestEvent(ev *event.E) (err error) {
174 // Get client pubkey from event
175 clientPubkey := ev.Pubkey
176 clientPubkeyHex := hex.Enc(clientPubkey)
177
178 // Generate or get conversation key
179 var conversationKey []byte
180 m.clientsMutex.Lock()
181 if existingKey, exists := m.connectedClients[clientPubkeyHex]; exists {
182 conversationKey = existingKey
183 } else {
184 // Generate conversation key using the wallet's secret key and client's public key
185 if conversationKey, err = encryption.GenerateConversationKey(
186 m.walletSecretKey.Sec(), clientPubkey,
187 ); chk.E(err) {
188 m.clientsMutex.Unlock()
189 return
190 }
191 m.connectedClients[clientPubkeyHex] = conversationKey
192 }
193 m.clientsMutex.Unlock()
194
195 // Decrypt request content
196 var decrypted string
197 if decrypted, err = encryption.Decrypt(
198 conversationKey, string(ev.Content),
199 ); chk.E(err) {
200 return
201 }
202
203 var request map[string]any
204 if err = json.Unmarshal([]byte(decrypted), &request); chk.E(err) {
205 return
206 }
207
208 method, ok := request["method"].(string)
209 if !ok {
210 return fmt.Errorf("invalid method")
211 }
212
213 params := request["params"]
214
215 // Process the method
216 var result any
217 if result, err = m.processMethod(method, params); chk.E(err) {
218 // Send error response
219 return m.sendErrorResponse(
220 clientPubkey, conversationKey, "INTERNAL", err.Error(),
221 )
222 }
223
224 // Send success response
225 return m.sendSuccessResponse(clientPubkey, conversationKey, result)
226 }
227
228 // processMethod handles the actual NWC method execution
229 func (m *MockWalletService) processMethod(
230 method string, params any,
231 ) (result any, err error) {
232 switch method {
233 case "get_info":
234 return m.getInfo()
235 case "get_balance":
236 return m.getBalance()
237 case "make_invoice":
238 return m.makeInvoice(params)
239 case "pay_invoice":
240 return m.payInvoice(params)
241 default:
242 err = fmt.Errorf("unsupported method: %s", method)
243 return
244 }
245 }
246
247 // getInfo returns wallet information
248 func (m *MockWalletService) getInfo() (result map[string]any, err error) {
249 result = map[string]any{
250 "alias": "Mock Wallet",
251 "color": "#3399FF",
252 "pubkey": hex.Enc(m.walletPublicKey),
253 "network": "mainnet",
254 "block_height": 850000,
255 "block_hash": "0000000000000000000123456789abcdef",
256 "methods": []string{
257 "get_info", "get_balance", "make_invoice", "pay_invoice",
258 },
259 }
260 return
261 }
262
263 // getBalance returns the current wallet balance
264 func (m *MockWalletService) getBalance() (result map[string]any, err error) {
265 m.balanceMutex.RLock()
266 balance := m.balance
267 m.balanceMutex.RUnlock()
268
269 result = map[string]any{
270 "balance": balance * 1000, // convert to msats
271 }
272 return
273 }
274
275 // makeInvoice creates a Lightning invoice
276 func (m *MockWalletService) makeInvoice(params any) (
277 result map[string]any, err error,
278 ) {
279 paramsMap, ok := params.(map[string]any)
280 if !ok {
281 err = fmt.Errorf("invalid params")
282 return
283 }
284
285 amount, ok := paramsMap["amount"].(float64)
286 if !ok {
287 err = fmt.Errorf("missing or invalid amount")
288 return
289 }
290
291 description := ""
292 if desc, ok := paramsMap["description"].(string); ok {
293 description = desc
294 }
295
296 paymentHash := make([]byte, 32)
297 rand.Read(paymentHash)
298
299 // Generate a fake bolt11 invoice
300 bolt11 := fmt.Sprintf("lnbc%dm1pwxxxxxxx", int64(amount/1000))
301
302 result = map[string]any{
303 "type": "incoming",
304 "invoice": bolt11,
305 "description": description,
306 "payment_hash": hex.Enc(paymentHash),
307 "amount": int64(amount),
308 "created_at": time.Now().Unix(),
309 "expires_at": time.Now().Add(24 * time.Hour).Unix(),
310 }
311 return
312 }
313
314 // payInvoice pays a Lightning invoice
315 func (m *MockWalletService) payInvoice(params any) (
316 result map[string]any, err error,
317 ) {
318 paramsMap, ok := params.(map[string]any)
319 if !ok {
320 err = fmt.Errorf("invalid params")
321 return
322 }
323
324 invoice, ok := paramsMap["invoice"].(string)
325 if !ok {
326 err = fmt.Errorf("missing or invalid invoice")
327 return
328 }
329
330 // Mock payment amount (would parse from invoice in real implementation)
331 amount := int64(1000) // 1000 msats
332
333 // Check balance
334 m.balanceMutex.Lock()
335 if m.balance*1000 < amount {
336 m.balanceMutex.Unlock()
337 err = fmt.Errorf("insufficient balance")
338 return
339 }
340 m.balance -= amount / 1000
341 m.balanceMutex.Unlock()
342
343 preimage := make([]byte, 32)
344 rand.Read(preimage)
345
346 result = map[string]any{
347 "type": "outgoing",
348 "invoice": invoice,
349 "amount": amount,
350 "preimage": hex.Enc(preimage),
351 "created_at": time.Now().Unix(),
352 }
353
354 // Emit payment_sent notification
355 go m.emitPaymentNotification("payment_sent", result)
356 return
357 }
358
359 // sendSuccessResponse sends a successful NWC response
360 func (m *MockWalletService) sendSuccessResponse(
361 clientPubkey []byte, conversationKey []byte, result any,
362 ) (err error) {
363 response := map[string]any{
364 "result": result,
365 }
366
367 var responseBytes []byte
368 if responseBytes, err = json.Marshal(response); chk.E(err) {
369 return
370 }
371
372 return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
373 }
374
375 // sendErrorResponse sends an error NWC response
376 func (m *MockWalletService) sendErrorResponse(
377 clientPubkey []byte, conversationKey []byte, code, message string,
378 ) (err error) {
379 response := map[string]any{
380 "error": map[string]any{
381 "code": code,
382 "message": message,
383 },
384 }
385
386 var responseBytes []byte
387 if responseBytes, err = json.Marshal(response); chk.E(err) {
388 return
389 }
390
391 return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
392 }
393
394 // sendEncryptedResponse sends an encrypted response event (kind 23195)
395 func (m *MockWalletService) sendEncryptedResponse(
396 clientPubkey []byte, conversationKey []byte, content []byte,
397 ) (err error) {
398 var encrypted string
399 if encrypted, err = encryption.Encrypt(
400 conversationKey, content, nil,
401 ); chk.E(err) {
402 return
403 }
404
405 ev := &event.E{
406 Content: []byte(encrypted),
407 CreatedAt: time.Now().Unix(),
408 Kind: 23195,
409 Tags: tag.NewS(
410 tag.NewFromAny("encryption", "nip44_v2"),
411 tag.NewFromAny("p", hex.Enc(clientPubkey)),
412 ),
413 }
414
415 if err = ev.Sign(m.walletSecretKey); chk.E(err) {
416 return
417 }
418
419 return m.client.Publish(m.ctx, ev)
420 }
421
422 // emitPaymentNotification emits a payment notification (kind 23197)
423 func (m *MockWalletService) emitPaymentNotification(
424 notificationType string, paymentData map[string]any,
425 ) (err error) {
426 notification := map[string]any{
427 "notification_type": notificationType,
428 "notification": paymentData,
429 }
430
431 var content []byte
432 if content, err = json.Marshal(notification); chk.E(err) {
433 return
434 }
435
436 // Send notification to all connected clients
437 m.clientsMutex.RLock()
438 defer m.clientsMutex.RUnlock()
439
440 for clientPubkeyHex, conversationKey := range m.connectedClients {
441 var clientPubkey []byte
442 if clientPubkey, err = hex.Dec(clientPubkeyHex); chk.E(err) {
443 continue
444 }
445
446 var encrypted string
447 if encrypted, err = encryption.Encrypt(
448 conversationKey, content, nil,
449 ); chk.E(err) {
450 continue
451 }
452
453 ev := &event.E{
454 Content: []byte(encrypted),
455 CreatedAt: time.Now().Unix(),
456 Kind: 23197,
457 Tags: tag.NewS(
458 tag.NewFromAny("encryption", "nip44_v2"),
459 tag.NewFromAny("p", hex.Enc(clientPubkey)),
460 ),
461 }
462
463 if err = ev.Sign(m.walletSecretKey); chk.E(err) {
464 continue
465 }
466
467 m.client.Publish(m.ctx, ev)
468 }
469 return
470 }
471
472 // SimulateIncomingPayment simulates an incoming payment for testing
473 func (m *MockWalletService) SimulateIncomingPayment(
474 pubkey []byte, amount int64, description string,
475 ) (err error) {
476 // Add to balance
477 m.balanceMutex.Lock()
478 m.balance += amount / 1000 // convert msats to sats
479 m.balanceMutex.Unlock()
480
481 paymentHash := make([]byte, 32)
482 rand.Read(paymentHash)
483
484 preimage := make([]byte, 32)
485 rand.Read(preimage)
486
487 paymentData := map[string]any{
488 "type": "incoming",
489 "invoice": fmt.Sprintf("lnbc%dm1pwxxxxxxx", amount/1000),
490 "description": description,
491 "amount": amount,
492 "payment_hash": hex.Enc(paymentHash),
493 "preimage": hex.Enc(preimage),
494 "created_at": time.Now().Unix(),
495 }
496
497 // Emit payment_received notification
498 return m.emitPaymentNotification("payment_received", paymentData)
499 }
500