diff --git a/nip60/helpers.go b/nip60/helpers.go index c941fd7..0cf5b67 100644 --- a/nip60/helpers.go +++ b/nip60/helpers.go @@ -115,16 +115,12 @@ func signOutput( // constructProofs unblinds the blindedSignatures and returns the proofs func constructProofs( + prep preparedOutputs, blindedSignatures cashu.BlindedSignatures, - blindedMessages cashu.BlindedMessages, - secrets []string, - rs []*secp256k1.PrivateKey, keys map[uint64]*btcec.PublicKey, ) (cashu.Proofs, error) { - sigsLenght := len(blindedSignatures) - if sigsLenght != len(secrets) || sigsLenght != len(rs) { - return nil, errors.New("lengths do not match") - } + // blinded sigs might be less than slices in prep, but that is fine, we just ignore the last + // items in prep. it happens when we are building proofs from change sent by a mint after melt. proofs := make(cashu.Proofs, len(blindedSignatures)) for i, blindedSignature := range blindedSignatures { @@ -139,7 +135,7 @@ func constructProofs( if !nut12.VerifyBlindSignatureDLEQ( *blindedSignature.DLEQ, pubkey, - blindedMessages[i].B_, + prep.bm[i].B_, blindedSignature.C_, ) { return nil, errors.New("got blinded signature with invalid DLEQ proof") @@ -147,19 +143,19 @@ func constructProofs( dleq = &cashu.DLEQProof{ E: blindedSignature.DLEQ.E, S: blindedSignature.DLEQ.S, - R: hex.EncodeToString(rs[i].Serialize()), + R: hex.EncodeToString(prep.rs[i].Serialize()), } } } - C, err := unblindSignature(blindedSignature.C_, rs[i], pubkey) + C, err := unblindSignature(blindedSignature.C_, prep.rs[i], pubkey) if err != nil { return nil, err } proof := cashu.Proof{ Amount: blindedSignature.Amount, - Secret: secrets[i], + Secret: prep.secrets[i], C: C, Id: blindedSignature.Id, DLEQ: dleq, diff --git a/nip60/lightning-swap.go b/nip60/lightning-swap.go index df1f249..11edad8 100644 --- a/nip60/lightning-swap.go +++ b/nip60/lightning-swap.go @@ -139,7 +139,11 @@ inspectmeltstatusresponse: return nil, fmt.Errorf("mint request to %s failed (%s): %w", to, mintQuote, err), manualActionRequired } - proofs, err = constructProofs(mintResponse.Signatures, blindedMessages, secrets, rs, keysetKeys) + proofs, err = constructProofs(preparedOutputs{ + bm: blindedMessages, + secrets: secrets, + rs: rs, + }, mintResponse.Signatures, keysetKeys) if err != nil { return nil, fmt.Errorf("error constructing proofs: %w", err), manualActionRequired } diff --git a/nip60/pay.go b/nip60/pay.go index 96b56f9..f611638 100644 --- a/nip60/pay.go +++ b/nip60/pay.go @@ -26,18 +26,27 @@ func (w *Wallet) PayBolt11(ctx context.Context, invoice string, opts ...SendOpti var chosen chosenTokens var meltQuote string - var meltAmount uint64 + var meltAmountWithoutFeeReserve uint64 feeReservePct := uint64(1) feeReserveAbs := uint64(1) + + excludeMints := make([]string, 0, 1) + for range 10 { amount := invoiceAmount*(100+feeReservePct)/100 + feeReserveAbs var fee uint64 - chosen, fee, err = w.getProofsForSending(ctx, amount, ss.specificMint) + chosen, fee, err = w.getProofsForSending(ctx, amount, ss.specificMint, excludeMints) if err != nil { return "", err } + // we will only do this in mints that support nut08 + if info, _ := client.GetMintInfo(ctx, chosen.mint); info == nil || !info.Nuts.Nut08.Supported { + excludeMints = append(excludeMints, chosen.mint) + continue + } + // 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, @@ -49,36 +58,45 @@ func (w *Wallet) PayBolt11(ctx context.Context, invoice string, opts ...SendOpti // if amount in proofs is not sufficient to pay for the melt request, // increase the amount and get proofs again (because of lighting fees) - meltQuote = meltResp.Quote - meltAmount = meltResp.Amount + meltResp.FeeReserve + fee - - if meltAmount > chosen.proofs.Amount() { + if meltResp.Amount+meltResp.FeeReserve+fee > chosen.proofs.Amount() { feeReserveAbs++ } else { + meltQuote = meltResp.Quote + meltAmountWithoutFeeReserve = invoiceAmount + fee goto meltworked } } - return "", fmt.Errorf("stop trying to do the melt because the invoice is too expensive") + return "", fmt.Errorf("stopped trying to do the melt because all the mints are charging way too much") 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) + activeKeyset, err := client.GetActiveKeyset(ctx, chosen.mint) if err != nil { - return "", fmt.Errorf("failed to swap at %s into the exact melt amount: %w", chosen.mint, err) + return "", fmt.Errorf("failed to get active keyset for %s: %w", chosen.mint, err) + } + ksKeys, err := parseKeysetKeys(activeKeyset.Keys) + if err != nil { + return "", fmt.Errorf("failed to parse keys for %s: %w", chosen.mint, err) } - if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, change, chosen.tokenIndexes); err != nil { - return "", err - } + // since we rely on nut08 we will send all the proofs we've gathered and expect a change + // we do a split here and discard the principal, as we won't get it back from the mint + _, preChange, err := splitIntoPrincipalAndChange( + chosen.keysets, + chosen.proofs, + meltAmountWithoutFeeReserve, + activeKeyset.Id, + nil, + ) // 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, + Quote: meltQuote, + Inputs: chosen.proofs, + Outputs: preChange.bm, }) inspectmeltstatusresponse: if err != nil || meltStatus.State == nut05.Unpaid { @@ -94,5 +112,15 @@ inspectmeltstatusresponse: } } + // the invoice has been paid, now we save the change we got + change, err := constructProofs(preChange, meltStatus.Change, ksKeys) + if err != nil { + return "", fmt.Errorf("failed to construct principal proofs: %w", err) + } + + if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, change, chosen.tokenIndexes); err != nil { + return "", err + } + return meltStatus.Preimage, nil } diff --git a/nip60/send.go b/nip60/send.go index 1459e00..1dec340 100644 --- a/nip60/send.go +++ b/nip60/send.go @@ -59,7 +59,7 @@ func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOptio w.tokensMu.Lock() defer w.tokensMu.Unlock() - chosen, _, err := w.getProofsForSending(ctx, amount, ss.specificMint) + chosen, _, err := w.getProofsForSending(ctx, amount, ss.specificMint, nil) if err != nil { return "", err } @@ -138,7 +138,7 @@ func (w *Wallet) saveChangeAndDeleteUsedTokens( deleteEvent := nostr.Event{ CreatedAt: nostr.Now(), Kind: 5, - Tags: nostr.Tags{{"e", token.event.ID}, {"k", "7375"}}, + Tags: nostr.Tags{{"e", token.event.ID}, {"k", "7375"}, {"alt", "deleting"}}, } w.wl.kr.SignEvent(ctx, &deleteEvent) w.wl.Changes <- deleteEvent @@ -163,12 +163,16 @@ func (w *Wallet) getProofsForSending( ctx context.Context, amount uint64, specificMint string, + excludeMints []string, ) (chosenTokens, uint64, error) { byMint := make(map[string]chosenTokens) for t, token := range w.Tokens { if specificMint != "" && token.Mint != specificMint { continue } + if slices.Contains(excludeMints, token.Mint) { + continue + } part, ok := byMint[token.Mint] if !ok { diff --git a/nip60/swap.go b/nip60/swap.go index e3135ea..0e0519d 100644 --- a/nip60/swap.go +++ b/nip60/swap.go @@ -3,8 +3,11 @@ package nip60 import ( "context" "fmt" + "slices" + "github.com/btcsuite/btcd/btcec/v2" "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut02" "github.com/elnosh/gonuts/cashu/nuts/nut03" "github.com/elnosh/gonuts/cashu/nuts/nut10" "github.com/nbd-wtf/go-nostr/nip60/client" @@ -41,7 +44,6 @@ func (w *Wallet) SwapProofs( opt(&ss) } - // fetch all this keyset drama first keysets, err := client.GetAllKeysets(ctx, mint) if err != nil { return nil, nil, fmt.Errorf("failed to get all keysets for %s: %w", mint, err) @@ -55,6 +57,63 @@ func (w *Wallet) SwapProofs( return nil, nil, fmt.Errorf("failed to parse keys for %s: %w", mint, err) } + prePrincipal, preChange, err := splitIntoPrincipalAndChange( + keysets, + proofs, + targetAmount, + activeKeyset.Id, + ss.spendingCondition, + ) + if err != nil { + return nil, nil, err + } + + if ss.mustSignOutputs { + for i, output := range prePrincipal.bm { + prePrincipal.bm[i].Witness, err = signOutput(w.PrivateKey, output) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign output message %d: %w", i, err) + } + } + } + + req := nut03.PostSwapRequest{ + Inputs: proofs, + Outputs: slices.Concat(prePrincipal.bm, preChange.bm), + } + + res, err := client.PostSwap(ctx, mint, req) + if err != nil { + return nil, nil, fmt.Errorf("failed to swap tokens at %s: %w", mint, err) + } + + // build the proofs locally from mint's response + principal, err = constructProofs(prePrincipal, res.Signatures[0:len(prePrincipal.bm)], ksKeys) + if err != nil { + return nil, nil, fmt.Errorf("failed to construct principal proofs: %w", err) + } + + change, err = constructProofs(preChange, res.Signatures[len(prePrincipal.bm):], ksKeys) + if err != nil { + return nil, nil, fmt.Errorf("failed to construct principal proofs: %w", err) + } + + return principal, change, nil +} + +type preparedOutputs struct { + bm cashu.BlindedMessages + rs []*btcec.PrivateKey + secrets []string +} + +func splitIntoPrincipalAndChange( + keysets []nut02.Keyset, + proofs cashu.Proofs, + targetAmount uint64, + activeKeysetId string, + spendingCondition *nut10.SpendingCondition, +) (principal preparedOutputs, change preparedOutputs, err error) { // decide the shape of the proofs we'll swap for proofsAmount := proofs.Amount() var ( @@ -71,8 +130,8 @@ func (w *Wallet) SwapProofs( principalAmount = targetAmount - fee changeAmount = 0 } else { - return nil, nil, fmt.Errorf("can't swap for more than we are sending: %d > %d", - targetAmount, proofsAmount) + err = fmt.Errorf("can't swap for more than we are sending: %d > %d", targetAmount, proofsAmount) + return } splits := make([]uint64, 0, len(proofs)*2) splits = append(splits, cashu.AmountSplit(principalAmount)...) @@ -80,35 +139,19 @@ func (w *Wallet) SwapProofs( splits = append(splits, cashu.AmountSplit(changeAmount)...) // prepare message to send to mint - outputs, secrets, rs, err := createBlindedMessages(splits, activeKeyset.Id, ss.spendingCondition) + bm, secrets, rs, err := createBlindedMessages(splits, activeKeysetId, spendingCondition) if err != nil { - return nil, nil, fmt.Errorf("failed to create blinded message: %w", err) + err = fmt.Errorf("failed to create blinded message: %w", err) + return } - if ss.mustSignOutputs { - for i, output := range outputs { - outputs[i].Witness, err = signOutput(w.PrivateKey, output) - if err != nil { - return nil, nil, fmt.Errorf("failed to sign output message %d: %w", i, err) - } - } - } - - req := nut03.PostSwapRequest{ - Inputs: proofs, - Outputs: outputs, - } - - res, err := client.PostSwap(ctx, mint, req) - if err != nil { - return nil, nil, fmt.Errorf("failed to swap tokens at %s: %w", mint, err) - } - - // build the proofs locally from mint's response - newProofs, err := constructProofs(res.Signatures, req.Outputs, secrets, rs, ksKeys) - if err != nil { - return nil, nil, fmt.Errorf("failed to construct proofs: %w", err) - } - - return newProofs[0:changeStartIndex], newProofs[changeStartIndex:], nil + return preparedOutputs{ + bm: bm[0:changeStartIndex], + rs: rs[0:changeStartIndex], + secrets: secrets[0:changeStartIndex], + }, preparedOutputs{ + bm: bm[changeStartIndex:], + rs: rs[changeStartIndex:], + secrets: secrets[changeStartIndex:], + }, nil }