From 98328a0a227f69e18b906d3bb6b52b69721ae674 Mon Sep 17 00:00:00 2001 From: elnosh Date: Thu, 10 Oct 2024 15:44:35 -0500 Subject: [PATCH] wallet and cli commands for pending proofs and quotes --- cmd/nutw/nutw.go | 58 +++++++++++ mint/config.go | 4 + mint/server.go | 10 +- testutils/utils.go | 2 + wallet/storage/bolt.go | 12 ++- wallet/wallet.go | 71 +++++++++----- wallet/wallet_integration_test.go | 158 ++++++++++++++++++++++++++++++ 7 files changed, 285 insertions(+), 30 deletions(-) diff --git a/cmd/nutw/nutw.go b/cmd/nutw/nutw.go index e75f712..1181d27 100644 --- a/cmd/nutw/nutw.go +++ b/cmd/nutw/nutw.go @@ -109,6 +109,7 @@ func main() { sendCmd, receiveCmd, payCmd, + quotesCmd, p2pkLockCmd, mnemonicCmd, restoreCmd, @@ -144,6 +145,11 @@ func getBalance(ctx *cli.Context) error { } fmt.Printf("\nTotal balance: %v sats\n", totalBalance) + + pendingBalance := nutw.PendingBalance() + if pendingBalance > 0 { + fmt.Printf("Pending balance: %v sats\n", pendingBalance) + } return nil } @@ -416,6 +422,58 @@ func pay(ctx *cli.Context) error { return nil } +const ( + checkFlag = "check" +) + +var quotesCmd = &cli.Command{ + Name: "quotes", + Usage: "list and check status of pending melt quotes", + Before: setupWallet, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: checkFlag, + Usage: "check state of quote", + }, + }, + Action: quotes, +} + +func quotes(ctx *cli.Context) error { + pendingQuotes := nutw.GetPendingMeltQuotes() + + if ctx.IsSet(checkFlag) { + quote := ctx.String(checkFlag) + + quoteResponse, err := nutw.CheckMeltQuoteState(quote) + if err != nil { + printErr(err) + } + + switch quoteResponse.State { + case nut05.Paid: + fmt.Printf("Invoice for quote '%v' was paid. Preimage: %v\n", quote, quoteResponse.Preimage) + case nut05.Pending: + fmt.Println("payment is still pending") + case nut05.Unpaid: + fmt.Println("quote was not paid") + } + + return nil + } + + if len(pendingQuotes) > 0 { + fmt.Println("Pending quotes: ") + for _, quote := range pendingQuotes { + fmt.Printf("ID: %v\n", quote) + } + } else { + fmt.Println("no pending quotes") + } + + return nil +} + var p2pkLockCmd = &cli.Command{ Name: "p2pk-lock", Usage: "Retrieves a public key to which ecash can be locked", diff --git a/mint/config.go b/mint/config.go index 4952455..6ec3c37 100644 --- a/mint/config.go +++ b/mint/config.go @@ -1,6 +1,8 @@ package mint import ( + "time" + "github.com/elnosh/gonuts/cashu/nuts/nut06" "github.com/elnosh/gonuts/mint/lightning" ) @@ -23,6 +25,8 @@ type Config struct { Limits MintLimits LightningClient lightning.Client LogLevel LogLevel + // NOTE: using this value for testing + MeltTimeout *time.Duration } type MintInfo struct { diff --git a/mint/server.go b/mint/server.go index 863076d..5a44559 100644 --- a/mint/server.go +++ b/mint/server.go @@ -27,6 +27,8 @@ import ( type MintServer struct { httpServer *http.Server mint *Mint + // NOTE: using this value for testing + meltTimeout *time.Duration } func (ms *MintServer) Start() error { @@ -46,7 +48,7 @@ func SetupMintServer(config Config) (*MintServer, error) { return nil, err } - mintServer := &MintServer{mint: mint} + mintServer := &MintServer{mint: mint, meltTimeout: config.MeltTimeout} err = mintServer.setupHttpServer(config.Port) if err != nil { return nil, err @@ -449,7 +451,11 @@ func (ms *MintServer) meltTokens(rw http.ResponseWriter, req *http.Request) { return } - ctx, cancel := context.WithTimeout(context.Background(), time.Minute*1) + timeout := time.Minute * 1 + if ms.meltTimeout != nil { + timeout = *ms.meltTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() meltQuote, err := ms.mint.MeltTokens(ctx, method, meltTokensRequest.Quote, meltTokensRequest.Inputs) diff --git a/testutils/utils.go b/testutils/utils.go index 96173ce..f468cbb 100644 --- a/testutils/utils.go +++ b/testutils/utils.go @@ -238,6 +238,7 @@ func mintConfig( return nil, fmt.Errorf("error setting LND client: %v", err) } + timeout := time.Second * 2 mintConfig := &mint.Config{ DerivationPathIdx: 0, Port: port, @@ -247,6 +248,7 @@ func mintConfig( Limits: limits, LightningClient: lndClient, LogLevel: mint.Disable, + MeltTimeout: &timeout, } return mintConfig, nil diff --git a/wallet/storage/bolt.go b/wallet/storage/bolt.go index 10818cc..1768d89 100644 --- a/wallet/storage/bolt.go +++ b/wallet/storage/bolt.go @@ -265,11 +265,11 @@ func (db *BoltDB) DeletePendingProofsByQuoteId(quoteId string) error { return err } - y, err := hex.DecodeString(proof.Y) - if err != nil { - return err - } if proof.MeltQuoteId == quoteId { + y, err := hex.DecodeString(proof.Y) + if err != nil { + return err + } if err := pendingProofsb.Delete(y); err != nil { return err } @@ -292,7 +292,9 @@ func (db *BoltDB) DeletePendingProofs(Ys []string) error { if val == nil { return ProofNotFound } - return pendingProofsb.Delete(y) + if err := pendingProofsb.Delete(y); err != nil { + return err + } } return nil diff --git a/wallet/wallet.go b/wallet/wallet.go index 14a8b33..bb4cbe7 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -314,6 +314,18 @@ func (w *Wallet) GetBalanceByMints() map[string]uint64 { return mintsBalances } +func (w *Wallet) PendingBalance() uint64 { + return Amount(w.db.GetPendingProofs()) +} + +func Amount(proofs []storage.DBProof) uint64 { + var totalAmount uint64 = 0 + for _, proof := range proofs { + totalAmount += proof.Amount + } + return totalAmount +} + // RequestMint requests a mint quote to the wallet's current mint // for the specified amount func (w *Wallet) RequestMint(amount uint64) (*nut04.PostMintQuoteBolt11Response, error) { @@ -340,9 +352,8 @@ func (w *Wallet) RequestMint(amount uint64) (*nut04.PostMintQuoteBolt11Response, QuoteExpiry: mintResponse.Expiry, } - err = w.db.SaveInvoice(invoice) - if err != nil { - return nil, err + if err = w.db.SaveInvoice(invoice); err != nil { + return nil, fmt.Errorf("error saving invoice: %v", err) } return mintResponse, nil @@ -731,7 +742,7 @@ func (w *Wallet) CheckMeltQuoteState(quoteId string) (*nut05.PostMeltQuoteBolt11 } } - if quote.State != nut05.Unknown || quote.State == nut05.Unpaid { + if (quote.State == nut05.Unknown && !quote.Paid) || quote.State == nut05.Unpaid { pendingProofs := w.db.GetPendingProofsByQuoteId(quoteId) // if there were any pending proofs tied to this quote, remove them from pending // and add them to available proofs for wallet to use @@ -756,7 +767,6 @@ func (w *Wallet) CheckMeltQuoteState(quoteId string) (*nut05.PostMeltQuoteBolt11 return nil, fmt.Errorf("error storing proofs: %v", err) } } - } } @@ -764,8 +774,8 @@ func (w *Wallet) CheckMeltQuoteState(quoteId string) (*nut05.PostMeltQuoteBolt11 } // Melt will request the mint to pay the given invoice -func (w *Wallet) Melt(invoice, mint string) (*nut05.PostMeltQuoteBolt11Response, error) { - selectedMint, ok := w.mints[mint] +func (w *Wallet) Melt(invoice, mintURL string) (*nut05.PostMeltQuoteBolt11Response, error) { + selectedMint, ok := w.mints[mintURL] if !ok { return nil, ErrMintNotExist } @@ -776,7 +786,7 @@ func (w *Wallet) Melt(invoice, mint string) (*nut05.PostMeltQuoteBolt11Response, } meltRequest := nut05.PostMeltQuoteBolt11Request{Request: invoice, Unit: "sat"} - meltQuoteResponse, err := PostMeltQuoteBolt11(mint, meltRequest) + meltQuoteResponse, err := PostMeltQuoteBolt11(mintURL, meltRequest) if err != nil { return nil, err } @@ -809,7 +819,7 @@ func (w *Wallet) Melt(invoice, mint string) (*nut05.PostMeltQuoteBolt11Response, quoteInvoice := storage.Invoice{ TransactionType: storage.Melt, Id: meltQuoteResponse.Quote, - Mint: mint, + Mint: mintURL, QuoteAmount: amountNeeded, InvoiceAmount: uint64(bolt11.MSatoshi / 1000), PaymentRequest: invoice, @@ -826,22 +836,15 @@ func (w *Wallet) Melt(invoice, mint string) (*nut05.PostMeltQuoteBolt11Response, Inputs: proofs, Outputs: outputs, } - meltBolt11Response, err := PostMeltBolt11(mint, meltBolt11Request) + meltBolt11Response, err := PostMeltBolt11(mintURL, meltBolt11Request) if err != nil { - cashuErr, ok := err.(cashu.Error) - if ok { - // if the error was a failed lightning payment - // remove proofs from pending and add them back to available proofs to wallet - if cashuErr.Code == cashu.LightningPaymentErrCode { - if err := w.db.SaveProofs(proofs); err != nil { - return nil, fmt.Errorf("error storing proofs: %v", err) - } - if err := w.db.DeletePendingProofsByQuoteId(meltQuoteResponse.Quote); err != nil { - return nil, fmt.Errorf("error removing pending proofs: %v", err) - } - } + // if there was error with melt, remove proofs from pending and save them for use + if err := w.db.SaveProofs(proofs); err != nil { + return nil, fmt.Errorf("error storing proofs: %v", err) + } + if err := w.db.DeletePendingProofsByQuoteId(meltQuoteResponse.Quote); err != nil { + return nil, fmt.Errorf("error removing pending proofs: %v", err) } - // if some other error, leave proofs as pending return nil, err } @@ -1716,6 +1719,9 @@ func Restore(walletPath, mnemonic string, mintsToRestore []string) (cashu.Proofs // create batch of 100 blinded messages for i := 0; i < 100; i++ { secret, r, err := generateDeterministicSecret(keysetDerivationPath, counter) + if err != nil { + return nil, err + } B_, r, err := crypto.BlindMessage(secret, r) if err != nil { return nil, err @@ -1805,6 +1811,25 @@ func Restore(walletPath, mnemonic string, mintsToRestore []string) (cashu.Proofs return proofsRestored, nil } +func (w *Wallet) GetPendingProofs() []storage.DBProof { + return w.db.GetPendingProofs() +} + +// GetPendingMeltQuotes return a list of pending quote ids +func (w *Wallet) GetPendingMeltQuotes() []string { + pendingProofs := w.db.GetPendingProofs() + pendingProofsMap := make(map[string][]storage.DBProof) + var pendingQuotes []string + for _, proof := range pendingProofs { + if _, ok := pendingProofsMap[proof.MeltQuoteId]; !ok { + pendingQuotes = append(pendingQuotes, proof.MeltQuoteId) + } + pendingProofsMap[proof.MeltQuoteId] = append(pendingProofsMap[proof.MeltQuoteId], proof) + } + + return pendingQuotes +} + func (w *Wallet) GetInvoiceByPaymentRequest(pr string) (*storage.Invoice, error) { bolt11, err := decodepay.Decodepay(pr) if err != nil { diff --git a/wallet/wallet_integration_test.go b/wallet/wallet_integration_test.go index 67d9fcc..7bb03cc 100644 --- a/wallet/wallet_integration_test.go +++ b/wallet/wallet_integration_test.go @@ -4,6 +4,7 @@ package wallet_test import ( "context" + "crypto/sha256" "errors" "flag" "log" @@ -19,6 +20,7 @@ import ( "github.com/elnosh/gonuts/testutils" "github.com/elnosh/gonuts/wallet" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" ) var ( @@ -600,6 +602,162 @@ func TestWalletBalanceFees(t *testing.T) { } } +func TestPendingProofs(t *testing.T) { + mintURL := "http://127.0.0.1:3338" + testWalletPath := filepath.Join(".", "/testpendingwallet") + testWallet, err := testutils.CreateTestWallet(testWalletPath, mintURL) + if err != nil { + t.Fatal(err) + } + defer func() { + os.RemoveAll(testWalletPath) + }() + + var fundingBalance uint64 = 15000 + if err := testutils.FundCashuWallet(ctx, testWallet, lnd2, fundingBalance); err != nil { + t.Fatalf("error funding wallet: %v", err) + } + + // use hodl invoice to cause melt to get stuck in pending + 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 := testWallet.Melt(addHodlInvoiceRes.PaymentRequest, testWallet.CurrentMint()) + if err != nil { + t.Fatalf("unexpected error in melt: %v", err) + } + if meltQuote.State != nut05.Pending { + t.Fatalf("expected quote state of '%s' but got '%s' instead", nut05.Pending, meltQuote.State) + } + + // check amount of pending proofs is same as quote amount + pendingProofsAmount := wallet.Amount(testWallet.GetPendingProofs()) + expectedAmount := meltQuote.Amount + meltQuote.FeeReserve + if pendingProofsAmount != expectedAmount { + t.Fatalf("expected amount of pending proofs of '%v' but got '%v' instead", + expectedAmount, pendingProofsAmount) + } + pendingBalance := testWallet.PendingBalance() + expectedPendingBalance := meltQuote.Amount + meltQuote.FeeReserve + if pendingBalance != expectedPendingBalance { + t.Fatalf("expected pending balance of '%v' but got '%v' instead", + expectedPendingBalance, pendingBalance) + } + + // there should be 1 pending quote + pendingMeltQuotes := testWallet.GetPendingMeltQuotes() + if len(pendingMeltQuotes) != 1 { + t.Fatalf("expected '%v' pending quote but got '%v' instead", 1, len(pendingMeltQuotes)) + } + if pendingMeltQuotes[0] != meltQuote.Quote { + t.Fatalf("expected pending quote with id '%v' but got '%v' instead", + meltQuote.Quote, pendingMeltQuotes[0]) + } + + // settle hodl invoice and test that there are no pending proofs now + settleHodlInvoice := invoicesrpc.SettleInvoiceMsg{Preimage: preimage} + _, err = lnd2.InvoicesClient.SettleInvoice(ctx, &settleHodlInvoice) + if err != nil { + t.Fatalf("error settling hodl invoice: %v", err) + } + + meltQuoteStateResponse, err := testWallet.CheckMeltQuoteState(meltQuote.Quote) + if err != nil { + t.Fatalf("unexpected error checking melt quote state: %v", err) + } + if meltQuoteStateResponse.State != nut05.Paid { + t.Fatalf("expected quote state of '%s' but got '%s' instead", + nut05.Paid, meltQuoteStateResponse.State) + } + + // check no pending proofs or pending balance after settling and checking melt quote state + pendingProofsAmount = wallet.Amount(testWallet.GetPendingProofs()) + if pendingProofsAmount != 0 { + t.Fatalf("expected no pending proofs amount but got '%v' instead", pendingProofsAmount) + } + pendingBalance = testWallet.PendingBalance() + if pendingBalance != 0 { + t.Fatalf("expected no pending balance but got '%v' instead", pendingBalance) + } + + // check no pending melt quotes + pendingMeltQuotes = testWallet.GetPendingMeltQuotes() + if len(pendingMeltQuotes) != 0 { + t.Fatalf("expected no pending quotes but got '%v' instead", len(pendingMeltQuotes)) + } + + // test hodl invoice to cause melt to get stuck in pending and then cancel it + 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 = testWallet.Melt(addHodlInvoiceRes.PaymentRequest, testWallet.CurrentMint()) + if err != nil { + t.Fatalf("unexpected error in melt: %v", err) + } + if meltQuote.State != nut05.Pending { + t.Fatalf("expected quote state of '%s' but got '%s' instead", nut05.Pending, meltQuote.State) + } + pendingProofsAmount = wallet.Amount(testWallet.GetPendingProofs()) + expectedAmount = meltQuote.Amount + meltQuote.FeeReserve + if pendingProofsAmount != expectedAmount { + t.Fatalf("expected amount of pending proofs of '%v' but got '%v' instead", + expectedAmount, pendingProofsAmount) + } + pendingMeltQuotes = testWallet.GetPendingMeltQuotes() + if len(pendingMeltQuotes) != 1 { + t.Fatalf("expected '%v' pending quote but got '%v' instead", 1, len(pendingMeltQuotes)) + } + + cancelInvoice := invoicesrpc.CancelInvoiceMsg{PaymentHash: hash[:]} + _, err = lnd2.InvoicesClient.CancelInvoice(ctx, &cancelInvoice) + if err != nil { + t.Fatalf("error canceling hodl invoice: %v", err) + } + + meltQuoteStateResponse, err = testWallet.CheckMeltQuoteState(meltQuote.Quote) + if err != nil { + t.Fatalf("unexpected error checking melt quote state: %v", err) + } + if meltQuoteStateResponse.State != nut05.Unpaid { + t.Fatalf("expected quote state of '%s' but got '%s' instead", + nut05.Unpaid, meltQuoteStateResponse.State) + } + + // check no pending proofs or pending balance after canceling and checking melt quote state + pendingProofsAmount = wallet.Amount(testWallet.GetPendingProofs()) + if pendingProofsAmount != 0 { + t.Fatalf("expected no pending proofs amount but got '%v' instead", pendingProofsAmount) + } + pendingBalance = testWallet.PendingBalance() + if pendingBalance != 0 { + t.Fatalf("expected no pending balance but got '%v' instead", pendingBalance) + } + // check no pending melt quotes + pendingMeltQuotes = testWallet.GetPendingMeltQuotes() + if len(pendingMeltQuotes) != 0 { + t.Fatalf("expected no pending quotes but got '%v' instead", len(pendingMeltQuotes)) + } + + // check proofs that were pending were added back to wallet balance + // so wallet balance at this point should be fundingWalletAmount - firstSuccessfulMeltAmount + walletBalance := testWallet.GetBalance() + expectedWalletBalance := fundingBalance - meltQuote.Amount - meltQuote.FeeReserve + if walletBalance != expectedWalletBalance { + t.Fatalf("expected wallet balance of '%v' but got '%v' instead", + expectedWalletBalance, walletBalance) + } +} + func TestWalletRestore(t *testing.T) { mintURL := "http://127.0.0.1:3338"