Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
5e00db3
feat: add LND hold invoices support
May 6, 2025
1f37ce7
fix: add HOLD_INVOICE_ACCEPTED_NOTIFICATION to notifications list ret…
May 6, 2025
47308d5
fix: revert
May 6, 2025
6395c41
fix: remove unneeded null checks
May 6, 2025
764d497
docs: add extra event type to README
rolznz May 6, 2025
e1cefb9
feat: add LND hold invoices support
May 6, 2025
2d51cfb
fix: add HOLD_INVOICE_ACCEPTED_NOTIFICATION to notifications list ret…
May 6, 2025
c9ac1cf
fix: revert
May 6, 2025
7772ab1
fix: remove unneeded null checks
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…
May 6, 2025
f754de4
fix: remove 0 expiry checking in the make_hold_invoice_controller
May 7, 2025
5180ede
fix: use JSON logging
May 7, 2025
16207ec
revert fly.toml
May 7, 2025
1ea6ef6
fix: duplicated check
May 7, 2025
92b6433
fix: move publishing nwc_hold_invoice_accepted out of the transaction
May 7, 2025
5f3cc17
fix: move publishing nwc_hold_invoice_accepted out of the transaction
May 7, 2025
a3caf37
fix: check the invoice state ACCEPTED before calling the lnClient.Set…
May 7, 2025
0a59793
fix: check the invoice state ACCEPTED before calling the lnClient.Set…
May 7, 2025
4fce7fb
fix: check the invoice state ACCEPTED before calling the lnClient.Set…
May 7, 2025
36fee98
fix: check the invoice state ACCEPTED before calling the lnClient.Set…
May 7, 2025
0360dc5
fix: cancel hold invoice tests
May 7, 2025
f56f9a4
fix: make hold invoice tests
May 7, 2025
589ce25
fix: make hold invoice tests
May 7, 2025
228901f
fix: settle hold invoice tests
May 7, 2025
d0b4836
fix: resubscribe to pending hold invoices
May 7, 2025
bc252ec
fix: missing WatchHoldInvoice
May 7, 2025
2555f89
feat: add LDK impl
May 8, 2025
81c785c
fix: cleanup
May 9, 2025
848fb39
fix: payment_hash
May 9, 2025
d3041f4
fix: remove unneeded update
May 9, 2025
2fd58c8
feat: add support for self payments for hold invoices (#1304)
rolznz May 9, 2025
9c4949a
fix: remove hold invoices scope
May 9, 2025
75d8b8e
fix: remove hold invoices scope
May 9, 2025
22b6138
fix: mock hold bolt11 expiry to 10 years
May 14, 2025
7586bf6
fix: sleep 1 second to give a change of lookupinvoice to read cancelled
May 14, 2025
616db22
Merge branch 'master' into feat/hold-invoices
May 15, 2025
4e58ac4
fix: missing timeout param
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
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"
Comment thread
frnandu marked this conversation as resolved.
)

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)
Comment thread
frnandu marked this conversation as resolved.
Outdated
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
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
12 changes: 12 additions & 0 deletions lnclient/ldk/ldk.go
Original file line number Diff line number Diff line change
Expand Up @@ -1866,6 +1866,18 @@ 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) {
return nil, errors.New("make_hold_invoice is not yet implemented for LDK")
}

func (ls *LDKService) CancelHoldInvoice(ctx context.Context, paymentHash string) error {
return errors.New("cancel_hold_invoice is not yet implemented for LDK")
}

func (ls *LDKService) SettleHoldInvoice(ctx context.Context, preimage string) error {
return errors.New("settle_hold_invoice is not yet implemented for LDK")
}

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

Expand Down
224 changes: 221 additions & 3 deletions lnclient/lnd/lnd.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,22 @@ import (
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/lnclient/lnd/wrapper"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/getAlby/hub/transactions"

"github.com/sirupsen/logrus"
// "gorm.io/gorm"

"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
)

type LNDService struct {
client *wrapper.LNDWrapper
nodeInfo *lnclient.NodeInfo
cancel context.CancelFunc
globalCtx context.Context
eventPublisher events.EventPublisher
}

Expand Down Expand Up @@ -72,12 +75,13 @@ func NewLNDService(ctx context.Context, eventPublisher events.EventPublisher, ln
return nil, err
}

lndCtx, cancel := context.WithCancel(ctx)
lndCtx, svcCancel := context.WithCancel(ctx)

lndService := &LNDService{
client: lndClient,
nodeInfo: nodeInfo,
cancel: cancel,
cancel: svcCancel,
globalCtx: ctx,
Comment thread
rolznz marked this conversation as resolved.
Outdated
eventPublisher: eventPublisher,
}

Expand Down Expand Up @@ -278,6 +282,79 @@ func (svc *LNDService) subscribeChannelEvents(ctx context.Context) {
}
}

func (svc *LNDService) subscribeSingleInvoice(paymentHashBytes []byte) {
Comment thread
frnandu marked this conversation as resolved.
// Use the global context for the lifetime of this subscription, but create a cancellable one for this specific task
// This allows the goroutine to be potentially cancelled externally if needed, though it primarily exits on invoice state change.
// We use a background context derived from the global one to avoid cancelling if the original request context finishes.
ctx, cancel := context.WithCancel(svc.globalCtx)
defer cancel() // Ensure cancellation happens on exit

paymentHashHex := hex.EncodeToString(paymentHashBytes)
log := logger.Logger.WithField("paymentHash", paymentHashHex)

log.Info("Starting subscribeSingleInvoice goroutine")

subReq := &invoicesrpc.SubscribeSingleInvoiceRequest{
RHash: paymentHashBytes,
}

invoiceStream, err := svc.client.SubscribeSingleInvoice(ctx, subReq)
if err != nil {
log.WithError(err).Error("SubscribeSingleInvoice call failed")
// Goroutine will exit
return
}

log.Info("Successfully subscribed to single invoice stream")

defer func() {
log.Info("Exiting subscribeSingleInvoice goroutine")
if r := recover(); r != nil {
log.WithField("panic", r).Errorf("PANIC recovered in single invoice stream processing")
}
}()

for {
invoice, err := invoiceStream.Recv()

if err != nil {
log.WithError(err).Error("Failed to receive single invoice update from stream")
return
}
if ctx.Err() != nil {
log.Info("Context cancelled, exiting single invoice subscription loop")
return
}

log.WithFields(logrus.Fields{
"rawState": invoice.State.String(),
"addIndex": invoice.AddIndex,
"settleIndex": invoice.SettleIndex,
"amtPaidMsat": invoice.AmtPaidMsat,
}).Info("Raw update received from single invoice stream")

switch invoice.State {
case lnrpc.Invoice_ACCEPTED:
log.Info("Hold invoice accepted, publishing internal event")
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_lnclient_hold_invoice_accepted",
Properties: lndInvoiceToTransaction(invoice),
})
case lnrpc.Invoice_CANCELED:
Comment thread
rolznz marked this conversation as resolved.
log.Info("Hold invoice canceled, publishing internal event")
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_lnclient_hold_invoice_canceled",
Properties: lndInvoiceToTransaction(invoice),
})
return // Invoice reached final state, exit goroutine
case lnrpc.Invoice_SETTLED:
return // Invoice reached final state, exit goroutine
case lnrpc.Invoice_OPEN:
// Continue loop
}
}
}

func (svc *LNDService) Shutdown() error {
logger.Logger.Info("cancelling LND context")
svc.cancel()
Expand Down Expand Up @@ -506,6 +583,134 @@ func (svc *LNDService) MakeInvoice(ctx context.Context, amount int64, descriptio
return transaction, nil
}

func (svc *LNDService) MakeHoldInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64, paymentHash string) (transaction *lnclient.Transaction, err error) { // Added missing function name
var descriptionHashBytes []byte
var paymentHashBytes []byte

if descriptionHash != "" {
descriptionHashBytes, err = hex.DecodeString(descriptionHash)
if err != nil || len(descriptionHashBytes) != 32 {
if err == nil {
err = errors.New("description hash must be 32 bytes hex")
}
logger.Logger.WithFields(logrus.Fields{
"descriptionHash": descriptionHash,
}).WithError(err).Error("Invalid description hash")
return nil, err
}
}

paymentHashBytes, err = hex.DecodeString(paymentHash)
if err != nil || len(paymentHashBytes) != 32 {
if err == nil {
err = errors.New("payment hash must be 32 bytes hex")
}
logger.Logger.WithFields(logrus.Fields{
"paymentHash": paymentHash,
}).WithError(err).Error("Invalid payment hash")
return nil, err
}

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

channels, err := svc.ListChannels(ctx)
if err != nil {
return nil, err
}

hasPublicChannels := false
for _, channel := range channels {
if channel.Active && channel.Public {
hasPublicChannels = true
}
}

addInvoiceRequest := &invoicesrpc.AddHoldInvoiceRequest{
ValueMsat: amount,
Memo: description,
DescriptionHash: descriptionHashBytes,
Expiry: expiry,
Private: !hasPublicChannels, // use private channel hints in the invoice
Hash: paymentHashBytes,
}

resp, err := svc.client.AddHoldInvoice(ctx, addInvoiceRequest)
if err != nil {
logger.Logger.WithError(err).Error("Failed to create hold invoice")
return nil, err
}

// Start subscribing to updates for this specific hold invoice in a separate goroutine
go svc.subscribeSingleInvoice(paymentHashBytes)
logger.Logger.WithField("paymentHash", paymentHash).Info("Launched single invoice subscription goroutine")

inv, err := svc.client.LookupInvoice(ctx, &lnrpc.PaymentHash{RHash: paymentHashBytes})
if err != nil {
logger.Logger.WithField("paymentHash", paymentHash).WithError(err).Error("Failed to lookup hold invoice after creation")
return &lnclient.Transaction{
Comment thread
rolznz marked this conversation as resolved.
Outdated
Type: "incoming",
Invoice: resp.PaymentRequest,
PaymentHash: paymentHash,
Amount: amount,
CreatedAt: time.Now().Unix(),
ExpiresAt: &expiry,
}, nil
}

transaction = lndInvoiceToTransaction(inv)
return transaction, nil
}

func (svc *LNDService) SettleHoldInvoice(ctx context.Context, preimage string) (err error) {
preimageBytes, err := hex.DecodeString(preimage)
if err != nil || len(preimageBytes) != 32 {
if err == nil {
err = errors.New("preimage must be 32 bytes hex")
}
logger.Logger.WithFields(logrus.Fields{
"preimage": preimage,
}).WithError(err).Error("Invalid preimage")
return err
}

_, err = svc.client.SettleInvoice(ctx, &invoicesrpc.SettleInvoiceMsg{
Preimage: preimageBytes,
})
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"preimage": preimage,
}).WithError(err).Error("Failed to settle hold invoice")
return err
}
return nil
}

func (svc *LNDService) CancelHoldInvoice(ctx context.Context, paymentHash string) (err error) {
paymentHashBytes, err := hex.DecodeString(paymentHash)
if err != nil || len(paymentHashBytes) != 32 {
if err == nil {
err = errors.New("payment hash must be 32 bytes hex")
}
logger.Logger.WithFields(logrus.Fields{
"paymentHash": paymentHash,
}).WithError(err).Error("Invalid payment hash")
return err
}

_, err = svc.client.CancelInvoice(ctx, &invoicesrpc.CancelInvoiceMsg{
PaymentHash: paymentHashBytes,
})
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"paymentHash": paymentHash,
}).WithError(err).Error("Failed to cancel hold invoice")
return err
}
return nil
}

func (svc *LNDService) LookupInvoice(ctx context.Context, paymentHash string) (transaction *lnclient.Transaction, err error) {
paymentHashBytes, err := hex.DecodeString(paymentHash)
if err != nil || len(paymentHashBytes) != 32 {
Expand Down Expand Up @@ -1196,7 +1401,20 @@ func (svc *LNDService) UpdateLastWalletSyncRequest() {}

func (svc *LNDService) 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",
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,
}
}

Expand Down
Loading
Loading