Merge pull request #15

feat: NWC Subscription System
This commit is contained in:
2025-08-19 16:57:47 +01:00
committed by GitHub
32 changed files with 2675 additions and 3217 deletions

View File

@@ -154,4 +154,4 @@ func generateQueryFilter(index int) *filter.F {
Limit: &limit, Limit: &limit,
} }
} }
} }

View File

@@ -1,162 +0,0 @@
# NWC Client CLI Tool
A command-line interface tool for making calls to Nostr Wallet Connect (NWC) services.
## Overview
This CLI tool allows you to interact with NWC wallet services using the methods defined in the NIP-47 specification. It provides a simple interface for executing wallet operations and displays the JSON response from the wallet service.
## Usage
```
nwcclient <connection URL> <method> [parameters...]
```
### Connection URL
The connection URL should be in the Nostr Wallet Connect format:
```
nostr+walletconnect://<wallet_pubkey>?relay=<relay_url>&secret=<secret>
```
### Supported Methods
The following methods are supported by this CLI tool:
- `get_info` - Get wallet information
- `get_balance` - Get wallet balance
- `get_budget` - Get wallet budget
- `make_invoice` - Create an invoice
- `pay_invoice` - Pay an invoice
- `pay_keysend` - Send a keysend payment
- `lookup_invoice` - Look up an invoice
- `list_transactions` - List transactions
- `sign_message` - Sign a message
### Unsupported Methods
The following methods are defined in the NIP-47 specification but are not directly supported by this CLI tool due to limitations in the underlying nwc package:
- `create_connection` - Create a connection
- `make_hold_invoice` - Create a hold invoice
- `settle_hold_invoice` - Settle a hold invoice
- `cancel_hold_invoice` - Cancel a hold invoice
- `multi_pay_invoice` - Pay multiple invoices
- `multi_pay_keysend` - Send multiple keysend payments
## Method Parameters
### Methods with No Parameters
- `get_info`
- `get_balance`
- `get_budget`
Example:
```
nwcclient <connection URL> get_info
```
### Methods with Parameters
#### make_invoice
```
nwcclient <connection URL> make_invoice <amount> <description> [description_hash] [expiry]
```
- `amount` - Amount in millisatoshis (msats)
- `description` - Invoice description
- `description_hash` (optional) - Hash of the description
- `expiry` (optional) - Expiry time in seconds
Example:
```
nwcclient <connection URL> make_invoice 1000000 "Test invoice" "" 3600
```
#### pay_invoice
```
nwcclient <connection URL> pay_invoice <invoice> [amount]
```
- `invoice` - BOLT11 invoice
- `amount` (optional) - Amount in millisatoshis (msats)
Example:
```
nwcclient <connection URL> pay_invoice lnbc1...
```
#### pay_keysend
```
nwcclient <connection URL> pay_keysend <amount> <pubkey> [preimage]
```
- `amount` - Amount in millisatoshis (msats)
- `pubkey` - Recipient's public key
- `preimage` (optional) - Payment preimage
Example:
```
nwcclient <connection URL> pay_keysend 1000000 03...
```
#### lookup_invoice
```
nwcclient <connection URL> lookup_invoice <payment_hash_or_invoice>
```
- `payment_hash_or_invoice` - Payment hash or BOLT11 invoice
Example:
```
nwcclient <connection URL> lookup_invoice 3d...
```
#### list_transactions
```
nwcclient <connection URL> list_transactions [from <timestamp>] [until <timestamp>] [limit <count>] [offset <count>] [unpaid <true|false>] [type <incoming|outgoing>]
```
Parameters are specified as name-value pairs:
- `from` - Start timestamp
- `until` - End timestamp
- `limit` - Maximum number of transactions to return
- `offset` - Number of transactions to skip
- `unpaid` - Whether to include unpaid transactions
- `type` - Transaction type (incoming or outgoing)
Example:
```
nwcclient <connection URL> list_transactions limit 10 type incoming
```
#### sign_message
```
nwcclient <connection URL> sign_message <message>
```
- `message` - Message to sign
Example:
```
nwcclient <connection URL> sign_message "Hello, world!"
```
## Output
The tool prints the JSON response from the wallet service to stdout. If an error occurs, an error message is printed to stderr.
## Limitations
- The tool only supports methods that have direct client methods in the nwc package.
- Complex parameters like metadata are not supported.
- The tool does not support interactive authentication or authorization.

View File

@@ -1,453 +0,0 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/protocol/nwc"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/interrupt"
)
func printUsage() {
fmt.Println("Usage: walletcli \"<NWC connection URL>\" <method> [<args...>]")
fmt.Println("\nAvailable methods:")
fmt.Println(" get_wallet_service_info - Get wallet service information")
fmt.Println(" get_info - Get wallet information")
fmt.Println(" get_balance - Get wallet balance")
fmt.Println(" get_budget - Get wallet budget")
fmt.Println(" make_invoice - Create an invoice")
fmt.Println(" Args: <amount> [<description>] [<description_hash>] [<expiry>]")
fmt.Println(" pay_invoice - Pay an invoice")
fmt.Println(" Args: <invoice> [<amount>] [<comment>]")
fmt.Println(" pay_keysend - Pay to a node using keysend")
fmt.Println(" Args: <pubkey> <amount> [<preimage>] [<tlv_type> <tlv_value>...]")
fmt.Println(" lookup_invoice - Look up an invoice")
fmt.Println(" Args: <payment_hash or invoice>")
fmt.Println(" list_transactions - List transactions")
fmt.Println(" Args: [<limit>] [<offset>] [<from>] [<until>]")
fmt.Println(" make_hold_invoice - Create a hold invoice")
fmt.Println(" Args: <amount> <payment_hash> [<description>] [<description_hash>] [<expiry>]")
fmt.Println(" settle_hold_invoice - Settle a hold invoice")
fmt.Println(" Args: <preimage>")
fmt.Println(" cancel_hold_invoice - Cancel a hold invoice")
fmt.Println(" Args: <payment_hash>")
fmt.Println(" sign_message - Sign a message")
fmt.Println(" Args: <message>")
fmt.Println(" create_connection - Create a connection")
fmt.Println(" Args: <pubkey> <name> <methods> [<notification_types>] [<max_amount>] [<budget_renewal>] [<expires_at>]")
fmt.Println(" subscribe - Subscribe to payment_received, payment_sent and hold_invoice_accepted notifications visible in the scope of the connection")
}
func main() {
if len(os.Args) < 3 {
printUsage()
os.Exit(1)
}
connectionURL := os.Args[1]
method := os.Args[2]
args := os.Args[3:]
// Create context
// c, cancel := context.Cancel(context.Bg())
c := context.Bg()
// defer cancel()
// Create NWC client
cl, err := nwc.NewClient(c, connectionURL)
if err != nil {
fmt.Printf("Error creating client: %v\n", err)
os.Exit(1)
}
// Execute the requested method
switch method {
case "get_wallet_service_info":
handleGetWalletServiceInfo(c, cl)
case "get_info":
handleGetInfo(c, cl)
case "get_balance":
handleGetBalance(c, cl)
case "get_budget":
handleGetBudget(c, cl)
case "make_invoice":
handleMakeInvoice(c, cl, args)
case "pay_invoice":
handlePayInvoice(c, cl, args)
case "pay_keysend":
handlePayKeysend(c, cl, args)
case "lookup_invoice":
handleLookupInvoice(c, cl, args)
case "list_transactions":
handleListTransactions(c, cl, args)
case "make_hold_invoice":
handleMakeHoldInvoice(c, cl, args)
case "settle_hold_invoice":
handleSettleHoldInvoice(c, cl, args)
case "cancel_hold_invoice":
handleCancelHoldInvoice(c, cl, args)
case "sign_message":
handleSignMessage(c, cl, args)
case "create_connection":
handleCreateConnection(c, cl, args)
case "subscribe":
handleSubscribe(c, cl)
default:
fmt.Printf("Unknown method: %s\n", method)
printUsage()
os.Exit(1)
}
}
func handleGetWalletServiceInfo(c context.T, cl *nwc.Client) {
if _, raw, err := cl.GetWalletServiceInfo(c, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleCancelHoldInvoice(c context.T, cl *nwc.Client, args []string) {
if len(args) < 1 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> cancel_hold_invoice <payment_hash>")
return
}
params := &nwc.CancelHoldInvoiceParams{
PaymentHash: args[0],
}
var err error
var raw []byte
if raw, err = cl.CancelHoldInvoice(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleCreateConnection(c context.T, cl *nwc.Client, args []string) {
if len(args) < 3 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> create_connection <pubkey> <name> <methods> [<notification_types>] [<max_amount>] [<budget_renewal>] [<expires_at>]")
return
}
params := &nwc.CreateConnectionParams{
Pubkey: args[0],
Name: args[1],
RequestMethods: strings.Split(args[2], ","),
}
if len(args) > 3 {
params.NotificationTypes = strings.Split(args[3], ",")
}
if len(args) > 4 {
maxAmount, err := strconv.ParseUint(args[4], 10, 64)
if err != nil {
fmt.Printf("Error parsing max_amount: %v\n", err)
return
}
params.MaxAmount = &maxAmount
}
if len(args) > 5 {
params.BudgetRenewal = &args[5]
}
if len(args) > 6 {
expiresAt, err := strconv.ParseInt(args[6], 10, 64)
if err != nil {
fmt.Printf("Error parsing expires_at: %v\n", err)
return
}
params.ExpiresAt = &expiresAt
}
var raw []byte
var err error
if raw, err = cl.CreateConnection(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleGetBalance(c context.T, cl *nwc.Client) {
if _, raw, err := cl.GetBalance(c, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleGetBudget(c context.T, cl *nwc.Client) {
if _, raw, err := cl.GetBudget(c, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleGetInfo(c context.T, cl *nwc.Client) {
if _, raw, err := cl.GetInfo(c, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleListTransactions(c context.T, cl *nwc.Client, args []string) {
params := &nwc.ListTransactionsParams{}
if len(args) > 0 {
limit, err := strconv.ParseUint(args[0], 10, 16)
if err != nil {
fmt.Printf("Error parsing limit: %v\n", err)
return
}
limitUint16 := uint16(limit)
params.Limit = &limitUint16
}
if len(args) > 1 {
offset, err := strconv.ParseUint(args[1], 10, 32)
if err != nil {
fmt.Printf("Error parsing offset: %v\n", err)
return
}
offsetUint32 := uint32(offset)
params.Offset = &offsetUint32
}
if len(args) > 2 {
from, err := strconv.ParseInt(args[2], 10, 64)
if err != nil {
fmt.Printf("Error parsing from: %v\n", err)
return
}
params.From = &from
}
if len(args) > 3 {
until, err := strconv.ParseInt(args[3], 10, 64)
if err != nil {
fmt.Printf("Error parsing until: %v\n", err)
return
}
params.Until = &until
}
var raw []byte
var err error
if _, raw, err = cl.ListTransactions(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleLookupInvoice(c context.T, cl *nwc.Client, args []string) {
if len(args) < 1 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> lookup_invoice <payment_hash or invoice>")
return
}
params := &nwc.LookupInvoiceParams{}
// Determine if the argument is a payment hash or an invoice
if strings.HasPrefix(args[0], "ln") {
invoice := args[0]
params.Invoice = &invoice
} else {
paymentHash := args[0]
params.PaymentHash = &paymentHash
}
var err error
var raw []byte
if _, raw, err = cl.LookupInvoice(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleMakeHoldInvoice(c context.T, cl *nwc.Client, args []string) {
if len(args) < 2 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> make_hold_invoice <amount> <payment_hash> [<description>] [<description_hash>] [<expiry>]")
return
}
amount, err := strconv.ParseUint(args[0], 10, 64)
if err != nil {
fmt.Printf("Error parsing amount: %v\n", err)
return
}
params := &nwc.MakeHoldInvoiceParams{
Amount: amount,
PaymentHash: args[1],
}
if len(args) > 2 {
params.Description = args[2]
}
if len(args) > 3 {
params.DescriptionHash = args[3]
}
if len(args) > 4 {
expiry, err := strconv.ParseInt(args[4], 10, 64)
if err != nil {
fmt.Printf("Error parsing expiry: %v\n", err)
return
}
params.Expiry = &expiry
}
var raw []byte
if _, raw, err = cl.MakeHoldInvoice(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleMakeInvoice(c context.T, cl *nwc.Client, args []string) {
if len(args) < 1 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> make_invoice <amount> [<description>] [<description_hash>] [<expiry>]")
return
}
amount, err := strconv.ParseUint(args[0], 10, 64)
if err != nil {
fmt.Printf("Error parsing amount: %v\n", err)
return
}
params := &nwc.MakeInvoiceParams{
Amount: amount,
}
if len(args) > 1 {
params.Description = args[1]
}
if len(args) > 2 {
params.DescriptionHash = args[2]
}
if len(args) > 3 {
expiry, err := strconv.ParseInt(args[3], 10, 64)
if err != nil {
fmt.Printf("Error parsing expiry: %v\n", err)
return
}
params.Expiry = &expiry
}
var raw []byte
if _, raw, err = cl.MakeInvoice(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handlePayKeysend(c context.T, cl *nwc.Client, args []string) {
if len(args) < 2 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> pay_keysend <pubkey> <amount> [<preimage>] [<tlv_type> <tlv_value>...]")
return
}
pubkey := args[0]
amount, err := strconv.ParseUint(args[1], 10, 64)
if err != nil {
fmt.Printf("Error parsing amount: %v\n", err)
return
}
params := &nwc.PayKeysendParams{
Pubkey: pubkey,
Amount: amount,
}
// Optional preimage
if len(args) > 2 {
preimage := args[2]
params.Preimage = &preimage
}
// Optional TLV records (must come in pairs)
if len(args) > 3 {
// Start from index 3 and process pairs of arguments
for i := 3; i < len(args)-1; i += 2 {
tlvType, err := strconv.ParseUint(args[i], 10, 32)
if err != nil {
fmt.Printf("Error parsing TLV type: %v\n", err)
return
}
tlvValue := args[i+1]
params.TLVRecords = append(
params.TLVRecords, nwc.PayKeysendTLVRecord{
Type: uint32(tlvType),
Value: tlvValue,
},
)
}
}
var raw []byte
if _, raw, err = cl.PayKeysend(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handlePayInvoice(c context.T, cl *nwc.Client, args []string) {
if len(args) < 1 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> pay_invoice <invoice> [<amount>] [<comment>]")
return
}
params := &nwc.PayInvoiceParams{
Invoice: args[0],
}
if len(args) > 1 {
amount, err := strconv.ParseUint(args[1], 10, 64)
if err != nil {
fmt.Printf("Error parsing amount: %v\n", err)
return
}
params.Amount = &amount
}
if len(args) > 2 {
comment := args[2]
params.Metadata = &nwc.PayInvoiceMetadata{
Comment: &comment,
}
}
if _, raw, err := cl.PayInvoice(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleSettleHoldInvoice(c context.T, cl *nwc.Client, args []string) {
if len(args) < 1 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> settle_hold_invoice <preimage>")
return
}
params := &nwc.SettleHoldInvoiceParams{
Preimage: args[0],
}
var raw []byte
var err error
if raw, err = cl.SettleHoldInvoice(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleSignMessage(c context.T, cl *nwc.Client, args []string) {
if len(args) < 1 {
fmt.Println("Error: Missing required arguments")
fmt.Println("Usage: walletcli <NWC connection URL> sign_message <message>")
return
}
params := &nwc.SignMessageParams{
Message: args[0],
}
var raw []byte
var err error
if _, raw, err = cl.SignMessage(c, params, true); !chk.E(err) {
fmt.Println(string(raw))
}
}
func handleSubscribe(c context.T, cl *nwc.Client) {
// Create a context with a cancel
c, cancel := context.Cancel(c)
interrupt.AddHandler(cancel)
// Get wallet service info to check if notifications are supported
wsi, _, err := cl.GetWalletServiceInfo(c, false)
if err != nil {
fmt.Printf("Error getting wallet service info: %v\n", err)
return
}
// Check if the wallet supports notifications
if len(wsi.NotificationTypes) == 0 {
fmt.Println("Wallet does not support notifications")
return
}
var evc event.C
if evc, err = cl.Subscribe(c); chk.E(err) {
return
}
for {
select {
case <-c.Done():
return
case ev := <-evc:
fmt.Println(ev.Marshal(nil))
}
}
}

View File

@@ -1,207 +0,0 @@
# Mock Wallet Service Examples
This document contains example commands for testing the mock wallet service using the CLI client.
## Starting the Mock Wallet Service
To start the mock wallet service, run the following command from the project root:
```bash
go run cmd/walletcli/mock-wallet-service/main.go --relay ws://localhost:8080 --generate-key
```
This will generate a new wallet key and connect to a relay at ws://localhost:8080. The output will include the wallet's public key, which you'll need for connecting to it.
Alternatively, you can provide your own wallet key:
```bash
go run cmd/walletcli/mock-wallet-service/main.go --relay ws://localhost:8080 --key YOUR_PRIVATE_KEY_HEX
```
## Connecting to the Mock Wallet Service
To connect to the mock wallet service, you'll need to create a connection URL in the following format:
```
nostr+walletconnect://WALLET_PUBLIC_KEY?relay=ws://localhost:8080&secret=CLIENT_SECRET_KEY
```
Where:
- `WALLET_PUBLIC_KEY` is the public key of the wallet service (printed when starting the service)
- `CLIENT_SECRET_KEY` is a private key for the client (you can generate one using any nostr key generation tool)
For example:
```
nostr+walletconnect://7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e?relay=ws://localhost:8080&secret=d5e4f0a6b2c8a9e7d1f3b5a8c2e4f6a8b0d2c4e6f8a0b2d4e6f8a0c2e4d6b8a0
```
## Example Commands
Below are example commands for each method supported by the mock wallet service. Replace `CONNECTION_URL` with your actual connection URL.
### Get Wallet Service Info
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" get_wallet_service_info
```
### Get Info
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" get_info
```
### Get Balance
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" get_balance
```
### Get Budget
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" get_budget
```
### Make Invoice
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" make_invoice 1000 "Test invoice"
```
This creates an invoice for 1000 sats with the description "Test invoice".
### Pay Invoice
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" pay_invoice "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4"
```
This pays an invoice. You can use any valid Lightning invoice string.
### Pay Keysend
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" pay_keysend "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 1000
```
This sends 1000 sats to the specified public key using keysend.
### Lookup Invoice
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" lookup_invoice "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
```
This looks up an invoice by payment hash.
### List Transactions
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" list_transactions 10
```
This lists up to 10 transactions.
### Make Hold Invoice
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" make_hold_invoice 1000 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" "Test hold invoice"
```
This creates a hold invoice for 1000 sats with the specified payment hash and description.
### Settle Hold Invoice
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" settle_hold_invoice "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
```
This settles a hold invoice with the specified preimage.
### Cancel Hold Invoice
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" cancel_hold_invoice "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
```
This cancels a hold invoice with the specified payment hash.
### Sign Message
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" sign_message "Test message to sign"
```
This signs a message with the wallet's private key.
### Create Connection
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" create_connection "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" "Test Connection" "get_info,get_balance,make_invoice" "payment_received,payment_sent"
```
This creates a connection with the specified public key, name, methods, and notification types.
### Subscribe
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" subscribe
```
This subscribes to notifications from the wallet service.
## Complete Example Workflow
Here's a complete example workflow for testing the mock wallet service:
1. Start the mock wallet service:
```bash
go run cmd/walletcli/mock-wallet-service/main.go --relay ws://localhost:8080 --generate-key
```
2. Note the wallet's public key from the output.
3. Generate a client secret key (or use an existing one).
4. Create a connection URL:
```
nostr+walletconnect://WALLET_PUBLIC_KEY?relay=ws://localhost:8080&secret=CLIENT_SECRET_KEY
```
5. Get wallet service info:
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" get_wallet_service_info
```
6. Get wallet info:
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" get_info
```
7. Get wallet balance:
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" get_balance
```
8. Create an invoice:
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" make_invoice 1000 "Test invoice"
```
9. Look up the invoice:
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" lookup_invoice "PAYMENT_HASH_FROM_INVOICE"
```
10. Subscribe to notifications:
```bash
go run cmd/walletcli/main.go "CONNECTION_URL" subscribe
```
## Notes
- The mock wallet service returns generic results for all methods, regardless of the input parameters.
- The mock wallet service does not actually perform any real Lightning Network operations.
- The mock wallet service does not persist any data between restarts.

View File

@@ -1,456 +0,0 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"time"
"orly.dev/pkg/crypto/encryption"
"orly.dev/pkg/crypto/p256k"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/filter"
"orly.dev/pkg/encoders/filters"
"orly.dev/pkg/encoders/hex"
"orly.dev/pkg/encoders/kind"
"orly.dev/pkg/encoders/kinds"
"orly.dev/pkg/encoders/tag"
"orly.dev/pkg/encoders/tags"
"orly.dev/pkg/encoders/timestamp"
"orly.dev/pkg/interfaces/signer"
"orly.dev/pkg/protocol/nwc"
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/interrupt"
)
var (
relayURL = flag.String("relay", "ws://localhost:8080", "Relay URL to connect to")
walletKey = flag.String("key", "", "Wallet private key (hex)")
generateKey = flag.Bool("generate-key", false, "Generate a new wallet key")
)
func main() {
flag.Parse()
// Create context
c, cancel := context.Cancel(context.Bg())
interrupt.AddHandler(cancel)
defer cancel()
// Initialize wallet key
var walletSigner signer.I
var err error
if *generateKey {
// Generate a new wallet key
walletSigner = &p256k.Signer{}
if err = walletSigner.Generate(); chk.E(err) {
fmt.Printf("Error generating wallet key: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated wallet key: %s\n", hex.Enc(walletSigner.Sec()))
fmt.Printf("Wallet public key: %s\n", hex.Enc(walletSigner.Pub()))
} else if *walletKey != "" {
// Use provided wallet key
if walletSigner, err = p256k.NewSecFromHex(*walletKey); chk.E(err) {
fmt.Printf("Error initializing wallet key: %v\n", err)
os.Exit(1)
}
fmt.Printf("Using wallet key: %s\n", *walletKey)
fmt.Printf("Wallet public key: %s\n", hex.Enc(walletSigner.Pub()))
} else {
// Generate a temporary wallet key
walletSigner = &p256k.Signer{}
if err = walletSigner.Generate(); chk.E(err) {
fmt.Printf("Error generating temporary wallet key: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated temporary wallet key: %s\n", hex.Enc(walletSigner.Sec()))
fmt.Printf("Wallet public key: %s\n", hex.Enc(walletSigner.Pub()))
}
// Connect to relay
fmt.Printf("Connecting to relay: %s\n", *relayURL)
relay, err := ws.RelayConnect(c, *relayURL)
if err != nil {
fmt.Printf("Error connecting to relay: %v\n", err)
os.Exit(1)
}
defer relay.Close()
fmt.Println("Connected to relay")
// Create a mock wallet service info event
walletServiceInfoEvent := createWalletServiceInfoEvent(walletSigner)
// Publish wallet service info event
if err = relay.Publish(c, walletServiceInfoEvent); chk.E(err) {
fmt.Printf("Error publishing wallet service info: %v\n", err)
os.Exit(1)
}
fmt.Println("Published wallet service info")
// Subscribe to wallet requests
fmt.Println("Subscribing to wallet requests...")
sub, err := relay.Subscribe(
c, filters.New(
&filter.F{
Kinds: kinds.New(kind.WalletRequest),
Tags: tags.New(tag.New("#p", hex.Enc(walletSigner.Pub()))),
},
),
)
if err != nil {
fmt.Printf("Error subscribing to wallet requests: %v\n", err)
os.Exit(1)
}
defer sub.Unsub()
fmt.Println("Subscribed to wallet requests")
// Process wallet requests
fmt.Println("Waiting for wallet requests...")
for {
select {
case <-c.Done():
fmt.Println("Context canceled, exiting")
return
case ev := <-sub.Events:
fmt.Printf("Received wallet request: %s\n", hex.Enc(ev.ID))
go handleWalletRequest(c, relay, walletSigner, ev)
}
}
}
// handleWalletRequest processes a wallet request and sends a response
func handleWalletRequest(c context.T, relay *ws.Client, walletKey signer.I, ev *event.E) {
// Get the client's public key from the event
clientPubKey := ev.Pubkey
// Generate conversation key
var ck []byte
var err error
if ck, err = encryption.GenerateConversationKeyWithSigner(
walletKey,
clientPubKey,
); chk.E(err) {
fmt.Printf("Error generating conversation key: %v\n", err)
return
}
// Decrypt the content
var content []byte
if content, err = encryption.Decrypt(ev.Content, ck); chk.E(err) {
fmt.Printf("Error decrypting content: %v\n", err)
return
}
// Parse the request
var req nwc.Request
if err = json.Unmarshal(content, &req); chk.E(err) {
fmt.Printf("Error parsing request: %v\n", err)
return
}
fmt.Printf("Handling method: %s\n", req.Method)
// Process the request based on the method
var result interface{}
var respErr *nwc.ResponseError
switch req.Method {
case string(nwc.GetWalletServiceInfo):
result = handleGetWalletServiceInfo()
case string(nwc.GetInfo):
result = handleGetInfo(walletKey)
case string(nwc.GetBalance):
result = handleGetBalance()
case string(nwc.GetBudget):
result = handleGetBudget()
case string(nwc.MakeInvoice):
result = handleMakeInvoice()
case string(nwc.PayInvoice):
result = handlePayInvoice()
case string(nwc.PayKeysend):
result = handlePayKeysend()
case string(nwc.LookupInvoice):
result = handleLookupInvoice()
case string(nwc.ListTransactions):
result = handleListTransactions()
case string(nwc.MakeHoldInvoice):
result = handleMakeHoldInvoice()
case string(nwc.SettleHoldInvoice):
// No result for SettleHoldInvoice
case string(nwc.CancelHoldInvoice):
// No result for CancelHoldInvoice
case string(nwc.SignMessage):
result = handleSignMessage()
case string(nwc.CreateConnection):
// No result for CreateConnection
default:
respErr = &nwc.ResponseError{
Code: "method_not_found",
Message: fmt.Sprintf("method %s not found", req.Method),
}
}
// Create response
resp := nwc.Response{
ResultType: req.Method,
Result: result,
Error: respErr,
}
// Marshal response
var respBytes []byte
if respBytes, err = json.Marshal(resp); chk.E(err) {
fmt.Printf("Error marshaling response: %v\n", err)
return
}
// Encrypt response
var encResp []byte
if encResp, err = encryption.Encrypt(respBytes, ck); chk.E(err) {
fmt.Printf("Error encrypting response: %v\n", err)
return
}
// Create response event
respEv := &event.E{
Content: encResp,
CreatedAt: timestamp.Now(),
Kind: kind.WalletResponse,
Tags: tags.New(
tag.New("p", hex.Enc(clientPubKey)),
tag.New("e", hex.Enc(ev.ID)),
tag.New(string(nwc.EncryptionTag), string(nwc.Nip44V2)),
),
}
// Sign the response event
if err = respEv.Sign(walletKey); chk.E(err) {
fmt.Printf("Error signing response event: %v\n", err)
return
}
// Publish the response event
if err = relay.Publish(c, respEv); chk.E(err) {
fmt.Printf("Error publishing response event: %v\n", err)
return
}
fmt.Printf("Successfully handled request: %s\n", hex.Enc(ev.ID))
}
// createWalletServiceInfoEvent creates a wallet service info event
func createWalletServiceInfoEvent(walletKey signer.I) *event.E {
ev := &event.E{
Content: []byte(
string(nwc.GetWalletServiceInfo) + " " +
string(nwc.GetInfo) + " " +
string(nwc.GetBalance) + " " +
string(nwc.GetBudget) + " " +
string(nwc.MakeInvoice) + " " +
string(nwc.PayInvoice) + " " +
string(nwc.PayKeysend) + " " +
string(nwc.LookupInvoice) + " " +
string(nwc.ListTransactions) + " " +
string(nwc.MakeHoldInvoice) + " " +
string(nwc.SettleHoldInvoice) + " " +
string(nwc.CancelHoldInvoice) + " " +
string(nwc.SignMessage) + " " +
string(nwc.CreateConnection),
),
CreatedAt: timestamp.Now(),
Kind: kind.WalletServiceInfo,
Tags: tags.New(
tag.New(string(nwc.EncryptionTag), string(nwc.Nip44V2)),
tag.New(string(nwc.NotificationTag), string(nwc.PaymentReceived)+" "+string(nwc.PaymentSent)+" "+string(nwc.HoldInvoiceAccepted)),
),
}
if err := ev.Sign(walletKey); chk.E(err) {
fmt.Printf("Error signing wallet service info event: %v\n", err)
os.Exit(1)
}
return ev
}
// Handler functions for each method
func handleGetWalletServiceInfo() *nwc.WalletServiceInfo {
fmt.Println("Handling GetWalletServiceInfo request")
return &nwc.WalletServiceInfo{
EncryptionTypes: []nwc.EncryptionType{nwc.Nip44V2},
Capabilities: []nwc.Capability{
nwc.GetWalletServiceInfo,
nwc.GetInfo,
nwc.GetBalance,
nwc.GetBudget,
nwc.MakeInvoice,
nwc.PayInvoice,
nwc.PayKeysend,
nwc.LookupInvoice,
nwc.ListTransactions,
nwc.MakeHoldInvoice,
nwc.SettleHoldInvoice,
nwc.CancelHoldInvoice,
nwc.SignMessage,
nwc.CreateConnection,
},
NotificationTypes: []nwc.NotificationType{
nwc.PaymentReceived,
nwc.PaymentSent,
nwc.HoldInvoiceAccepted,
},
}
}
func handleGetInfo(walletKey signer.I) *nwc.GetInfoResult {
fmt.Println("Handling GetInfo request")
return &nwc.GetInfoResult{
Alias: "Mock Wallet",
Color: "#ff9900",
Pubkey: hex.Enc(walletKey.Pub()),
Network: "testnet",
BlockHeight: 123456,
BlockHash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
Methods: []string{
string(nwc.GetWalletServiceInfo),
string(nwc.GetInfo),
string(nwc.GetBalance),
string(nwc.GetBudget),
string(nwc.MakeInvoice),
string(nwc.PayInvoice),
string(nwc.PayKeysend),
string(nwc.LookupInvoice),
string(nwc.ListTransactions),
string(nwc.MakeHoldInvoice),
string(nwc.SettleHoldInvoice),
string(nwc.CancelHoldInvoice),
string(nwc.SignMessage),
string(nwc.CreateConnection),
},
Notifications: []string{
string(nwc.PaymentReceived),
string(nwc.PaymentSent),
string(nwc.HoldInvoiceAccepted),
},
}
}
func handleGetBalance() *nwc.GetBalanceResult {
fmt.Println("Handling GetBalance request")
return &nwc.GetBalanceResult{
Balance: 1000000, // 1,000,000 sats
}
}
func handleGetBudget() *nwc.GetBudgetResult {
fmt.Println("Handling GetBudget request")
return &nwc.GetBudgetResult{
UsedBudget: 5000,
TotalBudget: 10000,
RenewsAt: int(time.Now().Add(24 * time.Hour).Unix()),
RenewalPeriod: "daily",
}
}
func handleMakeInvoice() *nwc.Transaction {
fmt.Println("Handling MakeInvoice request")
return &nwc.Transaction{
Type: "invoice",
State: "unpaid",
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Mock invoice",
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Amount: 1000,
CreatedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
}
func handlePayInvoice() *nwc.PayInvoiceResult {
fmt.Println("Handling PayInvoice request")
return &nwc.PayInvoiceResult{
Preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
FeesPaid: 10,
}
}
func handlePayKeysend() *nwc.PayKeysendResult {
fmt.Println("Handling PayKeysend request")
return &nwc.PayKeysendResult{
Preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
FeesPaid: 5,
}
}
func handleLookupInvoice() *nwc.Transaction {
fmt.Println("Handling LookupInvoice request")
return &nwc.Transaction{
Type: "invoice",
State: "settled",
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Mock invoice",
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Amount: 1000,
CreatedAt: time.Now().Add(-1 * time.Hour).Unix(),
ExpiresAt: time.Now().Add(23 * time.Hour).Unix(),
}
}
func handleListTransactions() *nwc.ListTransactionsResult {
fmt.Println("Handling ListTransactions request")
return &nwc.ListTransactionsResult{
Transactions: []nwc.Transaction{
{
Type: "incoming",
State: "settled",
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Mock incoming transaction",
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Amount: 1000,
CreatedAt: time.Now().Add(-24 * time.Hour).Unix(),
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
},
{
Type: "outgoing",
State: "settled",
Invoice: "lnbc20n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Mock outgoing transaction",
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Amount: 2000,
FeesPaid: 10,
CreatedAt: time.Now().Add(-12 * time.Hour).Unix(),
ExpiresAt: time.Now().Add(36 * time.Hour).Unix(),
},
},
TotalCount: 2,
}
}
func handleMakeHoldInvoice() *nwc.Transaction {
fmt.Println("Handling MakeHoldInvoice request")
return &nwc.Transaction{
Type: "hold_invoice",
State: "unpaid",
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Mock hold invoice",
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Amount: 1000,
CreatedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
}
func handleSignMessage() *nwc.SignMessageResult {
fmt.Println("Handling SignMessage request")
return &nwc.SignMessageResult{
Message: "Mock message",
Signature: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
}

3
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/dgraph-io/badger/v4 v4.8.0 github.com/dgraph-io/badger/v4 v4.8.0
github.com/fasthttp/websocket v1.5.12 github.com/fasthttp/websocket v1.5.12
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/go-chi/chi/v5 v5.2.2
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/klauspost/cpuid/v2 v2.3.0 github.com/klauspost/cpuid/v2 v2.3.0
github.com/minio/sha256-simd v1.0.1 github.com/minio/sha256-simd v1.0.1
@@ -19,6 +20,7 @@ require (
github.com/rs/cors v1.11.1 github.com/rs/cors v1.11.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b
github.com/vmihailenco/msgpack/v5 v5.4.1
go-simpler.org/env v0.12.0 go-simpler.org/env v0.12.0
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.41.0
@@ -50,6 +52,7 @@ require (
github.com/templexxx/cpu v0.1.1 // indirect github.com/templexxx/cpu v0.1.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.65.0 // indirect github.com/valyala/fasthttp v1.65.0 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect

31
go.sum
View File

@@ -26,8 +26,6 @@ github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNm
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y=
github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA=
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs= github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w= github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
@@ -43,6 +41,8 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -68,8 +68,6 @@ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uia
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -111,10 +109,12 @@ github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3W
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ= github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.63.0 h1:DisIL8OjB7ul2d7cBaMRcKTQDYnrGy56R4FCiuDP0Ns=
github.com/valyala/fasthttp v1.63.0/go.mod h1:REc4IeW+cAEyLrRPa5A81MIjvz0QE1laoTX2EaPHKJM=
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go-simpler.org/env v0.12.0 h1:kt/lBts0J1kjWJAnB740goNdvwNxt5emhYngL0Fzufs= go-simpler.org/env v0.12.0 h1:kt/lBts0J1kjWJAnB740goNdvwNxt5emhYngL0Fzufs=
@@ -131,12 +131,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp/typeparams v0.0.0-20250711185948-6ae5c78190dc h1:mPO8OXAJgNBiEFwAG1Lh4pe7uxJgEWPk+io1+SzvMfk= golang.org/x/exp/typeparams v0.0.0-20250711185948-6ae5c78190dc h1:mPO8OXAJgNBiEFwAG1Lh4pe7uxJgEWPk+io1+SzvMfk=
@@ -144,14 +140,10 @@ golang.org/x/exp/typeparams v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:LKZHyeO
golang.org/x/lint v0.0.0-20241112194109-818c5a804067 h1:adDmSQyFTCiv19j015EGKJBoaa7ElV0Q1Wovb/4G7NA= golang.org/x/lint v0.0.0-20241112194109-818c5a804067 h1:adDmSQyFTCiv19j015EGKJBoaa7ElV0Q1Wovb/4G7NA=
golang.org/x/lint v0.0.0-20241112194109-818c5a804067/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20241112194109-818c5a804067/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -162,26 +154,17 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY=
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -27,27 +27,30 @@ import (
// and default values. It defines parameters for app behaviour, storage // and default values. It defines parameters for app behaviour, storage
// locations, logging, and network settings used across the relay service. // locations, logging, and network settings used across the relay service.
type C struct { type C struct {
AppName string `env:"ORLY_APP_NAME" default:"ORLY"` AppName string `env:"ORLY_APP_NAME" default:"ORLY"`
Config string `env:"ORLY_CONFIG_DIR" usage:"location for configuration file, which has the name '.env' to make it harder to delete, and is a standard environment KEY=value<newline>... style" default:"~/.config/orly"` Config string `env:"ORLY_CONFIG_DIR" usage:"location for configuration file, which has the name '.env' to make it harder to delete, and is a standard environment KEY=value<newline>... style" default:"~/.config/orly"`
State string `env:"ORLY_STATE_DATA_DIR" usage:"storage location for state data affected by dynamic interactive interfaces" default:"~/.local/state/orly"` State string `env:"ORLY_STATE_DATA_DIR" usage:"storage location for state data affected by dynamic interactive interfaces" default:"~/.local/state/orly"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/cache/orly"` DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/cache/orly"`
Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"` Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"` Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"` LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"`
DbLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"` DbLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"debug level: fatal error warn info debug trace"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof on 127.0.0.1:6060" enum:"cpu,memory,allocation"` Pprof string `env:"ORLY_PPROF" usage:"enable pprof on 127.0.0.1:6060" enum:"cpu,memory,allocation"`
AuthRequired bool `env:"ORLY_AUTH_REQUIRED" default:"false" usage:"require authentication for all requests"` AuthRequired bool `env:"ORLY_AUTH_REQUIRED" default:"false" usage:"require authentication for all requests"`
PublicReadable bool `env:"ORLY_PUBLIC_READABLE" default:"true" usage:"allow public read access to regardless of whether the client is authed"` PublicReadable bool `env:"ORLY_PUBLIC_READABLE" default:"true" usage:"allow public read access to regardless of whether the client is authed"`
SpiderSeeds []string `env:"ORLY_SPIDER_SEEDS" usage:"seeds to use for the spider (relays that are looked up initially to find owner relay lists) (comma separated)" default:"wss://profiles.nostr1.com/,wss://relay.nostr.band/,wss://relay.damus.io/,wss://nostr.wine/,wss://nostr.land/,wss://theforest.nostr1.com/,wss://profiles.nostr1.com/"` SpiderSeeds []string `env:"ORLY_SPIDER_SEEDS" usage:"seeds to use for the spider (relays that are looked up initially to find owner relay lists) (comma separated)" default:"wss://profiles.nostr1.com/,wss://relay.nostr.band/,wss://relay.damus.io/,wss://nostr.wine/,wss://nostr.land/,wss://theforest.nostr1.com/,wss://profiles.nostr1.com/"`
SpiderType string `env:"ORLY_SPIDER_TYPE" usage:"whether to spider, and what degree of spidering: none, directory, follows (follows means to the second degree of the follow graph)" default:"directory"` SpiderType string `env:"ORLY_SPIDER_TYPE" usage:"whether to spider, and what degree of spidering: none, directory, follows (follows means to the second degree of the follow graph)" default:"directory"`
SpiderTime time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"how often to run the spider, uses notation 0h0m0s" default:"1h"` SpiderTime time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"how often to run the spider, uses notation 0h0m0s" default:"1h"`
SpiderSecondDegree bool `env:"ORLY_SPIDER_SECOND_DEGREE" default:"true" usage:"whether to enable spidering the second degree of follows for non-directory events if ORLY_SPIDER_TYPE is set to 'follows'"` SpiderSecondDegree bool `env:"ORLY_SPIDER_SECOND_DEGREE" default:"true" usage:"whether to enable spidering the second degree of follows for non-directory events if ORLY_SPIDER_TYPE is set to 'follows'"`
Owners []string `env:"ORLY_OWNERS" usage:"list of users whose follow lists designate whitelisted users who can publish events, and who can read if public readable is false (comma separated)"` Owners []string `env:"ORLY_OWNERS" usage:"list of users whose follow lists designate whitelisted users who can publish events, and who can read if public readable is false (comma separated)"`
Private bool `env:"ORLY_PRIVATE" usage:"do not spider for user metadata because the relay is private and this would leak relay memberships" default:"false"` Private bool `env:"ORLY_PRIVATE" usage:"do not spider for user metadata because the relay is private and this would leak relay memberships" default:"false"`
Whitelist []string `env:"ORLY_WHITELIST" usage:"only allow connections from this list of IP addresses"` Whitelist []string `env:"ORLY_WHITELIST" usage:"only allow connections from this list of IP addresses"`
Blacklist []string `env:"ORLY_BLACKLIST" usage:"list of pubkeys to block when auth is not required (comma separated)"` Blacklist []string `env:"ORLY_BLACKLIST" usage:"list of pubkeys to block when auth is not required (comma separated)"`
RelaySecret string `env:"ORLY_SECRET_KEY" usage:"secret key for relay cluster replication authentication"` RelaySecret string `env:"ORLY_SECRET_KEY" usage:"secret key for relay cluster replication authentication"`
PeerRelays []string `env:"ORLY_PEER_RELAYS" usage:"list of peer relays URLs that new events are pushed to in format <pubkey>|<url>"` PeerRelays []string `env:"ORLY_PEER_RELAYS" usage:"list of peer relays URLs that new events are pushed to in format <pubkey>|<url>"`
NWCUri string `env:"ORLY_NWC_URI" usage:"NWC (Nostr Wallet Connect) connection string for Lightning payments"`
SubscriptionEnabled bool `env:"ORLY_SUBSCRIPTION_ENABLED" default:"false" usage:"enable subscription-based access control requiring payment for non-directory events"`
MonthlyPriceSats int64 `env:"ORLY_MONTHLY_PRICE_SATS" default:"6000" usage:"price in satoshis for one month subscription (default ~$2 USD)"`
} }
// New creates and initializes a new configuration object for the relay // New creates and initializes a new configuration object for the relay

View File

@@ -3,13 +3,17 @@ package relay
import ( import (
"net/http" "net/http"
"orly.dev/pkg/utils" "orly.dev/pkg/utils"
"time"
"orly.dev/pkg/database"
"orly.dev/pkg/encoders/event" "orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/hex"
"orly.dev/pkg/utils/context" "orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/log"
) )
// AcceptEvent determines whether an incoming event should be accepted for // AcceptEvent determines whether an incoming event should be accepted for
// processing based on authentication requirements. // processing based on authentication requirements and subscription status.
// //
// # Parameters // # Parameters
// //
@@ -33,20 +37,77 @@ import (
// //
// # Expected Behaviour: // # Expected Behaviour:
// //
// - If authentication is required and no public key is provided, reject the // - If subscriptions are enabled, check subscription status for non-directory events
// event. //
// - If authentication is required and no public key is provided, reject the event.
// //
// - Otherwise, accept the event for processing. // - Otherwise, accept the event for processing.
func (s *Server) AcceptEvent( func (s *Server) AcceptEvent(
c context.T, ev *event.E, hr *http.Request, authedPubkey []byte, c context.T, ev *event.E, hr *http.Request, authedPubkey []byte,
remote string, remote string,
) (accept bool, notice string, afterSave func()) { ) (accept bool, notice string, afterSave func()) {
// Check subscription if enabled
if s.C.SubscriptionEnabled {
// Skip subscription check for directory events (kinds 0, 3, 10002)
kindInt := ev.Kind.ToInt()
isDirectoryEvent := kindInt == 0 || kindInt == 3 || kindInt == 10002
if !isDirectoryEvent {
// Check cache first
pubkeyHex := hex.Enc(ev.Pubkey)
now := time.Now()
s.subscriptionMutex.RLock()
cacheExpiry, cached := s.subscriptionCache[pubkeyHex]
s.subscriptionMutex.RUnlock()
if cached && now.Before(cacheExpiry) {
// Cache hit - subscription is active
accept = true
} else {
// Cache miss or expired - check database
if s.relay != nil && s.relay.Storage() != nil {
if db, ok := s.relay.Storage().(*database.D); ok {
isActive, err := db.IsSubscriptionActive(ev.Pubkey)
if err != nil {
log.E.F("error checking subscription for %s: %v", pubkeyHex, err)
notice = "error checking subscription status"
return
}
if !isActive {
notice = "subscription required - visit relay info page for payment details"
return
}
// Cache positive result for 60 seconds
s.subscriptionMutex.Lock()
s.subscriptionCache[pubkeyHex] = now.Add(60 * time.Second)
s.subscriptionMutex.Unlock()
accept = true
} else {
// Storage is not a database.D, subscription checks disabled
log.E.F("subscription enabled but storage is not database.D")
}
}
}
// If subscription check passed, continue with auth checks if needed
if !accept {
return
}
}
}
if !s.AuthRequired() { if !s.AuthRequired() {
// Check blacklist for public relay mode // Check blacklist for public relay mode
if len(s.blacklistPubkeys) > 0 { if len(s.blacklistPubkeys) > 0 {
for _, blockedPubkey := range s.blacklistPubkeys { for _, blockedPubkey := range s.blacklistPubkeys {
if utils.FastEqual(blockedPubkey, ev.Pubkey) { if utils.FastEqual(blockedPubkey, ev.Pubkey) {
notice = "event author is blacklisted" notice = "event author is blacklisted"
accept = false
return return
} }
} }
@@ -57,11 +118,13 @@ func (s *Server) AcceptEvent(
// if auth is required and the user is not authed, reject // if auth is required and the user is not authed, reject
if len(authedPubkey) == 0 { if len(authedPubkey) == 0 {
notice = "client isn't authed" notice = "client isn't authed"
accept = false
return return
} }
for _, u := range s.OwnersMuted() { for _, u := range s.OwnersMuted() {
if utils.FastEqual(u, authedPubkey) { if utils.FastEqual(u, authedPubkey) {
notice = "event author is banned from this relay" notice = "event author is banned from this relay"
accept = false
return return
} }
} }
@@ -73,5 +136,6 @@ func (s *Server) AcceptEvent(
return return
} }
} }
accept = false
return return
} }

346
pkg/app/relay/metrics.go Normal file
View File

@@ -0,0 +1,346 @@
package relay
import (
"fmt"
"net/http"
"sync"
"time"
"orly.dev/pkg/database"
"orly.dev/pkg/utils/log"
)
// MetricsCollector tracks subscription system metrics
type MetricsCollector struct {
mu sync.RWMutex
db *database.D
// Subscription metrics
totalTrialSubscriptions int64
totalPaidSubscriptions int64
// Payment metrics
paymentSuccessCount int64
paymentFailureCount int64
// Conversion metrics
trialToPaidConversions int64
totalTrialsStarted int64
// Duration metrics
subscriptionDurations []time.Duration
maxDurationSamples int
// Health status
lastHealthCheck time.Time
isHealthy bool
healthCheckErrors []string
}
// NewMetricsCollector creates a new metrics collector
func NewMetricsCollector(db *database.D) *MetricsCollector {
return &MetricsCollector{
db: db,
maxDurationSamples: 1000,
isHealthy: true,
lastHealthCheck: time.Now(),
}
}
// RecordTrialStarted increments trial subscription counter
func (mc *MetricsCollector) RecordTrialStarted() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.totalTrialsStarted++
mc.totalTrialSubscriptions++
}
// RecordPaidSubscription increments paid subscription counter
func (mc *MetricsCollector) RecordPaidSubscription() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.totalPaidSubscriptions++
}
// RecordTrialExpired decrements trial subscription counter
func (mc *MetricsCollector) RecordTrialExpired() {
mc.mu.Lock()
defer mc.mu.Unlock()
if mc.totalTrialSubscriptions > 0 {
mc.totalTrialSubscriptions--
}
}
// RecordPaidExpired decrements paid subscription counter
func (mc *MetricsCollector) RecordPaidExpired() {
mc.mu.Lock()
defer mc.mu.Unlock()
if mc.totalPaidSubscriptions > 0 {
mc.totalPaidSubscriptions--
}
}
// RecordPaymentSuccess increments successful payment counter
func (mc *MetricsCollector) RecordPaymentSuccess() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.paymentSuccessCount++
}
// RecordPaymentFailure increments failed payment counter
func (mc *MetricsCollector) RecordPaymentFailure() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.paymentFailureCount++
}
// RecordTrialToPaidConversion records when a trial user becomes paid
func (mc *MetricsCollector) RecordTrialToPaidConversion() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.trialToPaidConversions++
// Move from trial to paid
if mc.totalTrialSubscriptions > 0 {
mc.totalTrialSubscriptions--
}
mc.totalPaidSubscriptions++
}
// RecordSubscriptionDuration adds a subscription duration sample
func (mc *MetricsCollector) RecordSubscriptionDuration(duration time.Duration) {
mc.mu.Lock()
defer mc.mu.Unlock()
// Keep only the most recent samples to prevent memory growth
mc.subscriptionDurations = append(mc.subscriptionDurations, duration)
if len(mc.subscriptionDurations) > mc.maxDurationSamples {
mc.subscriptionDurations = mc.subscriptionDurations[1:]
}
}
// GetMetrics returns current metrics snapshot
func (mc *MetricsCollector) GetMetrics() map[string]interface{} {
mc.mu.RLock()
defer mc.mu.RUnlock()
totalPayments := mc.paymentSuccessCount + mc.paymentFailureCount
var paymentSuccessRate float64
if totalPayments > 0 {
paymentSuccessRate = float64(mc.paymentSuccessCount) / float64(totalPayments)
}
var conversionRate float64
if mc.totalTrialsStarted > 0 {
conversionRate = float64(mc.trialToPaidConversions) / float64(mc.totalTrialsStarted)
}
var avgDuration time.Duration
if len(mc.subscriptionDurations) > 0 {
var total time.Duration
for _, d := range mc.subscriptionDurations {
total += d
}
avgDuration = total / time.Duration(len(mc.subscriptionDurations))
}
return map[string]interface{}{
"total_trial_subscriptions": mc.totalTrialSubscriptions,
"total_paid_subscriptions": mc.totalPaidSubscriptions,
"total_active_subscriptions": mc.totalTrialSubscriptions + mc.totalPaidSubscriptions,
"payment_success_count": mc.paymentSuccessCount,
"payment_failure_count": mc.paymentFailureCount,
"payment_success_rate": paymentSuccessRate,
"trial_to_paid_conversions": mc.trialToPaidConversions,
"total_trials_started": mc.totalTrialsStarted,
"conversion_rate": conversionRate,
"average_subscription_duration_seconds": avgDuration.Seconds(),
"last_health_check": mc.lastHealthCheck.Unix(),
"is_healthy": mc.isHealthy,
}
}
// GetPrometheusMetrics returns metrics in Prometheus format
func (mc *MetricsCollector) GetPrometheusMetrics() string {
metrics := mc.GetMetrics()
promMetrics := `# HELP orly_trial_subscriptions_total Total number of active trial subscriptions
# TYPE orly_trial_subscriptions_total gauge
orly_trial_subscriptions_total %d
# HELP orly_paid_subscriptions_total Total number of active paid subscriptions
# TYPE orly_paid_subscriptions_total gauge
orly_paid_subscriptions_total %d
# HELP orly_active_subscriptions_total Total number of active subscriptions (trial + paid)
# TYPE orly_active_subscriptions_total gauge
orly_active_subscriptions_total %d
# HELP orly_payment_success_total Total number of successful payments
# TYPE orly_payment_success_total counter
orly_payment_success_total %d
# HELP orly_payment_failure_total Total number of failed payments
# TYPE orly_payment_failure_total counter
orly_payment_failure_total %d
# HELP orly_payment_success_rate Payment success rate (0.0 to 1.0)
# TYPE orly_payment_success_rate gauge
orly_payment_success_rate %.6f
# HELP orly_trial_to_paid_conversions_total Total number of trial to paid conversions
# TYPE orly_trial_to_paid_conversions_total counter
orly_trial_to_paid_conversions_total %d
# HELP orly_trials_started_total Total number of trials started
# TYPE orly_trials_started_total counter
orly_trials_started_total %d
# HELP orly_conversion_rate Trial to paid conversion rate (0.0 to 1.0)
# TYPE orly_conversion_rate gauge
orly_conversion_rate %.6f
# HELP orly_avg_subscription_duration_seconds Average subscription duration in seconds
# TYPE orly_avg_subscription_duration_seconds gauge
orly_avg_subscription_duration_seconds %.2f
# HELP orly_last_health_check_timestamp Last health check timestamp
# TYPE orly_last_health_check_timestamp gauge
orly_last_health_check_timestamp %d
# HELP orly_health_status Health status (1 = healthy, 0 = unhealthy)
# TYPE orly_health_status gauge
orly_health_status %d
`
healthStatus := 0
if metrics["is_healthy"].(bool) {
healthStatus = 1
}
return fmt.Sprintf(promMetrics,
metrics["total_trial_subscriptions"],
metrics["total_paid_subscriptions"],
metrics["total_active_subscriptions"],
metrics["payment_success_count"],
metrics["payment_failure_count"],
metrics["payment_success_rate"],
metrics["trial_to_paid_conversions"],
metrics["total_trials_started"],
metrics["conversion_rate"],
metrics["average_subscription_duration_seconds"],
metrics["last_health_check"],
healthStatus,
)
}
// PerformHealthCheck checks system health
func (mc *MetricsCollector) PerformHealthCheck() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.lastHealthCheck = time.Now()
mc.healthCheckErrors = []string{}
mc.isHealthy = true
if mc.db != nil {
testPubkey := make([]byte, 32)
_, err := mc.db.GetSubscription(testPubkey)
if err != nil {
mc.isHealthy = false
mc.healthCheckErrors = append(mc.healthCheckErrors, fmt.Sprintf("database error: %v", err))
}
} else {
mc.isHealthy = false
mc.healthCheckErrors = append(mc.healthCheckErrors, "database not initialized")
}
if mc.isHealthy {
log.D.Ln("health check passed")
} else {
log.W.F("health check failed: %v", mc.healthCheckErrors)
}
}
// GetHealthStatus returns current health status
func (mc *MetricsCollector) GetHealthStatus() map[string]interface{} {
mc.mu.RLock()
defer mc.mu.RUnlock()
return map[string]interface{}{
"healthy": mc.isHealthy,
"last_check": mc.lastHealthCheck.Format(time.RFC3339),
"errors": mc.healthCheckErrors,
"uptime_seconds": time.Since(mc.lastHealthCheck).Seconds(),
}
}
// StartPeriodicHealthChecks runs health checks periodically
func (mc *MetricsCollector) StartPeriodicHealthChecks(interval time.Duration, stopCh <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Perform initial health check
mc.PerformHealthCheck()
for {
select {
case <-ticker.C:
mc.PerformHealthCheck()
case <-stopCh:
log.D.Ln("stopping periodic health checks")
return
}
}
}
// MetricsHandler handles HTTP requests for metrics endpoint
func (mc *MetricsCollector) MetricsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(http.StatusOK)
metrics := mc.GetPrometheusMetrics()
w.Write([]byte(metrics))
}
// HealthHandler handles HTTP requests for health check endpoint
func (mc *MetricsCollector) HealthHandler(w http.ResponseWriter, r *http.Request) {
// Perform real-time health check
mc.PerformHealthCheck()
status := mc.GetHealthStatus()
w.Header().Set("Content-Type", "application/json")
if status["healthy"].(bool) {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusServiceUnavailable)
}
// Simple JSON formatting without external dependencies
healthy := "true"
if !status["healthy"].(bool) {
healthy = "false"
}
errorsJson := "[]"
if errors, ok := status["errors"].([]string); ok && len(errors) > 0 {
errorsJson = `["`
for i, err := range errors {
if i > 0 {
errorsJson += `", "`
}
errorsJson += err
}
errorsJson += `"]`
}
response := fmt.Sprintf(`{
"healthy": %s,
"last_check": "%s",
"errors": %s,
"uptime_seconds": %.2f
}`, healthy, status["last_check"], errorsJson, status["uptime_seconds"])
w.Write([]byte(response))
}

View File

@@ -0,0 +1,175 @@
package relay
import (
"fmt"
"strings"
"sync"
"orly.dev/pkg/app/config"
"orly.dev/pkg/database"
"orly.dev/pkg/encoders/bech32encoding"
"orly.dev/pkg/protocol/nwc"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/log"
)
// PaymentProcessor handles NWC payment notifications and updates subscriptions
type PaymentProcessor struct {
nwcClient *nwc.Client
db *database.D
config *config.C
ctx context.T
cancel context.F
wg sync.WaitGroup
}
// NewPaymentProcessor creates a new payment processor
func NewPaymentProcessor(cfg *config.C, db *database.D) (pp *PaymentProcessor, err error) {
if cfg.NWCUri == "" {
return nil, fmt.Errorf("NWC URI not configured")
}
var nwcClient *nwc.Client
if nwcClient, err = nwc.NewClient(cfg.NWCUri); chk.E(err) {
return nil, fmt.Errorf("failed to create NWC client: %w", err)
}
ctx, cancel := context.Cancel(context.Bg())
pp = &PaymentProcessor{
nwcClient: nwcClient,
db: db,
config: cfg,
ctx: ctx,
cancel: cancel,
}
return pp, nil
}
// Start begins listening for payment notifications
func (pp *PaymentProcessor) Start() error {
pp.wg.Add(1)
go func() {
defer pp.wg.Done()
if err := pp.listenForPayments(); err != nil {
log.E.F("payment processor error: %v", err)
}
}()
return nil
}
// Stop gracefully stops the payment processor
func (pp *PaymentProcessor) Stop() {
if pp.cancel != nil {
pp.cancel()
}
pp.wg.Wait()
}
// listenForPayments subscribes to NWC notifications and processes payments
func (pp *PaymentProcessor) listenForPayments() error {
return pp.nwcClient.SubscribeNotifications(pp.ctx, pp.handleNotification)
}
// handleNotification processes incoming payment notifications
func (pp *PaymentProcessor) handleNotification(notificationType string, notification map[string]any) error {
// Only process payment_received notifications
if notificationType != "payment_received" {
return nil
}
amount, ok := notification["amount"].(float64)
if !ok {
return fmt.Errorf("invalid amount")
}
description, _ := notification["description"].(string)
userNpub := pp.extractNpubFromDescription(description)
if userNpub == "" {
if metadata, ok := notification["metadata"].(map[string]any); ok {
if npubField, ok := metadata["npub"].(string); ok {
userNpub = npubField
}
}
}
if userNpub == "" {
return fmt.Errorf("no npub in payment description")
}
pubkey, err := pp.npubToPubkey(userNpub)
if err != nil {
return fmt.Errorf("invalid npub: %w", err)
}
satsReceived := int64(amount / 1000)
monthlyPrice := pp.config.MonthlyPriceSats
if monthlyPrice <= 0 {
monthlyPrice = 6000
}
days := int((float64(satsReceived) / float64(monthlyPrice)) * 30)
if days < 1 {
return fmt.Errorf("payment amount too small")
}
if err := pp.db.ExtendSubscription(pubkey, days); err != nil {
return fmt.Errorf("failed to extend subscription: %w", err)
}
// Record payment history
invoice, _ := notification["invoice"].(string)
preimage, _ := notification["preimage"].(string)
if err := pp.db.RecordPayment(pubkey, satsReceived, invoice, preimage); err != nil {
log.E.F("failed to record payment: %v", err)
}
log.I.F("payment processed: %s %d sats -> %d days", userNpub, satsReceived, days)
return nil
}
// extractNpubFromDescription extracts an npub from the payment description
func (pp *PaymentProcessor) extractNpubFromDescription(description string) string {
// Look for npub1... pattern in the description
parts := strings.Fields(description)
for _, part := range parts {
if strings.HasPrefix(part, "npub1") && len(part) == 63 {
return part
}
}
// Also check if the entire description is just an npub
description = strings.TrimSpace(description)
if strings.HasPrefix(description, "npub1") && len(description) == 63 {
return description
}
return ""
}
// npubToPubkey converts an npub string to pubkey bytes
func (pp *PaymentProcessor) npubToPubkey(npubStr string) ([]byte, error) {
// Validate npub format
if !strings.HasPrefix(npubStr, "npub1") || len(npubStr) != 63 {
return nil, fmt.Errorf("invalid npub format")
}
// Decode using bech32encoding
prefix, value, err := bech32encoding.Decode([]byte(npubStr))
if err != nil {
return nil, fmt.Errorf("failed to decode npub: %w", err)
}
if !strings.EqualFold(string(prefix), "npub") {
return nil, fmt.Errorf("invalid prefix: %s", string(prefix))
}
pubkey, ok := value.([]byte)
if !ok {
return nil, fmt.Errorf("decoded value is not []byte")
}
return pubkey, nil
}

View File

@@ -8,8 +8,10 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"orly.dev/pkg/database"
"orly.dev/pkg/protocol/openapi" "orly.dev/pkg/protocol/openapi"
"orly.dev/pkg/protocol/socketapi" "orly.dev/pkg/protocol/socketapi"
@@ -43,7 +45,11 @@ type Server struct {
*config.C *config.C
*Lists *Lists
*Peers *Peers
Mux *servemux.S Mux *servemux.S
MetricsCollector *MetricsCollector
subscriptionCache map[string]time.Time // pubkey hex -> cache expiry time
subscriptionMutex sync.RWMutex
paymentProcessor *PaymentProcessor
} }
// ServerParams represents the configuration parameters for initializing a // ServerParams represents the configuration parameters for initializing a
@@ -99,14 +105,15 @@ func NewServer(
} }
} }
s = &Server{ s = &Server{
Ctx: sp.Ctx, Ctx: sp.Ctx,
Cancel: sp.Cancel, Cancel: sp.Cancel,
relay: sp.Rl, relay: sp.Rl,
mux: serveMux, mux: serveMux,
options: op, options: op,
C: sp.C, C: sp.C,
Lists: new(Lists), Lists: new(Lists),
Peers: new(Peers), Peers: new(Peers),
subscriptionCache: make(map[string]time.Time),
} }
// Parse blacklist pubkeys // Parse blacklist pubkeys
for _, v := range s.C.Blacklist { for _, v := range s.C.Blacklist {
@@ -225,6 +232,24 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (s *Server) Start( func (s *Server) Start(
host string, port int, started ...chan bool, host string, port int, started ...chan bool,
) (err error) { ) (err error) {
// Initialize payment processor if subscription is enabled
if s.C.SubscriptionEnabled && s.C.NWCUri != "" {
if db, ok := s.relay.Storage().(*database.D); ok {
if s.paymentProcessor, err = NewPaymentProcessor(s.C, db); err != nil {
log.E.F("failed to create payment processor: %v", err)
// Continue without payment processor
} else {
if err := s.paymentProcessor.Start(); err != nil {
log.E.F("failed to start payment processor: %v", err)
} else {
log.I.F("payment processor started successfully")
}
}
} else {
log.E.F("subscription enabled but storage is not database.D")
}
}
log.I.F("running spider every %v", s.C.SpiderTime) log.I.F("running spider every %v", s.C.SpiderTime)
if len(s.C.Owners) > 0 { if len(s.C.Owners) > 0 {
// start up spider // start up spider
@@ -289,6 +314,13 @@ func (s *Server) Start(
// context. // context.
func (s *Server) Shutdown() { func (s *Server) Shutdown() {
log.I.Ln("shutting down relay") log.I.Ln("shutting down relay")
// Stop payment processor if running
if s.paymentProcessor != nil {
log.I.Ln("stopping payment processor")
s.paymentProcessor.Stop()
}
s.Cancel() s.Cancel()
log.W.Ln("closing event store") log.W.Ln("closing event store")
chk.E(s.relay.Storage().Close()) chk.E(s.relay.Storage().Close())

View File

@@ -0,0 +1,113 @@
package relay
import (
"testing"
"github.com/dgraph-io/badger/v4"
"orly.dev/pkg/app/config"
"orly.dev/pkg/database"
)
func TestSubscriptionTrialActivation(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &database.D{DB: db}
pubkey := make([]byte, 32)
// Test direct database calls
active, err := d.IsSubscriptionActive(pubkey)
if err != nil {
t.Fatal(err)
}
if !active {
t.Fatal("trial should be activated on first check")
}
// Verify subscription was created
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub == nil {
t.Fatal("subscription should exist")
}
if sub.TrialEnd.IsZero() {
t.Error("trial end should be set")
}
}
func TestSubscriptionExtension(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &database.D{DB: db}
pubkey := make([]byte, 32)
// Create subscription and extend it
err = d.ExtendSubscription(pubkey, 30)
if err != nil {
t.Fatal(err)
}
// Check it's active
active, err := d.IsSubscriptionActive(pubkey)
if err != nil {
t.Fatal(err)
}
if !active {
t.Error("subscription should be active after extension")
}
// Verify paid until is set
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub.PaidUntil.IsZero() {
t.Error("paid until should be set")
}
}
func TestConfigValidation(t *testing.T) {
// Test default values
cfg := &config.C{}
if cfg.SubscriptionEnabled {
t.Error("subscription should be disabled by default")
}
if cfg.MonthlyPriceSats != 0 {
t.Error("monthly price should be 0 by default before config load")
}
}
func TestPaymentProcessingSimple(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &database.D{DB: db}
// Test payment recording
pubkey := make([]byte, 32)
err = d.RecordPayment(pubkey, 6000, "test_invoice", "test_preimage")
if err != nil {
t.Fatal(err)
}
// Test payment history retrieval
payments, err := d.GetPaymentHistory(pubkey)
if err != nil {
t.Fatal(err)
}
if len(payments) != 1 {
t.Errorf("expected 1 payment, got %d", len(payments))
}
}

View File

@@ -120,7 +120,7 @@ func GetIndexesFromFilter(f *filter.F) (idxs []Range, err error) {
caEnd.Set(uint64(math.MaxInt64)) caEnd.Set(uint64(math.MaxInt64))
} }
if f.Tags != nil && f.Tags.Len() > 1 { if f.Tags != nil && f.Tags.Len() > 0 {
// sort the tags so they are in iteration order (reverse) // sort the tags so they are in iteration order (reverse)
tmp := f.Tags.ToSliceOfTags() tmp := f.Tags.ToSliceOfTags()
sort.Slice( sort.Slice(

View File

@@ -29,25 +29,14 @@ func (d *D) QueryForIds(c context.T, f *filter.F) (
var results []*store.IdPkTs var results []*store.IdPkTs
var founds []*types.Uint40 var founds []*types.Uint40
for _, idx := range idxs { for _, idx := range idxs {
if f.Tags != nil && f.Tags.Len() > 1 { if founds, err = d.GetSerialsByRange(idx); chk.E(err) {
if founds, err = d.GetSerialsByRange(idx); chk.E(err) { return
return
}
var tmp []*store.IdPkTs
if tmp, err = d.GetFullIdPubkeyBySerials(founds); chk.E(err) {
return
}
results = append(results, tmp...)
} else {
if founds, err = d.GetSerialsByRange(idx); chk.E(err) {
return
}
var tmp []*store.IdPkTs
if tmp, err = d.GetFullIdPubkeyBySerials(founds); chk.E(err) {
return
}
results = append(results, tmp...)
} }
var tmp []*store.IdPkTs
if tmp, err = d.GetFullIdPubkeyBySerials(founds); chk.E(err) {
return
}
results = append(results, tmp...)
} }
// deduplicate in case this somehow happened (such as two or more // deduplicate in case this somehow happened (such as two or more
// from one tag matched, only need it once) // from one tag matched, only need it once)

View File

@@ -0,0 +1,169 @@
package database
import (
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"github.com/vmihailenco/msgpack/v5"
)
type Subscription struct {
TrialEnd time.Time `msgpack:"trial_end"`
PaidUntil time.Time `msgpack:"paid_until"`
}
func (d *D) GetSubscription(pubkey []byte) (*Subscription, error) {
key := fmt.Sprintf("sub:%s", hex.EncodeToString(pubkey))
var sub *Subscription
err := d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(key))
if err == badger.ErrKeyNotFound {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
sub = &Subscription{}
return msgpack.Unmarshal(val, sub)
})
})
return sub, err
}
func (d *D) IsSubscriptionActive(pubkey []byte) (bool, error) {
key := fmt.Sprintf("sub:%s", hex.EncodeToString(pubkey))
now := time.Now()
active := false
err := d.DB.Update(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(key))
if err == badger.ErrKeyNotFound {
sub := &Subscription{TrialEnd: now.AddDate(0, 0, 30)}
data, err := msgpack.Marshal(sub)
if err != nil {
return err
}
active = true
return txn.Set([]byte(key), data)
}
if err != nil {
return err
}
var sub Subscription
err = item.Value(func(val []byte) error {
return msgpack.Unmarshal(val, &sub)
})
if err != nil {
return err
}
active = now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil))
return nil
})
return active, err
}
func (d *D) ExtendSubscription(pubkey []byte, days int) error {
if days <= 0 {
return fmt.Errorf("invalid days: %d", days)
}
key := fmt.Sprintf("sub:%s", hex.EncodeToString(pubkey))
now := time.Now()
return d.DB.Update(func(txn *badger.Txn) error {
var sub Subscription
item, err := txn.Get([]byte(key))
if err == badger.ErrKeyNotFound {
sub.PaidUntil = now.AddDate(0, 0, days)
} else if err != nil {
return err
} else {
err = item.Value(func(val []byte) error {
return msgpack.Unmarshal(val, &sub)
})
if err != nil {
return err
}
extendFrom := now
if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) {
extendFrom = sub.PaidUntil
}
sub.PaidUntil = extendFrom.AddDate(0, 0, days)
}
data, err := msgpack.Marshal(&sub)
if err != nil {
return err
}
return txn.Set([]byte(key), data)
})
}
type Payment struct {
Amount int64 `msgpack:"amount"`
Timestamp time.Time `msgpack:"timestamp"`
Invoice string `msgpack:"invoice"`
Preimage string `msgpack:"preimage"`
}
func (d *D) RecordPayment(pubkey []byte, amount int64, invoice, preimage string) error {
now := time.Now()
key := fmt.Sprintf("payment:%d:%s", now.Unix(), hex.EncodeToString(pubkey))
payment := Payment{
Amount: amount,
Timestamp: now,
Invoice: invoice,
Preimage: preimage,
}
data, err := msgpack.Marshal(&payment)
if err != nil {
return err
}
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Set([]byte(key), data)
})
}
func (d *D) GetPaymentHistory(pubkey []byte) ([]Payment, error) {
prefix := fmt.Sprintf("payment:")
suffix := fmt.Sprintf(":%s", hex.EncodeToString(pubkey))
var payments []Payment
err := d.DB.View(func(txn *badger.Txn) error {
it := txn.NewIterator(badger.DefaultIteratorOptions)
defer it.Close()
for it.Seek([]byte(prefix)); it.ValidForPrefix([]byte(prefix)); it.Next() {
key := string(it.Item().Key())
if !strings.HasSuffix(key, suffix) {
continue
}
err := it.Item().Value(func(val []byte) error {
var payment Payment
err := msgpack.Unmarshal(val, &payment)
if err != nil {
return err
}
payments = append(payments, payment)
return nil
})
if err != nil {
return err
}
}
return nil
})
return payments, err
}

View File

@@ -0,0 +1,121 @@
package database
import (
"testing"
"github.com/dgraph-io/badger/v4"
)
func TestSubscriptionLifecycle(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &D{DB: db}
pubkey := []byte("test_pubkey_32_bytes_long_enough")
// First check should create trial
active, err := d.IsSubscriptionActive(pubkey)
if err != nil {
t.Fatal(err)
}
if !active {
t.Error("expected trial to be active")
}
// Verify trial was created
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub == nil {
t.Fatal("expected subscription to exist")
}
if sub.TrialEnd.IsZero() {
t.Error("expected trial end to be set")
}
if !sub.PaidUntil.IsZero() {
t.Error("expected paid until to be zero")
}
// Extend subscription
err = d.ExtendSubscription(pubkey, 30)
if err != nil {
t.Fatal(err)
}
// Check subscription is still active
active, err = d.IsSubscriptionActive(pubkey)
if err != nil {
t.Fatal(err)
}
if !active {
t.Error("expected subscription to be active after extension")
}
// Verify paid until was set
sub, err = d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub.PaidUntil.IsZero() {
t.Error("expected paid until to be set after extension")
}
}
func TestExtendSubscriptionEdgeCases(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &D{DB: db}
pubkey := []byte("test_pubkey_32_bytes_long_enough")
// Test extending non-existent subscription
err = d.ExtendSubscription(pubkey, 30)
if err != nil {
t.Fatal(err)
}
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub.PaidUntil.IsZero() {
t.Error("expected paid until to be set")
}
// Test invalid days
err = d.ExtendSubscription(pubkey, 0)
if err == nil {
t.Error("expected error for 0 days")
}
err = d.ExtendSubscription(pubkey, -1)
if err == nil {
t.Error("expected error for negative days")
}
}
func TestGetNonExistentSubscription(t *testing.T) {
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
if err != nil {
t.Fatal(err)
}
defer db.Close()
d := &D{DB: db}
pubkey := []byte("non_existent_pubkey_32_bytes_long")
sub, err := d.GetSubscription(pubkey)
if err != nil {
t.Fatal(err)
}
if sub != nil {
t.Error("expected nil for non-existent subscription")
}
}

View File

@@ -142,12 +142,6 @@ func (f *F) Marshal(dst []byte) (b []byte) {
dst = text2.MarshalHexArray(dst, f.Authors.ToSliceOfBytes()) dst = text2.MarshalHexArray(dst, f.Authors.ToSliceOfBytes())
} }
if f.Tags.Len() > 0 { if f.Tags.Len() > 0 {
// log.I.S(f.Tags)
// if first {
// dst = append(dst, ',')
// } else {
// first = true
// }
// tags are stored as tags with the initial element the "#a" and the rest the list in // tags are stored as tags with the initial element the "#a" and the rest the list in
// each element of the tags list. eg: // each element of the tags list. eg:
// //
@@ -158,14 +152,14 @@ func (f *F) Marshal(dst []byte) (b []byte) {
// nothing here // nothing here
continue continue
} }
if tg.Len() < 1 || len(tg.Key()) != 2 { if tg.Len() < 2 {
// if there is no values, skip; the "key" field must be 2 characters long, // must have at least key and one value
continue continue
} }
tKey := tg.ToSliceOfBytes()[0] tKey := tg.ToSliceOfBytes()[0]
if tKey[0] != '#' && if len(tKey) != 1 ||
(tKey[1] < 'a' && tKey[1] > 'z' || tKey[1] < 'A' && tKey[1] > 'Z') { ((tKey[0] < 'a' || tKey[0] > 'z') && (tKey[0] < 'A' || tKey[0] > 'Z')) {
// first "key" field must begin with '#' and second be alpha // key must be single alpha character
continue continue
} }
values := tg.ToSliceOfBytes()[1:] values := tg.ToSliceOfBytes()[1:]
@@ -177,17 +171,12 @@ func (f *F) Marshal(dst []byte) (b []byte) {
} else { } else {
first = true first = true
} }
// append the key // append the key with # prefix
dst = append(dst, '"', tg.B(0)[0], tg.B(0)[1], '"', ':') dst = append(dst, '"', '#', tKey[0], '"', ':')
dst = append(dst, '[') dst = append(dst, '[')
for i, value := range values { for i, value := range values {
dst = append(dst, '"') dst = append(dst, '"')
// if tKey[1] == 'e' || tKey[1] == 'p' {
// // event and pubkey tags are binary 32 bytes
// dst = hex.EncAppend(dst, value)
// } else {
dst = append(dst, value...) dst = append(dst, value...)
// }
dst = append(dst, '"') dst = append(dst, '"')
if i < len(values)-1 { if i < len(values)-1 {
dst = append(dst, ',') dst = append(dst, ',')
@@ -461,12 +450,15 @@ func (f *F) MatchesIgnoringTimestampConstraints(ev *event.E) bool {
// } // }
if f.Tags.Len() > 0 { if f.Tags.Len() > 0 {
for _, v := range f.Tags.ToSliceOfTags() { for _, v := range f.Tags.ToSliceOfTags() {
tvs := v.ToSliceOfBytes() if v.Len() < 2 {
if !ev.Tags.ContainsAny(v.FilterKey(), tag.New(tvs...)) { continue
}
key := v.Key()
values := v.ToSliceOfBytes()[1:]
if !ev.Tags.ContainsAny(key, tag.New(values...)) {
return false return false
} }
} }
// return false
} }
return true return true
} }

View 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

View File

@@ -1,208 +0,0 @@
package nwc
import (
"bytes"
"fmt"
"time"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/filter"
"orly.dev/pkg/encoders/filters"
"orly.dev/pkg/encoders/kind"
"orly.dev/pkg/encoders/kinds"
"orly.dev/pkg/encoders/tag"
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/values"
)
func (cl *Client) GetWalletServiceInfo(c context.T, noUnmarshal bool) (
wsi *WalletServiceInfo, raw []byte, err error,
) {
ctx, cancel := context.Timeout(c, 10*time.Second)
defer cancel()
var rc *ws.Client
if rc, err = ws.RelayConnect(c, cl.relay); chk.E(err) {
return
}
var sub *ws.Subscription
if sub, err = rc.Subscribe(
ctx, filters.New(
&filter.F{
Limit: values.ToUintPointer(1),
Kinds: kinds.New(kind.WalletServiceInfo),
Authors: tag.New(cl.walletPublicKey),
},
),
); chk.E(err) {
return
}
defer sub.Unsub()
select {
case <-ctx.Done():
err = fmt.Errorf("context canceled")
return
case e := <-sub.Events:
raw = e.Marshal(nil)
if noUnmarshal {
return
}
wsi = &WalletServiceInfo{}
encTag := e.Tags.GetFirst(tag.New(EncryptionTag))
notTag := e.Tags.GetFirst(tag.New(NotificationTag))
if encTag != nil {
et := bytes.Split(encTag.Value(), []byte(" "))
for _, v := range et {
wsi.EncryptionTypes = append(wsi.EncryptionTypes, v)
}
}
if notTag != nil {
nt := bytes.Split(notTag.Value(), []byte(" "))
for _, v := range nt {
wsi.NotificationTypes = append(wsi.NotificationTypes, v)
}
}
caps := bytes.Split(e.Content, []byte(" "))
for _, v := range caps {
wsi.Capabilities = append(wsi.Capabilities, v)
}
}
return
}
func (cl *Client) CancelHoldInvoice(
c context.T, chi *CancelHoldInvoiceParams, noUnmarshal bool,
) (raw []byte, err error) {
return cl.RPC(c, CancelHoldInvoice, chi, nil, noUnmarshal, nil)
}
func (cl *Client) CreateConnection(
c context.T, cc *CreateConnectionParams, noUnmarshal bool,
) (raw []byte, err error) {
return cl.RPC(c, CreateConnection, cc, nil, noUnmarshal, nil)
}
func (cl *Client) GetBalance(c context.T, noUnmarshal bool) (
gb *GetBalanceResult, raw []byte, err error,
) {
gb = &GetBalanceResult{}
raw, err = cl.RPC(c, GetBalance, nil, gb, noUnmarshal, nil)
return
}
func (cl *Client) GetBudget(c context.T, noUnmarshal bool) (
gb *GetBudgetResult, raw []byte, err error,
) {
gb = &GetBudgetResult{}
raw, err = cl.RPC(c, GetBudget, nil, gb, noUnmarshal, nil)
return
}
func (cl *Client) GetInfo(c context.T, noUnmarshal bool) (
gi *GetInfoResult, raw []byte, err error,
) {
gi = &GetInfoResult{}
raw, err = cl.RPC(c, GetInfo, nil, gi, noUnmarshal, nil)
return
}
func (cl *Client) ListTransactions(
c context.T, params *ListTransactionsParams, noUnmarshal bool,
) (lt *ListTransactionsResult, raw []byte, err error) {
lt = &ListTransactionsResult{}
raw, err = cl.RPC(c, ListTransactions, params, &lt, noUnmarshal, nil)
return
}
func (cl *Client) LookupInvoice(
c context.T, params *LookupInvoiceParams, noUnmarshal bool,
) (li *LookupInvoiceResult, raw []byte, err error) {
li = &LookupInvoiceResult{}
raw, err = cl.RPC(c, LookupInvoice, params, &li, noUnmarshal, nil)
return
}
func (cl *Client) MakeHoldInvoice(
c context.T,
mhi *MakeHoldInvoiceParams, noUnmarshal bool,
) (mi *MakeInvoiceResult, raw []byte, err error) {
mi = &MakeInvoiceResult{}
raw, err = cl.RPC(c, MakeHoldInvoice, mhi, mi, noUnmarshal, nil)
return
}
func (cl *Client) MakeInvoice(
c context.T, params *MakeInvoiceParams, noUnmarshal bool,
) (mi *MakeInvoiceResult, raw []byte, err error) {
mi = &MakeInvoiceResult{}
raw, err = cl.RPC(c, MakeInvoice, params, &mi, noUnmarshal, nil)
return
}
// MultiPayInvoice
// MultiPayKeysend
func (cl *Client) PayKeysend(
c context.T, params *PayKeysendParams, noUnmarshal bool,
) (pk *PayKeysendResult, raw []byte, err error) {
pk = &PayKeysendResult{}
raw, err = cl.RPC(c, PayKeysend, params, &pk, noUnmarshal, nil)
return
}
func (cl *Client) PayInvoice(
c context.T, params *PayInvoiceParams, noUnmarshal bool,
) (pi *PayInvoiceResult, raw []byte, err error) {
pi = &PayInvoiceResult{}
raw, err = cl.RPC(c, PayInvoice, params, &pi, noUnmarshal, nil)
return
}
func (cl *Client) SettleHoldInvoice(
c context.T, shi *SettleHoldInvoiceParams, noUnmarshal bool,
) (raw []byte, err error) {
return cl.RPC(c, SettleHoldInvoice, shi, nil, noUnmarshal, nil)
}
func (cl *Client) SignMessage(
c context.T, sm *SignMessageParams, noUnmarshal bool,
) (res *SignMessageResult, raw []byte, err error) {
res = &SignMessageResult{}
raw, err = cl.RPC(c, SignMessage, sm, &res, noUnmarshal, nil)
return
}
func (cl *Client) Subscribe(c context.T) (evc event.C, err error) {
var rc *ws.Client
if rc, err = ws.RelayConnect(c, cl.relay); chk.E(err) {
return
}
defer rc.Close()
var sub *ws.Subscription
if sub, err = rc.Subscribe(
c, filters.New(
&filter.F{
Kinds: kinds.New(
kind.WalletNotification, kind.WalletNotificationNip4,
),
Authors: tag.New(cl.walletPublicKey),
},
),
); chk.E(err) {
return
}
defer sub.Unsub()
go func() {
for {
select {
case <-c.Done():
return
case ev := <-sub.Events:
evc <- ev
}
}
}()
return
}

View File

@@ -6,7 +6,6 @@ import (
"time" "time"
"orly.dev/pkg/crypto/encryption" "orly.dev/pkg/crypto/encryption"
"orly.dev/pkg/crypto/p256k"
"orly.dev/pkg/encoders/event" "orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/filter" "orly.dev/pkg/encoders/filter"
"orly.dev/pkg/encoders/filters" "orly.dev/pkg/encoders/filters"
@@ -20,140 +19,230 @@ import (
"orly.dev/pkg/protocol/ws" "orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/chk" "orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context" "orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/log"
"orly.dev/pkg/utils/values" "orly.dev/pkg/utils/values"
) )
type Client struct { type Client struct {
client *ws.Client
relay string relay string
clientSecretKey signer.I clientSecretKey signer.I
walletPublicKey []byte walletPublicKey []byte
conversationKey []byte // nip44 conversationKey []byte
} }
type Request struct { func NewClient(connectionURI string) (cl *Client, err error) {
Method string `json:"method"`
Params any `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 any `json:"result"`
}
func NewClient(c context.T, connectionURI string) (cl *Client, err error) {
var parts *ConnectionParams var parts *ConnectionParams
if parts, err = ParseConnectionURI(connectionURI); chk.E(err) { if parts, err = ParseConnectionURI(connectionURI); chk.E(err) {
return return
} }
clientKey := &p256k.Signer{}
if err = clientKey.InitSec(parts.clientSecretKey); chk.E(err) {
return
}
var ck []byte
if ck, err = encryption.GenerateConversationKeyWithSigner(
clientKey,
parts.walletPublicKey,
); chk.E(err) {
return
}
var relay *ws.Client
if relay, err = ws.RelayConnect(c, parts.relay); chk.E(err) {
return
}
cl = &Client{ cl = &Client{
client: relay,
relay: parts.relay, relay: parts.relay,
clientSecretKey: clientKey, clientSecretKey: parts.clientSecretKey,
walletPublicKey: parts.walletPublicKey, walletPublicKey: parts.walletPublicKey,
conversationKey: ck, conversationKey: parts.conversationKey,
} }
return return
} }
type rpcOptions struct { func (cl *Client) Request(c context.T, method string, params, result any) (err error) {
timeout *time.Duration ctx, cancel := context.Timeout(c, 10*time.Second)
} defer cancel()
request := map[string]any{"method": method}
if params != nil {
request["params"] = params
}
func (cl *Client) RPC(
c context.T, method Capability, params, result any, noUnmarshal bool,
opts *rpcOptions,
) (raw []byte, err error) {
var req []byte var req []byte
if req, err = json.Marshal( if req, err = json.Marshal(request); chk.E(err) {
Request{
Method: string(method),
Params: params,
},
); chk.E(err) {
return return
} }
var content []byte var content []byte
if content, err = encryption.Encrypt(req, cl.conversationKey); chk.E(err) { if content, err = encryption.Encrypt(req, cl.conversationKey); chk.E(err) {
return return
} }
ev := &event.E{ ev := &event.E{
Content: content, Content: content,
CreatedAt: timestamp.Now(), CreatedAt: timestamp.Now(),
Kind: kind.WalletRequest, Kind: kind.New(23194),
Tags: tags.New( Tags: tags.New(
tag.New("encryption", "nip44_v2"),
tag.New("p", hex.Enc(cl.walletPublicKey)), tag.New("p", hex.Enc(cl.walletPublicKey)),
tag.New(EncryptionTag, Nip44V2),
), ),
} }
if err = ev.Sign(cl.clientSecretKey); chk.E(err) { if err = ev.Sign(cl.clientSecretKey); chk.E(err) {
return return
} }
var rc *ws.Client var rc *ws.Client
if rc, err = ws.RelayConnect(c, cl.relay); chk.E(err) { if rc, err = ws.RelayConnect(ctx, cl.relay); chk.E(err) {
return return
} }
defer rc.Close() defer rc.Close()
var sub *ws.Subscription var sub *ws.Subscription
if sub, err = rc.Subscribe( if sub, err = rc.Subscribe(
c, filters.New( ctx, filters.New(
&filter.F{ &filter.F{
Limit: values.ToUintPointer(1), Limit: values.ToUintPointer(1),
Kinds: kinds.New(kind.WalletResponse), Kinds: kinds.New(kind.New(23195)),
Authors: tag.New(cl.walletPublicKey), Since: &timestamp.T{V: time.Now().Unix()},
Tags: tags.New(tag.New("#e", hex.Enc(ev.ID))),
}, },
), ),
); chk.E(err) { ); chk.E(err) {
return return
} }
defer sub.Unsub() defer sub.Unsub()
if err = rc.Publish(context.Bg(), ev); chk.E(err) {
return if err = rc.Publish(ctx, ev); chk.E(err) {
return fmt.Errorf("publish failed: %w", err)
} }
select { select {
case <-c.Done(): case <-ctx.Done():
err = fmt.Errorf("context canceled waiting for response") return fmt.Errorf("no response from wallet (connection may be inactive)")
case e := <-sub.Events: case e := <-sub.Events:
if raw, err = encryption.Decrypt( if e == nil {
e.Content, cl.conversationKey, return fmt.Errorf("subscription closed (wallet connection inactive)")
); chk.E(err) { }
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 return
} }
if noUnmarshal {
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)
} }
resp := &Response{
Result: &result, if result != nil && resp["result"] != nil {
} var resultBytes []byte
if err = json.Unmarshal(raw, resp); chk.E(err) { if resultBytes, err = json.Marshal(resp["result"]); chk.E(err) {
return return
}
if err = json.Unmarshal(resultBytes, result); chk.E(err) {
return
}
} }
} }
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.T, handler NotificationHandler) (err error) {
delay := time.Second
for {
if err = cl.subscribeNotificationsOnce(c, handler); err != nil {
if 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.T, 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, filters.New(
&filter.F{
Kinds: kinds.New(kind.New(23197), kind.New(23196)),
Tags: tags.New(
tag.New("p", hex.Enc(cl.clientSecretKey.Pub())),
),
Since: &timestamp.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, &notification); 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)
}

View File

@@ -0,0 +1,179 @@
package nwc_test
import (
"encoding/json"
"orly.dev/pkg/crypto/encryption"
"orly.dev/pkg/crypto/p256k"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/hex"
"orly.dev/pkg/encoders/kind"
"orly.dev/pkg/encoders/tag"
"orly.dev/pkg/encoders/tags"
"orly.dev/pkg/encoders/timestamp"
"orly.dev/pkg/protocol/nwc"
"testing"
)
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: timestamp.Now(),
Kind: kind.New(23194),
Tags: tags.New(
tag.New("encryption", "nip44_v2"),
tag.New("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 tag.S(0) == "encryption" && tag.S(1) == "nip44_v2" {
hasEncryption = true
}
if tag.S(0) == "p" && tag.S(1) == hex.Enc(walletPubkey) {
hasP = true
}
}
}
if !hasEncryption {
t.Fatal("event missing encryption tag")
}
if !hasP {
t.Fatal("event missing p tag")
}
// Test passed
}

View File

@@ -1,943 +0,0 @@
package nwc
import (
"encoding/json"
"testing"
"time"
"orly.dev/pkg/utils/context"
)
// TestHandleGetWalletServiceInfo tests the handleGetWalletServiceInfo function
func TestHandleGetWalletServiceInfo(t *testing.T) {
// Create a handler function that returns a predefined WalletServiceInfo
handler := func(c context.T, params json.RawMessage) (
result interface{}, err error,
) {
return &WalletServiceInfo{
EncryptionTypes: []EncryptionType{Nip44V2},
Capabilities: []Capability{
GetWalletServiceInfo,
GetInfo,
GetBalance,
GetBudget,
MakeInvoice,
PayInvoice,
},
NotificationTypes: []NotificationType{
PaymentReceived,
PaymentSent,
},
}, nil
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, nil)
if err != nil {
t.Fatalf("Failed to get wallet service info: %v", err)
}
// Verify the result
wsi, ok := result.(*WalletServiceInfo)
if !ok {
t.Fatal("Result is not a WalletServiceInfo")
}
// Check encryption types
if len(wsi.EncryptionTypes) != 1 || string(wsi.EncryptionTypes[0]) != string(Nip44V2) {
t.Errorf(
"Expected encryption type %s, got %v", Nip44V2, wsi.EncryptionTypes,
)
}
// Check capabilities
expectedCapabilities := []Capability{
GetWalletServiceInfo,
GetInfo,
GetBalance,
GetBudget,
MakeInvoice,
PayInvoice,
}
if len(wsi.Capabilities) != len(expectedCapabilities) {
t.Errorf(
"Expected %d capabilities, got %d", len(expectedCapabilities),
len(wsi.Capabilities),
)
}
// Check notification types
expectedNotificationTypes := []NotificationType{
PaymentReceived,
PaymentSent,
}
if len(wsi.NotificationTypes) != len(expectedNotificationTypes) {
t.Errorf(
"Expected %d notification types, got %d",
len(expectedNotificationTypes), len(wsi.NotificationTypes),
)
}
}
// TestHandleCancelHoldInvoice tests the handleCancelHoldInvoice function
func TestHandleCancelHoldInvoice(t *testing.T) {
// Create test parameters
params := &CancelHoldInvoiceParams{
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
// Create a handler function that processes the parameters
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p CancelHoldInvoiceParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.PaymentHash != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid payment hash",
}
}
// Return nil result (success with no data)
return nil, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to cancel hold invoice: %v", err)
}
// Verify the result is nil (success with no data)
if result != nil {
t.Errorf("Expected nil result, got %v", result)
}
}
// TestHandleCreateConnection tests the handleCreateConnection function
func TestHandleCreateConnection(t *testing.T) {
// Create test parameters
params := &CreateConnectionParams{
Pubkey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Name: "Test Connection",
RequestMethods: []string{"get_info", "get_balance", "make_invoice"},
NotificationTypes: []string{"payment_received", "payment_sent"},
}
// Create a handler function that processes the parameters
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p CreateConnectionParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.Pubkey != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid pubkey",
}
}
if p.Name != "Test Connection" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid name",
}
}
if len(p.RequestMethods) != 3 {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid request methods",
}
}
if len(p.NotificationTypes) != 2 {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid notification types",
}
}
// Return nil result (success with no data)
return nil, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to create connection: %v", err)
}
// Verify the result is nil (success with no data)
if result != nil {
t.Errorf("Expected nil result, got %v", result)
}
}
// TestHandleGetBalance tests the handleGetBalance function
func TestHandleGetBalance(t *testing.T) {
// Create a handler function that returns a predefined GetBalanceResult
handler := func(c context.T, params json.RawMessage) (
result interface{}, err error,
) {
return &GetBalanceResult{
Balance: 1000000, // 1,000,000 sats
}, nil
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, nil)
if err != nil {
t.Fatalf("Failed to get balance: %v", err)
}
// Verify the result
balance, ok := result.(*GetBalanceResult)
if !ok {
t.Fatal("Result is not a GetBalanceResult")
}
// Check balance
if balance.Balance != 1000000 {
t.Errorf("Expected balance 1000000, got %d", balance.Balance)
}
}
// TestHandleGetBudget tests the handleGetBudget function
func TestHandleGetBudget(t *testing.T) {
// Create a handler function that returns a predefined GetBudgetResult
handler := func(c context.T, params json.RawMessage) (
result interface{}, err error,
) {
return &GetBudgetResult{
UsedBudget: 5000,
TotalBudget: 10000,
RenewsAt: 1722000000, // Some future timestamp
RenewalPeriod: "daily",
}, nil
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, nil)
if err != nil {
t.Fatalf("Failed to get budget: %v", err)
}
// Verify the result
budget, ok := result.(*GetBudgetResult)
if !ok {
t.Fatal("Result is not a GetBudgetResult")
}
// Check fields
if budget.UsedBudget != 5000 {
t.Errorf("Expected used budget 5000, got %d", budget.UsedBudget)
}
if budget.TotalBudget != 10000 {
t.Errorf("Expected total budget 10000, got %d", budget.TotalBudget)
}
if budget.RenewsAt != 1722000000 {
t.Errorf("Expected renews at 1722000000, got %d", budget.RenewsAt)
}
if budget.RenewalPeriod != "daily" {
t.Errorf(
"Expected renewal period 'daily', got '%s'", budget.RenewalPeriod,
)
}
}
// TestHandleGetInfo tests the handleGetInfo function
func TestHandleGetInfo(t *testing.T) {
// Create a handler function that returns a predefined GetInfoResult
handler := func(c context.T, params json.RawMessage) (
result interface{}, err error,
) {
return &GetInfoResult{
Alias: "Test Wallet",
Color: "#ff9900",
Pubkey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Network: "testnet",
BlockHeight: 123456,
BlockHash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
Methods: []string{
string(GetInfo),
string(GetBalance),
string(MakeInvoice),
string(PayInvoice),
},
Notifications: []string{
string(PaymentReceived),
string(PaymentSent),
},
}, nil
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, nil)
if err != nil {
t.Fatalf("Failed to get info: %v", err)
}
// Verify the result
info, ok := result.(*GetInfoResult)
if !ok {
t.Fatal("Result is not a GetInfoResult")
}
// Check fields
if info.Alias != "Test Wallet" {
t.Errorf("Expected alias 'Test Wallet', got '%s'", info.Alias)
}
if info.Color != "#ff9900" {
t.Errorf("Expected color '#ff9900', got '%s'", info.Color)
}
if info.Network != "testnet" {
t.Errorf("Expected network 'testnet', got '%s'", info.Network)
}
if info.BlockHeight != 123456 {
t.Errorf("Expected block height 123456, got %d", info.BlockHeight)
}
}
// TestHandleListTransactions tests the handleListTransactions function
func TestHandleListTransactions(t *testing.T) {
// Create test parameters
limit := uint16(10)
params := &ListTransactionsParams{
Limit: &limit,
}
// Create a handler function that returns a predefined ListTransactionsResult
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p ListTransactionsParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.Limit == nil || *p.Limit != 10 {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid limit",
}
}
// Create mock transactions
transactions := []Transaction{
{
Type: "incoming",
State: "settled",
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Test transaction 1",
Amount: 1000,
CreatedAt: time.Now().Add(-24 * time.Hour).Unix(),
},
{
Type: "outgoing",
State: "settled",
Invoice: "lnbc20n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Test transaction 2",
Amount: 2000,
CreatedAt: time.Now().Add(-12 * time.Hour).Unix(),
},
}
// Return mock result
return &ListTransactionsResult{
Transactions: transactions,
TotalCount: 2,
}, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to list transactions: %v", err)
}
// Verify the result
txList, ok := result.(*ListTransactionsResult)
if !ok {
t.Fatal("Result is not a ListTransactionsResult")
}
// Check fields
if txList.TotalCount != 2 {
t.Errorf("Expected total count 2, got %d", txList.TotalCount)
}
if len(txList.Transactions) != 2 {
t.Errorf("Expected 2 transactions, got %d", len(txList.Transactions))
}
if txList.Transactions[0].Type != "incoming" {
t.Errorf(
"Expected first transaction type 'incoming', got '%s'",
txList.Transactions[0].Type,
)
}
if txList.Transactions[1].Type != "outgoing" {
t.Errorf(
"Expected second transaction type 'outgoing', got '%s'",
txList.Transactions[1].Type,
)
}
}
// TestHandleLookupInvoice tests the handleLookupInvoice function
func TestHandleLookupInvoice(t *testing.T) {
// Create test parameters
paymentHash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
params := &LookupInvoiceParams{
PaymentHash: &paymentHash,
}
// Create a handler function that returns a predefined LookupInvoiceResult
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p LookupInvoiceParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.PaymentHash == nil || *p.PaymentHash != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid payment hash",
}
}
// Return mock invoice
return &LookupInvoiceResult{
Type: "invoice",
State: "settled",
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Test invoice",
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Amount: 1000,
CreatedAt: time.Now().Add(-1 * time.Hour).Unix(),
ExpiresAt: time.Now().Add(23 * time.Hour).Unix(),
}, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to lookup invoice: %v", err)
}
// Verify the result
invoice, ok := result.(*LookupInvoiceResult)
if !ok {
t.Fatal("Result is not a LookupInvoiceResult")
}
// Check fields
if invoice.Type != "invoice" {
t.Errorf("Expected type 'invoice', got '%s'", invoice.Type)
}
if invoice.State != "settled" {
t.Errorf("Expected state 'settled', got '%s'", invoice.State)
}
if invoice.PaymentHash != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
t.Errorf(
"Expected payment hash '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', got '%s'",
invoice.PaymentHash,
)
}
if invoice.Amount != 1000 {
t.Errorf("Expected amount 1000, got %d", invoice.Amount)
}
}
// TestHandleMakeHoldInvoice tests the handleMakeHoldInvoice function
func TestHandleMakeHoldInvoice(t *testing.T) {
// Create test parameters
params := &MakeHoldInvoiceParams{
Amount: 1000,
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Description: "Test hold invoice",
}
// Create a handler function that returns a predefined MakeInvoiceResult
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p MakeHoldInvoiceParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.Amount != 1000 {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid amount",
}
}
if p.PaymentHash != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid payment hash",
}
}
// Return mock invoice
return &MakeInvoiceResult{
Type: "hold_invoice",
State: "unpaid",
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Test hold invoice",
PaymentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Amount: 1000,
CreatedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to make hold invoice: %v", err)
}
// Verify the result
invoice, ok := result.(*MakeInvoiceResult)
if !ok {
t.Fatal("Result is not a MakeInvoiceResult")
}
// Check fields
if invoice.Type != "hold_invoice" {
t.Errorf("Expected type 'hold_invoice', got '%s'", invoice.Type)
}
if invoice.State != "unpaid" {
t.Errorf("Expected state 'unpaid', got '%s'", invoice.State)
}
if invoice.PaymentHash != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
t.Errorf(
"Expected payment hash '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', got '%s'",
invoice.PaymentHash,
)
}
if invoice.Amount != 1000 {
t.Errorf("Expected amount 1000, got %d", invoice.Amount)
}
if invoice.Description != "Test hold invoice" {
t.Errorf(
"Expected description 'Test hold invoice', got '%s'",
invoice.Description,
)
}
}
// TestHandleMakeInvoice tests the handleMakeInvoice function
func TestHandleMakeInvoice(t *testing.T) {
// Create test parameters
params := &MakeInvoiceParams{
Amount: 1000,
Description: "Test invoice",
}
// Create a handler function that returns a predefined MakeInvoiceResult
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p MakeInvoiceParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.Amount != 1000 {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid amount",
}
}
// Return mock invoice
return &MakeInvoiceResult{
Type: "invoice",
State: "unpaid",
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
Description: "Test invoice",
Amount: 1000,
CreatedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to make invoice: %v", err)
}
// Verify the result
invoice, ok := result.(*MakeInvoiceResult)
if !ok {
t.Fatal("Result is not a MakeInvoiceResult")
}
// Check fields
if invoice.Type != "invoice" {
t.Errorf("Expected type 'invoice', got '%s'", invoice.Type)
}
if invoice.State != "unpaid" {
t.Errorf("Expected state 'unpaid', got '%s'", invoice.State)
}
if invoice.Amount != 1000 {
t.Errorf("Expected amount 1000, got %d", invoice.Amount)
}
if invoice.Description != "Test invoice" {
t.Errorf(
"Expected description 'Test invoice', got '%s'",
invoice.Description,
)
}
}
// TestHandlePayKeysend tests the handlePayKeysend function
func TestHandlePayKeysend(t *testing.T) {
// Create test parameters
params := &PayKeysendParams{
Amount: 1000,
Pubkey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
// Create a handler function that returns a predefined PayKeysendResult
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p PayKeysendParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.Amount != 1000 {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid amount",
}
}
if p.Pubkey != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid pubkey",
}
}
// Return mock payment result
return &PayKeysendResult{
Preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
FeesPaid: 5,
}, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to pay keysend: %v", err)
}
// Verify the result
payment, ok := result.(*PayKeysendResult)
if !ok {
t.Fatal("Result is not a PayKeysendResult")
}
// Check fields
if payment.Preimage != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
t.Errorf(
"Expected preimage '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', got '%s'",
payment.Preimage,
)
}
if payment.FeesPaid != 5 {
t.Errorf("Expected fees paid 5, got %d", payment.FeesPaid)
}
}
// TestHandlePayInvoice tests the handlePayInvoice function
func TestHandlePayInvoice(t *testing.T) {
// Create test parameters
params := &PayInvoiceParams{
Invoice: "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4",
}
// Create a handler function that returns a predefined PayInvoiceResult
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p PayInvoiceParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.Invoice != "lnbc10n1p3zry4app5wkpza973yxheqzh6gr5vt93m3w9mfakz7r35nzk3j6cjgdyvd9ksdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqy4lgd8tj274q2rnzl7xvjwh9xct6rkjn47fn7tvj2s8loyy83gy7z5a5xxaqjz3tldmhglggnv8x8h8xwj7gxcr9gy5aquawzh4gqj6d3h4" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid invoice",
}
}
// Return mock payment result
return &PayInvoiceResult{
Preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
FeesPaid: 10,
}, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to pay invoice: %v", err)
}
// Verify the result
payment, ok := result.(*PayInvoiceResult)
if !ok {
t.Fatal("Result is not a PayInvoiceResult")
}
// Check fields
if payment.Preimage != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
t.Errorf(
"Expected preimage '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', got '%s'",
payment.Preimage,
)
}
if payment.FeesPaid != 10 {
t.Errorf("Expected fees paid 10, got %d", payment.FeesPaid)
}
}
// TestHandleSettleHoldInvoice tests the handleSettleHoldInvoice function
func TestHandleSettleHoldInvoice(t *testing.T) {
// Create test parameters
params := &SettleHoldInvoiceParams{
Preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
// Create a handler function that processes the parameters
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p SettleHoldInvoiceParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.Preimage != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid preimage",
}
}
// Return nil result (success with no data)
return nil, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to settle hold invoice: %v", err)
}
// Verify the result is nil (success with no data)
if result != nil {
t.Errorf("Expected nil result, got %v", result)
}
}
// TestHandleSignMessage tests the handleSignMessage function
func TestHandleSignMessage(t *testing.T) {
// Create test parameters
params := &SignMessageParams{
Message: "Test message to sign",
}
// Create a handler function that returns a predefined SignMessageResult
handler := func(
c context.T, paramsJSON json.RawMessage,
) (result interface{}, err error) {
// Parse parameters
var p SignMessageParams
if err = json.Unmarshal(paramsJSON, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Failed to parse parameters",
}
}
// Check parameters
if p.Message != "Test message to sign" {
return nil, &ResponseError{
Code: "invalid_params",
Message: "Invalid message",
}
}
// Return mock signature result
return &SignMessageResult{
Message: "Test message to sign",
Signature: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}, nil
}
// Marshal parameters to JSON
paramsJSON, err := json.Marshal(params)
if err != nil {
t.Fatalf("Failed to marshal parameters: %v", err)
}
// Call the handler function
ctx := context.Bg()
result, err := handler(ctx, paramsJSON)
if err != nil {
t.Fatalf("Failed to sign message: %v", err)
}
// Verify the result
signature, ok := result.(*SignMessageResult)
if !ok {
t.Fatal("Result is not a SignMessageResult")
}
// Check fields
if signature.Message != "Test message to sign" {
t.Errorf(
"Expected message 'Test message to sign', got '%s'",
signature.Message,
)
}
if signature.Signature != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
t.Errorf(
"Expected signature '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', got '%s'",
signature.Signature,
)
}
}
// TestSendNotification tests the SendNotification function
func TestSendNotification(t *testing.T) {
// This test just verifies that the SendNotification function exists and can be called
// The actual notification functionality is tested in the implementation of SendNotification
t.Log("SendNotification function exists and can be called")
}

View File

@@ -0,0 +1,470 @@
package nwc
import (
"crypto/rand"
"encoding/json"
"fmt"
"sync"
"time"
"orly.dev/pkg/crypto/encryption"
"orly.dev/pkg/crypto/p256k"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/filter"
"orly.dev/pkg/encoders/filters"
"orly.dev/pkg/encoders/hex"
"orly.dev/pkg/encoders/kind"
"orly.dev/pkg/encoders/kinds"
"orly.dev/pkg/encoders/tag"
"orly.dev/pkg/encoders/tags"
"orly.dev/pkg/encoders/timestamp"
"orly.dev/pkg/interfaces/signer"
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
)
// 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.T
cancel context.F
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.Cancel(context.Bg())
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: timestamp.Now(),
Kind: kind.New(13194),
Tags: tags.New(),
}
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, filters.New(
&filter.F{
Kinds: kinds.New(kind.New(23194)),
Tags: tags.New(
tag.New("p", hex.Enc(m.walletPublicKey)),
),
Since: &timestamp.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: timestamp.Now(),
Kind: kind.New(23195),
Tags: tags.New(
tag.New("encryption", "nip44_v2"),
tag.New("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: timestamp.Now(),
Kind: kind.New(23197),
Tags: tags.New(
tag.New("encryption", "nip44_v2"),
tag.New("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)
}

View File

@@ -0,0 +1,175 @@
package nwc_test
import (
"orly.dev/pkg/protocol/nwc"
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/context"
"testing"
"time"
)
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.Timeout(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.Timeout(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.Timeout(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
}

View File

@@ -1,191 +0,0 @@
package nwc
// Capability represents a NIP-47 method
type Capability []byte
var (
CancelHoldInvoice = Capability("cancel_hold_invoice")
CreateConnection = Capability("create_connection")
GetBalance = Capability("get_balance")
GetBudget = Capability("get_budget")
GetInfo = Capability("get_info")
GetWalletServiceInfo = Capability("get_wallet_service_info")
ListTransactions = Capability("list_transactions")
LookupInvoice = Capability("lookup_invoice")
MakeHoldInvoice = Capability("make_hold_invoice")
MakeInvoice = Capability("make_invoice")
MultiPayInvoice = Capability("multi_pay_invoice")
MultiPayKeysend = Capability("multi_pay_keysend")
PayInvoice = Capability("pay_invoice")
PayKeysend = Capability("pay_keysend")
SettleHoldInvoice = Capability("settle_hold_invoice")
SignMessage = Capability("sign_message")
)
// EncryptionType represents the encryption type used for NIP-47 messages
type EncryptionType []byte
var (
EncryptionTag = []byte("encryption")
Nip04 = EncryptionType("nip04")
Nip44V2 = EncryptionType("nip44_v2")
)
type NotificationType []byte
var (
NotificationTag = []byte("notification")
PaymentReceived = NotificationType("payment_received")
PaymentSent = NotificationType("payment_sent")
HoldInvoiceAccepted = NotificationType("hold_invoice_accepted")
)
type WalletServiceInfo struct {
EncryptionTypes []EncryptionType
Capabilities []Capability
NotificationTypes []NotificationType
}
type GetInfoResult struct {
Alias string `json:"alias"`
Color string `json:"color"`
Pubkey string `json:"pubkey"`
Network string `json:"network"`
BlockHeight uint64 `json:"block_height"`
BlockHash string `json:"block_hash"`
Methods []string `json:"methods"`
Notifications []string `json:"notifications,omitempty"`
Metadata any `json:"metadata,omitempty"`
LUD16 string `json:"lud16,omitempty"`
}
type GetBudgetResult struct {
UsedBudget int `json:"used_budget,omitempty"`
TotalBudget int `json:"total_budget,omitempty"`
RenewsAt int `json:"renews_at,omitempty"`
RenewalPeriod string `json:"renewal_period,omitempty"`
}
type GetBalanceResult struct {
Balance uint64 `json:"balance"`
}
type MakeInvoiceParams struct {
Amount uint64 `json:"amount"`
Description string `json:"description,omitempty"`
DescriptionHash string `json:"description_hash,omitempty"`
Expiry *int64 `json:"expiry,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
type MakeHoldInvoiceParams struct {
Amount uint64 `json:"amount"`
PaymentHash string `json:"payment_hash"`
Description string `json:"description,omitempty"`
DescriptionHash string `json:"description_hash,omitempty"`
Expiry *int64 `json:"expiry,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
type SettleHoldInvoiceParams struct {
Preimage string `json:"preimage"`
}
type CancelHoldInvoiceParams struct {
PaymentHash string `json:"payment_hash"`
}
type PayInvoicePayerData struct {
Email string `json:"email"`
Name string `json:"name"`
Pubkey string `json:"pubkey"`
}
type PayInvoiceMetadata struct {
Comment *string `json:"comment"`
PayerData *PayInvoicePayerData `json:"payer_data"`
Other any
}
type PayInvoiceParams struct {
Invoice string `json:"invoice"`
Amount *uint64 `json:"amount,omitempty"`
Metadata *PayInvoiceMetadata `json:"metadata,omitempty"`
}
type PayInvoiceResult struct {
Preimage string `json:"preimage"`
FeesPaid uint64 `json:"fees_paid"`
}
type PayKeysendTLVRecord struct {
Type uint32 `json:"type"`
Value string `json:"value"`
}
type PayKeysendParams struct {
Amount uint64 `json:"amount"`
Pubkey string `json:"pubkey"`
Preimage *string `json:"preimage,omitempty"`
TLVRecords []PayKeysendTLVRecord `json:"tlv_records,omitempty"`
}
type PayKeysendResult = PayInvoiceResult
type LookupInvoiceParams struct {
PaymentHash *string `json:"payment_hash,omitempty"`
Invoice *string `json:"invoice,omitempty"`
}
type ListTransactionsParams struct {
From *int64 `json:"from,omitempty"`
Until *int64 `json:"until,omitempty"`
Limit *uint16 `json:"limit,omitempty"`
Offset *uint32 `json:"offset,omitempty"`
Unpaid *bool `json:"unpaid,omitempty"`
UnpaidOutgoing *bool `json:"unpaid_outgoing,omitempty"`
UnpaidIncoming *bool `json:"unpaid_incoming,omitempty"`
Type *string `json:"type,omitempty"`
}
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 int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
SettledDeadline *uint64 `json:"settled_deadline,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
type SignMessageParams struct {
Message string `json:"message"`
}
type SignMessageResult struct {
Message string `json:"message"`
Signature string `json:"signature"`
}
type CreateConnectionParams struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"`
RequestMethods []string `json:"request_methods"`
NotificationTypes []string `json:"notification_types"`
MaxAmount *uint64 `json:"max_amount,omitempty"`
BudgetRenewal *string `json:"budget_renewal,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
}

View File

@@ -4,13 +4,16 @@ import (
"errors" "errors"
"net/url" "net/url"
"orly.dev/pkg/crypto/encryption"
"orly.dev/pkg/crypto/p256k" "orly.dev/pkg/crypto/p256k"
"orly.dev/pkg/interfaces/signer"
"orly.dev/pkg/utils/chk" "orly.dev/pkg/utils/chk"
) )
type ConnectionParams struct { type ConnectionParams struct {
clientSecretKey []byte clientSecretKey signer.I
walletPublicKey []byte walletPublicKey []byte
conversationKey []byte
relay string relay string
} }
@@ -19,6 +22,11 @@ func (c *ConnectionParams) GetWalletPublicKey() []byte {
return c.walletPublicKey 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) { func ParseConnectionURI(nwcUri string) (parts *ConnectionParams, err error) {
var p *url.URL var p *url.URL
if p, err = url.Parse(nwcUri); chk.E(err) { if p, err = url.Parse(nwcUri); chk.E(err) {
@@ -49,9 +57,21 @@ func ParseConnectionURI(nwcUri string) (parts *ConnectionParams, err error) {
err = errors.New("missing secret parameter") err = errors.New("missing secret parameter")
return return
} }
if parts.clientSecretKey, err = p256k.HexToBin(secret); chk.E(err) { var secretBytes []byte
if secretBytes, err = p256k.HexToBin(secret); chk.E(err) {
err = errors.New("invalid secret") err = errors.New("invalid secret")
return 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 return
} }

View File

@@ -1,182 +0,0 @@
package nwc
import (
"encoding/json"
"fmt"
"orly.dev/pkg/utils/context"
)
// handleGetWalletServiceInfo handles the GetWalletServiceInfo method.
func (ws *WalletService) handleGetWalletServiceInfo(c context.T, params json.RawMessage) (result interface{}, err error) {
// Empty stub implementation
return &WalletServiceInfo{}, nil
}
// handleCancelHoldInvoice handles the CancelHoldInvoice method.
func (ws *WalletService) handleCancelHoldInvoice(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p CancelHoldInvoiceParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return nil, nil
}
// handleCreateConnection handles the CreateConnection method.
func (ws *WalletService) handleCreateConnection(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p CreateConnectionParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return nil, nil
}
// handleGetBalance handles the GetBalance method.
func (ws *WalletService) handleGetBalance(c context.T, params json.RawMessage) (result interface{}, err error) {
// Empty stub implementation
return &GetBalanceResult{}, nil
}
// handleGetBudget handles the GetBudget method.
func (ws *WalletService) handleGetBudget(c context.T, params json.RawMessage) (result interface{}, err error) {
// Empty stub implementation
return &GetBudgetResult{}, nil
}
// handleGetInfo handles the GetInfo method.
func (ws *WalletService) handleGetInfo(c context.T, params json.RawMessage) (result interface{}, err error) {
// Empty stub implementation
return &GetInfoResult{}, nil
}
// handleListTransactions handles the ListTransactions method.
func (ws *WalletService) handleListTransactions(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p ListTransactionsParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return &ListTransactionsResult{}, nil
}
// handleLookupInvoice handles the LookupInvoice method.
func (ws *WalletService) handleLookupInvoice(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p LookupInvoiceParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return &LookupInvoiceResult{}, nil
}
// handleMakeHoldInvoice handles the MakeHoldInvoice method.
func (ws *WalletService) handleMakeHoldInvoice(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p MakeHoldInvoiceParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return &MakeInvoiceResult{}, nil
}
// handleMakeInvoice handles the MakeInvoice method.
func (ws *WalletService) handleMakeInvoice(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p MakeInvoiceParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return &MakeInvoiceResult{}, nil
}
// handlePayKeysend handles the PayKeysend method.
func (ws *WalletService) handlePayKeysend(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p PayKeysendParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return &PayKeysendResult{}, nil
}
// handlePayInvoice handles the PayInvoice method.
func (ws *WalletService) handlePayInvoice(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p PayInvoiceParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return &PayInvoiceResult{}, nil
}
// handleSettleHoldInvoice handles the SettleHoldInvoice method.
func (ws *WalletService) handleSettleHoldInvoice(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p SettleHoldInvoiceParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return nil, nil
}
// handleSignMessage handles the SignMessage method.
func (ws *WalletService) handleSignMessage(c context.T, params json.RawMessage) (result interface{}, err error) {
// Parse parameters
var p SignMessageParams
if err = json.Unmarshal(params, &p); err != nil {
return nil, &ResponseError{
Code: "invalid_params",
Message: fmt.Sprintf("failed to parse parameters: %v", err),
}
}
// Empty stub implementation
return &SignMessageResult{}, nil
}

View File

@@ -1,238 +0,0 @@
package nwc
import (
"encoding/json"
"fmt"
"sync"
"orly.dev/pkg/crypto/encryption"
"orly.dev/pkg/encoders/event"
"orly.dev/pkg/encoders/hex"
"orly.dev/pkg/encoders/kind"
"orly.dev/pkg/encoders/tag"
"orly.dev/pkg/encoders/tags"
"orly.dev/pkg/encoders/timestamp"
"orly.dev/pkg/interfaces/signer"
"orly.dev/pkg/protocol/ws"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
)
// WalletService represents a wallet service that clients can connect to.
type WalletService struct {
mutex sync.Mutex
listener *ws.Listener
walletSecretKey signer.I
walletPublicKey []byte
conversationKey []byte // nip44
handlers map[string]MethodHandler
}
// MethodHandler is a function type for handling wallet service method calls.
type MethodHandler func(
c context.T, params json.RawMessage,
) (result interface{}, err error)
// NewWalletService creates a new WalletService with the given listener and wallet key.
func NewWalletService(
listener *ws.Listener, walletKey signer.I,
) (ws *WalletService, err error) {
pubKey := walletKey.Pub()
ws = &WalletService{
listener: listener,
walletSecretKey: walletKey,
walletPublicKey: pubKey,
handlers: make(map[string]MethodHandler),
}
// Register default method handlers
ws.registerDefaultHandlers()
return
}
// RegisterHandler registers a handler for a specific method.
func (ws *WalletService) RegisterHandler(
method string, handler MethodHandler,
) {
ws.mutex.Lock()
defer ws.mutex.Unlock()
ws.handlers[method] = handler
}
// registerDefaultHandlers registers the default empty stub handlers for all supported methods.
func (ws *WalletService) registerDefaultHandlers() {
// Register handlers for all supported methods
ws.RegisterHandler(string(GetWalletServiceInfo), ws.handleGetWalletServiceInfo)
ws.RegisterHandler(string(CancelHoldInvoice), ws.handleCancelHoldInvoice)
ws.RegisterHandler(string(CreateConnection), ws.handleCreateConnection)
ws.RegisterHandler(string(GetBalance), ws.handleGetBalance)
ws.RegisterHandler(string(GetBudget), ws.handleGetBudget)
ws.RegisterHandler(string(GetInfo), ws.handleGetInfo)
ws.RegisterHandler(string(ListTransactions), ws.handleListTransactions)
ws.RegisterHandler(string(LookupInvoice), ws.handleLookupInvoice)
ws.RegisterHandler(string(MakeHoldInvoice), ws.handleMakeHoldInvoice)
ws.RegisterHandler(string(MakeInvoice), ws.handleMakeInvoice)
ws.RegisterHandler(string(PayKeysend), ws.handlePayKeysend)
ws.RegisterHandler(string(PayInvoice), ws.handlePayInvoice)
ws.RegisterHandler(string(SettleHoldInvoice), ws.handleSettleHoldInvoice)
ws.RegisterHandler(string(SignMessage), ws.handleSignMessage)
}
// HandleRequest processes an incoming wallet request event.
func (ws *WalletService) HandleRequest(c context.T, ev *event.E) (err error) {
// Verify the event is a wallet request
if ev.Kind != kind.WalletRequest {
return fmt.Errorf("invalid event kind: %d", ev.Kind)
}
// Get the client's public key from the event
clientPubKey := ev.Pubkey
// Generate conversation key
var ck []byte
if ck, err = encryption.GenerateConversationKeyWithSigner(
ws.walletSecretKey,
clientPubKey,
); chk.E(err) {
return
}
// Decrypt the content
var content []byte
if content, err = encryption.Decrypt(ev.Content, ck); chk.E(err) {
return
}
// Parse the request
var req Request
if err = json.Unmarshal(content, &req); chk.E(err) {
return
}
// Find the handler for the method
ws.mutex.Lock()
handler, exists := ws.handlers[req.Method]
ws.mutex.Unlock()
var result interface{}
var respErr *ResponseError
if !exists {
respErr = &ResponseError{
Code: "method_not_found",
Message: fmt.Sprintf("method %s not found", req.Method),
}
} else {
// Call the handler
var params json.RawMessage
if req.Params != nil {
var paramsBytes []byte
if paramsBytes, err = json.Marshal(req.Params); chk.E(err) {
return
}
params = paramsBytes
}
result, err = handler(c, params)
if err != nil {
if re, ok := err.(*ResponseError); ok {
respErr = re
} else {
respErr = &ResponseError{
Code: "internal_error",
Message: err.Error(),
}
}
}
}
// Create response
resp := Response{
ResultType: req.Method,
Result: result,
Error: respErr,
}
// Marshal response
var respBytes []byte
if respBytes, err = json.Marshal(resp); chk.E(err) {
return
}
// Encrypt response
var encResp []byte
if encResp, err = encryption.Encrypt(respBytes, ck); chk.E(err) {
return
}
// Create response event
respEv := &event.E{
Content: encResp,
CreatedAt: timestamp.Now(),
Kind: kind.WalletResponse,
Tags: tags.New(
tag.New("p", hex.Enc(clientPubKey)),
tag.New("e", hex.Enc(ev.ID)),
tag.New(EncryptionTag, Nip44V2),
),
}
// Sign the response event
if err = respEv.Sign(ws.walletSecretKey); chk.E(err) {
return
}
// Send the response
_, err = ws.listener.Write(respEv.Marshal(nil))
return
}
// SendNotification sends a notification to a client.
func (ws *WalletService) SendNotification(
c context.T, clientPubKey []byte, notificationType string,
content interface{},
) (err error) {
// Generate conversation key
var ck []byte
if ck, err = encryption.GenerateConversationKeyWithSigner(
ws.walletSecretKey,
clientPubKey,
); chk.E(err) {
return
}
// Marshal content
var contentBytes []byte
if contentBytes, err = json.Marshal(content); chk.E(err) {
return
}
// Encrypt content
var encContent []byte
if encContent, err = encryption.Encrypt(contentBytes, ck); chk.E(err) {
return
}
// Create notification event
notifEv := &event.E{
Content: encContent,
CreatedAt: timestamp.Now(),
Kind: kind.WalletNotification,
Tags: tags.New(
tag.New("p", hex.Enc(clientPubKey)),
tag.New(NotificationTag, []byte(notificationType)),
tag.New(EncryptionTag, Nip44V2),
),
}
// Sign the notification event
if err = notifEv.Sign(ws.walletSecretKey); chk.E(err) {
return
}
// Send the notification
_, err = ws.listener.Write(notifEv.Marshal(nil))
return
}

View File

@@ -0,0 +1,152 @@
package openapi
import (
"fmt"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"orly.dev/pkg/app/relay/helpers"
"orly.dev/pkg/encoders/bech32encoding"
"orly.dev/pkg/protocol/nwc"
"orly.dev/pkg/utils/chk"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/keys"
"orly.dev/pkg/utils/log"
)
type InvoiceInput struct {
Auth string `header:"Authorization" doc:"nostr nip-98 (and expiring variant)" required:"false"`
Accept string `header:"Accept" default:"application/json"`
Body *InvoiceBody `doc:"invoice request parameters"`
}
type InvoiceBody struct {
Pubkey string `json:"pubkey" doc:"user public key in hex or npub format" example:"npub1..."`
Months int `json:"months" doc:"number of months subscription (1-12)" minimum:"1" maximum:"12" example:"1"`
}
type InvoiceOutput struct {
Body *InvoiceResponse
}
type InvoiceResponse struct {
Bolt11 string `json:"bolt11" doc:"Lightning Network payment request"`
Amount int64 `json:"amount" doc:"amount in satoshis"`
Expiry int64 `json:"expiry" doc:"invoice expiration timestamp"`
Error string `json:"error,omitempty" doc:"error message if any"`
}
type MakeInvoiceParams struct {
Amount int64 `json:"amount"`
Description string `json:"description"`
Expiry int64 `json:"expiry,omitempty"`
}
type MakeInvoiceResult struct {
Bolt11 string `json:"invoice"`
PayHash string `json:"payment_hash"`
}
// RegisterInvoice implements the POST /api/invoice endpoint for generating Lightning invoices
func (x *Operations) RegisterInvoice(api huma.API) {
name := "Invoice"
description := `Generate a Lightning invoice for subscription payment
Creates a Lightning Network invoice for a specified number of months subscription.
The invoice amount is calculated based on the configured monthly price.`
path := x.path + "/invoice"
scopes := []string{"user"}
method := http.MethodPost
huma.Register(
api, huma.Operation{
OperationID: name,
Summary: name,
Path: path,
Method: method,
Tags: []string{"payments"},
Description: helpers.GenerateDescription(description, scopes),
Security: []map[string][]string{{"auth": scopes}},
}, func(ctx context.T, input *InvoiceInput) (
output *InvoiceOutput, err error,
) {
output = &InvoiceOutput{Body: &InvoiceResponse{}}
// Validate input
if input.Body == nil {
output.Body.Error = "request body is required"
return output, huma.Error400BadRequest("request body is required")
}
if input.Body.Pubkey == "" {
output.Body.Error = "pubkey is required"
return output, huma.Error400BadRequest("pubkey is required")
}
if input.Body.Months < 1 || input.Body.Months > 12 {
output.Body.Error = "months must be between 1 and 12"
return output, huma.Error400BadRequest("months must be between 1 and 12")
}
// Get config from server
cfg := x.I.Config()
if cfg.NWCUri == "" {
output.Body.Error = "NWC not configured"
return output, huma.Error503ServiceUnavailable("NWC wallet not configured")
}
// Validate and convert pubkey format
var pubkeyBytes []byte
if pubkeyBytes, err = keys.DecodeNpubOrHex(input.Body.Pubkey); chk.E(err) {
output.Body.Error = "invalid pubkey format"
return output, huma.Error400BadRequest("invalid pubkey format: must be hex or npub")
}
// Convert to npub for description
var npub []byte
if npub, err = bech32encoding.BinToNpub(pubkeyBytes); chk.E(err) {
output.Body.Error = "failed to convert pubkey to npub"
log.E.F("failed to convert pubkey to npub: %v", err)
return output, huma.Error500InternalServerError("failed to process pubkey")
}
// Calculate amount based on MonthlyPriceSats config
totalAmount := cfg.MonthlyPriceSats * int64(input.Body.Months)
// Create invoice description with npub and month count
description := fmt.Sprintf("ORLY relay subscription: %d month(s) for %s", input.Body.Months, string(npub))
// Create NWC client
var nwcClient *nwc.Client
if nwcClient, err = nwc.NewClient(cfg.NWCUri); chk.E(err) {
output.Body.Error = "failed to connect to wallet"
log.E.F("failed to create NWC client: %v", err)
return output, huma.Error503ServiceUnavailable("wallet connection failed")
}
// Create invoice via NWC make_invoice method
params := &MakeInvoiceParams{
Amount: totalAmount,
Description: description,
Expiry: 3600, // 1 hour expiry
}
var result MakeInvoiceResult
if err = nwcClient.Request(ctx, "make_invoice", params, &result); chk.E(err) {
output.Body.Error = fmt.Sprintf("wallet error: %v", err)
log.E.F("NWC make_invoice failed: %v", err)
return output, huma.Error502BadGateway("wallet request failed")
}
// Return JSON with bolt11 invoice, amount, and expiry
output.Body.Bolt11 = result.Bolt11
output.Body.Amount = totalAmount
output.Body.Expiry = time.Now().Unix() + 3600 // Current time + 1 hour
log.I.F("generated invoice for %s: %d sats for %d months", string(npub), totalAmount, input.Body.Months)
return output, nil
},
)
}

View File

@@ -0,0 +1,186 @@
package openapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5"
"orly.dev/pkg/app/config"
)
// mockServerInterface implements the server.I interface for testing
type mockServerInterface struct {
cfg *config.C
}
func (m *mockServerInterface) Config() *config.C {
return m.cfg
}
func (m *mockServerInterface) Storage() interface{} {
return nil
}
func TestInvoiceEndpoint(t *testing.T) {
// Create a test configuration
cfg := &config.C{
NWCUri: "nostr+walletconnect://test@relay.example.com?secret=test",
MonthlyPriceSats: 6000,
}
// Create mock server interface
mockServer := &mockServerInterface{cfg: cfg}
// Create a router and API
router := chi.NewRouter()
api := humachi.New(router, &humachi.HumaConfig{
OpenAPI: humachi.DefaultOpenAPIConfig(),
})
// Create operations and register invoice endpoint
ops := &Operations{
I: mockServer,
path: "/api",
}
// Note: We cannot fully test the endpoint without a real NWC connection
// This test mainly validates the structure and basic validation
ops.RegisterInvoice(api)
tests := []struct {
name string
body map[string]interface{}
expectedStatus int
expectError bool
}{
{
name: "missing body",
body: nil,
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "missing pubkey",
body: map[string]interface{}{"months": 1},
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "invalid months - too low",
body: map[string]interface{}{"pubkey": "npub1test", "months": 0},
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "invalid months - too high",
body: map[string]interface{}{"pubkey": "npub1test", "months": 13},
expectedStatus: http.StatusBadRequest,
expectError: true,
},
{
name: "invalid pubkey format",
body: map[string]interface{}{"pubkey": "invalid", "months": 1},
expectedStatus: http.StatusBadRequest,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body *bytes.Buffer
if tt.body != nil {
jsonBody, _ := json.Marshal(tt.body)
body = bytes.NewBuffer(jsonBody)
} else {
body = bytes.NewBuffer([]byte{})
}
req := httptest.NewRequest("POST", "/api/invoice", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if tt.expectError {
// Check that error is present in response
if response["error"] == nil && response["detail"] == nil {
t.Errorf("expected error in response, but got none: %v", response)
}
}
})
}
}
func TestInvoiceValidation(t *testing.T) {
// Test pubkey format validation
validPubkeys := []string{
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5sgp4",
"0000000000000000000000000000000000000000000000000000000000000000",
}
invalidPubkeys := []string{
"",
"invalid",
"npub1invalid",
"1234567890abcdef", // too short
"gg00000000000000000000000000000000000000000000000000000000000000", // invalid hex
}
for _, pubkey := range validPubkeys {
t.Run("valid_pubkey_"+pubkey[:8], func(t *testing.T) {
// These should not return an error when parsing
// (Note: actual validation would need keys.DecodeNpubOrHex)
if pubkey == "" {
t.Skip("empty pubkey test")
}
})
}
for _, pubkey := range invalidPubkeys {
t.Run("invalid_pubkey_"+pubkey, func(t *testing.T) {
// These should return an error when parsing
// (Note: actual validation would need keys.DecodeNpubOrHex)
if pubkey == "" {
// Empty pubkey should be invalid
}
})
}
}
func TestInvoiceAmountCalculation(t *testing.T) {
cfg := &config.C{
MonthlyPriceSats: 6000,
}
tests := []struct {
months int
expectedAmount int64
}{
{1, 6000},
{3, 18000},
{6, 36000},
{12, 72000},
}
for _, tt := range tests {
t.Run("months_"+string(rune(tt.months)), func(t *testing.T) {
totalAmount := cfg.MonthlyPriceSats * int64(tt.months)
if totalAmount != tt.expectedAmount {
t.Errorf("expected amount %d, got %d", tt.expectedAmount, totalAmount)
}
})
}
}

View File

@@ -0,0 +1,181 @@
package openapi
import (
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"orly.dev/pkg/app/relay/helpers"
"orly.dev/pkg/database"
"orly.dev/pkg/encoders/bech32encoding"
"orly.dev/pkg/utils/context"
"orly.dev/pkg/utils/log"
)
// SubscriptionInput defines the input for the subscription status endpoint
type SubscriptionInput struct {
Auth string `header:"Authorization" doc:"nostr nip-98 (and expiring variant)" required:"false"`
Pubkey string `path:"pubkey" doc:"User's public key in hex or npub format" maxLength:"64" minLength:"52"`
}
// SubscriptionOutput defines the response for the subscription status endpoint
type SubscriptionOutput struct {
Body SubscriptionStatus `json:"subscription"`
}
// SubscriptionStatus contains the subscription information for a user
type SubscriptionStatus struct {
TrialEnd *time.Time `json:"trial_end,omitempty"`
PaidUntil *time.Time `json:"paid_until,omitempty"`
IsActive bool `json:"is_active"`
DaysRemaining *int `json:"days_remaining,omitempty"`
}
// parsePubkey converts either hex or npub format pubkey to bytes
func parsePubkey(pubkeyStr string) (pubkey []byte, err error) {
pubkeyStr = strings.TrimSpace(pubkeyStr)
// Check if it's npub format
if strings.HasPrefix(pubkeyStr, "npub") {
if pubkey, err = bech32encoding.NpubToBytes([]byte(pubkeyStr)); err != nil {
return nil, err
}
return pubkey, nil
}
// Assume it's hex format
if pubkey, err = hex.DecodeString(pubkeyStr); err != nil {
return nil, err
}
// Validate length (should be 32 bytes for a public key)
if len(pubkey) != 32 {
err = log.E.Err("invalid pubkey length: expected 32 bytes, got %d", len(pubkey))
return nil, err
}
return pubkey, nil
}
// calculateDaysRemaining calculates the number of days remaining in the subscription
func calculateDaysRemaining(sub *database.Subscription) *int {
if sub == nil {
return nil
}
now := time.Now()
var activeUntil time.Time
// Check if trial is active
if now.Before(sub.TrialEnd) {
activeUntil = sub.TrialEnd
} else if !sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil) {
activeUntil = sub.PaidUntil
} else {
// No active subscription
return nil
}
days := int(activeUntil.Sub(now).Hours() / 24)
if days < 0 {
days = 0
}
return &days
}
// RegisterSubscription implements the subscription status API endpoint
func (x *Operations) RegisterSubscription(api huma.API) {
name := "Subscription"
description := `Get subscription status for a user by their public key
Returns subscription information including trial status, paid subscription status,
active status, and days remaining.`
path := x.path + "/subscription/{pubkey}"
scopes := []string{"user", "read"}
method := http.MethodGet
huma.Register(
api, huma.Operation{
OperationID: name,
Summary: name,
Path: path,
Method: method,
Tags: []string{"subscription"},
Description: helpers.GenerateDescription(description, scopes),
Security: []map[string][]string{{"auth": scopes}},
}, func(ctx context.T, input *SubscriptionInput) (
output *SubscriptionOutput, err error,
) {
r := ctx.Value("http-request").(*http.Request)
remote := helpers.GetRemoteFromReq(r)
// Rate limiting check - simple in-memory rate limiter
// TODO: Implement proper distributed rate limiting
// Parse pubkey from either hex or npub format
var pubkey []byte
if pubkey, err = parsePubkey(input.Pubkey); err != nil {
err = huma.Error400BadRequest("Invalid pubkey format", err)
return
}
// Get subscription manager
storage := x.Storage()
db, ok := storage.(*database.D)
if !ok {
err = huma.Error500InternalServerError("Database error")
return
}
var sub *database.Subscription
if sub, err = db.GetSubscription(pubkey); err != nil {
err = huma.Error500InternalServerError("Failed to retrieve subscription", err)
return
}
// Handle non-existent subscriptions gracefully
var status SubscriptionStatus
if sub == nil {
// No subscription exists yet
status = SubscriptionStatus{
IsActive: false,
DaysRemaining: nil,
}
} else {
now := time.Now()
isActive := false
// Check if trial is active or paid subscription is active
if now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)) {
isActive = true
}
status = SubscriptionStatus{
IsActive: isActive,
DaysRemaining: calculateDaysRemaining(sub),
}
// Include trial_end if it's set and in the future
if !sub.TrialEnd.IsZero() {
status.TrialEnd = &sub.TrialEnd
}
// Include paid_until if it's set
if !sub.PaidUntil.IsZero() {
status.PaidUntil = &sub.PaidUntil
}
}
log.I.F("subscription status request for pubkey %x from %s: active=%v, days_remaining=%v",
pubkey, remote, status.IsActive, status.DaysRemaining)
output = &SubscriptionOutput{
Body: status,
}
return
},
)
}