diff --git a/nip60/receive.go b/nip60/receive.go index 19fa487..d8b4131 100644 --- a/nip60/receive.go +++ b/nip60/receive.go @@ -11,17 +11,47 @@ import ( "github.com/nbd-wtf/go-nostr/nip60/client" ) +type receiveSettings struct { + intoMint []string + isNutzap bool +} + +type ReceiveOption func(*receiveSettings) + +func WithMintDestination(url string) ReceiveOption { + return func(rs *receiveSettings) { + rs.intoMint = append(rs.intoMint, url) + } +} + +func WithNutzap() ReceiveOption { + return func(rs *receiveSettings) { + rs.isNutzap = true + } +} + func (w *Wallet) Receive( ctx context.Context, proofs cashu.Proofs, mint string, + opts ...ReceiveOption, ) error { if w.PublishUpdate == nil { return fmt.Errorf("can't do write operations: missing PublishUpdate function") } - source := "http" + nostr.NormalizeURL(mint)[2:] - lightningSwap := slices.Contains(w.Mints, source) + rs := receiveSettings{} + for _, opt := range opts { + opt(&rs) + } + + source, _ := nostr.NormalizeHTTPURL(mint) + destination := rs.intoMint + if len(destination) == 0 { + destination = w.Mints + } + + lightningSwap := slices.Contains(destination, source) swapOpts := make([]SwapOption, 0, 1) for i, proof := range proofs { @@ -60,7 +90,7 @@ func (w *Wallet) Receive( // 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 lightningSwap { - for _, targetMint := range w.Mints { + for _, targetMint := range destination { swappedProofs, err, status := lightningMeltMint( ctx, newProofs, @@ -110,7 +140,7 @@ saveproofs: { EventID: newToken.event.ID, Created: true, - IsNutzap: false, + IsNutzap: rs.isNutzap, }, }, createdAt: nostr.Now(), diff --git a/nip60/token.go b/nip60/token.go index f3b4fc7..3b6bdaa 100644 --- a/nip60/token.go +++ b/nip60/token.go @@ -72,7 +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:] + t.Mint, _ = nostr.NormalizeHTTPURL(t.Mint) return nil } diff --git a/nip60/wallet.go b/nip60/wallet.go index 57660cb..25d1bee 100644 --- a/nip60/wallet.go +++ b/nip60/wallet.go @@ -24,6 +24,7 @@ type Wallet struct { kr nostr.Keyer // PublishUpdate must be set to a function that publishes event to the user relays + // (if all arguments are their zero values that means it is a wallet update event). PublishUpdate func( event nostr.Event, deleted *Token, @@ -78,7 +79,7 @@ func loadWalletFromPool( kinds := []int{17375, 7375} if withHistory { - kinds = append(kinds, 7375) + kinds = append(kinds, 7376) } eoseChan := make(chan struct{}) @@ -229,6 +230,89 @@ func (w *Wallet) Balance() uint64 { return sum } +func (w *Wallet) AddMint(ctx context.Context, urls ...string) error { + if w.PublishUpdate == nil { + return fmt.Errorf("can't do write operations: missing PublishUpdate function") + } + + for _, url := range urls { + url, err := nostr.NormalizeHTTPURL(url) + if err != nil { + return err + } + + if !slices.Contains(w.Mints, url) { + w.Mints = append(w.Mints, url) + } + } + + evt := nostr.Event{} + if err := w.toEvent(ctx, w.kr, &evt); err != nil { + return err + } + + w.Lock() + w.PublishUpdate(evt, nil, nil, nil, false) + w.Unlock() + + return nil +} + +func (w *Wallet) RemoveMint(ctx context.Context, urls ...string) error { + if w.PublishUpdate == nil { + return fmt.Errorf("can't do write operations: missing PublishUpdate function") + } + + for _, url := range urls { + url, err := nostr.NormalizeHTTPURL(url) + if err != nil { + return err + } + + if idx := slices.Index(w.Mints, url); idx != -1 { + w.Mints = slices.Delete(w.Mints, idx, idx+1) + } + } + + evt := nostr.Event{} + if err := w.toEvent(ctx, w.kr, &evt); err != nil { + return err + } + + w.Lock() + w.PublishUpdate(evt, nil, nil, nil, false) + w.Unlock() + + return nil +} + +func (w *Wallet) SetPrivateKey(ctx context.Context, privateKey string) error { + if w.PublishUpdate == nil { + return fmt.Errorf("can't do write operations: missing PublishUpdate function") + } + + skb, err := hex.DecodeString(privateKey) + if err != nil { + return err + } + if len(skb) != 32 { + return fmt.Errorf("private key must be 32 bytes, got %d", len(skb)) + } + + w.PrivateKey, w.PublicKey = btcec.PrivKeyFromBytes(skb) + + evt := nostr.Event{} + if err := w.toEvent(ctx, w.kr, &evt); err != nil { + return err + } + + w.Lock() + w.PublishUpdate(evt, nil, nil, nil, false) + w.Unlock() + + return nil +} + func (w *Wallet) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { evt.CreatedAt = nostr.Now() evt.Kind = 17375 @@ -239,12 +323,15 @@ func (w *Wallet) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) return err } - tags := make(nostr.Tags, 0, 1+len(w.Mints)) - tags = append(tags, nostr.Tag{"privkey", hex.EncodeToString(w.PrivateKey.Serialize())}) - for _, mint := range w.Mints { - tags = append(tags, nostr.Tag{"mint", mint}) + encryptedTags := make(nostr.Tags, 0, 1+len(w.Mints)) + if w.PrivateKey != nil { + encryptedTags = append(encryptedTags, nostr.Tag{"privkey", hex.EncodeToString(w.PrivateKey.Serialize())}) } - jtags, _ := json.Marshal(tags) + + for _, mint := range w.Mints { + encryptedTags = append(encryptedTags, nostr.Tag{"mint", mint}) + } + jtags, _ := json.Marshal(encryptedTags) evt.Content, err = kr.Encrypt( ctx, string(jtags), @@ -301,15 +388,12 @@ func (w *Wallet) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) er } } - if privateKey == nil { - return fmt.Errorf("missing wallet private key") + if privateKey != nil { + w.PrivateKey = privateKey + w.PublicKey = w.PrivateKey.PubKey() } - // finally set these things when we know nothing will fail w.Mints = mints - fmt.Println("mints", mints) - w.PrivateKey = privateKey - w.PublicKey = w.PrivateKey.PubKey() return nil } diff --git a/nip61/info.go b/nip61/info.go index 09c45f1..98ed93f 100644 --- a/nip61/info.go +++ b/nip61/info.go @@ -14,7 +14,7 @@ type Info struct { Relays []string } -func (zi *Info) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { +func (zi *Info) ToEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { evt.CreatedAt = nostr.Now() evt.Kind = 10019 @@ -36,7 +36,7 @@ func (zi *Info) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) e return nil } -func (zi *Info) parse(evt *nostr.Event) error { +func (zi *Info) ParseEvent(evt *nostr.Event) error { zi.Mints = make([]string, 0) for _, tag := range evt.Tags { if len(tag) < 2 { @@ -46,7 +46,8 @@ func (zi *Info) parse(evt *nostr.Event) error { 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:]) + url, _ := nostr.NormalizeHTTPURL(tag[1]) + zi.Mints = append(zi.Mints, url) } case "relay": zi.Relays = append(zi.Relays, tag[1]) diff --git a/nip61/nip61.go b/nip61/nip61.go index d9df45a..78c8c9c 100644 --- a/nip61/nip61.go +++ b/nip61/nip61.go @@ -33,7 +33,7 @@ func SendNutzap( } info := Info{} - if err := info.parse(ie.Event); err != nil { + if err := info.ParseEvent(ie.Event); err != nil { return nil, err } diff --git a/normalize.go b/normalize.go index 6211b3c..a0f2ae5 100644 --- a/normalize.go +++ b/normalize.go @@ -1,6 +1,7 @@ package nostr import ( + "fmt" "net/url" "strings" ) @@ -36,6 +37,49 @@ func NormalizeURL(u string) string { return p.String() } +// NormalizeHTTPURL does normalization of http(s):// URLs according to rfc3986. Don't use for relay URLs. +func NormalizeHTTPURL(s string) (string, error) { + s = strings.TrimSpace(s) + + if !strings.HasPrefix(s, "http") { + s = "https://" + s + } + + u, err := url.Parse(s) + if err != nil { + return s, err + } + + if u.Scheme == "" { + u, err = url.Parse("http://" + s) + if err != nil { + return s, err + } + } + + if strings.HasPrefix(s, "//") { + s = "http:" + s + } + + var p int + switch u.Scheme { + case "http": + p = 80 + case "https": + p = 443 + } + u.Host = strings.TrimSuffix(u.Host, fmt.Sprintf(":%d", p)) + + v := u.Query() + u.RawQuery = v.Encode() + u.RawQuery, _ = url.QueryUnescape(u.RawQuery) + + h := u.String() + h = strings.TrimSuffix(h, "/") + + return h, nil +} + // NormalizeOKMessage takes a string message that is to be sent in an `OK` or `CLOSED` command // and prefixes it with ": " if it doesn't already have an acceptable prefix. func NormalizeOKMessage(reason string, prefix string) string {