16 changed files with 3089 additions and 1 deletions
@ -0,0 +1 @@ |
|||||||
|
Code copied from https://github.com/paulmillr/nip44/tree/e7aed61aaf77240ac10c325683eed14b22e7950f/go. |
||||||
@ -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 |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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)) |
||||||
|
} |
||||||
@ -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 |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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
|
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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
|
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
Loading…
Reference in new issue