diff --git a/cashu/cashu.go b/cashu/cashu.go index 3eea538..0794b76 100644 --- a/cashu/cashu.go +++ b/cashu/cashu.go @@ -422,11 +422,13 @@ var ( MintingDisabled = Error{Detail: "minting is disabled", Code: MintingDisabledErrCode} MintAmountExceededErr = Error{Detail: "max amount for minting exceeded", Code: AmountLimitExceeded} OutputsOverQuoteAmountErr = Error{Detail: "sum of the output amounts is greater than quote amount", Code: StandardErrCode} - ProofAlreadyUsedErr = Error{Detail: "proofs already used", Code: ProofAlreadyUsedErrCode} + ProofAlreadyUsedErr = Error{Detail: "proof already used", Code: ProofAlreadyUsedErrCode} + ProofPendingErr = Error{Detail: "proof is pending", Code: ProofAlreadyUsedErrCode} InvalidProofErr = Error{Detail: "invalid proof", Code: InvalidProofErrCode} NoProofsProvided = Error{Detail: "no proofs provided", Code: InvalidProofErrCode} DuplicateProofs = Error{Detail: "duplicate proofs", Code: InvalidProofErrCode} QuoteNotExistErr = Error{Detail: "quote does not exist", Code: QuoteErrCode} + MeltQuotePending = Error{Detail: "quote is pending", Code: MeltQuotePendingErrCode} MeltQuoteAlreadyPaid = Error{Detail: "quote already paid", Code: MeltQuoteAlreadyPaidErrCode} MeltAmountExceededErr = Error{Detail: "max amount for melting exceeded", Code: AmountLimitExceeded} InsufficientProofsAmount = Error{ diff --git a/go.mod b/go.mod index 6b2007e..db8ae66 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/btcsuite/btcd/btcec/v2 v2.3.3 github.com/btcsuite/btcd/btcutil v1.1.5 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 - github.com/elnosh/btc-docker-test v0.0.0-20240730150514-6d94d76b8881 + github.com/elnosh/btc-docker-test v0.0.0-20240927160251-93a4da3d1754 github.com/fxamacker/cbor/v2 v2.7.0 github.com/golang-migrate/migrate/v4 v4.17.1 github.com/gorilla/mux v1.8.0 diff --git a/go.sum b/go.sum index c0ae06f..cbaf8c7 100644 --- a/go.sum +++ b/go.sum @@ -185,6 +185,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elnosh/btc-docker-test v0.0.0-20240730150514-6d94d76b8881 h1:iHr0CRNKU9ilxf+LGUon9XB39lRvLlbbm9C9dx2Y/u0= github.com/elnosh/btc-docker-test v0.0.0-20240730150514-6d94d76b8881/go.mod h1:W2G5BhKocXfbC61N4Jy8Z+0rSPGAbDcZsKIr+4B5v9Y= +github.com/elnosh/btc-docker-test v0.0.0-20240927160251-93a4da3d1754 h1:LYVrpWL+RI13UBb36U0TkFu09X7+haLR/ix2zJVQ+Ac= +github.com/elnosh/btc-docker-test v0.0.0-20240927160251-93a4da3d1754/go.mod h1:OZ/LMGKylMDHiAh47vr2MjzJGzE2iRafZyqseh7RppA= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= diff --git a/mint/lightning/lightning.go b/mint/lightning/lightning.go index e09523e..8f27465 100644 --- a/mint/lightning/lightning.go +++ b/mint/lightning/lightning.go @@ -1,10 +1,13 @@ package lightning +import "context" + // Client interface to interact with a Lightning backend type Client interface { CreateInvoice(amount uint64) (Invoice, error) InvoiceStatus(hash string) (Invoice, error) - SendPayment(request string, amount uint64) (string, error) + SendPayment(ctx context.Context, request string, amount uint64) (PaymentStatus, error) + OutgoingPaymentStatus(ctx context.Context, hash string) (PaymentStatus, error) FeeReserve(amount uint64) uint64 } @@ -16,3 +19,16 @@ type Invoice struct { Amount uint64 Expiry uint64 } + +type State int + +const ( + Succeeded State = iota + Failed + Pending +) + +type PaymentStatus struct { + Preimage string + PaymentStatus State +} diff --git a/mint/lightning/lnd.go b/mint/lightning/lnd.go index 3d55659..717f96a 100644 --- a/mint/lightning/lnd.go +++ b/mint/lightning/lnd.go @@ -9,6 +9,7 @@ import ( "time" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/macaroons" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -26,7 +27,8 @@ type LndConfig struct { } type LndClient struct { - grpcClient lnrpc.LightningClient + grpcClient lnrpc.LightningClient + routerClient routerrpc.RouterClient } func SetupLndClient(config LndConfig) (*LndClient, error) { @@ -41,7 +43,8 @@ func SetupLndClient(config LndConfig) (*LndClient, error) { } grpcClient := lnrpc.NewLightningClient(conn) - return &LndClient{grpcClient: grpcClient}, nil + routerClient := routerrpc.NewRouterClient(conn) + return &LndClient{grpcClient: grpcClient, routerClient: routerClient}, nil } func (lnd *LndClient) CreateInvoice(amount uint64) (Invoice, error) { @@ -89,31 +92,66 @@ func (lnd *LndClient) InvoiceStatus(hash string) (Invoice, error) { return invoice, nil } -type SendPaymentResponse struct { - PaymentError string `json:"payment_error"` - PaymentPreimage string `json:"payment_preimage"` -} - -func (lnd *LndClient) SendPayment(request string, amount uint64) (string, error) { +func (lnd *LndClient) SendPayment(ctx context.Context, request string, amount uint64) (PaymentStatus, error) { feeLimit := lnd.FeeReserve(amount) sendPaymentRequest := lnrpc.SendRequest{ PaymentRequest: request, FeeLimit: &lnrpc.FeeLimit{Limit: &lnrpc.FeeLimit_Fixed{Fixed: int64(feeLimit)}}, } - sendPaymentResponse, err := lnd.grpcClient.SendPaymentSync(context.Background(), &sendPaymentRequest) + sendPaymentResponse, err := lnd.grpcClient.SendPaymentSync(ctx, &sendPaymentRequest) if err != nil { - return "", fmt.Errorf("error making payment: %v", err) + // if context deadline is exceeded (1 min), mark payment as pending + // if any other error, mark as failed + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return PaymentStatus{PaymentStatus: Pending}, nil + } else { + return PaymentStatus{PaymentStatus: Failed}, err + } } if len(sendPaymentResponse.PaymentError) > 0 { - return "", fmt.Errorf("error making payment: %v", sendPaymentResponse.PaymentError) - } - if len(sendPaymentResponse.PaymentPreimage) == 0 { - return "", fmt.Errorf("could not make payment") + return PaymentStatus{PaymentStatus: Failed}, fmt.Errorf("payment error: %v", sendPaymentResponse.PaymentError) } preimage := hex.EncodeToString(sendPaymentResponse.PaymentPreimage) - return preimage, nil + paymentResponse := PaymentStatus{Preimage: preimage, PaymentStatus: Succeeded} + return paymentResponse, nil +} + +func (lnd *LndClient) OutgoingPaymentStatus(ctx context.Context, hash string) (PaymentStatus, error) { + hashBytes, err := hex.DecodeString(hash) + if err != nil { + return PaymentStatus{}, errors.New("invalid hash provided") + } + + trackPaymentRequest := routerrpc.TrackPaymentRequest{ + PaymentHash: hashBytes, + // setting this to only get the final payment update + NoInflightUpdates: true, + } + + trackPaymentStream, err := lnd.routerClient.TrackPaymentV2(ctx, &trackPaymentRequest) + if err != nil { + return PaymentStatus{PaymentStatus: Failed}, err + } + + // this should block until final payment update + payment, err := trackPaymentStream.Recv() + if err != nil { + return PaymentStatus{PaymentStatus: Failed}, fmt.Errorf("payment failed: %w", err) + } + if payment.Status == lnrpc.Payment_UNKNOWN || payment.Status == lnrpc.Payment_FAILED { + return PaymentStatus{PaymentStatus: Failed}, + fmt.Errorf("payment failed: %s", payment.FailureReason.String()) + } + if payment.Status == lnrpc.Payment_IN_FLIGHT { + return PaymentStatus{PaymentStatus: Pending}, nil + } + if payment.Status == lnrpc.Payment_SUCCEEDED { + return PaymentStatus{PaymentStatus: Succeeded, Preimage: payment.PaymentPreimage}, nil + } + + return PaymentStatus{PaymentStatus: Failed}, errors.New("unknown") } func (lnd *LndClient) FeeReserve(amount uint64) uint64 { diff --git a/mint/mint.go b/mint/mint.go index 852639f..7a702dc 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -1,6 +1,7 @@ package mint import ( + "context" "crypto/sha256" "database/sql" "encoding/hex" @@ -12,6 +13,7 @@ import ( "path/filepath" "reflect" "slices" + "strings" "time" "github.com/btcsuite/btcd/btcec/v2" @@ -494,7 +496,7 @@ func (m *Mint) RequestMeltQuote(method, request, unit string) (storage.MeltQuote // GetMeltQuoteState returns the state of a melt quote. // Used to check whether a melt quote has been paid. -func (m *Mint) GetMeltQuoteState(method, quoteId string) (storage.MeltQuote, error) { +func (m *Mint) GetMeltQuoteState(ctx context.Context, method, quoteId string) (storage.MeltQuote, error) { if method != BOLT11_METHOD { return storage.MeltQuote{}, cashu.PaymentMethodNotSupportedErr } @@ -504,12 +506,89 @@ func (m *Mint) GetMeltQuoteState(method, quoteId string) (storage.MeltQuote, err return storage.MeltQuote{}, cashu.QuoteNotExistErr } + // if quote is pending, check with backend if status of payment has changed + if meltQuote.State == nut05.Pending { + paymentStatus, err := m.lightningClient.OutgoingPaymentStatus(ctx, meltQuote.PaymentHash) + if paymentStatus.PaymentStatus == lightning.Pending { + return meltQuote, nil + } + if err != nil { + // if it gets to here, payment failed. + // mark quote as unpaid and remove pending proofs + if paymentStatus.PaymentStatus == lightning.Failed && strings.Contains(err.Error(), "payment failed") { + meltQuote.State = nut05.Unpaid + err = m.db.UpdateMeltQuote(meltQuote.Id, "", 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.removePendingProofsForQuote(meltQuote.Id) + if err != nil { + msg := fmt.Sprintf("error removing pending proofs for quote: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + } + } + + // settle proofs (remove pending, and add to used) + // mark quote as paid and set preimage + if paymentStatus.PaymentStatus == lightning.Succeeded { + proofs, err := m.removePendingProofsForQuote(meltQuote.Id) + if err != nil { + msg := fmt.Sprintf("error removing pending proofs for quote: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + err = m.db.SaveProofs(proofs) + if err != nil { + msg := fmt.Sprintf("error invalidating proofs. Could not save proofs to db: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + + meltQuote.State = nut05.Paid + meltQuote.Preimage = paymentStatus.Preimage + err = m.db.UpdateMeltQuote(meltQuote.Id, paymentStatus.Preimage, nut05.Paid) + if err != nil { + msg := fmt.Sprintf("error updating melt quote state: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + } + } + return meltQuote, nil } +func (m *Mint) removePendingProofsForQuote(quoteId string) (cashu.Proofs, error) { + dbproofs, err := m.db.GetPendingProofsByQuote(quoteId) + if err != nil { + return nil, err + } + + proofs := make(cashu.Proofs, len(dbproofs)) + Ys := make([]string, len(dbproofs)) + for i, dbproof := range dbproofs { + Ys[i] = dbproof.Y + + proof := cashu.Proof{ + Amount: dbproof.Amount, + Id: dbproof.Id, + Secret: dbproof.Secret, + C: dbproof.C, + } + proofs[i] = proof + } + + err = m.db.RemovePendingProofs(Ys) + if err != nil { + return nil, err + } + + return proofs, nil +} + // MeltTokens verifies whether proofs provided are valid // and proceeds to attempt payment. -func (m *Mint) MeltTokens(method, quoteId string, proofs cashu.Proofs) (storage.MeltQuote, error) { +func (m *Mint) MeltTokens(ctx context.Context, method, quoteId string, proofs cashu.Proofs) (storage.MeltQuote, error) { var proofsAmount uint64 Ys := make([]string, len(proofs)) for i, proof := range proofs { @@ -534,6 +613,9 @@ func (m *Mint) MeltTokens(method, quoteId string, proofs cashu.Proofs) (storage. if meltQuote.State == nut05.Paid { return storage.MeltQuote{}, cashu.MeltQuoteAlreadyPaid } + if meltQuote.State == nut05.Pending { + return storage.MeltQuote{}, cashu.MeltQuotePending + } err = m.verifyProofs(proofs, Ys) if err != nil { @@ -550,6 +632,19 @@ func (m *Mint) MeltTokens(method, quoteId string, proofs cashu.Proofs) (storage. return storage.MeltQuote{}, nut11.SigAllOnlySwap } + // set proofs as pending before trying to make payment + err = m.db.AddPendingProofs(proofs, meltQuote.Id) + if err != nil { + msg := fmt.Sprintf("error setting proofs as pending in db: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + meltQuote.State = nut05.Pending + err = m.db.UpdateMeltQuote(meltQuote.Id, "", nut05.Pending) + if err != nil { + msg := fmt.Sprintf("error updating melt quote state: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + // 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) @@ -558,28 +653,101 @@ func (m *Mint) MeltTokens(method, quoteId string, proofs cashu.Proofs) (storage. 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) + err := m.db.RemovePendingProofs(Ys) if err != nil { - return storage.MeltQuote{}, cashu.BuildCashuError(err.Error(), cashu.LightningBackendErrCode) + msg := fmt.Sprintf("error removing pending proofs: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) } - - // 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) + err = m.db.SaveProofs(proofs) if err != nil { - msg := fmt.Sprintf("error updating melt quote state: %v", err) + msg := fmt.Sprintf("error invalidating proofs. Could not save proofs to db: %v", err) return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) } - } - err = m.db.SaveProofs(proofs) - if err != nil { - msg := fmt.Sprintf("error invalidating proofs. Could not save proofs to db: %v", err) - return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } else { + // if quote can't be settled internally, ask backend to make payment + sendPaymentResponse, err := m.lightningClient.SendPayment(ctx, meltQuote.InvoiceRequest, meltQuote.Amount) + if err != nil { + // if the payment error field was present in the response from SendPayment + // the payment most likely failed so we can already return unpaid state here + if strings.Contains(err.Error(), "payment error") { + meltQuote.State = nut05.Unpaid + err = m.db.UpdateMeltQuote(meltQuote.Id, "", 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.RemovePendingProofs(Ys) + if err != nil { + msg := fmt.Sprintf("error removing proofs from pending: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + return meltQuote, nil + } + + // if SendPayment failed for something other that payment error + // do not return yet, an extra check will be done + sendPaymentResponse.PaymentStatus = lightning.Failed + } + + switch sendPaymentResponse.PaymentStatus { + case lightning.Succeeded: + // if payment succeeded: + // - unset pending proofs and mark them as spent by adding them to the db + // - mark melt quote as paid + meltQuote.State = nut05.Paid + meltQuote.Preimage = sendPaymentResponse.Preimage + err = m.settleProofs(Ys, proofs) + if err != nil { + return storage.MeltQuote{}, err + } + err = m.db.UpdateMeltQuote(meltQuote.Id, sendPaymentResponse.Preimage, nut05.Paid) + if err != nil { + msg := fmt.Sprintf("error updating melt quote state: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + + case lightning.Pending: + // if payment is pending, leave quote and proofs as pending and return + return meltQuote, nil + + case lightning.Failed: + // if got failed from SendPayment + // do additional check by calling to get outgoing payment status + paymentStatus, err := m.lightningClient.OutgoingPaymentStatus(ctx, meltQuote.PaymentHash) + if paymentStatus.PaymentStatus == lightning.Pending { + return meltQuote, nil + } + if err != nil { + // if it gets to here, most likely the payment failed + // so mark quote as unpaid and remove proofs from pending + meltQuote.State = nut05.Unpaid + err = m.db.UpdateMeltQuote(meltQuote.Id, "", 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.RemovePendingProofs(Ys) + if err != nil { + msg := fmt.Sprintf("error removing proofs from pending: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + } + + if paymentStatus.PaymentStatus == lightning.Succeeded { + err = m.settleProofs(Ys, proofs) + if err != nil { + return storage.MeltQuote{}, err + } + meltQuote.State = nut05.Paid + meltQuote.Preimage = paymentStatus.Preimage + err = m.db.UpdateMeltQuote(meltQuote.Id, paymentStatus.Preimage, nut05.Paid) + if err != nil { + msg := fmt.Sprintf("error updating melt quote state: %v", err) + return storage.MeltQuote{}, cashu.BuildCashuError(msg, cashu.DBErrCode) + } + } + } } return meltQuote, nil @@ -617,6 +785,23 @@ func (m *Mint) settleQuotesInternally( return meltQuote, nil } +// settleProofs will remove the proofs from the pending table +// and mark them as spent by adding them to the used proofs table +func (m *Mint) settleProofs(Ys []string, proofs cashu.Proofs) error { + err := m.db.RemovePendingProofs(Ys) + if err != nil { + msg := fmt.Sprintf("error removing pending proofs: %v", err) + return cashu.BuildCashuError(msg, cashu.DBErrCode) + } + err = m.db.SaveProofs(proofs) + if err != nil { + msg := fmt.Sprintf("error invalidating proofs. Could not save proofs to db: %v", err) + return cashu.BuildCashuError(msg, cashu.DBErrCode) + } + + return nil +} + func (m *Mint) ProofsStateCheck(Ys []string) ([]nut07.ProofState, error) { usedProofs, err := m.db.GetProofsUsed(Ys) if err != nil { @@ -668,7 +853,18 @@ func (m *Mint) verifyProofs(proofs cashu.Proofs, Ys []string) error { return cashu.NoProofsProvided } - // check if proofs were alredy used + // check if proofs are either pending or already spent + pendingProofs, err := m.db.GetPendingProofs(Ys) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("could not get pending proofs from db: %v", err) + return cashu.BuildCashuError(msg, cashu.DBErrCode) + } + } + if len(pendingProofs) != 0 { + return cashu.ProofPendingErr + } + usedProofs, err := m.db.GetProofsUsed(Ys) if err != nil { if !errors.Is(err, sql.ErrNoRows) { diff --git a/mint/mint_integration_test.go b/mint/mint_integration_test.go index ac46815..eacb2e0 100644 --- a/mint/mint_integration_test.go +++ b/mint/mint_integration_test.go @@ -4,6 +4,7 @@ package mint_test import ( "context" + "crypto/sha256" "encoding/hex" "errors" "flag" @@ -26,6 +27,7 @@ import ( "github.com/elnosh/gonuts/mint" "github.com/elnosh/gonuts/testutils" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" ) var ( @@ -400,19 +402,19 @@ func TestMeltQuoteState(t *testing.T) { } // test invalid method - _, err = testMint.GetMeltQuoteState("strike", meltRequest.Id) + _, err = testMint.GetMeltQuoteState(ctx, "strike", meltRequest.Id) if !errors.Is(err, cashu.PaymentMethodNotSupportedErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.PaymentMethodNotSupportedErr, err) } // test invalid quote id - _, err = testMint.GetMeltQuoteState(testutils.BOLT11_METHOD, "quote1234") + _, err = testMint.GetMeltQuoteState(ctx, testutils.BOLT11_METHOD, "quote1234") if !errors.Is(err, cashu.QuoteNotExistErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.PaymentMethodNotSupportedErr, err) } // test before paying melt - meltQuote, err := testMint.GetMeltQuoteState(testutils.BOLT11_METHOD, meltRequest.Id) + meltQuote, err := testMint.GetMeltQuoteState(ctx, testutils.BOLT11_METHOD, meltRequest.Id) if err != nil { t.Fatalf("unexpected error getting melt quote state: %v", err) } @@ -426,12 +428,12 @@ func TestMeltQuoteState(t *testing.T) { t.Fatalf("error generating valid proofs: %v", err) } - _, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofs) + _, err = testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, validProofs) if err != nil { t.Fatalf("got unexpected error in melt: %v", err) } - meltQuote, err = testMint.GetMeltQuoteState(testutils.BOLT11_METHOD, meltRequest.Id) + meltQuote, err = testMint.GetMeltQuoteState(ctx, testutils.BOLT11_METHOD, meltRequest.Id) if err != nil { t.Fatalf("unexpected error getting melt quote state: %v", err) } @@ -464,7 +466,7 @@ func TestMelt(t *testing.T) { } // test proofs amount under melt amount - _, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, underProofs) + _, err = testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, underProofs) if !errors.Is(err, cashu.InsufficientProofsAmount) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.PaymentMethodNotSupportedErr, err) } @@ -477,7 +479,7 @@ func TestMelt(t *testing.T) { // test invalid proofs validProofs[0].Secret = "some invalid secret" - _, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofs) + _, err = testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, validProofs) if !errors.Is(err, cashu.InvalidProofErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.InvalidProofErr, err) } @@ -489,12 +491,12 @@ func TestMelt(t *testing.T) { duplicateProofs := make(cashu.Proofs, proofsLen) copy(duplicateProofs, validProofs) duplicateProofs[proofsLen-2] = duplicateProofs[proofsLen-1] - _, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, duplicateProofs) + _, err = testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, duplicateProofs) if !errors.Is(err, cashu.DuplicateProofs) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.DuplicateProofs, err) } - melt, err := testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofs) + melt, err := testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, validProofs) if err != nil { t.Fatalf("got unexpected error in melt: %v", err) } @@ -503,7 +505,7 @@ func TestMelt(t *testing.T) { } // test quote already paid - _, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofs) + _, err = testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, validProofs) if !errors.Is(err, cashu.MeltQuoteAlreadyPaid) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.MeltQuoteAlreadyPaid, err) } @@ -513,7 +515,7 @@ func TestMelt(t *testing.T) { if err != nil { t.Fatalf("got unexpected error in melt request: %v", err) } - _, err = testMint.MeltTokens(testutils.BOLT11_METHOD, newQuote.Id, validProofs) + _, err = testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, newQuote.Id, validProofs) if !errors.Is(err, cashu.ProofAlreadyUsedErr) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.ProofAlreadyUsedErr, err) } @@ -551,13 +553,13 @@ func TestMelt(t *testing.T) { } // test proofs below needed amount with fees - _, err = mintFees.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, underProofs) + _, err = mintFees.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, underProofs) if !errors.Is(err, cashu.InsufficientProofsAmount) { t.Fatalf("expected error '%v' but got '%v' instead", cashu.InsufficientProofsAmount, err) } // test valid proofs accounting for fees - melt, err = mintFees.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofsWithFees) + melt, err = mintFees.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, validProofsWithFees) if err != nil { t.Fatalf("got unexpected error in melt: %v", err) } @@ -587,7 +589,7 @@ func TestMelt(t *testing.T) { t.Fatal("RequestMeltQuote did not return fee reserve of 0 for internal quote") } - melt, err = testMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, proofs) + melt, err = testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, proofs) if err != nil { t.Fatalf("got unexpected error in melt: %v", err) } @@ -600,6 +602,70 @@ func TestMelt(t *testing.T) { if err != nil { t.Fatalf("got unexpected error in mint: %v", err) } + + // tests for pending state + preimage, _ := testutils.GenerateRandomBytes() + hash := sha256.Sum256(preimage) + hodlInvoice := invoicesrpc.AddHoldInvoiceRequest{Hash: hash[:], Value: 2100} + addHodlInvoiceRes, err := lnd2.InvoicesClient.AddHoldInvoice(ctx, &hodlInvoice) + if err != nil { + t.Fatalf("error creating hodl invoice: %v", err) + } + + meltQuote, err = testMint.RequestMeltQuote(testutils.BOLT11_METHOD, addHodlInvoiceRes.PaymentRequest, testutils.SAT_UNIT) + if err != nil { + t.Fatalf("got unexpected error in melt request: %v", err) + } + + validProofs, err = testutils.GetValidProofsForAmount(2200, testMint, lnd2) + if err != nil { + t.Fatalf("error generating valid proofs: %v", err) + } + + // custom context just for this melt call to timeout afte 5s and return pending + // for the stuck hodl invoice + meltContext, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + melt, err = testMint.MeltTokens(meltContext, testutils.BOLT11_METHOD, meltQuote.Id, validProofs) + if err != nil { + t.Fatalf("got unexpected error in melt: %v", err) + } + if melt.State != nut05.Pending { + t.Fatalf("expected melt quote with state of '%v' but got '%v' instead", nut05.Pending.String(), melt.State.String()) + } + + _, err = testMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, validProofs) + if !errors.Is(err, cashu.MeltQuotePending) { + t.Fatalf("expected error '%v' but got '%v' instead", cashu.MeltQuotePending, err) + } + + // try to use currently pending proofs in another op. + // swap should return err saying proofs are pending + blindedMessages, _, _, _ = testutils.CreateBlindedMessages(validProofs.Amount(), testMint.GetActiveKeyset()) + _, err = testMint.Swap(validProofs, blindedMessages) + if !errors.Is(err, cashu.ProofPendingErr) { + t.Fatalf("expected error '%v' but got '%v' instead", cashu.ProofPendingErr, err) + } + + settleHodlInvoice := invoicesrpc.SettleInvoiceMsg{Preimage: preimage} + _, err = lnd2.InvoicesClient.SettleInvoice(ctx, &settleHodlInvoice) + if err != nil { + t.Fatalf("error settling hodl invoice: %v", err) + } + + meltQuote, err = testMint.GetMeltQuoteState(ctx, testutils.BOLT11_METHOD, melt.Id) + if err != nil { + t.Fatalf("unexpected error getting melt quote state: %v", err) + } + if meltQuote.State != nut05.Paid { + t.Fatalf("expected melt quote with state of '%v' but got '%v' instead", nut05.Paid.String(), meltQuote.State.String()) + } + + expectedPreimage := hex.EncodeToString(preimage) + if meltQuote.Preimage != expectedPreimage { + t.Fatalf("expected melt quote with preimage of '%v' but got '%v' instead", preimage, meltQuote.Preimage) + } } func TestProofsStateCheck(t *testing.T) { @@ -792,7 +858,7 @@ func TestMintLimits(t *testing.T) { if err != nil { t.Fatalf("got unexpected error in melt request: %v", err) } - _, err = limitsMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, validProofs) + _, err = limitsMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, validProofs) if err != nil { t.Fatalf("got unexpected error in melt: %v", err) } @@ -958,13 +1024,13 @@ func TestNUT11P2PK(t *testing.T) { t.Fatalf("got unexpected error in melt request: %v", err) } - _, err = p2pkMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, lockedProofs) + _, err = p2pkMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, lockedProofs) if !errors.Is(err, nut11.InvalidWitness) { t.Fatalf("expected error '%v' but got '%v' instead", nut11.InvalidWitness, err) } signedProofs, _ = testutils.AddSignaturesToInputs(lockedProofs, []*btcec.PrivateKey{lock}) - _, err = p2pkMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, lockedProofs) + _, err = p2pkMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, lockedProofs) if err != nil { t.Fatalf("unexpected error melting: %v", err) } @@ -988,7 +1054,7 @@ func TestNUT11P2PK(t *testing.T) { if err != nil { t.Fatalf("got unexpected error in melt request: %v", err) } - _, err = p2pkMint.MeltTokens(testutils.BOLT11_METHOD, meltQuote.Id, lockedProofs) + _, err = p2pkMint.MeltTokens(ctx, testutils.BOLT11_METHOD, meltQuote.Id, lockedProofs) if !errors.Is(err, nut11.SigAllOnlySwap) { t.Fatalf("expected error '%v' but got '%v' instead", nut11.SigAllOnlySwap, err) } diff --git a/mint/server.go b/mint/server.go index aa2d4e9..64c6261 100644 --- a/mint/server.go +++ b/mint/server.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/elnosh/gonuts/cashu" "github.com/elnosh/gonuts/cashu/nuts/nut01" @@ -405,7 +406,10 @@ func (ms *MintServer) meltQuoteState(rw http.ResponseWriter, req *http.Request) method := vars["method"] quoteId := vars["quote_id"] - meltQuote, err := ms.mint.GetMeltQuoteState(method, quoteId) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*1) + defer cancel() + + meltQuote, err := ms.mint.GetMeltQuoteState(ctx, method, quoteId) if err != nil { ms.writeErr(rw, req, err) return @@ -442,7 +446,10 @@ func (ms *MintServer) meltTokens(rw http.ResponseWriter, req *http.Request) { return } - meltQuote, err := ms.mint.MeltTokens(method, meltTokensRequest.Quote, meltTokensRequest.Inputs) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*1) + defer cancel() + + meltQuote, err := ms.mint.MeltTokens(ctx, method, meltTokensRequest.Quote, meltTokensRequest.Inputs) if err != nil { cashuErr, ok := err.(*cashu.Error) // note: if there was internal error from lightning backend diff --git a/mint/storage/sqlite/migrations/000005_add_pending_proofs_table.down.sql b/mint/storage/sqlite/migrations/000005_add_pending_proofs_table.down.sql new file mode 100644 index 0000000..2e81ff7 --- /dev/null +++ b/mint/storage/sqlite/migrations/000005_add_pending_proofs_table.down.sql @@ -0,0 +1,2 @@ + +DROP TABLE IF EXISTS pending_proofs; diff --git a/mint/storage/sqlite/migrations/000005_add_pending_proofs_table.up.sql b/mint/storage/sqlite/migrations/000005_add_pending_proofs_table.up.sql new file mode 100644 index 0000000..60ca3cf --- /dev/null +++ b/mint/storage/sqlite/migrations/000005_add_pending_proofs_table.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS pending_proofs ( + y TEXT PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + secret TEXT NOT NULL UNIQUE, + c TEXT NOT NULL, + melt_quote_id TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_pending_proofs_y ON pending_proofs(y); diff --git a/mint/storage/sqlite/sqlite.go b/mint/storage/sqlite/sqlite.go index 24b5958..2e11a1d 100644 --- a/mint/storage/sqlite/sqlite.go +++ b/mint/storage/sqlite/sqlite.go @@ -204,6 +204,127 @@ func (sqlite *SQLiteDB) GetProofsUsed(Ys []string) ([]storage.DBProof, error) { return proofs, nil } +func (sqlite *SQLiteDB) AddPendingProofs(proofs cashu.Proofs, quoteId string) error { + tx, err := sqlite.db.Begin() + if err != nil { + return err + } + + stmt, err := tx.Prepare("INSERT INTO pending_proofs (y, amount, keyset_id, secret, c, melt_quote_id) VALUES (?, ?, ?, ?, ?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + for _, proof := range proofs { + Y, err := crypto.HashToCurve([]byte(proof.Secret)) + if err != nil { + return err + } + Yhex := hex.EncodeToString(Y.SerializeCompressed()) + + if _, err := stmt.Exec(Yhex, proof.Amount, proof.Id, proof.Secret, proof.C, quoteId); err != nil { + tx.Rollback() + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + + return nil +} + +func (sqlite *SQLiteDB) GetPendingProofs(Ys []string) ([]storage.DBProof, error) { + proofs := []storage.DBProof{} + query := `SELECT y, amount, keyset_id, secret, c FROM pending_proofs WHERE y in (?` + strings.Repeat(",?", len(Ys)-1) + `)` + + args := make([]any, len(Ys)) + for i, y := range Ys { + args[i] = y + } + + rows, err := sqlite.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var proof storage.DBProof + err := rows.Scan( + &proof.Y, + &proof.Amount, + &proof.Id, + &proof.Secret, + &proof.C, + ) + if err != nil { + return nil, err + } + + proofs = append(proofs, proof) + } + + return proofs, nil +} + +func (sqlite *SQLiteDB) GetPendingProofsByQuote(quoteId string) ([]storage.DBProof, error) { + proofs := []storage.DBProof{} + query := `SELECT y, amount, keyset_id, secret, c FROM pending_proofs WHERE melt_quote_id = ?` + + rows, err := sqlite.db.Query(query, quoteId) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var proof storage.DBProof + err := rows.Scan( + &proof.Y, + &proof.Amount, + &proof.Id, + &proof.Secret, + &proof.C, + ) + if err != nil { + return nil, err + } + + proofs = append(proofs, proof) + } + + return proofs, nil +} + +func (sqlite *SQLiteDB) RemovePendingProofs(Ys []string) error { + tx, err := sqlite.db.Begin() + if err != nil { + return err + } + + stmt, err := tx.Prepare("DELETE FROM pending_proofs WHERE y = ?") + if err != nil { + return err + } + defer stmt.Close() + + for _, y := range Ys { + if _, err := stmt.Exec(y); err != nil { + tx.Rollback() + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + + return nil +} + func (sqlite *SQLiteDB) SaveMintQuote(mintQuote storage.MintQuote) error { _, err := sqlite.db.Exec( `INSERT INTO mint_quotes (id, payment_request, payment_hash, amount, state, expiry) diff --git a/mint/storage/storage.go b/mint/storage/storage.go index 46e70f4..564e18a 100644 --- a/mint/storage/storage.go +++ b/mint/storage/storage.go @@ -18,6 +18,10 @@ type MintDB interface { SaveProofs(cashu.Proofs) error GetProofsUsed(Ys []string) ([]DBProof, error) + AddPendingProofs(proofs cashu.Proofs, quoteId string) error + GetPendingProofs(Ys []string) ([]DBProof, error) + GetPendingProofsByQuote(quoteId string) ([]DBProof, error) + RemovePendingProofs(Ys []string) error SaveMintQuote(MintQuote) error GetMintQuote(string) (MintQuote, error) diff --git a/testutils/utils.go b/testutils/utils.go index aee98bc..df2bebd 100644 --- a/testutils/utils.go +++ b/testutils/utils.go @@ -638,3 +638,12 @@ func CreateNutshellMintContainer(ctx context.Context, inputFeePpk int) (*Nutshel return nutshellContainer, nil } + +func GenerateRandomBytes() ([]byte, error) { + randomBytes := make([]byte, 32) + _, err := rand.Read(randomBytes) + if err != nil { + return nil, err + } + return randomBytes[:], nil +} diff --git a/wallet/wallet_integration_test.go b/wallet/wallet_integration_test.go index 9d91f69..b59d22f 100644 --- a/wallet/wallet_integration_test.go +++ b/wallet/wallet_integration_test.go @@ -14,6 +14,7 @@ import ( btcdocker "github.com/elnosh/btc-docker-test" "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut05" "github.com/elnosh/gonuts/cashu/nuts/nut12" "github.com/elnosh/gonuts/testutils" "github.com/elnosh/gonuts/wallet" @@ -502,10 +503,13 @@ func TestWalletBalance(t *testing.T) { } balanceBeforeMelt := balanceTestWallet.GetBalance() - // doing self-payment so this should make melt request fail - _, err = balanceTestWallet.Melt(addInvoiceResponse.PaymentRequest, mintURL) - if err == nil { - t.Fatal("expected error in melt request but got nil") + // doing self-payment so this should make melt return unpaid + meltresponse, err := balanceTestWallet.Melt(addInvoiceResponse.PaymentRequest, mintURL) + if err != nil { + t.Fatalf("got unexpected error in melt: %v", err) + } + if meltresponse.State != nut05.Unpaid { + t.Fatalf("expected melt with unpaid state but got '%v'", meltresponse.State.String()) } // check balance is same after failed melt