diff --git a/nip60/eventcodec_test.go b/nip60/eventcodec_test.go deleted file mode 100644 index 2b6b68d..0000000 --- a/nip60/eventcodec_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package nip60 - -import ( - "context" - "testing" - "time" - - "github.com/btcsuite/btcd/btcec/v2" - "github.com/elnosh/gonuts/cashu" - "github.com/nbd-wtf/go-nostr" - "github.com/nbd-wtf/go-nostr/keyer" - "github.com/stretchr/testify/require" -) - -func TestWalletRoundtrip(t *testing.T) { - ctx := context.Background() - kr, err := keyer.NewPlainKeySigner("94b46586f475bbc92746cb8f14d59b083047ac3ab747774b066d17673c1cc527") - require.NoError(t, err) - - // create initial wallets with arbitrary data - sk1, _ := btcec.NewPrivateKey() - wallet1 := Wallet{ - Identifier: "wallet1", - Name: "My First Wallet", - Description: "Test wallet number one", - PrivateKey: sk1, - Relays: []string{"wss://relay1.example.com", "wss://relay2.example.com"}, - Mints: []string{"https://mint1.example.com"}, - Tokens: []Token{ - { - Mint: "https://mint1.example.com", - Proofs: []cashu.Proof{ - {Id: "proof1", Amount: 100, Secret: "secret1", C: "c1"}, - {Id: "proof2", Amount: 200, Secret: "secret2", C: "c2"}, - }, - mintedAt: nostr.Now(), - }, - { - Mint: "https://mint2.example.com", - Proofs: []cashu.Proof{ - {Id: "proof3", Amount: 500, Secret: "secret3", C: "c3"}, - }, - mintedAt: nostr.Now(), - }, - }, - History: []HistoryEntry{ - { - In: true, - Amount: 300, - tokenEventIDs: []string{ - "559cecf5aba6ab825347bedfd56ff603a2c6aa7c8d88790ca1e232759699bbc7", - "8f2c40b064e3e601d070362f53ace6fe124992da8a7322357c0868f22f6c2350", - }, - nutZaps: []bool{false, false}, - createdAt: nostr.Now(), - }, - }, - } - - sk2, _ := btcec.NewPrivateKey() - wallet2 := Wallet{ - Identifier: "wallet2", - Name: "Second Wallet", - Description: "Test wallet number two", - PrivateKey: sk2, - Relays: []string{"wss://relay3.example.com"}, - Mints: []string{"https://mint2.example.com"}, - Tokens: []Token{ - { - Mint: "https://mint2.example.com", - Proofs: []cashu.Proof{ - {Id: "proof3", Amount: 500, Secret: "secret3", C: "c3"}, - }, - mintedAt: nostr.Now(), - }, - }, - History: []HistoryEntry{ - { - In: false, - Amount: 200, - tokenEventIDs: []string{ - "cc9dd6298ae7e1ae0866448f11fed1c3a818b7db837caf8d5c48e496200477fe", - }, - nutZaps: []bool{false}, - createdAt: nostr.Now(), - }, - { - In: true, - Amount: 300, - tokenEventIDs: []string{ - "63e8ff4ca4f16d6edc0c93dd1659cc8029178560aef2c9a00ca323738ed680e3", - "3898e1c01fd6043dd46b819ce6a940867ccc116bc7c733124d2c0658fb1d569e", - }, - nutZaps: []bool{false, false}, - createdAt: nostr.Now(), - }, - }, - } - - // convert wallets to events - events := [][]nostr.Event{ - make([]nostr.Event, 0, 4), - make([]nostr.Event, 0, 4), - } - - 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...) - require.Len(t, allEvents, 8) - - // make a derived shuffled version - reversedAllEvents := make([]nostr.Event, len(allEvents)) - for i, evt := range allEvents { - reversedAllEvents[len(allEvents)-1-i] = evt - } - - for _, allEvents := range [][]nostr.Event{allEvents, reversedAllEvents} { - // create channel and feed events into it - eventChan := make(chan nostr.RelayEvent) - done := make(chan struct{}) - go func() { - for _, evt := range allEvents { - eventChan <- nostr.RelayEvent{Event: &evt} - } - close(eventChan) - done <- struct{}{} - }() - - // load wallets from events - walletStash := loadStash(ctx, kr, eventChan, make(chan struct{})) - - <-done - time.Sleep(time.Millisecond * 200) - - // compare loaded wallets with original ones - loadedWallet1 := walletStash.wallets[wallet1.Identifier] - require.Equal(t, wallet1.Name, loadedWallet1.Name) - require.Equal(t, wallet1.Description, loadedWallet1.Description) - require.Equal(t, wallet1.Mints, loadedWallet1.Mints) - require.Equal(t, wallet1.PrivateKey, loadedWallet1.PrivateKey) - require.Len(t, loadedWallet1.Tokens, len(wallet1.Tokens)) - require.Len(t, loadedWallet1.History, len(wallet1.History)) - - loadedWallet2 := walletStash.wallets[wallet2.Identifier] - require.Equal(t, wallet2.Name, loadedWallet2.Name) - require.Equal(t, wallet2.Description, loadedWallet2.Description) - require.Equal(t, wallet2.Mints, loadedWallet2.Mints) - require.Equal(t, wallet2.PrivateKey, loadedWallet2.PrivateKey) - require.Len(t, loadedWallet2.Tokens, len(wallet2.Tokens)) - require.Len(t, loadedWallet2.History, len(wallet2.History)) - - // check token amounts - require.Equal(t, wallet1.Balance(), loadedWallet1.Balance()) - require.Equal(t, wallet2.Balance(), loadedWallet2.Balance()) - } -} diff --git a/nip60/history.go b/nip60/history.go index 7eab800..2d78c2e 100644 --- a/nip60/history.go +++ b/nip60/history.go @@ -13,14 +13,19 @@ type HistoryEntry struct { In bool // in = received, out = sent Amount uint64 - tokenEventIDs []string - nutZaps []bool + TokenReferences []TokenRef createdAt nostr.Timestamp event *nostr.Event } -func (h HistoryEntry) toEvent(ctx context.Context, kr nostr.Keyer, walletId string, evt *nostr.Event) error { +type TokenRef struct { + EventID string + Created bool + IsNutzap bool +} + +func (h HistoryEntry) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { pk, err := kr.GetPublicKey(ctx) if err != nil { return err @@ -33,27 +38,25 @@ func (h HistoryEntry) toEvent(ctx context.Context, kr nostr.Keyer, walletId stri evt.CreatedAt = h.createdAt evt.Kind = 7376 - evt.Tags = nostr.Tags{{"a", fmt.Sprintf("37375:%s:%s", pk, walletId)}} + evt.Tags = nostr.Tags{} encryptedTags := nostr.Tags{ nostr.Tag{"direction", dir}, - nostr.Tag{"amount", strconv.FormatUint(uint64(h.Amount), 10), "sat"}, + nostr.Tag{"amount", strconv.FormatUint(uint64(h.Amount), 10)}, } - for i, tid := range h.tokenEventIDs { - isNutZap := h.nutZaps[i] - - if h.In && isNutZap { - evt.Tags = append(evt.Tags, nostr.Tag{"e", tid, "", "redeemed"}) + for _, tf := range h.TokenReferences { + if tf.IsNutzap { + evt.Tags = append(evt.Tags, nostr.Tag{"e", tf.EventID, "", "redeemed"}) continue } - marker := "created" - if !h.In { - marker = "destroyed" + marker := "destroyed" + if tf.Created { + marker = "created" } - encryptedTags = append(encryptedTags, nostr.Tag{"e", tid, "", marker}) + encryptedTags = append(encryptedTags, nostr.Tag{"e", tf.EventID, "", marker}) } jsonb, _ := json.Marshal(encryptedTags) @@ -97,14 +100,14 @@ func (h *HistoryEntry) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Eve tags = append(tags, evt.Tags...) } - essential := 0 + missingDirection := true for _, tag := range tags { if len(tag) < 2 { continue } switch tag[0] { case "direction": - essential++ + missingDirection = false if tag[1] == "in" { h.In = true } else if tag[1] == "out" { @@ -113,40 +116,45 @@ func (h *HistoryEntry) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Eve return fmt.Errorf("unexpected 'direction' tag %s", tag[1]) } case "amount": - essential++ if len(tag) < 2 { return fmt.Errorf("'amount' tag must have at least 2 items") } - if len(tag) >= 3 && tag[2] != "sat" { - return fmt.Errorf("only 'sat' wallets are supported") - } v, err := strconv.ParseUint(tag[1], 10, 64) if err != nil { return fmt.Errorf("invalid 'amount' %s: %w", tag[1], err) } h.Amount = v case "e": - essential++ if len(tag) < 4 { return fmt.Errorf("'e' tag must have at least 4 items") } if !nostr.IsValid32ByteHex(tag[1]) { return fmt.Errorf("'e' tag has invalid event id %s", tag[1]) } - h.tokenEventIDs = append(h.tokenEventIDs, tag[1]) + + h.TokenReferences = append(h.TokenReferences) + + tf := TokenRef{EventID: tag[1]} + switch tag[3] { case "created": - h.nutZaps = append(h.nutZaps, false) + tf.Created = true case "destroyed": - h.nutZaps = append(h.nutZaps, false) + tf.Created = false case "redeemed": - h.nutZaps = append(h.nutZaps, true) + tf.IsNutzap = true + default: + return fmt.Errorf("unsupported 'e' token marker: %s", tag[3]) } } } - if essential < 3 { - return fmt.Errorf("missing essential tags") + if h.Amount == 0 { + return fmt.Errorf("missing 'amount' tag") + } + + if missingDirection { + return fmt.Errorf("missing 'direction' tag") } return nil diff --git a/nip60/pay.go b/nip60/pay.go index 16ada39..f6dc63e 100644 --- a/nip60/pay.go +++ b/nip60/pay.go @@ -12,7 +12,7 @@ import ( ) func (w *Wallet) PayBolt11(ctx context.Context, invoice string, opts ...SendOption) (string, error) { - if w.wl.PublishUpdate == nil { + if w.PublishUpdate == nil { return "", fmt.Errorf("can't do write operations: missing PublishUpdate function") } @@ -124,23 +124,22 @@ inspectmeltstatusresponse: } he := HistoryEntry{ - event: &nostr.Event{}, - tokenEventIDs: make([]string, 0, 1), - nutZaps: make([]bool, 0, 1), - createdAt: nostr.Now(), - In: false, - Amount: chosen.proofs.Amount() - changeProofs.Amount(), + event: &nostr.Event{}, + TokenReferences: make([]TokenRef, 0, 5), + createdAt: nostr.Now(), + In: false, + Amount: chosen.proofs.Amount() - changeProofs.Amount(), } if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes, &he); err != nil { return "", err } - w.wl.Lock() - if err := he.toEvent(ctx, w.wl.kr, w.Identifier, he.event); err == nil { - w.wl.PublishUpdate(*he.event, nil, nil, nil, true) + w.Lock() + if err := he.toEvent(ctx, w.kr, he.event); err == nil { + w.PublishUpdate(*he.event, nil, nil, nil, true) } - w.wl.Unlock() + w.Unlock() return meltStatus.Preimage, nil } diff --git a/nip60/receive.go b/nip60/receive.go index e1ca1db..19fa487 100644 --- a/nip60/receive.go +++ b/nip60/receive.go @@ -16,7 +16,7 @@ func (w *Wallet) Receive( proofs cashu.Proofs, mint string, ) error { - if w.wl.PublishUpdate == nil { + if w.PublishUpdate == nil { return fmt.Errorf("can't do write operations: missing PublishUpdate function") } @@ -100,25 +100,30 @@ saveproofs: mintedAt: nostr.Now(), event: &nostr.Event{}, } - if err := newToken.toEvent(ctx, w.wl.kr, w.Identifier, newToken.event); err != nil { + if err := newToken.toEvent(ctx, w.kr, newToken.event); err != nil { return fmt.Errorf("failed to make new token: %w", err) } he := HistoryEntry{ - event: &nostr.Event{}, - tokenEventIDs: []string{newToken.event.ID}, - nutZaps: []bool{false}, - createdAt: nostr.Now(), - In: true, - Amount: newToken.Proofs.Amount(), + event: &nostr.Event{}, + TokenReferences: []TokenRef{ + { + EventID: newToken.event.ID, + Created: true, + IsNutzap: false, + }, + }, + createdAt: nostr.Now(), + In: true, + Amount: newToken.Proofs.Amount(), } - w.wl.Lock() - w.wl.PublishUpdate(*newToken.event, nil, &newToken, nil, false) - if err := he.toEvent(ctx, w.wl.kr, w.Identifier, he.event); err == nil { - w.wl.PublishUpdate(*he.event, nil, nil, nil, true) + w.Lock() + w.PublishUpdate(*newToken.event, nil, &newToken, nil, false) + if err := he.toEvent(ctx, w.kr, he.event); err == nil { + w.PublishUpdate(*he.event, nil, nil, nil, true) } - w.wl.Unlock() + w.Unlock() w.tokensMu.Lock() w.Tokens = append(w.Tokens, newToken) diff --git a/nip60/send-external.go b/nip60/send-external.go index b501c1b..f0fcab2 100644 --- a/nip60/send-external.go +++ b/nip60/send-external.go @@ -15,7 +15,7 @@ func (w *Wallet) SendExternal( targetAmount uint64, opts ...SendOption, ) (cashu.Proofs, error) { - if w.wl.PublishUpdate == nil { + if w.PublishUpdate == nil { return nil, fmt.Errorf("can't do write operations: missing PublishUpdate function") } diff --git a/nip60/send.go b/nip60/send.go index 5aad447..e865a85 100644 --- a/nip60/send.go +++ b/nip60/send.go @@ -51,7 +51,7 @@ type chosenTokens struct { } func (w *Wallet) Send(ctx context.Context, amount uint64, opts ...SendOption) (cashu.Proofs, string, error) { - if w.wl.PublishUpdate == nil { + if w.PublishUpdate == nil { return nil, "", fmt.Errorf("can't do write operations: missing PublishUpdate function") } @@ -101,23 +101,22 @@ func (w *Wallet) Send(ctx context.Context, amount uint64, opts ...SendOption) (c } he := HistoryEntry{ - event: &nostr.Event{}, - tokenEventIDs: make([]string, 0, 1), - nutZaps: make([]bool, 0, 1), - createdAt: nostr.Now(), - In: false, - Amount: chosen.proofs.Amount() - changeProofs.Amount(), + event: &nostr.Event{}, + TokenReferences: make([]TokenRef, 0, 5), + createdAt: nostr.Now(), + In: false, + Amount: chosen.proofs.Amount() - changeProofs.Amount(), } if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes, &he); err != nil { return nil, chosen.mint, err } - w.wl.Lock() - if err := he.toEvent(ctx, w.wl.kr, w.Identifier, he.event); err == nil { - w.wl.PublishUpdate(*he.event, nil, nil, nil, true) + w.Lock() + if err := he.toEvent(ctx, w.kr, he.event); err == nil { + w.PublishUpdate(*he.event, nil, nil, nil, true) } - w.wl.Unlock() + w.Unlock() return proofsToSend, chosen.mint, nil } @@ -150,11 +149,18 @@ func (w *Wallet) saveChangeAndDeleteUsedTokens( Kind: 5, Tags: nostr.Tags{{"e", token.event.ID}, {"k", "7375"}}, } - w.wl.kr.SignEvent(ctx, &deleteEvent) + w.kr.SignEvent(ctx, &deleteEvent) - w.wl.Lock() - w.wl.PublishUpdate(deleteEvent, &token, nil, nil, false) - w.wl.Unlock() + w.Lock() + w.PublishUpdate(deleteEvent, &token, nil, nil, false) + w.Unlock() + + // fill in the history deleted token + he.TokenReferences = append(he.TokenReferences, TokenRef{ + EventID: token.event.ID, + Created: false, + IsNutzap: false, + }) } continue } @@ -162,19 +168,22 @@ func (w *Wallet) saveChangeAndDeleteUsedTokens( } if len(changeToken.Proofs) > 0 { - if err := changeToken.toEvent(ctx, w.wl.kr, w.Identifier, changeToken.event); err != nil { + if err := changeToken.toEvent(ctx, w.kr, changeToken.event); err != nil { return fmt.Errorf("failed to make change token: %w", err) } - w.wl.Lock() - w.wl.PublishUpdate(*changeToken.event, nil, nil, &changeToken, false) - w.wl.Unlock() + w.Lock() + w.PublishUpdate(*changeToken.event, nil, nil, &changeToken, false) + w.Unlock() // we don't have to lock tokensMu here because this function will always be called with that lock already held w.Tokens = append(updatedTokens, changeToken) // fill in the history created token - he.tokenEventIDs = append(he.tokenEventIDs, changeToken.event.ID) - he.nutZaps = append(he.nutZaps, false) + he.TokenReferences = append(he.TokenReferences, TokenRef{ + EventID: changeToken.event.ID, + Created: true, + IsNutzap: false, + }) } return nil diff --git a/nip60/stash.go b/nip60/stash.go deleted file mode 100644 index cc7a76d..0000000 --- a/nip60/stash.go +++ /dev/null @@ -1,342 +0,0 @@ -package nip60 - -import ( - "context" - "fmt" - "iter" - "slices" - "strings" - "sync" - "time" - - "github.com/btcsuite/btcd/btcec/v2" - "github.com/nbd-wtf/go-nostr" -) - -type WalletStash struct { - sync.Mutex - wallets map[string]*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 - - // PublishUpdate must be set to a function that publishes event to the user relays - PublishUpdate func( - event nostr.Event, - deleted *Token, - received *Token, - change *Token, - isHistory bool, - ) - - // Processed, if not nil, is called every time a received event is processed - Processed func(*nostr.Event, 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 { - return loadStashFromPool(ctx, kr, pool, relays, false) -} - -func LoadStashWithHistory( - ctx context.Context, - kr nostr.Keyer, - pool *nostr.SimplePool, - relays []string, -) *WalletStash { - return loadStashFromPool(ctx, kr, pool, relays, true) -} - -func loadStashFromPool( - ctx context.Context, - kr nostr.Keyer, - pool *nostr.SimplePool, - relays []string, - withHistory bool, -) *WalletStash { - pk, err := kr.GetPublicKey(ctx) - if err != nil { - return nil - } - - kinds := []int{37375, 7375} - if withHistory { - kinds = append(kinds, 7375) - } - - eoseChan := make(chan struct{}) - events := pool.SubManyNotifyEOSE( - ctx, - relays, - nostr.Filters{ - {Kinds: kinds, Authors: []string{pk}}, - {Kinds: []int{5}, Tags: nostr.TagMap{"k": []string{"7375"}}, 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, - 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 - - time.Sleep(100 * time.Millisecond) // race condition hack - 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 { - if wl.Processed != nil { - wl.Processed(ie.Event, err) - } - wl.Unlock() - 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 != nil && 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] { - if !slices.ContainsFunc(wallet.History, func(c HistoryEntry) bool { - return c.event.ID == he.event.ID - }) { - wallet.History = append(wallet.History, he) - } - } - delete(wl.pendingHistory, wallet.Identifier) - wallet.tokensMu.Lock() - for _, token := range wl.pendingTokens[wallet.Identifier] { - if !slices.ContainsFunc(wallet.Tokens, func(c Token) bool { - return c.event.ID == token.event.ID - }) { - 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 { - if wl.Processed != nil { - wl.Processed(ie.Event, fmt.Errorf("event missing 'a' tag")) - } - wl.Unlock() - continue - } - spl := strings.SplitN((*ref)[1], ":", 3) - if len(spl) < 3 { - if wl.Processed != nil { - wl.Processed(ie.Event, fmt.Errorf("event with invalid 'a' tag")) - } - wl.Unlock() - continue - } - - token := Token{} - if err := token.parse(ctx, kr, ie.Event); err != nil { - if wl.Processed != nil { - wl.Processed(ie.Event, err) - } - wl.Unlock() - continue - } - - if wallet, ok := wl.wallets[spl[2]]; ok { - wallet.tokensMu.Lock() - if !slices.ContainsFunc(wallet.Tokens, func(c Token) bool { - return c.event.ID == token.event.ID - }) { - 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 { - if wl.Processed != nil { - wl.Processed(ie.Event, fmt.Errorf("event missing 'a' tag")) - } - wl.Unlock() - continue - } - spl := strings.SplitN((*ref)[1], ":", 3) - if len(spl) < 3 { - if wl.Processed != nil { - wl.Processed(ie.Event, fmt.Errorf("event with invalid 'a' tag")) - } - wl.Unlock() - continue - } - - he := HistoryEntry{} - if err := he.parse(ctx, kr, ie.Event); err != nil { - if wl.Processed != nil { - wl.Processed(ie.Event, err) - } - wl.Unlock() - continue - } - - if wallet, ok := wl.wallets[spl[2]]; ok { - if !slices.ContainsFunc(wallet.History, func(c HistoryEntry) bool { - return c.event.ID == he.event.ID - }) { - wallet.History = append(wallet.History, he) - } - } else { - wl.pendingHistory[spl[2]] = append(wl.pendingHistory[spl[2]], he) - } - } - - if wl.Processed != nil { - wl.Processed(ie.Event, nil) - } - wl.Unlock() - } - }() - - return wl -} - -func (wl *WalletStash) EnsureWallet(ctx context.Context, id string) *Wallet { - wl.Lock() - defer wl.Unlock() - if w, ok := wl.wallets[id]; ok { - return w - } - - sk, err := btcec.NewPrivateKey() - if err != nil { - return nil - } - - w := &Wallet{ - Identifier: id, - PrivateKey: sk, - PublicKey: sk.PubKey(), - wl: wl, - } - wl.wallets[id] = w - - event := nostr.Event{} - if err := w.toEvent(ctx, wl.kr, &event); err != nil { - return nil - } - - wl.PublishUpdate(event, nil, nil, nil, false) - - return w -} - -func (wl *WalletStash) Wallets() iter.Seq[*Wallet] { - return func(yield func(*Wallet) bool) { - wl.Lock() - defer wl.Unlock() - - for _, w := range wl.wallets { - if !yield(w) { - return - } - } - } -} - -// Close waits for pending operations to end -func (wl *WalletStash) Close() error { - wl.Lock() - defer wl.Unlock() - return nil -} - -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] - } - } - } -} diff --git a/nip60/token.go b/nip60/token.go index 250edc7..f3b4fc7 100644 --- a/nip60/token.go +++ b/nip60/token.go @@ -26,7 +26,7 @@ func (t Token) ID() string { return "" } -func (t Token) toEvent(ctx context.Context, kr nostr.Keyer, walletId string, evt *nostr.Event) error { +func (t Token) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { pk, err := kr.GetPublicKey(ctx) if err != nil { return err @@ -34,7 +34,7 @@ func (t Token) toEvent(ctx context.Context, kr nostr.Keyer, walletId string, evt evt.CreatedAt = t.mintedAt evt.Kind = 7375 - evt.Tags = nostr.Tags{{"a", fmt.Sprintf("37375:%s:%s", pk, walletId)}} + evt.Tags = nostr.Tags{} content, _ := json.Marshal(t) evt.Content, err = kr.Encrypt( diff --git a/nip60/wallet.go b/nip60/wallet.go index ff5b037..8152cf8 100644 --- a/nip60/wallet.go +++ b/nip60/wallet.go @@ -5,7 +5,9 @@ import ( "encoding/hex" "encoding/json" "fmt" + "slices" "sync" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -13,22 +15,210 @@ import ( ) type Wallet struct { - wl *WalletStash - - Identifier string - Description string - Name string - PrivateKey *btcec.PrivateKey - PublicKey *btcec.PublicKey - Relays []string - Mints []string - Tokens []Token - History []HistoryEntry - + sync.Mutex tokensMu sync.Mutex + event *nostr.Event - event *nostr.Event - tokensPendingDeletion []string + pendingDeletions []string // token events that should be deleted + + kr nostr.Keyer + + // PublishUpdate must be set to a function that publishes event to the user relays + PublishUpdate func( + event nostr.Event, + deleted *Token, + received *Token, + change *Token, + isHistory bool, + ) + + // Processed, if not nil, is called every time a received event is processed + Processed func(*nostr.Event, error) + + // Stable is closed when we have gotten an EOSE from all relays + Stable chan struct{} + + // properties that come in events + PrivateKey *btcec.PrivateKey + PublicKey *btcec.PublicKey + Mints []string + Tokens []Token + History []HistoryEntry +} + +func LoadWallet( + ctx context.Context, + kr nostr.Keyer, + pool *nostr.SimplePool, + relays []string, +) *Wallet { + return loadWalletFromPool(ctx, kr, pool, relays, false) +} + +func LoadWalletWithHistory( + ctx context.Context, + kr nostr.Keyer, + pool *nostr.SimplePool, + relays []string, +) *Wallet { + return loadWalletFromPool(ctx, kr, pool, relays, true) +} + +func loadWalletFromPool( + ctx context.Context, + kr nostr.Keyer, + pool *nostr.SimplePool, + relays []string, + withHistory bool, +) *Wallet { + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return nil + } + + kinds := []int{37375, 7375} + if withHistory { + kinds = append(kinds, 7375) + } + + eoseChan := make(chan struct{}) + events := pool.SubManyNotifyEOSE( + ctx, + relays, + nostr.Filters{ + {Kinds: kinds, Authors: []string{pk}}, + {Kinds: []int{5}, Tags: nostr.TagMap{"k": []string{"7375"}}, Authors: []string{pk}}, + }, + eoseChan, + ) + + return loadWallet(ctx, kr, events, eoseChan) +} + +func loadWallet( + ctx context.Context, + kr nostr.Keyer, + events chan nostr.RelayEvent, + eoseChan chan struct{}, +) *Wallet { + w := &Wallet{ + pendingDeletions: make([]string, 0, 128), + kr: kr, + Stable: make(chan struct{}), + Tokens: make([]Token, 0, 128), + History: make([]HistoryEntry, 0, 128), + } + + eosed := false + go func() { + <-eoseChan + eosed = true + + // check all pending deletions and delete stuff locally + for _, id := range w.pendingDeletions { + w.removeDeletedToken(id) + } + w.pendingDeletions = nil + + time.Sleep(100 * time.Millisecond) // race condition hack + close(w.Stable) + }() + + go func() { + for ie := range events { + w.Lock() + switch ie.Event.Kind { + case 5: + if !eosed { + for _, tag := range ie.Event.Tags.All([]string{"e", ""}) { + w.pendingDeletions = append(w.pendingDeletions, tag[1]) + } + } else { + for _, tag := range ie.Event.Tags.All([]string{"e", ""}) { + w.removeDeletedToken(tag[1]) + } + } + case 17375: + if err := w.parse(ctx, kr, ie.Event); err != nil { + if w.Processed != nil { + w.Processed(ie.Event, err) + } + w.Unlock() + continue + } + + // if this metadata is newer than what we had, update + if w.event == nil || ie.Event.CreatedAt > w.event.CreatedAt { + w.parse(ctx, kr, ie.Event) // this will either fail or set the new metadata + } + case 7375: // token + token := Token{} + if err := token.parse(ctx, kr, ie.Event); err != nil { + if w.Processed != nil { + w.Processed(ie.Event, err) + } + w.Unlock() + continue + } + + w.tokensMu.Lock() + if !slices.ContainsFunc(w.Tokens, func(c Token) bool { return c.event.ID == token.event.ID }) { + w.Tokens = append(w.Tokens, token) + } + w.tokensMu.Unlock() + + // keep track tokens that were deleted by this, if they exist + if !eosed { + for _, del := range token.Deleted { + w.pendingDeletions = append(w.pendingDeletions, del) + } + } else { + for _, del := range token.Deleted { + w.removeDeletedToken(del) + } + } + + case 7376: // history + he := HistoryEntry{} + if err := he.parse(ctx, kr, ie.Event); err != nil { + if w.Processed != nil { + w.Processed(ie.Event, err) + } + w.Unlock() + continue + } + + if !slices.ContainsFunc(w.History, func(c HistoryEntry) bool { return c.event.ID == he.event.ID }) { + w.History = append(w.History, he) + } + } + + if w.Processed != nil { + w.Processed(ie.Event, nil) + } + w.Unlock() + } + }() + + return w +} + +// Close waits for pending operations to end +func (w *Wallet) Close() error { + w.Lock() + defer w.Unlock() + return nil +} + +func (w *Wallet) removeDeletedToken(eventId string) { + 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 (w *Wallet) Balance() uint64 { @@ -39,49 +229,31 @@ func (w *Wallet) Balance() uint64 { return sum } -func (w *Wallet) DisplayName() string { - if w.Name != "" { - return fmt.Sprintf("%s (%s)", w.Name, w.Identifier) - } - return w.Identifier -} - 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) + evt.Tags = nostr.Tags{} pk, err := kr.GetPublicKey(ctx) if err != nil { 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}) + } + jtags, _ := json.Marshal(tags) evt.Content, err = kr.Encrypt( ctx, - fmt.Sprintf(`[["privkey","%x"]]`, w.PrivateKey.Serialize()), + string(jtags), pk, ) if err != nil { return err } - evt.Tags = append(evt.Tags, - nostr.Tag{"d", w.Identifier}, - nostr.Tag{"unit", "sat"}, - ) - if w.Name != "" { - evt.Tags = append(evt.Tags, nostr.Tag{"name", w.Name}) - } - if w.Description != "" { - evt.Tags = append(evt.Tags, nostr.Tag{"description", w.Description}) - } - for _, relay := range w.Relays { - evt.Tags = append(evt.Tags, nostr.Tag{"relay", relay}) - } - for _, mint := range w.Mints { - evt.Tags = append(evt.Tags, nostr.Tag{"mint", mint}) - } - err = kr.SignEvent(ctx, evt) if err != nil { return err @@ -91,8 +263,6 @@ func (w *Wallet) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) } 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) @@ -112,42 +282,33 @@ func (w *Wallet) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) er tags = append(tags, evt.Tags...) } - essential := 0 + var mints []string + var privateKey *btcec.PrivateKey + for _, tag := range tags { if len(tag) < 2 { continue } switch tag[0] { - case "d": - essential++ - w.Identifier = tag[1] - case "name": - w.Name = tag[1] - case "description": - w.Description = tag[1] - case "unit": - essential++ - if tag[1] != "sat" { - return fmt.Errorf("only 'sat' wallets are supported") - } - case "relay": - w.Relays = append(w.Relays, tag[1]) case "mint": - w.Mints = append(w.Mints, tag[1]) + mints = append(mints, tag[1]) case "privkey": - essential++ skb, err := hex.DecodeString(tag[1]) if err != nil { return fmt.Errorf("failed to parse private key: %w", err) } - w.PrivateKey = secp256k1.PrivKeyFromBytes(skb) - w.PublicKey = w.PrivateKey.PubKey() + privateKey = secp256k1.PrivKeyFromBytes(skb) } } - if essential != 3 { - return fmt.Errorf("missing essential tags") + if privateKey == nil { + return fmt.Errorf("missing wallet private key") } + // finally set these things when we know nothing will fail + w.Mints = mints + w.PrivateKey = privateKey + w.PublicKey = w.PrivateKey.PubKey() + return nil } diff --git a/nip60/wallet_test.go b/nip60/wallet_test.go index 5ad77fc..58d2552 100644 --- a/nip60/wallet_test.go +++ b/nip60/wallet_test.go @@ -2,114 +2,208 @@ package nip60 import ( "context" - "os" + "fmt" + "slices" "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/elnosh/gonuts/cashu" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/keyer" - "github.com/stretchr/testify/require" + "golang.org/x/exp/rand" ) -var testRelays = []string{ - "wss://relay.damus.io", - "wss://nos.lol", - "wss://relay.nostr.band", -} - -func TestWalletTransfer(t *testing.T) { +func TestWallet(t *testing.T) { ctx := context.Background() - - // setup first wallet - sk1 := os.Getenv("NIP60_SECRET_KEY_1") - if sk1 == "" { - t.Skip("NIP60_SECRET_KEY_1 not set") - } - kr1, err := keyer.NewPlainKeySigner(sk1) + kr, err := keyer.NewPlainKeySigner("040cbf11f24b080ad9d8669d7514d9f3b7b1f58e5a6dcb75549352b041656537") if err != nil { t.Fatal(err) } - pool := nostr.NewSimplePool(ctx) - stash1 := LoadStash(ctx, kr1, pool, testRelays) - if stash1 == nil { - t.Fatal("failed to load stash 1") - } - stash1.PublishUpdate = func(event nostr.Event, deleted, received, change *Token, isHistory bool) { - pool.PublishMany(ctx, testRelays, event) + privateKey, _ := btcec.NewPrivateKey() + + w := &Wallet{ + kr: kr, + PrivateKey: privateKey, + PublicKey: privateKey.PubKey(), + Mints: []string{"https://mint1.com", "https://mint2.com"}, + Tokens: []Token{ + { + Mint: "https://mint1.com", + Proofs: cashu.Proofs{{Amount: 100}}, + mintedAt: nostr.Timestamp(time.Now().Add(-3 * time.Hour).Unix()), + }, + { + Mint: "https://mint2.com", + Proofs: cashu.Proofs{{Amount: 200}}, + mintedAt: nostr.Timestamp(time.Now().Add(-2 * time.Hour).Unix()), + }, + { + Mint: "https://mint1.com", + Proofs: cashu.Proofs{{Amount: 300}}, + mintedAt: nostr.Timestamp(time.Now().Add(-1 * time.Hour).Unix()), + }, + }, + History: []HistoryEntry{ + { + In: true, + Amount: 100, + createdAt: nostr.Timestamp(time.Now().Add(-3 * time.Hour).Unix()), + TokenReferences: []TokenRef{ + {Created: true, EventID: "645babb9051f46ddc97d960e68f82934e627f136dde7b860bf87c9213d937b58"}, + }, + }, + { + In: true, + Amount: 200, + createdAt: nostr.Timestamp(time.Now().Add(-2 * time.Hour).Unix()), + TokenReferences: []TokenRef{ + {Created: false, EventID: "add072ae7d7a027748e03024267a1c073f3fbc26cca468ba8630d039a7f5df72"}, + {Created: true, EventID: "b8460b5589b68a0d9a017ac3784d17a0729046206aa631f7f4b763b738e36cf8"}, + }, + }, + { + In: true, + Amount: 300, + createdAt: nostr.Timestamp(time.Now().Add(-1 * time.Hour).Unix()), + TokenReferences: []TokenRef{ + {Created: false, EventID: "61f86031d0ab95e9134a3ab955e96104cb1f4d610172838d28aa7ae9dc1cc924"}, + {Created: true, EventID: "588b78e4af06e960434239e7367a0bedf84747d4c52ff943f5e8b7daa3e1b601", IsNutzap: true}, + {Created: false, EventID: "8f14c0a4ff1bf85ccc26bf0125b9a289552f9b59bbb310b163d6a88a7bbd4ebc"}, + {Created: true, EventID: "41a6f442b7c3c9e2f1e8c4835c00f17c56b3e3be4c9f7cf7bc4cdd705b1b61db", IsNutzap: true}, + }, + }, + }, } - // setup second wallet - sk2 := os.Getenv("NIP60_SECRET_KEY_2") - if sk2 == "" { - t.Skip("NIP60_SECRET_KEY_2 not set") - } - kr2, err := keyer.NewPlainKeySigner(sk2) - if err != nil { + // turn everything into events + events := make([]*nostr.Event, 0, 7) + + // wallet metadata event + metaEvent := &nostr.Event{} + if err := w.toEvent(ctx, kr, metaEvent); err != nil { t.Fatal(err) } + events = append(events, metaEvent) - stash2 := LoadStash(ctx, kr2, pool, testRelays) - if stash2 == nil { - t.Fatal("failed to load stash 2") - } - stash2.PublishUpdate = func(event nostr.Event, deleted, received, change *Token, isHistory bool) { - pool.PublishMany(ctx, testRelays, event) + // token events + for i := range w.Tokens { + evt := &nostr.Event{} + evt.Tags = nostr.Tags{} + if err := w.Tokens[i].toEvent(ctx, kr, evt); err != nil { + t.Fatal(err) + } + w.Tokens[i].event = evt + events = append(events, evt) } - // wait for initial load - select { - case <-stash1.Stable: - case <-time.After(15 * time.Second): - t.Fatal("timeout waiting for stash 1 to load") + // history events + for i := range w.History { + evt := &nostr.Event{} + evt.Tags = nostr.Tags{} + if err := w.History[i].toEvent(ctx, kr, evt); err != nil { + t.Fatal(err) + } + w.History[i].event = evt + events = append(events, evt) } - select { - case <-stash2.Stable: - case <-time.After(15 * time.Second): - t.Fatal("timeout waiting for stash 2 to load") + // test different orderings + testCases := []struct { + name string + sort func([]*nostr.Event) + }{ + { + name: "random order", + sort: func(evts []*nostr.Event) { + r := rand.New(rand.NewSource(42)) // deterministic + r.Shuffle(len(evts), func(i, j int) { + evts[i], evts[j] = evts[j], evts[i] + }) + }, + }, + { + name: "most recent first", + sort: func(evts []*nostr.Event) { + slices.SortFunc(evts, func(a, b *nostr.Event) int { + return int(b.CreatedAt - a.CreatedAt) + }) + }, + }, + { + name: "least recent first", + sort: func(evts []*nostr.Event) { + slices.SortFunc(evts, func(a, b *nostr.Event) int { + return int(a.CreatedAt - b.CreatedAt) + }) + }, + }, } - // ensure wallets exist and have tokens - w1 := stash1.EnsureWallet(ctx, "test") - require.Greater(t, w1.Balance(), uint64(0), "wallet 1 has no balance") + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // make a copy and sort it + eventsCopy := make([]*nostr.Event, len(events)) + copy(eventsCopy, events) + tc.sort(eventsCopy) - w2 := stash2.EnsureWallet(ctx, "test") - initialBalance1 := w1.Balance() - initialBalance2 := w2.Balance() + // create relay event channel + evtChan := make(chan nostr.RelayEvent) + eoseChan := make(chan struct{}) - t.Logf("initial balances: w1=%d w2=%d", initialBalance1, initialBalance2) + // send events in a goroutine + go func() { + for _, evt := range eventsCopy { + evtChan <- nostr.RelayEvent{Event: evt} + } + close(eoseChan) + close(evtChan) + }() - // send half of wallet 1's balance to wallet 2 - pk2, err := kr2.GetPublicKey(ctx) - require.NoError(t, err) + // load wallet from events + loaded := loadWallet(ctx, kr, evtChan, eoseChan) + loaded.Processed = func(evt *nostr.Event, err error) { + fmt.Println("processed", evt, err) + } - halfBalance := initialBalance1 / 2 - proofs, mint, err := w1.Send(ctx, halfBalance, WithP2PK(pk2)) - require.NoError(t, err) + <-loaded.Stable - // receive token in wallet 2 - err = w2.Receive(ctx, proofs, mint) - require.NoError(t, err) + // check if loaded wallet matches original + if len(loaded.Tokens) != len(w.Tokens) { + t.Errorf("token count mismatch: %d != %d", len(loaded.Tokens), len(w.Tokens)) + } + if len(loaded.History) != len(w.History) { + t.Errorf("history count mismatch: %d != %d", len(loaded.History), len(w.History)) + } - // verify balances - require.Equal(t, initialBalance1-halfBalance, w1.Balance(), "wallet 1 balance wrong after send") - require.Equal(t, initialBalance2+halfBalance, w2.Balance(), "wallet 2 balance wrong after receive") + // check tokens are equal regardless of order + for _, ta := range loaded.Tokens { + found := false + for _, tb := range w.Tokens { + if ta.Mint == tb.Mint && ta.Proofs[0].Amount == tb.Proofs[0].Amount { + found = true + break + } + } + if !found { + t.Errorf("token not found in loaded wallet: %v", ta) + } + } - // now send it back - pk1, err := kr1.GetPublicKey(ctx) - require.NoError(t, err) - - proofs, mint, err = w2.Send(ctx, halfBalance, WithP2PK(pk1)) - require.NoError(t, err) - - // receive token back in wallet 1 - err = w1.Receive(ctx, proofs, mint) - require.NoError(t, err) - - // verify final balances match initial - require.Equal(t, initialBalance1, w1.Balance(), "wallet 1 final balance wrong") - require.Equal(t, initialBalance2, w2.Balance(), "wallet 2 final balance wrong") - - t.Logf("final balances: w1=%d w2=%d", w1.Balance(), w2.Balance()) + // check history entries are equal regardless of order + for _, ha := range loaded.History { + found := false + for _, hb := range w.History { + if ha.In == hb.In && ha.Amount == hb.Amount { + found = true + break + } + } + if !found { + t.Errorf("history entry not found in loaded wallet: %v", ha) + } + } + }) + } } diff --git a/nip61/nip61.go b/nip61/nip61.go index cc9745d..d9df45a 100644 --- a/nip61/nip61.go +++ b/nip61/nip61.go @@ -9,7 +9,6 @@ import ( "slices" "strings" - "github.com/elnosh/gonuts/cashu" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip60" ) @@ -57,7 +56,6 @@ func SendNutzap( } 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}) }