diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c242c..0babae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/nip57/nip57.go b/nip57/nip57.go index ae1c264..4bc68df 100644 --- a/nip57/nip57.go +++ b/nip57/nip57.go @@ -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). @@ -328,7 +329,7 @@ func NewZapRequest(p ZapRequestParams) *nip01.Event { CreatedAt: uint64(time.Now().Unix()), Kind: KindZapRequest, Tags: tags, - Content: "", + Content: p.Content, } } diff --git a/nip57/zap_invoice.go b/nip57/zap_invoice.go new file mode 100644 index 0000000..d88ffcf --- /dev/null +++ b/nip57/zap_invoice.go @@ -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 +} diff --git a/nip57/zap_invoice_test.go b/nip57/zap_invoice_test.go new file mode 100644 index 0000000..198656b --- /dev/null +++ b/nip57/zap_invoice_test.go @@ -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:///... 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") + } +} diff --git a/nipcash/cash_consolidate.go b/nipcash/cash_consolidate.go index 355e3b1..bdd570d 100644 --- a/nipcash/cash_consolidate.go +++ b/nipcash/cash_consolidate.go @@ -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 { @@ -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 diff --git a/nipcash/cash_consolidate_test.go b/nipcash/cash_consolidate_test.go index 36a6b7b..9276cad 100644 --- a/nipcash/cash_consolidate_test.go +++ b/nipcash/cash_consolidate_test.go @@ -1,6 +1,9 @@ package nipcash -import "testing" +import ( + "encoding/json" + "testing" +) func TestCashConsolidateParams_Request_TooFewSources(t *testing.T) { privKeyHex, _ := generateTestKeypair(t) @@ -146,3 +149,150 @@ func TestByProof_MalformedJSON(t *testing.T) { t.Fatal("expected an error for malformed captured proof JSON") } } + +// cash_consolidate keys new_wallet_token's delivery to the target, not the +// caller — ParseResult must only decrypt when the target is the caller's +// own pubkey, and otherwise return the wire value as-is (plaintext for +// bearer/connection_key targets, still-opaque ciphertext for a third +// party's pubkey) without erroring. + +func TestCashConsolidateParams_ParseResult_SelfTarget_Decrypts(t *testing.T) { + callerPrivHex, callerPubHex := generateTestKeypair(t) + newWalletPrivHex, newWalletPubHex := generateTestKeypair(t) + + // Self-consolidate: new_identity == the caller's own pubkey, so the Hub + // encrypts to the same pubkey the caller's own credential can decrypt + // with. + ciphertext := encryptForTest(t, newWalletPrivHex, callerPubHex, "lokicash1thetoken") + + p := CashConsolidateParams{ + Sources: []Source{From(randomKeyHex(t), 1000, BySigning(callerPrivHex))}, + To: Pubkey(callerPubHex), + } + raw, err := json.Marshal(cashConsolidateResponseWire{ + AmountMillis: 1000, + NewWalletPubkey: newWalletPubHex, + NewWalletToken: ciphertext, + }) + if err != nil { + t.Fatal(err) + } + result, err := p.ParseResult(raw) + if err != nil { + t.Fatalf("ParseResult: %v", err) + } + if result.NewWalletToken != "lokicash1thetoken" { + t.Fatalf("NewWalletToken: got %q, want decrypted plaintext", result.NewWalletToken) + } +} + +func TestCashConsolidateParams_ParseResult_ThirdPartyPubkeyTarget_Decrypts(t *testing.T) { + callerPrivHex, callerPubHex := generateTestKeypair(t) + _, carolPubHex := generateTestKeypair(t) + newWalletPrivHex, newWalletPubHex := generateTestKeypair(t) + + // The Hub encrypts to the CALLER even though the target is Carol (same + // convention as cash_transfer) — the caller decrypts it themselves and + // hands Carol a plain token out of band. + ciphertext := encryptForTest(t, newWalletPrivHex, callerPubHex, "lokicash1forcarol") + + p := CashConsolidateParams{ + Sources: []Source{From(randomKeyHex(t), 1000, BySigning(callerPrivHex))}, + To: Pubkey(carolPubHex), + } + raw, err := json.Marshal(cashConsolidateResponseWire{ + AmountMillis: 1000, + NewWalletPubkey: newWalletPubHex, + NewWalletToken: ciphertext, + }) + if err != nil { + t.Fatal(err) + } + result, err := p.ParseResult(raw) + if err != nil { + t.Fatalf("ParseResult: %v", err) + } + if result.NewWalletToken != "lokicash1forcarol" { + t.Fatalf("NewWalletToken: got %q, want decrypted plaintext", result.NewWalletToken) + } +} + +func TestCashConsolidateParams_ParseResult_UndecryptableDelivery_FallsBackToRawValue(t *testing.T) { + callerPrivHex, _ := generateTestKeypair(t) + _, carolPubHex := generateTestKeypair(t) + newWalletPrivHex, newWalletPubHex := generateTestKeypair(t) + + // Encrypted to a key the caller doesn't hold — e.g. a Hub still keying + // pubkey-target delivery some other way. Decryption fails; ParseResult + // must not error, just preserve the raw value instead of losing it. + ciphertext := encryptForTest(t, newWalletPrivHex, carolPubHex, "lokicash1forcarol") + + p := CashConsolidateParams{ + Sources: []Source{From(randomKeyHex(t), 1000, BySigning(callerPrivHex))}, + To: Pubkey(carolPubHex), + } + raw, err := json.Marshal(cashConsolidateResponseWire{ + AmountMillis: 1000, + NewWalletPubkey: newWalletPubHex, + NewWalletToken: ciphertext, + }) + if err != nil { + t.Fatal(err) + } + result, err := p.ParseResult(raw) + if err != nil { + t.Fatalf("ParseResult must not error on an undecryptable delivery: %v", err) + } + if result.NewWalletToken != ciphertext { + t.Fatalf("NewWalletToken: got %q, want the raw ciphertext preserved as-is", result.NewWalletToken) + } +} + +func TestCashConsolidateParams_ParseResult_BearerTarget_PassesThroughPlaintext(t *testing.T) { + callerPrivHex, _ := generateTestKeypair(t) + bt := NewBearerTarget() + + p := CashConsolidateParams{ + Sources: []Source{From(randomKeyHex(t), 1000, BySigning(callerPrivHex))}, + To: bt, + } + raw, err := json.Marshal(cashConsolidateResponseWire{ + AmountMillis: 1000, + NewWalletPubkey: randomKeyHex(t), + NewWalletToken: "lokicash1plaintext", + }) + if err != nil { + t.Fatal(err) + } + result, err := p.ParseResult(raw) + if err != nil { + t.Fatalf("ParseResult: %v", err) + } + if result.NewWalletToken != "lokicash1plaintext" { + t.Fatalf("NewWalletToken: got %q, want the plaintext value passed through unchanged", result.NewWalletToken) + } +} + +func TestCashConsolidateParams_ParseResult_ConnectionKeyTarget_PassesThroughPlaintext(t *testing.T) { + callerPrivHex, _ := generateTestKeypair(t) + + p := CashConsolidateParams{ + Sources: []Source{From(randomKeyHex(t), 1000, BySigning(callerPrivHex))}, + To: ConnectionKey("discord", "someone", "iapub"), + } + raw, err := json.Marshal(cashConsolidateResponseWire{ + AmountMillis: 1000, + NewWalletPubkey: randomKeyHex(t), + NewWalletToken: "lokicash1plaintext", + }) + if err != nil { + t.Fatal(err) + } + result, err := p.ParseResult(raw) + if err != nil { + t.Fatalf("ParseResult: %v", err) + } + if result.NewWalletToken != "lokicash1plaintext" { + t.Fatalf("NewWalletToken: got %q, want the plaintext value passed through unchanged", result.NewWalletToken) + } +} diff --git a/relay/verification.go b/relay/verification.go index 541b21d..ed40891 100644 --- a/relay/verification.go +++ b/relay/verification.go @@ -3,9 +3,7 @@ package relay import ( "context" "fmt" - "io" "net/http" - "strings" "sync" "sync/atomic" "time" @@ -151,42 +149,8 @@ func (w *ProfileVerificationWorker) processJob(job VerificationJob) { wg.Add(1) go func() { defer wg.Done() - url := utils.GetLud16URL(job.Lud16) - if url != "" { - req, _ := http.NewRequestWithContext(w.ctx, "GET", url, nil) - if resp, err := w.httpClient.Do(req); err == nil { - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode == http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err == nil { - var payResponse struct { - Metadata string `json:"metadata"` - } - if err := utils.UnmarshalJSON(body, &payResponse); err != nil { - // Alternative: some services return the metadata directly as an array or object - // but LUD-06 says it's inside a metadata field as a string. - } else { - var metadata [][]interface{} - if err := utils.UnmarshalJSON([]byte(payResponse.Metadata), &metadata); err == nil { - chainCount := 0 - for _, item := range metadata { - if len(item) > 0 { - if tag, ok := item[0].(string); ok && strings.HasPrefix(tag, "chain/") { - chainCount++ - } - } - } - if chainCount == 0 { - // Bitcoin is assumed if no chain tags provided - ludChains = 1 - } else { - ludChains = chainCount - } - } - } - } - } - } + if payResp, err := utils.FetchLud16PayResponse(w.ctx, w.httpClient, job.Lud16); err == nil { + ludChains = len(payResp.Chains) } }() } diff --git a/utils/lnurl.go b/utils/lnurl.go index 177493d..e69ed36 100644 --- a/utils/lnurl.go +++ b/utils/lnurl.go @@ -45,3 +45,17 @@ func ValidateLNURL(lnurlStr string) error { return nil } + +// EncodeLNURL bech32-encodes rawURL as an "lnurl1..." string — the inverse +// of ValidateLNURL's decode step. +func EncodeLNURL(rawURL string) (string, error) { + bits5, err := bech32.ConvertBits([]byte(rawURL), 8, 5, true) + if err != nil { + return "", fmt.Errorf("failed to convert bits: %w", err) + } + encoded, err := bech32.Encode("lnurl", bits5) + if err != nil { + return "", fmt.Errorf("failed to encode bech32: %w", err) + } + return encoded, nil +} diff --git a/utils/lud06.go b/utils/lud06.go new file mode 100644 index 0000000..64453ac --- /dev/null +++ b/utils/lud06.go @@ -0,0 +1,101 @@ +package utils + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// PayResponse is a LUD-06 payRequest document (a LUD-16 identifier's +// well-known endpoint), plus NIP-57's allowsNostr/nostrPubkey fields and the +// Zap Protocol's chain/* metadata entries already parsed out. +type PayResponse struct { + Callback string + MinSendable int64 + MaxSendable int64 + AllowsNostr bool + NostrPubkey string + Chains []string // never empty; see ParsePayMetadataChains +} + +// ParsePayMetadataChains extracts the Lightning-routable chains a LUD-06 +// payRequest's "metadata" field advertises (the Zap Protocol's "chain/" +// entries, e.g. "chain/flokicoin"). metadata is the raw metadata string +// exactly as the payRequest response carries it — a JSON-encoded array, not +// a nested array itself. Returns ["bitcoin"] when no chain entry is present, +// or when metadata is malformed. +func ParsePayMetadataChains(metadata string) []string { + var entries [][]any + if err := json.Unmarshal([]byte(metadata), &entries); err != nil { + return []string{"bitcoin"} + } + var chains []string + for _, entry := range entries { + if len(entry) == 0 { + continue + } + tag, ok := entry[0].(string) + if !ok || !strings.HasPrefix(tag, "chain/") { + continue + } + chains = append(chains, strings.TrimPrefix(tag, "chain/")) + } + if len(chains) == 0 { + return []string{"bitcoin"} + } + return chains +} + +// FetchLud16PayResponse fetches and parses a LUD-16 identifier's LNURL-pay +// document. Returns an error only for a transport failure, a non-200 +// response, or a document missing its callback URL; a malformed metadata +// field degrades to Chains = ["bitcoin"] instead of failing the whole fetch. +func FetchLud16PayResponse(ctx context.Context, client *http.Client, lud16 string) (*PayResponse, error) { + endpoint := GetLud16URL(lud16) + if endpoint == "" { + return nil, fmt.Errorf("invalid lud16 identifier %q", lud16) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("lud16 %q: unexpected status %d", lud16, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var wire struct { + Callback string `json:"callback"` + MinSendable int64 `json:"minSendable"` + MaxSendable int64 `json:"maxSendable"` + Metadata string `json:"metadata"` + AllowsNostr bool `json:"allowsNostr"` + NostrPubkey string `json:"nostrPubkey"` + } + if err := json.Unmarshal(body, &wire); err != nil { + return nil, fmt.Errorf("lud16 %q: parse pay response: %w", lud16, err) + } + if wire.Callback == "" { + return nil, fmt.Errorf("lud16 %q: pay response has no callback", lud16) + } + + return &PayResponse{ + Callback: wire.Callback, + MinSendable: wire.MinSendable, + MaxSendable: wire.MaxSendable, + AllowsNostr: wire.AllowsNostr, + NostrPubkey: wire.NostrPubkey, + Chains: ParsePayMetadataChains(wire.Metadata), + }, nil +} diff --git a/utils/lud06_test.go b/utils/lud06_test.go new file mode 100644 index 0000000..acf5125 --- /dev/null +++ b/utils/lud06_test.go @@ -0,0 +1,114 @@ +package utils + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestParsePayMetadataChains_DefaultsToBitcoin(t *testing.T) { + got := ParsePayMetadataChains(`[["text/plain","a description"]]`) + if len(got) != 1 || got[0] != "bitcoin" { + t.Fatalf("got %v, want [bitcoin]", got) + } +} + +func TestParsePayMetadataChains_MalformedDefaultsToBitcoin(t *testing.T) { + got := ParsePayMetadataChains("not json") + if len(got) != 1 || got[0] != "bitcoin" { + t.Fatalf("got %v, want [bitcoin]", got) + } +} + +func TestParsePayMetadataChains_ExtractsChainEntries(t *testing.T) { + got := ParsePayMetadataChains(`[["text/plain","a description"],["chain/flokicoin",""],["chain/litecoin",""]]`) + want := []string{"flokicoin", "litecoin"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestFetchLud16PayResponse_HappyPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/lnurlp/alice" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{ + "callback": "https://example.com/callback", + "minSendable": 1000, + "maxSendable": 100000000, + "metadata": "[[\"chain/flokicoin\",\"\"]]", + "allowsNostr": true, + "nostrPubkey": "abc123" + }`)) + })) + defer server.Close() + + resp, err := fetchTestPayResponse(t, server, "alice") + if err != nil { + t.Fatalf("FetchLud16PayResponse: %v", err) + } + if resp.Callback != "https://example.com/callback" { + t.Fatalf("Callback: got %q", resp.Callback) + } + if !resp.AllowsNostr || resp.NostrPubkey != "abc123" { + t.Fatalf("AllowsNostr/NostrPubkey: got %v/%q", resp.AllowsNostr, resp.NostrPubkey) + } + if len(resp.Chains) != 1 || resp.Chains[0] != "flokicoin" { + t.Fatalf("Chains: got %v, want [flokicoin]", resp.Chains) + } +} + +func TestFetchLud16PayResponse_MissingCallbackErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"minSendable": 1000}`)) + })) + defer server.Close() + + if _, err := fetchTestPayResponse(t, server, "alice"); err == nil { + t.Fatal("expected an error for a pay response with no callback") + } +} + +func TestFetchLud16PayResponse_NonOKStatusErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + if _, err := fetchTestPayResponse(t, server, "alice"); err == nil { + t.Fatal("expected an error for a non-200 response") + } +} + +// fetchTestPayResponse calls FetchLud16PayResponse against server by +// rewriting GetLud16URL's fixed https:///... shape onto server's own +// address — GetLud16URL always builds an https URL, so tests exercise the +// request-building and response-parsing directly against server's handler +// via a client whose transport redirects to it. +func fetchTestPayResponse(t *testing.T, server *httptest.Server, name string) (*PayResponse, error) { + t.Helper() + client := &http.Client{Transport: redirectToServerTransport{server: server}} + return FetchLud16PayResponse(context.Background(), client, name+"@example.com") +} + +type redirectToServerTransport struct { + server *httptest.Server +} + +func (rt redirectToServerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + target := *req.URL + serverURL, _ := req.URL.Parse(rt.server.URL) + target.Scheme = serverURL.Scheme + target.Host = serverURL.Host + clone := req.Clone(req.Context()) + clone.URL = &target + clone.Host = "" + return http.DefaultTransport.RoundTrip(clone) +}