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