Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## [0.3.1]

### Added

- `utils.ParsePayMetadataChains`/`utils.FetchLud16PayResponse`: parse a
LUD-06 pay response and list the Lightning-routable chains it
advertises. Previously only available privately inside the relay's
own profile-verification worker.
- `nip57.RequestZapInvoice`: a high-level zap helper — resolves a
recipient's LUD-16 address, builds and signs the zap request, and
fetches an invoice back in one call.

### Changed

- `nip57.ZapRequestParams` gained a `Content` field, so a zap request
can carry an optional public comment.

### Fixed

- `nipcash.CashConsolidateParams.ParseResult` always decrypted
`new_wallet_token` and returned an error if that failed, even though
the merge itself had already succeeded. A bearer/connection_key target
has no real pubkey yet, so the Hub delivers the token in the clear and
decryption always failed for it. `ParseResult` now passes that token
through unchanged; for a pubkey target it still decrypts with the first
source's credential, but a decryption failure now preserves the raw
value instead of returning an error.

## [0.3.0]

### Added
Expand Down
3 changes: 2 additions & 1 deletion nip57/nip57.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ type ZapRequestParams struct {
EventID *string // optional "e" tag — zapped event ID
ATag string // optional "a" tag — zapped addressable event coordinate
KTag string // optional "k" tag — zapped event's kind
Content string // optional public note attached to the zap
}

// NewZapRequest creates a new NIP-57 zap request event (kind 9734).
Expand Down Expand Up @@ -328,7 +329,7 @@ func NewZapRequest(p ZapRequestParams) *nip01.Event {
CreatedAt: uint64(time.Now().Unix()),
Kind: KindZapRequest,
Tags: tags,
Content: "",
Content: p.Content,
}
}

Expand Down
130 changes: 130 additions & 0 deletions nip57/zap_invoice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package nip57

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"github.com/ohstr/nmilat/utils"
)

// Failure modes specific to RequestZapInvoice.
var (
ErrRecipientDoesNotAcceptZaps = errors.New("nip57: recipient's LNURL provider does not support zaps")
ErrAmountOutOfRange = errors.New("nip57: amount outside the provider's min/maxSendable range")
ErrCallbackFailed = errors.New("nip57: callback did not return an invoice")
)

// RequestZapInvoiceParams describes a zap payment: who to zap, how much, and
// what the resulting invoice should carry.
type RequestZapInvoiceParams struct {
SenderPrivateKey string // nsec hex, signs the zap request
RecipientPubkey string // recipient's Nostr pubkey — "p" tag
RecipientLud16 string // recipient's LUD-16 identifier, resolved for the LNURL provider
AmountMsat int64 // required
Relays []string // relays the zap receipt should be published to
Comment string // optional public note attached to the zap
EventID *string // optional "e" tag — zapped event ID
}

// RequestZapInvoiceResult is a zap invoice request's outcome.
type RequestZapInvoiceResult struct {
Bolt11 string
PayResponse *utils.PayResponse // the recipient's LNURL provider info, e.g. to inspect Chains
}

// RequestZapInvoice runs the client side of a zap payment up to "pay this
// invoice" (NIP-57 Appendix A): resolve the recipient's LUD-16 identifier,
// build and sign a zap request naming that provider (kind 9734), and call
// back for an invoice. It does not pay the invoice or wait for a receipt —
// pay Bolt11 with your own Lightning client, then verify the resulting kind
// 9735 receipt with ValidateZapReceipt.
func RequestZapInvoice(ctx context.Context, client *http.Client, p RequestZapInvoiceParams) (*RequestZapInvoiceResult, error) {
if p.AmountMsat <= 0 {
return nil, ErrInvalidAmountValue
}

payResp, err := utils.FetchLud16PayResponse(ctx, client, p.RecipientLud16)
if err != nil {
return nil, err
}
if !payResp.AllowsNostr || payResp.NostrPubkey == "" {
return nil, ErrRecipientDoesNotAcceptZaps
}
if payResp.MinSendable > 0 && p.AmountMsat < payResp.MinSendable {
return nil, fmt.Errorf("%w: %d msat below minimum %d", ErrAmountOutOfRange, p.AmountMsat, payResp.MinSendable)
}
if payResp.MaxSendable > 0 && p.AmountMsat > payResp.MaxSendable {
return nil, fmt.Errorf("%w: %d msat above maximum %d", ErrAmountOutOfRange, p.AmountMsat, payResp.MaxSendable)
}

bech32LNURL, err := utils.EncodeLNURL(utils.GetLud16URL(p.RecipientLud16))
if err != nil {
return nil, fmt.Errorf("encode lnurl: %w", err)
}

event := NewZapRequest(ZapRequestParams{
Recipient: p.RecipientPubkey,
Relays: p.Relays,
AmountMsat: p.AmountMsat,
Lnurl: bech32LNURL,
EventID: p.EventID,
Content: p.Comment,
})
if err := event.Sign(p.SenderPrivateKey); err != nil {
return nil, fmt.Errorf("sign zap request: %w", err)
}
eventJSON, err := json.Marshal(event)
if err != nil {
return nil, fmt.Errorf("marshal zap request: %w", err)
}

bolt11, err := requestInvoice(ctx, client, payResp.Callback, p.AmountMsat, string(eventJSON), bech32LNURL)
if err != nil {
return nil, err
}
return &RequestZapInvoiceResult{Bolt11: bolt11, PayResponse: payResp}, nil
}

// requestInvoice calls an LNURL-pay callback with the zap request attached,
// per NIP-57 Appendix A / LUD-06.
func requestInvoice(ctx context.Context, client *http.Client, callback string, amountMsat int64, zapRequestJSON, lnurl string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, callback, nil)
if err != nil {
return "", err
}
q := req.URL.Query()
q.Set("amount", fmt.Sprintf("%d", amountMsat))
q.Set("nostr", zapRequestJSON)
q.Set("lnurl", lnurl)
req.URL.RawQuery = q.Encode()

resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: status %d", ErrCallbackFailed, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}

var wire struct {
PR string `json:"pr"`
Status string `json:"status"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(body, &wire); err != nil {
return "", fmt.Errorf("%w: parse response: %w", ErrCallbackFailed, err)
}
if wire.Status == "ERROR" || wire.PR == "" {
return "", fmt.Errorf("%w: %s", ErrCallbackFailed, wire.Reason)
}
return wire.PR, nil
}
147 changes: 147 additions & 0 deletions nip57/zap_invoice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package nip57

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)

// redirectToServerTransport rewrites every request's scheme/host to server's,
// so code that builds a fixed https://<domain>/... URL (GetLud16URL) can
// still be exercised against an httptest.Server.
type redirectToServerTransport struct{ server *httptest.Server }

func (rt redirectToServerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
serverURL, _ := req.URL.Parse(rt.server.URL)
clone := req.Clone(req.Context())
clone.URL.Scheme = serverURL.Scheme
clone.URL.Host = serverURL.Host
clone.Host = ""
return http.DefaultTransport.RoundTrip(clone)
}

func TestRequestZapInvoice_HappyPath(t *testing.T) {
recipientPubkey := "0000000000000000000000000000000000000000000000000000000000000001"

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/lnurlp/alice":
_, _ = fmt.Fprintf(w, `{
"callback": "%s/callback",
"minSendable": 1000,
"maxSendable": 100000000,
"metadata": "[[\"chain/flokicoin\",\"\"]]",
"allowsNostr": true,
"nostrPubkey": "%s"
}`, "http://"+r.Host, recipientPubkey)
case "/callback":
nostrEvent := r.URL.Query().Get("nostr")
var event struct {
Kind int `json:"kind"`
}
if err := json.Unmarshal([]byte(nostrEvent), &event); err != nil || event.Kind != KindZapRequest {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(`{"pr":"lnbc1invoice"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

client := &http.Client{Transport: redirectToServerTransport{server: server}}
result, err := RequestZapInvoice(context.Background(), client, RequestZapInvoiceParams{
SenderPrivateKey: zapsTestPrivKey,
RecipientPubkey: recipientPubkey,
RecipientLud16: "alice@example.com",
AmountMsat: 5000,
Relays: []string{"wss://relay.example.com"},
})
if err != nil {
t.Fatalf("RequestZapInvoice: %v", err)
}
if result.Bolt11 != "lnbc1invoice" {
t.Fatalf("Bolt11: got %q", result.Bolt11)
}
if len(result.PayResponse.Chains) != 1 || result.PayResponse.Chains[0] != "flokicoin" {
t.Fatalf("PayResponse.Chains: got %v, want [flokicoin]", result.PayResponse.Chains)
}
}

func TestRequestZapInvoice_RecipientDoesNotAcceptZaps(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"callback":"https://example.com/callback","allowsNostr":false}`))
}))
defer server.Close()

client := &http.Client{Transport: redirectToServerTransport{server: server}}
_, err := RequestZapInvoice(context.Background(), client, RequestZapInvoiceParams{
SenderPrivateKey: zapsTestPrivKey,
RecipientPubkey: "0000000000000000000000000000000000000000000000000000000000000001",
RecipientLud16: "alice@example.com",
AmountMsat: 5000,
Relays: []string{"wss://relay.example.com"},
})
if err == nil {
t.Fatal("expected an error when the provider doesn't support zaps")
}
}

func TestRequestZapInvoice_AmountOutOfRange(t *testing.T) {
recipientPubkey := "0000000000000000000000000000000000000000000000000000000000000001"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `{
"callback": "https://example.com/callback",
"minSendable": 10000,
"maxSendable": 100000,
"allowsNostr": true,
"nostrPubkey": "%s"
}`, recipientPubkey)
}))
defer server.Close()

client := &http.Client{Transport: redirectToServerTransport{server: server}}
_, err := RequestZapInvoice(context.Background(), client, RequestZapInvoiceParams{
SenderPrivateKey: zapsTestPrivKey,
RecipientPubkey: recipientPubkey,
RecipientLud16: "alice@example.com",
AmountMsat: 5000, // below minSendable
Relays: []string{"wss://relay.example.com"},
})
if err == nil {
t.Fatal("expected an error for an amount below minSendable")
}
}

func TestRequestZapInvoice_CallbackError(t *testing.T) {
recipientPubkey := "0000000000000000000000000000000000000000000000000000000000000001"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/lnurlp/alice":
_, _ = fmt.Fprintf(w, `{
"callback": "%s/callback",
"allowsNostr": true,
"nostrPubkey": "%s"
}`, "http://"+r.Host, recipientPubkey)
case "/callback":
_, _ = w.Write([]byte(`{"status":"ERROR","reason":"amount too small"}`))
}
}))
defer server.Close()

client := &http.Client{Transport: redirectToServerTransport{server: server}}
_, err := RequestZapInvoice(context.Background(), client, RequestZapInvoiceParams{
SenderPrivateKey: zapsTestPrivKey,
RecipientPubkey: recipientPubkey,
RecipientLud16: "alice@example.com",
AmountMsat: 5000,
Relays: []string{"wss://relay.example.com"},
})
if err == nil {
t.Fatal("expected an error when the callback reports ERROR")
}
}
27 changes: 16 additions & 11 deletions nipcash/cash_consolidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,17 @@ type CashConsolidateResult struct {
}

// ParseResult parses cash_consolidate's wire response, decrypting
// NewWalletToken with the first source's own Credential — any source's
// decryptDelivery derives the identical key, since the inner delivery layer
// is keyed to the caller's own real identity privkey and the new wallet's
// pubkey, not to any one specific source. Exported for nipcash/client's
// use; a caller using nipcash/client's CashConsolidate method never calls
// this directly.
// NewWalletToken with the first source's own Credential when the target is
// a pubkey — the Hub encrypts it to the caller, same as cash_transfer's own
// delivery. A bearer/connection_key target has no real pubkey yet, so the
// Hub sends the token in the clear instead; ParseResult passes it through
// unchanged rather than attempting decryption. If decryption ever fails
// (e.g. an older Hub still keying pubkey-target delivery some other way),
// the raw value is preserved as-is instead of erroring — the merge itself
// already succeeded.
//
// Exported for nipcash/client's use; a caller using nipcash/client's
// CashConsolidate method never calls this directly.
func (p CashConsolidateParams) ParseResult(data []byte) (*CashConsolidateResult, error) {
var wire cashConsolidateResponseWire
if err := json.Unmarshal(data, &wire); err != nil {
Expand All @@ -126,11 +131,11 @@ func (p CashConsolidateParams) ParseResult(data []byte) (*CashConsolidateResult,
NewWalletPubkey: wire.NewWalletPubkey,
ExpiresAt: wire.ExpiresAt,
}
if wire.NewWalletToken != "" && len(p.Sources) > 0 {
token, err := p.Sources[0].Credential.decryptDelivery(wire.NewWalletPubkey, wire.NewWalletToken)
if err != nil {
return nil, err
}
result.NewWalletToken = wire.NewWalletToken
if wire.NewWalletToken == "" || len(p.Sources) == 0 || !IsPubkeyTarget(p.To) {
return result, nil
}
if token, err := p.Sources[0].Credential.decryptDelivery(wire.NewWalletPubkey, wire.NewWalletToken); err == nil {
result.NewWalletToken = token
}
return result, nil
Expand Down
Loading
Loading