mirror of
https://github.com/nbd-wtf/go-nostr.git
synced 2025-08-09 04:31:47 +02:00
nip60/nip61: update to latest nip changes.
(a single default wallet, always default to sats, no names etc)
This commit is contained in:
@@ -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
|
In bool // in = received, out = sent
|
||||||
Amount uint64
|
Amount uint64
|
||||||
|
|
||||||
tokenEventIDs []string
|
TokenReferences []TokenRef
|
||||||
nutZaps []bool
|
|
||||||
|
|
||||||
createdAt nostr.Timestamp
|
createdAt nostr.Timestamp
|
||||||
event *nostr.Event
|
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)
|
pk, err := kr.GetPublicKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -33,27 +38,25 @@ func (h HistoryEntry) toEvent(ctx context.Context, kr nostr.Keyer, walletId stri
|
|||||||
|
|
||||||
evt.CreatedAt = h.createdAt
|
evt.CreatedAt = h.createdAt
|
||||||
evt.Kind = 7376
|
evt.Kind = 7376
|
||||||
evt.Tags = nostr.Tags{{"a", fmt.Sprintf("37375:%s:%s", pk, walletId)}}
|
evt.Tags = nostr.Tags{}
|
||||||
|
|
||||||
encryptedTags := nostr.Tags{
|
encryptedTags := nostr.Tags{
|
||||||
nostr.Tag{"direction", dir},
|
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 {
|
for _, tf := range h.TokenReferences {
|
||||||
isNutZap := h.nutZaps[i]
|
if tf.IsNutzap {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"e", tf.EventID, "", "redeemed"})
|
||||||
if h.In && isNutZap {
|
|
||||||
evt.Tags = append(evt.Tags, nostr.Tag{"e", tid, "", "redeemed"})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
marker := "created"
|
marker := "destroyed"
|
||||||
if !h.In {
|
if tf.Created {
|
||||||
marker = "destroyed"
|
marker = "created"
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptedTags = append(encryptedTags, nostr.Tag{"e", tid, "", marker})
|
encryptedTags = append(encryptedTags, nostr.Tag{"e", tf.EventID, "", marker})
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonb, _ := json.Marshal(encryptedTags)
|
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...)
|
tags = append(tags, evt.Tags...)
|
||||||
}
|
}
|
||||||
|
|
||||||
essential := 0
|
missingDirection := true
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
if len(tag) < 2 {
|
if len(tag) < 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch tag[0] {
|
switch tag[0] {
|
||||||
case "direction":
|
case "direction":
|
||||||
essential++
|
missingDirection = false
|
||||||
if tag[1] == "in" {
|
if tag[1] == "in" {
|
||||||
h.In = true
|
h.In = true
|
||||||
} else if tag[1] == "out" {
|
} 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])
|
return fmt.Errorf("unexpected 'direction' tag %s", tag[1])
|
||||||
}
|
}
|
||||||
case "amount":
|
case "amount":
|
||||||
essential++
|
|
||||||
if len(tag) < 2 {
|
if len(tag) < 2 {
|
||||||
return fmt.Errorf("'amount' tag must have at least 2 items")
|
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)
|
v, err := strconv.ParseUint(tag[1], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid 'amount' %s: %w", tag[1], err)
|
return fmt.Errorf("invalid 'amount' %s: %w", tag[1], err)
|
||||||
}
|
}
|
||||||
h.Amount = v
|
h.Amount = v
|
||||||
case "e":
|
case "e":
|
||||||
essential++
|
|
||||||
if len(tag) < 4 {
|
if len(tag) < 4 {
|
||||||
return fmt.Errorf("'e' tag must have at least 4 items")
|
return fmt.Errorf("'e' tag must have at least 4 items")
|
||||||
}
|
}
|
||||||
if !nostr.IsValid32ByteHex(tag[1]) {
|
if !nostr.IsValid32ByteHex(tag[1]) {
|
||||||
return fmt.Errorf("'e' tag has invalid event id %s", 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] {
|
switch tag[3] {
|
||||||
case "created":
|
case "created":
|
||||||
h.nutZaps = append(h.nutZaps, false)
|
tf.Created = true
|
||||||
case "destroyed":
|
case "destroyed":
|
||||||
h.nutZaps = append(h.nutZaps, false)
|
tf.Created = false
|
||||||
case "redeemed":
|
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 {
|
if h.Amount == 0 {
|
||||||
return fmt.Errorf("missing essential tags")
|
return fmt.Errorf("missing 'amount' tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
if missingDirection {
|
||||||
|
return fmt.Errorf("missing 'direction' tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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) {
|
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")
|
return "", fmt.Errorf("can't do write operations: missing PublishUpdate function")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,23 +124,22 @@ inspectmeltstatusresponse:
|
|||||||
}
|
}
|
||||||
|
|
||||||
he := HistoryEntry{
|
he := HistoryEntry{
|
||||||
event: &nostr.Event{},
|
event: &nostr.Event{},
|
||||||
tokenEventIDs: make([]string, 0, 1),
|
TokenReferences: make([]TokenRef, 0, 5),
|
||||||
nutZaps: make([]bool, 0, 1),
|
createdAt: nostr.Now(),
|
||||||
createdAt: nostr.Now(),
|
In: false,
|
||||||
In: false,
|
Amount: chosen.proofs.Amount() - changeProofs.Amount(),
|
||||||
Amount: chosen.proofs.Amount() - changeProofs.Amount(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes, &he); err != nil {
|
if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes, &he); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.wl.Lock()
|
w.Lock()
|
||||||
if err := he.toEvent(ctx, w.wl.kr, w.Identifier, he.event); err == nil {
|
if err := he.toEvent(ctx, w.kr, he.event); err == nil {
|
||||||
w.wl.PublishUpdate(*he.event, nil, nil, nil, true)
|
w.PublishUpdate(*he.event, nil, nil, nil, true)
|
||||||
}
|
}
|
||||||
w.wl.Unlock()
|
w.Unlock()
|
||||||
|
|
||||||
return meltStatus.Preimage, nil
|
return meltStatus.Preimage, nil
|
||||||
}
|
}
|
||||||
|
@@ -16,7 +16,7 @@ func (w *Wallet) Receive(
|
|||||||
proofs cashu.Proofs,
|
proofs cashu.Proofs,
|
||||||
mint string,
|
mint string,
|
||||||
) error {
|
) error {
|
||||||
if w.wl.PublishUpdate == nil {
|
if w.PublishUpdate == nil {
|
||||||
return fmt.Errorf("can't do write operations: missing PublishUpdate function")
|
return fmt.Errorf("can't do write operations: missing PublishUpdate function")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,25 +100,30 @@ saveproofs:
|
|||||||
mintedAt: nostr.Now(),
|
mintedAt: nostr.Now(),
|
||||||
event: &nostr.Event{},
|
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)
|
return fmt.Errorf("failed to make new token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
he := HistoryEntry{
|
he := HistoryEntry{
|
||||||
event: &nostr.Event{},
|
event: &nostr.Event{},
|
||||||
tokenEventIDs: []string{newToken.event.ID},
|
TokenReferences: []TokenRef{
|
||||||
nutZaps: []bool{false},
|
{
|
||||||
createdAt: nostr.Now(),
|
EventID: newToken.event.ID,
|
||||||
In: true,
|
Created: true,
|
||||||
Amount: newToken.Proofs.Amount(),
|
IsNutzap: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: nostr.Now(),
|
||||||
|
In: true,
|
||||||
|
Amount: newToken.Proofs.Amount(),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.wl.Lock()
|
w.Lock()
|
||||||
w.wl.PublishUpdate(*newToken.event, nil, &newToken, nil, false)
|
w.PublishUpdate(*newToken.event, nil, &newToken, nil, false)
|
||||||
if err := he.toEvent(ctx, w.wl.kr, w.Identifier, he.event); err == nil {
|
if err := he.toEvent(ctx, w.kr, he.event); err == nil {
|
||||||
w.wl.PublishUpdate(*he.event, nil, nil, nil, true)
|
w.PublishUpdate(*he.event, nil, nil, nil, true)
|
||||||
}
|
}
|
||||||
w.wl.Unlock()
|
w.Unlock()
|
||||||
|
|
||||||
w.tokensMu.Lock()
|
w.tokensMu.Lock()
|
||||||
w.Tokens = append(w.Tokens, newToken)
|
w.Tokens = append(w.Tokens, newToken)
|
||||||
|
@@ -15,7 +15,7 @@ func (w *Wallet) SendExternal(
|
|||||||
targetAmount uint64,
|
targetAmount uint64,
|
||||||
opts ...SendOption,
|
opts ...SendOption,
|
||||||
) (cashu.Proofs, error) {
|
) (cashu.Proofs, error) {
|
||||||
if w.wl.PublishUpdate == nil {
|
if w.PublishUpdate == nil {
|
||||||
return nil, fmt.Errorf("can't do write operations: missing PublishUpdate function")
|
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) {
|
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")
|
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{
|
he := HistoryEntry{
|
||||||
event: &nostr.Event{},
|
event: &nostr.Event{},
|
||||||
tokenEventIDs: make([]string, 0, 1),
|
TokenReferences: make([]TokenRef, 0, 5),
|
||||||
nutZaps: make([]bool, 0, 1),
|
createdAt: nostr.Now(),
|
||||||
createdAt: nostr.Now(),
|
In: false,
|
||||||
In: false,
|
Amount: chosen.proofs.Amount() - changeProofs.Amount(),
|
||||||
Amount: chosen.proofs.Amount() - changeProofs.Amount(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes, &he); err != nil {
|
if err := w.saveChangeAndDeleteUsedTokens(ctx, chosen.mint, changeProofs, chosen.tokenIndexes, &he); err != nil {
|
||||||
return nil, chosen.mint, err
|
return nil, chosen.mint, err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.wl.Lock()
|
w.Lock()
|
||||||
if err := he.toEvent(ctx, w.wl.kr, w.Identifier, he.event); err == nil {
|
if err := he.toEvent(ctx, w.kr, he.event); err == nil {
|
||||||
w.wl.PublishUpdate(*he.event, nil, nil, nil, true)
|
w.PublishUpdate(*he.event, nil, nil, nil, true)
|
||||||
}
|
}
|
||||||
w.wl.Unlock()
|
w.Unlock()
|
||||||
|
|
||||||
return proofsToSend, chosen.mint, nil
|
return proofsToSend, chosen.mint, nil
|
||||||
}
|
}
|
||||||
@@ -150,11 +149,18 @@ func (w *Wallet) saveChangeAndDeleteUsedTokens(
|
|||||||
Kind: 5,
|
Kind: 5,
|
||||||
Tags: nostr.Tags{{"e", token.event.ID}, {"k", "7375"}},
|
Tags: nostr.Tags{{"e", token.event.ID}, {"k", "7375"}},
|
||||||
}
|
}
|
||||||
w.wl.kr.SignEvent(ctx, &deleteEvent)
|
w.kr.SignEvent(ctx, &deleteEvent)
|
||||||
|
|
||||||
w.wl.Lock()
|
w.Lock()
|
||||||
w.wl.PublishUpdate(deleteEvent, &token, nil, nil, false)
|
w.PublishUpdate(deleteEvent, &token, nil, nil, false)
|
||||||
w.wl.Unlock()
|
w.Unlock()
|
||||||
|
|
||||||
|
// fill in the history deleted token
|
||||||
|
he.TokenReferences = append(he.TokenReferences, TokenRef{
|
||||||
|
EventID: token.event.ID,
|
||||||
|
Created: false,
|
||||||
|
IsNutzap: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -162,19 +168,22 @@ func (w *Wallet) saveChangeAndDeleteUsedTokens(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(changeToken.Proofs) > 0 {
|
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)
|
return fmt.Errorf("failed to make change token: %w", err)
|
||||||
}
|
}
|
||||||
w.wl.Lock()
|
w.Lock()
|
||||||
w.wl.PublishUpdate(*changeToken.event, nil, nil, &changeToken, false)
|
w.PublishUpdate(*changeToken.event, nil, nil, &changeToken, false)
|
||||||
w.wl.Unlock()
|
w.Unlock()
|
||||||
|
|
||||||
// we don't have to lock tokensMu here because this function will always be called with that lock already held
|
// 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)
|
w.Tokens = append(updatedTokens, changeToken)
|
||||||
|
|
||||||
// fill in the history created token
|
// fill in the history created token
|
||||||
he.tokenEventIDs = append(he.tokenEventIDs, changeToken.event.ID)
|
he.TokenReferences = append(he.TokenReferences, TokenRef{
|
||||||
he.nutZaps = append(he.nutZaps, false)
|
EventID: changeToken.event.ID,
|
||||||
|
Created: true,
|
||||||
|
IsNutzap: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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>"
|
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)
|
pk, err := kr.GetPublicKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -34,7 +34,7 @@ func (t Token) toEvent(ctx context.Context, kr nostr.Keyer, walletId string, evt
|
|||||||
|
|
||||||
evt.CreatedAt = t.mintedAt
|
evt.CreatedAt = t.mintedAt
|
||||||
evt.Kind = 7375
|
evt.Kind = 7375
|
||||||
evt.Tags = nostr.Tags{{"a", fmt.Sprintf("37375:%s:%s", pk, walletId)}}
|
evt.Tags = nostr.Tags{}
|
||||||
|
|
||||||
content, _ := json.Marshal(t)
|
content, _ := json.Marshal(t)
|
||||||
evt.Content, err = kr.Encrypt(
|
evt.Content, err = kr.Encrypt(
|
||||||
|
287
nip60/wallet.go
287
nip60/wallet.go
@@ -5,7 +5,9 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/btcec/v2"
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
@@ -13,22 +15,210 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Wallet struct {
|
type Wallet struct {
|
||||||
wl *WalletStash
|
sync.Mutex
|
||||||
|
|
||||||
Identifier string
|
|
||||||
Description string
|
|
||||||
Name string
|
|
||||||
PrivateKey *btcec.PrivateKey
|
|
||||||
PublicKey *btcec.PublicKey
|
|
||||||
Relays []string
|
|
||||||
Mints []string
|
|
||||||
Tokens []Token
|
|
||||||
History []HistoryEntry
|
|
||||||
|
|
||||||
tokensMu sync.Mutex
|
tokensMu sync.Mutex
|
||||||
|
event *nostr.Event
|
||||||
|
|
||||||
event *nostr.Event
|
pendingDeletions []string // token events that should be deleted
|
||||||
tokensPendingDeletion []string
|
|
||||||
|
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 {
|
func (w *Wallet) Balance() uint64 {
|
||||||
@@ -39,49 +229,31 @@ func (w *Wallet) Balance() uint64 {
|
|||||||
return sum
|
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 {
|
func (w *Wallet) toEvent(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error {
|
||||||
evt.CreatedAt = nostr.Now()
|
evt.CreatedAt = nostr.Now()
|
||||||
evt.Kind = 37375
|
evt.Kind = 37375
|
||||||
evt.Tags = make(nostr.Tags, 0, 7)
|
evt.Tags = nostr.Tags{}
|
||||||
|
|
||||||
pk, err := kr.GetPublicKey(ctx)
|
pk, err := kr.GetPublicKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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(
|
evt.Content, err = kr.Encrypt(
|
||||||
ctx,
|
ctx,
|
||||||
fmt.Sprintf(`[["privkey","%x"]]`, w.PrivateKey.Serialize()),
|
string(jtags),
|
||||||
pk,
|
pk,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
err = kr.SignEvent(ctx, evt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
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
|
w.event = evt
|
||||||
|
|
||||||
pk, err := kr.GetPublicKey(ctx)
|
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...)
|
tags = append(tags, evt.Tags...)
|
||||||
}
|
}
|
||||||
|
|
||||||
essential := 0
|
var mints []string
|
||||||
|
var privateKey *btcec.PrivateKey
|
||||||
|
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
if len(tag) < 2 {
|
if len(tag) < 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch tag[0] {
|
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":
|
case "mint":
|
||||||
w.Mints = append(w.Mints, tag[1])
|
mints = append(mints, tag[1])
|
||||||
case "privkey":
|
case "privkey":
|
||||||
essential++
|
|
||||||
skb, err := hex.DecodeString(tag[1])
|
skb, err := hex.DecodeString(tag[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse private key: %w", err)
|
return fmt.Errorf("failed to parse private key: %w", err)
|
||||||
}
|
}
|
||||||
w.PrivateKey = secp256k1.PrivKeyFromBytes(skb)
|
privateKey = secp256k1.PrivKeyFromBytes(skb)
|
||||||
w.PublicKey = w.PrivateKey.PubKey()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if essential != 3 {
|
if privateKey == nil {
|
||||||
return fmt.Errorf("missing essential tags")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -2,114 +2,208 @@ package nip60
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
|
"github.com/elnosh/gonuts/cashu"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/nbd-wtf/go-nostr/keyer"
|
"github.com/nbd-wtf/go-nostr/keyer"
|
||||||
"github.com/stretchr/testify/require"
|
"golang.org/x/exp/rand"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testRelays = []string{
|
func TestWallet(t *testing.T) {
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://nos.lol",
|
|
||||||
"wss://relay.nostr.band",
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWalletTransfer(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
kr, err := keyer.NewPlainKeySigner("040cbf11f24b080ad9d8669d7514d9f3b7b1f58e5a6dcb75549352b041656537")
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pool := nostr.NewSimplePool(ctx)
|
privateKey, _ := btcec.NewPrivateKey()
|
||||||
stash1 := LoadStash(ctx, kr1, pool, testRelays)
|
|
||||||
if stash1 == nil {
|
w := &Wallet{
|
||||||
t.Fatal("failed to load stash 1")
|
kr: kr,
|
||||||
}
|
PrivateKey: privateKey,
|
||||||
stash1.PublishUpdate = func(event nostr.Event, deleted, received, change *Token, isHistory bool) {
|
PublicKey: privateKey.PubKey(),
|
||||||
pool.PublishMany(ctx, testRelays, event)
|
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
|
// turn everything into events
|
||||||
sk2 := os.Getenv("NIP60_SECRET_KEY_2")
|
events := make([]*nostr.Event, 0, 7)
|
||||||
if sk2 == "" {
|
|
||||||
t.Skip("NIP60_SECRET_KEY_2 not set")
|
// wallet metadata event
|
||||||
}
|
metaEvent := &nostr.Event{}
|
||||||
kr2, err := keyer.NewPlainKeySigner(sk2)
|
if err := w.toEvent(ctx, kr, metaEvent); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
events = append(events, metaEvent)
|
||||||
|
|
||||||
stash2 := LoadStash(ctx, kr2, pool, testRelays)
|
// token events
|
||||||
if stash2 == nil {
|
for i := range w.Tokens {
|
||||||
t.Fatal("failed to load stash 2")
|
evt := &nostr.Event{}
|
||||||
}
|
evt.Tags = nostr.Tags{}
|
||||||
stash2.PublishUpdate = func(event nostr.Event, deleted, received, change *Token, isHistory bool) {
|
if err := w.Tokens[i].toEvent(ctx, kr, evt); err != nil {
|
||||||
pool.PublishMany(ctx, testRelays, event)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Tokens[i].event = evt
|
||||||
|
events = append(events, evt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for initial load
|
// history events
|
||||||
select {
|
for i := range w.History {
|
||||||
case <-stash1.Stable:
|
evt := &nostr.Event{}
|
||||||
case <-time.After(15 * time.Second):
|
evt.Tags = nostr.Tags{}
|
||||||
t.Fatal("timeout waiting for stash 1 to load")
|
if err := w.History[i].toEvent(ctx, kr, evt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.History[i].event = evt
|
||||||
|
events = append(events, evt)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
// test different orderings
|
||||||
case <-stash2.Stable:
|
testCases := []struct {
|
||||||
case <-time.After(15 * time.Second):
|
name string
|
||||||
t.Fatal("timeout waiting for stash 2 to load")
|
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
|
for _, tc := range testCases {
|
||||||
w1 := stash1.EnsureWallet(ctx, "test")
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
require.Greater(t, w1.Balance(), uint64(0), "wallet 1 has no balance")
|
// make a copy and sort it
|
||||||
|
eventsCopy := make([]*nostr.Event, len(events))
|
||||||
|
copy(eventsCopy, events)
|
||||||
|
tc.sort(eventsCopy)
|
||||||
|
|
||||||
w2 := stash2.EnsureWallet(ctx, "test")
|
// create relay event channel
|
||||||
initialBalance1 := w1.Balance()
|
evtChan := make(chan nostr.RelayEvent)
|
||||||
initialBalance2 := w2.Balance()
|
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
|
// load wallet from events
|
||||||
pk2, err := kr2.GetPublicKey(ctx)
|
loaded := loadWallet(ctx, kr, evtChan, eoseChan)
|
||||||
require.NoError(t, err)
|
loaded.Processed = func(evt *nostr.Event, err error) {
|
||||||
|
fmt.Println("processed", evt, err)
|
||||||
|
}
|
||||||
|
|
||||||
halfBalance := initialBalance1 / 2
|
<-loaded.Stable
|
||||||
proofs, mint, err := w1.Send(ctx, halfBalance, WithP2PK(pk2))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// receive token in wallet 2
|
// check if loaded wallet matches original
|
||||||
err = w2.Receive(ctx, proofs, mint)
|
if len(loaded.Tokens) != len(w.Tokens) {
|
||||||
require.NoError(t, err)
|
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
|
// check tokens are equal regardless of order
|
||||||
require.Equal(t, initialBalance1-halfBalance, w1.Balance(), "wallet 1 balance wrong after send")
|
for _, ta := range loaded.Tokens {
|
||||||
require.Equal(t, initialBalance2+halfBalance, w2.Balance(), "wallet 2 balance wrong after receive")
|
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
|
// check history entries are equal regardless of order
|
||||||
pk1, err := kr1.GetPublicKey(ctx)
|
for _, ha := range loaded.History {
|
||||||
require.NoError(t, err)
|
found := false
|
||||||
|
for _, hb := range w.History {
|
||||||
proofs, mint, err = w2.Send(ctx, halfBalance, WithP2PK(pk1))
|
if ha.In == hb.In && ha.Amount == hb.Amount {
|
||||||
require.NoError(t, err)
|
found = true
|
||||||
|
break
|
||||||
// receive token back in wallet 1
|
}
|
||||||
err = w1.Receive(ctx, proofs, mint)
|
}
|
||||||
require.NoError(t, err)
|
if !found {
|
||||||
|
t.Errorf("history entry not found in loaded wallet: %v", ha)
|
||||||
// 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())
|
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,6 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/elnosh/gonuts/cashu"
|
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"github.com/nbd-wtf/go-nostr/nip60"
|
"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{"p", targetUserPublickey})
|
||||||
nutzap.Tags = append(nutzap.Tags, nostr.Tag{"unit", cashu.Sat.String()})
|
|
||||||
if eventId != "" {
|
if eventId != "" {
|
||||||
nutzap.Tags = append(nutzap.Tags, nostr.Tag{"e", eventId})
|
nutzap.Tags = append(nutzap.Tags, nostr.Tag{"e", eventId})
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user