Skip to content

Commit 3b098e2

Browse files
committed
Add L402 paywall support, remove keys.List(), add by-hash lookups
- Add L402Service with CreateChallenge(), Verify(), Pay() methods - Add L402 types: CreateL402ChallengeParams, L402Challenge, VerifyL402Params, VerifyL402Response, PayL402Params, L402PayResponse - Remove Keys.List() — server endpoint removed - Remove APIKey type - Add GetByHash() and WatchByHash() to InvoicesService and PaymentsService - Bump version to 0.5.0 Made-with: Cursor
1 parent f134ab6 commit 3b098e2

7 files changed

Lines changed: 285 additions & 19 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,27 @@ fmt.Printf("%d sats available\n", wallet.Available)
9595

9696
---
9797

98+
## L402 paywalls
99+
100+
```go
101+
// Create a challenge (server side)
102+
challenge, _ := client.L402.CreateChallenge(ctx, &lnbot.CreateL402ChallengeParams{
103+
Amount: 100,
104+
Description: lnbot.Ptr("API access"),
105+
})
106+
107+
// Pay the challenge (client side)
108+
result, _ := client.L402.Pay(ctx, &lnbot.PayL402Params{
109+
WwwAuthenticate: challenge.WwwAuthenticate,
110+
})
111+
112+
// Verify a token (server side, stateless)
113+
v, _ := client.L402.Verify(ctx, &lnbot.VerifyL402Params{
114+
Authorization: *result.Authorization,
115+
})
116+
fmt.Println(v.Valid)
117+
```
118+
98119
## Error handling
99120

100121
```go

invoices.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ func (s *InvoicesService) CreateForAddress(ctx context.Context, params *CreateIn
6464
return &v, nil
6565
}
6666

67+
// GetByHash returns a single invoice by its payment hash.
68+
func (s *InvoicesService) GetByHash(ctx context.Context, paymentHash string) (*Invoice, error) {
69+
var v Invoice
70+
if err := s.c.get(ctx, fmt.Sprintf("/v1/invoices/%s", paymentHash), &v); err != nil {
71+
return nil, err
72+
}
73+
return &v, nil
74+
}
75+
6776
// Watch opens an SSE stream and sends events to the returned channel.
6877
// The channel is closed when the stream ends. Cancel the context to abort.
6978
func (s *InvoicesService) Watch(ctx context.Context, number int, timeout *int) (<-chan InvoiceEvent, <-chan error) {
@@ -141,3 +150,81 @@ func (s *InvoicesService) Watch(ctx context.Context, number int, timeout *int) (
141150

142151
return events, errs
143152
}
153+
154+
// WatchByHash opens an SSE stream for an invoice identified by payment hash.
155+
// The channel is closed when the stream ends. Cancel the context to abort.
156+
func (s *InvoicesService) WatchByHash(ctx context.Context, paymentHash string, timeout *int) (<-chan InvoiceEvent, <-chan error) {
157+
events := make(chan InvoiceEvent, 1)
158+
errs := make(chan error, 1)
159+
160+
go func() {
161+
defer close(events)
162+
defer close(errs)
163+
164+
path := fmt.Sprintf("%s/v1/invoices/%s/events", s.c.baseURL, paymentHash)
165+
if timeout != nil {
166+
path = fmt.Sprintf("%s?timeout=%d", path, *timeout)
167+
}
168+
169+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
170+
if err != nil {
171+
errs <- err
172+
return
173+
}
174+
req.Header.Set("Accept", "text/event-stream")
175+
req.Header.Set("User-Agent", "lnbot-go/"+Version)
176+
if s.c.apiKey != "" {
177+
req.Header.Set("Authorization", "Bearer "+s.c.apiKey)
178+
}
179+
180+
resp, err := s.c.http.Do(req)
181+
if err != nil {
182+
errs <- err
183+
return
184+
}
185+
defer resp.Body.Close()
186+
187+
if resp.StatusCode >= 400 {
188+
body, _ := io.ReadAll(resp.Body)
189+
errs <- parseAPIError(resp.StatusCode, body)
190+
return
191+
}
192+
193+
scanner := bufio.NewScanner(resp.Body)
194+
var (
195+
eventType string
196+
dataLines []string
197+
)
198+
dispatch := func() {
199+
if eventType == "" || len(dataLines) == 0 {
200+
return
201+
}
202+
raw := strings.Join(dataLines, "\n")
203+
var data Invoice
204+
if json.Unmarshal([]byte(raw), &data) == nil {
205+
events <- InvoiceEvent{Event: eventType, Data: data}
206+
}
207+
}
208+
209+
for scanner.Scan() {
210+
line := scanner.Text()
211+
switch {
212+
case line == "":
213+
dispatch()
214+
eventType = ""
215+
dataLines = nil
216+
case strings.HasPrefix(line, "event:"):
217+
eventType = strings.TrimSpace(line[6:])
218+
case strings.HasPrefix(line, "data:"):
219+
dataLines = append(dataLines, strings.TrimSpace(line[5:]))
220+
}
221+
}
222+
dispatch()
223+
224+
if err := scanner.Err(); err != nil {
225+
errs <- err
226+
}
227+
}()
228+
229+
return events, errs
230+
}

keys.go

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,6 @@ import (
88
// KeysService handles API key operations.
99
type KeysService struct{ c *Client }
1010

11-
// List returns all API keys for the current wallet.
12-
func (s *KeysService) List(ctx context.Context) ([]APIKey, error) {
13-
var v []APIKey
14-
if err := s.c.get(ctx, "/v1/keys", &v); err != nil {
15-
return nil, err
16-
}
17-
return v, nil
18-
}
19-
2011
// Rotate rotates the API key at the given slot (0 = primary, 1 = secondary).
2112
func (s *KeysService) Rotate(ctx context.Context, slot int) (*RotatedAPIKey, error) {
2213
var v RotatedAPIKey

l402.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package lnbot
2+
3+
import "context"
4+
5+
// L402Service handles L402 paywall authentication operations.
6+
type L402Service struct{ c *Client }
7+
8+
// CreateChallenge creates an L402 challenge (invoice + macaroon) for paywall authentication.
9+
func (s *L402Service) CreateChallenge(ctx context.Context, params *CreateL402ChallengeParams) (*L402Challenge, error) {
10+
var v L402Challenge
11+
if err := s.c.post(ctx, "/v1/l402/challenges", params, &v); err != nil {
12+
return nil, err
13+
}
14+
return &v, nil
15+
}
16+
17+
// Verify verifies an L402 authorization token. Stateless — checks signature, preimage, and caveats.
18+
func (s *L402Service) Verify(ctx context.Context, params *VerifyL402Params) (*VerifyL402Response, error) {
19+
var v VerifyL402Response
20+
if err := s.c.post(ctx, "/v1/l402/verify", params, &v); err != nil {
21+
return nil, err
22+
}
23+
return &v, nil
24+
}
25+
26+
// Pay pays an L402 challenge and returns a ready-to-use Authorization header.
27+
func (s *L402Service) Pay(ctx context.Context, params *PayL402Params) (*L402PayResponse, error) {
28+
var v L402PayResponse
29+
if err := s.c.post(ctx, "/v1/l402/pay", params, &v); err != nil {
30+
return nil, err
31+
}
32+
return &v, nil
33+
}

lnbot.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const (
1818
defaultTimeout = 30 * time.Second
1919

2020
// Version is the SDK version sent in the User-Agent header.
21-
Version = "0.4.0"
21+
Version = "0.5.0"
2222
)
2323

2424
// Option configures the Client.
@@ -50,6 +50,7 @@ type Client struct {
5050
Events *EventsService
5151
Backup *BackupService
5252
Restore *RestoreService
53+
L402 *L402Service
5354
}
5455

5556
// New creates a new LnBot client.
@@ -73,6 +74,7 @@ func New(apiKey string, opts ...Option) *Client {
7374
c.Events = &EventsService{c: c}
7475
c.Backup = &BackupService{c: c}
7576
c.Restore = &RestoreService{c: c}
77+
c.L402 = &L402Service{c: c}
7678
return c
7779
}
7880

payments.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ func (s *PaymentsService) Get(ctx context.Context, number int) (*Payment, error)
4545
return &v, nil
4646
}
4747

48+
// GetByHash returns a single payment by its payment hash.
49+
func (s *PaymentsService) GetByHash(ctx context.Context, paymentHash string) (*Payment, error) {
50+
var v Payment
51+
if err := s.c.get(ctx, fmt.Sprintf("/v1/payments/%s", paymentHash), &v); err != nil {
52+
return nil, err
53+
}
54+
return &v, nil
55+
}
56+
4857
// Watch opens an SSE stream and sends events to the returned channel.
4958
// The channel is closed when the stream ends. Cancel the context to abort.
5059
func (s *PaymentsService) Watch(ctx context.Context, number int, timeout *int) (<-chan PaymentEvent, <-chan error) {
@@ -122,3 +131,81 @@ func (s *PaymentsService) Watch(ctx context.Context, number int, timeout *int) (
122131

123132
return events, errs
124133
}
134+
135+
// WatchByHash opens an SSE stream for a payment identified by payment hash.
136+
// The channel is closed when the stream ends. Cancel the context to abort.
137+
func (s *PaymentsService) WatchByHash(ctx context.Context, paymentHash string, timeout *int) (<-chan PaymentEvent, <-chan error) {
138+
events := make(chan PaymentEvent, 1)
139+
errs := make(chan error, 1)
140+
141+
go func() {
142+
defer close(events)
143+
defer close(errs)
144+
145+
path := fmt.Sprintf("%s/v1/payments/%s/events", s.c.baseURL, paymentHash)
146+
if timeout != nil {
147+
path = fmt.Sprintf("%s?timeout=%d", path, *timeout)
148+
}
149+
150+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
151+
if err != nil {
152+
errs <- err
153+
return
154+
}
155+
req.Header.Set("Accept", "text/event-stream")
156+
req.Header.Set("User-Agent", "lnbot-go/"+Version)
157+
if s.c.apiKey != "" {
158+
req.Header.Set("Authorization", "Bearer "+s.c.apiKey)
159+
}
160+
161+
resp, err := s.c.http.Do(req)
162+
if err != nil {
163+
errs <- err
164+
return
165+
}
166+
defer resp.Body.Close()
167+
168+
if resp.StatusCode >= 400 {
169+
body, _ := io.ReadAll(resp.Body)
170+
errs <- parseAPIError(resp.StatusCode, body)
171+
return
172+
}
173+
174+
scanner := bufio.NewScanner(resp.Body)
175+
var (
176+
eventType string
177+
dataLines []string
178+
)
179+
dispatch := func() {
180+
if eventType == "" || len(dataLines) == 0 {
181+
return
182+
}
183+
raw := strings.Join(dataLines, "\n")
184+
var data Payment
185+
if json.Unmarshal([]byte(raw), &data) == nil {
186+
events <- PaymentEvent{Event: eventType, Data: data}
187+
}
188+
}
189+
190+
for scanner.Scan() {
191+
line := scanner.Text()
192+
switch {
193+
case line == "":
194+
dispatch()
195+
eventType = ""
196+
dataLines = nil
197+
case strings.HasPrefix(line, "event:"):
198+
eventType = strings.TrimSpace(line[6:])
199+
case strings.HasPrefix(line, "data:"):
200+
dataLines = append(dataLines, strings.TrimSpace(line[5:]))
201+
}
202+
}
203+
dispatch()
204+
205+
if err := scanner.Err(); err != nil {
206+
errs <- err
207+
}
208+
}()
209+
210+
return events, errs
211+
}

types.go

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,6 @@ type UpdateWalletParams struct {
4646
// API Keys
4747
// ---------------------------------------------------------------------------
4848

49-
// APIKey represents an API key's metadata.
50-
type APIKey struct {
51-
ID string `json:"id"`
52-
Name string `json:"name"`
53-
Hint string `json:"hint"`
54-
CreatedAt *time.Time `json:"createdAt"`
55-
LastUsedAt *time.Time `json:"lastUsedAt"`
56-
}
57-
5849
// RotatedAPIKey is returned after rotating an API key.
5950
type RotatedAPIKey struct {
6051
Key string `json:"key"`
@@ -298,3 +289,57 @@ type PasskeyAssertionParams struct {
298289
SessionID string `json:"sessionId"`
299290
Assertion map[string]any `json:"assertion"`
300291
}
292+
293+
// ---------------------------------------------------------------------------
294+
// L402
295+
// ---------------------------------------------------------------------------
296+
297+
// CreateL402ChallengeParams are the parameters for creating an L402 challenge.
298+
type CreateL402ChallengeParams struct {
299+
Amount int64 `json:"amount"`
300+
Description *string `json:"description,omitempty"`
301+
ExpirySeconds *int `json:"expirySeconds,omitempty"`
302+
Caveats []string `json:"caveats,omitempty"`
303+
}
304+
305+
// L402Challenge is returned when an L402 challenge is created.
306+
type L402Challenge struct {
307+
Macaroon string `json:"macaroon"`
308+
Invoice string `json:"invoice"`
309+
PaymentHash string `json:"paymentHash"`
310+
ExpiresAt time.Time `json:"expiresAt"`
311+
WwwAuthenticate string `json:"wwwAuthenticate"`
312+
}
313+
314+
// VerifyL402Params are the parameters for verifying an L402 token.
315+
type VerifyL402Params struct {
316+
Authorization string `json:"authorization"`
317+
}
318+
319+
// VerifyL402Response is returned when verifying an L402 token.
320+
type VerifyL402Response struct {
321+
Valid bool `json:"valid"`
322+
PaymentHash *string `json:"paymentHash"`
323+
Caveats []string `json:"caveats"`
324+
Error *string `json:"error"`
325+
}
326+
327+
// PayL402Params are the parameters for paying an L402 challenge.
328+
type PayL402Params struct {
329+
WwwAuthenticate string `json:"wwwAuthenticate"`
330+
MaxFee *int64 `json:"maxFee,omitempty"`
331+
Reference *string `json:"reference,omitempty"`
332+
Wait *bool `json:"wait,omitempty"`
333+
Timeout *int `json:"timeout,omitempty"`
334+
}
335+
336+
// L402PayResponse is returned after paying an L402 challenge.
337+
type L402PayResponse struct {
338+
Authorization *string `json:"authorization"`
339+
PaymentHash string `json:"paymentHash"`
340+
Preimage *string `json:"preimage"`
341+
Amount int64 `json:"amount"`
342+
Fee *int64 `json:"fee"`
343+
PaymentNumber int `json:"paymentNumber"`
344+
Status string `json:"status"`
345+
}

0 commit comments

Comments
 (0)