Add NWC protocol handling and NIP-44 encryption and decryption functions.
This commit is contained in:
1
pkg/crypto/encryption/README.md
Normal file
1
pkg/crypto/encryption/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Code copied from https://github.com/paulmillr/nip44/tree/e7aed61aaf77240ac10c325683eed14b22e7950f/go.
|
||||
3
pkg/crypto/encryption/doc.go
Normal file
3
pkg/crypto/encryption/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package encryption contains the message encryption schemes defined in NIP-04
|
||||
// and NIP-44, used for encrypting the content of nostr messages.
|
||||
package encryption
|
||||
88
pkg/crypto/encryption/nip4.go
Normal file
88
pkg/crypto/encryption/nip4.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"lukechampine.com/frand"
|
||||
)
|
||||
|
||||
// EncryptNip4 encrypts message with key using aes-256-cbc. key should be the shared secret generated by
|
||||
// ComputeSharedSecret.
|
||||
//
|
||||
// Returns: base64(encrypted_bytes) + "?iv=" + base64(initialization_vector).
|
||||
func EncryptNip4(msg, key []byte) (ct []byte, err error) {
|
||||
// block size is 16 bytes
|
||||
iv := make([]byte, 16)
|
||||
if _, err = frand.Read(iv); chk.E(err) {
|
||||
err = errorf.E("error creating initialization vector: %w", err)
|
||||
return
|
||||
}
|
||||
// automatically picks aes-256 based on key length (32 bytes)
|
||||
var block cipher.Block
|
||||
if block, err = aes.NewCipher(key); chk.E(err) {
|
||||
err = errorf.E("error creating block cipher: %w", err)
|
||||
return
|
||||
}
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
plaintext := []byte(msg)
|
||||
// add padding
|
||||
base := len(plaintext)
|
||||
// this will be a number between 1 and 16 (inclusive), never 0
|
||||
bs := block.BlockSize()
|
||||
padding := bs - base%bs
|
||||
// encode the padding in all the padding bytes themselves
|
||||
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
paddedMsgBytes := append(plaintext, padText...)
|
||||
ciphertext := make([]byte, len(paddedMsgBytes))
|
||||
mode.CryptBlocks(ciphertext, paddedMsgBytes)
|
||||
return []byte(base64.StdEncoding.EncodeToString(ciphertext) + "?iv=" +
|
||||
base64.StdEncoding.EncodeToString(iv)), nil
|
||||
}
|
||||
|
||||
// DecryptNip4 decrypts a content string using the shared secret key. The inverse operation to message ->
|
||||
// EncryptNip4(message, key).
|
||||
func DecryptNip4(content, key []byte) (msg []byte, err error) {
|
||||
parts := bytes.Split(content, []byte("?iv="))
|
||||
if len(parts) < 2 {
|
||||
return nil, errorf.E(
|
||||
"error parsing encrypted message: no initialization vector",
|
||||
)
|
||||
}
|
||||
ciphertext := make([]byte, base64.StdEncoding.EncodedLen(len(parts[0])))
|
||||
if _, err = base64.StdEncoding.Decode(ciphertext, parts[0]); chk.E(err) {
|
||||
err = errorf.E("error decoding ciphertext from base64: %w", err)
|
||||
return
|
||||
}
|
||||
iv := make([]byte, base64.StdEncoding.EncodedLen(len(parts[1])))
|
||||
if _, err = base64.StdEncoding.Decode(iv, parts[1]); chk.E(err) {
|
||||
err = errorf.E("error decoding iv from base64: %w", err)
|
||||
return
|
||||
}
|
||||
var block cipher.Block
|
||||
if block, err = aes.NewCipher(key); chk.E(err) {
|
||||
err = errorf.E("error creating block cipher: %w", err)
|
||||
return
|
||||
}
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
msg = make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(msg, ciphertext)
|
||||
// remove padding
|
||||
var (
|
||||
plaintextLen = len(msg)
|
||||
)
|
||||
if plaintextLen > 0 {
|
||||
// the padding amount is encoded in the padding bytes themselves
|
||||
padding := int(msg[plaintextLen-1])
|
||||
if padding > plaintextLen {
|
||||
err = errorf.E("invalid padding amount: %d", padding)
|
||||
return
|
||||
}
|
||||
msg = msg[0 : plaintextLen-padding]
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
260
pkg/crypto/encryption/nip44.go
Normal file
260
pkg/crypto/encryption/nip44.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
"golang.org/x/crypto/chacha20"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
"next.orly.dev/pkg/crypto/sha256"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
version byte = 2
|
||||
MinPlaintextSize = 0x0001 // 1b msg => padded to 32b
|
||||
MaxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
|
||||
)
|
||||
|
||||
type Opts struct {
|
||||
err error
|
||||
nonce []byte
|
||||
}
|
||||
|
||||
// Deprecated: use WithCustomNonce instead of WithCustomSalt, so the naming is less confusing
|
||||
var WithCustomSalt = WithCustomNonce
|
||||
|
||||
// WithCustomNonce enables using a custom nonce (salt) instead of using the
|
||||
// system crypto/rand entropy source.
|
||||
func WithCustomNonce(salt []byte) func(opts *Opts) {
|
||||
return func(opts *Opts) {
|
||||
if len(salt) != 32 {
|
||||
opts.err = errorf.E("salt must be 32 bytes, got %d", len(salt))
|
||||
}
|
||||
opts.nonce = salt
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt data using a provided symmetric conversation key using NIP-44
|
||||
// encryption (chacha20 cipher stream and sha256 HMAC).
|
||||
func Encrypt(
|
||||
plaintext, conversationKey []byte, applyOptions ...func(opts *Opts),
|
||||
) (
|
||||
cipherString []byte, err error,
|
||||
) {
|
||||
|
||||
var o Opts
|
||||
for _, apply := range applyOptions {
|
||||
apply(&o)
|
||||
}
|
||||
if chk.E(o.err) {
|
||||
err = o.err
|
||||
return
|
||||
}
|
||||
if o.nonce == nil {
|
||||
o.nonce = make([]byte, 32)
|
||||
if _, err = rand.Read(o.nonce); chk.E(err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
var enc, cc20nonce, auth []byte
|
||||
if enc, cc20nonce, auth, err = getKeys(
|
||||
conversationKey, o.nonce,
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
plain := plaintext
|
||||
size := len(plain)
|
||||
if size < MinPlaintextSize || size > MaxPlaintextSize {
|
||||
err = errorf.E("plaintext should be between 1b and 64kB")
|
||||
return
|
||||
}
|
||||
padding := CalcPadding(size)
|
||||
padded := make([]byte, 2+padding)
|
||||
binary.BigEndian.PutUint16(padded, uint16(size))
|
||||
copy(padded[2:], plain)
|
||||
var cipher []byte
|
||||
if cipher, err = encrypt(enc, cc20nonce, padded); chk.E(err) {
|
||||
return
|
||||
}
|
||||
var mac []byte
|
||||
if mac, err = sha256Hmac(auth, cipher, o.nonce); chk.E(err) {
|
||||
return
|
||||
}
|
||||
ct := make([]byte, 0, 1+32+len(cipher)+32)
|
||||
ct = append(ct, version)
|
||||
ct = append(ct, o.nonce...)
|
||||
ct = append(ct, cipher...)
|
||||
ct = append(ct, mac...)
|
||||
cipherString = make([]byte, base64.StdEncoding.EncodedLen(len(ct)))
|
||||
base64.StdEncoding.Encode(cipherString, ct)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt data that has been encoded using a provided symmetric conversation
|
||||
// key using NIP-44 encryption (chacha20 cipher stream and sha256 HMAC).
|
||||
func Decrypt(b64ciphertextWrapped, conversationKey []byte) (
|
||||
plaintext []byte,
|
||||
err error,
|
||||
) {
|
||||
cLen := len(b64ciphertextWrapped)
|
||||
if cLen < 132 || cLen > 87472 {
|
||||
err = errorf.E("invalid payload length: %d", cLen)
|
||||
return
|
||||
}
|
||||
if len(b64ciphertextWrapped) > 0 && b64ciphertextWrapped[0] == '#' {
|
||||
err = errorf.E("unknown version")
|
||||
return
|
||||
}
|
||||
var decoded []byte
|
||||
if decoded, err = base64.StdEncoding.DecodeString(string(b64ciphertextWrapped)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if decoded[0] != version {
|
||||
err = errorf.E("unknown version %d", decoded[0])
|
||||
return
|
||||
}
|
||||
dLen := len(decoded)
|
||||
if dLen < 99 || dLen > 65603 {
|
||||
err = errorf.E("invalid data length: %d", dLen)
|
||||
return
|
||||
}
|
||||
nonce, ciphertext, givenMac := decoded[1:33], decoded[33:dLen-32], decoded[dLen-32:]
|
||||
var enc, cc20nonce, auth []byte
|
||||
if enc, cc20nonce, auth, err = getKeys(conversationKey, nonce); chk.E(err) {
|
||||
return
|
||||
}
|
||||
var expectedMac []byte
|
||||
if expectedMac, err = sha256Hmac(auth, ciphertext, nonce); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if !utils.FastEqual(givenMac, expectedMac) {
|
||||
err = errorf.E("invalid hmac")
|
||||
return
|
||||
}
|
||||
var padded []byte
|
||||
if padded, err = encrypt(enc, cc20nonce, ciphertext); chk.E(err) {
|
||||
return
|
||||
}
|
||||
unpaddedLen := binary.BigEndian.Uint16(padded[0:2])
|
||||
if unpaddedLen < uint16(MinPlaintextSize) || unpaddedLen > uint16(MaxPlaintextSize) ||
|
||||
len(padded) != 2+CalcPadding(int(unpaddedLen)) {
|
||||
err = errorf.E("invalid padding")
|
||||
return
|
||||
}
|
||||
unpadded := padded[2:][:unpaddedLen]
|
||||
if len(unpadded) == 0 || len(unpadded) != int(unpaddedLen) {
|
||||
err = errorf.E("invalid padding")
|
||||
return
|
||||
}
|
||||
plaintext = unpadded
|
||||
return
|
||||
}
|
||||
|
||||
// GenerateConversationKeyFromHex performs an ECDH key generation hashed with the nip-44-v2 using hkdf.
|
||||
func GenerateConversationKeyFromHex(pkh, skh string) (ck []byte, err error) {
|
||||
if skh >= "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" ||
|
||||
skh == "0000000000000000000000000000000000000000000000000000000000000000" {
|
||||
err = errorf.E(
|
||||
"invalid private key: x coordinate %s is not on the secp256k1 curve",
|
||||
skh,
|
||||
)
|
||||
return
|
||||
}
|
||||
var sign signer.I
|
||||
if sign, err = p256k.NewSecFromHex(skh); chk.E(err) {
|
||||
return
|
||||
}
|
||||
var pk []byte
|
||||
if pk, err = p256k.HexToBin(pkh); chk.E(err) {
|
||||
return
|
||||
}
|
||||
var shared []byte
|
||||
if shared, err = sign.ECDH(pk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
ck = hkdf.Extract(sha256.New, shared, []byte("nip44-v2"))
|
||||
return
|
||||
}
|
||||
|
||||
func GenerateConversationKeyWithSigner(sign signer.I, pk []byte) (
|
||||
ck []byte, err error,
|
||||
) {
|
||||
var shared []byte
|
||||
if shared, err = sign.ECDH(pk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
ck = hkdf.Extract(sha256.New, shared, []byte("nip44-v2"))
|
||||
return
|
||||
}
|
||||
|
||||
func encrypt(key, nonce, message []byte) (dst []byte, err error) {
|
||||
var cipher *chacha20.Cipher
|
||||
if cipher, err = chacha20.NewUnauthenticatedCipher(key, nonce); chk.E(err) {
|
||||
return
|
||||
}
|
||||
dst = make([]byte, len(message))
|
||||
cipher.XORKeyStream(dst, message)
|
||||
return
|
||||
}
|
||||
|
||||
func sha256Hmac(key, ciphertext, nonce []byte) (h []byte, err error) {
|
||||
if len(nonce) != sha256.Size {
|
||||
err = errorf.E("nonce aad must be 32 bytes")
|
||||
return
|
||||
}
|
||||
hm := hmac.New(sha256.New, key)
|
||||
hm.Write(nonce)
|
||||
hm.Write(ciphertext)
|
||||
h = hm.Sum(nil)
|
||||
return
|
||||
}
|
||||
|
||||
func getKeys(conversationKey, nonce []byte) (
|
||||
enc, cc20nonce, auth []byte, err error,
|
||||
) {
|
||||
if len(conversationKey) != 32 {
|
||||
err = errorf.E("conversation key must be 32 bytes")
|
||||
return
|
||||
}
|
||||
if len(nonce) != 32 {
|
||||
err = errorf.E("nonce must be 32 bytes")
|
||||
return
|
||||
}
|
||||
r := hkdf.Expand(sha256.New, conversationKey, nonce)
|
||||
enc = make([]byte, 32)
|
||||
if _, err = io.ReadFull(r, enc); chk.E(err) {
|
||||
return
|
||||
}
|
||||
cc20nonce = make([]byte, 12)
|
||||
if _, err = io.ReadFull(r, cc20nonce); chk.E(err) {
|
||||
return
|
||||
}
|
||||
auth = make([]byte, 32)
|
||||
if _, err = io.ReadFull(r, auth); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// CalcPadding creates padding for the message payload that is precisely a power
|
||||
// of two in order to reduce the chances of plaintext attack. This is plainly
|
||||
// retarded because it could blow out the message size a lot when just a random few
|
||||
// dozen bytes and a length prefix would achieve the same result.
|
||||
func CalcPadding(sLen int) (l int) {
|
||||
if sLen <= 32 {
|
||||
return 32
|
||||
}
|
||||
nextPower := 1 << int(math.Floor(math.Log2(float64(sLen-1)))+1)
|
||||
chunk := int(math.Max(32, float64(nextPower/8)))
|
||||
l = chunk * int(math.Floor(float64((sLen-1)/chunk))+1)
|
||||
return
|
||||
}
|
||||
1381
pkg/crypto/encryption/nip44_test.go
Normal file
1381
pkg/crypto/encryption/nip44_test.go
Normal file
File diff suppressed because it is too large
Load Diff
83
pkg/crypto/keys/keys.go
Normal file
83
pkg/crypto/keys/keys.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package keys is a set of helpers for generating and converting public/secret
|
||||
// keys to hex and back to binary.
|
||||
package keys
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/ec/schnorr"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
|
||||
// GeneratePrivateKey - deprecated, use GenerateSecretKeyHex
|
||||
var GeneratePrivateKey = func() string { return GenerateSecretKeyHex() }
|
||||
|
||||
// GenerateSecretKey creates a new secret key and returns the bytes of the secret.
|
||||
func GenerateSecretKey() (skb []byte, err error) {
|
||||
signer := &p256k.Signer{}
|
||||
if err = signer.Generate(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
skb = signer.Sec()
|
||||
return
|
||||
}
|
||||
|
||||
// GenerateSecretKeyHex generates a secret key and encodes the bytes as hex.
|
||||
func GenerateSecretKeyHex() (sks string) {
|
||||
skb, err := GenerateSecretKey()
|
||||
if chk.E(err) {
|
||||
return
|
||||
}
|
||||
return hex.Enc(skb)
|
||||
}
|
||||
|
||||
// GetPublicKeyHex generates a public key from a hex encoded secret key.
|
||||
func GetPublicKeyHex(sk string) (pk string, err error) {
|
||||
var b []byte
|
||||
if b, err = hex.Dec(sk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
signer := &p256k.Signer{}
|
||||
if err = signer.InitSec(b); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
return hex.Enc(signer.Pub()), nil
|
||||
}
|
||||
|
||||
// SecretBytesToPubKeyHex generates a public key from secret key bytes.
|
||||
func SecretBytesToPubKeyHex(skb []byte) (pk string, err error) {
|
||||
signer := &p256k.Signer{}
|
||||
if err = signer.InitSec(skb); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return hex.Enc(signer.Pub()), nil
|
||||
}
|
||||
|
||||
// IsValid32ByteHex checks that a hex string is a valid 32 bytes lower case hex encoded value as
|
||||
// per nostr NIP-01 spec.
|
||||
func IsValid32ByteHex[V []byte | string](pk V) bool {
|
||||
if utils.FastEqual(bytes.ToLower([]byte(pk)), []byte(pk)) {
|
||||
return false
|
||||
}
|
||||
var err error
|
||||
dec := make([]byte, 32)
|
||||
if _, err = hex.DecBytes(dec, []byte(pk)); chk.E(err) {
|
||||
}
|
||||
return len(dec) == 32
|
||||
}
|
||||
|
||||
// IsValidPublicKey checks that a hex encoded public key is a valid BIP-340 public key.
|
||||
func IsValidPublicKey[V []byte | string](pk V) bool {
|
||||
v, _ := hex.Dec(string(pk))
|
||||
_, err := schnorr.ParsePubKey(v)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// HexPubkeyToBytes decodes a pubkey from hex encoded string/bytes.
|
||||
func HexPubkeyToBytes[V []byte | string](hpk V) (pkb []byte, err error) {
|
||||
return hex.DecAppend(nil, []byte(hpk))
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"lol.mleku.dev/errorf"
|
||||
"next.orly.dev/pkg/encoders/text"
|
||||
utils "next.orly.dev/pkg/utils"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
|
||||
// The tag position meanings, so they are clear when reading.
|
||||
|
||||
@@ -166,3 +166,11 @@ func (s *S) GetAll(t []byte) (all []*T) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *S) GetTagElement(i int) (t *T) {
|
||||
if s == nil || len(*s) < i {
|
||||
return
|
||||
}
|
||||
t = (*s)[i]
|
||||
return
|
||||
}
|
||||
|
||||
56
pkg/protocol/nwc/README.md
Normal file
56
pkg/protocol/nwc/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# NWC Client
|
||||
|
||||
Nostr Wallet Connect (NIP-47) client implementation.
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
import "orly.dev/pkg/protocol/nwc"
|
||||
|
||||
// Create client from NWC connection URI
|
||||
client, err := nwc.NewClient("nostr+walletconnect://...")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Make requests
|
||||
var info map[string]any
|
||||
err = client.Request(ctx, "get_info", nil, &info)
|
||||
|
||||
var balance map[string]any
|
||||
err = client.Request(ctx, "get_balance", nil, &balance)
|
||||
|
||||
var invoice map[string]any
|
||||
params := map[string]any{"amount": 1000, "description": "test"}
|
||||
err = client.Request(ctx, "make_invoice", params, &invoice)
|
||||
```
|
||||
|
||||
## Methods
|
||||
|
||||
- `get_info` - Get wallet info
|
||||
- `get_balance` - Get wallet balance
|
||||
- `make_invoice` - Create invoice
|
||||
- `lookup_invoice` - Check invoice status
|
||||
- `pay_invoice` - Pay invoice
|
||||
|
||||
## Payment Notifications
|
||||
|
||||
```go
|
||||
// Subscribe to payment notifications
|
||||
err = client.SubscribeNotifications(ctx, func(notificationType string, notification map[string]any) error {
|
||||
if notificationType == "payment_received" {
|
||||
amount := notification["amount"].(float64)
|
||||
description := notification["description"].(string)
|
||||
// Process payment...
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- NIP-44 encryption
|
||||
- Event signing
|
||||
- Relay communication
|
||||
- Payment notifications
|
||||
- Error handling
|
||||
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)
|
||||
}
|
||||
188
pkg/protocol/nwc/crypto_test.go
Normal file
188
pkg/protocol/nwc/crypto_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package nwc_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/crypto/encryption"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/protocol/nwc"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
|
||||
func TestNWCConversationKey(t *testing.T) {
|
||||
secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
walletPubkey := "816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b"
|
||||
|
||||
uri := "nostr+walletconnect://" + walletPubkey + "?relay=wss://relay.getalby.com/v1&secret=" + secret
|
||||
|
||||
parts, err := nwc.ParseConnectionURI(uri)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Validate conversation key was generated
|
||||
convKey := parts.GetConversationKey()
|
||||
if len(convKey) == 0 {
|
||||
t.Fatal("conversation key should not be empty")
|
||||
}
|
||||
|
||||
// Validate wallet public key
|
||||
walletKey := parts.GetWalletPublicKey()
|
||||
if len(walletKey) == 0 {
|
||||
t.Fatal("wallet public key should not be empty")
|
||||
}
|
||||
|
||||
expected, err := hex.Dec(walletPubkey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(walletKey) != len(expected) {
|
||||
t.Fatal("wallet public key length mismatch")
|
||||
}
|
||||
|
||||
for i := range walletKey {
|
||||
if walletKey[i] != expected[i] {
|
||||
t.Fatal("wallet public key mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// Test passed
|
||||
}
|
||||
|
||||
func TestNWCEncryptionDecryption(t *testing.T) {
|
||||
secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
walletPubkey := "816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b"
|
||||
|
||||
uri := "nostr+walletconnect://" + walletPubkey + "?relay=wss://relay.getalby.com/v1&secret=" + secret
|
||||
|
||||
parts, err := nwc.ParseConnectionURI(uri)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
convKey := parts.GetConversationKey()
|
||||
testMessage := `{"method":"get_info","params":null}`
|
||||
|
||||
// Test encryption
|
||||
encrypted, err := encryption.Encrypt([]byte(testMessage), convKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encryption failed: %v", err)
|
||||
}
|
||||
|
||||
if len(encrypted) == 0 {
|
||||
t.Fatal("encrypted message should not be empty")
|
||||
}
|
||||
|
||||
// Test decryption
|
||||
decrypted, err := encryption.Decrypt(encrypted, convKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decryption failed: %v", err)
|
||||
}
|
||||
|
||||
if string(decrypted) != testMessage {
|
||||
t.Fatalf(
|
||||
"decrypted message mismatch: got %s, want %s", string(decrypted),
|
||||
testMessage,
|
||||
)
|
||||
}
|
||||
|
||||
// Test passed
|
||||
}
|
||||
|
||||
func TestNWCEventCreation(t *testing.T) {
|
||||
secretBytes, err := hex.Dec("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientKey := &p256k.Signer{}
|
||||
if err := clientKey.InitSec(secretBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
walletPubkey, err := hex.Dec("816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
convKey, err := encryption.GenerateConversationKeyWithSigner(
|
||||
clientKey, walletPubkey,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
request := map[string]any{"method": "get_info"}
|
||||
reqBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encrypted, err := encryption.Encrypt(reqBytes, convKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create NWC event
|
||||
ev := &event.E{
|
||||
Content: encrypted,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Kind: 23194,
|
||||
Tags: tag.NewS(
|
||||
tag.NewFromAny("encryption", "nip44_v2"),
|
||||
tag.NewFromAny("p", hex.Enc(walletPubkey)),
|
||||
),
|
||||
}
|
||||
|
||||
if err := ev.Sign(clientKey); err != nil {
|
||||
t.Fatalf("event signing failed: %v", err)
|
||||
}
|
||||
|
||||
// Validate event structure
|
||||
if len(ev.Content) == 0 {
|
||||
t.Fatal("event content should not be empty")
|
||||
}
|
||||
|
||||
if len(ev.ID) == 0 {
|
||||
t.Fatal("event should have ID after signing")
|
||||
}
|
||||
|
||||
if len(ev.Sig) == 0 {
|
||||
t.Fatal("event should have signature after signing")
|
||||
}
|
||||
|
||||
// Validate tags
|
||||
hasEncryption := false
|
||||
hasP := false
|
||||
for i := 0; i < ev.Tags.Len(); i++ {
|
||||
tag := ev.Tags.GetTagElement(i)
|
||||
if tag.Len() >= 2 {
|
||||
if utils.FastEqual(
|
||||
tag.T[0], "encryption",
|
||||
) && utils.FastEqual(tag.T[1], "nip44_v2") {
|
||||
hasEncryption = true
|
||||
}
|
||||
if utils.FastEqual(
|
||||
tag.T[0], "p",
|
||||
) && utils.FastEqual(tag.T[1], hex.Enc(walletPubkey)) {
|
||||
hasP = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEncryption {
|
||||
t.Fatal("event missing encryption tag")
|
||||
}
|
||||
|
||||
if !hasP {
|
||||
t.Fatal("event missing p tag")
|
||||
}
|
||||
|
||||
// Test passed
|
||||
}
|
||||
495
pkg/protocol/nwc/mock_wallet_service.go
Normal file
495
pkg/protocol/nwc/mock_wallet_service.go
Normal file
@@ -0,0 +1,495 @@
|
||||
package nwc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/encryption"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
"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"
|
||||
)
|
||||
|
||||
// MockWalletService implements a mock NIP-47 wallet service for testing
|
||||
type MockWalletService struct {
|
||||
relay string
|
||||
walletSecretKey signer.I
|
||||
walletPublicKey []byte
|
||||
client *ws.Client
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
balance int64 // in satoshis
|
||||
balanceMutex sync.RWMutex
|
||||
connectedClients map[string][]byte // pubkey -> conversation key
|
||||
clientsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMockWalletService creates a new mock wallet service
|
||||
func NewMockWalletService(
|
||||
relay string, initialBalance int64,
|
||||
) (service *MockWalletService, err error) {
|
||||
// Generate wallet keypair
|
||||
walletKey := &p256k.Signer{}
|
||||
if err = walletKey.Generate(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
service = &MockWalletService{
|
||||
relay: relay,
|
||||
walletSecretKey: walletKey,
|
||||
walletPublicKey: walletKey.Pub(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
balance: initialBalance,
|
||||
connectedClients: make(map[string][]byte),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start begins the mock wallet service
|
||||
func (m *MockWalletService) Start() (err error) {
|
||||
// Connect to relay
|
||||
if m.client, err = ws.RelayConnect(m.ctx, m.relay); chk.E(err) {
|
||||
return fmt.Errorf("failed to connect to relay: %w", err)
|
||||
}
|
||||
|
||||
// Publish wallet info event
|
||||
if err = m.publishWalletInfo(); chk.E(err) {
|
||||
return fmt.Errorf("failed to publish wallet info: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to request events
|
||||
if err = m.subscribeToRequests(); chk.E(err) {
|
||||
return fmt.Errorf("failed to subscribe to requests: %w", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Stop stops the mock wallet service
|
||||
func (m *MockWalletService) Stop() {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
if m.client != nil {
|
||||
m.client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// GetWalletPublicKey returns the wallet's public key
|
||||
func (m *MockWalletService) GetWalletPublicKey() []byte {
|
||||
return m.walletPublicKey
|
||||
}
|
||||
|
||||
// publishWalletInfo publishes the NIP-47 info event (kind 13194)
|
||||
func (m *MockWalletService) publishWalletInfo() (err error) {
|
||||
capabilities := []string{
|
||||
"get_info",
|
||||
"get_balance",
|
||||
"make_invoice",
|
||||
"pay_invoice",
|
||||
}
|
||||
|
||||
info := map[string]any{
|
||||
"capabilities": capabilities,
|
||||
"notifications": []string{"payment_received", "payment_sent"},
|
||||
}
|
||||
|
||||
var content []byte
|
||||
if content, err = json.Marshal(info); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
ev := &event.E{
|
||||
Content: content,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Kind: 13194,
|
||||
Tags: tag.NewS(),
|
||||
}
|
||||
|
||||
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
return m.client.Publish(m.ctx, ev)
|
||||
}
|
||||
|
||||
// subscribeToRequests subscribes to NWC request events (kind 23194)
|
||||
func (m *MockWalletService) subscribeToRequests() (err error) {
|
||||
var sub *ws.Subscription
|
||||
if sub, err = m.client.Subscribe(
|
||||
m.ctx, filter.NewS(
|
||||
&filter.F{
|
||||
Kinds: kind.NewS(kind.New(23194)),
|
||||
Tags: tag.NewS(
|
||||
tag.NewFromAny("p", hex.Enc(m.walletPublicKey)),
|
||||
),
|
||||
Since: ×tamp.T{V: time.Now().Unix()},
|
||||
},
|
||||
),
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle incoming request events
|
||||
go m.handleRequestEvents(sub)
|
||||
return
|
||||
}
|
||||
|
||||
// handleRequestEvents processes incoming NWC request events
|
||||
func (m *MockWalletService) handleRequestEvents(sub *ws.Subscription) {
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case ev := <-sub.Events:
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
if err := m.processRequestEvent(ev); chk.E(err) {
|
||||
fmt.Printf("Error processing request event: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processRequestEvent processes a single NWC request event
|
||||
func (m *MockWalletService) processRequestEvent(ev *event.E) (err error) {
|
||||
// Get client pubkey from event
|
||||
clientPubkey := ev.Pubkey
|
||||
clientPubkeyHex := hex.Enc(clientPubkey)
|
||||
|
||||
// Generate or get conversation key
|
||||
var conversationKey []byte
|
||||
m.clientsMutex.Lock()
|
||||
if existingKey, exists := m.connectedClients[clientPubkeyHex]; exists {
|
||||
conversationKey = existingKey
|
||||
} else {
|
||||
if conversationKey, err = encryption.GenerateConversationKeyWithSigner(
|
||||
m.walletSecretKey, clientPubkey,
|
||||
); chk.E(err) {
|
||||
m.clientsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
m.connectedClients[clientPubkeyHex] = conversationKey
|
||||
}
|
||||
m.clientsMutex.Unlock()
|
||||
|
||||
// Decrypt request content
|
||||
var decrypted []byte
|
||||
if decrypted, err = encryption.Decrypt(
|
||||
ev.Content, conversationKey,
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
var request map[string]any
|
||||
if err = json.Unmarshal(decrypted, &request); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
method, ok := request["method"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid method")
|
||||
}
|
||||
|
||||
params := request["params"]
|
||||
|
||||
// Process the method
|
||||
var result any
|
||||
if result, err = m.processMethod(method, params); chk.E(err) {
|
||||
// Send error response
|
||||
return m.sendErrorResponse(
|
||||
clientPubkey, conversationKey, "INTERNAL", err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
// Send success response
|
||||
return m.sendSuccessResponse(clientPubkey, conversationKey, result)
|
||||
}
|
||||
|
||||
// processMethod handles the actual NWC method execution
|
||||
func (m *MockWalletService) processMethod(
|
||||
method string, params any,
|
||||
) (result any, err error) {
|
||||
switch method {
|
||||
case "get_info":
|
||||
return m.getInfo()
|
||||
case "get_balance":
|
||||
return m.getBalance()
|
||||
case "make_invoice":
|
||||
return m.makeInvoice(params)
|
||||
case "pay_invoice":
|
||||
return m.payInvoice(params)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported method: %s", method)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// getInfo returns wallet information
|
||||
func (m *MockWalletService) getInfo() (result map[string]any, err error) {
|
||||
result = map[string]any{
|
||||
"alias": "Mock Wallet",
|
||||
"color": "#3399FF",
|
||||
"pubkey": hex.Enc(m.walletPublicKey),
|
||||
"network": "mainnet",
|
||||
"block_height": 850000,
|
||||
"block_hash": "0000000000000000000123456789abcdef",
|
||||
"methods": []string{
|
||||
"get_info", "get_balance", "make_invoice", "pay_invoice",
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// getBalance returns the current wallet balance
|
||||
func (m *MockWalletService) getBalance() (result map[string]any, err error) {
|
||||
m.balanceMutex.RLock()
|
||||
balance := m.balance
|
||||
m.balanceMutex.RUnlock()
|
||||
|
||||
result = map[string]any{
|
||||
"balance": balance * 1000, // convert to msats
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// makeInvoice creates a Lightning invoice
|
||||
func (m *MockWalletService) makeInvoice(params any) (
|
||||
result map[string]any, err error,
|
||||
) {
|
||||
paramsMap, ok := params.(map[string]any)
|
||||
if !ok {
|
||||
err = fmt.Errorf("invalid params")
|
||||
return
|
||||
}
|
||||
|
||||
amount, ok := paramsMap["amount"].(float64)
|
||||
if !ok {
|
||||
err = fmt.Errorf("missing or invalid amount")
|
||||
return
|
||||
}
|
||||
|
||||
description := ""
|
||||
if desc, ok := paramsMap["description"].(string); ok {
|
||||
description = desc
|
||||
}
|
||||
|
||||
paymentHash := make([]byte, 32)
|
||||
rand.Read(paymentHash)
|
||||
|
||||
// Generate a fake bolt11 invoice
|
||||
bolt11 := fmt.Sprintf("lnbc%dm1pwxxxxxxx", int64(amount/1000))
|
||||
|
||||
result = map[string]any{
|
||||
"type": "incoming",
|
||||
"invoice": bolt11,
|
||||
"description": description,
|
||||
"payment_hash": hex.Enc(paymentHash),
|
||||
"amount": int64(amount),
|
||||
"created_at": time.Now().Unix(),
|
||||
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// payInvoice pays a Lightning invoice
|
||||
func (m *MockWalletService) payInvoice(params any) (
|
||||
result map[string]any, err error,
|
||||
) {
|
||||
paramsMap, ok := params.(map[string]any)
|
||||
if !ok {
|
||||
err = fmt.Errorf("invalid params")
|
||||
return
|
||||
}
|
||||
|
||||
invoice, ok := paramsMap["invoice"].(string)
|
||||
if !ok {
|
||||
err = fmt.Errorf("missing or invalid invoice")
|
||||
return
|
||||
}
|
||||
|
||||
// Mock payment amount (would parse from invoice in real implementation)
|
||||
amount := int64(1000) // 1000 msats
|
||||
|
||||
// Check balance
|
||||
m.balanceMutex.Lock()
|
||||
if m.balance*1000 < amount {
|
||||
m.balanceMutex.Unlock()
|
||||
err = fmt.Errorf("insufficient balance")
|
||||
return
|
||||
}
|
||||
m.balance -= amount / 1000
|
||||
m.balanceMutex.Unlock()
|
||||
|
||||
preimage := make([]byte, 32)
|
||||
rand.Read(preimage)
|
||||
|
||||
result = map[string]any{
|
||||
"type": "outgoing",
|
||||
"invoice": invoice,
|
||||
"amount": amount,
|
||||
"preimage": hex.Enc(preimage),
|
||||
"created_at": time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Emit payment_sent notification
|
||||
go m.emitPaymentNotification("payment_sent", result)
|
||||
return
|
||||
}
|
||||
|
||||
// sendSuccessResponse sends a successful NWC response
|
||||
func (m *MockWalletService) sendSuccessResponse(
|
||||
clientPubkey []byte, conversationKey []byte, result any,
|
||||
) (err error) {
|
||||
response := map[string]any{
|
||||
"result": result,
|
||||
}
|
||||
|
||||
var responseBytes []byte
|
||||
if responseBytes, err = json.Marshal(response); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
|
||||
}
|
||||
|
||||
// sendErrorResponse sends an error NWC response
|
||||
func (m *MockWalletService) sendErrorResponse(
|
||||
clientPubkey []byte, conversationKey []byte, code, message string,
|
||||
) (err error) {
|
||||
response := map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
|
||||
var responseBytes []byte
|
||||
if responseBytes, err = json.Marshal(response); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
|
||||
}
|
||||
|
||||
// sendEncryptedResponse sends an encrypted response event (kind 23195)
|
||||
func (m *MockWalletService) sendEncryptedResponse(
|
||||
clientPubkey []byte, conversationKey []byte, content []byte,
|
||||
) (err error) {
|
||||
var encrypted []byte
|
||||
if encrypted, err = encryption.Encrypt(
|
||||
content, conversationKey,
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
ev := &event.E{
|
||||
Content: encrypted,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Kind: 23195,
|
||||
Tags: tag.NewS(
|
||||
tag.NewFromAny("encryption", "nip44_v2"),
|
||||
tag.NewFromAny("p", hex.Enc(clientPubkey)),
|
||||
),
|
||||
}
|
||||
|
||||
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
return m.client.Publish(m.ctx, ev)
|
||||
}
|
||||
|
||||
// emitPaymentNotification emits a payment notification (kind 23197)
|
||||
func (m *MockWalletService) emitPaymentNotification(
|
||||
notificationType string, paymentData map[string]any,
|
||||
) (err error) {
|
||||
notification := map[string]any{
|
||||
"notification_type": notificationType,
|
||||
"notification": paymentData,
|
||||
}
|
||||
|
||||
var content []byte
|
||||
if content, err = json.Marshal(notification); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send notification to all connected clients
|
||||
m.clientsMutex.RLock()
|
||||
defer m.clientsMutex.RUnlock()
|
||||
|
||||
for clientPubkeyHex, conversationKey := range m.connectedClients {
|
||||
var clientPubkey []byte
|
||||
if clientPubkey, err = hex.Dec(clientPubkeyHex); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
var encrypted []byte
|
||||
if encrypted, err = encryption.Encrypt(
|
||||
content, conversationKey,
|
||||
); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
ev := &event.E{
|
||||
Content: encrypted,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Kind: 23197,
|
||||
Tags: tag.NewS(
|
||||
tag.NewFromAny("encryption", "nip44_v2"),
|
||||
tag.NewFromAny("p", hex.Enc(clientPubkey)),
|
||||
),
|
||||
}
|
||||
|
||||
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
m.client.Publish(m.ctx, ev)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SimulateIncomingPayment simulates an incoming payment for testing
|
||||
func (m *MockWalletService) SimulateIncomingPayment(
|
||||
pubkey []byte, amount int64, description string,
|
||||
) (err error) {
|
||||
// Add to balance
|
||||
m.balanceMutex.Lock()
|
||||
m.balance += amount / 1000 // convert msats to sats
|
||||
m.balanceMutex.Unlock()
|
||||
|
||||
paymentHash := make([]byte, 32)
|
||||
rand.Read(paymentHash)
|
||||
|
||||
preimage := make([]byte, 32)
|
||||
rand.Read(preimage)
|
||||
|
||||
paymentData := map[string]any{
|
||||
"type": "incoming",
|
||||
"invoice": fmt.Sprintf("lnbc%dm1pwxxxxxxx", amount/1000),
|
||||
"description": description,
|
||||
"amount": amount,
|
||||
"payment_hash": hex.Enc(paymentHash),
|
||||
"preimage": hex.Enc(preimage),
|
||||
"created_at": time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Emit payment_received notification
|
||||
return m.emitPaymentNotification("payment_received", paymentData)
|
||||
}
|
||||
176
pkg/protocol/nwc/nwc_test.go
Normal file
176
pkg/protocol/nwc/nwc_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package nwc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/protocol/nwc"
|
||||
"next.orly.dev/pkg/protocol/ws"
|
||||
)
|
||||
|
||||
func TestNWCClientCreation(t *testing.T) {
|
||||
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
c, err := nwc.NewClient(uri)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c == nil {
|
||||
t.Fatal("client should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNWCInvalidURI(t *testing.T) {
|
||||
invalidURIs := []string{
|
||||
"invalid://test",
|
||||
"nostr+walletconnect://",
|
||||
"nostr+walletconnect://invalid",
|
||||
"nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b",
|
||||
"nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=invalid",
|
||||
}
|
||||
|
||||
for _, uri := range invalidURIs {
|
||||
_, err := nwc.NewClient(uri)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid URI: %s", uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNWCRelayConnection(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rc, err := ws.RelayConnect(ctx, "wss://relay.getalby.com/v1")
|
||||
if err != nil {
|
||||
t.Fatalf("relay connection failed: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
t.Log("relay connection successful")
|
||||
}
|
||||
|
||||
func TestNWCRequestTimeout(t *testing.T) {
|
||||
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
c, err := nwc.NewClient(uri)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var r map[string]any
|
||||
err = c.Request(ctx, "get_info", nil, &r)
|
||||
|
||||
if err == nil {
|
||||
t.Log("wallet responded")
|
||||
return
|
||||
}
|
||||
|
||||
expectedErrors := []string{
|
||||
"no response from wallet",
|
||||
"subscription closed",
|
||||
"timeout waiting for response",
|
||||
"context deadline exceeded",
|
||||
}
|
||||
|
||||
errorFound := false
|
||||
for _, expected := range expectedErrors {
|
||||
if contains(err.Error(), expected) {
|
||||
errorFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !errorFound {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("proper timeout handling: %v", err)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
|
||||
findInString(s, substr))))
|
||||
}
|
||||
|
||||
func findInString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestNWCEncryption(t *testing.T) {
|
||||
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
c, err := nwc.NewClient(uri)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// We can't directly access private fields, but we can test the client creation
|
||||
// check conversation key generation
|
||||
if c == nil {
|
||||
t.Fatal("client creation should succeed with valid URI")
|
||||
}
|
||||
|
||||
// Test passed
|
||||
}
|
||||
|
||||
func TestNWCEventFormat(t *testing.T) {
|
||||
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
c, err := nwc.NewClient(uri)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test client creation
|
||||
// The Request method will create proper NWC events with:
|
||||
// - Kind 23194 for requests
|
||||
// - Proper encryption tag
|
||||
// - Signed with client key
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var r map[string]any
|
||||
err = c.Request(ctx, "get_info", nil, &r)
|
||||
|
||||
// We expect this to fail due to inactive connection, but it should fail
|
||||
// after creating and sending NWC event
|
||||
if err == nil {
|
||||
t.Log("wallet responded")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it failed for the right reason (connection/response issue, not formatting)
|
||||
validFailures := []string{
|
||||
"subscription closed",
|
||||
"no response from wallet",
|
||||
"context deadline exceeded",
|
||||
"timeout waiting for response",
|
||||
}
|
||||
|
||||
validFailure := false
|
||||
for _, failure := range validFailures {
|
||||
if contains(err.Error(), failure) {
|
||||
validFailure = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !validFailure {
|
||||
t.Fatalf("unexpected error type (suggests formatting issue): %v", err)
|
||||
}
|
||||
|
||||
// Test passed
|
||||
}
|
||||
81
pkg/protocol/nwc/uri.go
Normal file
81
pkg/protocol/nwc/uri.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package nwc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/encryption"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
)
|
||||
|
||||
type ConnectionParams struct {
|
||||
clientSecretKey signer.I
|
||||
walletPublicKey []byte
|
||||
conversationKey []byte
|
||||
relay string
|
||||
}
|
||||
|
||||
// GetWalletPublicKey returns the wallet public key from the ConnectionParams.
|
||||
func (c *ConnectionParams) GetWalletPublicKey() []byte {
|
||||
return c.walletPublicKey
|
||||
}
|
||||
|
||||
// GetConversationKey returns the conversation key from the ConnectionParams.
|
||||
func (c *ConnectionParams) GetConversationKey() []byte {
|
||||
return c.conversationKey
|
||||
}
|
||||
|
||||
func ParseConnectionURI(nwcUri string) (parts *ConnectionParams, err error) {
|
||||
var p *url.URL
|
||||
if p, err = url.Parse(nwcUri); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if p == nil {
|
||||
err = errors.New("invalid uri")
|
||||
return
|
||||
}
|
||||
parts = &ConnectionParams{}
|
||||
if p.Scheme != "nostr+walletconnect" {
|
||||
err = errors.New("incorrect scheme")
|
||||
return
|
||||
}
|
||||
if parts.walletPublicKey, err = p256k.HexToBin(p.Host); chk.E(err) {
|
||||
err = errors.New("invalid public key")
|
||||
return
|
||||
}
|
||||
query := p.Query()
|
||||
var ok bool
|
||||
var relay []string
|
||||
if relay, ok = query["relay"]; !ok {
|
||||
err = errors.New("missing relay parameter")
|
||||
return
|
||||
}
|
||||
if len(relay) == 0 {
|
||||
return nil, errors.New("no relays")
|
||||
}
|
||||
parts.relay = relay[0]
|
||||
var secret string
|
||||
if secret = query.Get("secret"); secret == "" {
|
||||
err = errors.New("missing secret parameter")
|
||||
return
|
||||
}
|
||||
var secretBytes []byte
|
||||
if secretBytes, err = p256k.HexToBin(secret); chk.E(err) {
|
||||
err = errors.New("invalid secret")
|
||||
return
|
||||
}
|
||||
clientKey := &p256k.Signer{}
|
||||
if err = clientKey.InitSec(secretBytes); chk.E(err) {
|
||||
return
|
||||
}
|
||||
parts.clientSecretKey = clientKey
|
||||
if parts.conversationKey, err = encryption.GenerateConversationKeyWithSigner(
|
||||
clientKey,
|
||||
parts.walletPublicKey,
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user