From 7eba27f0269f44f0c3c3f67ddc06790e0c14f698 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 28 Jan 2025 15:24:58 -0300 Subject: [PATCH] nip60: wallet.SendToken() and wallet.SwapProofs() --- nip60/{nip60_test.go => eventcodec_test.go} | 0 nip60/helpers.go | 39 ++-- nip60/lightning-swap.go | 134 ++++++++++++++ nip60/receive.go | 59 +----- nip60/send.go | 153 ++++++++++++++++ nip60/{lib.go => stash.go} | 5 + nip60/swap.go | 190 +++++++++----------- nip60/wallet.go | 10 +- 8 files changed, 410 insertions(+), 180 deletions(-) rename nip60/{nip60_test.go => eventcodec_test.go} (100%) create mode 100644 nip60/lightning-swap.go create mode 100644 nip60/send.go rename nip60/{lib.go => stash.go} (96%) diff --git a/nip60/nip60_test.go b/nip60/eventcodec_test.go similarity index 100% rename from nip60/nip60_test.go rename to nip60/eventcodec_test.go diff --git a/nip60/helpers.go b/nip60/helpers.go index 4822925..a34a77a 100644 --- a/nip60/helpers.go +++ b/nip60/helpers.go @@ -22,7 +22,7 @@ import ( "github.com/elnosh/gonuts/crypto" ) -func calculateFee(inputs cashu.Proofs, keysets []nut02.Keyset) uint { +func calculateFee(inputs cashu.Proofs, keysets []nut02.Keyset) uint64 { var n uint = 0 next: for _, proof := range inputs { @@ -35,13 +35,14 @@ next: panic(fmt.Errorf("spending a proof we don't have the keyset for? %v // %v", proof, keysets)) } - return (n + 999) / 1000 + return uint64((n + 999) / 1000) } // returns blinded messages, secrets - [][]byte, and list of r func createBlindedMessages( splitAmounts []uint64, keysetId string, + spendingCondition *nut10.SpendingCondition, ) (cashu.BlindedMessages, []string, []*secp256k1.PrivateKey, error) { splitLen := len(splitAmounts) blindedMessages := make(cashu.BlindedMessages, splitLen) @@ -49,13 +50,25 @@ func createBlindedMessages( rs := make([]*secp256k1.PrivateKey, splitLen) for i, amt := range splitAmounts { - var secret string - var r *secp256k1.PrivateKey - secret, r, err := generateRandomSecret() + r, err := secp256k1.GeneratePrivateKey() if err != nil { return nil, nil, nil, err } + var secret string + if spendingCondition != nil { + secret, err = nut10.NewSecretFromSpendingCondition(*spendingCondition) + if err != nil { + return nil, nil, nil, err + } + } else { + secretBytes := make([]byte, 32) + if _, err := rand.Read(secretBytes); err != nil { + return nil, nil, nil, err + } + secret = hex.EncodeToString(secretBytes) + } + B_, r, err := crypto.BlindMessage(secret, r) if err != nil { return nil, nil, nil, err @@ -69,22 +82,6 @@ func createBlindedMessages( return blindedMessages, secrets, rs, nil } -func generateRandomSecret() (string, *secp256k1.PrivateKey, error) { - r, err := secp256k1.GeneratePrivateKey() - if err != nil { - return "", nil, err - } - - secretBytes := make([]byte, 32) - _, err = rand.Read(secretBytes) - if err != nil { - return "", nil, err - } - secret := hex.EncodeToString(secretBytes) - - return secret, r, nil -} - func splitWalletTarget(proofs cashu.Proofs, amountToSplit uint64, mint string) []uint64 { target := 3 diff --git a/nip60/lightning-swap.go b/nip60/lightning-swap.go new file mode 100644 index 0000000..b3098c4 --- /dev/null +++ b/nip60/lightning-swap.go @@ -0,0 +1,134 @@ +package nip60 + +import ( + "context" + "fmt" + "time" + + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut02" + "github.com/elnosh/gonuts/cashu/nuts/nut04" + "github.com/elnosh/gonuts/cashu/nuts/nut05" + "github.com/nbd-wtf/go-nostr/nip60/client" +) + +// lightningMeltMint does the lightning dance of moving funds between mints +func lightningMeltMint( + ctx context.Context, + proofs cashu.Proofs, + from string, + fromKeysets []nut02.Keyset, + to string, +) (newProofs cashu.Proofs, err error, canTryWithAnotherTargetMint bool, manualActionRequired bool) { + // 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 + } + + // 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 + } + + // now we start the melt-mint process in multiple attempts + invoicePct := 0.99 + proofsAmount := proofs.Amount() + amount := float64(proofsAmount) * invoicePct + fee := uint64(calculateFee(proofs, fromKeysets)) + var meltQuote string + var mintQuote string + for range 10 { + // request _mint_ quote to the 'to' mint -- this will generate an invoice + mintResp, err := client.PostMintQuoteBolt11(ctx, to, nut04.PostMintQuoteBolt11Request{ + Amount: uint64(amount) - fee, + Unit: cashu.Sat.String(), + }) + if err != nil { + return nil, fmt.Errorf("error requesting mint quote from %s: %w", to, err), true, false + } + + // request _melt_ quote from the 'from' mint + // this melt will pay the invoice generated from the previous mint quote request + meltResp, err := client.PostMeltQuoteBolt11(ctx, from, nut05.PostMeltQuoteBolt11Request{ + Request: mintResp.Request, + Unit: cashu.Sat.String(), + }) + if err != nil { + return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), false, false + } + + // if amount in proofs is less than amount asked from mint in melt request, + // lower the amount for mint request (because of lighting fees?) + if meltResp.Amount+meltResp.FeeReserve+fee > proofsAmount { + invoicePct -= 0.01 + amount *= invoicePct + } else { + meltQuote = meltResp.Quote + mintQuote = mintResp.Quote + goto meltworked + } + } + + return nil, fmt.Errorf("stop trying to do the melt because the mint part is too expensive"), true, false + +meltworked: + // request from mint to pay invoice from the mint quote request + _, err = client.PostMeltBolt11(ctx, from, nut05.PostMeltBolt11Request{ + Quote: meltQuote, + Inputs: proofs, + }) + if err != nil { + return nil, fmt.Errorf("error melting token: %v", err), false, true + } + + 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 + } + + return nil, fmt.Errorf("we gave up waiting for the invoice at %s to be paid: %s", to, meltQuote), false, true +} diff --git a/nip60/receive.go b/nip60/receive.go index 7e012ed..804f364 100644 --- a/nip60/receive.go +++ b/nip60/receive.go @@ -5,10 +5,7 @@ import ( "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" "github.com/nbd-wtf/go-nostr/nip60/client" @@ -21,9 +18,9 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error } source := "http" + nostr.NormalizeURL(token.Mint())[2:] - swap := slices.Contains(w.Mints, source) + lightningSwap := slices.Contains(w.Mints, source) proofs := token.Proofs() - isp2pk := false + swapOpts := make([]SwapOption, 0, 1) for i, proof := range proofs { if proof.Secret != "" { @@ -31,7 +28,7 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error if err == nil { switch nut10Secret.Kind { case nut10.P2PK: - isp2pk = true + swapOpts = append(swapOpts, WithSignedOutputs()) proofs[i].Witness, err = signInput(w.PrivateKey, w.PublicKey, proof, nut10Secret) if err != nil { return fmt.Errorf("failed to sign locked proof %d: %w", i, err) @@ -49,58 +46,18 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error if err != nil { return fmt.Errorf("failed to get %s keysets: %w", source, err) } - var sourceActiveKeyset nut02.Keyset - var sourceActiveKeys map[uint64]*btcec.PublicKey - for _, keyset := range sourceKeysets { - if keyset.Unit == cashu.Sat.String() && keyset.Active { - sourceActiveKeyset = keyset - sourceActiveKeysHex, err := client.GetKeysetById(ctx, source, keyset.Id) - if err != nil { - return fmt.Errorf("failed to get keyset keys for %s: %w", keyset.Id, err) - } - sourceActiveKeys, err = parseKeysetKeys(sourceActiveKeysHex) - } - } // get new proofs - splits := make([]uint64, len(proofs)) - for i, p := range proofs { // TODO: do the fee stuff here because it won't always be free - splits[i] = p.Amount - } - - outputs, secrets, rs, err := createBlindedMessages(splits, sourceActiveKeyset.Id) + _, newProofs, err := w.SwapProofs(ctx, source, proofs, proofs.Amount(), swapOpts...) if err != nil { - return fmt.Errorf("failed to create blinded message: %w", err) + return err } - if isp2pk { - for i, output := range outputs { - outputs[i].Witness, err = signOutput(w.PrivateKey, output) - if err != nil { - return fmt.Errorf("failed to sign output message %d: %w", i, err) - } - } - } - - req := nut03.PostSwapRequest{ - Inputs: proofs, - Outputs: outputs, - } - - res, err := client.PostSwap(ctx, source, req) - if err != nil { - return fmt.Errorf("failed to claim received tokens at %s: %w", source, err) - } - - newProofs, err := constructProofs(res.Signatures, req.Outputs, secrets, rs, sourceActiveKeys) - if err != nil { - return fmt.Errorf("failed to construct proofs: %w", err) - } - newMint := source + newMint := source // if we don't have to do a lightning swap then new mint will be the same as old mint // if we have to swap to our own mint we do it now by getting a bolt11 invoice from our mint // and telling the current mint to pay it - if swap { + if lightningSwap { for _, targetMint := range w.Mints { swappedProofs, err, tryAnother, needsManualAction := lightningMeltMint( ctx, @@ -132,11 +89,13 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error } saveproofs: + w.tokensMu.Lock() w.Tokens = append(w.Tokens, Token{ Mint: newMint, Proofs: newProofs, mintedAt: nostr.Now(), }) + w.tokensMu.Unlock() return nil } diff --git a/nip60/send.go b/nip60/send.go new file mode 100644 index 0000000..aa40979 --- /dev/null +++ b/nip60/send.go @@ -0,0 +1,153 @@ +package nip60 + +import ( + "context" + "encoding/hex" + "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/nut10" + "github.com/elnosh/gonuts/cashu/nuts/nut11" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip60/client" +) + +type SendOption func(opts *sendSettings) + +type sendSettings struct { + specificMint string + p2pk *btcec.PublicKey + refundtimelock int64 +} + +func WithP2PK(pubkey string) SendOption { + return func(opts *sendSettings) { + pkb, _ := hex.DecodeString(pubkey) + opts.p2pk, _ = btcec.ParsePubKey(pkb) + } +} + +func WithRefundable(timelock nostr.Timestamp) SendOption { + return func(opts *sendSettings) { + opts.refundtimelock = int64(timelock) + } +} + +func WithMint(url string) SendOption { + return func(opts *sendSettings) { + opts.specificMint = url + } +} + +func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOption) (string, error) { + ss := &sendSettings{} + for _, opt := range opts { + opt(ss) + } + + w.tokensMu.Lock() + defer w.tokensMu.Unlock() + + type part struct { + mint string + tokens []Token + tokenIndexes []int + proofs cashu.Proofs + keysets []nut02.Keyset + } + + 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 { + return "", fmt.Errorf("mint doesn't support p2pk: %w", err) + } + + tags := nut11.P2PKTags{ + NSigs: 1, + Locktime: 0, + Pubkeys: []*btcec.PublicKey{ss.p2pk}, + } + if ss.refundtimelock != 0 { + tags.Refund = []*btcec.PublicKey{w.PublicKey} + tags.Locktime = ss.refundtimelock + } + + swapOpts = append(swapOpts, WithSpendingCondition( + nut10.SpendingCondition{ + Kind: nut10.P2PK, + Data: hex.EncodeToString(ss.p2pk.SerializeCompressed()), + Tags: nut11.SerializeP2PKTags(tags), + }, + )) + } + + // get new proofs + proofsToSend, changeProofs, err := w.SwapProofs(ctx, target.mint, target.proofs, amount, swapOpts...) + if err != nil { + return "", err + } + + // delete spent tokens and save our change + newTokens := make([]Token, 0, len(w.Tokens)) + for i, token := range w.Tokens { + if slices.Contains(target.tokenIndexes, i) { + continue + } + newTokens = append(newTokens, token) + } + w.Tokens = append(newTokens, Token{ + mintedAt: nostr.Now(), + Mint: target.mint, + Proofs: changeProofs, + }) + + // serialize token we're sending out + token, err := cashu.NewTokenV4(proofsToSend, target.mint, cashu.Sat, true) + if err != nil { + return "", err + } + + return token.Serialize() +} diff --git a/nip60/lib.go b/nip60/stash.go similarity index 96% rename from nip60/lib.go rename to nip60/stash.go index 754ba1c..6112064 100644 --- a/nip60/lib.go +++ b/nip60/stash.go @@ -90,9 +90,12 @@ func LoadStash( for _, he := range wl.pendingHistory[wallet.Identifier] { wallet.History = append(wallet.History, he) } + + wallet.tokensMu.Lock() for _, token := range wl.pendingTokens[wallet.Identifier] { wallet.Tokens = append(wallet.Tokens, token) } + wallet.tokensMu.Unlock() wl.wallets[wallet.Identifier] = wallet @@ -124,7 +127,9 @@ func LoadStash( } if wallet, ok := wl.wallets[spl[2]]; ok { + wallet.tokensMu.Lock() wallet.Tokens = append(wallet.Tokens, token) + wallet.tokensMu.Unlock() } else { wl.pendingTokens[spl[2]] = append(wl.pendingTokens[spl[2]], token) } diff --git a/nip60/swap.go b/nip60/swap.go index 5acffe3..67c3d46 100644 --- a/nip60/swap.go +++ b/nip60/swap.go @@ -3,132 +3,110 @@ package nip60 import ( "context" "fmt" - "time" "github.com/elnosh/gonuts/cashu" - "github.com/elnosh/gonuts/cashu/nuts/nut02" - "github.com/elnosh/gonuts/cashu/nuts/nut04" - "github.com/elnosh/gonuts/cashu/nuts/nut05" + "github.com/elnosh/gonuts/cashu/nuts/nut03" + "github.com/elnosh/gonuts/cashu/nuts/nut10" "github.com/nbd-wtf/go-nostr/nip60/client" ) -// lightningMeltMint does the lightning dance of moving funds between mints -func lightningMeltMint( +type SwapOption func(*swapSettings) + +func WithSignedOutputs() SwapOption { + return func(ss *swapSettings) { + ss.mustSignOutputs = true + } +} + +func WithSpendingCondition(sc nut10.SpendingCondition) SwapOption { + return func(ss *swapSettings) { + ss.spendingCondition = &sc + } +} + +type swapSettings struct { + spendingCondition *nut10.SpendingCondition + mustSignOutputs bool +} + +func (w *Wallet) SwapProofs( ctx context.Context, + mint string, proofs cashu.Proofs, - from string, - fromKeysets []nut02.Keyset, - to string, -) (newProofs cashu.Proofs, err error, canTryWithAnotherTargetMint bool, manualActionRequired bool) { - // 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 + targetAmount uint64, + opts ...SwapOption, +) (principal cashu.Proofs, change cashu.Proofs, err error) { + var ss swapSettings + for _, opt := range opts { + opt(&ss) } - // unblind the signatures from the promises and build the proofs - keysetKeys, err := parseKeysetKeys(keyset.Keys) + // fetch all this keyset drama first + keysets, err := client.GetAllKeysets(ctx, mint) if err != nil { - return nil, fmt.Errorf("target mint %s sent us an invalid keyset: %w", to, err), true, false + return nil, nil, fmt.Errorf("failed to get all keysets for %s: %w", mint, err) + } + activeKeyset, err := client.GetActiveKeyset(ctx, mint) + if err != nil { + return nil, nil, fmt.Errorf("failed to get active keyset for %s: %w", mint, err) + } + ksKeys, err := parseKeysetKeys(activeKeyset.Keys) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse keys for %s: %w", mint, err) } - // now we start the melt-mint process in multiple attempts - invoicePct := 0.99 + // decide the shape of the proofs we'll swap for proofsAmount := proofs.Amount() - amount := float64(proofsAmount) * invoicePct - fee := uint64(calculateFee(proofs, fromKeysets)) - var meltQuote string - var mintQuote string - for range 10 { - // request _mint_ quote to the 'to' mint -- this will generate an invoice - mintResp, err := client.PostMintQuoteBolt11(ctx, to, nut04.PostMintQuoteBolt11Request{ - Amount: uint64(amount) - fee, - Unit: cashu.Sat.String(), - }) - if err != nil { - return nil, fmt.Errorf("error requesting mint quote from %s: %w", to, err), true, false - } - - // request _melt_ quote from the 'from' mint - // this melt will pay the invoice generated from the previous mint quote request - meltResp, err := client.PostMeltQuoteBolt11(ctx, from, nut05.PostMeltQuoteBolt11Request{ - Request: mintResp.Request, - Unit: cashu.Sat.String(), - }) - if err != nil { - return nil, fmt.Errorf("error requesting melt quote from %s: %w", from, err), false, false - } - - // if amount in proofs is less than amount asked from mint in melt request, - // lower the amount for mint request (because of lighting fees?) - if meltResp.Amount+meltResp.FeeReserve+fee > proofsAmount { - invoicePct -= 0.01 - amount *= invoicePct - } else { - meltQuote = meltResp.Quote - mintQuote = mintResp.Quote - goto meltworked - } + var ( + principalAmount uint64 + changeAmount uint64 + ) + fee := calculateFee(proofs, keysets) + if targetAmount < proofsAmount { + // we'll get the exact target, then a change, and fee will be taken from the change + changeAmount = proofsAmount - targetAmount - fee + } else if targetAmount == proofsAmount { + // we're swapping everything, so take the fee from the principal + principalAmount = targetAmount - fee + } else { + return nil, nil, fmt.Errorf("can't swap for more than we are sending: %d > %d", + targetAmount, proofsAmount) } + splits := make([]uint64, 0, len(proofs)*2) + splits = append(splits, cashu.AmountSplit(principalAmount)...) + changeStartIndex := len(splits) + splits = append(splits, cashu.AmountSplit(changeAmount)...) - return nil, fmt.Errorf("stop trying to do the melt because the mint part is too expensive"), true, false - -meltworked: - // request from mint to pay invoice from the mint quote request - _, err = client.PostMeltBolt11(ctx, from, nut05.PostMeltBolt11Request{ - Quote: meltQuote, - Inputs: proofs, - }) + // prepare message to send to mint + outputs, secrets, rs, err := createBlindedMessages(splits, activeKeyset.Id, ss.spendingCondition) if err != nil { - return nil, fmt.Errorf("error melting token: %v", err), false, true + return nil, nil, fmt.Errorf("failed to create blinded message: %w", err) } - 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 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) } } - - // 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) - 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 } - return nil, fmt.Errorf("we gave up waiting for the invoice at %s to be paid: %s", to, meltQuote), false, true + req := nut03.PostSwapRequest{ + Inputs: proofs, + Outputs: outputs, + } + + res, err := client.PostSwap(ctx, mint, req) + if err != nil { + return nil, nil, fmt.Errorf("failed to claim received 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 } diff --git a/nip60/wallet.go b/nip60/wallet.go index 4121115..0b3411c 100644 --- a/nip60/wallet.go +++ b/nip60/wallet.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "strconv" + "sync" "github.com/btcsuite/btcd/btcec/v2" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -24,9 +25,10 @@ type Wallet struct { History []HistoryEntry temporaryBalance uint64 + tokensMu sync.Mutex } -func (w Wallet) Balance() uint64 { +func (w *Wallet) Balance() uint64 { var sum uint64 for _, token := range w.Tokens { sum += token.Proofs.Amount() @@ -34,14 +36,14 @@ func (w Wallet) Balance() uint64 { return sum } -func (w Wallet) DisplayName() string { +func (w *Wallet) DisplayName() string { if w.Name != "" { return fmt.Sprintf("%s (%s)", w.Name, w.Identifier) } return w.Identifier } -func (w Wallet) ToPublishableEvents( +func (w *Wallet) ToPublishableEvents( ctx context.Context, kr nostr.Keyer, skipExisting bool, @@ -91,6 +93,7 @@ func (w Wallet) ToPublishableEvents( events := make([]nostr.Event, 0, 1+len(w.Tokens)) events = append(events, evt) + w.tokensMu.Lock() for _, t := range w.Tokens { var evt nostr.Event @@ -108,6 +111,7 @@ func (w Wallet) ToPublishableEvents( events = append(events, evt) } + w.tokensMu.Unlock() for _, h := range w.History { var evt nostr.Event