package phoenixd import ( "context" b64 "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/getAlby/hub/lnclient" "github.com/getAlby/hub/logger" "github.com/sirupsen/logrus" ) // errNotFound indicates that a payment was not found at the queried endpoint. var errNotFound = errors.New("phoenixd: payment not found") type InvoiceResponse struct { PaymentHash string `json:"paymentHash"` Preimage string `json:"preimage"` ExternalId string `json:"externalId"` Description string `json:"description"` Invoice string `json:"invoice"` IsPaid bool `json:"isPaid"` ReceivedSat int64 `json:"receivedSat"` FeesSat int64 `json:"fees"` CompletedAt int64 `json:"completedAt"` CreatedAt int64 `json:"createdAt"` } type OutgoingPaymentResponse struct { PaymentHash string `json:"paymentHash"` Preimage string `json:"preimage"` Invoice string `json:"invoice"` IsPaid bool `json:"isPaid"` Sent int64 `json:"sent"` Fees int64 `json:"fees"` CompletedAt int64 `json:"completedAt"` CreatedAt int64 `json:"createdAt"` } type PayResponse struct { PaymentHash string `json:"paymentHash"` PaymentId string `json:"paymentId"` PaymentPreimage string `json:"paymentPreimage"` RoutingFeeSat int64 `json:"routingFeeSat"` } type MakeInvoiceResponse struct { AmountSat int64 `json:"amountSat"` PaymentHash string `json:"paymentHash"` Serialized string `json:"serialized"` } type InfoResponse struct { NodeId string `json:"nodeId"` } type BalanceResponse struct { BalanceSat int64 `json:"balanceSat"` FeeCreditSat int64 `json:"feeCreditSat"` } type PhoenixService struct { Address string Authorization string pubkey string nodeInfo *lnclient.NodeInfo ctx context.Context } func NewPhoenixService(ctx context.Context, address string, authorization string) (result lnclient.LNClient, err error) { authorizationBase64 := b64.StdEncoding.EncodeToString([]byte(":" + authorization)) // some environments (e.g. in a cloud environment like render.com) can only get the address and the port but not the protocol // in those cases we default to http for local requests if !strings.HasPrefix(address, "http") { address = "http://" + address } phoenixService := &PhoenixService{ctx: ctx, Address: address, Authorization: authorizationBase64} info, err := fetchNodeInfo(ctx, phoenixService) if err != nil { return nil, err } phoenixService.nodeInfo = info phoenixService.pubkey = info.Pubkey return phoenixService, nil } func (svc *PhoenixService) GetBalances(ctx context.Context, includeInactiveChannels bool) (*lnclient.BalancesResponse, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/getbalance", nil) if err != nil { return nil, err } req.Header.Add("Authorization", "Basic "+svc.Authorization) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { logger.Logger.WithFields(logrus.Fields{ "body": string(body), "status_code": resp.StatusCode, }).Error("phoenixd get balance endpoint returned non-success code") return nil, fmt.Errorf("phoenixd get balance endpoint returned non-success code: %d %s", resp.StatusCode, string(body)) } var balanceRes BalanceResponse if err := json.Unmarshal(body, &balanceRes); err != nil { return nil, err } balance := balanceRes.BalanceSat * 1000 return &lnclient.BalancesResponse{ Onchain: lnclient.OnchainBalanceResponse{ PendingBalancesDetails: []lnclient.PendingBalanceDetails{}, PendingSweepBalancesDetails: []lnclient.PendingBalanceDetails{}}, Lightning: lnclient.LightningBalanceResponse{ TotalSpendableMsat: balance, NextMaxSpendableMsat: balance, NextMaxSpendableMPPMsat: balance, }, }, nil } func (svc *PhoenixService) GetInfo(ctx context.Context) (info *lnclient.NodeInfo, err error) { return svc.nodeInfo, nil } func fetchNodeInfo(ctx context.Context, svc *PhoenixService) (info *lnclient.NodeInfo, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/getinfo", nil) if err != nil { return nil, err } req.Header.Add("Authorization", "Basic "+svc.Authorization) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { logger.Logger.WithFields(logrus.Fields{ "body": string(body), "status_code": resp.StatusCode, }).Error("phoenixd get info endpoint returned non-success code") return nil, fmt.Errorf("phoenixd get info endpoint returned non-success code: %d %s", resp.StatusCode, string(body)) } var infoRes InfoResponse if err := json.Unmarshal(body, &infoRes); err != nil { return nil, err } return &lnclient.NodeInfo{ Alias: "Phoenix", Color: "", Pubkey: infoRes.NodeId, Network: "bitcoin", BlockHeight: 0, BlockHash: "", }, nil } func (svc *PhoenixService) ListChannels(ctx context.Context) ([]lnclient.Channel, error) { channels := []lnclient.Channel{} return channels, nil } func (svc *PhoenixService) MakeInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, throughNodePubkey *string) (transaction *lnclient.Transaction, err error) { // TODO: support expiry if expiry == 0 { expiry = lnclient.DEFAULT_INVOICE_EXPIRY } form := url.Values{} amountSat := strconv.FormatInt(amountMsat/1000, 10) form.Add("amountSat", amountSat) if description != "" { form.Add("description", description) } else if descriptionHash != "" { form.Add("descriptionHash", descriptionHash) } else { form.Add("description", "invoice") } 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 form.Add("externalId", today) // for some resone phoenixd requires an external id to query a list of invoices. thus we set this to nwc logger.Logger.WithFields(logrus.Fields{ "externalId": today, "amountSat": amountSat, }).Infof("Requesting phoenix invoice") req, err := http.NewRequestWithContext(ctx, http.MethodPost, svc.Address+"/createinvoice", strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Add("Authorization", "Basic "+svc.Authorization) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { logger.Logger.WithFields(logrus.Fields{ "body": string(body), "status_code": resp.StatusCode, }).Error("phoenixd create invoice endpoint returned non-success code") return nil, fmt.Errorf("phoenixd create invoice endpoint returned non-success code: %d %s", resp.StatusCode, string(body)) } var invoiceRes MakeInvoiceResponse if err := json.Unmarshal(body, &invoiceRes); err != nil { return nil, err } tx, err := svc.LookupInvoice(ctx, invoiceRes.PaymentHash) if err != nil { logger.Logger.WithError(err).Error("failed to lookup newly created invoice") return nil, err } return tx, nil } func (svc *PhoenixService) MakeHoldInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, paymentHash string, minCltvExpiryDelta *uint64) (transaction *lnclient.Transaction, err error) { return nil, errors.New("not implemented") } func (svc *PhoenixService) SettleHoldInvoice(ctx context.Context, preimage string) (err error) { return errors.New("not implemented") } func (svc *PhoenixService) CancelHoldInvoice(ctx context.Context, paymentHash string) (err error) { return errors.New("not implemented") } // LookupInvoice looks up a transaction by payment hash. It first checks // incoming payments, then falls back to outgoing payments if the incoming // payment is not found (HTTP 404). func (svc *PhoenixService) LookupInvoice(ctx context.Context, paymentHash string) (transaction *lnclient.Transaction, err error) { transaction, err = svc.lookupIncomingPayment(ctx, paymentHash) if err == nil { return transaction, nil } // Only fall back to outgoing lookup when incoming returns not-found. if !errors.Is(err, errNotFound) { return nil, err } return svc.lookupOutgoingPayment(ctx, paymentHash) } // lookupIncomingPayment fetches an incoming payment from Phoenixd by payment hash. func (svc *PhoenixService) lookupIncomingPayment(ctx context.Context, paymentHash string) (*lnclient.Transaction, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/payments/incoming/"+paymentHash, nil) if err != nil { return nil, fmt.Errorf("create phoenixd incoming payment request: %w", err) } req.Header.Add("Authorization", "Basic "+svc.Authorization) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("call phoenixd incoming payment endpoint: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read phoenixd incoming payment response: %w", err) } if resp.StatusCode == http.StatusNotFound { return nil, errNotFound } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("phoenixd incoming payments endpoint returned non-success code: %d %s", resp.StatusCode, string(body)) } var invoiceRes InvoiceResponse if err := json.Unmarshal(body, &invoiceRes); err != nil { return nil, fmt.Errorf("decode phoenixd incoming payment response: %w", err) } return phoenixInvoiceToTransaction(&invoiceRes) } // lookupOutgoingPayment fetches an outgoing payment from Phoenixd using the // /payments/outgoingbyhash/{paymentHash} endpoint. func (svc *PhoenixService) lookupOutgoingPayment(ctx context.Context, paymentHash string) (*lnclient.Transaction, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/payments/outgoingbyhash/"+paymentHash, nil) if err != nil { return nil, fmt.Errorf("create phoenixd outgoing payment request: %w", err) } req.Header.Add("Authorization", "Basic "+svc.Authorization) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("call phoenixd outgoing payment endpoint: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read phoenixd outgoing payment response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("phoenixd outgoing payment lookup returned non-success code: %d %s", resp.StatusCode, string(body)) } var paymentRes OutgoingPaymentResponse if err := json.Unmarshal(body, &paymentRes); err != nil { return nil, fmt.Errorf("decode phoenixd outgoing payment response: %w", err) } return outgoingPaymentToTransaction(&paymentRes) } func (svc *PhoenixService) SendPaymentSync(payReq string, amountMsat *uint64) (*lnclient.PayInvoiceResponse, error) { // TODO: support 0-amount invoices if amountMsat != nil { return nil, errors.New("0-amount invoices not supported") } form := url.Values{} form.Add("invoice", payReq) req, err := http.NewRequestWithContext(svc.ctx, http.MethodPost, svc.Address+"/payinvoice", strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Add("Authorization", "Basic "+svc.Authorization) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{Timeout: 90 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("phoenixd /payinvoice returned non-success status: %d %s", resp.StatusCode, string(body)) } var payRes PayResponse if err := json.Unmarshal(body, &payRes); err != nil { return nil, err } return &lnclient.PayInvoiceResponse{ Preimage: payRes.PaymentPreimage, FeeMsat: uint64(payRes.RoutingFeeSat) * 1000, }, nil } func (svc *PhoenixService) SendKeysend(amountMsat uint64, destination string, custom_records []lnclient.TLVRecord, preimage string) (*lnclient.PayKeysendResponse, error) { return nil, errors.New("not implemented") } func (svc *PhoenixService) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (txId string, err error) { return "", errors.New("not implemented") } func (svc *PhoenixService) ResetRouter(key string) error { return errors.New("not implemented") } func (svc *PhoenixService) Shutdown() error { // No specific shutdown actions needed for Phoenixd client via HTTP return nil } func (svc *PhoenixService) GetNodeConnectionInfo(ctx context.Context) (nodeConnectionInfo *lnclient.NodeConnectionInfo, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, svc.Address+"/getinfo", nil) if err != nil { return nil, err } req.Header.Add("Authorization", "Basic "+svc.Authorization) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("phoenixd /getinfo returned non-success status: %d %s", resp.StatusCode, string(body)) } var infoRes InfoResponse if err := json.Unmarshal(body, &infoRes); err != nil { return nil, err } return &lnclient.NodeConnectionInfo{ Pubkey: infoRes.NodeId, }, nil } func (svc *PhoenixService) ConnectPeer(ctx context.Context, connectPeerRequest *lnclient.ConnectPeerRequest) error { return errors.New("not implemented") } func (svc *PhoenixService) OpenChannel(ctx context.Context, openChannelRequest *lnclient.OpenChannelRequest) (*lnclient.OpenChannelResponse, error) { return nil, errors.New("not implemented") } func (svc *PhoenixService) CloseChannel(ctx context.Context, closeChannelRequest *lnclient.CloseChannelRequest) error { return errors.New("not implemented") } func (svc *PhoenixService) GetNewOnchainAddress(ctx context.Context) (string, error) { return "", errors.New("not implemented") } func (svc *PhoenixService) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainBalanceResponse, error) { return nil, errors.New("not implemented") } func (svc *PhoenixService) SignMessage(ctx context.Context, message string) (string, error) { return "", errors.New("not implemented") } func (svc *PhoenixService) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) { return nil, nil } func (svc *PhoenixService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, error) { return []byte{}, nil } func (svc *PhoenixService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) { _, err = fetchNodeInfo(ctx, svc) if err != nil { return nil, err } return &lnclient.NodeStatus{ IsReady: true, }, nil } func (svc *PhoenixService) GetStorageDir() (string, error) { return "", nil } func (svc *PhoenixService) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) { return nil, nil } func (svc *PhoenixService) UpdateLastWalletSyncRequest() {} func (svc *PhoenixService) DisconnectPeer(ctx context.Context, peerId string) error { return errors.New("not implemented") } func (svc *PhoenixService) UpdateChannel(ctx context.Context, updateChannelRequest *lnclient.UpdateChannelRequest) error { return errors.New("not implemented") } func (svc *PhoenixService) GetSupportedNIP47Methods() []string { return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"} } func (svc *PhoenixService) GetSupportedNIP47NotificationTypes() []string { return []string{} } func (svc *PhoenixService) GetPubkey() string { return svc.pubkey } func phoenixInvoiceToTransaction(invoiceRes *InvoiceResponse) (*lnclient.Transaction, error) { var settledAt *int64 if invoiceRes.CompletedAt != 0 { settledAtUnix := time.UnixMilli(invoiceRes.CompletedAt).Unix() settledAt = &settledAtUnix } paymentRequest, err := decodepay.Decodepay(invoiceRes.Invoice) if err != nil { logger.Logger.WithFields(logrus.Fields{ "bolt11": invoiceRes.Invoice, }).Errorf("Failed to decode bolt11 invoice: %v", err) return nil, err } expiresAt := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix() return &lnclient.Transaction{ Type: "incoming", Invoice: invoiceRes.Invoice, Preimage: invoiceRes.Preimage, PaymentHash: invoiceRes.PaymentHash, AmountMsat: paymentRequest.MSatoshi, FeesPaidMsat: invoiceRes.FeesSat * 1000, CreatedAt: time.UnixMilli(invoiceRes.CreatedAt).Unix(), Description: invoiceRes.Description, SettledAt: settledAt, ExpiresAt: &expiresAt, DescriptionHash: paymentRequest.DescriptionHash, }, nil } // outgoingPaymentToTransaction converts a Phoenixd OutgoingPaymentResponse // to an lnclient.Transaction. func outgoingPaymentToTransaction(payment *OutgoingPaymentResponse) (*lnclient.Transaction, error) { var settledAt *int64 if payment.CompletedAt != 0 { settledAtUnix := time.UnixMilli(payment.CompletedAt).Unix() settledAt = &settledAtUnix } paymentRequest, err := decodepay.Decodepay(payment.Invoice) if err != nil { logger.Logger.WithFields(logrus.Fields{ "bolt11": payment.Invoice, }).Errorf("Failed to decode bolt11 invoice: %v", err) return nil, fmt.Errorf("decode phoenixd outgoing payment bolt11: %w", err) } expiresAt := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix() // unlike incoming payments, "fees" on outgoing payments is in millisats, // and "sent" (in sats) includes the fees amountMsat := paymentRequest.MSatoshi if amountMsat == 0 { amountMsat = payment.Sent*1000 - payment.Fees } return &lnclient.Transaction{ Type: "outgoing", Invoice: payment.Invoice, Preimage: payment.Preimage, PaymentHash: payment.PaymentHash, AmountMsat: amountMsat, FeesPaidMsat: payment.Fees, CreatedAt: time.UnixMilli(payment.CreatedAt).Unix(), Description: paymentRequest.Description, SettledAt: settledAt, ExpiresAt: &expiresAt, DescriptionHash: paymentRequest.DescriptionHash, }, nil } func (svc *PhoenixService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef { return nil } func (svc *PhoenixService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) { return nil, lnclient.ErrUnknownCustomNodeCommand } func (svc *PhoenixService) MakeOffer(ctx context.Context, description string) (string, error) { return "", errors.New("not supported") } func (svc *PhoenixService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) { return nil, errors.ErrUnsupported }