nip61, and related modifications to nip60.

This commit is contained in:
fiatjaf 2025-02-02 18:16:46 -03:00
parent c9f670d700
commit d0f53b3b7a
11 changed files with 327 additions and 59 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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 {

36
nip60/send-external.go Normal file
View File

@ -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)
}

View File

@ -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(

View File

@ -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
}

25
nip60/utils.go Normal file
View File

@ -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
}

View File

@ -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

61
nip61/info.go Normal file
View File

@ -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
}

135
nip61/nip61.go Normal file
View File

@ -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
}
}
}
}
}
}