From a14527852e7c519c005ec9e1717b2f27a202821b Mon Sep 17 00:00:00 2001 From: elnosh Date: Mon, 7 Oct 2024 15:33:26 -0500 Subject: [PATCH] wallet - support nut-08 for overpaid lightning fees --- cashu/cashu.go | 8 +++ cashu/nuts/nut05/nut05.go | 37 +++++++------ wallet/wallet.go | 73 +++++++++++++++++++------ wallet/wallet_integration_test.go | 89 ++++++++++++++++++++++++++----- 4 files changed, 161 insertions(+), 46 deletions(-) diff --git a/cashu/cashu.go b/cashu/cashu.go index bd66367..8dab4f8 100644 --- a/cashu/cashu.go +++ b/cashu/cashu.go @@ -73,6 +73,14 @@ type BlindedSignature struct { type BlindedSignatures []BlindedSignature +func (bs BlindedSignatures) Amount() uint64 { + var totalAmount uint64 = 0 + for _, sig := range bs { + totalAmount += sig.Amount + } + return totalAmount +} + // Cashu Proof. See https://github.com/cashubtc/nuts/blob/main/00.md#proof type Proof struct { Amount uint64 `json:"amount"` diff --git a/cashu/nuts/nut05/nut05.go b/cashu/nuts/nut05/nut05.go index 989917c..79d2ae3 100644 --- a/cashu/nuts/nut05/nut05.go +++ b/cashu/nuts/nut05/nut05.go @@ -49,28 +49,31 @@ type PostMeltQuoteBolt11Request struct { } type PostMeltQuoteBolt11Response struct { - Quote string `json:"quote"` - Amount uint64 `json:"amount"` - FeeReserve uint64 `json:"fee_reserve"` - State State `json:"state"` - Paid bool `json:"paid"` // DEPRECATED: use state instead - Expiry uint64 `json:"expiry"` - Preimage string `json:"payment_preimage,omitempty"` + Quote string `json:"quote"` + Amount uint64 `json:"amount"` + FeeReserve uint64 `json:"fee_reserve"` + State State `json:"state"` + Paid bool `json:"paid"` // DEPRECATED: use state instead + Expiry uint64 `json:"expiry"` + Preimage string `json:"payment_preimage,omitempty"` + Change cashu.BlindedSignatures `json:"change,omitempty"` } type PostMeltBolt11Request struct { - Quote string `json:"quote"` - Inputs cashu.Proofs `json:"inputs"` + Quote string `json:"quote"` + Inputs cashu.Proofs `json:"inputs"` + Outputs cashu.BlindedMessages `json:"outputs,omitempty"` } type TempQuote struct { - Quote string `json:"quote"` - Amount uint64 `json:"amount"` - FeeReserve uint64 `json:"fee_reserve"` - State string `json:"state"` - Paid bool `json:"paid"` // DEPRECATED: use state instead - Expiry uint64 `json:"expiry"` - Preimage string `json:"payment_preimage,omitempty"` + Quote string `json:"quote"` + Amount uint64 `json:"amount"` + FeeReserve uint64 `json:"fee_reserve"` + State string `json:"state"` + Paid bool `json:"paid"` // DEPRECATED: use state instead + Expiry uint64 `json:"expiry"` + Preimage string `json:"payment_preimage,omitempty"` + Change cashu.BlindedSignatures `json:"change,omitempty"` } func (quoteResponse *PostMeltQuoteBolt11Response) MarshalJSON() ([]byte, error) { @@ -82,6 +85,7 @@ func (quoteResponse *PostMeltQuoteBolt11Response) MarshalJSON() ([]byte, error) Paid: quoteResponse.Paid, Expiry: quoteResponse.Expiry, Preimage: quoteResponse.Preimage, + Change: quoteResponse.Change, } return json.Marshal(tempQuote) } @@ -101,6 +105,7 @@ func (quoteResponse *PostMeltQuoteBolt11Response) UnmarshalJSON(data []byte) err quoteResponse.Paid = tempQuote.Paid quoteResponse.Expiry = tempQuote.Expiry quoteResponse.Preimage = tempQuote.Preimage + quoteResponse.Change = tempQuote.Change return nil } diff --git a/wallet/wallet.go b/wallet/wallet.go index 05199a7..b5da8f0 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -362,12 +362,12 @@ func (w *Wallet) MintTokens(quoteId string) (cashu.Proofs, error) { return nil, err } // TODO: remove usage of 'Paid' field after mints have upgraded - if !mintQuote.Paid || mintQuote.State == nut04.Unpaid { - return nil, errors.New("invoice not paid") - } if mintQuote.State == nut04.Issued { return nil, errors.New("quote has already been issued") } + if !mintQuote.Paid || mintQuote.State == nut04.Unpaid { + return nil, errors.New("invoice not paid") + } invoice, err := w.GetInvoiceByPaymentRequest(mintQuote.Request) if err != nil { @@ -410,7 +410,7 @@ func (w *Wallet) MintTokens(quoteId string) (cashu.Proofs, error) { } // only increase counter if mint was successful - err = w.incrementKeysetCounter(activeKeyset.Id, uint32(len(blindedMessages))) + err = w.db.IncrementKeysetCounter(activeKeyset.Id, uint32(len(blindedMessages))) if err != nil { return nil, fmt.Errorf("error incrementing keyset counter: %v", err) } @@ -600,7 +600,7 @@ func (w *Wallet) swap(proofsToSwap cashu.Proofs, mintURL string) (cashu.Proofs, // only increment the counter if mint was from trusted list if trustedMint { - err = w.incrementKeysetCounter(activeSatKeyset.Id, uint32(len(outputs))) + err = w.db.IncrementKeysetCounter(activeSatKeyset.Id, uint32(len(outputs))) if err != nil { return nil, fmt.Errorf("error incrementing keyset counter: %v", err) } @@ -722,7 +722,22 @@ func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt1 return nil, err } - meltBolt11Request := nut05.PostMeltBolt11Request{Quote: meltQuoteResponse.Quote, Inputs: proofs} + activeKeyset, err := w.getActiveSatKeyset(w.currentMint.mintURL) + if err != nil { + return nil, fmt.Errorf("error getting active sat keyset: %v", err) + } + counter := w.counterForKeyset(activeKeyset.Id) + + // NUT-08 include blank outputs in request for overpaid lightning fees + numBlankOutputs := calculateBlankOutputs(meltQuoteResponse.FeeReserve) + split := make([]uint64, numBlankOutputs) + outputs, outputsSecrets, outputsRs, err := w.createBlindedMessages(split, activeKeyset.Id, &counter) + + meltBolt11Request := nut05.PostMeltBolt11Request{ + Quote: meltQuoteResponse.Quote, + Inputs: proofs, + Outputs: outputs, + } meltBolt11Response, err := PostMeltBolt11(mintURL, meltBolt11Request) if err != nil { w.saveProofs(proofs) @@ -742,10 +757,36 @@ func (w *Wallet) Melt(invoice string, mintURL string) (*nut05.PostMeltQuoteBolt1 meltBolt11Response.State = nut05.Unpaid } } + if !paid { // save proofs if invoice was not paid w.saveProofs(proofs) } else { + change := len(meltBolt11Response.Change) + // if mint provided blind signtures for any overpaid lightning fees: + // - unblind them and save the proofs in the db + // - increment keyset counter in db (by the number of blind sigs provided by mint) + if change > 0 { + changeProofs, err := constructProofs( + meltBolt11Response.Change, + outputs[:change], + outputsSecrets[:change], + outputsRs[:change], + activeKeyset, + ) + if err != nil { + return nil, fmt.Errorf("error unblinding signature from change: %v", err) + } + w.saveProofs(changeProofs) + + err = w.db.IncrementKeysetCounter(activeKeyset.Id, uint32(change)) + if err != nil { + return nil, fmt.Errorf("error incrementing keyset counter: %v", err) + } + + fmt.Printf("got %v in change\n", changeProofs.Amount()) + } + bolt11, err := decodepay.Decodepay(invoice) if err != nil { return nil, fmt.Errorf("error decoding bolt11 invoice: %v", err) @@ -985,7 +1026,7 @@ func (w *Wallet) swapToSend( // remaining proofs are change proofs to save to db w.saveProofs(proofsFromSwap) - err = w.incrementKeysetCounter(activeSatKeyset.Id, incrementCounterBy) + err = w.db.IncrementKeysetCounter(activeSatKeyset.Id, incrementCounterBy) if err != nil { return nil, fmt.Errorf("error incrementing keyset counter: %v", err) } @@ -1095,6 +1136,13 @@ func (w *Wallet) splitWalletTarget(amountToSplit uint64, mint string) []uint64 { return amounts } +func calculateBlankOutputs(feeReserve uint64) int { + if feeReserve == 0 { + return 0 + } + return int(math.Max(math.Ceil(math.Log2(float64(feeReserve))), 1)) +} + func (w *Wallet) fees(proofs cashu.Proofs, mint *walletMint) uint { var fees uint = 0 for _, proof := range proofs { @@ -1245,7 +1293,8 @@ func constructProofs( keyset *crypto.WalletKeyset, ) (cashu.Proofs, error) { - if len(blindedSignatures) != len(secrets) || len(blindedSignatures) != len(rs) { + sigsLenght := len(blindedSignatures) + if sigsLenght != len(secrets) || sigsLenght != len(rs) { return nil, errors.New("lengths do not match") } @@ -1311,14 +1360,6 @@ func unblindSignature(C_str string, r *secp256k1.PrivateKey, key *secp256k1.Publ return Cstr, nil } -func (w *Wallet) incrementKeysetCounter(keysetId string, num uint32) error { - err := w.db.IncrementKeysetCounter(keysetId, num) - if err != nil { - return err - } - return nil -} - // keyset passed should exist in wallet func (w *Wallet) counterForKeyset(keysetId string) uint32 { return w.db.GetKeysetCounter(keysetId) diff --git a/wallet/wallet_integration_test.go b/wallet/wallet_integration_test.go index b59d22f..67d9fcc 100644 --- a/wallet/wallet_integration_test.go +++ b/wallet/wallet_integration_test.go @@ -27,6 +27,7 @@ var ( lnd1 *btcdocker.Lnd lnd2 *btcdocker.Lnd dbMigrationPath = "../mint/storage/sqlite/migrations" + nutshellMint *testutils.NutshellMintContainer ) func TestMain(m *testing.M) { @@ -105,6 +106,12 @@ func testMain(m *testing.M) int { log.Fatal(mintWithFees.Start()) }() + nutshellMint, err = testutils.CreateNutshellMintContainer(ctx, 0) + if err != nil { + log.Fatalf("error starting nutshell mint: %v", err) + } + defer nutshellMint.Terminate(ctx) + return m.Run() } @@ -934,12 +941,76 @@ func TestNutshell(t *testing.T) { } } -func TestSendToPubkeyNutshell(t *testing.T) { - nutshellMint, err := testutils.CreateNutshellMintContainer(ctx, 0) +func TestOverpaidFeesChange(t *testing.T) { + nutshellURL := nutshellMint.Host + + testWalletPath := filepath.Join(".", "/nutshellfeeschange") + testWallet, err := testutils.CreateTestWallet(testWalletPath, nutshellURL) if err != nil { - t.Fatalf("error starting nutshell mint: %v", err) + t.Fatal(err) } - defer nutshellMint.Terminate(ctx) + defer func() { + os.RemoveAll(testWalletPath) + }() + + mintRes, err := testWallet.RequestMint(10000) + if err != nil { + t.Fatalf("unexpected error requesting mint: %v", err) + } + + _, err = testWallet.MintTokens(mintRes.Quote) + if err != nil { + t.Fatalf("unexpected error minting tokens: %v", err) + } + + var invoiceAmount int64 = 2000 + invoice := lnrpc.Invoice{Value: invoiceAmount} + addInvoiceResponse, err := lnd2.Client.AddInvoice(ctx, &invoice) + if err != nil { + t.Fatalf("error creating invoice: %v", err) + } + + balanceBeforeMelt := testWallet.GetBalance() + meltResponse, err := testWallet.Melt(addInvoiceResponse.PaymentRequest, nutshellURL) + if err != nil { + t.Fatalf("got unexpected melt error: %v", err) + } + change := len(meltResponse.Change) + if change < 1 { + t.Fatalf("expected change") + } + + // actual lightning fee paid + lightningFee := meltResponse.FeeReserve - meltResponse.Change.Amount() + expectedBalance := balanceBeforeMelt - uint64(invoiceAmount) - lightningFee + if testWallet.GetBalance() != expectedBalance { + t.Fatalf("expected balance of '%v' but got '%v' instead", expectedBalance, testWallet.GetBalance()) + } + + // do extra ops after melting to check counter for blinded messages + // was incremented correctly + mintRes, err = testWallet.RequestMint(5000) + if err != nil { + t.Fatalf("unexpected error requesting mint: %v", err) + } + _, err = testWallet.MintTokens(mintRes.Quote) + if err != nil { + t.Fatalf("unexpected error minting tokens: %v", err) + } + + var sendAmount uint64 = testWallet.GetBalance() + proofsToSend, err := testWallet.Send(sendAmount, nutshellURL, true) + if err != nil { + t.Fatalf("got unexpected error: %v", err) + } + token, _ := cashu.NewTokenV4(proofsToSend, nutshellURL, testutils.SAT_UNIT, false) + _, err = testWallet.Receive(token, false) + if err != nil { + t.Fatalf("unexpected error receiving: %v", err) + } +} + +func TestSendToPubkeyNutshell(t *testing.T) { nutshellURL := nutshellMint.Host nutshellMint2, err := testutils.CreateNutshellMintContainer(ctx, 0) @@ -970,11 +1041,6 @@ func TestSendToPubkeyNutshell(t *testing.T) { } func TestDLEQProofsNutshell(t *testing.T) { - nutshellMint, err := testutils.CreateNutshellMintContainer(ctx, 0) - if err != nil { - t.Fatalf("error starting nutshell mint: %v", err) - } - defer nutshellMint.Terminate(ctx) nutshellURL := nutshellMint.Host testWalletPath := filepath.Join(".", "/testwalletdleqnutshell") @@ -990,11 +1056,6 @@ func TestDLEQProofsNutshell(t *testing.T) { } func TestWalletRestoreNutshell(t *testing.T) { - nutshellMint, err := testutils.CreateNutshellMintContainer(ctx, 0) - if err != nil { - t.Fatalf("error starting nutshell mint: %v", err) - } - defer nutshellMint.Terminate(ctx) mintURL := nutshellMint.Host testWalletPath := filepath.Join(".", "/testrestorewalletnutshell")