Skip to content

feat: add hold invoices support for LND & LDK #1298

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5e00db3
feat: add LND hold invoices support
frnandu May 6, 2025
1f37ce7
fix: add HOLD_INVOICE_ACCEPTED_NOTIFICATION to notifications list ret…
frnandu May 6, 2025
47308d5
fix: revert
frnandu May 6, 2025
6395c41
fix: remove unneeded null checks
frnandu May 6, 2025
764d497
docs: add extra event type to README
rolznz May 6, 2025
e1cefb9
feat: add LND hold invoices support
frnandu May 6, 2025
2d51cfb
fix: add HOLD_INVOICE_ACCEPTED_NOTIFICATION to notifications list ret…
frnandu May 6, 2025
c9ac1cf
fix: revert
frnandu May 6, 2025
7772ab1
fix: remove unneeded null checks
frnandu May 6, 2025
92b8008
docs: add extra event type to README
rolznz May 6, 2025
3ad5a18
Merge remote-tracking branch 'origin/feat/hold-invoices' into feat/ho…
frnandu May 6, 2025
f754de4
fix: remove 0 expiry checking in the make_hold_invoice_controller
frnandu May 7, 2025
5180ede
fix: use JSON logging
frnandu May 7, 2025
16207ec
revert fly.toml
frnandu May 7, 2025
1ea6ef6
fix: duplicated check
frnandu May 7, 2025
92b6433
fix: move publishing nwc_hold_invoice_accepted out of the transaction
frnandu May 7, 2025
5f3cc17
fix: move publishing nwc_hold_invoice_accepted out of the transaction
frnandu May 7, 2025
a3caf37
fix: check the invoice state ACCEPTED before calling the lnClient.Set…
frnandu May 7, 2025
0a59793
fix: check the invoice state ACCEPTED before calling the lnClient.Set…
frnandu May 7, 2025
4fce7fb
fix: check the invoice state ACCEPTED before calling the lnClient.Set…
frnandu May 7, 2025
36fee98
fix: check the invoice state ACCEPTED before calling the lnClient.Set…
frnandu May 7, 2025
0360dc5
fix: cancel hold invoice tests
frnandu May 7, 2025
f56f9a4
fix: make hold invoice tests
frnandu May 7, 2025
589ce25
fix: make hold invoice tests
frnandu May 7, 2025
228901f
fix: settle hold invoice tests
frnandu May 7, 2025
d0b4836
fix: resubscribe to pending hold invoices
frnandu May 7, 2025
bc252ec
fix: missing WatchHoldInvoice
frnandu May 7, 2025
2555f89
feat: add LDK impl
frnandu May 8, 2025
81c785c
fix: cleanup
frnandu May 9, 2025
848fb39
fix: payment_hash
frnandu May 9, 2025
d3041f4
fix: remove unneeded update
frnandu May 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ Internally Alby Hub uses a basic implementation of the pubsub messaging pattern
- `nwc_payment_failed` - failed to make a lightning payment
- `nwc_payment_sent` - successfully made a lightning payment
- `nwc_payment_received` - received a lightning payment
- `nwc_hold_invoice_accepted` - accepted a lightning payment, but it needs to be cancelled or settled
- `nwc_budget_warning` - successfully made a lightning payment, but budget is nearly exceeded
- `nwc_app_created` - a new app connection was created
- `nwc_app_deleted` - a new app connection was deleted
Expand Down
2 changes: 2 additions & 0 deletions constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const (
TRANSACTION_STATE_PENDING = "PENDING"
TRANSACTION_STATE_SETTLED = "SETTLED"
TRANSACTION_STATE_FAILED = "FAILED"
TRANSACTION_STATE_ACCEPTED = "ACCEPTED"
)

const (
Expand Down Expand Up @@ -37,6 +38,7 @@ const (
LOOKUP_INVOICE_SCOPE = "lookup_invoice"
LIST_TRANSACTIONS_SCOPE = "list_transactions"
SIGN_MESSAGE_SCOPE = "sign_message"
HOLD_INVOICES_SCOPE = "hold_invoices" // covers all hold invoice operations (make/settle/cancel)
NOTIFICATIONS_SCOPE = "notifications" // covers all notification types
SUPERUSER_SCOPE = "superuser"
)
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/screens/apps/NewApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
if (requestMethodsSet.has("make_invoice")) {
scopes.push("make_invoice");
}
// Add check for hold invoice methods
if (
requestMethodsSet.has("make_hold_invoice") ||
requestMethodsSet.has("settle_hold_invoice") ||
requestMethodsSet.has("cancel_hold_invoice")
) {
scopes.push("hold_invoices");
}
if (requestMethodsSet.has("lookup_invoice")) {
scopes.push("lookup_invoice");
}
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
BellIcon,
CirclePauseIcon,
CirclePlusIcon,
CrownIcon,
HandCoinsIcon,
Expand All @@ -24,7 +25,10 @@ export type Nip47RequestMethod =
| "list_transactions"
| "sign_message"
| "multi_pay_invoice"
| "multi_pay_keysend";
| "multi_pay_keysend"
| "make_hold_invoice"
| "settle_hold_invoice"
| "cancel_hold_invoice";

export type BudgetRenewalType =
| "daily"
Expand All @@ -39,6 +43,7 @@ export type Scope =
| "get_balance"
| "get_info"
| "make_invoice"
| "hold_invoices"
| "lookup_invoice"
| "list_transactions"
| "sign_message"
Expand All @@ -57,6 +62,7 @@ export const scopeIconMap: ScopeIconMap = {
list_transactions: NotebookTabsIcon,
lookup_invoice: SearchIcon,
make_invoice: CirclePlusIcon,
hold_invoices: CirclePauseIcon,
pay_invoice: HandCoinsIcon,
sign_message: PenLineIcon,
notifications: BellIcon,
Expand Down Expand Up @@ -84,6 +90,7 @@ export const scopeDescriptions: Record<Scope, string> = {
lookup_invoice: "Lookup status of invoices",
make_invoice: "Create invoices",
pay_invoice: "Send payments",
hold_invoices: "Create, settle & cancel hold invoices",
sign_message: "Sign messages",
notifications: "Receive wallet notifications",
superuser: "Create other app connections",
Expand Down
16 changes: 16 additions & 0 deletions lnclient/cashu/cashu.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ func (cs *CashuService) MakeInvoice(ctx context.Context, amount int64, descripti
return cs.cashuMintQuoteToTransaction(mintQuote), nil
}

func (cs *CashuService) MakeHoldInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64, paymentHash string) (transaction *lnclient.Transaction, err error) {
return nil, errors.New("not implemented")
}

func (cs *CashuService) SettleHoldInvoice(ctx context.Context, preimage string) (err error) {
return errors.New("not implemented")
}

func (cs *CashuService) CancelHoldInvoice(ctx context.Context, paymentHash string) (err error) {
return errors.New("not implemented")
}

func (cs *CashuService) LookupInvoice(ctx context.Context, paymentHash string) (transaction *lnclient.Transaction, err error) {
mintQuote := cs.getMintQuoteByPaymentHash(paymentHash)
if mintQuote != nil {
Expand Down Expand Up @@ -555,3 +567,7 @@ func (cs *CashuService) executeCommandResetWallet() (*lnclient.CustomNodeCommand
func (cs *CashuService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
return nil, errors.ErrUnsupported
}

func (cs *CashuService) WatchHoldInvoice(ctx context.Context, paymentHash string) error {
return errors.ErrUnsupported
}
174 changes: 172 additions & 2 deletions lnclient/ldk/ldk.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import (
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/lsp"
"github.com/getAlby/hub/nip47/models"
"github.com/getAlby/hub/nip47/notifications"
"github.com/getAlby/hub/service/keys"
"github.com/getAlby/hub/transactions"
)
Expand Down Expand Up @@ -1587,6 +1589,28 @@ func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
"total_fee_earned_msat": eventType.TotalFeeEarnedMsat,
"outbound_amount_forwarded_msat": eventType.OutboundAmountForwardedMsat,
}).Info("LDK Payment forwarded")

case ldk_node.EventPaymentClaimable:
logger.Logger.WithFields(logrus.Fields{
"claimable_amount_msats": eventType.ClaimableAmountMsat,
"payment_hash": eventType.PaymentHash,
}).Info("LDK Payment Claimable")

payment := ls.node.Payment(eventType.PaymentId)
if payment == nil {
logger.Logger.WithField("payment_id", eventType.PaymentId).Error("could not find LDK payment")
return
}

transaction, err := ls.ldkPaymentToTransaction(payment)
if err != nil {
logger.Logger.WithField("payment_id", eventType.PaymentId).Error("failed to convert LDK payment to transaction")
return
}
ls.eventPublisher.Publish(&events.Event{
Event: "nwc_lnclient_hold_invoice_accepted",
Properties: transaction,
})
}
}

Expand Down Expand Up @@ -1808,11 +1832,30 @@ func (ls *LDKService) UpdateLastWalletSyncRequest() {
}

func (ls *LDKService) 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"}
return []string{
models.PAY_INVOICE_METHOD,
models.PAY_KEYSEND_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.MULTI_PAY_KEYSEND_METHOD,
models.SIGN_MESSAGE_METHOD,
models.MAKE_HOLD_INVOICE_METHOD,
models.SETTLE_HOLD_INVOICE_METHOD,
models.CANCEL_HOLD_INVOICE_METHOD,
}
}

func (ls *LDKService) GetSupportedNIP47NotificationTypes() []string {
return []string{"payment_received", "payment_sent"}
return []string{
notifications.PAYMENT_RECEIVED_NOTIFICATION,
notifications.PAYMENT_SENT_NOTIFICATION,
notifications.HOLD_INVOICE_ACCEPTED_NOTIFICATION,
}
}

func (ls *LDKService) getPaymentFailReason(eventPaymentFailed *ldk_node.EventPaymentFailed) string {
Expand Down Expand Up @@ -1897,6 +1940,133 @@ func (ls *LDKService) ExecuteCustomNodeCommand(ctx context.Context, command *lnc
return nil, nil
}

func (ls *LDKService) MakeHoldInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64, paymentHash string) (*lnclient.Transaction, error) {
if time.Duration(expiry)*time.Second > maxInvoiceExpiry {
return nil, errors.New("expiry is too long")
}

maxReceivable := ls.getMaxReceivable()

if amount > maxReceivable {
ls.eventPublisher.Publish(&events.Event{
Event: "nwc_incoming_liquidity_required",
Properties: map[string]interface{}{
"node_type": config.LDKBackendType,
},
})
}

if expiry == 0 {
expiry = lnclient.DEFAULT_INVOICE_EXPIRY
}

var descriptionType ldk_node.Bolt11InvoiceDescription
descriptionType = ldk_node.Bolt11InvoiceDescriptionDirect{
Description: description,
}
if description == "" && descriptionHash != "" {
descriptionType = ldk_node.Bolt11InvoiceDescriptionHash{
Hash: descriptionHash,
}
}

decodedPaymentHash, err := hex.DecodeString(paymentHash)
if err != nil {
logger.Logger.WithError(err).WithField("paymentHash", paymentHash).Error("Failed to decode payment hash for MakeHoldInvoice")
return nil, fmt.Errorf("failed to decode payment hash: %w", err)
}
if len(decodedPaymentHash) != 32 {
return nil, errors.New("payment hash must be 32 bytes")
}
var paymentHash32 [32]byte
copy(paymentHash32[:], decodedPaymentHash)

ldkPaymentHash := ldk_node.PaymentHash(hex.EncodeToString(paymentHash32[:]))

invoice, err := checkLDKErr(ls.node.Bolt11Payment().ReceiveForHash(uint64(amount),
descriptionType,
uint32(expiry),
ldkPaymentHash))

if err != nil {
logger.Logger.WithError(err).Error("MakeHoldInvoice failed")
return nil, err
}

var expiresAt *int64
paymentRequest, err := decodepay.Decodepay(invoice)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"bolt11": invoice,
}).WithError(err).Error("Failed to decode bolt11 invoice")
return nil, err
}
expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
expiresAt = &expiresAtUnix
description = paymentRequest.Description
descriptionHash = paymentRequest.DescriptionHash

transaction := &lnclient.Transaction{
Type: "incoming",
Invoice: invoice,
PaymentHash: paymentRequest.PaymentHash,
Amount: amount,
CreatedAt: int64(paymentRequest.CreatedAt),
ExpiresAt: expiresAt,
Description: description,
DescriptionHash: descriptionHash,
}

return transaction, nil
}

func (ls *LDKService) CancelHoldInvoice(ctx context.Context, paymentHash string) error {
_, err := hex.DecodeString(paymentHash)
if err != nil {
logger.Logger.WithError(err).WithField("paymentHash", paymentHash).Error("Failed to decode payment hash for CancelHoldInvoice")
return err
}

err = ls.node.Bolt11Payment().FailForHash(paymentHash).AsError()
if err != nil {
logger.Logger.WithError(err).WithField("paymentHash", paymentHash).Error("CancelHoldInvoice failed")
}
return err
}

func (ls *LDKService) SettleHoldInvoice(ctx context.Context, preimage string) error {
decodedPreimage, err := hex.DecodeString(preimage)
if err != nil {
logger.Logger.WithError(err).WithField("preimage", preimage).Error("Failed to decode preimage for SettleHoldInvoice")
return err
}
if len(decodedPreimage) != 32 {
return errors.New("preimage must be 32 bytes")
}

paymentHash256 := sha256.New()
paymentHash256.Write(decodedPreimage)
paymentHashBytes := paymentHash256.Sum(nil)
paymentHash := hex.EncodeToString(paymentHashBytes)

paymentDetails := ls.node.Payment(paymentHash)

if paymentDetails == nil {
logger.Logger.WithField("payment_hash", paymentHash).Error("SettleHoldInvoice: Could not find payment by derived hash")
return errors.New("payment not found for derived hash")
}
if paymentDetails.AmountMsat == nil {
logger.Logger.WithField("payment_hash", paymentHash).Error("SettleHoldInvoice: Payment has no amount_msat")
return errors.New("payment has no amount_msat")
}

err = ls.node.Bolt11Payment().ClaimForHash(paymentHash, *paymentDetails.AmountMsat, preimage).AsError()
if err != nil {
logger.Logger.WithError(err).WithField("preimage", preimage).WithField("derived_payment_hash", paymentHash).Error("SettleHoldInvoice failed")
}
return err
}

func GetVssNodeIdentifier(keys keys.Keys) (string, error) {
key, err := keys.DeriveKey([]uint32{bip32.FirstHardenedChild + 2})

Expand Down
Loading
Loading