diff --git a/mint/lightning/lightning.go b/mint/lightning/lightning.go index 896d88b..e09523e 100644 --- a/mint/lightning/lightning.go +++ b/mint/lightning/lightning.go @@ -11,6 +11,7 @@ type Client interface { type Invoice struct { PaymentRequest string PaymentHash string + Preimage string Settled bool Amount uint64 Expiry uint64 diff --git a/mint/lightning/lnd.go b/mint/lightning/lnd.go index 1771601..3d55659 100644 --- a/mint/lightning/lnd.go +++ b/mint/lightning/lnd.go @@ -81,6 +81,7 @@ func (lnd *LndClient) InvoiceStatus(hash string) (Invoice, error) { invoice := Invoice{ PaymentRequest: lookupInvoiceResponse.PaymentRequest, PaymentHash: hash, + Preimage: hex.EncodeToString(lookupInvoiceResponse.RPreimage), Settled: invoiceSettled, Amount: uint64(lookupInvoiceResponse.Value), } diff --git a/mint/mint.go b/mint/mint.go index ff2d494..852639f 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -252,19 +252,21 @@ func (m *Mint) GetMintQuoteState(method, quoteId string) (storage.MintQuote, err return storage.MintQuote{}, cashu.QuoteNotExistErr } - // check if the invoice has been paid - status, err := m.lightningClient.InvoiceStatus(mintQuote.PaymentHash) - if err != nil { - msg := fmt.Sprintf("error getting status of payment request: %v", err) - return storage.MintQuote{}, cashu.BuildCashuError(msg, cashu.LightningBackendErrCode) - } - - if status.Settled && mintQuote.State == nut04.Unpaid { - mintQuote.State = nut04.Paid - err := m.db.UpdateMintQuoteState(mintQuote.Id, mintQuote.State) + // if previously unpaid, check if invoice has been paid + if mintQuote.State == nut04.Unpaid { + status, err := m.lightningClient.InvoiceStatus(mintQuote.PaymentHash) if err != nil { - msg := fmt.Sprintf("error getting quote state: %v", err) - return storage.MintQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + msg := fmt.Sprintf("error getting invoice status: %v", err) + return storage.MintQuote{}, cashu.BuildCashuError(msg, cashu.LightningBackendErrCode) + } + + if status.Settled { + mintQuote.State = nut04.Paid + err := m.db.UpdateMintQuoteState(mintQuote.Id, mintQuote.State) + if err != nil { + msg := fmt.Sprintf("error getting quote state: %v", err) + return storage.MintQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } } } @@ -284,12 +286,21 @@ func (m *Mint) MintTokens(method, id string, blindedMessages cashu.BlindedMessag } var blindedSignatures cashu.BlindedSignatures - status, err := m.lightningClient.InvoiceStatus(mintQuote.PaymentHash) - if err != nil { - msg := fmt.Sprintf("error getting status of payment request: %v", err) - return nil, cashu.BuildCashuError(msg, cashu.LightningBackendErrCode) + invoicePaid := false + if mintQuote.State == nut04.Unpaid { + invoiceStatus, err := m.lightningClient.InvoiceStatus(mintQuote.PaymentHash) + if err != nil { + msg := fmt.Sprintf("error getting invoice status: %v", err) + return nil, cashu.BuildCashuError(msg, cashu.LightningBackendErrCode) + } + if invoiceStatus.Settled { + invoicePaid = true + } + } else { + invoicePaid = true } - if status.Settled { + + if invoicePaid { if mintQuote.State == nut04.Issued { return nil, cashu.MintQuoteAlreadyIssued } @@ -418,7 +429,7 @@ func (m *Mint) Swap(proofs cashu.Proofs, blindedMessages cashu.BlindedMessages) return blindedSignatures, nil } -// MeltRequest will process a request to melt tokens and return a MeltQuote. +// RequestMeltQuote will process a request to melt tokens and return a MeltQuote. // A melt is requested by a wallet to request the mint to pay an invoice. func (m *Mint) RequestMeltQuote(method, request, unit string) (storage.MeltQuote, error) { if method != BOLT11_METHOD { @@ -450,10 +461,8 @@ func (m *Mint) RequestMeltQuote(method, request, unit string) (storage.MeltQuote if err != nil { return storage.MeltQuote{}, cashu.StandardErr } - // Fee reserve that is required by the mint fee := m.lightningClient.FeeReserve(satAmount) - expiry := uint64(time.Now().Add(time.Minute * QuoteExpiryMins).Unix()) meltQuote := storage.MeltQuote{ Id: quoteId, @@ -462,8 +471,19 @@ func (m *Mint) RequestMeltQuote(method, request, unit string) (storage.MeltQuote Amount: satAmount, FeeReserve: fee, State: nut05.Unpaid, - Expiry: expiry, + Expiry: uint64(time.Now().Add(time.Minute * QuoteExpiryMins).Unix()), } + + // check if a mint quote exists with the same invoice. + // if mint quote exists with same invoice, it can be + // settled internally so set the fee to 0 + mintQuote, err := m.db.GetMintQuoteByPaymentHash(bolt11.PaymentHash) + if err == nil { + meltQuote.InvoiceRequest = mintQuote.PaymentRequest + meltQuote.PaymentHash = mintQuote.PaymentHash + meltQuote.FeeReserve = 0 + } + if err := m.db.SaveMeltQuote(meltQuote); err != nil { msg := fmt.Sprintf("error saving melt quote to db: %v", err) return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) @@ -530,26 +550,67 @@ func (m *Mint) MeltTokens(method, quoteId string, proofs cashu.Proofs) (storage. return storage.MeltQuote{}, nut11.SigAllOnlySwap } - // if proofs are valid, ask the lightning backend - // to make the payment - preimage, err := m.lightningClient.SendPayment(meltQuote.InvoiceRequest, meltQuote.Amount) + // before asking backend to send payment, check if quotes can be settled + // internally (i.e mint and melt quotes exist with the same invoice) + mintQuote, err := m.db.GetMintQuoteByPaymentHash(meltQuote.PaymentHash) + if err == nil { + meltQuote, err = m.settleQuotesInternally(mintQuote, meltQuote) + if err != nil { + return storage.MeltQuote{}, err + } + } else { + // if quote can't be settled internally, ask backend to make payment + preimage, err := m.lightningClient.SendPayment(meltQuote.InvoiceRequest, meltQuote.Amount) + if err != nil { + return storage.MeltQuote{}, cashu.BuildCashuError(err.Error(), cashu.LightningBackendErrCode) + } + + // if payment succeeded, mark melt quote as paid + // and invalidate proofs + meltQuote.State = nut05.Paid + meltQuote.Preimage = preimage + err = m.db.UpdateMeltQuote(meltQuote.Id, meltQuote.Preimage, meltQuote.State) + if err != nil { + msg := fmt.Sprintf("error updating melt quote state: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + } + + err = m.db.SaveProofs(proofs) if err != nil { - return storage.MeltQuote{}, cashu.BuildCashuError(err.Error(), cashu.LightningBackendErrCode) + msg := fmt.Sprintf("error invalidating proofs. Could not save proofs to db: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + + return meltQuote, nil +} + +// if a pair of mint and melt quotes have the same invoice, +// settle them internally and update in db +func (m *Mint) settleQuotesInternally( + mintQuote storage.MintQuote, + meltQuote storage.MeltQuote, +) (storage.MeltQuote, error) { + // need to get the invoice from the backend first to get the preimage + invoice, err := m.lightningClient.InvoiceStatus(mintQuote.PaymentHash) + if err != nil { + msg := fmt.Sprintf("error getting invoice status from lightning backend: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.LightningBackendErrCode) } - // if payment succeeded, mark melt quote as paid - // and invalidate proofs meltQuote.State = nut05.Paid - meltQuote.Preimage = preimage + meltQuote.Preimage = invoice.Preimage err = m.db.UpdateMeltQuote(meltQuote.Id, meltQuote.Preimage, meltQuote.State) if err != nil { - msg := fmt.Sprintf("error getting quote state: %v", err) + msg := fmt.Sprintf("error updating melt quote state: %v", err) return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) } - err = m.db.SaveProofs(proofs) + // mark mint quote request as paid + mintQuote.State = nut04.Paid + err = m.db.UpdateMintQuoteState(mintQuote.Id, mintQuote.State) if err != nil { - msg := fmt.Sprintf("error invalidating proofs. Could not save proofs to db: %v", err) + msg := fmt.Sprintf("error updating mint quote state: %v", err) return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) } diff --git a/mint/mint_integration_test.go b/mint/mint_integration_test.go index 722a2b7..ac46815 100644 --- a/mint/mint_integration_test.go +++ b/mint/mint_integration_test.go @@ -564,8 +564,41 @@ func TestMelt(t *testing.T) { if melt.State != nut05.Paid { t.Fatal("got unexpected unpaid melt quote") } - if melt.State != nut05.Paid { - t.Fatal("got unexpected unpaid melt quote") + + // test internal quotes (mint and melt quotes with same invoice) + var mintAmount uint64 = 42000 + mintQuoteResponse, err := testMint.RequestMintQuote(testutils.BOLT11_METHOD, mintAmount, testutils.SAT_UNIT) + if err != nil { + t.Fatalf("error requesting mint quote: %v", err) + } + keyset := testMint.GetActiveKeyset() + blindedMessages, _, _, err := testutils.CreateBlindedMessages(mintAmount, keyset) + + proofs, err := testutils.GetValidProofsForAmount(mintAmount, testMint, lnd2) + if err != nil { + t.Fatalf("error generating valid proofs: %v", err) + } + + meltQuote, err = testMint.RequestMeltQuote(testutils.BOLT11_METHOD, mintQuoteResponse.PaymentRequest, testutils.SAT_UNIT) + if err != nil { + t.Fatalf("got unexpected error in melt request: %v", err) + } + if meltQuote.FeeReserve != 0 { + t.Fatal("RequestMeltQuote did not return fee reserve of 0 for internal quote") + } + + melt, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, proofs) + if err != nil { + t.Fatalf("got unexpected error in melt: %v", err) + } + if len(melt.Preimage) == 0 { + t.Fatal("melt returned empty preimage") + } + + // now mint should work because quote was settled internally + _, err = testMint.MintTokens(testutils.BOLT11_METHOD, mintQuoteResponse.Id, blindedMessages) + if err != nil { + t.Fatalf("got unexpected error in mint: %v", err) } } diff --git a/mint/storage/sqlite/sqlite.go b/mint/storage/sqlite/sqlite.go index 182e458..24b5958 100644 --- a/mint/storage/sqlite/sqlite.go +++ b/mint/storage/sqlite/sqlite.go @@ -241,6 +241,28 @@ func (sqlite *SQLiteDB) GetMintQuote(quoteId string) (storage.MintQuote, error) return mintQuote, nil } +func (sqlite *SQLiteDB) GetMintQuoteByPaymentHash(paymentHash string) (storage.MintQuote, error) { + row := sqlite.db.QueryRow("SELECT * FROM mint_quotes WHERE payment_hash = ?", paymentHash) + + var mintQuote storage.MintQuote + var state string + + err := row.Scan( + &mintQuote.Id, + &mintQuote.PaymentRequest, + &mintQuote.PaymentHash, + &mintQuote.Amount, + &state, + &mintQuote.Expiry, + ) + if err != nil { + return storage.MintQuote{}, err + } + mintQuote.State = nut04.StringToState(state) + + return mintQuote, nil +} + func (sqlite *SQLiteDB) UpdateMintQuoteState(quoteId string, state nut04.State) error { updatedState := state.String() result, err := sqlite.db.Exec("UPDATE mint_quotes SET state = ? WHERE id = ?", updatedState, quoteId) diff --git a/mint/storage/storage.go b/mint/storage/storage.go index f76362e..46e70f4 100644 --- a/mint/storage/storage.go +++ b/mint/storage/storage.go @@ -21,6 +21,7 @@ type MintDB interface { SaveMintQuote(MintQuote) error GetMintQuote(string) (MintQuote, error) + GetMintQuoteByPaymentHash(string) (MintQuote, error) UpdateMintQuoteState(quoteId string, state nut04.State) error SaveMeltQuote(MeltQuote) error diff --git a/wallet/wallet_integration_test.go b/wallet/wallet_integration_test.go index abb009f..9d91f69 100644 --- a/wallet/wallet_integration_test.go +++ b/wallet/wallet_integration_test.go @@ -700,7 +700,7 @@ func TestSendToPubkey(t *testing.T) { p2pkMintURL := "http://127.0.0.1:8889" p2pkMintPath2 := filepath.Join(".", "p2pkmint2") - p2pkMint2, err := testutils.CreateTestMintServer(lnd2, "8890", p2pkMintPath, dbMigrationPath, 0) + p2pkMint2, err := testutils.CreateTestMintServer(lnd2, "8890", p2pkMintPath2, dbMigrationPath, 0) if err != nil { t.Fatal(err) }