diff --git a/nip60/lightning-swap.go b/nip60/lightning-swap.go index baac173..df1f249 100644 --- a/nip60/lightning-swap.go +++ b/nip60/lightning-swap.go @@ -42,9 +42,9 @@ func lightningMeltMint( } // now we start the melt-mint process in multiple attempts - invoicePct := 0.99 + invoicePct := uint64(99) proofsAmount := proofs.Amount() - amount := float64(proofsAmount) * invoicePct + amount := proofsAmount * invoicePct / 100 fee := uint64(calculateFee(proofs, fromKeysets)) var meltQuote string var mintQuote string @@ -65,14 +65,14 @@ func lightningMeltMint( Unit: cashu.Sat.String(), }) if err != nil { - return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), nothingCanBeDone + return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), storeTokenFromSourceMint } // if amount in proofs is less than amount asked from mint in melt request, - // lower the amount for mint request (because of lighting fees?) + // lower the amount for mint request (because of lighting fees) if meltResp.Amount+meltResp.FeeReserve+fee > proofsAmount { - invoicePct -= 0.01 - amount *= invoicePct + invoicePct-- + amount = proofsAmount * invoicePct / 100 } else { meltQuote = meltResp.Quote mintQuote = mintResp.Quote @@ -124,7 +124,7 @@ inspectmeltstatusresponse: } // if it got paid make proceed to get proofs - split := []uint64{1, 2, 3, 4} + split := cashu.AmountSplit(amount) blindedMessages, secrets, rs, err := createBlindedMessages(split, keyset.Id, nil) if err != nil { return nil, fmt.Errorf("error creating blinded messages: %v", err), manualActionRequired diff --git a/nip60/pay.go b/nip60/pay.go new file mode 100644 index 0000000..aad51bc --- /dev/null +++ b/nip60/pay.go @@ -0,0 +1,96 @@ +package nip60 + +import ( + "context" + "fmt" + "time" + + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut05" + "github.com/nbd-wtf/go-nostr/nip60/client" +) + +func (w *Wallet) PayBolt11(ctx context.Context, invoice string, opts ...SendOption) (string, error) { + ss := &sendSettings{} + for _, opt := range opts { + opt(ss) + } + + invoiceAmount, err := getSatoshisAmountFromBolt11(invoice) + if err != nil { + return "", err + } + + w.tokensMu.Lock() + defer w.tokensMu.Unlock() + + var chosen chosenTokens + var meltQuote string + var meltAmount uint64 + + invoicePct := uint64(99) + for range 10 { + amount := invoiceAmount * invoicePct / 100 + var fee uint64 + chosen, fee, err = w.getProofsForSending(ctx, amount, ss.specificMint) + if err != nil { + return "", err + } + + // request _melt_ quote (ask the mint how much will it cost to pay a bolt11 invoice) + meltResp, err := client.PostMeltQuoteBolt11(ctx, chosen.mint, nut05.PostMeltQuoteBolt11Request{ + Request: invoice, + Unit: cashu.Sat.String(), + }) + if err != nil { + return "", fmt.Errorf("error requesting melt quote from %s: %w", chosen.mint, err) + } + + // if amount in proofs is not sufficient to pay for the melt request, + // increase the amount and get proofs again (because of lighting fees) + if meltResp.Amount+meltResp.FeeReserve+fee > chosen.proofs.Amount() { + invoicePct-- + } else { + meltQuote = meltResp.Quote + meltAmount = meltResp.Amount + goto meltworked + } + } + + return "", fmt.Errorf("stop trying to do the melt because the mint part is too expensive") + +meltworked: + // swap our proofs so we get the exact amount for paying the invoice + principal, change, err := w.SwapProofs(ctx, chosen.mint, chosen.proofs, meltAmount) + if err != nil { + return "", fmt.Errorf("failed to swap at %s into the exact melt amount: %w", chosen.mint, err) + } + + if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, change, chosen.tokenIndexes); err != nil { + return "", err + } + + // request from mint to _melt_ into paying the invoice + delay := 200 * time.Millisecond + // this request will block until the invoice is paid or it fails + // (but the API also says it can return "pending" so we handle both) + meltStatus, err := client.PostMeltBolt11(ctx, chosen.mint, nut05.PostMeltBolt11Request{ + Quote: meltQuote, + Inputs: principal, + }) +inspectmeltstatusresponse: + if err != nil || meltStatus.State == nut05.Unpaid { + return "", fmt.Errorf("error melting token: %w", err) + } else if meltStatus.State == nut05.Unknown { + return "", fmt.Errorf("we don't know what happened with the melt at %s: %v", chosen.mint, meltStatus) + } else if meltStatus.State == nut05.Pending { + for { + time.Sleep(delay) + delay *= 2 + meltStatus, err = client.GetMeltQuoteState(ctx, chosen.mint, meltStatus.Quote) + goto inspectmeltstatusresponse + } + } + + return meltStatus.Preimage, nil +} diff --git a/nip60/send.go b/nip60/send.go index f8d778f..bb7f2d0 100644 --- a/nip60/send.go +++ b/nip60/send.go @@ -42,6 +42,14 @@ func WithMint(url string) SendOption { } } +type chosenTokens struct { + mint string + tokens []Token + tokenIndexes []int + proofs cashu.Proofs + keysets []nut02.Keyset +} + func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOption) (string, error) { ss := &sendSettings{} for _, opt := range opts { @@ -51,56 +59,15 @@ func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOptio w.tokensMu.Lock() defer w.tokensMu.Unlock() - type part struct { - mint string - tokens []Token - tokenIndexes []int - proofs cashu.Proofs - keysets []nut02.Keyset + chosen, _, err := w.getProofsForSending(ctx, amount, ss.specificMint) + if err != nil { + return "", err } - var target part - byMint := make(map[string]part) - for t, token := range w.Tokens { - if ss.specificMint != "" && token.Mint != ss.specificMint { - continue - } - - part, ok := byMint[token.Mint] - if !ok { - keysets, err := client.GetAllKeysets(ctx, token.Mint) - if err != nil { - return "", fmt.Errorf("failed to get %s keysets: %w", token.Mint, err) - } - part.keysets = keysets - part.tokens = make([]Token, 0, 3) - part.tokenIndexes = make([]int, 0, 3) - part.proofs = make(cashu.Proofs, 0, 7) - part.mint = token.Mint - } - - part.tokens = append(part.tokens, token) - part.tokenIndexes = append(part.tokenIndexes, t) - part.proofs = append(part.proofs, token.Proofs...) - if part.proofs.Amount() >= amount { - // maybe we found it here - fee := calculateFee(part.proofs, part.keysets) - if part.proofs.Amount() >= (amount + fee) { - // yes, we did - target = part - goto found - } - } - } - - // if we got here it's because we didn't get enough proofs from the same mint - return "", fmt.Errorf("not enough proofs found from the same mint") - -found: swapOpts := make([]SwapOption, 0, 2) if ss.p2pk != nil { - if info, err := client.GetMintInfo(ctx, target.mint); err != nil || !info.Nuts.Nut11.Supported { + if info, err := client.GetMintInfo(ctx, chosen.mint); err != nil || !info.Nuts.Nut11.Supported { return "", fmt.Errorf("mint doesn't support p2pk: %w", err) } @@ -124,24 +91,47 @@ found: } // get new proofs - proofsToSend, changeProofs, err := w.SwapProofs(ctx, target.mint, target.proofs, amount, swapOpts...) + proofsToSend, changeProofs, err := w.SwapProofs(ctx, chosen.mint, chosen.proofs, amount, swapOpts...) if err != nil { return "", err } + if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes); err != nil { + return "", err + } + + // serialize token we're sending out + token, err := cashu.NewTokenV4(proofsToSend, chosen.mint, cashu.Sat, true) + if err != nil { + return "", err + } + + wevt := nostr.Event{} + w.toEvent(ctx, w.wl.kr, &wevt) + w.wl.Changes <- wevt + + return token.Serialize() +} + +func (w *Wallet) saveChangeAndDeleteUsedTokens( + ctx context.Context, + mintURL string, + changeProofs cashu.Proofs, + usedTokenIndexes []int, +) error { // delete spent tokens and save our change updatedTokens := make([]Token, 0, len(w.Tokens)) changeToken := Token{ mintedAt: nostr.Now(), - Mint: target.mint, + Mint: mintURL, Proofs: changeProofs, - Deleted: make([]string, 0, len(target.tokenIndexes)), + Deleted: make([]string, 0, len(usedTokenIndexes)), event: &nostr.Event{}, } for i, token := range w.Tokens { - if slices.Contains(target.tokenIndexes, i) { + if slices.Contains(usedTokenIndexes, i) { if token.event != nil { token.Deleted = append(token.Deleted, token.event.ID) @@ -160,21 +150,51 @@ found: if len(changeToken.Proofs) > 0 { if err := changeToken.toEvent(ctx, w.wl.kr, w.Identifier, changeToken.event); err != nil { - return "", fmt.Errorf("failed to make change token: %w", err) + return fmt.Errorf("failed to make change token: %w", err) } w.wl.Changes <- *changeToken.event w.Tokens = append(updatedTokens, changeToken) } - // serialize token we're sending out - token, err := cashu.NewTokenV4(proofsToSend, target.mint, cashu.Sat, true) - if err != nil { - return "", err + return nil +} + +func (w *Wallet) getProofsForSending( + ctx context.Context, + amount uint64, + specificMint string, +) (chosenTokens, uint64, error) { + byMint := make(map[string]chosenTokens) + for t, token := range w.Tokens { + if specificMint != "" && token.Mint != specificMint { + continue + } + + part, ok := byMint[token.Mint] + if !ok { + keysets, err := client.GetAllKeysets(ctx, token.Mint) + if err != nil { + return chosenTokens{}, 0, fmt.Errorf("failed to get %s keysets: %w", token.Mint, err) + } + part.keysets = keysets + part.tokens = make([]Token, 0, 3) + part.tokenIndexes = make([]int, 0, 3) + part.proofs = make(cashu.Proofs, 0, 7) + } + + part.tokens = append(part.tokens, token) + part.tokenIndexes = append(part.tokenIndexes, t) + part.proofs = append(part.proofs, token.Proofs...) + if part.proofs.Amount() >= amount { + // maybe we found it here + fee := calculateFee(part.proofs, part.keysets) + if part.proofs.Amount() >= (amount + fee) { + // yes, we did + return part, fee, nil + } + } } - wevt := nostr.Event{} - w.toEvent(ctx, w.wl.kr, &wevt) - w.wl.Changes <- wevt - - return token.Serialize() + // if we got here it's because we didn't get enough proofs from the same mint + return chosenTokens{}, 0, fmt.Errorf("not enough proofs found from the same mint") } diff --git a/nip60/stash.go b/nip60/stash.go index 8d35490..0d4c4a2 100644 --- a/nip60/stash.go +++ b/nip60/stash.go @@ -37,18 +37,42 @@ func LoadStash( kr nostr.Keyer, pool *nostr.SimplePool, relays []string, +) *WalletStash { + return loadStashFromPool(ctx, kr, pool, relays, false) +} + +func LoadStashWithHistory( + ctx context.Context, + kr nostr.Keyer, + pool *nostr.SimplePool, + relays []string, +) *WalletStash { + return loadStashFromPool(ctx, kr, pool, relays, true) +} + +func loadStashFromPool( + ctx context.Context, + kr nostr.Keyer, + pool *nostr.SimplePool, + relays []string, + withHistory bool, ) *WalletStash { pk, err := kr.GetPublicKey(ctx) if err != nil { return nil } + kinds := []int{37375, 7375} + if withHistory { + kinds = append(kinds, 7375) + } + eoseChan := make(chan struct{}) events := pool.SubManyNotifyEOSE( ctx, relays, nostr.Filters{ - {Kinds: []int{37375, 7375, 7376}, Authors: []string{pk}}, + {Kinds: kinds, Authors: []string{pk}}, {Kinds: []int{5}, Tags: nostr.TagMap{"k": []string{"7375"}}, Authors: []string{pk}}, }, eoseChan,