nip60: just rely on nut08 overpaid fees to get change when melting, don't try to swap beforehand.

This commit is contained in:
fiatjaf
2025-01-30 09:45:00 -03:00
parent be65134354
commit f0054af4d8
5 changed files with 135 additions and 60 deletions

View File

@ -115,16 +115,12 @@ func signOutput(
// constructProofs unblinds the blindedSignatures and returns the proofs // constructProofs unblinds the blindedSignatures and returns the proofs
func constructProofs( func constructProofs(
prep preparedOutputs,
blindedSignatures cashu.BlindedSignatures, blindedSignatures cashu.BlindedSignatures,
blindedMessages cashu.BlindedMessages,
secrets []string,
rs []*secp256k1.PrivateKey,
keys map[uint64]*btcec.PublicKey, keys map[uint64]*btcec.PublicKey,
) (cashu.Proofs, error) { ) (cashu.Proofs, error) {
sigsLenght := len(blindedSignatures) // blinded sigs might be less than slices in prep, but that is fine, we just ignore the last
if sigsLenght != len(secrets) || sigsLenght != len(rs) { // items in prep. it happens when we are building proofs from change sent by a mint after melt.
return nil, errors.New("lengths do not match")
}
proofs := make(cashu.Proofs, len(blindedSignatures)) proofs := make(cashu.Proofs, len(blindedSignatures))
for i, blindedSignature := range blindedSignatures { for i, blindedSignature := range blindedSignatures {
@ -139,7 +135,7 @@ func constructProofs(
if !nut12.VerifyBlindSignatureDLEQ( if !nut12.VerifyBlindSignatureDLEQ(
*blindedSignature.DLEQ, *blindedSignature.DLEQ,
pubkey, pubkey,
blindedMessages[i].B_, prep.bm[i].B_,
blindedSignature.C_, blindedSignature.C_,
) { ) {
return nil, errors.New("got blinded signature with invalid DLEQ proof") return nil, errors.New("got blinded signature with invalid DLEQ proof")
@ -147,19 +143,19 @@ func constructProofs(
dleq = &cashu.DLEQProof{ dleq = &cashu.DLEQProof{
E: blindedSignature.DLEQ.E, E: blindedSignature.DLEQ.E,
S: blindedSignature.DLEQ.S, 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 { if err != nil {
return nil, err return nil, err
} }
proof := cashu.Proof{ proof := cashu.Proof{
Amount: blindedSignature.Amount, Amount: blindedSignature.Amount,
Secret: secrets[i], Secret: prep.secrets[i],
C: C, C: C,
Id: blindedSignature.Id, Id: blindedSignature.Id,
DLEQ: dleq, DLEQ: dleq,

View File

@ -139,7 +139,11 @@ inspectmeltstatusresponse:
return nil, fmt.Errorf("mint request to %s failed (%s): %w", to, mintQuote, err), manualActionRequired 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 { if err != nil {
return nil, fmt.Errorf("error constructing proofs: %w", err), manualActionRequired return nil, fmt.Errorf("error constructing proofs: %w", err), manualActionRequired
} }

View File

@ -26,18 +26,27 @@ func (w *Wallet) PayBolt11(ctx context.Context, invoice string, opts ...SendOpti
var chosen chosenTokens var chosen chosenTokens
var meltQuote string var meltQuote string
var meltAmount uint64 var meltAmountWithoutFeeReserve uint64
feeReservePct := uint64(1) feeReservePct := uint64(1)
feeReserveAbs := uint64(1) feeReserveAbs := uint64(1)
excludeMints := make([]string, 0, 1)
for range 10 { for range 10 {
amount := invoiceAmount*(100+feeReservePct)/100 + feeReserveAbs amount := invoiceAmount*(100+feeReservePct)/100 + feeReserveAbs
var fee uint64 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 { if err != nil {
return "", err 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) // 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{ meltResp, err := client.PostMeltQuoteBolt11(ctx, chosen.mint, nut05.PostMeltQuoteBolt11Request{
Request: invoice, 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, // if amount in proofs is not sufficient to pay for the melt request,
// increase the amount and get proofs again (because of lighting fees) // increase the amount and get proofs again (because of lighting fees)
meltQuote = meltResp.Quote if meltResp.Amount+meltResp.FeeReserve+fee > chosen.proofs.Amount() {
meltAmount = meltResp.Amount + meltResp.FeeReserve + fee
if meltAmount > chosen.proofs.Amount() {
feeReserveAbs++ feeReserveAbs++
} else { } else {
meltQuote = meltResp.Quote
meltAmountWithoutFeeReserve = invoiceAmount + fee
goto meltworked 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: meltworked:
// swap our proofs so we get the exact amount for paying the invoice activeKeyset, err := client.GetActiveKeyset(ctx, chosen.mint)
principal, change, err := w.SwapProofs(ctx, chosen.mint, chosen.proofs, meltAmount)
if err != nil { 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 { // since we rely on nut08 we will send all the proofs we've gathered and expect a change
return "", err // 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 // request from mint to _melt_ into paying the invoice
delay := 200 * time.Millisecond delay := 200 * time.Millisecond
// this request will block until the invoice is paid or it fails // 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) // (but the API also says it can return "pending" so we handle both)
meltStatus, err := client.PostMeltBolt11(ctx, chosen.mint, nut05.PostMeltBolt11Request{ meltStatus, err := client.PostMeltBolt11(ctx, chosen.mint, nut05.PostMeltBolt11Request{
Quote: meltQuote, Quote: meltQuote,
Inputs: principal, Inputs: chosen.proofs,
Outputs: preChange.bm,
}) })
inspectmeltstatusresponse: inspectmeltstatusresponse:
if err != nil || meltStatus.State == nut05.Unpaid { 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 return meltStatus.Preimage, nil
} }

View File

@ -59,7 +59,7 @@ func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOptio
w.tokensMu.Lock() w.tokensMu.Lock()
defer w.tokensMu.Unlock() defer w.tokensMu.Unlock()
chosen, _, err := w.getProofsForSending(ctx, amount, ss.specificMint) chosen, _, err := w.getProofsForSending(ctx, amount, ss.specificMint, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -138,7 +138,7 @@ func (w *Wallet) saveChangeAndDeleteUsedTokens(
deleteEvent := nostr.Event{ deleteEvent := nostr.Event{
CreatedAt: nostr.Now(), CreatedAt: nostr.Now(),
Kind: 5, 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.kr.SignEvent(ctx, &deleteEvent)
w.wl.Changes <- deleteEvent w.wl.Changes <- deleteEvent
@ -163,12 +163,16 @@ func (w *Wallet) getProofsForSending(
ctx context.Context, ctx context.Context,
amount uint64, amount uint64,
specificMint string, specificMint string,
excludeMints []string,
) (chosenTokens, uint64, error) { ) (chosenTokens, uint64, error) {
byMint := make(map[string]chosenTokens) byMint := make(map[string]chosenTokens)
for t, token := range w.Tokens { for t, token := range w.Tokens {
if specificMint != "" && token.Mint != specificMint { if specificMint != "" && token.Mint != specificMint {
continue continue
} }
if slices.Contains(excludeMints, token.Mint) {
continue
}
part, ok := byMint[token.Mint] part, ok := byMint[token.Mint]
if !ok { if !ok {

View File

@ -3,8 +3,11 @@ package nip60
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/elnosh/gonuts/cashu" "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/nut03"
"github.com/elnosh/gonuts/cashu/nuts/nut10" "github.com/elnosh/gonuts/cashu/nuts/nut10"
"github.com/nbd-wtf/go-nostr/nip60/client" "github.com/nbd-wtf/go-nostr/nip60/client"
@ -41,7 +44,6 @@ func (w *Wallet) SwapProofs(
opt(&ss) opt(&ss)
} }
// fetch all this keyset drama first
keysets, err := client.GetAllKeysets(ctx, mint) keysets, err := client.GetAllKeysets(ctx, mint)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to get all keysets for %s: %w", mint, err) 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) 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 // decide the shape of the proofs we'll swap for
proofsAmount := proofs.Amount() proofsAmount := proofs.Amount()
var ( var (
@ -71,8 +130,8 @@ func (w *Wallet) SwapProofs(
principalAmount = targetAmount - fee principalAmount = targetAmount - fee
changeAmount = 0 changeAmount = 0
} else { } else {
return nil, nil, fmt.Errorf("can't swap for more than we are sending: %d > %d", err = fmt.Errorf("can't swap for more than we are sending: %d > %d", targetAmount, proofsAmount)
targetAmount, proofsAmount) return
} }
splits := make([]uint64, 0, len(proofs)*2) splits := make([]uint64, 0, len(proofs)*2)
splits = append(splits, cashu.AmountSplit(principalAmount)...) splits = append(splits, cashu.AmountSplit(principalAmount)...)
@ -80,35 +139,19 @@ func (w *Wallet) SwapProofs(
splits = append(splits, cashu.AmountSplit(changeAmount)...) splits = append(splits, cashu.AmountSplit(changeAmount)...)
// prepare message to send to mint // 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 { 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 { return preparedOutputs{
for i, output := range outputs { bm: bm[0:changeStartIndex],
outputs[i].Witness, err = signOutput(w.PrivateKey, output) rs: rs[0:changeStartIndex],
if err != nil { secrets: secrets[0:changeStartIndex],
return nil, nil, fmt.Errorf("failed to sign output message %d: %w", i, err) }, preparedOutputs{
} bm: bm[changeStartIndex:],
} rs: rs[changeStartIndex:],
} secrets: secrets[changeStartIndex:],
}, nil
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
} }