mirror of
https://github.com/nbd-wtf/go-nostr.git
synced 2025-03-17 21:32:56 +01:00
nip60/nip61: update to latest nip changes.
(a single default wallet, always default to sats, no names etc)
This commit is contained in:
parent
8446557788
commit
3c0f4a723a
@ -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())
|
||||
}
|
||||
}
|
@ -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
|
||||
|
21
nip60/pay.go
21
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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
342
nip60/stash.go
342
nip60/stash.go
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
287
nip60/wallet.go
287
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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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})
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user