From 2244740f61dbeb93910fdc5679da412453cb6427 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 28 Jan 2025 19:11:18 -0300 Subject: [PATCH] nip60: make it work with emitting events to be published dynamically and stuff. --- nip60/eventcodec_test.go | 35 ++++- nip60/receive.go | 18 ++- nip60/send.go | 44 ++++-- nip60/stash.go | 332 ++++++++++++++++++++++++--------------- nip60/token.go | 5 +- nip60/wallet.go | 72 ++------- 6 files changed, 304 insertions(+), 202 deletions(-) diff --git a/nip60/eventcodec_test.go b/nip60/eventcodec_test.go index c672a9d..bda09a3 100644 --- a/nip60/eventcodec_test.go +++ b/nip60/eventcodec_test.go @@ -98,11 +98,33 @@ func TestWalletRoundtrip(t *testing.T) { } // convert wallets to events - events1, err := wallet1.ToPublishableEvents(ctx, kr, false) - require.NoError(t, err) + events := [][]nostr.Event{ + make([]nostr.Event, 0, 4), + make([]nostr.Event, 0, 4), + } - events2, err := wallet2.ToPublishableEvents(ctx, kr, false) - require.NoError(t, err) + for i, w := range []*Wallet{&wallet1, &wallet2} { + evt := nostr.Event{} + err := w.toEvent(ctx, kr, &evt) + require.NoError(t, err) + events[i] = append(events[i], evt) + + for _, token := range w.Tokens { + evt = nostr.Event{} + err = token.toEvent(ctx, kr, w.Identifier, &evt) + require.NoError(t, err) + events[i] = append(events[i], evt) + } + + for _, he := range w.History { + evt = nostr.Event{} + err = he.toEvent(ctx, kr, w.Identifier, &evt) + require.NoError(t, err) + events[i] = append(events[i], evt) + } + } + + events1, events2 := events[0], events[1] // combine all events allEvents := append(events1, events2...) @@ -127,13 +149,12 @@ func TestWalletRoundtrip(t *testing.T) { }() // load wallets from events - errorChan := make(chan error) - walletStash := LoadStash(ctx, kr, eventChan, errorChan) + walletStash := loadStash(ctx, kr, eventChan, make(chan struct{})) var errorChanErr error go func() { for { - errorChanErr = <-errorChan + errorChanErr = <-walletStash.Processed if errorChanErr != nil { return } diff --git a/nip60/receive.go b/nip60/receive.go index b5f0823..3a83bb3 100644 --- a/nip60/receive.go +++ b/nip60/receive.go @@ -89,13 +89,25 @@ func (w *Wallet) ReceiveToken(ctx context.Context, serializedToken string) error } saveproofs: - w.tokensMu.Lock() - w.Tokens = append(w.Tokens, Token{ + newToken := Token{ Mint: newMint, Proofs: newProofs, mintedAt: nostr.Now(), - }) + event: &nostr.Event{}, + } + if err := newToken.toEvent(ctx, w.wl.kr, w.Identifier, newToken.event); err != nil { + return fmt.Errorf("failed to make new token: %w", err) + } + + w.wl.Changes <- *newToken.event + + w.tokensMu.Lock() + w.Tokens = append(w.Tokens, newToken) w.tokensMu.Unlock() + wevt := nostr.Event{} + w.toEvent(ctx, w.wl.kr, &wevt) + w.wl.Changes <- wevt + return nil } diff --git a/nip60/send.go b/nip60/send.go index aa40979..a32c49d 100644 --- a/nip60/send.go +++ b/nip60/send.go @@ -130,18 +130,40 @@ found: } // 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{ + updatedTokens := make([]Token, 0, len(w.Tokens)) + + changeToken := Token{ mintedAt: nostr.Now(), Mint: target.mint, Proofs: changeProofs, - }) + Deleted: make([]string, 0, len(target.tokenIndexes)), + event: &nostr.Event{}, + } + + for i, token := range w.Tokens { + if slices.Contains(target.tokenIndexes, i) { + if token.event != nil { + token.Deleted = append(token.Deleted, token.event.ID) + + deleteEvent := nostr.Event{ + CreatedAt: nostr.Now(), + Kind: 5, + Tags: nostr.Tags{{"e", token.event.ID}}, + } + w.wl.kr.SignEvent(ctx, &deleteEvent) + w.wl.Changes <- deleteEvent + } + continue + } + 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) + } + 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) @@ -149,5 +171,9 @@ found: return "", err } + wevt := nostr.Event{} + w.toEvent(ctx, w.wl.kr, &wevt) + w.wl.Changes <- wevt + return token.Serialize() } diff --git a/nip60/stash.go b/nip60/stash.go index 6112064..d809a4b 100644 --- a/nip60/stash.go +++ b/nip60/stash.go @@ -15,8 +15,213 @@ type WalletStash struct { sync.Mutex wallets map[string]*Wallet - pendingTokens map[string][]Token // tokens not yet assigned to a wallet - pendingHistory map[string][]HistoryEntry // history entries not yet assigned to a wallet + pendingHistory map[string][]HistoryEntry // history entries not yet assigned to a wallet + pendingTokens map[string][]Token // tokens not yet assigned to a wallet + pendingDeletions []string // token events that should be deleted + + kr nostr.Keyer + + // Changes emits a stream of events that must be published whenever something changes + Changes chan nostr.Event + + // Processed emits an error or nil every time an event is processed + Processed chan error + + // Stable is closed when we have gotten an EOSE from all relays + Stable chan struct{} +} + +func LoadStash( + ctx context.Context, + kr nostr.Keyer, + pool *nostr.SimplePool, + relays []string, +) *WalletStash { + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return nil + } + + eoseChan := make(chan struct{}) + events := pool.SubManyNotifyEOSE( + ctx, + relays, + nostr.Filters{{Kinds: []int{5, 37375, 7375, 7376}, Authors: []string{pk}}}, + eoseChan, + ) + + return loadStash(ctx, kr, events, eoseChan) +} + +func loadStash( + ctx context.Context, + kr nostr.Keyer, + events chan nostr.RelayEvent, + eoseChan chan struct{}, +) *WalletStash { + wl := &WalletStash{ + wallets: make(map[string]*Wallet, 1), + pendingTokens: make(map[string][]Token), + pendingHistory: make(map[string][]HistoryEntry), + pendingDeletions: make([]string, 0, 128), + kr: kr, + Changes: make(chan nostr.Event), + Processed: make(chan error), + Stable: make(chan struct{}), + } + + eosed := false + go func() { + <-eoseChan + eosed = true + + // check all pending deletions and delete stuff locally + for _, id := range wl.pendingDeletions { + wl.removeDeletedToken(id) + } + wl.pendingDeletions = nil + + close(wl.Stable) + }() + + go func() { + for ie := range events { + wl.Lock() + switch ie.Event.Kind { + case 5: + if !eosed { + for _, tag := range ie.Event.Tags.All([]string{"e", ""}) { + wl.pendingDeletions = append(wl.pendingDeletions, tag[1]) + } + } else { + for _, tag := range ie.Event.Tags.All([]string{"e", ""}) { + wl.removeDeletedToken(tag[1]) + } + } + case 37375: + wallet := &Wallet{ + wl: wl, + } + if err := wallet.parse(ctx, kr, ie.Event); err != nil { + wl.Unlock() + wl.Processed <- fmt.Errorf("event %s failed: %w", ie.Event, err) + continue + } + + // if we already have a wallet with this identifier then we must be careful + if curr, ok := wl.wallets[wallet.Identifier]; ok { + // if the metadata we have is newer ignore this event + if curr.event.CreatedAt > ie.Event.CreatedAt { + wl.Unlock() + continue + } + + // otherwise transfer history events and tokens to the new wallet object + wallet.Tokens = curr.Tokens + wallet.History = curr.History + } + + // get all pending stuff and assign them to this, then delete the pending stuff + for _, he := range wl.pendingHistory[wallet.Identifier] { + wallet.History = append(wallet.History, he) + } + delete(wl.pendingHistory, wallet.Identifier) + wallet.tokensMu.Lock() + for _, token := range wl.pendingTokens[wallet.Identifier] { + wallet.Tokens = append(wallet.Tokens, token) + } + delete(wl.pendingTokens, wallet.Identifier) + wallet.tokensMu.Unlock() + + // finally save the new wallet object + wl.wallets[wallet.Identifier] = wallet + + case 7375: // token + ref := ie.Event.Tags.GetFirst([]string{"a", ""}) + if ref == nil { + wl.Unlock() + wl.Processed <- fmt.Errorf("event %s missing 'a' tag", ie.Event) + continue + } + spl := strings.SplitN((*ref)[1], ":", 3) + if len(spl) < 3 { + wl.Unlock() + wl.Processed <- fmt.Errorf("event %s invalid 'a' tag", ie.Event) + continue + } + + token := Token{} + if err := token.parse(ctx, kr, ie.Event); err != nil { + wl.Unlock() + wl.Processed <- fmt.Errorf("event %s failed: %w", ie.Event, err) + continue + } + + 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) + } + + // keep track tokens that were deleted by this, if they exist + if !eosed { + for _, del := range token.Deleted { + wl.pendingDeletions = append(wl.pendingDeletions, del) + } + } else { + for _, del := range token.Deleted { + wl.removeDeletedToken(del) + } + } + + case 7376: // history + ref := ie.Event.Tags.GetFirst([]string{"a", ""}) + if ref == nil { + wl.Unlock() + wl.Processed <- fmt.Errorf("event %s missing 'a' tag", ie.Event) + continue + } + spl := strings.SplitN((*ref)[1], ":", 3) + if len(spl) < 3 { + wl.Unlock() + wl.Processed <- fmt.Errorf("event %s invalid 'a' tag", ie.Event) + continue + } + + he := HistoryEntry{} + if err := he.parse(ctx, kr, ie.Event); err != nil { + wl.Unlock() + wl.Processed <- fmt.Errorf("event %s failed: %w", ie.Event, err) + continue + } + + if wallet, ok := wl.wallets[spl[2]]; ok { + wallet.History = append(wallet.History, he) + } else { + wl.pendingHistory[spl[2]] = append(wl.pendingHistory[spl[2]], he) + } + } + + wl.Unlock() + } + }() + + return wl +} + +func (wl *WalletStash) removeDeletedToken(eventId string) { + for _, w := range wl.wallets { + for t := len(w.Tokens) - 1; t >= 0; t-- { + token := w.Tokens[t] + if token.event != nil && token.event.ID == eventId { + // swap delete + w.Tokens[t] = w.Tokens[len(w.Tokens)-1] + w.Tokens = w.Tokens[0 : len(w.Tokens)-1] + } + } + } } func (wl *WalletStash) EnsureWallet(id string) *Wallet { @@ -35,6 +240,7 @@ func (wl *WalletStash) EnsureWallet(id string) *Wallet { Identifier: id, PrivateKey: sk, PublicKey: sk.PubKey(), + wl: wl, } wl.wallets[id] = w return w @@ -52,125 +258,3 @@ func (wl *WalletStash) Wallets() iter.Seq[*Wallet] { } } } - -func NewStash() *WalletStash { - return &WalletStash{ - wallets: make(map[string]*Wallet, 1), - pendingTokens: make(map[string][]Token), - pendingHistory: make(map[string][]HistoryEntry), - } -} - -func LoadStash( - ctx context.Context, - kr nostr.Keyer, - events <-chan nostr.RelayEvent, - errors chan<- error, -) *WalletStash { - wl := &WalletStash{ - wallets: make(map[string]*Wallet, 1), - pendingTokens: make(map[string][]Token), - pendingHistory: make(map[string][]HistoryEntry), - } - - go func() { - for ie := range events { - wl.Lock() - switch ie.Event.Kind { - case 37375: - wallet := &Wallet{} - if err := wallet.parse(ctx, kr, ie.Event); err != nil { - wl.Unlock() - if errors != nil { - errors <- fmt.Errorf("event %s failed: %w", ie.Event, err) - } - continue - } - - 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 - - case 7375: // token - ref := ie.Event.Tags.GetFirst([]string{"a", ""}) - if ref == nil { - wl.Unlock() - if errors != nil { - errors <- fmt.Errorf("event %s missing 'a' tag", ie.Event) - } - continue - } - spl := strings.SplitN((*ref)[1], ":", 3) - if len(spl) < 3 { - wl.Unlock() - if errors != nil { - errors <- fmt.Errorf("event %s invalid 'a' tag", ie.Event) - } - continue - } - - token := Token{} - if err := token.parse(ctx, kr, ie.Event); err != nil { - wl.Unlock() - if errors != nil { - errors <- fmt.Errorf("event %s failed: %w", ie.Event, err) - } - continue - } - - 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) - } - - case 7376: // history - ref := ie.Event.Tags.GetFirst([]string{"a", ""}) - if ref == nil { - wl.Unlock() - if errors != nil { - errors <- fmt.Errorf("event %s missing 'a' tag", ie.Event) - } - continue - } - spl := strings.SplitN((*ref)[1], ":", 3) - if len(spl) < 3 { - wl.Unlock() - if errors != nil { - errors <- fmt.Errorf("event %s invalid 'a' tag", ie.Event) - } - continue - } - - he := HistoryEntry{} - if err := he.parse(ctx, kr, ie.Event); err != nil { - wl.Unlock() - if errors != nil { - errors <- fmt.Errorf("event %s failed: %w", ie.Event, err) - } - continue - } - - if wallet, ok := wl.wallets[spl[2]]; ok { - wallet.History = append(wallet.History, he) - } else { - wl.pendingHistory[spl[2]] = append(wl.pendingHistory[spl[2]], he) - } - } - - wl.Unlock() - } - }() - - return wl -} diff --git a/nip60/token.go b/nip60/token.go index bd9ad4f..649e76c 100644 --- a/nip60/token.go +++ b/nip60/token.go @@ -10,8 +10,9 @@ import ( ) type Token struct { - Mint string `json:"mint"` - Proofs cashu.Proofs `json:"proofs"` + Mint string `json:"mint"` + Proofs cashu.Proofs `json:"proofs"` + Deleted []string `json:"del,omitempty"` mintedAt nostr.Timestamp event *nostr.Event diff --git a/nip60/wallet.go b/nip60/wallet.go index 0b3411c..80c5102 100644 --- a/nip60/wallet.go +++ b/nip60/wallet.go @@ -14,6 +14,8 @@ import ( ) type Wallet struct { + wl *WalletStash + Identifier string Description string Name string @@ -26,6 +28,9 @@ type Wallet struct { temporaryBalance uint64 tokensMu sync.Mutex + + event *nostr.Event + tokensPendingDeletion []string } func (w *Wallet) Balance() uint64 { @@ -43,20 +48,14 @@ func (w *Wallet) DisplayName() string { return w.Identifier } -func (w *Wallet) ToPublishableEvents( - ctx context.Context, - kr nostr.Keyer, - skipExisting bool, -) ([]nostr.Event, error) { - evt := nostr.Event{ - CreatedAt: nostr.Now(), - Kind: 37375, - Tags: make(nostr.Tags, 0, 7), - } +func (w *Wallet) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { + evt.CreatedAt = nostr.Now() + evt.Kind = 37375 + evt.Tags = make(nostr.Tags, 0, 7) pk, err := kr.GetPublicKey(ctx) if err != nil { - return nil, err + return err } evt.Content, err = kr.Encrypt( @@ -65,7 +64,7 @@ func (w *Wallet) ToPublishableEvents( pk, ) if err != nil { - return nil, err + return err } evt.Tags = append(evt.Tags, @@ -85,64 +84,23 @@ func (w *Wallet) ToPublishableEvents( evt.Tags = append(evt.Tags, nostr.Tag{"mint", mint}) } - err = kr.SignEvent(ctx, &evt) + err = kr.SignEvent(ctx, evt) if err != nil { - return nil, err + return err } - 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 - - if t.event != nil { - if skipExisting { - continue - } - evt = *t.event - } else { - err := t.toEvent(ctx, kr, w.Identifier, &evt) - if err != nil { - return nil, err - } - } - - events = append(events, evt) - } - w.tokensMu.Unlock() - - for _, h := range w.History { - var evt nostr.Event - - if h.event != nil { - if skipExisting { - continue - } - evt = *h.event - } else { - err := h.toEvent(ctx, kr, w.Identifier, &evt) - if err != nil { - return nil, err - } - } - - events = append(events, evt) - } - - return events, nil + return nil } func (w *Wallet) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { w.Tokens = make([]Token, 0, 128) w.History = make([]HistoryEntry, 0, 128) + w.event = evt pk, err := kr.GetPublicKey(ctx) if err != nil { return err } - jsonb, err := kr.Decrypt(ctx, evt.Content, pk) if err != nil { return err