From d0f53b3b7a42da47c9e00a2d40bf1c04e31588ff Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 2 Feb 2025 18:16:46 -0300 Subject: [PATCH] nip61, and related modifications to nip60. --- kinds.go | 2 + nip60/lightning-swap.go | 81 ++++++++++++++---------- nip60/pay.go | 2 +- nip60/receive.go | 14 ++--- nip60/send-external.go | 36 +++++++++++ nip60/send.go | 20 +++--- nip60/token.go | 2 + nip60/utils.go | 25 ++++++++ nip60/wallet_test.go | 8 +-- nip61/info.go | 61 ++++++++++++++++++ nip61/nip61.go | 135 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 327 insertions(+), 59 deletions(-) create mode 100644 nip60/send-external.go create mode 100644 nip60/utils.go create mode 100644 nip61/info.go create mode 100644 nip61/nip61.go diff --git a/kinds.go b/kinds.go index 28343d2..4018913 100644 --- a/kinds.go +++ b/kinds.go @@ -59,6 +59,7 @@ const ( KindSimpleGroupJoinRequest int = 9021 KindSimpleGroupLeaveRequest int = 9022 KindZapGoal int = 9041 + KindNutZap int = 9321 KindTidalLogin int = 9467 KindZapRequest int = 9734 KindZap int = 9735 @@ -73,6 +74,7 @@ const ( KindSearchRelayList int = 10007 KindSimpleGroupList int = 10009 KindInterestList int = 10015 + KindNutZapInfo int = 10019 KindEmojiList int = 10030 KindDMRelayList int = 10050 KindUserServerList int = 10063 diff --git a/nip60/lightning-swap.go b/nip60/lightning-swap.go index 11edad8..c68cf06 100644 --- a/nip60/lightning-swap.go +++ b/nip60/lightning-swap.go @@ -29,18 +29,6 @@ func lightningMeltMint( fromKeysets []nut02.Keyset, to string, ) (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), 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), tryAnotherTargetMint - } - // now we start the melt-mint process in multiple attempts invoicePct := uint64(99) proofsAmount := proofs.Amount() @@ -107,46 +95,73 @@ 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) + proofs, err = redeemMinted(ctx, to, mintQuote, amount) if err != nil { - 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 + return nil, + fmt.Errorf("failed to redeem minted proofs at %s (after successfully melting at %s): %w", to, from, err), + manualActionRequired } - // if it got paid make proceed to get proofs - split := cashu.AmountSplit(amount) + return proofs, nil, nothingCanBeDone +} + +func redeemMinted( + ctx context.Context, + mint string, + mintQuote string, + mintAmount uint64, +) (cashu.Proofs, error) { + // 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, mint, mintQuote) + if err != nil { + return nil, fmt.Errorf( + "target failed to answer to our mint quote checks (%s): %w; a manual fix is needed", + mintQuote, err, + ) + } + if mintQuoteStatusResp.State != nut04.Paid { + return nil, fmt.Errorf("target says the invoice wasn't paid (mint quote %s)", mintQuote) + } + + // since it got paid proceed to get proofs + // + + // get active keyset of target mint + keyset, err := client.GetActiveKeyset(ctx, mint) + if err != nil { + return nil, fmt.Errorf("failed to get keyset keys for %s: %w", mint, err) + } + + // unblind the signatures from the promises and build the blinded messages + keysetKeys, err := parseKeysetKeys(keyset.Keys) + if err != nil { + return nil, fmt.Errorf("target mint %s sent us an invalid keyset: %w", mint, err) + } + split := cashu.AmountSplit(mintAmount) 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("error creating blinded messages: %w", err) } // request mint to sign the blinded messages - mintResponse, err := client.PostMintBolt11(ctx, to, nut04.PostMintBolt11Request{ + mintResponse, err := client.PostMintBolt11(ctx, mint, nut04.PostMintBolt11Request{ Quote: mintQuote, Outputs: blindedMessages, }) if err != nil { - return nil, fmt.Errorf("mint request to %s failed (%s): %w", to, mintQuote, err), manualActionRequired + return nil, fmt.Errorf("mint request: %w", err) } - proofs, err = constructProofs(preparedOutputs{ + // finally turn those into proofs + 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 + return nil, fmt.Errorf("error constructing proofs: %w", err) } - return proofs, nil, nothingCanBeDone + return proofs, nil } diff --git a/nip60/pay.go b/nip60/pay.go index dd12f46..16ada39 100644 --- a/nip60/pay.go +++ b/nip60/pay.go @@ -38,7 +38,7 @@ func (w *Wallet) PayBolt11(ctx context.Context, invoice string, opts ...SendOpti excludeMints := make([]string, 0, 1) - for range 10 { + for range 5 { amount := invoiceAmount*(100+feeReservePct)/100 + feeReserveAbs var fee uint64 chosen, fee, err = w.getProofsForSending(ctx, amount, ss.specificMint, excludeMints) diff --git a/nip60/receive.go b/nip60/receive.go index ecdb9a9..e1ca1db 100644 --- a/nip60/receive.go +++ b/nip60/receive.go @@ -11,19 +11,17 @@ import ( "github.com/nbd-wtf/go-nostr/nip60/client" ) -func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error { +func (w *Wallet) Receive( + ctx context.Context, + proofs cashu.Proofs, + mint string, +) error { if w.wl.PublishUpdate == nil { return fmt.Errorf("can't do write operations: missing PublishUpdate function") } - token, err := cashu.DecodeToken(serializedToken) - if err != nil { - return err - } - - source := "http" + nostr.NormalizeURL(token.Mint())[2:] + source := "http" + nostr.NormalizeURL(mint)[2:] lightningSwap := slices.Contains(w.Mints, source) - proofs := token.Proofs() swapOpts := make([]SwapOption, 0, 1) for i, proof := range proofs { diff --git a/nip60/send-external.go b/nip60/send-external.go new file mode 100644 index 0000000..b501c1b --- /dev/null +++ b/nip60/send-external.go @@ -0,0 +1,36 @@ +package nip60 + +import ( + "context" + "fmt" + + "github.com/elnosh/gonuts/cashu" + "github.com/elnosh/gonuts/cashu/nuts/nut04" + "github.com/nbd-wtf/go-nostr/nip60/client" +) + +func (w *Wallet) SendExternal( + ctx context.Context, + mint string, + targetAmount uint64, + opts ...SendOption, +) (cashu.Proofs, error) { + if w.wl.PublishUpdate == nil { + return nil, fmt.Errorf("can't do write operations: missing PublishUpdate function") + } + + // get the invoice from target mint + mintResp, err := client.PostMintQuoteBolt11(ctx, mint, nut04.PostMintQuoteBolt11Request{ + Unit: cashu.Sat.String(), + Amount: targetAmount, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate mint quote: %w", err) + } + + if _, err := w.PayBolt11(ctx, mintResp.Request, opts...); err != nil { + return nil, err + } + + return redeemMinted(ctx, mint, mintResp.Quote, targetAmount) +} diff --git a/nip60/send.go b/nip60/send.go index 5a7d5a1..5aad447 100644 --- a/nip60/send.go +++ b/nip60/send.go @@ -50,9 +50,9 @@ type chosenTokens struct { keysets []nut02.Keyset } -func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOption) (string, error) { +func (w *Wallet) Send(ctx context.Context, amount uint64, opts ...SendOption) (cashu.Proofs, string, error) { if w.wl.PublishUpdate == nil { - return "", fmt.Errorf("can't do write operations: missing PublishUpdate function") + return nil, "", fmt.Errorf("can't do write operations: missing PublishUpdate function") } ss := &sendSettings{} @@ -65,14 +65,14 @@ func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOptio chosen, _, err := w.getProofsForSending(ctx, amount, ss.specificMint, nil) if err != nil { - return "", err + return nil, "", err } swapOpts := make([]SwapOption, 0, 2) if ss.p2pk != nil { if info, err := client.GetMintInfo(ctx, chosen.mint); err != nil || !info.Nuts.Nut11.Supported { - return "", fmt.Errorf("mint doesn't support p2pk: %w", err) + return nil, chosen.mint, fmt.Errorf("mint doesn't support p2pk: %w", err) } tags := nut11.P2PKTags{ @@ -97,7 +97,7 @@ func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOptio // get new proofs proofsToSend, changeProofs, err := w.swapProofs(ctx, chosen.mint, chosen.proofs, amount, swapOpts...) if err != nil { - return "", err + return nil, chosen.mint, err } he := HistoryEntry{ @@ -110,7 +110,7 @@ func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOptio } if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes, &he); err != nil { - return "", err + return nil, chosen.mint, err } w.wl.Lock() @@ -119,13 +119,7 @@ func (w *Wallet) SendToken(ctx context.Context, amount uint64, opts ...SendOptio } w.wl.Unlock() - // serialize token we're sending out - token, err := cashu.NewTokenV4(proofsToSend, chosen.mint, cashu.Sat, true) - if err != nil { - return "", err - } - - return token.Serialize() + return proofsToSend, chosen.mint, nil } func (w *Wallet) saveChangeAndDeleteUsedTokens( diff --git a/nip60/token.go b/nip60/token.go index 11f1f98..250edc7 100644 --- a/nip60/token.go +++ b/nip60/token.go @@ -72,5 +72,7 @@ func (t *Token) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) err return fmt.Errorf("failed to parse token content: %w", err) } + t.Mint = "http" + nostr.NormalizeURL(t.Mint)[2:] + return nil } diff --git a/nip60/utils.go b/nip60/utils.go new file mode 100644 index 0000000..896b1c5 --- /dev/null +++ b/nip60/utils.go @@ -0,0 +1,25 @@ +package nip60 + +import "github.com/elnosh/gonuts/cashu" + +func GetProofsAndMint(tokenStr string) (cashu.Proofs, string, error) { + token, err := cashu.DecodeToken(tokenStr) + if err != nil { + return nil, "", err + } + return token.Proofs(), token.Mint(), nil +} + +func MakeTokenString(proofs cashu.Proofs, mint string) string { + token, err := cashu.NewTokenV4(proofs, mint, cashu.Sat, true) + if err != nil { + panic(err) + } + + tokenStr, err := token.Serialize() + if err != nil { + panic(err) + } + + return tokenStr +} diff --git a/nip60/wallet_test.go b/nip60/wallet_test.go index 5c2e667..5ad77fc 100644 --- a/nip60/wallet_test.go +++ b/nip60/wallet_test.go @@ -85,11 +85,11 @@ func TestWalletTransfer(t *testing.T) { require.NoError(t, err) halfBalance := initialBalance1 / 2 - token, err := w1.SendToken(ctx, halfBalance, WithP2PK(pk2)) + proofs, mint, err := w1.Send(ctx, halfBalance, WithP2PK(pk2)) require.NoError(t, err) // receive token in wallet 2 - err = w2.ReceiveToken(ctx, token) + err = w2.Receive(ctx, proofs, mint) require.NoError(t, err) // verify balances @@ -100,11 +100,11 @@ func TestWalletTransfer(t *testing.T) { pk1, err := kr1.GetPublicKey(ctx) require.NoError(t, err) - token, err = w2.SendToken(ctx, halfBalance, WithP2PK(pk1)) + proofs, mint, err = w2.Send(ctx, halfBalance, WithP2PK(pk1)) require.NoError(t, err) // receive token back in wallet 1 - err = w1.ReceiveToken(ctx, token) + err = w1.Receive(ctx, proofs, mint) require.NoError(t, err) // verify final balances match initial diff --git a/nip61/info.go b/nip61/info.go new file mode 100644 index 0000000..09c45f1 --- /dev/null +++ b/nip61/info.go @@ -0,0 +1,61 @@ +package nip61 + +import ( + "context" + "slices" + + "github.com/elnosh/gonuts/cashu" + "github.com/nbd-wtf/go-nostr" +) + +type Info struct { + PublicKey string + Mints []string + Relays []string +} + +func (zi *Info) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { + evt.CreatedAt = nostr.Now() + evt.Kind = 10019 + + evt.Tags = make(nostr.Tags, 0, len(zi.Mints)+len(zi.Relays)+1) + for _, mint := range zi.Mints { + evt.Tags = append(evt.Tags, nostr.Tag{"mint", mint}) + } + for _, url := range zi.Relays { + evt.Tags = append(evt.Tags, nostr.Tag{"relay", url}) + } + if zi.PublicKey != "" { + evt.Tags = append(evt.Tags, nostr.Tag{"pubkey", zi.PublicKey}) + } + + if err := kr.SignEvent(ctx, evt); err != nil { + return err + } + + return nil +} + +func (zi *Info) parse(evt *nostr.Event) error { + zi.Mints = make([]string, 0) + for _, tag := range evt.Tags { + if len(tag) < 2 { + continue + } + + switch tag[0] { + case "mint": + if len(tag) == 2 || slices.Contains(tag[2:], cashu.Sat.String()) { + zi.Mints = append(zi.Mints, "http"+nostr.NormalizeURL(tag[1])[2:]) + } + case "relay": + zi.Relays = append(zi.Relays, tag[1]) + case "pubkey": + if nostr.IsValidPublicKey(tag[1]) { + zi.PublicKey = tag[1] + } + } + } + + return nil +} diff --git a/nip61/nip61.go b/nip61/nip61.go new file mode 100644 index 0000000..cc9745d --- /dev/null +++ b/nip61/nip61.go @@ -0,0 +1,135 @@ +package nip61 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "iter" + "slices" + "strings" + + "github.com/elnosh/gonuts/cashu" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip60" +) + +var NutzapsNotAccepted = errors.New("user doesn't accept nutzaps") + +func SendNutzap( + ctx context.Context, + kr nostr.Keyer, + w *nip60.Wallet, + pool *nostr.SimplePool, + targetUserPublickey string, + getUserReadRelays func(context.Context, string, int) []string, + relays []string, + eventId string, // can be "" if not targeting a specific event + amount uint64, + message string, +) (chan nostr.PublishResult, error) { + ie := pool.QuerySingle(ctx, relays, nostr.Filter{Kinds: []int{10019}, Authors: []string{targetUserPublickey}}) + if ie == nil { + return nil, NutzapsNotAccepted + } + + info := Info{} + if err := info.parse(ie.Event); err != nil { + return nil, err + } + + if len(info.Mints) == 0 || info.PublicKey == "" { + return nil, NutzapsNotAccepted + } + + targetRelays := info.Relays + if len(targetRelays) == 0 { + targetRelays = getUserReadRelays(ctx, targetUserPublickey, 3) + if len(targetRelays) == 0 { + return nil, fmt.Errorf("no relays found for sending the nutzap") + } + } + + nutzap := nostr.Event{ + CreatedAt: nostr.Now(), + Kind: nostr.KindNutZap, + Tags: make(nostr.Tags, 0, 8), + } + + nutzap.Tags = append(nutzap.Tags, nostr.Tag{"p", targetUserPublickey}) + nutzap.Tags = append(nutzap.Tags, nostr.Tag{"unit", cashu.Sat.String()}) + if eventId != "" { + nutzap.Tags = append(nutzap.Tags, nostr.Tag{"e", eventId}) + } + + // check if we have enough tokens in any of these mints + for mint := range getEligibleTokensWeHave(info.Mints, w.Tokens, amount) { + proofs, _, err := w.Send(ctx, amount, nip60.WithP2PK(info.PublicKey), nip60.WithMint(mint)) + if err != nil { + continue + } + + // we have succeeded, now we just have to publish the event + nutzap.Tags = append(nutzap.Tags, nostr.Tag{"u", mint}) + for _, proof := range proofs { + proofj, _ := json.Marshal(proof) + nutzap.Tags = append(nutzap.Tags, nostr.Tag{"proof", string(proofj)}) + } + + if err := kr.SignEvent(ctx, &nutzap); err != nil { + return nil, fmt.Errorf("failed to sign nutzap event %s: %w", nutzap, err) + } + + return pool.PublishMany(ctx, targetRelays, nutzap), nil + } + + // we don't have tokens at the desired target mint, so we first have to create some + for _, mint := range info.Mints { + proofs, err := w.SendExternal(ctx, mint, amount) + if err != nil { + if strings.Contains(err.Error(), "generate mint quote") { + continue + } + return nil, fmt.Errorf("failed to send: %w", err) + } + + // we have succeeded, now we just have to publish the event + nutzap.Tags = append(nutzap.Tags, nostr.Tag{"u", mint}) + for _, proof := range proofs { + proofj, _ := json.Marshal(proof) + nutzap.Tags = append(nutzap.Tags, nostr.Tag{"proof", string(proofj)}) + } + + if err := kr.SignEvent(ctx, &nutzap); err != nil { + return nil, fmt.Errorf("failed to sign nutzap event %s: %w", nutzap, err) + } + + return pool.PublishMany(ctx, targetRelays, nutzap), nil + } + + return nil, fmt.Errorf("failed to send, we don't have enough money or all mints are down") +} + +func getEligibleTokensWeHave( + theirMints []string, + ourTokens []nip60.Token, + targetAmount uint64, +) iter.Seq[string] { + have := make([]uint64, len(theirMints)) + + return func(yield func(string) bool) { + for _, token := range ourTokens { + if idx := slices.Index(theirMints, token.Mint); idx != -1 { + have[idx] += token.Proofs.Amount() + + /* hardcoded estimated maximum fee, + unlikely to be more than this */ + if have[idx] > targetAmount*101/100+2 { + if !yield(token.Mint) { + break + } + } + } + } + } +}