diff --git a/mint/mint.go b/mint/mint.go index bd0e2c9..2cb1197 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -532,8 +532,6 @@ func (m *Mint) RequestMeltQuote(meltQuoteRequest nut05.PostMeltQuoteBolt11Reques } // Fee reserve that is required by the mint fee := m.lightningClient.FeeReserve(satAmount) - m.logInfof("got melt quote request for invoice of amount '%v'. Setting fee reserve to %v", satAmount, fee) - meltQuote := storage.MeltQuote{ Id: quoteId, InvoiceRequest: request, @@ -557,6 +555,9 @@ func (m *Mint) RequestMeltQuote(meltQuoteRequest nut05.PostMeltQuoteBolt11Reques meltQuote.FeeReserve = 0 } + m.logInfof("got melt quote request for invoice of amount '%v'. Setting fee reserve to %v", + satAmount, meltQuote.FeeReserve) + if err := m.db.SaveMeltQuote(meltQuote); err != nil { errmsg := fmt.Sprintf("error saving melt quote to db: %v", err) return storage.MeltQuote{}, cashu.BuildCashuError(errmsg, cashu.DBErrCode) diff --git a/mint/mint_integration_test.go b/mint/mint_integration_test.go index b0cdb27..b27bd76 100644 --- a/mint/mint_integration_test.go +++ b/mint/mint_integration_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "reflect" + "sync" "testing" "time" @@ -772,6 +773,145 @@ func TestPendingProofs(t *testing.T) { } } +func TestConcurrentMint(t *testing.T) { + var mintAmount uint64 = 2100 + mintQuoteRequest := nut04.PostMintQuoteBolt11Request{Amount: mintAmount, Unit: cashu.Sat.String()} + mintQuoteResponse, _ := testMint.RequestMintQuote(mintQuoteRequest) + + keyset := testMint.GetActiveKeyset() + blindedMessages, _, _, _ := testutils.CreateBlindedMessages(mintAmount, keyset) + + //pay invoice + sendPaymentRequest := lnrpc.SendRequest{ + PaymentRequest: mintQuoteResponse.PaymentRequest, + } + response, _ := lnd2.Client.SendPaymentSync(ctx, &sendPaymentRequest) + if len(response.PaymentError) > 0 { + t.Fatalf("error paying invoice: %v", response.PaymentError) + } + + var wg sync.WaitGroup + var mu sync.Mutex + // test 100 concurrent requests to mint tokens for same quote id + errCount := 0 + numRequests := 100 + for i := 0; i < numRequests; i++ { + wg.Add(1) + go func() { + mintTokensRequest := nut04.PostMintBolt11Request{Quote: mintQuoteResponse.Id, Outputs: blindedMessages} + _, err := testMint.MintTokens(mintTokensRequest) + if err != nil { + mu.Lock() + errCount++ + mu.Unlock() + } + wg.Done() + }() + } + wg.Wait() + + // out of the 100 requests only 1 should have succeeded. + // there should be 99 errors + if errCount != 99 { + t.Fatalf("expected 99 errors but got %v", errCount) + } + +} + +func TestConcurrentSwap(t *testing.T) { + var amount uint64 = 2100 + proofs, err := testutils.GetValidProofsForAmount(amount, testMint, lnd2) + if err != nil { + t.Fatalf("error generating valid proofs: %v", err) + } + + keyset := testMint.GetActiveKeyset() + + var wg sync.WaitGroup + var mu sync.Mutex + // test 100 concurrent swap requests using same proofs + errCount := 0 + numRequests := 100 + for i := 0; i < numRequests; i++ { + wg.Add(1) + go func() { + blindedMessages, _, _, _ := testutils.CreateBlindedMessages(amount, keyset) + _, err := testMint.Swap(proofs, blindedMessages) + if err != nil { + mu.Lock() + errCount++ + mu.Unlock() + } + wg.Done() + }() + } + wg.Wait() + + // out of the 100 requests only 1 should have succeeded. + // there should be 99 errors + if errCount != 99 { + t.Fatalf("expected 99 errors but got %v", errCount) + } +} + +func TestConcurrentMelt(t *testing.T) { + var amount uint64 = 210 + numRequests := 100 + meltQuotes := make([]string, numRequests) + + var feeReserve uint64 = 0 + // create 100 melt quotes + for i := 0; i < numRequests; i++ { + invoice := lnrpc.Invoice{Value: int64(amount)} + addInvoiceResponse, err := lnd2.Client.AddInvoice(ctx, &invoice) + if err != nil { + t.Fatalf("error creating invoice: %v", err) + } + paymentRequest := addInvoiceResponse.PaymentRequest + + meltQuoteRequest := nut05.PostMeltQuoteBolt11Request{Request: paymentRequest, Unit: cashu.Sat.String()} + meltQuote, err := testMint.RequestMeltQuote(meltQuoteRequest) + if err != nil { + t.Fatalf("got unexpected error in melt request: %v", err) + } + meltQuotes[i] = meltQuote.Id + feeReserve = meltQuote.FeeReserve + } + + proofs, err := testutils.GetValidProofsForAmount(amount+feeReserve, testMint, lnd2) + if err != nil { + t.Fatalf("error generating valid proofs: %v", err) + } + + var wg sync.WaitGroup + var mu sync.Mutex + + // for the created melt quotes, do concurrent requests for each one using the same set of proofs + // only 1 should succeed + errCount := 0 + for i := 0; i < numRequests; i++ { + wg.Add(1) + go func() { + meltTokensRequest := nut05.PostMeltBolt11Request{Quote: meltQuotes[i], Inputs: proofs} + _, err := testMint.MeltTokens(ctx, meltTokensRequest) + if err != nil { + mu.Lock() + errCount++ + mu.Unlock() + } + wg.Done() + }() + } + wg.Wait() + + // out of the 100 requests only 1 should have succeeded. + // there should be 99 errors + if errCount != 99 { + t.Fatalf("expected 99 errors but got %v", errCount) + } + +} + func TestProofsStateCheck(t *testing.T) { proofs, err := testutils.GetValidProofsForAmount(5000, testMint, lnd2) if err != nil { diff --git a/mint/storage/sqlite/sqlite_test.go b/mint/storage/sqlite/sqlite_test.go new file mode 100644 index 0000000..cc8e11c --- /dev/null +++ b/mint/storage/sqlite/sqlite_test.go @@ -0,0 +1,452 @@ +package sqlite + +import ( + "encoding/hex" + "log" + "math/rand/v2" + "os" + "reflect" + "slices" + "strings" + "sync" + "testing" + + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut04" + "github.com/elnosh/gonuts/cashu/nuts/nut05" + "github.com/elnosh/gonuts/crypto" + "github.com/elnosh/gonuts/mint/storage" +) + +var ( + db *SQLiteDB +) + +func TestMain(m *testing.M) { + code, err := testMain(m) + if err != nil { + log.Println(err) + } + os.Exit(code) +} + +func testMain(m *testing.M) (int, error) { + dbpath := "./testsqlite" + err := os.MkdirAll(dbpath, 0750) + if err != nil { + return 1, err + } + + migrations := "./migrations" + db, err = InitSQLite(dbpath, migrations) + if err != nil { + return 1, err + } + defer os.RemoveAll(dbpath) + + return m.Run(), nil +} + +func TestProofs(t *testing.T) { + proofs := generateRandomProofs(50) + + if err := db.SaveProofs(proofs); err != nil { + t.Fatalf("error saving proofs: %v", err) + } + + Ys := make([]string, 20) + expectedProofs := make([]storage.DBProof, 20) + for i := 0; i < 20; i++ { + Y, _ := crypto.HashToCurve([]byte(proofs[i].Secret)) + Yhex := hex.EncodeToString(Y.SerializeCompressed()) + Ys[i] = Yhex + expectedProofs[i] = toDBProof(proofs[i], Yhex, "") + } + + dbProofs, err := db.GetProofsUsed(Ys) + if err != nil { + t.Fatalf("error getting used proofs: %v", err) + } + + if len(dbProofs) != 20 { + t.Fatalf("got incorrect number of proofs from db. Expected %v but got %v", 20, len(dbProofs)) + } + + sortDBProofs(expectedProofs) + sortDBProofs(dbProofs) + + if !reflect.DeepEqual(dbProofs, expectedProofs) { + t.Fatal("proofs from db do not match generated ones saved to db") + } +} + +func TestPendingProofs(t *testing.T) { + quoteId := "quoteid12345" + proofs := generateRandomProofs(50) + + if err := db.AddPendingProofs(proofs, quoteId); err != nil { + t.Fatalf("error saving pending proofs: %v", err) + } + + Ys := make([]string, 20) + expectedProofs := make([]storage.DBProof, 20) + for i := 0; i < 20; i++ { + Y, _ := crypto.HashToCurve([]byte(proofs[i].Secret)) + Yhex := hex.EncodeToString(Y.SerializeCompressed()) + Ys[i] = Yhex + expectedProofs[i] = toDBProof(proofs[i], Yhex, quoteId) + } + + pendingProofs, err := db.GetPendingProofs(Ys) + if err != nil { + t.Fatalf("error getting pending proofs: %v", err) + } + + if len(pendingProofs) != 20 { + t.Fatalf("got incorrect number of pending proofs from db. Expected %v but got %v", + 20, len(pendingProofs)) + } + + sortDBProofs(expectedProofs) + sortDBProofs(pendingProofs) + + if !reflect.DeepEqual(pendingProofs, expectedProofs) { + t.Fatal("pending proofs from db do not match generated ones saved to db") + } + + proofs2 := generateRandomProofs(100) + if err := db.AddPendingProofs(proofs2, "anotherquoteid"); err != nil { + t.Fatalf("error saving pending proofs: %v", err) + } + + expectedProofs = make([]storage.DBProof, 50) + for i, proof := range proofs { + Y, _ := crypto.HashToCurve([]byte(proof.Secret)) + Yhex := hex.EncodeToString(Y.SerializeCompressed()) + expectedProofs[i] = toDBProof(proof, Yhex, "") + } + + pendingProofsByQuote, err := db.GetPendingProofsByQuote(quoteId) + if err != nil { + t.Fatalf("error getting pending proofs for quote id '%v': %v", quoteId, err) + } + + if len(pendingProofsByQuote) != 50 { + t.Fatalf("got incorrect number of pending proofs from db. Expected %v but got %v", + 50, len(pendingProofsByQuote)) + } + + sortDBProofs(expectedProofs) + sortDBProofs(pendingProofsByQuote) + + if !reflect.DeepEqual(pendingProofsByQuote, expectedProofs) { + t.Fatal("pending proofs from db do not match generated ones saved to db") + } + + if err := db.RemovePendingProofs(Ys); err != nil { + t.Fatalf("error deleting pending proofs: %v", err) + } + + pendingProofs, err = db.GetPendingProofs(Ys) + if err != nil { + t.Fatalf("error getting pending proofs: %v", err) + } + + if len(pendingProofs) != 0 { + t.Fatalf("expected no pending proofs but got %v", len(pendingProofs)) + } + +} + +func TestMintQuotes(t *testing.T) { + mintQuotes := generateRandomMintQuotes(150) + + var wg sync.WaitGroup + errChan := make(chan error, 150) + done := make(chan interface{}) + for _, quote := range mintQuotes { + wg.Add(1) + go func() { + if err := db.SaveMintQuote(quote); err != nil { + errChan <- err + } + wg.Done() + }() + } + wg.Wait() + go func() { + done <- struct{}{} + }() + + select { + case err := <-errChan: + t.Fatalf("error saving mint quote: %v", err) + case <-done: + } + + expectedQuote := mintQuotes[21] + quote, err := db.GetMintQuote(expectedQuote.Id) + if err != nil { + t.Fatalf("error getting mint quote by id: %v", err) + } + + if !reflect.DeepEqual(expectedQuote, quote) { + t.Fatal("quote from db does not match generated one") + } + + quote, err = db.GetMintQuoteByPaymentHash(expectedQuote.PaymentHash) + if err != nil { + t.Fatalf("error getting mint quote by payment hash: %v", err) + } + + if !reflect.DeepEqual(expectedQuote, quote) { + t.Fatal("quote from db does not match generated one") + } + + if err := db.UpdateMintQuoteState(quote.Id, nut04.Paid); err != nil { + t.Fatalf("error updating mint quote: %v", err) + } + + expectedQuote.State = nut04.Paid + quote, err = db.GetMintQuote(expectedQuote.Id) + if err != nil { + t.Fatalf("error getting mint quote by id: %v", err) + } + if !reflect.DeepEqual(expectedQuote, quote) { + t.Fatal("quote from db does not match generated one") + } + + if err := db.UpdateMintQuoteState(quote.Id, nut04.Issued); err != nil { + t.Fatalf("error updating mint quote: %v", err) + } + + expectedQuote.State = nut04.Issued + quote, err = db.GetMintQuote(expectedQuote.Id) + if err != nil { + t.Fatalf("error getting mint quote by id: %v", err) + } + if !reflect.DeepEqual(expectedQuote, quote) { + t.Fatal("quote from db does not match generated one") + } +} + +func TestMeltQuote(t *testing.T) { + meltQuotes := generateRandomMeltQuotes(150) + + var wg sync.WaitGroup + errChan := make(chan error, 150) + done := make(chan interface{}) + for _, quote := range meltQuotes { + wg.Add(1) + go func() { + if err := db.SaveMeltQuote(quote); err != nil { + errChan <- err + } + wg.Done() + }() + } + wg.Wait() + go func() { + done <- struct{}{} + }() + + select { + case err := <-errChan: + t.Fatalf("error saving melt quote: %v", err) + case <-done: + } + + expectedQuote := meltQuotes[21] + quote, err := db.GetMeltQuote(expectedQuote.Id) + if err != nil { + t.Fatalf("error getting melt quote by id: %v", err) + } + + if !reflect.DeepEqual(expectedQuote, quote) { + t.Fatal("quote from db does not match generated one") + } + + meltQuote, err := db.GetMeltQuoteByPaymentRequest(expectedQuote.InvoiceRequest) + if err != nil { + t.Fatalf("error getting melt quote by payment request: %v", err) + } + + if !reflect.DeepEqual(expectedQuote, *meltQuote) { + t.Fatal("quote from db does not match generated one") + } + + if err := db.UpdateMeltQuote(quote.Id, "", nut05.Pending); err != nil { + t.Fatalf("error updating melt quote: %v", err) + } + + expectedQuote.State = nut05.Pending + quote, err = db.GetMeltQuote(expectedQuote.Id) + if err != nil { + t.Fatalf("error getting melt quote by id: %v", err) + } + if !reflect.DeepEqual(expectedQuote, quote) { + t.Fatal("quote from db does not match generated one") + } + + if err := db.UpdateMeltQuote(quote.Id, "fakepreimage", nut05.Paid); err != nil { + t.Fatalf("error updating melt quote: %v", err) + } + + expectedQuote.State = nut05.Paid + expectedQuote.Preimage = "fakepreimage" + quote, err = db.GetMeltQuote(expectedQuote.Id) + if err != nil { + t.Fatalf("error getting melt quote by id: %v", err) + } + if !reflect.DeepEqual(expectedQuote, quote) { + t.Fatal("quote from db does not match generated one") + } +} + +func TestBlindSignatures(t *testing.T) { + count := 50 + blindedMessages := generateRandomB_s(count) + blindSignatures := generateBlindSignatures(count) + + var wg sync.WaitGroup + errChan := make(chan error, count) + done := make(chan interface{}) + for i := 0; i < count; i++ { + wg.Add(1) + go func() { + if err := db.SaveBlindSignature(blindedMessages[i], blindSignatures[i]); err != nil { + errChan <- err + } + wg.Done() + }() + } + wg.Wait() + go func() { + done <- struct{}{} + }() + + select { + case err := <-errChan: + t.Fatalf("error saving blind signature: %v", err) + case <-done: + } + + expectedBlindSig := blindSignatures[21] + blindSig, err := db.GetBlindSignature(blindedMessages[21]) + if err != nil { + t.Fatalf("error getting blind signature: %v", err) + } + + if !reflect.DeepEqual(blindSig, expectedBlindSig) { + t.Fatal("blind signature from db does match generated one") + } + + blindSigs, err := db.GetBlindSignatures(blindedMessages[:20]) + if err != nil { + t.Fatalf("error getting blind signatures: %v", err) + } + + if len(blindSigs) != 20 { + t.Fatalf("got incorrect number of blind signatures from db. Expected %v but got %v", + 20, len(blindSigs)) + } + +} + +func generateRandomString(length int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = letters[rand.IntN(len(letters))] + } + return string(b) +} + +func generateRandomProofs(num int) cashu.Proofs { + proofs := make(cashu.Proofs, num) + + for i := 0; i < num; i++ { + proof := cashu.Proof{ + Amount: 21, + Id: generateRandomString(32), + Secret: generateRandomString(64), + C: generateRandomString(64), + } + proofs[i] = proof + } + + return proofs +} + +func toDBProof(proof cashu.Proof, Y string, quoteId string) storage.DBProof { + return storage.DBProof{ + Y: Y, + Amount: proof.Amount, + Id: proof.Id, + Secret: proof.Secret, + C: proof.C, + MeltQuoteId: quoteId, + } +} + +func sortDBProofs(proofs []storage.DBProof) { + slices.SortFunc(proofs, func(a, b storage.DBProof) int { + return strings.Compare(a.Secret, b.Secret) + }) +} + +func generateRandomMintQuotes(num int) []storage.MintQuote { + quotes := make([]storage.MintQuote, num) + for i := 0; i < num; i++ { + quote := storage.MintQuote{ + Id: generateRandomString(32), + Amount: 21, + PaymentRequest: generateRandomString(100), + PaymentHash: generateRandomString(50), + State: nut04.Unpaid, + } + quotes[i] = quote + } + return quotes +} + +func generateRandomMeltQuotes(num int) []storage.MeltQuote { + quotes := make([]storage.MeltQuote, num) + for i := 0; i < num; i++ { + quote := storage.MeltQuote{ + Id: generateRandomString(32), + InvoiceRequest: generateRandomString(100), + PaymentHash: generateRandomString(50), + Amount: 21, + FeeReserve: 1, + State: nut05.Unpaid, + } + quotes[i] = quote + } + return quotes +} + +func generateRandomB_s(num int) []string { + B_s := make([]string, num) + for i := 0; i < num; i++ { + B_s[i] = generateRandomString(33) + } + return B_s +} + +func generateBlindSignatures(num int) cashu.BlindedSignatures { + blindSigs := make(cashu.BlindedSignatures, num) + for i := 0; i < num; i++ { + sig := cashu.BlindedSignature{ + C_: generateRandomString(33), + Id: generateRandomString(32), + Amount: 21, + DLEQ: &cashu.DLEQProof{ + E: generateRandomString(33), + S: generateRandomString(33), + }, + } + blindSigs[i] = sig + } + return blindSigs +}