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: &timestamp.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