Add NWC protocol handling and NIP-44 encryption and decryption functions.
This commit is contained in:
265
pkg/protocol/nwc/client.go
Normal file
265
pkg/protocol/nwc/client.go
Normal 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: ×tamp.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: ×tamp.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, ¬ification); 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)
|
||||
}
|
||||
Reference in New Issue
Block a user