From 794561a3b339f5515c61c426eda97510c582c6bf Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 28 Jan 2025 23:28:13 -0300 Subject: [PATCH] nip60: slight improvement to lightning melt-mint flow. --- nip60/lightning-swap.go | 124 ++++++++++++++++++++++------------------ nip60/receive.go | 9 ++- nip60/send.go | 11 ++-- nip60/token.go | 8 +++ 4 files changed, 89 insertions(+), 63 deletions(-) diff --git a/nip60/lightning-swap.go b/nip60/lightning-swap.go index b3098c4..baac173 100644 --- a/nip60/lightning-swap.go +++ b/nip60/lightning-swap.go @@ -12,6 +12,15 @@ import ( "github.com/nbd-wtf/go-nostr/nip60/client" ) +type lightningSwapStatus int + +const ( + nothingCanBeDone = iota + tryAnotherTargetMint + storeTokenFromSourceMint + manualActionRequired +) + // lightningMeltMint does the lightning dance of moving funds between mints func lightningMeltMint( ctx context.Context, @@ -19,17 +28,17 @@ func lightningMeltMint( from string, fromKeysets []nut02.Keyset, to string, -) (newProofs cashu.Proofs, err error, canTryWithAnotherTargetMint bool, manualActionRequired bool) { +) (cashu.Proofs, error, lightningSwapStatus) { // get active keyset of target mint keyset, err := client.GetActiveKeyset(ctx, to) if err != nil { - return nil, fmt.Errorf("failed to get keyset keys for %s: %w", to, err), true, false + return nil, fmt.Errorf("failed to get keyset keys for %s: %w", to, err), tryAnotherTargetMint } // unblind the signatures from the promises and build the proofs keysetKeys, err := parseKeysetKeys(keyset.Keys) if err != nil { - return nil, fmt.Errorf("target mint %s sent us an invalid keyset: %w", to, err), true, false + return nil, fmt.Errorf("target mint %s sent us an invalid keyset: %w", to, err), tryAnotherTargetMint } // now we start the melt-mint process in multiple attempts @@ -46,7 +55,7 @@ func lightningMeltMint( Unit: cashu.Sat.String(), }) if err != nil { - return nil, fmt.Errorf("error requesting mint quote from %s: %w", to, err), true, false + return nil, fmt.Errorf("error requesting mint quote from %s: %w", to, err), tryAnotherTargetMint } // request _melt_ quote from the 'from' mint @@ -56,7 +65,7 @@ func lightningMeltMint( Unit: cashu.Sat.String(), }) if err != nil { - return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), false, false + return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), nothingCanBeDone } // if amount in proofs is less than amount asked from mint in melt request, @@ -71,64 +80,69 @@ func lightningMeltMint( } } - return nil, fmt.Errorf("stop trying to do the melt because the mint part is too expensive"), true, false + return nil, fmt.Errorf("stop trying to do the melt because the mint part is too expensive"), tryAnotherTargetMint meltworked: - // request from mint to pay invoice from the mint quote request - _, err = client.PostMeltBolt11(ctx, from, nut05.PostMeltBolt11Request{ + // 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, from, nut05.PostMeltBolt11Request{ Quote: meltQuote, Inputs: proofs, }) +inspectmeltstatusresponse: + if err != nil || meltStatus.State == nut05.Unpaid { + return nil, fmt.Errorf("error melting token: %w", err), storeTokenFromSourceMint + } else if meltStatus.State == nut05.Unknown { + return nil, + fmt.Errorf("we don't know what happened with the melt at %s: %v", from, meltStatus), + manualActionRequired + } else if meltStatus.State == nut05.Pending { + for { + time.Sleep(delay) + delay *= 2 + meltStatus, err = client.GetMeltQuoteState(ctx, from, meltStatus.Quote) + goto inspectmeltstatusresponse + } + } + + // source mint says it has paid the invoice, now check it against the target mint + // check if the _mint_ invoice was paid + mintQuoteStatusResp, err := client.GetMintQuoteState(ctx, to, mintQuote) if err != nil { - return nil, fmt.Errorf("error melting token: %v", err), false, true + return nil, fmt.Errorf( + "target mint %s failed to answer to our mint quote checks (%s): %w; a manual fix is needed", + to, meltQuote, err, + ), manualActionRequired + } + if mintQuoteStatusResp.State != nut04.Paid { + return nil, fmt.Errorf( + "target mint %s says the invoice wasn't paid although the source mint %s said it did, %s -> %s", + to, from, meltQuote, mintQuote, + ), manualActionRequired } - sleepTime := time.Millisecond * 200 - failures := 0 - for range 12 { - sleepTime *= 2 - time.Sleep(sleepTime) - - // check if the _mint_ invoice was paid - mintQuoteStatusResp, err := client.GetMintQuoteState(ctx, to, mintQuote) - if err != nil { - failures++ - if failures > 10 { - return nil, fmt.Errorf( - "target mint %s failed to answer to our mint quote checks (%s): %w; a manual fix is needed", - to, meltQuote, err, - ), false, true - } - } - - // if it wasn't paid try again - if mintQuoteStatusResp.State != nut04.Paid { - continue - } - - // if it got paid make proceed to get proofs - split := []uint64{1, 2, 3, 4} - blindedMessages, secrets, rs, err := createBlindedMessages(split, keyset.Id, nil) - if err != nil { - return nil, fmt.Errorf("error creating blinded messages: %v", err), false, true - } - - // request mint to sign the blinded messages - mintResponse, err := client.PostMintBolt11(ctx, to, nut04.PostMintBolt11Request{ - Quote: mintQuote, - Outputs: blindedMessages, - }) - if err != nil { - return nil, fmt.Errorf("mint request to %s failed (%s): %w", to, mintQuote, err), false, true - } - - proofs, err := constructProofs(mintResponse.Signatures, blindedMessages, secrets, rs, keysetKeys) - if err != nil { - return nil, fmt.Errorf("error constructing proofs: %w", err), false, true - } - - return proofs, nil, false, false + // if it got paid make proceed to get proofs + split := []uint64{1, 2, 3, 4} + blindedMessages, secrets, rs, err := createBlindedMessages(split, keyset.Id, nil) + if err != nil { + return nil, fmt.Errorf("error creating blinded messages: %v", err), manualActionRequired } - return nil, fmt.Errorf("we gave up waiting for the invoice at %s to be paid: %s", to, meltQuote), false, true + // request mint to sign the blinded messages + mintResponse, err := client.PostMintBolt11(ctx, to, nut04.PostMintBolt11Request{ + Quote: mintQuote, + Outputs: blindedMessages, + }) + if err != nil { + return nil, fmt.Errorf("mint request to %s failed (%s): %w", to, mintQuote, err), manualActionRequired + } + + proofs, err = constructProofs(mintResponse.Signatures, blindedMessages, secrets, rs, keysetKeys) + if err != nil { + return nil, fmt.Errorf("error constructing proofs: %w", err), manualActionRequired + } + + return proofs, nil, nothingCanBeDone } diff --git a/nip60/receive.go b/nip60/receive.go index 3a83bb3..425dd29 100644 --- a/nip60/receive.go +++ b/nip60/receive.go @@ -59,7 +59,7 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error // and telling the current mint to pay it if lightningSwap { for _, targetMint := range w.Mints { - swappedProofs, err, tryAnother, needsManualAction := lightningMeltMint( + swappedProofs, err, status := lightningMeltMint( ctx, newProofs, source, @@ -67,12 +67,15 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error targetMint, ) if err != nil { - if tryAnother { + if status == tryAnotherTargetMint { continue } - if needsManualAction { + if status == manualActionRequired { return fmt.Errorf("failed to swap (needs manual action): %w", err) } + if status == nothingCanBeDone { + return fmt.Errorf("failed to swap (nothing can be done, we probably lost the money): %w", err) + } // if we get here that means we still have our proofs from the untrusted mint, so save those goto saveproofs diff --git a/nip60/send.go b/nip60/send.go index a32c49d..b6c71cf 100644 --- a/nip60/send.go +++ b/nip60/send.go @@ -158,12 +158,13 @@ found: updatedTokens = append(updatedTokens, token) } - if err := changeToken.toEvent(ctx, w.wl.kr, w.Identifier, changeToken.event); err != nil { - return "", fmt.Errorf("failed to make change token: %w", err) + 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) + } + w.wl.Changes <- *changeToken.event + w.Tokens = append(updatedTokens, changeToken) } - 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) diff --git a/nip60/token.go b/nip60/token.go index 649e76c..11f1f98 100644 --- a/nip60/token.go +++ b/nip60/token.go @@ -18,6 +18,14 @@ type Token struct { event *nostr.Event } +func (t Token) ID() string { + if t.event != nil { + return t.event.ID + } + + return "" +} + func (t Token) toEvent(ctx context.Context, kr nostr.Keyer, walletId string, evt *nostr.Event) error { pk, err := kr.GetPublicKey(ctx) if err != nil {