alby-hub/lnclient/phoenixd/phoenixd.go
Roland Bewick c2ae53dd07 feat: add NWC-321 pay and receive methods with BOLT-11 support
Implements the NWC-321 (BIP-321 Lightning Payments) pay and receive
methods, limited to BOLT-11 instructions:

- pay parses the BIP-321 URI, selects the lightning (BOLT-11)
  instruction and rejects URIs without one
  (UNSUPPORTED_PAYMENT_INSTRUCTION), validates the invoice network
  against the node network (UNSUPPORTED_NETWORK), rejects conflicting
  or invalid amounts, unknown req- parameters and payer_note
  (undeliverable over BOLT-11)
- receive returns a BIP-321 URI containing a single BOLT-11 invoice;
  a variable amount is rejected as zero-amount invoices are not
  supported
- both methods reuse the existing pay_invoice / make_invoice scopes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:24:01 +07:00

604 lines
20 KiB
Go

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/getAlby/hub/nip47/models"
"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{
models.PAY_INVOICE_METHOD,
models.GET_BALANCE_METHOD,
models.GET_BUDGET_METHOD,
models.GET_INFO_METHOD,
models.MAKE_INVOICE_METHOD,
models.LOOKUP_INVOICE_METHOD,
models.LIST_TRANSACTIONS_METHOD,
models.MULTI_PAY_INVOICE_METHOD,
models.PAY_METHOD,
models.RECEIVE_METHOD,
}
}
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
}