@@ -154,4 +154,4 @@ func generateQueryFilter(index int) *filter.F {
|
|||||||
Limit: &limit,
|
Limit: &limit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
3
go.mod
@@ -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
31
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
346
pkg/app/relay/metrics.go
Normal 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))
|
||||||
|
}
|
||||||
175
pkg/app/relay/payment_processor.go
Normal file
175
pkg/app/relay/payment_processor.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
113
pkg/app/relay/subscription_test.go
Normal file
113
pkg/app/relay/subscription_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
169
pkg/database/subscriptions.go
Normal file
169
pkg/database/subscriptions.go
Normal 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
|
||||||
|
}
|
||||||
121
pkg/database/subscriptions_test.go
Normal file
121
pkg/database/subscriptions_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
56
pkg/protocol/nwc/README.md
Normal file
56
pkg/protocol/nwc/README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# NWC Client
|
||||||
|
|
||||||
|
Nostr Wallet Connect (NIP-47) client implementation.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "orly.dev/pkg/protocol/nwc"
|
||||||
|
|
||||||
|
// Create client from NWC connection URI
|
||||||
|
client, err := nwc.NewClient("nostr+walletconnect://...")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make requests
|
||||||
|
var info map[string]any
|
||||||
|
err = client.Request(ctx, "get_info", nil, &info)
|
||||||
|
|
||||||
|
var balance map[string]any
|
||||||
|
err = client.Request(ctx, "get_balance", nil, &balance)
|
||||||
|
|
||||||
|
var invoice map[string]any
|
||||||
|
params := map[string]any{"amount": 1000, "description": "test"}
|
||||||
|
err = client.Request(ctx, "make_invoice", params, &invoice)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Methods
|
||||||
|
|
||||||
|
- `get_info` - Get wallet info
|
||||||
|
- `get_balance` - Get wallet balance
|
||||||
|
- `make_invoice` - Create invoice
|
||||||
|
- `lookup_invoice` - Check invoice status
|
||||||
|
- `pay_invoice` - Pay invoice
|
||||||
|
|
||||||
|
## Payment Notifications
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Subscribe to payment notifications
|
||||||
|
err = client.SubscribeNotifications(ctx, func(notificationType string, notification map[string]any) error {
|
||||||
|
if notificationType == "payment_received" {
|
||||||
|
amount := notification["amount"].(float64)
|
||||||
|
description := notification["description"].(string)
|
||||||
|
// Process payment...
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- NIP-44 encryption
|
||||||
|
- Event signing
|
||||||
|
- Relay communication
|
||||||
|
- Payment notifications
|
||||||
|
- Error handling
|
||||||
@@ -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, <, 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
|
|
||||||
}
|
|
||||||
@@ -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: ×tamp.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: ×tamp.T{V: time.Now().Unix()},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
); chk.E(err) {
|
||||||
|
return fmt.Errorf("subscription failed: %w", err)
|
||||||
|
}
|
||||||
|
defer sub.Unsub()
|
||||||
|
|
||||||
|
log.I.F("subscribed to NWC notifications from wallet %s", hex.Enc(cl.walletPublicKey))
|
||||||
|
|
||||||
|
// Process notification events
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Done():
|
||||||
|
return context.Canceled
|
||||||
|
case ev := <-sub.Events:
|
||||||
|
if ev == nil {
|
||||||
|
// Channel closed, subscription ended
|
||||||
|
return fmt.Errorf("subscription closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the notification event
|
||||||
|
if err := cl.processNotificationEvent(ev, handler); err != nil {
|
||||||
|
log.E.F("error processing notification: %v", err)
|
||||||
|
// Continue processing other notifications even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processNotificationEvent decrypts and processes a single notification event
|
||||||
|
func (cl *Client) processNotificationEvent(ev *event.E, handler NotificationHandler) (err error) {
|
||||||
|
// Decrypt the notification content
|
||||||
|
var decrypted []byte
|
||||||
|
if decrypted, err = encryption.Decrypt(ev.Content, cl.conversationKey); err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt notification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the notification JSON
|
||||||
|
var notification map[string]any
|
||||||
|
if err = json.Unmarshal(decrypted, ¬ification); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse notification JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract notification type
|
||||||
|
notificationType, ok := notification["notification_type"].(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("missing or invalid notification_type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract notification data
|
||||||
|
notificationData, ok := notification["notification"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("missing or invalid notification data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to type-specific handler
|
||||||
|
return handler(notificationType, notificationData)
|
||||||
|
}
|
||||||
|
|||||||
179
pkg/protocol/nwc/crypto_test.go
Normal file
179
pkg/protocol/nwc/crypto_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
470
pkg/protocol/nwc/mock_wallet_service.go
Normal file
470
pkg/protocol/nwc/mock_wallet_service.go
Normal 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: ×tamp.T{V: time.Now().Unix()},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
); chk.E(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming request events
|
||||||
|
go m.handleRequestEvents(sub)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestEvents processes incoming NWC request events
|
||||||
|
func (m *MockWalletService) handleRequestEvents(sub *ws.Subscription) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.ctx.Done():
|
||||||
|
return
|
||||||
|
case ev := <-sub.Events:
|
||||||
|
if ev == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.processRequestEvent(ev); chk.E(err) {
|
||||||
|
fmt.Printf("Error processing request event: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processRequestEvent processes a single NWC request event
|
||||||
|
func (m *MockWalletService) processRequestEvent(ev *event.E) (err error) {
|
||||||
|
// Get client pubkey from event
|
||||||
|
clientPubkey := ev.Pubkey
|
||||||
|
clientPubkeyHex := hex.Enc(clientPubkey)
|
||||||
|
|
||||||
|
// Generate or get conversation key
|
||||||
|
var conversationKey []byte
|
||||||
|
m.clientsMutex.Lock()
|
||||||
|
if existingKey, exists := m.connectedClients[clientPubkeyHex]; exists {
|
||||||
|
conversationKey = existingKey
|
||||||
|
} else {
|
||||||
|
if conversationKey, err = encryption.GenerateConversationKeyWithSigner(
|
||||||
|
m.walletSecretKey, clientPubkey,
|
||||||
|
); chk.E(err) {
|
||||||
|
m.clientsMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.connectedClients[clientPubkeyHex] = conversationKey
|
||||||
|
}
|
||||||
|
m.clientsMutex.Unlock()
|
||||||
|
|
||||||
|
// Decrypt request content
|
||||||
|
var decrypted []byte
|
||||||
|
if decrypted, err = encryption.Decrypt(ev.Content, conversationKey); chk.E(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request map[string]any
|
||||||
|
if err = json.Unmarshal(decrypted, &request); chk.E(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
method, ok := request["method"].(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid method")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := request["params"]
|
||||||
|
|
||||||
|
// Process the method
|
||||||
|
var result any
|
||||||
|
if result, err = m.processMethod(method, params); chk.E(err) {
|
||||||
|
// Send error response
|
||||||
|
return m.sendErrorResponse(clientPubkey, conversationKey, "INTERNAL", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send success response
|
||||||
|
return m.sendSuccessResponse(clientPubkey, conversationKey, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processMethod handles the actual NWC method execution
|
||||||
|
func (m *MockWalletService) processMethod(method string, params any) (result any, err error) {
|
||||||
|
switch method {
|
||||||
|
case "get_info":
|
||||||
|
return m.getInfo()
|
||||||
|
case "get_balance":
|
||||||
|
return m.getBalance()
|
||||||
|
case "make_invoice":
|
||||||
|
return m.makeInvoice(params)
|
||||||
|
case "pay_invoice":
|
||||||
|
return m.payInvoice(params)
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported method: %s", method)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInfo returns wallet information
|
||||||
|
func (m *MockWalletService) getInfo() (result map[string]any, err error) {
|
||||||
|
result = map[string]any{
|
||||||
|
"alias": "Mock Wallet",
|
||||||
|
"color": "#3399FF",
|
||||||
|
"pubkey": hex.Enc(m.walletPublicKey),
|
||||||
|
"network": "mainnet",
|
||||||
|
"block_height": 850000,
|
||||||
|
"block_hash": "0000000000000000000123456789abcdef",
|
||||||
|
"methods": []string{"get_info", "get_balance", "make_invoice", "pay_invoice"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBalance returns the current wallet balance
|
||||||
|
func (m *MockWalletService) getBalance() (result map[string]any, err error) {
|
||||||
|
m.balanceMutex.RLock()
|
||||||
|
balance := m.balance
|
||||||
|
m.balanceMutex.RUnlock()
|
||||||
|
|
||||||
|
result = map[string]any{
|
||||||
|
"balance": balance * 1000, // convert to msats
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeInvoice creates a Lightning invoice
|
||||||
|
func (m *MockWalletService) makeInvoice(params any) (result map[string]any, err error) {
|
||||||
|
paramsMap, ok := params.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("invalid params")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount, ok := paramsMap["amount"].(float64)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("missing or invalid amount")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
description := ""
|
||||||
|
if desc, ok := paramsMap["description"].(string); ok {
|
||||||
|
description = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentHash := make([]byte, 32)
|
||||||
|
rand.Read(paymentHash)
|
||||||
|
|
||||||
|
// Generate a fake bolt11 invoice
|
||||||
|
bolt11 := fmt.Sprintf("lnbc%dm1pwxxxxxxx", int64(amount/1000))
|
||||||
|
|
||||||
|
result = map[string]any{
|
||||||
|
"type": "incoming",
|
||||||
|
"invoice": bolt11,
|
||||||
|
"description": description,
|
||||||
|
"payment_hash": hex.Enc(paymentHash),
|
||||||
|
"amount": int64(amount),
|
||||||
|
"created_at": time.Now().Unix(),
|
||||||
|
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// payInvoice pays a Lightning invoice
|
||||||
|
func (m *MockWalletService) payInvoice(params any) (result map[string]any, err error) {
|
||||||
|
paramsMap, ok := params.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("invalid params")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice, ok := paramsMap["invoice"].(string)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("missing or invalid invoice")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock payment amount (would parse from invoice in real implementation)
|
||||||
|
amount := int64(1000) // 1000 msats
|
||||||
|
|
||||||
|
// Check balance
|
||||||
|
m.balanceMutex.Lock()
|
||||||
|
if m.balance*1000 < amount {
|
||||||
|
m.balanceMutex.Unlock()
|
||||||
|
err = fmt.Errorf("insufficient balance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.balance -= amount / 1000
|
||||||
|
m.balanceMutex.Unlock()
|
||||||
|
|
||||||
|
preimage := make([]byte, 32)
|
||||||
|
rand.Read(preimage)
|
||||||
|
|
||||||
|
result = map[string]any{
|
||||||
|
"type": "outgoing",
|
||||||
|
"invoice": invoice,
|
||||||
|
"amount": amount,
|
||||||
|
"preimage": hex.Enc(preimage),
|
||||||
|
"created_at": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit payment_sent notification
|
||||||
|
go m.emitPaymentNotification("payment_sent", result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSuccessResponse sends a successful NWC response
|
||||||
|
func (m *MockWalletService) sendSuccessResponse(clientPubkey []byte, conversationKey []byte, result any) (err error) {
|
||||||
|
response := map[string]any{
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseBytes []byte
|
||||||
|
if responseBytes, err = json.Marshal(response); chk.E(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendErrorResponse sends an error NWC response
|
||||||
|
func (m *MockWalletService) sendErrorResponse(clientPubkey []byte, conversationKey []byte, code, message string) (err error) {
|
||||||
|
response := map[string]any{
|
||||||
|
"error": map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseBytes []byte
|
||||||
|
if responseBytes, err = json.Marshal(response); chk.E(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendEncryptedResponse sends an encrypted response event (kind 23195)
|
||||||
|
func (m *MockWalletService) sendEncryptedResponse(clientPubkey []byte, conversationKey []byte, content []byte) (err error) {
|
||||||
|
var encrypted []byte
|
||||||
|
if encrypted, err = encryption.Encrypt(content, conversationKey); chk.E(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := &event.E{
|
||||||
|
Content: encrypted,
|
||||||
|
CreatedAt: 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)
|
||||||
|
}
|
||||||
175
pkg/protocol/nwc/nwc_test.go
Normal file
175
pkg/protocol/nwc/nwc_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
152
pkg/protocol/openapi/invoice.go
Normal file
152
pkg/protocol/openapi/invoice.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
186
pkg/protocol/openapi/invoice_test.go
Normal file
186
pkg/protocol/openapi/invoice_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
181
pkg/protocol/openapi/subscription.go
Normal file
181
pkg/protocol/openapi/subscription.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user