Merge 1c9e97d2101b29d30d12c8d35bf568b56ceee5dc into 7c9c5d7cd9e1b946a25b57726d816abb79873bb9

This commit is contained in:
Roland 2025-03-17 13:04:51 +07:00 committed by GitHub
commit 19c09f45d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 579 additions and 0 deletions

43
nip47/nip47.go Normal file
View File

@ -0,0 +1,43 @@
package nip47
import (
"errors"
"net/url"
"github.com/nbd-wtf/go-nostr"
)
type NWCURIParts struct {
clientSecretKey string
walletPublicKey string
relays []string
}
// extracts the NWC URI parts from a connection URI
func ParseNWCURI(nwcUri string) (*NWCURIParts, error) {
p, err := url.Parse(nwcUri)
if err != nil {
return nil, err
}
if p.Scheme != "nostr+walletconnect" {
return nil, errors.New("incorrect scheme")
}
if !nostr.IsValid32ByteHex(p.Host) {
return nil, errors.New("invalid wallet public key")
}
query := p.Query()
relays := query["relay"]
secret := query.Get("secret")
if !nostr.IsValid32ByteHex(secret) {
return nil, errors.New("invalid secret")
}
if len(relays) == 0 {
return nil, errors.New("no relays")
}
return &NWCURIParts{
walletPublicKey: p.Host,
clientSecretKey: secret,
relays: relays,
}, nil
}

23
nip47/nip47_test.go Normal file
View File

@ -0,0 +1,23 @@
package nip47
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseNWCURI(t *testing.T) {
uriParts, err := ParseNWCURI("nostr+walletconnect://739b65aa39cd4318708b5ae5ea85d52b758aa1f5502d32cb033eff9115f95f8d?relay=wss://relay.getalby.com/v1&secret=a5aa9fc79d90271f217c599191ce8479a0404d0c2417f85bc5bee18a89c0cb47")
require.NoError(t, err)
assert.Equal(t, "739b65aa39cd4318708b5ae5ea85d52b758aa1f5502d32cb033eff9115f95f8d", uriParts.walletPublicKey)
assert.Equal(t, "a5aa9fc79d90271f217c599191ce8479a0404d0c2417f85bc5bee18a89c0cb47", uriParts.clientSecretKey)
assert.Equal(t, []string{"wss://relay.getalby.com/v1"}, uriParts.relays)
_, err = ParseNWCURI("nostr+walletconnect://739b65aa39cd4318708b5ae5ea85d52b758aa1f5502d32cb033eff9115f95f8d?relay=wss://relay.getalby.com/v1")
assert.Equal(t, "invalid secret", err.Error())
_, err = ParseNWCURI("nostr+walletconnect://739b65aa39cd4318708b5ae5ea85d52b758aa1f5502d32cb033eff9115f95f8d?secret=a5aa9fc79d90271f217c599191ce8479a0404d0c2417f85bc5bee18a89c0cb47")
assert.Equal(t, "no relays", err.Error())
_, err = ParseNWCURI("nostrwalletconnect://739b65aa39cd4318708b5ae5ea85d52b758aa1f5502d32cb033eff9115f95f8d?relay=wss://relay.getalby.com/v1&secret=a5aa9fc79d90271f217c599191ce8479a0404d0c2417f85bc5bee18a89c0cb47")
assert.Equal(t, "incorrect scheme", err.Error())
}

341
nip47/nwc_client.go Normal file
View File

@ -0,0 +1,341 @@
package nip47
import (
"context"
"fmt"
"strings"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip44"
)
type WalletServiceInfo struct {
EncryptionTypes []string
Capabilities []string
NotificationTypes []string
}
type GetInfoResult struct {
Alias string `json:"alias"`
Color string `json:"color"`
Pubkey string `json:"pubkey"`
Network string `json:"network"`
BlockHeight uint `json:"block_height"`
BlockHash string `json:"block_hash"`
Methods []string `json:"methods"`
Notifications []string `json:"notifications"`
}
type MakeInvoiceParams struct {
Amount uint64 `json:"amount"`
Expiry *uint32 `json:"expiry"`
Description string `json:"description"`
DescriptionHash string `json:"description_hash"`
Metadata interface{} `json:"metadata"`
}
type PayInvoiceParams struct {
Invoice string `json:"invoice"`
Amount *uint64 `json:"amount"`
Metadata interface{} `json:"metadata"`
}
type LookupInvoiceParams struct {
PaymentHash string `json:"payment_hash"`
Invoice string `json:"invoice"`
}
type ListTransactionsParams struct {
From uint64 `json:"from"`
To uint64 `json:"to"`
Limit uint16 `json:"limit"`
Offset uint32 `json:"offset"`
Unpaid bool `json:"unpaid"`
UnpaidOutgoing bool `json:"unpaid_outgoing"`
UnpaidIncoming bool `json:"unpaid_incoming"`
Type string `json:"type"`
}
type GetBalanceResult struct {
Balance uint64 `json:"balance"`
}
type PayInvoiceResult struct {
Preimage string `json:"preimage"`
FeesPaid uint64 `json:"fees_paid"`
}
type MakeInvoiceResult = Transaction
type LookupInvoiceResult = Transaction
type ListTransactionsResult struct {
Transactions []Transaction `json:"transactions"`
TotalCount uint32 `json:"total_count"`
}
type Transaction struct {
Type string `json:"type"`
State string `json:"state"`
Invoice string `json:"invoice"`
Description string `json:"description"`
DescriptionHash string `json:"description_hash"`
Preimage string `json:"preimage"`
PaymentHash string `json:"payment_hash"`
Amount uint64 `json:"amount"`
FeesPaid uint64 `json:"fees_paid"`
CreatedAt uint64 `json:"created_at"`
ExpiresAt uint64 `json:"expires_at"`
SettledAt *uint64 `json:"settled_at"`
Metadata interface{} `json:"metadata"`
}
type NWCClient struct {
pool *nostr.SimplePool
relays []string
conversationKey [32]byte // nip44
clientSecretKey string
walletPublicKey string
}
var json = jsoniter.ConfigFastest
type Request struct {
Method string `json:"method"`
Params interface{} `json:"params"`
}
type ResponseError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (err *ResponseError) Error() string {
return fmt.Sprintf("%s %s", err.Code, err.Message)
}
type Response struct {
ResultType string `json:"result_type"`
Error *ResponseError `json:"error"`
Result interface{} `json:"result"`
}
// creates a new NWC client from a NWC URI
func NewNWCClientFromURI(ctx context.Context, nwcUri string, pool *nostr.SimplePool) (client *NWCClient, err error) {
nwcUriParts, err := ParseNWCURI(nwcUri)
if err != nil {
return nil, err
}
return NewNWCClient(ctx, nwcUriParts.clientSecretKey, nwcUriParts.walletPublicKey, nwcUriParts.relays, pool)
}
// creates a new NWC client from NWC URI parts
func NewNWCClient(ctx context.Context, clientSecretKey string, walletPublicKey string, relays []string, pool *nostr.SimplePool) (client *NWCClient, err error) {
if pool == nil {
pool = nostr.NewSimplePool(ctx)
}
conversationKey, err := nip44.GenerateConversationKey(walletPublicKey, clientSecretKey)
if err != nil {
return nil, err
}
return &NWCClient{
pool: pool,
relays: relays,
clientSecretKey: clientSecretKey,
conversationKey: conversationKey,
walletPublicKey: walletPublicKey,
}, nil
}
// fetches the NIP-47 info event (kind 13194)
func (client NWCClient) GetWalletServiceInfo(ctx context.Context) (*WalletServiceInfo, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
events := client.pool.SubscribeMany(ctx, client.relays, nostr.Filter{
Limit: 1,
Kinds: []int{13194},
Authors: []string{client.walletPublicKey}})
select {
case <-ctx.Done():
return nil, fmt.Errorf("context canceled")
case event := <-events:
encryptionTypes := []string{}
notificationTypes := []string{}
encryptionTag := event.Tags.GetFirst([]string{"encryption"})
notificationsTag := event.Tags.GetFirst([]string{"notifications"})
if encryptionTag != nil {
encryptionTypes = strings.Split((*encryptionTag).Value(), " ")
}
if notificationsTag != nil {
notificationTypes = strings.Split((*notificationsTag).Value(), " ")
}
info := &WalletServiceInfo{
EncryptionTypes: encryptionTypes,
NotificationTypes: notificationTypes,
Capabilities: strings.Split(event.Content, " "),
}
return info, nil
}
}
// executes the NIP-47 get_info request method
func (client NWCClient) GetInfo(ctx context.Context) (*GetInfoResult, error) {
getInfoResult := GetInfoResult{}
err := client.RPC(ctx, "get_info", nil, &getInfoResult, nil)
if err != nil {
return nil, err
}
return &getInfoResult, nil
}
// executes the NIP-47 make_invoice request method
func (client NWCClient) MakeInvoice(ctx context.Context, params *MakeInvoiceParams) (*MakeInvoiceResult, error) {
makeInvoiceResult := MakeInvoiceResult{}
err := client.RPC(ctx, "make_invoice", params, &makeInvoiceResult, nil)
if err != nil {
return nil, err
}
return &makeInvoiceResult, nil
}
// executes the NIP-47 pay_invoice request method
func (client NWCClient) PayInvoice(ctx context.Context, params *PayInvoiceParams) (*PayInvoiceResult, error) {
payInvoiceResult := PayInvoiceResult{}
err := client.RPC(ctx, "pay_invoice", params, &payInvoiceResult, nil)
if err != nil {
return nil, err
}
return &payInvoiceResult, nil
}
// executes the NIP-47 lookup_invoice request method
func (client NWCClient) LookupInvoice(ctx context.Context, params *LookupInvoiceParams) (*LookupInvoiceResult, error) {
lookupInvoiceResult := LookupInvoiceResult{}
err := client.RPC(ctx, "lookup_invoice", params, &lookupInvoiceResult, nil)
if err != nil {
return nil, err
}
return &lookupInvoiceResult, nil
}
// executes the NIP-47 list_transactions request method
func (client NWCClient) ListTransactions(ctx context.Context, params *ListTransactionsParams) (*ListTransactionsResult, error) {
listTransactionsResult := ListTransactionsResult{}
err := client.RPC(ctx, "list_transactions", params, &listTransactionsResult, nil)
if err != nil {
return nil, err
}
return &listTransactionsResult, nil
}
// executes the NIP-47 get_balance request method
func (client NWCClient) GetBalance(ctx context.Context) (*GetBalanceResult, error) {
getBalanceResult := GetBalanceResult{}
err := client.RPC(ctx, "get_balance", nil, &getBalanceResult, nil)
if err != nil {
return nil, err
}
return &getBalanceResult, nil
}
type rpcOptions struct {
timeoutSeconds *uint64
}
// executes a custom NIP-47 request method and waits for the response
func (client NWCClient) RPC(ctx context.Context, method string, params interface{}, result interface{}, opts *rpcOptions) error {
timeoutSeconds := uint64(10)
if opts != nil && opts.timeoutSeconds != nil {
timeoutSeconds = *opts.timeoutSeconds
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
defer cancel()
req, err := json.Marshal(Request{
Method: method,
Params: params,
})
if err != nil {
return err
}
content, err := nip44.Encrypt(string(req), client.conversationKey)
if err != nil {
return fmt.Errorf("error encrypting request: %w", err)
}
evt := nostr.Event{
Content: content,
CreatedAt: nostr.Now(),
Kind: 23194,
Tags: nostr.Tags{{"p", client.walletPublicKey}, {"encryption", "nip44_v2"}},
}
if err := evt.Sign(client.clientSecretKey); err != nil {
return fmt.Errorf("failed to sign request event: %w", err)
}
hasWorked := make(chan struct{})
events := client.pool.SubscribeMany(ctx, client.relays, nostr.Filter{
Limit: 1,
Kinds: []int{23195},
Authors: []string{client.walletPublicKey},
Tags: nostr.TagMap{"e": []string{evt.ID}}})
for _, url := range client.relays {
go func(url string) {
relay, err := client.pool.EnsureRelay(url)
if err != nil {
return
}
err = relay.Publish(ctx, evt)
if err != nil {
return
}
select {
case hasWorked <- struct{}{}:
default:
}
}(url)
}
select {
case <-hasWorked:
// continue
case <-ctx.Done():
return fmt.Errorf("couldn't connect to any relay")
}
select {
case <-ctx.Done():
return fmt.Errorf("context canceled")
case event := <-events:
plain, err := nip44.Decrypt(event.Content, client.conversationKey)
if err != nil {
return err
}
resp := Response{
Result: &result,
}
err = json.Unmarshal([]byte(plain), &resp)
if err != nil {
return err
}
return nil
}
}

172
nip47/nwc_client_test.go Normal file
View File

@ -0,0 +1,172 @@
package nip47
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetWalletServiceInfo(t *testing.T) {
client := createTestClient(t)
walletServiceInfo, err := client.GetWalletServiceInfo(context.TODO())
require.NoError(t, err)
require.NotNil(t, walletServiceInfo)
assert.Contains(t, walletServiceInfo.Capabilities, "get_info")
assert.Contains(t, walletServiceInfo.NotificationTypes, "payment_received")
assert.Contains(t, walletServiceInfo.EncryptionTypes, "nip44_v2")
}
func TestGetInfo(t *testing.T) {
client := createTestClient(t)
getInfoResult, err := client.GetInfo(context.TODO())
require.NoError(t, err)
require.NotNil(t, getInfoResult)
assert.Contains(t, getInfoResult.Methods, "get_info")
assert.Contains(t, getInfoResult.Notifications, "payment_received")
assert.Greater(t, getInfoResult.BlockHeight, uint(840_000))
assert.Equal(t, 64, len(getInfoResult.BlockHash))
assert.Equal(t, "mainnet", getInfoResult.Network)
}
func TestMakeInvoice(t *testing.T) {
client := createTestClient(t)
makeInvoiceResult, err := client.MakeInvoice(context.TODO(), &MakeInvoiceParams{
Amount: uint64(1000),
})
require.NoError(t, err)
require.NotNil(t, makeInvoiceResult)
assert.Equal(t, makeInvoiceResult.Amount, uint64(1000))
assert.True(t, strings.HasPrefix(makeInvoiceResult.Invoice, "lnbc"))
assert.Equal(t, "pending", makeInvoiceResult.State)
assert.Nil(t, makeInvoiceResult.SettledAt)
assert.Greater(t, makeInvoiceResult.ExpiresAt, uint64(time.Now().Unix()))
assert.Empty(t, makeInvoiceResult.Preimage)
}
func TestLookupInvoice(t *testing.T) {
client := createTestClient(t)
makeInvoiceResult, err := client.MakeInvoice(context.TODO(), &MakeInvoiceParams{
Amount: uint64(1000),
})
require.NoError(t, err)
require.NotNil(t, makeInvoiceResult)
lookupInvoiceResult, err := client.LookupInvoice(context.TODO(), &LookupInvoiceParams{
PaymentHash: makeInvoiceResult.PaymentHash,
})
require.NoError(t, err)
require.NotNil(t, lookupInvoiceResult)
require.NoError(t, err)
require.NotNil(t, lookupInvoiceResult)
assert.Equal(t, lookupInvoiceResult.Amount, uint64(1000))
assert.True(t, strings.HasPrefix(lookupInvoiceResult.Invoice, "lnbc"))
assert.Equal(t, "pending", lookupInvoiceResult.State)
assert.Nil(t, lookupInvoiceResult.SettledAt)
assert.Greater(t, lookupInvoiceResult.ExpiresAt, uint64(time.Now().Unix()))
assert.Empty(t, lookupInvoiceResult.Preimage)
}
func TestListTransactions(t *testing.T) {
client := createTestClient(t)
makeInvoiceResult, err := client.MakeInvoice(context.TODO(), &MakeInvoiceParams{
Amount: uint64(1000),
})
require.NoError(t, err)
require.NotNil(t, makeInvoiceResult)
listTransactionsResult, err := client.ListTransactions(context.TODO(), &ListTransactionsParams{
Unpaid: true,
})
require.NoError(t, err)
require.NotNil(t, listTransactionsResult)
require.NotZero(t, len(listTransactionsResult.Transactions))
require.NotZero(t, listTransactionsResult.TotalCount)
transaction := listTransactionsResult.Transactions[0]
require.NoError(t, err)
require.NotNil(t, transaction)
assert.Equal(t, transaction.Amount, uint64(1000))
assert.True(t, strings.HasPrefix(transaction.Invoice, "lnbc"))
assert.Equal(t, "pending", transaction.State)
assert.Nil(t, transaction.SettledAt)
assert.Greater(t, transaction.ExpiresAt, uint64(time.Now().Unix()))
assert.Empty(t, transaction.Preimage)
}
func TestGetBalance(t *testing.T) {
client := createTestClient(t)
getBalanceResult, err := client.GetBalance(context.TODO())
require.NoError(t, err)
require.NotNil(t, getBalanceResult)
assert.Equal(t, uint64(100_000), getBalanceResult.Balance)
}
func TestPayInvoice(t *testing.T) {
client := createTestClient(t)
makeInvoiceResult, err := client.MakeInvoice(context.TODO(), &MakeInvoiceParams{
Amount: uint64(1000),
})
require.NoError(t, err)
require.NotNil(t, makeInvoiceResult)
payInvoiceResult, err := client.PayInvoice(context.TODO(), &PayInvoiceParams{
Invoice: makeInvoiceResult.Invoice,
})
require.NoError(t, err)
require.NotNil(t, payInvoiceResult)
assert.Equal(t, 64, len(payInvoiceResult.Preimage))
assert.Equal(t, uint64(0), payInvoiceResult.FeesPaid)
require.NoError(t, err)
require.NotNil(t, makeInvoiceResult)
lookupInvoiceResult, err := client.LookupInvoice(context.TODO(), &LookupInvoiceParams{
PaymentHash: makeInvoiceResult.PaymentHash,
})
require.NoError(t, err)
require.NotNil(t, lookupInvoiceResult)
require.NoError(t, err)
require.NotNil(t, lookupInvoiceResult)
assert.Equal(t, lookupInvoiceResult.Amount, uint64(1000))
assert.True(t, strings.HasPrefix(lookupInvoiceResult.Invoice, "lnbc"))
assert.Equal(t, "settled", lookupInvoiceResult.State)
require.NotNil(t, lookupInvoiceResult.SettledAt)
assert.LessOrEqual(t, *lookupInvoiceResult.SettledAt, uint64(time.Now().Unix()))
assert.Greater(t, lookupInvoiceResult.ExpiresAt, uint64(time.Now().Unix()))
assert.Equal(t, 64, len(lookupInvoiceResult.Preimage))
}
func createTestClient(t *testing.T) *NWCClient {
nwcUri := os.Getenv("NWC_URI")
if nwcUri == "" {
t.Skip()
return nil
}
client, err := NewNWCClientFromURI(context.TODO(), nwcUri, nil)
require.NoError(t, err)
require.NotNil(t, client)
return client
}