nip60/nip61: update to latest nip changes.

(a single default wallet, always default to sats, no names etc)
This commit is contained in:
fiatjaf 2025-02-03 16:40:42 -03:00
parent 8446557788
commit 3c0f4a723a
11 changed files with 494 additions and 740 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ func (t Token) ID() string {
return "<not-published>"
}
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(

View File

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

View File

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

View File

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