phoenixd.go raw
1 package phoenixd
2
3 import (
4 "context"
5 b64 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15
16 decodepay "github.com/nbd-wtf/ln-decodepay"
17
18 "github.com/getAlby/hub/lnclient"
19 "github.com/getAlby/hub/logger"
20
21 "github.com/sirupsen/logrus"
22 )
23
24 // errNotFound indicates that a payment was not found at the queried endpoint.
25 var errNotFound = errors.New("phoenixd: payment not found")
26
27 type InvoiceResponse struct {
28 PaymentHash string `json:"paymentHash"`
29 Preimage string `json:"preimage"`
30 ExternalId string `json:"externalId"`
31 Description string `json:"description"`
32 Invoice string `json:"invoice"`
33 IsPaid bool `json:"isPaid"`
34 ReceivedSat int64 `json:"receivedSat"`
35 FeesSat int64 `json:"fees"`
36 CompletedAt int64 `json:"completedAt"`
37 CreatedAt int64 `json:"createdAt"`
38 }
39
40 type OutgoingPaymentResponse struct {
41 PaymentHash string `json:"paymentHash"`
42 Preimage string `json:"preimage"`
43 Invoice string `json:"invoice"`
44 IsPaid bool `json:"isPaid"`
45 Sent int64 `json:"sent"`
46 Fees int64 `json:"fees"`
47 CompletedAt int64 `json:"completedAt"`
48 CreatedAt int64 `json:"createdAt"`
49 }
50
51 type PayResponse struct {
52 PaymentHash string `json:"paymentHash"`
53 PaymentId string `json:"paymentId"`
54 PaymentPreimage string `json:"paymentPreimage"`
55 RoutingFeeSat int64 `json:"routingFeeSat"`
56 }
57
58 type MakeInvoiceResponse struct {
59 AmountSat int64 `json:"amountSat"`
60 PaymentHash string `json:"paymentHash"`
61 Serialized string `json:"serialized"`
62 }
63
64 type InfoResponse struct {
65 NodeId string `json:"nodeId"`
66 }
67
68 type BalanceResponse struct {
69 BalanceSat int64 `json:"balanceSat"`
70 FeeCreditSat int64 `json:"feeCreditSat"`
71 }
72
73 type PhoenixService struct {
74 Address string
75 Authorization string
76 pubkey string
77 nodeInfo *lnclient.NodeInfo
78 ctx context.Context
79 }
80
81 func NewPhoenixService(ctx context.Context, address string, authorization string) (result lnclient.LNClient, err error) {
82 authorizationBase64 := b64.StdEncoding.EncodeToString([]byte(":" + authorization))
83 // some environments (e.g. in a cloud environment like render.com) can only get the address and the port but not the protocol
84 // in those cases we default to http for local requests
85 if !strings.HasPrefix(address, "http") {
86 address = "http://" + address
87 }
88 phoenixService := &PhoenixService{ctx: ctx, Address: address, Authorization: authorizationBase64}
89
90 info, err := fetchNodeInfo(ctx, phoenixService)
91 if err != nil {
92 return nil, err
93 }
94 phoenixService.nodeInfo = info
95 phoenixService.pubkey = info.Pubkey
96
97 return phoenixService, nil
98 }
99
100 func (svc *PhoenixService) GetBalances(ctx context.Context, includeInactiveChannels bool) (*lnclient.BalancesResponse, error) {
101 req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/getbalance", nil)
102 if err != nil {
103 return nil, err
104 }
105 req.Header.Add("Authorization", "Basic "+svc.Authorization)
106 client := &http.Client{Timeout: 5 * time.Second}
107 resp, err := client.Do(req)
108 if err != nil {
109 return nil, err
110 }
111 defer resp.Body.Close()
112
113 body, err := io.ReadAll(resp.Body)
114 if err != nil {
115 return nil, err
116 }
117 if resp.StatusCode != http.StatusOK {
118 logger.Logger.WithFields(logrus.Fields{
119 "body": string(body),
120 "status_code": resp.StatusCode,
121 }).Error("phoenixd get balance endpoint returned non-success code")
122 return nil, fmt.Errorf("phoenixd get balance endpoint returned non-success code: %d %s", resp.StatusCode, string(body))
123 }
124
125 var balanceRes BalanceResponse
126 if err := json.Unmarshal(body, &balanceRes); err != nil {
127 return nil, err
128 }
129
130 balance := balanceRes.BalanceSat * 1000
131
132 return &lnclient.BalancesResponse{
133 Onchain: lnclient.OnchainBalanceResponse{
134 PendingBalancesDetails: []lnclient.PendingBalanceDetails{},
135 PendingSweepBalancesDetails: []lnclient.PendingBalanceDetails{}},
136 Lightning: lnclient.LightningBalanceResponse{
137 TotalSpendableMsat: balance,
138 NextMaxSpendableMsat: balance,
139 NextMaxSpendableMPPMsat: balance,
140 },
141 }, nil
142 }
143
144 func (svc *PhoenixService) GetInfo(ctx context.Context) (info *lnclient.NodeInfo, err error) {
145 return svc.nodeInfo, nil
146 }
147
148 func fetchNodeInfo(ctx context.Context, svc *PhoenixService) (info *lnclient.NodeInfo, err error) {
149 req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/getinfo", nil)
150 if err != nil {
151 return nil, err
152 }
153 req.Header.Add("Authorization", "Basic "+svc.Authorization)
154 client := &http.Client{Timeout: 5 * time.Second}
155 resp, err := client.Do(req)
156 if err != nil {
157 return nil, err
158 }
159 defer resp.Body.Close()
160
161 body, err := io.ReadAll(resp.Body)
162 if err != nil {
163 return nil, err
164 }
165 if resp.StatusCode != http.StatusOK {
166 logger.Logger.WithFields(logrus.Fields{
167 "body": string(body),
168 "status_code": resp.StatusCode,
169 }).Error("phoenixd get info endpoint returned non-success code")
170 return nil, fmt.Errorf("phoenixd get info endpoint returned non-success code: %d %s", resp.StatusCode, string(body))
171 }
172
173 var infoRes InfoResponse
174 if err := json.Unmarshal(body, &infoRes); err != nil {
175 return nil, err
176 }
177 return &lnclient.NodeInfo{
178 Alias: "Phoenix",
179 Color: "",
180 Pubkey: infoRes.NodeId,
181 Network: "bitcoin",
182 BlockHeight: 0,
183 BlockHash: "",
184 }, nil
185 }
186
187 func (svc *PhoenixService) ListChannels(ctx context.Context) ([]lnclient.Channel, error) {
188 channels := []lnclient.Channel{}
189 return channels, nil
190 }
191
192 func (svc *PhoenixService) MakeInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, throughNodePubkey *string) (transaction *lnclient.Transaction, err error) {
193 // TODO: support expiry
194 if expiry == 0 {
195 expiry = lnclient.DEFAULT_INVOICE_EXPIRY
196 }
197 form := url.Values{}
198 amountSat := strconv.FormatInt(amountMsat/1000, 10)
199 form.Add("amountSat", amountSat)
200 if description != "" {
201 form.Add("description", description)
202 } else if descriptionHash != "" {
203 form.Add("descriptionHash", descriptionHash)
204 } else {
205 form.Add("description", "invoice")
206 }
207
208 today := time.Now().UTC().Format("2006-02-01") // querying is too slow so we limit the invoices we query with the date - see list transactions
209 form.Add("externalId", today) // for some resone phoenixd requires an external id to query a list of invoices. thus we set this to nwc
210 logger.Logger.WithFields(logrus.Fields{
211 "externalId": today,
212 "amountSat": amountSat,
213 }).Infof("Requesting phoenix invoice")
214 req, err := http.NewRequestWithContext(ctx, http.MethodPost, svc.Address+"/createinvoice", strings.NewReader(form.Encode()))
215 if err != nil {
216 return nil, err
217 }
218 req.Header.Add("Authorization", "Basic "+svc.Authorization)
219 req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
220 client := &http.Client{Timeout: 10 * time.Second}
221 resp, err := client.Do(req)
222 if err != nil {
223 return nil, err
224 }
225 defer resp.Body.Close()
226
227 body, err := io.ReadAll(resp.Body)
228 if err != nil {
229 return nil, err
230 }
231 if resp.StatusCode != http.StatusOK {
232 logger.Logger.WithFields(logrus.Fields{
233 "body": string(body),
234 "status_code": resp.StatusCode,
235 }).Error("phoenixd create invoice endpoint returned non-success code")
236 return nil, fmt.Errorf("phoenixd create invoice endpoint returned non-success code: %d %s", resp.StatusCode, string(body))
237 }
238
239 var invoiceRes MakeInvoiceResponse
240 if err := json.Unmarshal(body, &invoiceRes); err != nil {
241 return nil, err
242 }
243
244 tx, err := svc.LookupInvoice(ctx, invoiceRes.PaymentHash)
245 if err != nil {
246 logger.Logger.WithError(err).Error("failed to lookup newly created invoice")
247 return nil, err
248 }
249
250 return tx, nil
251 }
252
253 func (svc *PhoenixService) MakeHoldInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, paymentHash string, minCltvExpiryDelta *uint64) (transaction *lnclient.Transaction, err error) {
254 return nil, errors.New("not implemented")
255 }
256
257 func (svc *PhoenixService) SettleHoldInvoice(ctx context.Context, preimage string) (err error) {
258 return errors.New("not implemented")
259 }
260
261 func (svc *PhoenixService) CancelHoldInvoice(ctx context.Context, paymentHash string) (err error) {
262 return errors.New("not implemented")
263 }
264
265 // LookupInvoice looks up a transaction by payment hash. It first checks
266 // incoming payments, then falls back to outgoing payments if the incoming
267 // payment is not found (HTTP 404).
268 func (svc *PhoenixService) LookupInvoice(ctx context.Context, paymentHash string) (transaction *lnclient.Transaction, err error) {
269 transaction, err = svc.lookupIncomingPayment(ctx, paymentHash)
270 if err == nil {
271 return transaction, nil
272 }
273
274 // Only fall back to outgoing lookup when incoming returns not-found.
275 if !errors.Is(err, errNotFound) {
276 return nil, err
277 }
278
279 return svc.lookupOutgoingPayment(ctx, paymentHash)
280 }
281
282 // lookupIncomingPayment fetches an incoming payment from Phoenixd by payment hash.
283 func (svc *PhoenixService) lookupIncomingPayment(ctx context.Context, paymentHash string) (*lnclient.Transaction, error) {
284 req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/payments/incoming/"+paymentHash, nil)
285 if err != nil {
286 return nil, fmt.Errorf("create phoenixd incoming payment request: %w", err)
287 }
288 req.Header.Add("Authorization", "Basic "+svc.Authorization)
289 client := &http.Client{Timeout: 5 * time.Second}
290 resp, err := client.Do(req)
291 if err != nil {
292 return nil, fmt.Errorf("call phoenixd incoming payment endpoint: %w", err)
293 }
294 defer resp.Body.Close()
295
296 body, err := io.ReadAll(resp.Body)
297 if err != nil {
298 return nil, fmt.Errorf("read phoenixd incoming payment response: %w", err)
299 }
300 if resp.StatusCode == http.StatusNotFound {
301 return nil, errNotFound
302 }
303 if resp.StatusCode != http.StatusOK {
304 return nil, fmt.Errorf("phoenixd incoming payments endpoint returned non-success code: %d %s", resp.StatusCode, string(body))
305 }
306
307 var invoiceRes InvoiceResponse
308 if err := json.Unmarshal(body, &invoiceRes); err != nil {
309 return nil, fmt.Errorf("decode phoenixd incoming payment response: %w", err)
310 }
311
312 return phoenixInvoiceToTransaction(&invoiceRes)
313 }
314
315 // lookupOutgoingPayment fetches an outgoing payment from Phoenixd using the
316 // /payments/outgoingbyhash/{paymentHash} endpoint.
317 func (svc *PhoenixService) lookupOutgoingPayment(ctx context.Context, paymentHash string) (*lnclient.Transaction, error) {
318 req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/payments/outgoingbyhash/"+paymentHash, nil)
319 if err != nil {
320 return nil, fmt.Errorf("create phoenixd outgoing payment request: %w", err)
321 }
322 req.Header.Add("Authorization", "Basic "+svc.Authorization)
323 client := &http.Client{Timeout: 5 * time.Second}
324 resp, err := client.Do(req)
325 if err != nil {
326 return nil, fmt.Errorf("call phoenixd outgoing payment endpoint: %w", err)
327 }
328 defer resp.Body.Close()
329
330 body, err := io.ReadAll(resp.Body)
331 if err != nil {
332 return nil, fmt.Errorf("read phoenixd outgoing payment response: %w", err)
333 }
334 if resp.StatusCode != http.StatusOK {
335 return nil, fmt.Errorf("phoenixd outgoing payment lookup returned non-success code: %d %s", resp.StatusCode, string(body))
336 }
337
338 var paymentRes OutgoingPaymentResponse
339 if err := json.Unmarshal(body, &paymentRes); err != nil {
340 return nil, fmt.Errorf("decode phoenixd outgoing payment response: %w", err)
341 }
342
343 return outgoingPaymentToTransaction(&paymentRes)
344 }
345
346 func (svc *PhoenixService) SendPaymentSync(payReq string, amountMsat *uint64) (*lnclient.PayInvoiceResponse, error) {
347 // TODO: support 0-amount invoices
348 if amountMsat != nil {
349 return nil, errors.New("0-amount invoices not supported")
350 }
351 form := url.Values{}
352 form.Add("invoice", payReq)
353 req, err := http.NewRequestWithContext(svc.ctx, http.MethodPost, svc.Address+"/payinvoice", strings.NewReader(form.Encode()))
354 if err != nil {
355 return nil, err
356 }
357 req.Header.Add("Authorization", "Basic "+svc.Authorization)
358 req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
359 client := &http.Client{Timeout: 90 * time.Second}
360 resp, err := client.Do(req)
361 if err != nil {
362 return nil, err
363 }
364 defer resp.Body.Close()
365
366 body, err := io.ReadAll(resp.Body)
367 if err != nil {
368 return nil, err
369 }
370 if resp.StatusCode != http.StatusOK {
371 return nil, fmt.Errorf("phoenixd /payinvoice returned non-success status: %d %s", resp.StatusCode, string(body))
372 }
373
374 var payRes PayResponse
375 if err := json.Unmarshal(body, &payRes); err != nil {
376 return nil, err
377 }
378
379 return &lnclient.PayInvoiceResponse{
380 Preimage: payRes.PaymentPreimage,
381 FeeMsat: uint64(payRes.RoutingFeeSat) * 1000,
382 }, nil
383 }
384
385 func (svc *PhoenixService) SendKeysend(amountMsat uint64, destination string, custom_records []lnclient.TLVRecord, preimage string) (*lnclient.PayKeysendResponse, error) {
386 return nil, errors.New("not implemented")
387 }
388
389 func (svc *PhoenixService) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (txId string, err error) {
390 return "", errors.New("not implemented")
391 }
392
393 func (svc *PhoenixService) ResetRouter(key string) error {
394 return errors.New("not implemented")
395 }
396
397 func (svc *PhoenixService) Shutdown() error {
398 // No specific shutdown actions needed for Phoenixd client via HTTP
399 return nil
400 }
401
402 func (svc *PhoenixService) GetNodeConnectionInfo(ctx context.Context) (nodeConnectionInfo *lnclient.NodeConnectionInfo, err error) {
403 req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/getinfo", nil)
404 if err != nil {
405 return nil, err
406 }
407 req.Header.Add("Authorization", "Basic "+svc.Authorization)
408 client := &http.Client{Timeout: 5 * time.Second}
409 resp, err := client.Do(req)
410 if err != nil {
411 return nil, err
412 }
413 defer resp.Body.Close()
414
415 body, err := io.ReadAll(resp.Body)
416 if err != nil {
417 return nil, err
418 }
419 if resp.StatusCode != http.StatusOK {
420 return nil, fmt.Errorf("phoenixd /getinfo returned non-success status: %d %s", resp.StatusCode, string(body))
421 }
422
423 var infoRes InfoResponse
424 if err := json.Unmarshal(body, &infoRes); err != nil {
425 return nil, err
426 }
427 return &lnclient.NodeConnectionInfo{
428 Pubkey: infoRes.NodeId,
429 }, nil
430 }
431
432 func (svc *PhoenixService) ConnectPeer(ctx context.Context, connectPeerRequest *lnclient.ConnectPeerRequest) error {
433 return errors.New("not implemented")
434 }
435 func (svc *PhoenixService) OpenChannel(ctx context.Context, openChannelRequest *lnclient.OpenChannelRequest) (*lnclient.OpenChannelResponse, error) {
436 return nil, errors.New("not implemented")
437 }
438
439 func (svc *PhoenixService) CloseChannel(ctx context.Context, closeChannelRequest *lnclient.CloseChannelRequest) error {
440 return errors.New("not implemented")
441 }
442
443 func (svc *PhoenixService) GetNewOnchainAddress(ctx context.Context) (string, error) {
444 return "", errors.New("not implemented")
445 }
446
447 func (svc *PhoenixService) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainBalanceResponse, error) {
448 return nil, errors.New("not implemented")
449 }
450
451 func (svc *PhoenixService) SignMessage(ctx context.Context, message string) (string, error) {
452 return "", errors.New("not implemented")
453 }
454
455 func (svc *PhoenixService) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) {
456 return nil, nil
457 }
458
459 func (svc *PhoenixService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, error) {
460 return []byte{}, nil
461 }
462
463 func (svc *PhoenixService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) {
464 _, err = fetchNodeInfo(ctx, svc)
465 if err != nil {
466 return nil, err
467 }
468
469 return &lnclient.NodeStatus{
470 IsReady: true,
471 }, nil
472 }
473
474 func (svc *PhoenixService) GetStorageDir() (string, error) {
475 return "", nil
476 }
477
478 func (svc *PhoenixService) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) {
479 return nil, nil
480 }
481
482 func (svc *PhoenixService) UpdateLastWalletSyncRequest() {}
483
484 func (svc *PhoenixService) DisconnectPeer(ctx context.Context, peerId string) error {
485 return errors.New("not implemented")
486 }
487
488 func (svc *PhoenixService) UpdateChannel(ctx context.Context, updateChannelRequest *lnclient.UpdateChannelRequest) error {
489 return errors.New("not implemented")
490 }
491
492 func (svc *PhoenixService) GetSupportedNIP47Methods() []string {
493 return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"}
494 }
495
496 func (svc *PhoenixService) GetSupportedNIP47NotificationTypes() []string {
497 return []string{}
498 }
499
500 func (svc *PhoenixService) GetPubkey() string {
501 return svc.pubkey
502 }
503
504 func phoenixInvoiceToTransaction(invoiceRes *InvoiceResponse) (*lnclient.Transaction, error) {
505 var settledAt *int64
506 if invoiceRes.CompletedAt != 0 {
507 settledAtUnix := time.UnixMilli(invoiceRes.CompletedAt).Unix()
508 settledAt = &settledAtUnix
509 }
510
511 paymentRequest, err := decodepay.Decodepay(invoiceRes.Invoice)
512 if err != nil {
513 logger.Logger.WithFields(logrus.Fields{
514 "bolt11": invoiceRes.Invoice,
515 }).Errorf("Failed to decode bolt11 invoice: %v", err)
516
517 return nil, err
518 }
519
520 expiresAt := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
521
522 return &lnclient.Transaction{
523 Type: "incoming",
524 Invoice: invoiceRes.Invoice,
525 Preimage: invoiceRes.Preimage,
526 PaymentHash: invoiceRes.PaymentHash,
527 AmountMsat: paymentRequest.MSatoshi,
528 FeesPaidMsat: invoiceRes.FeesSat * 1000,
529 CreatedAt: time.UnixMilli(invoiceRes.CreatedAt).Unix(),
530 Description: invoiceRes.Description,
531 SettledAt: settledAt,
532 ExpiresAt: &expiresAt,
533 DescriptionHash: paymentRequest.DescriptionHash,
534 }, nil
535 }
536
537 // outgoingPaymentToTransaction converts a Phoenixd OutgoingPaymentResponse
538 // to an lnclient.Transaction.
539 func outgoingPaymentToTransaction(payment *OutgoingPaymentResponse) (*lnclient.Transaction, error) {
540 var settledAt *int64
541 if payment.CompletedAt != 0 {
542 settledAtUnix := time.UnixMilli(payment.CompletedAt).Unix()
543 settledAt = &settledAtUnix
544 }
545
546 paymentRequest, err := decodepay.Decodepay(payment.Invoice)
547 if err != nil {
548 logger.Logger.WithFields(logrus.Fields{
549 "bolt11": payment.Invoice,
550 }).Errorf("Failed to decode bolt11 invoice: %v", err)
551 return nil, fmt.Errorf("decode phoenixd outgoing payment bolt11: %w", err)
552 }
553
554 expiresAt := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
555
556 // unlike incoming payments, "fees" on outgoing payments is in millisats,
557 // and "sent" (in sats) includes the fees
558 amountMsat := paymentRequest.MSatoshi
559 if amountMsat == 0 {
560 amountMsat = payment.Sent*1000 - payment.Fees
561 }
562
563 return &lnclient.Transaction{
564 Type: "outgoing",
565 Invoice: payment.Invoice,
566 Preimage: payment.Preimage,
567 PaymentHash: payment.PaymentHash,
568 AmountMsat: amountMsat,
569 FeesPaidMsat: payment.Fees,
570 CreatedAt: time.UnixMilli(payment.CreatedAt).Unix(),
571 Description: paymentRequest.Description,
572 SettledAt: settledAt,
573 ExpiresAt: &expiresAt,
574 DescriptionHash: paymentRequest.DescriptionHash,
575 }, nil
576 }
577
578 func (svc *PhoenixService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
579 return nil
580 }
581
582 func (svc *PhoenixService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
583 return nil, lnclient.ErrUnknownCustomNodeCommand
584 }
585
586 func (svc *PhoenixService) MakeOffer(ctx context.Context, description string) (string, error) {
587 return "", errors.New("not supported")
588 }
589
590 func (svc *PhoenixService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
591 return nil, errors.ErrUnsupported
592 }
593