From 3334f7a48bd70d2f2edeab9538a77c68580acab7 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 25 Jan 2025 22:21:39 -0300 Subject: [PATCH] implement nip60 events. --- go.mod | 4 +- go.sum | 10 ++- nip60/history.go | 153 ++++++++++++++++++++++++++++++++++++++ nip60/lib.go | 127 ++++++++++++++++++++++++++++++++ nip60/nip60_test.go | 161 ++++++++++++++++++++++++++++++++++++++++ nip60/token.go | 81 ++++++++++++++++++++ nip60/wallet.go | 176 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 706 insertions(+), 6 deletions(-) create mode 100644 nip60/history.go create mode 100644 nip60/lib.go create mode 100644 nip60/nip60_test.go create mode 100644 nip60/token.go create mode 100644 nip60/wallet.go diff --git a/go.mod b/go.mod index 91a4e4f..78c87cb 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/PowerDNS/lmdb-go v1.9.2 github.com/bluekeyes/go-gitdiff v0.7.1 github.com/btcsuite/btcd/btcec/v2 v2.3.4 - github.com/btcsuite/btcd/btcutil v1.1.3 + github.com/btcsuite/btcd/btcutil v1.1.5 github.com/coder/websocket v1.8.12 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 github.com/dgraph-io/badger/v4 v4.5.0 @@ -58,7 +58,7 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/julianday v1.0.0 // indirect diff --git a/go.sum b/go.sum index f26388b..93fadb4 100644 --- a/go.sum +++ b/go.sum @@ -23,15 +23,15 @@ github.com/bluekeyes/go-gitdiff v0.7.1 h1:graP4ElLRshr8ecu0UtqfNTCHrtSyZd3DABQm/ github.com/bluekeyes/go-gitdiff v0.7.1/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= -github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= -github.com/btcsuite/btcd/btcutil v1.1.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ= -github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= @@ -125,6 +125,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc= github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -155,8 +156,9 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-sqlite3 v0.18.3 h1:tyMa75uh7LcINcfo0WrzOvcTkfz8Hqu0TEPX+KVyes4= diff --git a/nip60/history.go b/nip60/history.go new file mode 100644 index 0000000..c9e231d --- /dev/null +++ b/nip60/history.go @@ -0,0 +1,153 @@ +package nip60 + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/nbd-wtf/go-nostr" +) + +type HistoryEntry struct { + In bool // in = received, out = sent + Amount uint32 + + tokenEventIDs []string + nutZaps []bool + + createdAt nostr.Timestamp + event *nostr.Event +} + +func (h HistoryEntry) toEvent(ctx context.Context, kr nostr.Keyer, walletId string, evt *nostr.Event) error { + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return err + } + + dir := "in" + if !h.In { + dir = "out" + } + + evt.CreatedAt = h.createdAt + evt.Kind = 7376 + evt.Tags = nostr.Tags{{"a", fmt.Sprintf("37375:%s:%s", pk, walletId)}} + + encryptedTags := nostr.Tags{ + nostr.Tag{"direction", dir}, + nostr.Tag{"amount", strconv.FormatUint(uint64(h.Amount), 10), "sat"}, + } + + for i, tid := range h.tokenEventIDs { + isNutZap := h.nutZaps[i] + + if h.In && isNutZap { + evt.Tags = append(evt.Tags, nostr.Tag{"e", tid, "", "redeemed"}) + continue + } + + marker := "created" + if !h.In { + marker = "destroyed" + } + + encryptedTags = append(encryptedTags, nostr.Tag{"e", tid, "", marker}) + } + + jsonb, _ := json.Marshal(encryptedTags) + evt.Content, err = kr.Encrypt( + ctx, + string(jsonb), + pk, + ) + if err != nil { + return err + } + + err = kr.SignEvent(ctx, evt) + if err != nil { + return err + } + + return nil +} + +func (h *HistoryEntry) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { + h.event = evt + h.createdAt = evt.CreatedAt + + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return err + } + + // event tags and encrypted tags are mixed together + jsonb, err := kr.Decrypt(ctx, evt.Content, pk) + if err != nil { + return err + } + var tags nostr.Tags + if len(jsonb) > 0 { + tags = make(nostr.Tags, 0, 7) + if err := json.Unmarshal([]byte(jsonb), &tags); err != nil { + return err + } + tags = append(tags, evt.Tags...) + } + + essential := 0 + for _, tag := range tags { + if len(tag) < 2 { + continue + } + switch tag[0] { + case "direction": + essential++ + if tag[1] == "in" { + h.In = true + } else if tag[1] == "out" { + h.In = false + } else { + return fmt.Errorf("unexpected 'direction' tag %s", tag[1]) + } + case "amount": + essential++ + if len(tag) < 3 { + return fmt.Errorf("'amount' tag must have at least 3 items") + } + if tag[2] != "sat" { + return fmt.Errorf("only 'sat' wallets are supported") + } + v, err := strconv.ParseUint(tag[1], 10, 32) + if err != nil { + return fmt.Errorf("invalid 'amount' %s: %w", tag[1], err) + } + h.Amount = uint32(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]) + switch tag[3] { + case "created": + h.nutZaps = append(h.nutZaps, false) + case "destroyed": + h.nutZaps = append(h.nutZaps, false) + case "redeemed": + h.nutZaps = append(h.nutZaps, true) + } + } + } + + if essential < 3 { + return fmt.Errorf("missing essential tags") + } + + return nil +} diff --git a/nip60/lib.go b/nip60/lib.go new file mode 100644 index 0000000..15a3b9f --- /dev/null +++ b/nip60/lib.go @@ -0,0 +1,127 @@ +package nip60 + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/nbd-wtf/go-nostr" +) + +type WalletStash struct { + sync.Mutex + wallets map[string]*Wallet + + pendingTokens map[string][]Token // tokens not yet assigned to a wallet + pendingHistory map[string][]HistoryEntry // history entries not yet assigned to a wallet +} + +func LoadWallets( + ctx context.Context, + kr nostr.Keyer, + events <-chan *nostr.Event, + errors chan<- error, +) *WalletStash { + wl := &WalletStash{ + wallets: make(map[string]*Wallet, 1), + pendingTokens: make(map[string][]Token), + pendingHistory: make(map[string][]HistoryEntry), + } + + go func() { + for evt := range events { + wl.Lock() + switch evt.Kind { + case 37375: + wallet := &Wallet{} + if err := wallet.parse(ctx, kr, evt); err != nil { + wl.Unlock() + if errors != nil { + errors <- fmt.Errorf("event %s failed: %w", evt, err) + } + continue + } + + for _, he := range wl.pendingHistory[wallet.Identifier] { + wallet.History = append(wallet.History, he) + } + for _, token := range wl.pendingTokens[wallet.Identifier] { + wallet.Tokens = append(wallet.Tokens, token) + } + + wl.wallets[wallet.Identifier] = wallet + + case 7375: // token + ref := evt.Tags.GetFirst([]string{"a", ""}) + if ref == nil { + wl.Unlock() + if errors != nil { + errors <- fmt.Errorf("event %s missing 'a' tag", evt) + } + continue + } + spl := strings.SplitN((*ref)[1], ":", 3) + if len(spl) < 3 { + wl.Unlock() + if errors != nil { + errors <- fmt.Errorf("event %s invalid 'a' tag", evt) + } + continue + } + + token := Token{} + if err := token.parse(ctx, kr, evt); err != nil { + wl.Unlock() + if errors != nil { + errors <- fmt.Errorf("event %s failed: %w", evt, err) + } + continue + } + + if wallet, ok := wl.wallets[spl[2]]; ok { + wallet.Tokens = append(wallet.Tokens, token) + } else { + wl.pendingTokens[spl[2]] = append(wl.pendingTokens[spl[2]], token) + } + + case 7376: // history + ref := evt.Tags.GetFirst([]string{"a", ""}) + if ref == nil { + wl.Unlock() + if errors != nil { + errors <- fmt.Errorf("event %s missing 'a' tag", evt) + } + continue + } + spl := strings.SplitN((*ref)[1], ":", 3) + if len(spl) < 3 { + wl.Unlock() + if errors != nil { + errors <- fmt.Errorf("event %s invalid 'a' tag", evt) + } + continue + } + + he := HistoryEntry{} + if err := he.parse(ctx, kr, evt); err != nil { + wl.Unlock() + if errors != nil { + errors <- fmt.Errorf("event %s failed: %w", evt, err) + } + continue + } + + if wallet, ok := wl.wallets[spl[2]]; ok { + wallet.History = append(wallet.History, he) + } else { + wl.pendingHistory[spl[2]] = append(wl.pendingHistory[spl[2]], he) + } + } + + wl.Unlock() + } + }() + + return wl +} diff --git a/nip60/nip60_test.go b/nip60/nip60_test.go new file mode 100644 index 0000000..199a646 --- /dev/null +++ b/nip60/nip60_test.go @@ -0,0 +1,161 @@ +package nip60 + +import ( + "context" + "fmt" + "testing" + "time" + + "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 + wallet1 := Wallet{ + Identifier: "wallet1", + Name: "My First Wallet", + Description: "Test wallet number one", + PrivateKey: "secret123", + Relays: []string{"wss://relay1.example.com", "wss://relay2.example.com"}, + Mints: []string{"https://mint1.example.com"}, + Tokens: []Token{ + { + Mint: "https://mint1.example.com", + Proofs: []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: []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(), + }, + }, + } + + wallet2 := Wallet{ + Identifier: "wallet2", + Name: "Second Wallet", + Description: "Test wallet number two", + PrivateKey: "secret456", + Relays: []string{"wss://relay3.example.com"}, + Mints: []string{"https://mint2.example.com"}, + Tokens: []Token{ + { + Mint: "https://mint2.example.com", + Proofs: []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 + events1, err := wallet1.ToPublishableEvents(ctx, kr, false) + require.NoError(t, err) + + events2, err := wallet2.ToPublishableEvents(ctx, kr, false) + require.NoError(t, err) + + // 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.Event) + done := make(chan struct{}) + go func() { + for _, evt := range allEvents { + eventChan <- &evt + } + close(eventChan) + done <- struct{}{} + }() + + // load wallets from events + errorChan := make(chan error) + walletStash := LoadWallets(ctx, kr, eventChan, errorChan) + + var errorChanErr error + go func() { + for { + errorChanErr = <-errorChan + fmt.Println(errorChanErr) + } + }() + + <-done + time.Sleep(time.Millisecond * 200) + require.NoError(t, errorChanErr, "errorChan shouldn't have received any errors: %w", errorChanErr) + + // 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.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.PrivateKey, loadedWallet2.PrivateKey) + require.Len(t, loadedWallet2.Tokens, len(wallet2.Tokens)) + require.Len(t, loadedWallet2.History, len(wallet2.History)) + + // check token amounts + require.Equal(t, wallet1.Balance(), loadedWallet1.Balance()) + require.Equal(t, wallet2.Balance(), loadedWallet2.Balance()) + } +} diff --git a/nip60/token.go b/nip60/token.go new file mode 100644 index 0000000..3b7082c --- /dev/null +++ b/nip60/token.go @@ -0,0 +1,81 @@ +package nip60 + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/nbd-wtf/go-nostr" +) + +type Token struct { + Mint string `json:"mint"` + Proofs []Proof `json:"proofs"` + + mintedAt nostr.Timestamp + event *nostr.Event +} + +type Proof struct { + ID string `json:"id"` + Amount uint32 `json:"amount"` + Secret string `json:"secret"` + C string `json:"C"` +} + +func (t Token) Amount() uint32 { + var sum uint32 + for _, p := range t.Proofs { + sum += p.Amount + } + return sum +} + +func (t Token) toEvent(ctx context.Context, kr nostr.Keyer, walletId string, evt *nostr.Event) error { + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return err + } + + evt.CreatedAt = t.mintedAt + evt.Kind = 7375 + evt.Tags = nostr.Tags{{"a", fmt.Sprintf("37375:%s:%s", pk, walletId)}} + + content, _ := json.Marshal(t) + evt.Content, err = kr.Encrypt( + ctx, + string(content), + pk, + ) + if err != nil { + return err + } + + err = kr.SignEvent(ctx, evt) + if err != nil { + return err + } + + return nil +} + +func (t *Token) parse(ctx context.Context, kr nostr.Keyer, evt *nostr.Event) error { + t.event = evt + t.mintedAt = evt.CreatedAt + + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return err + } + + content, err := kr.Decrypt(ctx, evt.Content, pk) + if err != nil { + return err + } + + if err := json.Unmarshal([]byte(content), t); err != nil { + return fmt.Errorf("failed to parse token content: %w", err) + } + + return nil +} diff --git a/nip60/wallet.go b/nip60/wallet.go new file mode 100644 index 0000000..b51dc37 --- /dev/null +++ b/nip60/wallet.go @@ -0,0 +1,176 @@ +package nip60 + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/nbd-wtf/go-nostr" +) + +type Wallet struct { + Identifier string + Description string + Name string + PrivateKey string + Relays []string + Mints []string + Tokens []Token + History []HistoryEntry + + temporaryBalance uint32 +} + +func (w Wallet) Balance() uint32 { + var sum uint32 + for _, token := range w.Tokens { + sum += token.Amount() + } + return sum +} + +func (w Wallet) ToPublishableEvents(ctx context.Context, kr nostr.Keyer, skipExisting bool) ([]nostr.Event, error) { + evt := nostr.Event{ + CreatedAt: nostr.Now(), + Kind: 37375, + Tags: make(nostr.Tags, 0, 7), + } + + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return nil, err + } + + evt.Content, err = kr.Encrypt( + ctx, + fmt.Sprintf(`[["balance","%d","sat"],["privkey","%s"]]`, w.Balance(), w.PrivateKey), + pk, + ) + if err != nil { + return nil, 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}) + } + + err = kr.SignEvent(ctx, &evt) + if err != nil { + return nil, err + } + + events := make([]nostr.Event, 0, 1+len(w.Tokens)) + events = append(events, evt) + + for _, t := range w.Tokens { + var evt nostr.Event + + if t.event != nil { + if skipExisting { + continue + } + evt = *t.event + } else { + err := t.toEvent(ctx, kr, w.Identifier, &evt) + if err != nil { + return nil, err + } + } + + events = append(events, evt) + } + + for _, h := range w.History { + var evt nostr.Event + + if h.event != nil { + if skipExisting { + continue + } + evt = *h.event + } else { + err := h.toEvent(ctx, kr, w.Identifier, &evt) + if err != nil { + return nil, err + } + } + + events = append(events, evt) + } + + return events, nil +} + +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) + + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return err + } + + jsonb, err := kr.Decrypt(ctx, evt.Content, pk) + if err != nil { + return err + } + var tags nostr.Tags + if len(jsonb) > 0 { + tags = make(nostr.Tags, 0, 7) + if err := json.Unmarshal([]byte(jsonb), &tags); err != nil { + return err + } + tags = append(tags, evt.Tags...) + } + + essential := 0 + for _, tag := range tags { + if len(tag) < 2 { + continue + } + switch tag[0] { + case "d": + 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]) + case "privkey": + w.PrivateKey = tag[1] + case "balance": + if len(tag) < 3 { + return fmt.Errorf("'balance' tag must have at least 3 items") + } + if tag[2] != "sat" { + return fmt.Errorf("only 'sat' wallets are supported") + } + v, err := strconv.ParseUint(tag[1], 10, 32) + if err != nil { + return fmt.Errorf("invalid 'balance' %s: %w", tag[1], err) + } + w.temporaryBalance = uint32(v) + } + } + + return nil +}