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

Merged
merged 52 commits into from
May 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 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
2fd58c8
feat: add support for self payments for hold invoices (#1304)
rolznz May 9, 2025
9c4949a
fix: remove hold invoices scope
frnandu May 9, 2025
75d8b8e
fix: remove hold invoices scope
frnandu May 9, 2025
22b6138
fix: mock hold bolt11 expiry to 10 years
frnandu May 14, 2025
7586bf6
fix: sleep 1 second to give a change of lookupinvoice to read cancelled
frnandu May 14, 2025
616db22
Merge branch 'master' into feat/hold-invoices
frnandu May 15, 2025
4e58ac4
fix: missing timeout param
frnandu May 15, 2025
f875992
feat: add hold invoice settle deadline to transactions (#1324)
rolznz May 22, 2025
d11533d
Merge remote-tracking branch 'origin/master' into feat/hold-invoices
rolznz May 22, 2025
4f26d2f
chore: update mockery, remove unused hold invoice method
rolznz May 22, 2025
bedadc3
chore: remove unnecessary comment
rolznz May 22, 2025
8695f69
chore: remove unused code
rolznz May 22, 2025
6ba5e3c
fix: do not return hold transaction if lookup failed
rolznz May 22, 2025
8aa27d0
chore: remove unused code
rolznz May 22, 2025
6cd6e91
fix: return correct errors from nip47 controllers, remove unused code
rolznz May 22, 2025
eaf3890
fix: failing test
rolznz May 22, 2025
3a5649a
chore: remove unnecessary code
rolznz May 22, 2025
1832158
fix: make error message more general
rolznz May 22, 2025
ea669d2
chore: remove unused code
rolznz May 22, 2025
4bbd148
fix: use correct context in lnd service
rolznz May 22, 2025
16032c9
fix: unstable hold payments test
rolznz May 22, 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
19 changes: 7 additions & 12 deletions .mockery.yaml
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
filename: "{{.InterfaceName}}.go"
dir: tests/mocks
outpkg: mocks

# Fix deprecation warnings:
issue-845-fix: True
resolve-type-alias: False

pkgname: mocks
template: testify
packages:
github.com/getAlby/hub/service:
github.com/getAlby/hub/config:
interfaces:
Service:

Config: {}
github.com/getAlby/hub/lnclient:
interfaces:
LNClient:
github.com/getAlby/hub/config:
LNClient: {}
github.com/getAlby/hub/service:
interfaces:
Config:
Service: {}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ 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_hold_invoice_canceled` - accepted hold payment was explicitly cancelled
- `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
1 change: 1 addition & 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
26 changes: 26 additions & 0 deletions db/migrations/202504231037_hold_invoices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package migrations

import (
_ "embed"

"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)

var _202505091314_hold_invoices = &gormigrate.Migration{
ID: "202505091314_hold_invoices",
Migrate: func(db *gorm.DB) error {

if err := db.Exec(`
ALTER TABLE transactions ADD COLUMN hold BOOLEAN;
ALTER TABLE transactions ADD COLUMN settle_deadline integer;
`).Error; err != nil {
return err
}

return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}
1 change: 1 addition & 0 deletions db/migrations/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func Migrate(gormDB *gorm.DB) error {
_202410141503_add_wallet_pubkey,
_202412212345_fix_types,
_202504231037_add_indexes,
_202505091314_hold_invoices,
})

return m.Migrate()
Expand Down
2 changes: 2 additions & 0 deletions db/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ type Transaction struct {
SelfPayment bool
Boostagram datatypes.JSON
FailureReason string
Hold bool
SettleDeadline *uint32 // block number for accepted hold invoices
}

const (
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/screens/apps/NewApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
if (requestMethodsSet.has("get_balance")) {
scopes.push("get_balance");
}
if (requestMethodsSet.has("make_invoice")) {
if (
requestMethodsSet.has("make_invoice") ||
requestMethodsSet.has("make_hold_invoice") ||
requestMethodsSet.has("settle_hold_invoice") ||
requestMethodsSet.has("cancel_hold_invoice")
) {
scopes.push("make_invoice");
}
if (requestMethodsSet.has("lookup_invoice")) {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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 Down
12 changes: 12 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
181 changes: 179 additions & 2 deletions lnclient/ldk/ldk.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 @@ -1601,6 +1603,35 @@ 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:
if eventType.ClaimDeadline == nil {
logger.Logger.WithField("payment_id", eventType.PaymentId).Error("claimable payment has no claim deadline")
return
}

logger.Logger.WithFields(logrus.Fields{
"claimable_amount_msats": eventType.ClaimableAmountMsat,
"payment_hash": eventType.PaymentHash,
"claim_deadline": *eventType.ClaimDeadline,
}).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
}
transaction.SettleDeadline = eventType.ClaimDeadline
ls.eventPublisher.Publish(&events.Event{
Event: "nwc_lnclient_hold_invoice_accepted",
Properties: transaction,
})
}
}

Expand Down Expand Up @@ -1822,11 +1853,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 @@ -1911,6 +1961,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