Skip to content

Commit

Permalink
wallet - support nut-08 for overpaid lightning fees
Browse files Browse the repository at this point in the history
  • Loading branch information
elnosh committed Oct 8, 2024
1 parent 3d9659e commit a145278
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 46 deletions.
8 changes: 8 additions & 0 deletions cashu/cashu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
37 changes: 21 additions & 16 deletions cashu/nuts/nut05/nut05.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand All @@ -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
}
73 changes: 57 additions & 16 deletions wallet/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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)
Expand Down
89 changes: 75 additions & 14 deletions wallet/wallet_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var (
lnd1 *btcdocker.Lnd
lnd2 *btcdocker.Lnd
dbMigrationPath = "../mint/storage/sqlite/migrations"
nutshellMint *testutils.NutshellMintContainer
)

func TestMain(m *testing.M) {
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down

0 comments on commit a145278

Please sign in to comment.