From 2b0eff8386bca6cecf180ff722726a593b8f493c Mon Sep 17 00:00:00 2001 From: elnosh Date: Tue, 8 Oct 2024 15:11:10 -0500 Subject: [PATCH 1/4] wallet - write proofs to db in one tx --- wallet/storage/bolt.go | 20 ++++++++++------- wallet/storage/storage.go | 4 ++-- wallet/wallet.go | 45 ++++++++++++++++++++------------------- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/wallet/storage/bolt.go b/wallet/storage/bolt.go index a56726e..eb935f8 100644 --- a/wallet/storage/bolt.go +++ b/wallet/storage/bolt.go @@ -139,16 +139,20 @@ func (db *BoltDB) GetProofsByKeysetId(id string) cashu.Proofs { return proofs } -func (db *BoltDB) SaveProof(proof cashu.Proof) error { - jsonProof, err := json.Marshal(proof) - if err != nil { - return fmt.Errorf("invalid proof format: %v", err) - } - +func (db *BoltDB) SaveProofs(proofs cashu.Proofs) error { if err := db.bolt.Update(func(tx *bolt.Tx) error { proofsb := tx.Bucket([]byte(proofsBucket)) - key := []byte(proof.Secret) - return proofsb.Put(key, jsonProof) + for _, proof := range proofs { + key := []byte(proof.Secret) + jsonProof, err := json.Marshal(proof) + if err != nil { + return fmt.Errorf("invalid proof: %v", err) + } + if err := proofsb.Put(key, jsonProof); err != nil { + return err + } + } + return nil }); err != nil { return fmt.Errorf("error saving proof: %v", err) } diff --git a/wallet/storage/storage.go b/wallet/storage/storage.go index c51d792..4e0ebb2 100644 --- a/wallet/storage/storage.go +++ b/wallet/storage/storage.go @@ -23,12 +23,12 @@ func (quote QuoteType) String() string { } } -type DB interface { +type WalletDB interface { SaveMnemonicSeed(string, []byte) GetSeed() []byte GetMnemonic() string - SaveProof(cashu.Proof) error + SaveProofs(cashu.Proofs) error GetProofsByKeysetId(string) cashu.Proofs GetProofs() cashu.Proofs DeleteProof(string) error diff --git a/wallet/wallet.go b/wallet/wallet.go index 34a9c15..dfd0940 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -40,7 +40,7 @@ var ( ) type Wallet struct { - db storage.DB + db storage.WalletDB masterKey *hdkeychain.ExtendedKey // key to receive locked ecash @@ -60,7 +60,7 @@ type walletMint struct { inactiveKeysets map[string]crypto.WalletKeyset } -func InitStorage(path string) (storage.DB, error) { +func InitStorage(path string) (storage.WalletDB, error) { // bolt db atm return storage.InitBolt(path) } @@ -404,14 +404,12 @@ func (w *Wallet) MintTokens(quoteId string) (cashu.Proofs, error) { } // store proofs in db - err = w.saveProofs(proofs) - if err != nil { + if err := w.db.SaveProofs(proofs); err != nil { return nil, fmt.Errorf("error storing proofs: %v", err) } // only increase counter if mint was successful - err = w.db.IncrementKeysetCounter(activeKeyset.Id, uint32(len(blindedMessages))) - if err != nil { + if err := w.db.IncrementKeysetCounter(activeKeyset.Id, uint32(len(blindedMessages))); err != nil { return nil, fmt.Errorf("error incrementing keyset counter: %v", err) } @@ -519,7 +517,10 @@ func (w *Wallet) Receive(token cashu.Token, swapToTrusted bool) (uint64, error) if err != nil { return 0, err } - w.saveProofs(proofs) + + if err := w.db.SaveProofs(proofs); err != nil { + return 0, fmt.Errorf("error storing proofs: %v", err) + } return proofs.Amount(), nil } @@ -740,7 +741,9 @@ func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt1 } meltBolt11Response, err := PostMeltBolt11(mintURL, meltBolt11Request) if err != nil { - w.saveProofs(proofs) + if err := w.db.SaveProofs(proofs); err != nil { + return nil, fmt.Errorf("error storing proofs: %v", err) + } return nil, err } @@ -760,7 +763,9 @@ func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt1 if !paid { // save proofs if invoice was not paid - w.saveProofs(proofs) + if err := w.db.SaveProofs(proofs); err != nil { + return nil, fmt.Errorf("error storing proofs: %v", err) + } } else { change := len(meltBolt11Response.Change) // if mint provided blind signtures for any overpaid lightning fees: @@ -777,7 +782,9 @@ func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt1 if err != nil { return nil, fmt.Errorf("error unblinding signature from change: %v", err) } - w.saveProofs(changeProofs) + if err := w.db.SaveProofs(changeProofs); err != nil { + return nil, fmt.Errorf("error storing change proofs: %v", err) + } err = w.db.IncrementKeysetCounter(activeKeyset.Id, uint32(change)) if err != nil { @@ -1022,7 +1029,9 @@ func (w *Wallet) swapToSend( } // remaining proofs are change proofs to save to db - w.saveProofs(proofsFromSwap) + if err := w.db.SaveProofs(proofsFromSwap); err != nil { + return nil, fmt.Errorf("error storing proofs: %v", err) + } err = w.db.IncrementKeysetCounter(activeSatKeyset.Id, incrementCounterBy) if err != nil { @@ -1688,10 +1697,12 @@ func Restore(walletPath, mnemonic string, mintsToRestore []string) (cashu.Proofs // save unspent proofs if proofState.State == nut07.Unspent { proof := proofs[proofState.Y] - db.SaveProof(proof) proofsRestored = append(proofsRestored, proof) } } + if err := db.SaveProofs(proofsRestored); err != nil { + return nil, fmt.Errorf("error saving restored proofs: %v", err) + } // save wallet keyset with latest counter moving forward for wallet if err := db.IncrementKeysetCounter(keyset.Id, counter); err != nil { @@ -1705,16 +1716,6 @@ func Restore(walletPath, mnemonic string, mintsToRestore []string) (cashu.Proofs return proofsRestored, nil } -func (w *Wallet) saveProofs(proofs cashu.Proofs) error { - for _, proof := range proofs { - err := w.db.SaveProof(proof) - if err != nil { - return err - } - } - return nil -} - func (w *Wallet) GetInvoiceByPaymentRequest(pr string) (*storage.Invoice, error) { bolt11, err := decodepay.Decodepay(pr) if err != nil { From 5326da4f0810d2391497959bfbee38e6d2736b59 Mon Sep 17 00:00:00 2001 From: elnosh Date: Wed, 9 Oct 2024 15:21:53 -0500 Subject: [PATCH 2/4] wallet - handle pending proofs in melt --- cashu/cashu.go | 3 +- cmd/nutw/nutw.go | 9 ++- wallet/storage/bolt.go | 166 +++++++++++++++++++++++++++++++++----- wallet/storage/storage.go | 20 ++++- wallet/wallet.go | 113 ++++++++++++++++---------- 5 files changed, 244 insertions(+), 67 deletions(-) diff --git a/cashu/cashu.go b/cashu/cashu.go index 8dab4f8..a7ab003 100644 --- a/cashu/cashu.go +++ b/cashu/cashu.go @@ -413,7 +413,8 @@ const ( MeltQuotePendingErrCode CashuErrCode = 20005 MeltQuoteAlreadyPaidErrCode CashuErrCode = 20006 - MeltQuoteErrCode CashuErrCode = 20008 + LightningPaymentErrCode CashuErrCode = 20008 + MeltQuoteErrCode CashuErrCode = 20009 ) var ( diff --git a/cmd/nutw/nutw.go b/cmd/nutw/nutw.go index 1a469a6..e75f712 100644 --- a/cmd/nutw/nutw.go +++ b/cmd/nutw/nutw.go @@ -404,10 +404,13 @@ func pay(ctx *cli.Context) error { printErr(err) } - if meltResponse.State == nut05.Paid { + switch meltResponse.State { + case nut05.Paid: fmt.Printf("Invoice paid sucessfully. Preimage: %v\n", meltResponse.Preimage) - } else { - fmt.Println("unable to pay invoice") + case nut05.Pending: + fmt.Println("payment is pending") + case nut05.Unpaid: + fmt.Println("mint could not pay invoice") } return nil diff --git a/wallet/storage/bolt.go b/wallet/storage/bolt.go index eb935f8..db1050a 100644 --- a/wallet/storage/bolt.go +++ b/wallet/storage/bolt.go @@ -1,6 +1,7 @@ package storage import ( + "encoding/hex" "encoding/json" "errors" "fmt" @@ -12,11 +13,16 @@ import ( ) const ( - keysetsBucket = "keysets" - proofsBucket = "proofs" - invoicesBucket = "invoices" - seedBucket = "seed" - mnemonicKey = "mnemonic" + keysetsBucket = "keysets" + proofsBucket = "proofs" + pendingProofsBucket = "pending_proofs" + invoicesBucket = "invoices" + seedBucket = "seed" + mnemonicKey = "mnemonic" +) + +var ( + ProofNotFound = errors.New("proof not found") ) type BoltDB struct { @@ -50,6 +56,11 @@ func (db *BoltDB) initWalletBuckets() error { return err } + _, err = tx.CreateBucketIfNotExists([]byte(pendingProofsBucket)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(invoicesBucket)) if err != nil { return err @@ -93,6 +104,23 @@ func (db *BoltDB) GetSeed() []byte { return seed } +func (db *BoltDB) SaveProofs(proofs cashu.Proofs) error { + return db.bolt.Update(func(tx *bolt.Tx) error { + proofsb := tx.Bucket([]byte(proofsBucket)) + for _, proof := range proofs { + key := []byte(proof.Secret) + jsonProof, err := json.Marshal(proof) + if err != nil { + return fmt.Errorf("invalid proof: %v", err) + } + if err := proofsb.Put(key, jsonProof); err != nil { + return err + } + } + return nil + }) +} + // return all proofs from db func (db *BoltDB) GetProofs() cashu.Proofs { proofs := cashu.Proofs{} @@ -124,7 +152,7 @@ func (db *BoltDB) GetProofsByKeysetId(id string) cashu.Proofs { for k, v := c.First(); k != nil; k, v = c.Next() { var proof cashu.Proof if err := json.Unmarshal(v, &proof); err != nil { - return fmt.Errorf("error getting proofs: %v", err) + return err } if proof.Id == id { @@ -139,35 +167,135 @@ func (db *BoltDB) GetProofsByKeysetId(id string) cashu.Proofs { return proofs } -func (db *BoltDB) SaveProofs(proofs cashu.Proofs) error { - if err := db.bolt.Update(func(tx *bolt.Tx) error { +func (db *BoltDB) DeleteProof(secret string) error { + return db.bolt.Update(func(tx *bolt.Tx) error { proofsb := tx.Bucket([]byte(proofsBucket)) + val := proofsb.Get([]byte(secret)) + if val == nil { + return ProofNotFound + } + return proofsb.Delete([]byte(secret)) + }) +} + +func (db *BoltDB) AddPendingProofsByQuoteId(proofs cashu.Proofs, quoteId string) error { + return db.bolt.Update(func(tx *bolt.Tx) error { + pendingProofsb := tx.Bucket([]byte(pendingProofsBucket)) for _, proof := range proofs { - key := []byte(proof.Secret) - jsonProof, err := json.Marshal(proof) + Y, err := crypto.HashToCurve([]byte(proof.Secret)) + if err != nil { + return err + } + Yhex := hex.EncodeToString(Y.SerializeCompressed()) + + dbProof := DBProof{ + Y: Yhex, + Amount: proof.Amount, + Id: proof.Id, + Secret: proof.Secret, + C: proof.C, + DLEQ: proof.DLEQ, + MeltQuoteId: quoteId, + } + + jsonProof, err := json.Marshal(dbProof) if err != nil { return fmt.Errorf("invalid proof: %v", err) } - if err := proofsb.Put(key, jsonProof); err != nil { + if err := pendingProofsb.Put(Y.SerializeCompressed(), jsonProof); err != nil { + return err + } + } + return nil + }) +} + +func (db *BoltDB) GetPendingProofs() []DBProof { + proofs := []DBProof{} + + db.bolt.View(func(tx *bolt.Tx) error { + proofsb := tx.Bucket([]byte(pendingProofsBucket)) + c := proofsb.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var proof DBProof + if err := json.Unmarshal(v, &proof); err != nil { + proofs = []DBProof{} + return nil + } + proofs = append(proofs, proof) + } + return nil + }) + return proofs +} + +func (db *BoltDB) GetPendingProofsByQuoteId(quoteId string) []DBProof { + proofs := []DBProof{} + + if err := db.bolt.View(func(tx *bolt.Tx) error { + pendingProofsb := tx.Bucket([]byte(pendingProofsBucket)) + + c := pendingProofsb.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var proof DBProof + if err := json.Unmarshal(v, &proof); err != nil { return err } + + if proof.MeltQuoteId == quoteId { + proofs = append(proofs, proof) + } } return nil }); err != nil { - return fmt.Errorf("error saving proof: %v", err) + return []DBProof{} } - return nil + + return proofs } -func (db *BoltDB) DeleteProof(secret string) error { +func (db *BoltDB) DeletePendingProofsByQuoteId(quoteId string) error { return db.bolt.Update(func(tx *bolt.Tx) error { - proofsb := tx.Bucket([]byte(proofsBucket)) - val := proofsb.Get([]byte(secret)) - if val == nil { - return errors.New("proof does not exist") + pendingProofsb := tx.Bucket([]byte(pendingProofsBucket)) + + c := pendingProofsb.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var proof DBProof + if err := json.Unmarshal(v, &proof); err != nil { + return err + } + + y, err := hex.DecodeString(proof.Y) + if err != nil { + return err + } + if proof.MeltQuoteId == quoteId { + if err := pendingProofsb.Delete(y); err != nil { + return err + } + } } + return nil + }) +} - return proofsb.Delete([]byte(secret)) +func (db *BoltDB) DeletePendingProofs(Ys []string) error { + return db.bolt.Update(func(tx *bolt.Tx) error { + pendingProofsb := tx.Bucket([]byte(pendingProofsBucket)) + + for _, v := range Ys { + y, err := hex.DecodeString(v) + if err != nil { + return fmt.Errorf("invalid Y: %v", err) + } + val := pendingProofsb.Get(y) + if val == nil { + return ProofNotFound + } + return pendingProofsb.Delete(y) + } + + return nil }) } diff --git a/wallet/storage/storage.go b/wallet/storage/storage.go index 4e0ebb2..f56ef30 100644 --- a/wallet/storage/storage.go +++ b/wallet/storage/storage.go @@ -29,10 +29,15 @@ type WalletDB interface { GetMnemonic() string SaveProofs(cashu.Proofs) error - GetProofsByKeysetId(string) cashu.Proofs GetProofs() cashu.Proofs + GetProofsByKeysetId(string) cashu.Proofs DeleteProof(string) error + AddPendingProofsByQuoteId(cashu.Proofs, string) error + GetPendingProofs() []DBProof + GetPendingProofsByQuoteId(string) []DBProof + DeletePendingProofsByQuoteId(string) error + SaveKeyset(*crypto.WalletKeyset) error GetKeysets() crypto.KeysetsMap GetKeyset(string) *crypto.WalletKeyset @@ -44,17 +49,28 @@ type WalletDB interface { GetInvoices() []Invoice } +type DBProof struct { + Y string `json:"y"` + Amount uint64 `json:"amount"` + Id string `json:"id"` + Secret string `json:"secret"` + C string `json:"C"` + DLEQ *cashu.DLEQProof `json:"dleq,omitempty"` + // set if proofs are tied to a melt quote + MeltQuoteId string `json:"quote_id"` +} + type Invoice struct { TransactionType QuoteType // mint or melt quote id Id string QuoteAmount uint64 + InvoiceAmount uint64 PaymentRequest string PaymentHash string Preimage string CreatedAt int64 Paid bool SettledAt int64 - InvoiceAmount uint64 QuoteExpiry uint64 } diff --git a/wallet/wallet.go b/wallet/wallet.go index dfd0940..7a725dc 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -705,14 +705,19 @@ func (w *Wallet) swapToTrusted(token cashu.Token) (cashu.Proofs, error) { } // Melt will request the mint to pay the given invoice -func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt11Response, error) { - selectedMint, ok := w.mints[mintURL] +func (w *Wallet) Melt(invoice, mint string) (*nut05.PostMeltQuoteBolt11Response, error) { + selectedMint, ok := w.mints[mint] if !ok { return nil, ErrMintNotExist } + bolt11, err := decodepay.Decodepay(invoice) + if err != nil { + return nil, fmt.Errorf("error decoding invoice: %v", err) + } + meltRequest := nut05.PostMeltQuoteBolt11Request{Request: invoice, Unit: "sat"} - meltQuoteResponse, err := PostMeltQuoteBolt11(mintURL, meltRequest) + meltQuoteResponse, err := PostMeltQuoteBolt11(mint, meltRequest) if err != nil { return nil, err } @@ -723,6 +728,11 @@ func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt1 return nil, err } + // set proofs to pending + if err := w.db.AddPendingProofsByQuoteId(proofs, meltQuoteResponse.Quote); err != nil { + return nil, fmt.Errorf("error saving pending proofs: %v", err) + } + activeKeyset, err := w.getActiveSatKeyset(selectedMint.mintURL) if err != nil { return nil, fmt.Errorf("error getting active sat keyset: %v", err) @@ -733,40 +743,86 @@ func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt1 numBlankOutputs := calculateBlankOutputs(meltQuoteResponse.FeeReserve) split := make([]uint64, numBlankOutputs) outputs, outputsSecrets, outputsRs, err := w.createBlindedMessages(split, activeKeyset.Id, &counter) + if err != nil { + return nil, fmt.Errorf("error generating blinded messages for change: %v", err) + } + + quoteInvoice := storage.Invoice{ + TransactionType: storage.Melt, + Id: meltQuoteResponse.Quote, + QuoteAmount: amountNeeded, + InvoiceAmount: uint64(bolt11.MSatoshi / 1000), + PaymentRequest: invoice, + PaymentHash: bolt11.PaymentHash, + CreatedAt: int64(bolt11.CreatedAt), + QuoteExpiry: meltQuoteResponse.Expiry, + } + if err := w.db.SaveInvoice(quoteInvoice); err != nil { + return nil, err + } meltBolt11Request := nut05.PostMeltBolt11Request{ Quote: meltQuoteResponse.Quote, Inputs: proofs, Outputs: outputs, } - meltBolt11Response, err := PostMeltBolt11(mintURL, meltBolt11Request) + meltBolt11Response, err := PostMeltBolt11(mint, meltBolt11Request) if err != nil { - if err := w.db.SaveProofs(proofs); err != nil { - return nil, fmt.Errorf("error storing proofs: %v", err) + 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 some other error, leave proofs as pending return nil, err } // TODO: deprecate paid field and only use State - // TODO: check for PENDING as well - paid := meltBolt11Response.Paid + meltState := nut05.Unpaid // if state field is present, use that instead of paid if meltBolt11Response.State != nut05.Unknown { - paid = meltBolt11Response.State == nut05.Paid + meltState = meltBolt11Response.State } else { - if paid { + if meltBolt11Response.Paid { + meltState = nut05.Paid meltBolt11Response.State = nut05.Paid } else { + meltState = nut05.Unpaid meltBolt11Response.State = nut05.Unpaid } } - if !paid { - // save proofs if invoice was not paid + switch meltState { + case nut05.Unpaid: + // if quote is unpaid, remove proofs from pending and add them + // to proofs available if err := w.db.SaveProofs(proofs); err != nil { return nil, fmt.Errorf("error storing proofs: %v", err) } - } else { + if err := w.db.DeletePendingProofsByQuoteId(meltQuoteResponse.Quote); err != nil { + return nil, fmt.Errorf("error removing pending proofs: %v", err) + } + case nut05.Paid: + // payment succeeded so remove proofs from pending + if err := w.db.DeletePendingProofsByQuoteId(meltQuoteResponse.Quote); err != nil { + return nil, fmt.Errorf("error removing pending proofs: %v", err) + } + + quoteInvoice.Preimage = meltBolt11Response.Preimage + quoteInvoice.Paid = true + quoteInvoice.SettledAt = time.Now().Unix() + if err := w.db.SaveInvoice(quoteInvoice); err != nil { + return nil, err + } + change := len(meltBolt11Response.Change) // if mint provided blind signtures for any overpaid lightning fees: // - unblind them and save the proofs in the db @@ -785,37 +841,10 @@ func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt1 if err := w.db.SaveProofs(changeProofs); err != nil { return nil, fmt.Errorf("error storing change proofs: %v", err) } - - err = w.db.IncrementKeysetCounter(activeKeyset.Id, uint32(change)) - if err != nil { + if err := w.db.IncrementKeysetCounter(activeKeyset.Id, uint32(change)); err != nil { return nil, fmt.Errorf("error incrementing keyset counter: %v", err) } } - - bolt11, err := decodepay.Decodepay(invoice) - if err != nil { - return nil, fmt.Errorf("error decoding bolt11 invoice: %v", err) - } - - // save invoice to db - invoice := storage.Invoice{ - TransactionType: storage.Melt, - QuoteAmount: amountNeeded, - Id: meltQuoteResponse.Quote, - PaymentRequest: invoice, - PaymentHash: bolt11.PaymentHash, - Preimage: meltBolt11Response.Preimage, - CreatedAt: int64(bolt11.CreatedAt), - Paid: true, - SettledAt: time.Now().Unix(), - InvoiceAmount: uint64(bolt11.MSatoshi / 1000), - QuoteExpiry: meltQuoteResponse.Expiry, - } - - err = w.db.SaveInvoice(invoice) - if err != nil { - return nil, err - } } return meltBolt11Response, err } @@ -1041,7 +1070,7 @@ func (w *Wallet) swapToSend( return proofsToSend, nil } -// getProofsForAmount will return proofs from mint for the give amount. +// getProofsForAmount will return proofs from mint for the given amount. // if pubkeyLock is present it will generate proofs locked to the public key. // It returns error if wallet does not have enough proofs to fulfill amount func (w *Wallet) getProofsForAmount( From 21f7e6d8c9e00098b1b1141af09970dfb17acc6a Mon Sep 17 00:00:00 2001 From: elnosh Date: Thu, 10 Oct 2024 12:39:58 -0500 Subject: [PATCH 3/4] wallet - check melt quote state changes --- wallet/storage/bolt.go | 26 +++++++++++++++++ wallet/storage/storage.go | 5 +++- wallet/wallet.go | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/wallet/storage/bolt.go b/wallet/storage/bolt.go index db1050a..10818cc 100644 --- a/wallet/storage/bolt.go +++ b/wallet/storage/bolt.go @@ -477,6 +477,32 @@ func (db *BoltDB) GetInvoice(paymentHash string) *Invoice { return invoice } +func (db *BoltDB) GetInvoiceByQuoteId(quoteId string) *Invoice { + var quoteInvoice *Invoice + + if err := db.bolt.View(func(tx *bolt.Tx) error { + invoicesb := tx.Bucket([]byte(invoicesBucket)) + + c := invoicesb.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var invoice Invoice + if err := json.Unmarshal(v, &invoice); err != nil { + return err + } + + if invoice.Id == quoteId { + quoteInvoice = &invoice + break + } + } + return nil + }); err != nil { + return nil + } + + return quoteInvoice +} + func (db *BoltDB) GetInvoices() []Invoice { var invoices []Invoice diff --git a/wallet/storage/storage.go b/wallet/storage/storage.go index f56ef30..f946b3a 100644 --- a/wallet/storage/storage.go +++ b/wallet/storage/storage.go @@ -46,6 +46,7 @@ type WalletDB interface { SaveInvoice(Invoice) error GetInvoice(string) *Invoice + GetInvoiceByQuoteId(string) *Invoice GetInvoices() []Invoice } @@ -63,7 +64,9 @@ type DBProof struct { type Invoice struct { TransactionType QuoteType // mint or melt quote id - Id string + Id string + // mint that issued quote + Mint string QuoteAmount uint64 InvoiceAmount uint64 PaymentRequest string diff --git a/wallet/wallet.go b/wallet/wallet.go index 7a725dc..14a8b33 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -37,6 +37,7 @@ import ( var ( ErrMintNotExist = errors.New("mint does not exist") ErrInsufficientMintBalance = errors.New("not enough funds in selected mint") + ErrQuoteNotFound = errors.New("quote not found") ) type Wallet struct { @@ -704,6 +705,64 @@ func (w *Wallet) swapToTrusted(token cashu.Token) (cashu.Proofs, error) { } } +func (w *Wallet) CheckMeltQuoteState(quoteId string) (*nut05.PostMeltQuoteBolt11Response, error) { + invoice := w.db.GetInvoiceByQuoteId(quoteId) + if invoice == nil { + return nil, ErrQuoteNotFound + } + + quote, err := GetMeltQuoteState(invoice.Mint, quoteId) + if err != nil { + return nil, err + } + + if !invoice.Paid { + // if paid status of invoice has changed, update in db + if quote.State == nut05.Paid || quote.Paid { + invoice.Paid = true + invoice.Preimage = quote.Preimage + invoice.SettledAt = time.Now().Unix() + if err := w.db.SaveInvoice(*invoice); err != nil { + return nil, err + } + + if err := w.db.DeletePendingProofsByQuoteId(quoteId); err != nil { + return nil, fmt.Errorf("error removing pending proofs: %v", err) + } + } + + if quote.State != nut05.Unknown || 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 + pendingProofsLen := len(pendingProofs) + if pendingProofsLen > 0 { + proofsToSave := make(cashu.Proofs, pendingProofsLen) + for i, pendingProof := range pendingProofs { + proof := cashu.Proof{ + Amount: pendingProof.Amount, + Id: pendingProof.Id, + Secret: pendingProof.Secret, + C: pendingProof.C, + DLEQ: pendingProof.DLEQ, + } + proofsToSave[i] = proof + } + + if err := w.db.DeletePendingProofsByQuoteId(quoteId); err != nil { + return nil, fmt.Errorf("error removing pending proofs: %v", err) + } + if err := w.db.SaveProofs(proofsToSave); err != nil { + return nil, fmt.Errorf("error storing proofs: %v", err) + } + } + + } + } + + return quote, nil +} + // 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] @@ -750,6 +809,7 @@ func (w *Wallet) Melt(invoice, mint string) (*nut05.PostMeltQuoteBolt11Response, quoteInvoice := storage.Invoice{ TransactionType: storage.Melt, Id: meltQuoteResponse.Quote, + Mint: mint, QuoteAmount: amountNeeded, InvoiceAmount: uint64(bolt11.MSatoshi / 1000), PaymentRequest: invoice, From 98328a0a227f69e18b906d3bb6b52b69721ae674 Mon Sep 17 00:00:00 2001 From: elnosh Date: Thu, 10 Oct 2024 15:44:35 -0500 Subject: [PATCH 4/4] 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"