alby-hub/lnclient/breez/breez.go
Roman D d799bdff76
feat: proposal: custom node commands (#1007)
* feat: custom node command execution models and methods

* feat: implement custom node command handlers for HTTP server and Wails

* chore: expose GetNodeCommands API methods in HTTP and Wails

* chore: add sample custom node command implementation for Cashu restore

* feat: add frontend for custom node commands

* chore: consistent naming of custom node command entities

* chore: consistent naming of custom node command entities

* chore: return interface{} as custom node command execution result

* test: add tests for ParseCommandLine

* chore: add extra ParseCommandLine tests for json

* test: add API tests and mocks

* fix: stabilize order of arguments when invoking custom node commands

* test: add test for unsupported custom node command argument

* chore: add the mockery configuration file and move mocks to tests/mocks

* docs: document mockery usage

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
2025-01-30 18:20:17 +07:00

503 lines
15 KiB
Go

//go:build !skip_breez
package breez
import (
"context"
"encoding/hex"
"errors"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/breez/breez-sdk-go/breez_sdk"
decodepay "github.com/nbd-wtf/ln-decodepay"
"github.com/sirupsen/logrus"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
)
type BreezService struct {
listener *BreezListener
svc *breez_sdk.BlockingBreezServices
pubkey string
}
type BreezListener struct {
}
func (listener BreezListener) Log(l breez_sdk.LogEntry) {
logLevel := logrus.InfoLevel
if l.Level == "TRACE" || l.Level == "DEBUG" || strings.Contains(l.Line, "connection to node lost") || strings.Contains(l.Line, "Restore channel") {
logLevel = logrus.DebugLevel
}
logger.Logger.WithField("level", l.Level).Log(logLevel, l.Line)
}
func (BreezListener) OnEvent(e breez_sdk.BreezEvent) {
log.Printf("received event %#v", e)
}
func NewBreezService(mnemonic, apiKey, inviteCode, workDir string) (result lnclient.LNClient, err error) {
if mnemonic == "" || apiKey == "" || inviteCode == "" || workDir == "" {
return nil, errors.New("one or more required breez configuration are missing")
}
// create dir if not exists
newpath := filepath.Join(workDir)
err = os.MkdirAll(newpath, os.ModePerm)
if err != nil {
return nil, err
}
seed, err := breez_sdk.MnemonicToSeed(mnemonic)
if err != nil {
return nil, err
}
nodeConfig := breez_sdk.NodeConfigGreenlight{
Config: breez_sdk.GreenlightNodeConfig{
InviteCode: &inviteCode,
},
}
listener := BreezListener{}
config := breez_sdk.DefaultConfig(breez_sdk.EnvironmentTypeProduction, apiKey, nodeConfig)
config.WorkingDir = workDir
breez_sdk.SetLogStream(listener)
svc, err := breez_sdk.Connect(breez_sdk.ConnectRequest{
Config: config,
Seed: seed,
}, listener)
if err != nil {
return nil, err
}
nodeInfo, err := svc.NodeInfo()
if err != nil {
return nil, err
}
logger.Logger.WithField("info", nodeInfo).Info("Node info")
logger.Logger.WithFields(logrus.Fields{
"ln balance": nodeInfo.ChannelsBalanceMsat,
"balance": nodeInfo.OnchainBalanceMsat,
"max_payable_msat": nodeInfo.MaxPayableMsat,
"max_receivable_msat": nodeInfo.MaxReceivableMsat,
"max_single_payment_amount_msat": nodeInfo.MaxSinglePaymentAmountMsat,
"connected_peers": nodeInfo.ConnectedPeers,
"inbound_liquidity_msats": nodeInfo.InboundLiquidityMsats,
}).Info("node balances and peers")
return &BreezService{
listener: &listener,
svc: svc,
pubkey: nodeInfo.Id,
}, nil
}
func (bs *BreezService) Shutdown() error {
return bs.svc.Disconnect()
}
func (bs *BreezService) SendPaymentSync(ctx context.Context, payReq string, amount *uint64) (*lnclient.PayInvoiceResponse, error) {
if amount != nil {
return nil, errors.New("0-amount invoices not supported")
}
sendPaymentRequest := breez_sdk.SendPaymentRequest{
Bolt11: payReq,
}
resp, err := bs.svc.SendPayment(sendPaymentRequest)
if err != nil {
return nil, err
}
var lnDetails breez_sdk.PaymentDetailsLn
if resp.Payment.Details != nil {
lnDetails, _ = resp.Payment.Details.(breez_sdk.PaymentDetailsLn)
}
return &lnclient.PayInvoiceResponse{
Preimage: lnDetails.Data.PaymentPreimage,
}, nil
}
func (bs *BreezService) SendKeysend(ctx context.Context, amount uint64, destination string, custom_records []lnclient.TLVRecord, preimage string) (*lnclient.PayKeysendResponse, error) {
// TODO: re-enable when passing custom preimage is possible
/*extraTlvs := []breez_sdk.TlvEntry{}
for _, record := range custom_records {
decodedValue, err := hex.DecodeString(record.Value)
if err != nil {
return "", "", 0, err
}
extraTlvs = append(extraTlvs, breez_sdk.TlvEntry{
FieldNumber: record.Type,
Value: decodedValue,
})
}
sendSpontaneousPaymentRequest := breez_sdk.SendSpontaneousPaymentRequest{
NodeId: destination,
AmountMsat: amount,
ExtraTlvs: &extraTlvs,
}
resp, err := bs.svc.SendSpontaneousPayment(sendSpontaneousPaymentRequest)
if err != nil {
return "", "", 0, err
}
var lnDetails breez_sdk.PaymentDetailsLn
if resp.Payment.Details != nil {
lnDetails, _ = resp.Payment.Details.(breez_sdk.PaymentDetailsLn)
}
return lnDetails.Data.PaymentHash, lnDetails.Data.PaymentPreimage, resp.Payment.FeeMsat, nil*/
return nil, errors.New("not supported")
}
func (bs *BreezService) MakeInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64) (transaction *lnclient.Transaction, err error) {
if expiry == 0 {
expiry = lnclient.DEFAULT_INVOICE_EXPIRY
}
expiry32 := uint32(expiry)
receivePaymentRequest := breez_sdk.ReceivePaymentRequest{
// amount provided in msat
AmountMsat: uint64(amount),
Description: description,
Expiry: &expiry32,
}
resp, err := bs.svc.ReceivePayment(receivePaymentRequest)
if err != nil {
return nil, err
}
tx := &lnclient.Transaction{
Type: "incoming",
Invoice: resp.LnInvoice.Bolt11,
Preimage: hex.EncodeToString(resp.LnInvoice.PaymentSecret),
PaymentHash: resp.LnInvoice.PaymentHash,
FeesPaid: 0,
CreatedAt: int64(resp.LnInvoice.Timestamp),
SettledAt: nil,
Metadata: nil,
}
if resp.LnInvoice.Description != nil {
tx.Description = *resp.LnInvoice.Description
}
if resp.LnInvoice.DescriptionHash != nil {
tx.DescriptionHash = *resp.LnInvoice.DescriptionHash
}
if resp.LnInvoice.AmountMsat != nil {
tx.Amount = int64(*resp.LnInvoice.AmountMsat)
}
invExpiry := int64(resp.LnInvoice.Expiry)
tx.ExpiresAt = &invExpiry
return tx, nil
}
func (bs *BreezService) LookupInvoice(ctx context.Context, paymentHash string) (transaction *lnclient.Transaction, err error) {
log.Printf("p: %v", paymentHash)
payment, err := bs.svc.PaymentByHash(paymentHash)
if err != nil {
return nil, err
}
if payment != nil {
log.Printf("p: %v", payment)
transaction, err = breezPaymentToTransaction(payment)
if err != nil {
return nil, err
}
return transaction, nil
} else {
return nil, errors.New("not found")
}
}
func (bs *BreezService) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaid bool, invoiceType string) (transactions []lnclient.Transaction, err error) {
request := breez_sdk.ListPaymentsRequest{}
if limit > 0 {
limit32 := uint32(limit)
request.Limit = &limit32
}
if offset > 0 {
offset32 := uint32(offset)
request.Offset = &offset32
}
if from > 0 {
from64 := int64(from)
request.FromTimestamp = &from64
}
if until > 0 {
until64 := int64(until)
request.ToTimestamp = &until64
}
payments, err := bs.svc.ListPayments(request)
if err != nil {
return nil, err
}
transactions = []lnclient.Transaction{}
for _, payment := range payments {
if payment.PaymentType != breez_sdk.PaymentTypeReceived && payment.PaymentType != breez_sdk.PaymentTypeSent {
// skip other types of payments for now
continue
}
transaction, err := breezPaymentToTransaction(&payment)
if err == nil {
transactions = append(transactions, *transaction)
}
}
return transactions, nil
}
func (bs *BreezService) GetInfo(ctx context.Context) (info *lnclient.NodeInfo, err error) {
return &lnclient.NodeInfo{
Alias: "breez",
Color: "",
Pubkey: "",
Network: "mainnet",
BlockHeight: 0,
BlockHash: "",
}, nil
}
func (bs *BreezService) ListChannels(ctx context.Context) ([]lnclient.Channel, error) {
channels := []lnclient.Channel{}
return channels, nil
}
func (bs *BreezService) GetNodeConnectionInfo(ctx context.Context) (nodeConnectionInfo *lnclient.NodeConnectionInfo, err error) {
return &lnclient.NodeConnectionInfo{}, nil
}
func (bs *BreezService) ConnectPeer(ctx context.Context, connectPeerRequest *lnclient.ConnectPeerRequest) error {
return nil
}
func (bs *BreezService) OpenChannel(ctx context.Context, openChannelRequest *lnclient.OpenChannelRequest) (*lnclient.OpenChannelResponse, error) {
return nil, nil
}
func (bs *BreezService) CloseChannel(ctx context.Context, closeChannelRequest *lnclient.CloseChannelRequest) (*lnclient.CloseChannelResponse, error) {
return nil, nil
}
func breezPaymentToTransaction(payment *breez_sdk.Payment) (*lnclient.Transaction, error) {
var lnDetails breez_sdk.PaymentDetailsLn
if payment.Details != nil {
lnDetails, _ = payment.Details.(breez_sdk.PaymentDetailsLn)
}
var txType string
if payment.PaymentType == breez_sdk.PaymentTypeSent {
txType = "outgoing"
} else {
txType = "incoming"
}
createdAt := payment.PaymentTime
var expiresAt *int64
description := lnDetails.Data.Label
descriptionHash := ""
if lnDetails.Data.Bolt11 != "" {
// TODO: Breez should provide these details so we don't need to manually decode the invoice
paymentRequest, err := decodepay.Decodepay(strings.ToLower(lnDetails.Data.Bolt11))
if err != nil {
log.Printf("Failed to decode bolt11 invoice: %v", payment)
return nil, err
}
createdAt = int64(paymentRequest.CreatedAt)
expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
expiresAt = &expiresAtUnix
description = paymentRequest.Description
descriptionHash = paymentRequest.DescriptionHash
}
tx := &lnclient.Transaction{
Type: txType,
Invoice: lnDetails.Data.Bolt11,
Preimage: lnDetails.Data.PaymentPreimage,
PaymentHash: lnDetails.Data.PaymentHash,
Amount: int64(payment.AmountMsat),
FeesPaid: int64(payment.FeeMsat),
CreatedAt: createdAt,
ExpiresAt: expiresAt,
Metadata: nil,
Description: description,
DescriptionHash: descriptionHash,
}
if payment.Status == breez_sdk.PaymentStatusComplete {
settledAt := payment.PaymentTime
tx.SettledAt = &settledAt
}
if payment.Description != nil {
tx.Description = *payment.Description
}
return tx, nil
}
func (bs *BreezService) GetNewOnchainAddress(ctx context.Context) (string, error) {
// The below code works but is not needed and there's no Breez UI to support it.
// Plus, it creates complexity with the deposit limits.
/*swapInfo, err := bs.svc.ReceiveOnchain(breez_sdk.ReceiveOnchainRequest{})
if err != nil {
logger.Logger.Errorf("Failed to get onchain address: %v", err)
return "", err
}
logger.Logger.Infof("This address has deposit limits! Min: %d Max: %d", swapInfo.MinAllowedDeposit, swapInfo.MaxAllowedDeposit)
return swapInfo.BitcoinAddress, nil*/
return "", nil
}
func (bs *BreezService) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainBalanceResponse, error) {
response, err := bs.svc.NodeInfo()
if err != nil {
logger.Logger.Errorf("Failed to get node info: %v", err)
return nil, err
}
return &lnclient.OnchainBalanceResponse{
Spendable: int64(response.OnchainBalanceMsat) / 1000,
Total: int64(response.OnchainBalanceMsat+response.PendingOnchainBalanceMsat) / 1000,
}, nil
}
func (bs *BreezService) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (txId string, err error) {
if !sendAll {
return "", errors.New("only send all is supported")
}
if toAddress == "" {
return "", errors.New("No address provided")
}
recommendedFees, err := bs.svc.RecommendedFees()
if err != nil {
logger.Logger.Errorf("Failed to get recommended fees info: %v", err)
return "", err
}
satPerVbyte := uint32(recommendedFees.FastestFee)
prepareReq := breez_sdk.PrepareRedeemOnchainFundsRequest{SatPerVbyte: satPerVbyte, ToAddress: toAddress}
prepareRedeemOnchainFundsResponse, err := bs.svc.PrepareRedeemOnchainFunds(prepareReq)
if err != nil {
logger.Logger.Errorf("Failed to prepare onchain address: %v", err)
return "", err
}
logger.Logger.Infof("PrepareRedeemOnchainFunds response: %#v", prepareRedeemOnchainFundsResponse)
redeemReq := breez_sdk.RedeemOnchainFundsRequest{SatPerVbyte: satPerVbyte, ToAddress: toAddress}
redeemOnchainFundsResponse, err := bs.svc.RedeemOnchainFunds(redeemReq)
if err != nil {
logger.Logger.Errorf("Failed to redeem onchain funds: %v", err)
return "", err
}
logger.Logger.Infof("RedeemOnchainFunds response: %#v", redeemOnchainFundsResponse)
return hex.EncodeToString(redeemOnchainFundsResponse.Txid), nil
}
func (bs *BreezService) ResetRouter(key string) error {
return nil
}
func (bs *BreezService) SendPaymentProbes(ctx context.Context, invoice string) error {
return nil
}
func (bs *BreezService) SendSpontaneousPaymentProbes(ctx context.Context, amountMsat uint64, nodeId string) error {
return nil
}
func (bs *BreezService) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) {
return nil, nil
}
func (bs *BreezService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, error) {
return []byte{}, nil
}
func (bs *BreezService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) {
return &lnclient.NodeStatus{
IsReady: true,
}, nil
}
func (bs *BreezService) SignMessage(ctx context.Context, message string) (string, error) {
resp, err := bs.svc.SignMessage(breez_sdk.SignMessageRequest{
Message: message,
})
if err != nil {
return "", err
}
return resp.Signature, nil
}
func (bs *BreezService) GetBalances(ctx context.Context) (*lnclient.BalancesResponse, error) {
info, err := bs.svc.NodeInfo()
if err != nil {
return nil, err
}
return &lnclient.BalancesResponse{
Onchain: lnclient.OnchainBalanceResponse{
Spendable: int64(info.OnchainBalanceMsat) / 1000,
Total: int64(info.OnchainBalanceMsat+info.PendingOnchainBalanceMsat) / 1000,
},
Lightning: lnclient.LightningBalanceResponse{
TotalSpendable: int64(info.MaxPayableMsat),
TotalReceivable: int64(info.MaxReceivableMsat),
NextMaxSpendable: int64(info.MaxSinglePaymentAmountMsat),
NextMaxReceivable: int64(info.MaxReceivableMsat),
NextMaxSpendableMPP: int64(info.MaxSinglePaymentAmountMsat),
NextMaxReceivableMPP: int64(info.MaxReceivableMsat),
},
}, nil
}
func (bs *BreezService) GetStorageDir() (string, error) {
return "", nil
}
func (bs *BreezService) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) {
return nil, nil
}
func (bs *BreezService) UpdateLastWalletSyncRequest() {}
func (bs *BreezService) UpdateChannel(ctx context.Context, updateChannelRequest *lnclient.UpdateChannelRequest) error {
return nil
}
func (bs *BreezService) DisconnectPeer(ctx context.Context, peerId string) error {
return nil
}
func (bs *BreezService) GetSupportedNIP47Methods() []string {
return []string{"pay_invoice" /*"pay_keysend",*/, "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"}
}
func (bs *BreezService) GetSupportedNIP47NotificationTypes() []string {
return []string{}
}
func (bs *BreezService) GetPubkey() string {
return bs.pubkey
}
func (bs *BreezService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return nil
}
func (bs *BreezService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}