From cacb6faed7f5b7799ef1c43c171f87e8752ad92e Mon Sep 17 00:00:00 2001 From: elnosh Date: Wed, 22 Jan 2025 13:32:37 -0500 Subject: [PATCH] mint - add method to subscribe to invoice state changes --- mint/invoicesub.go | 61 +++++++++++++++++++++++++++++++++ mint/lightning/fakebackend.go | 23 +++++++++++++ mint/lightning/lightning.go | 7 ++++ mint/lightning/lnd.go | 63 +++++++++++++++++++++++++++++++---- mint/mint.go | 5 ++- 5 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 mint/invoicesub.go diff --git a/mint/invoicesub.go b/mint/invoicesub.go new file mode 100644 index 0000000..061bd34 --- /dev/null +++ b/mint/invoicesub.go @@ -0,0 +1,61 @@ +package mint + +import ( + "time" + + "github.com/elnosh/gonuts/cashu/nuts/nut04" + "github.com/elnosh/gonuts/mint/lightning" +) + +// checkInvoicePaid should be called in a different goroutine to check in the background +// if the invoice for the quoteId gets paid and update it in the db. +func (m *Mint) checkInvoicePaid(quoteId string) { + mintQuote, err := m.db.GetMintQuote(quoteId) + if err != nil { + m.logErrorf("could not get mint quote '%v' from db: %v", quoteId, err) + return + } + + invoiceSub, err := m.lightningClient.SubscribeInvoice(mintQuote.PaymentHash) + if err != nil { + m.logErrorf("could not subscribe to invoice changes for mint quote '%v': %v", quoteId, err) + return + } + + updateChan := make(chan lightning.Invoice) + errChan := make(chan error) + + go func() { + for { + invoice, err := invoiceSub.Recv() + if err != nil { + errChan <- err + return + } + + // only send on channel if invoice gets settled + if invoice.Settled { + updateChan <- invoice + return + } + } + + }() + + timeUntilExpiry := int64(mintQuote.Expiry) - time.Now().Unix() + + select { + case invoice := <-updateChan: + if invoice.Settled { + m.logInfof("received update from invoice sub. Invoice for mint quote '%v' is PAID", mintQuote.Id) + if err := m.db.UpdateMintQuoteState(mintQuote.Id, nut04.Paid); err != nil { + m.logErrorf("could not mark mint quote '%v' as PAID in db: %v", mintQuote.Id, err) + } + } + case err := <-errChan: + m.logErrorf("error reading from invoice subscription: %v", err) + case <-time.After(time.Second * time.Duration(timeUntilExpiry)): + // cancel when quote reaches expiry time + m.logDebugf("canceling invoice subscription for quote '%v'. Reached deadline", mintQuote.Id) + } +} diff --git a/mint/lightning/fakebackend.go b/mint/lightning/fakebackend.go index f24cad8..3ed16e3 100644 --- a/mint/lightning/fakebackend.go +++ b/mint/lightning/fakebackend.go @@ -124,6 +124,29 @@ func (fb *FakeBackend) FeeReserve(amount uint64) uint64 { return 0 } +func (fb *FakeBackend) SubscribeInvoice(paymentHash string) (InvoiceSubscriptionClient, error) { + return &FakeInvoiceSub{ + paymentHash: paymentHash, + fb: fb, + }, nil +} + +type FakeInvoiceSub struct { + paymentHash string + fb *FakeBackend +} + +func (fakeSub *FakeInvoiceSub) Recv() (Invoice, error) { + invoiceIdx := slices.IndexFunc(fakeSub.fb.Invoices, func(i FakeBackendInvoice) bool { + return i.PaymentHash == fakeSub.paymentHash + }) + if invoiceIdx == -1 { + return Invoice{}, errors.New("invoice does not exist") + } + + return fakeSub.fb.Invoices[invoiceIdx].ToInvoice(), nil +} + func (fb *FakeBackend) SetInvoiceStatus(hash string, status State) { invoiceIdx := slices.IndexFunc(fb.Invoices, func(i FakeBackendInvoice) bool { return i.PaymentHash == hash diff --git a/mint/lightning/lightning.go b/mint/lightning/lightning.go index 8ae691b..4207af6 100644 --- a/mint/lightning/lightning.go +++ b/mint/lightning/lightning.go @@ -10,6 +10,7 @@ type Client interface { SendPayment(ctx context.Context, request string, amount uint64) (PaymentStatus, error) OutgoingPaymentStatus(ctx context.Context, hash string) (PaymentStatus, error) FeeReserve(amount uint64) uint64 + SubscribeInvoice(paymentHash string) (InvoiceSubscriptionClient, error) } type Invoice struct { @@ -34,3 +35,9 @@ type PaymentStatus struct { PaymentStatus State PaymentFailureReason string } + +// InvoiceSubscriptionClient subscribes to get updates on the status of an invoice +type InvoiceSubscriptionClient interface { + // This blocks until there is an update + Recv() (Invoice, error) +} diff --git a/mint/lightning/lnd.go b/mint/lightning/lnd.go index 23f745e..e3a93bf 100644 --- a/mint/lightning/lnd.go +++ b/mint/lightning/lnd.go @@ -7,9 +7,9 @@ import ( "fmt" "math" "strings" - "time" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/macaroons" "google.golang.org/grpc" @@ -17,7 +17,8 @@ import ( ) const ( - InvoiceExpiryMins = 10 + // 1 hour + InvoiceExpiryTime = 3600 FeePercent float64 = 0.01 ) @@ -28,8 +29,9 @@ type LndConfig struct { } type LndClient struct { - grpcClient lnrpc.LightningClient - routerClient routerrpc.RouterClient + grpcClient lnrpc.LightningClient + routerClient routerrpc.RouterClient + invoicesClient invoicesrpc.InvoicesClient } func SetupLndClient(config LndConfig) (*LndClient, error) { @@ -45,7 +47,13 @@ func SetupLndClient(config LndConfig) (*LndClient, error) { grpcClient := lnrpc.NewLightningClient(conn) routerClient := routerrpc.NewRouterClient(conn) - return &LndClient{grpcClient: grpcClient, routerClient: routerClient}, nil + invoicesClient := invoicesrpc.NewInvoicesClient(conn) + + return &LndClient{ + grpcClient: grpcClient, + routerClient: routerClient, + invoicesClient: invoicesClient, + }, nil } func (lnd *LndClient) ConnectionStatus() error { @@ -61,7 +69,7 @@ func (lnd *LndClient) ConnectionStatus() error { func (lnd *LndClient) CreateInvoice(amount uint64) (Invoice, error) { invoiceRequest := lnrpc.Invoice{ Value: int64(amount), - Expiry: InvoiceExpiryMins * 60, + Expiry: InvoiceExpiryTime, } addInvoiceResponse, err := lnd.grpcClient.AddInvoice(context.Background(), &invoiceRequest) @@ -74,7 +82,7 @@ func (lnd *LndClient) CreateInvoice(amount uint64) (Invoice, error) { PaymentRequest: addInvoiceResponse.PaymentRequest, PaymentHash: hash, Amount: amount, - Expiry: uint64(time.Now().Add(time.Minute * InvoiceExpiryMins).Unix()), + Expiry: InvoiceExpiryTime, } return invoice, nil } @@ -98,6 +106,7 @@ func (lnd *LndClient) InvoiceStatus(hash string) (Invoice, error) { Preimage: hex.EncodeToString(lookupInvoiceResponse.RPreimage), Settled: invoiceSettled, Amount: uint64(lookupInvoiceResponse.Value), + Expiry: uint64(lookupInvoiceResponse.Expiry), } return invoice, nil @@ -246,3 +255,43 @@ func (lnd *LndClient) FeeReserve(amount uint64) uint64 { fee := math.Ceil(float64(amount) * FeePercent) return uint64(fee) } + +func (lnd *LndClient) SubscribeInvoice(paymentHash string) (InvoiceSubscriptionClient, error) { + hash, err := hex.DecodeString(paymentHash) + if err != nil { + return nil, err + } + invoiceSubRequest := &invoicesrpc.SubscribeSingleInvoiceRequest{ + RHash: hash, + } + lndInvoiceClient, err := lnd.invoicesClient.SubscribeSingleInvoice(context.Background(), invoiceSubRequest) + if err != nil { + return nil, err + } + invoiceSub := &LndInvoiceSub{ + paymentHash: paymentHash, + invoiceSubClient: lndInvoiceClient, + } + return invoiceSub, nil +} + +type LndInvoiceSub struct { + paymentHash string + invoiceSubClient invoicesrpc.Invoices_SubscribeSingleInvoiceClient +} + +func (lndSub *LndInvoiceSub) Recv() (Invoice, error) { + invoiceRes, err := lndSub.invoiceSubClient.Recv() + if err != nil { + return Invoice{}, err + } + invoiceSettled := invoiceRes.State == lnrpc.Invoice_SETTLED + invoice := Invoice{ + PaymentRequest: invoiceRes.PaymentRequest, + PaymentHash: lndSub.paymentHash, + Preimage: hex.EncodeToString(invoiceRes.RPreimage), + Settled: invoiceSettled, + Amount: uint64(invoiceRes.Value), + } + return invoice, nil +} diff --git a/mint/mint.go b/mint/mint.go index 0af06eb..e3d40f4 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -291,7 +291,7 @@ func (m *Mint) RequestMintQuote(mintQuoteRequest nut04.PostMintQuoteBolt11Reques PaymentRequest: invoice.PaymentRequest, PaymentHash: invoice.PaymentHash, State: nut04.Unpaid, - Expiry: invoice.Expiry, + Expiry: uint64(time.Now().Add(time.Second * time.Duration(invoice.Expiry)).Unix()), } err = m.db.SaveMintQuote(mintQuote) @@ -300,6 +300,9 @@ func (m *Mint) RequestMintQuote(mintQuoteRequest nut04.PostMintQuoteBolt11Reques return storage.MintQuote{}, cashu.BuildCashuError(errmsg, cashu.DBErrCode) } + // goroutine to check in the background when invoice gets paid and update db if so + go m.checkInvoicePaid(quoteId) + return mintQuote, nil }