Skip to content

Commit 2ae53fb

Browse files
committed
add CLN REST backend with support for Core Lightning nodes
Signed-off-by: Gustavo Chain <me@qustavo.cc>
1 parent 37ad88a commit 2ae53fb

File tree

3 files changed

+477
-12
lines changed

3 files changed

+477
-12
lines changed

cmd/l402proxy/main.go

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package main
22

33
import (
4-
"context"
54
"crypto/rand"
65
"encoding/hex"
76
"fmt"
@@ -37,6 +36,11 @@ func main() {
3736
Usage: "Address to listen on",
3837
Value: ":8080",
3938
},
39+
&cli.StringFlag{
40+
Name: "backend",
41+
Usage: "Lightning backend to use (lnd or cln)",
42+
Value: "lnd",
43+
},
4044
&cli.StringFlag{
4145
Name: "lnd-host",
4246
Usage: "LND gRPC host:port",
@@ -52,6 +56,18 @@ func main() {
5256
Usage: "Path to LND TLS cert",
5357
Value: defaultCertPath(),
5458
},
59+
&cli.StringFlag{
60+
Name: "cln-url",
61+
Usage: "CLN REST API base URL (e.g. https://localhost:3010)",
62+
},
63+
&cli.StringFlag{
64+
Name: "cln-rune",
65+
Usage: "CLN rune (auth token)",
66+
},
67+
&cli.StringFlag{
68+
Name: "cln-cert",
69+
Usage: "Path to CLN TLS cert (optional; empty = system CAs)",
70+
},
5571
&cli.StringFlag{
5672
Name: "service-name",
5773
Usage: "Label used in invoice memos",
@@ -94,19 +110,44 @@ func run(c *cli.Context) error {
94110
return err
95111
}
96112

97-
backend, err := lightning.NewLNDBackend(lightning.LNDConfig{
98-
Host: c.String("lnd-host"),
99-
CertPath: c.String("lnd-cert"),
100-
MacaroonPath: c.String("lnd-macaroon"),
101-
})
102-
if err != nil {
103-
return fmt.Errorf("connecting to LND: %w", err)
113+
var backend lightning.Backend
114+
backendType := c.String("backend")
115+
116+
switch backendType {
117+
case "lnd":
118+
b, err := lightning.NewLNDBackend(lightning.LNDConfig{
119+
Host: c.String("lnd-host"),
120+
CertPath: c.String("lnd-cert"),
121+
MacaroonPath: c.String("lnd-macaroon"),
122+
})
123+
if err != nil {
124+
return fmt.Errorf("connecting to LND: %w", err)
125+
}
126+
backend = b
127+
case "cln":
128+
clnURL := c.String("cln-url")
129+
clnRune := c.String("cln-rune")
130+
if clnURL == "" || clnRune == "" {
131+
return fmt.Errorf("--cln-url and --cln-rune are required when using CLN backend")
132+
}
133+
b, err := lightning.NewCLNBackend(lightning.CLNConfig{
134+
BaseURL: clnURL,
135+
Rune: clnRune,
136+
CertPath: c.String("cln-cert"),
137+
})
138+
if err != nil {
139+
return fmt.Errorf("connecting to CLN: %w", err)
140+
}
141+
backend = b
142+
default:
143+
return fmt.Errorf("unsupported backend: %q (must be 'lnd' or 'cln')", backendType)
104144
}
105-
log.Info("waiting for LND to be ready...")
106-
if err := backend.Wait(context.Background()); err != nil {
107-
return fmt.Errorf("LND not ready: %w", err)
145+
146+
log.Info("waiting for backend to be ready...", "backend", backendType)
147+
if err := backend.Wait(c.Context); err != nil {
148+
return fmt.Errorf("backend not ready: %w", err)
108149
}
109-
log.Info("LND ready")
150+
log.Info("backend ready", "backend", backendType)
110151

111152
h := proxy.New(proxy.Config{
112153
Upstream: upstream,

pkg/lightning/cln.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package lightning
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/rand"
7+
"crypto/tls"
8+
"crypto/x509"
9+
"encoding/hex"
10+
"encoding/json"
11+
"fmt"
12+
"io"
13+
"log/slog"
14+
"net/http"
15+
"os"
16+
"time"
17+
)
18+
19+
// CLNConfig holds connection parameters for a Core Lightning node.
20+
type CLNConfig struct {
21+
BaseURL string // e.g. "https://localhost:3010"
22+
Rune string // CLN auth token
23+
CertPath string // optional; empty = system CAs
24+
}
25+
26+
// CLNBackend implements Backend against a Core Lightning node via REST API.
27+
type CLNBackend struct {
28+
baseURL string
29+
rune string
30+
client *http.Client
31+
}
32+
33+
// NewCLNBackend connects to a CLN node and returns a ready Backend.
34+
func NewCLNBackend(cfg CLNConfig) (*CLNBackend, error) {
35+
var tlsConfig *tls.Config
36+
37+
if cfg.CertPath != "" {
38+
certBytes, err := os.ReadFile(cfg.CertPath)
39+
if err != nil {
40+
return nil, fmt.Errorf("reading TLS cert: %w", err)
41+
}
42+
pool := x509.NewCertPool()
43+
if !pool.AppendCertsFromPEM(certBytes) {
44+
return nil, fmt.Errorf("failed to add CLN cert to pool")
45+
}
46+
tlsConfig = &tls.Config{RootCAs: pool}
47+
} else {
48+
tlsConfig = &tls.Config{}
49+
}
50+
51+
client := &http.Client{
52+
Timeout: 10 * time.Second,
53+
Transport: &http.Transport{
54+
TLSClientConfig: tlsConfig,
55+
},
56+
}
57+
58+
return &CLNBackend{
59+
baseURL: cfg.BaseURL,
60+
rune: cfg.Rune,
61+
client: client,
62+
}, nil
63+
}
64+
65+
// post performs a POST request to a CLN REST endpoint with auth headers and error handling.
66+
func (b *CLNBackend) post(ctx context.Context, path string, body any) (*http.Response, error) {
67+
var reqBody io.Reader
68+
if body != nil {
69+
jsonBody, err := json.Marshal(body)
70+
if err != nil {
71+
return nil, fmt.Errorf("marshaling request body: %w", err)
72+
}
73+
reqBody = bytes.NewReader(jsonBody)
74+
}
75+
76+
req, err := http.NewRequestWithContext(ctx, "POST", b.baseURL+path, reqBody)
77+
if err != nil {
78+
return nil, fmt.Errorf("creating request: %w", err)
79+
}
80+
81+
req.Header.Set("Content-Type", "application/json")
82+
req.Header.Set("Rune", b.rune)
83+
84+
resp, err := b.client.Do(req)
85+
if err != nil {
86+
return nil, fmt.Errorf("POST %s: %w", path, err)
87+
}
88+
89+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
90+
defer resp.Body.Close()
91+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
92+
return nil, fmt.Errorf("POST %s: HTTP %d: %s", path, resp.StatusCode, string(body))
93+
}
94+
95+
return resp, nil
96+
}
97+
98+
// Wait blocks until the CLN node is fully synced to chain or ctx is cancelled.
99+
// It polls getinfo every 5 seconds and logs progress.
100+
func (b *CLNBackend) Wait(ctx context.Context) error {
101+
for {
102+
resp, err := b.post(ctx, "/v1/getinfo", nil)
103+
if err != nil {
104+
return fmt.Errorf("getinfo: %w", err)
105+
}
106+
107+
var info struct {
108+
WarningBitcoindSync string `json:"warning_bitcoind_sync"`
109+
WarningLightningdSync string `json:"warning_lightningd_sync"`
110+
BlockHeight int64 `json:"blockheight"`
111+
}
112+
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
113+
resp.Body.Close()
114+
return fmt.Errorf("decoding getinfo: %w", err)
115+
}
116+
resp.Body.Close()
117+
118+
// Synced when both warnings are absent/empty
119+
if info.WarningBitcoindSync == "" && info.WarningLightningdSync == "" {
120+
return nil
121+
}
122+
123+
slog.Info("waiting for CLN to sync to chain...",
124+
"block_height", info.BlockHeight,
125+
)
126+
127+
select {
128+
case <-ctx.Done():
129+
return ctx.Err()
130+
case <-time.After(5 * time.Second):
131+
}
132+
}
133+
}
134+
135+
// CreateInvoice asks CLN to create a new invoice and returns its details.
136+
func (b *CLNBackend) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (*Invoice, error) {
137+
// Generate unique label: 16 random bytes, hex-encoded
138+
labelBytes := make([]byte, 16)
139+
if _, err := rand.Read(labelBytes); err != nil {
140+
return nil, fmt.Errorf("generating invoice label: %w", err)
141+
}
142+
label := hex.EncodeToString(labelBytes)
143+
144+
reqBody := map[string]any{
145+
"amount_msat": amountMsat,
146+
"label": label,
147+
"description": memo,
148+
}
149+
150+
resp, err := b.post(ctx, "/v1/invoice", reqBody)
151+
if err != nil {
152+
return nil, fmt.Errorf("invoice: %w", err)
153+
}
154+
defer resp.Body.Close()
155+
156+
var invResp struct {
157+
PaymentHash string `json:"payment_hash"`
158+
Bolt11 string `json:"bolt11"`
159+
}
160+
if err := json.NewDecoder(resp.Body).Decode(&invResp); err != nil {
161+
return nil, fmt.Errorf("decoding invoice response: %w", err)
162+
}
163+
164+
return &Invoice{
165+
PaymentHash: invResp.PaymentHash,
166+
PaymentRequest: invResp.Bolt11,
167+
AmountMsat: amountMsat,
168+
}, nil
169+
}
170+
171+
// VerifyPayment returns true if the invoice identified by paymentHash has been settled.
172+
func (b *CLNBackend) VerifyPayment(ctx context.Context, paymentHash string) (bool, error) {
173+
reqBody := map[string]string{
174+
"payment_hash": paymentHash,
175+
}
176+
177+
resp, err := b.post(ctx, "/v1/listinvoices", reqBody)
178+
if err != nil {
179+
return false, fmt.Errorf("listinvoices: %w", err)
180+
}
181+
defer resp.Body.Close()
182+
183+
var listResp struct {
184+
Invoices []struct {
185+
Status string `json:"status"`
186+
} `json:"invoices"`
187+
}
188+
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
189+
return false, fmt.Errorf("decoding listinvoices response: %w", err)
190+
}
191+
192+
if len(listResp.Invoices) == 0 {
193+
return false, nil
194+
}
195+
196+
return listResp.Invoices[0].Status == "paid", nil
197+
}

0 commit comments

Comments
 (0)