summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-07 15:20:57 -0800
committerbndw <ben@bdw.to>2026-02-07 15:20:57 -0800
commitd4fd2467d691a69a0ba75348086424b9fb33a297 (patch)
tree51bae6f1579e3248843a01053ccdea336f2730b2
wip
-rw-r--r--PLAN.md186
-rw-r--r--bech32.go162
-rw-r--r--bech32_test.go139
-rw-r--r--envelope.go262
-rw-r--r--envelope_test.go416
-rw-r--r--event.go72
-rw-r--r--event_test.go194
-rw-r--r--example_test.go100
-rw-r--r--examples/basic/main.go103
-rw-r--r--filter.go224
-rw-r--r--filter_test.go415
-rw-r--r--go.mod14
-rw-r--r--go.sum12
-rw-r--r--keys.go217
-rw-r--r--keys_test.go333
-rw-r--r--kinds.go51
-rw-r--r--kinds_test.go128
-rw-r--r--relay.go217
-rw-r--r--relay_test.go333
-rw-r--r--tags.go64
-rw-r--r--tags_test.go158
21 files changed, 3800 insertions, 0 deletions
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..39d8318
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,186 @@
1# Minimal Nostr Go Library - Implementation Plan
2
3## Overview
4
5Build a minimal Go library for Nostr split into two modules:
6
7**Module 1: Core** (`nostr-go` root) - 1 external dep
8- Types, signing, serialization
9- `github.com/btcsuite/btcd/btcec/v2` - BIP-340 Schnorr signatures
10
11**Module 2: Relay** (`nostr-go/relay`) - 1 additional dep
12- WebSocket connection, pub/sub
13- `github.com/coder/websocket` - WebSocket library
14- Imports core module
15
16Users who only need types/signing don't pull in websocket dependencies.
17
18## Package Structure
19
20```
21nostr-go/
22├── go.mod # Core module
23├── event.go # Event struct, ID computation, serialization
24├── tags.go # Tag/Tags types and helpers
25├── kinds.go # Event kind constants
26├── filter.go # Filter struct and matching logic
27├── keys.go # Key generation, signing, verification
28├── bech32.go # Bech32 encoding/decoding (our impl, ~150 lines)
29├── nip19.go # npub/nsec/note/nprofile encode/decode
30├── envelope.go # Protocol messages (EVENT, REQ, OK, etc.)
31├── *_test.go
32
33└── relay/
34 ├── go.mod # Relay module (imports core)
35 ├── relay.go # WebSocket connection primitives
36 ├── subscription.go # Subscription handling
37 └── *_test.go
38```
39
40## Core Types
41
42### Event (event.go)
43```go
44type Event struct {
45 ID string `json:"id"` // 64-char hex (SHA256)
46 PubKey string `json:"pubkey"` // 64-char hex (x-only pubkey)
47 CreatedAt int64 `json:"created_at"`
48 Kind int `json:"kind"`
49 Tags Tags `json:"tags"`
50 Content string `json:"content"`
51 Sig string `json:"sig"` // 128-char hex (Schnorr sig)
52}
53```
54
55**Design note**: Starting with hex strings for simplicity. Can evaluate byte arrays (`[32]byte`, `[64]byte`) later if type safety becomes important.
56
57Key methods:
58- `Serialize() []byte` - Canonical JSON for ID computation: `[0,"pubkey",created_at,kind,tags,"content"]`
59- `ComputeID() string` - SHA256 hash of serialized form
60- `Sign(privKeyHex string) error` - Sign with Schnorr, sets PubKey/ID/Sig
61- `Verify() bool` - Verify signature
62
63### Tags (tags.go)
64```go
65type Tag []string
66type Tags []Tag
67```
68Methods: `Key()`, `Value()`, `Find(key)`, `FindAll(key)`, `GetD()`
69
70### Filter (filter.go)
71```go
72type Filter struct {
73 IDs []string `json:"ids,omitempty"`
74 Kinds []int `json:"kinds,omitempty"`
75 Authors []string `json:"authors,omitempty"`
76 Tags map[string][]string `json:"-"` // Custom marshal for #e, #p
77 Since *int64 `json:"since,omitempty"`
78 Until *int64 `json:"until,omitempty"`
79 Limit int `json:"limit,omitempty"`
80}
81```
82Methods: `Matches(event) bool`, custom `MarshalJSON`/`UnmarshalJSON` for tag filters
83
84### Kinds (kinds.go)
85Essential constants only:
86```go
87const (
88 KindMetadata = 0
89 KindTextNote = 1
90 KindContactList = 3
91 KindEncryptedDM = 4
92 KindDeletion = 5
93 KindRepost = 6
94 KindReaction = 7
95)
96```
97Helpers: `IsRegular()`, `IsReplaceable()`, `IsEphemeral()`, `IsAddressable()`
98
99### Envelopes (envelope.go)
100Protocol messages as types with `Label()` and `MarshalJSON()`:
101- Client→Relay: `EventEnvelope`, `ReqEnvelope`, `CloseEnvelope`
102- Relay→Client: `EventEnvelope`, `OKEnvelope`, `EOSEEnvelope`, `ClosedEnvelope`, `NoticeEnvelope`
103- `ParseEnvelope(data []byte) (Envelope, error)`
104
105## Keys & Signing (keys.go)
106
107Using `github.com/btcsuite/btcd/btcec/v2/schnorr`:
108```go
109func GenerateKey() (string, error)
110func GetPublicKey(privKeyHex string) (string, error)
111func (e *Event) Sign(privKeyHex string) error
112func (e *Event) Verify() bool
113```
114
115## NIP-19 Encoding (nip19.go)
116
117Bech32 encoding for human-readable identifiers:
118```go
119func EncodePublicKey(pubKeyHex string) (string, error) // -> npub1...
120func EncodeSecretKey(secKeyHex string) (string, error) // -> nsec1...
121func EncodeNote(eventID string) (string, error) // -> note1...
122
123func DecodePublicKey(npub string) (string, error) // npub1... -> hex
124func DecodeSecretKey(nsec string) (string, error) // nsec1... -> hex
125func DecodeNote(note string) (string, error) // note1... -> hex
126
127// TLV-encoded types (nprofile, nevent, naddr) can be added later
128```
129
130## WebSocket Primitives (relay.go)
131
132Simple design - no complex goroutine orchestration:
133```go
134type Relay struct {
135 URL string
136 conn *websocket.Conn
137 mu sync.Mutex
138}
139
140func Connect(ctx context.Context, url string) (*Relay, error)
141func (r *Relay) Close() error
142func (r *Relay) Send(ctx context.Context, env Envelope) error
143func (r *Relay) Receive(ctx context.Context) (Envelope, error)
144func (r *Relay) Publish(ctx context.Context, event *Event) error
145func (r *Relay) Subscribe(ctx context.Context, id string, filters ...Filter) (*Subscription, error)
146
147type Subscription struct {
148 ID string
149 Events chan *Event
150 EOSE chan struct{}
151}
152func (s *Subscription) Listen() error
153func (s *Subscription) Close() error
154```
155
156## Implementation Order
157
158### Phase 1: Core Module (nostr-go)
1591. **go.mod** - Module definition with btcec/v2 dependency
1602. **event.go, tags.go, kinds.go** - Core types, serialization, ID computation
1613. **keys.go** - Schnorr signing with btcec/v2
1624. **bech32.go** - Bech32 encode/decode (~150 lines)
1635. **nip19.go** - npub/nsec/note encoding
1646. **filter.go** - Filter struct with custom JSON and matching
1657. **envelope.go** - All envelope types and ParseEnvelope
1668. **Core tests**
167
168### Phase 2: Relay Module (nostr-go/relay)
1691. **relay/go.mod** - Module definition with websocket dep, imports core
1702. **relay/relay.go** - WebSocket connection primitives
1713. **relay/subscription.go** - Subscription handling
1724. **Relay tests**
173
174## What's Omitted (v0.1)
175
176- NIP-42 AUTH
177- NIP-04 encrypted DMs
178- Connection pooling / relay pool
179- Automatic reconnection
180- Advanced kinds (10000+)
181
182## Verification
183
1841. Unit tests for each module
1852. Integration test: connect to `wss://relay.damus.io`, publish event, subscribe
1863. Verify signature interop with existing Nostr clients/libraries
diff --git a/bech32.go b/bech32.go
new file mode 100644
index 0000000..c8b1293
--- /dev/null
+++ b/bech32.go
@@ -0,0 +1,162 @@
1package nostr
2
3import (
4 "fmt"
5 "strings"
6)
7
8// Bech32 encoding/decoding for NIP-19 (npub, nsec, note, etc.)
9// Implements BIP-173 bech32 encoding.
10
11const bech32Alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
12
13var bech32AlphabetMap [256]int8
14
15func init() {
16 for i := range bech32AlphabetMap {
17 bech32AlphabetMap[i] = -1
18 }
19 for i, c := range bech32Alphabet {
20 bech32AlphabetMap[c] = int8(i)
21 }
22}
23
24// bech32Polymod computes the BCH checksum.
25func bech32Polymod(values []int) int {
26 gen := []int{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
27 chk := 1
28 for _, v := range values {
29 top := chk >> 25
30 chk = (chk&0x1ffffff)<<5 ^ v
31 for i := 0; i < 5; i++ {
32 if (top>>i)&1 == 1 {
33 chk ^= gen[i]
34 }
35 }
36 }
37 return chk
38}
39
40// bech32HRPExpand expands the human-readable part for checksum computation.
41func bech32HRPExpand(hrp string) []int {
42 result := make([]int, len(hrp)*2+1)
43 for i, c := range hrp {
44 result[i] = int(c) >> 5
45 result[i+len(hrp)+1] = int(c) & 31
46 }
47 return result
48}
49
50// bech32CreateChecksum creates the 6-character checksum.
51func bech32CreateChecksum(hrp string, data []int) []int {
52 values := append(bech32HRPExpand(hrp), data...)
53 values = append(values, []int{0, 0, 0, 0, 0, 0}...)
54 polymod := bech32Polymod(values) ^ 1
55 checksum := make([]int, 6)
56 for i := 0; i < 6; i++ {
57 checksum[i] = (polymod >> (5 * (5 - i))) & 31
58 }
59 return checksum
60}
61
62// bech32VerifyChecksum verifies the checksum of bech32 data.
63func bech32VerifyChecksum(hrp string, data []int) bool {
64 return bech32Polymod(append(bech32HRPExpand(hrp), data...)) == 1
65}
66
67// convertBits converts between bit groups.
68func convertBits(data []byte, fromBits, toBits int, pad bool) ([]int, error) {
69 acc := 0
70 bits := 0
71 result := make([]int, 0, len(data)*fromBits/toBits+1)
72 maxv := (1 << toBits) - 1
73
74 for _, value := range data {
75 acc = (acc << fromBits) | int(value)
76 bits += fromBits
77 for bits >= toBits {
78 bits -= toBits
79 result = append(result, (acc>>bits)&maxv)
80 }
81 }
82
83 if pad {
84 if bits > 0 {
85 result = append(result, (acc<<(toBits-bits))&maxv)
86 }
87 } else if bits >= fromBits || ((acc<<(toBits-bits))&maxv) != 0 {
88 return nil, fmt.Errorf("invalid padding")
89 }
90
91 return result, nil
92}
93
94// Bech32Encode encodes data with the given human-readable prefix.
95func Bech32Encode(hrp string, data []byte) (string, error) {
96 values, err := convertBits(data, 8, 5, true)
97 if err != nil {
98 return "", err
99 }
100
101 checksum := bech32CreateChecksum(hrp, values)
102 combined := append(values, checksum...)
103
104 var result strings.Builder
105 result.WriteString(hrp)
106 result.WriteByte('1')
107 for _, v := range combined {
108 result.WriteByte(bech32Alphabet[v])
109 }
110
111 return result.String(), nil
112}
113
114// Bech32Decode decodes a bech32 string, returning the HRP and data.
115func Bech32Decode(s string) (string, []byte, error) {
116 s = strings.ToLower(s)
117
118 pos := strings.LastIndexByte(s, '1')
119 if pos < 1 || pos+7 > len(s) {
120 return "", nil, fmt.Errorf("invalid bech32 string")
121 }
122
123 hrp := s[:pos]
124 dataStr := s[pos+1:]
125
126 data := make([]int, len(dataStr))
127 for i, c := range dataStr {
128 val := bech32AlphabetMap[c]
129 if val == -1 {
130 return "", nil, fmt.Errorf("invalid character: %c", c)
131 }
132 data[i] = int(val)
133 }
134
135 if !bech32VerifyChecksum(hrp, data) {
136 return "", nil, fmt.Errorf("invalid checksum")
137 }
138
139 // Remove checksum
140 data = data[:len(data)-6]
141
142 // Convert from 5-bit to 8-bit
143 result, err := convertBits(intSliceToBytes(data), 5, 8, false)
144 if err != nil {
145 return "", nil, err
146 }
147
148 bytes := make([]byte, len(result))
149 for i, v := range result {
150 bytes[i] = byte(v)
151 }
152
153 return hrp, bytes, nil
154}
155
156func intSliceToBytes(data []int) []byte {
157 result := make([]byte, len(data))
158 for i, v := range data {
159 result[i] = byte(v)
160 }
161 return result
162}
diff --git a/bech32_test.go b/bech32_test.go
new file mode 100644
index 0000000..fb1260b
--- /dev/null
+++ b/bech32_test.go
@@ -0,0 +1,139 @@
1package nostr
2
3import (
4 "bytes"
5 "encoding/hex"
6 "testing"
7)
8
9func TestBech32Encode(t *testing.T) {
10 // Test vector: 32 bytes of data
11 data, _ := hex.DecodeString("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
12
13 encoded, err := Bech32Encode("npub", data)
14 if err != nil {
15 t.Fatalf("Bech32Encode() error = %v", err)
16 }
17
18 if encoded[:5] != "npub1" {
19 t.Errorf("Encoded string should start with 'npub1', got %s", encoded[:5])
20 }
21
22 // Decode it back
23 hrp, decoded, err := Bech32Decode(encoded)
24 if err != nil {
25 t.Fatalf("Bech32Decode() error = %v", err)
26 }
27
28 if hrp != "npub" {
29 t.Errorf("HRP = %s, want npub", hrp)
30 }
31
32 if !bytes.Equal(decoded, data) {
33 t.Errorf("Round-trip failed: got %x, want %x", decoded, data)
34 }
35}
36
37func TestBech32EncodeNsec(t *testing.T) {
38 data, _ := hex.DecodeString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
39
40 encoded, err := Bech32Encode("nsec", data)
41 if err != nil {
42 t.Fatalf("Bech32Encode() error = %v", err)
43 }
44
45 if encoded[:5] != "nsec1" {
46 t.Errorf("Encoded string should start with 'nsec1', got %s", encoded[:5])
47 }
48
49 // Decode it back
50 hrp, decoded, err := Bech32Decode(encoded)
51 if err != nil {
52 t.Fatalf("Bech32Decode() error = %v", err)
53 }
54
55 if hrp != "nsec" {
56 t.Errorf("HRP = %s, want nsec", hrp)
57 }
58
59 if !bytes.Equal(decoded, data) {
60 t.Errorf("Round-trip failed")
61 }
62}
63
64func TestBech32DecodeErrors(t *testing.T) {
65 tests := []struct {
66 name string
67 input string
68 }{
69 {"no separator", "npubabcdef"},
70 {"empty data", "npub1"},
71 {"invalid character", "npub1o"}, // 'o' is not in bech32 alphabet
72 {"invalid checksum", "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpqqqqq"},
73 }
74
75 for _, tt := range tests {
76 t.Run(tt.name, func(t *testing.T) {
77 _, _, err := Bech32Decode(tt.input)
78 if err == nil {
79 t.Error("Bech32Decode() expected error, got nil")
80 }
81 })
82 }
83}
84
85func TestBech32KnownVectors(t *testing.T) {
86 // Test with known nostr npub/nsec values
87 // These can be verified with other nostr tools
88
89 // Generate a key and verify round-trip
90 key, err := GenerateKey()
91 if err != nil {
92 t.Fatalf("GenerateKey() error = %v", err)
93 }
94
95 npub := key.Npub()
96 nsec := key.Nsec()
97
98 // Verify npub decodes to public key
99 hrp, pubBytes, err := Bech32Decode(npub)
100 if err != nil {
101 t.Fatalf("Bech32Decode(npub) error = %v", err)
102 }
103 if hrp != "npub" {
104 t.Errorf("npub HRP = %s, want npub", hrp)
105 }
106 if hex.EncodeToString(pubBytes) != key.Public() {
107 t.Error("npub does not decode to correct public key")
108 }
109
110 // Verify nsec decodes to private key
111 hrp, privBytes, err := Bech32Decode(nsec)
112 if err != nil {
113 t.Fatalf("Bech32Decode(nsec) error = %v", err)
114 }
115 if hrp != "nsec" {
116 t.Errorf("nsec HRP = %s, want nsec", hrp)
117 }
118 if hex.EncodeToString(privBytes) != key.Private() {
119 t.Error("nsec does not decode to correct private key")
120 }
121}
122
123func TestBech32CaseInsensitive(t *testing.T) {
124 data, _ := hex.DecodeString("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
125 encoded, _ := Bech32Encode("npub", data)
126
127 // Test uppercase
128 upper := "NPUB1" + encoded[5:]
129 hrp, decoded, err := Bech32Decode(upper)
130 if err != nil {
131 t.Fatalf("Bech32Decode(uppercase) error = %v", err)
132 }
133 if hrp != "npub" {
134 t.Errorf("HRP = %s, want npub", hrp)
135 }
136 if !bytes.Equal(decoded, data) {
137 t.Error("Uppercase decode failed")
138 }
139}
diff --git a/envelope.go b/envelope.go
new file mode 100644
index 0000000..d395efa
--- /dev/null
+++ b/envelope.go
@@ -0,0 +1,262 @@
1package nostr
2
3import (
4 "encoding/json"
5 "fmt"
6)
7
8// Envelope represents a Nostr protocol message.
9type Envelope interface {
10 Label() string
11 MarshalJSON() ([]byte, error)
12}
13
14// EventEnvelope wraps an event for the EVENT message.
15// Used both client→relay (publish) and relay→client (subscription).
16type EventEnvelope struct {
17 SubscriptionID string // Only for relay→client messages
18 Event *Event
19}
20
21func (e EventEnvelope) Label() string { return "EVENT" }
22
23func (e EventEnvelope) MarshalJSON() ([]byte, error) {
24 if e.SubscriptionID != "" {
25 return json.Marshal([]interface{}{"EVENT", e.SubscriptionID, e.Event})
26 }
27 return json.Marshal([]interface{}{"EVENT", e.Event})
28}
29
30// ReqEnvelope represents a REQ message (client→relay).
31type ReqEnvelope struct {
32 SubscriptionID string
33 Filters []Filter
34}
35
36func (e ReqEnvelope) Label() string { return "REQ" }
37
38func (e ReqEnvelope) MarshalJSON() ([]byte, error) {
39 arr := make([]interface{}, 2+len(e.Filters))
40 arr[0] = "REQ"
41 arr[1] = e.SubscriptionID
42 for i, f := range e.Filters {
43 arr[2+i] = f
44 }
45 return json.Marshal(arr)
46}
47
48// CloseEnvelope represents a CLOSE message (client→relay).
49type CloseEnvelope struct {
50 SubscriptionID string
51}
52
53func (e CloseEnvelope) Label() string { return "CLOSE" }
54
55func (e CloseEnvelope) MarshalJSON() ([]byte, error) {
56 return json.Marshal([]interface{}{"CLOSE", e.SubscriptionID})
57}
58
59// OKEnvelope represents an OK message (relay→client).
60type OKEnvelope struct {
61 EventID string
62 OK bool
63 Message string
64}
65
66func (e OKEnvelope) Label() string { return "OK" }
67
68func (e OKEnvelope) MarshalJSON() ([]byte, error) {
69 return json.Marshal([]interface{}{"OK", e.EventID, e.OK, e.Message})
70}
71
72// EOSEEnvelope represents an EOSE (End of Stored Events) message (relay→client).
73type EOSEEnvelope struct {
74 SubscriptionID string
75}
76
77func (e EOSEEnvelope) Label() string { return "EOSE" }
78
79func (e EOSEEnvelope) MarshalJSON() ([]byte, error) {
80 return json.Marshal([]interface{}{"EOSE", e.SubscriptionID})
81}
82
83// ClosedEnvelope represents a CLOSED message (relay→client).
84type ClosedEnvelope struct {
85 SubscriptionID string
86 Message string
87}
88
89func (e ClosedEnvelope) Label() string { return "CLOSED" }
90
91func (e ClosedEnvelope) MarshalJSON() ([]byte, error) {
92 return json.Marshal([]interface{}{"CLOSED", e.SubscriptionID, e.Message})
93}
94
95// NoticeEnvelope represents a NOTICE message (relay→client).
96type NoticeEnvelope struct {
97 Message string
98}
99
100func (e NoticeEnvelope) Label() string { return "NOTICE" }
101
102func (e NoticeEnvelope) MarshalJSON() ([]byte, error) {
103 return json.Marshal([]interface{}{"NOTICE", e.Message})
104}
105
106// ParseEnvelope parses a raw JSON message into the appropriate envelope type.
107func ParseEnvelope(data []byte) (Envelope, error) {
108 var arr []json.RawMessage
109 if err := json.Unmarshal(data, &arr); err != nil {
110 return nil, fmt.Errorf("invalid envelope: %w", err)
111 }
112
113 if len(arr) < 2 {
114 return nil, fmt.Errorf("envelope too short")
115 }
116
117 var label string
118 if err := json.Unmarshal(arr[0], &label); err != nil {
119 return nil, fmt.Errorf("invalid envelope label: %w", err)
120 }
121
122 switch label {
123 case "EVENT":
124 return parseEventEnvelope(arr)
125 case "REQ":
126 return parseReqEnvelope(arr)
127 case "CLOSE":
128 return parseCloseEnvelope(arr)
129 case "OK":
130 return parseOKEnvelope(arr)
131 case "EOSE":
132 return parseEOSEEnvelope(arr)
133 case "CLOSED":
134 return parseClosedEnvelope(arr)
135 case "NOTICE":
136 return parseNoticeEnvelope(arr)
137 default:
138 return nil, fmt.Errorf("unknown envelope type: %s", label)
139 }
140}
141
142func parseEventEnvelope(arr []json.RawMessage) (*EventEnvelope, error) {
143 env := &EventEnvelope{}
144
145 if len(arr) == 2 {
146 // Client→relay: ["EVENT", event]
147 var event Event
148 if err := json.Unmarshal(arr[1], &event); err != nil {
149 return nil, fmt.Errorf("invalid event: %w", err)
150 }
151 env.Event = &event
152 } else if len(arr) == 3 {
153 // Relay→client: ["EVENT", subscription_id, event]
154 if err := json.Unmarshal(arr[1], &env.SubscriptionID); err != nil {
155 return nil, fmt.Errorf("invalid subscription ID: %w", err)
156 }
157 var event Event
158 if err := json.Unmarshal(arr[2], &event); err != nil {
159 return nil, fmt.Errorf("invalid event: %w", err)
160 }
161 env.Event = &event
162 } else {
163 return nil, fmt.Errorf("invalid EVENT envelope length: %d", len(arr))
164 }
165
166 return env, nil
167}
168
169func parseReqEnvelope(arr []json.RawMessage) (*ReqEnvelope, error) {
170 if len(arr) < 3 {
171 return nil, fmt.Errorf("REQ envelope must have at least 3 elements")
172 }
173
174 env := &ReqEnvelope{}
175 if err := json.Unmarshal(arr[1], &env.SubscriptionID); err != nil {
176 return nil, fmt.Errorf("invalid subscription ID: %w", err)
177 }
178
179 for i := 2; i < len(arr); i++ {
180 var filter Filter
181 if err := json.Unmarshal(arr[i], &filter); err != nil {
182 return nil, fmt.Errorf("invalid filter at index %d: %w", i-2, err)
183 }
184 env.Filters = append(env.Filters, filter)
185 }
186
187 return env, nil
188}
189
190func parseCloseEnvelope(arr []json.RawMessage) (*CloseEnvelope, error) {
191 if len(arr) != 2 {
192 return nil, fmt.Errorf("CLOSE envelope must have exactly 2 elements")
193 }
194
195 env := &CloseEnvelope{}
196 if err := json.Unmarshal(arr[1], &env.SubscriptionID); err != nil {
197 return nil, fmt.Errorf("invalid subscription ID: %w", err)
198 }
199
200 return env, nil
201}
202
203func parseOKEnvelope(arr []json.RawMessage) (*OKEnvelope, error) {
204 if len(arr) != 4 {
205 return nil, fmt.Errorf("OK envelope must have exactly 4 elements")
206 }
207
208 env := &OKEnvelope{}
209 if err := json.Unmarshal(arr[1], &env.EventID); err != nil {
210 return nil, fmt.Errorf("invalid event ID: %w", err)
211 }
212 if err := json.Unmarshal(arr[2], &env.OK); err != nil {
213 return nil, fmt.Errorf("invalid OK status: %w", err)
214 }
215 if err := json.Unmarshal(arr[3], &env.Message); err != nil {
216 return nil, fmt.Errorf("invalid message: %w", err)
217 }
218
219 return env, nil
220}
221
222func parseEOSEEnvelope(arr []json.RawMessage) (*EOSEEnvelope, error) {
223 if len(arr) != 2 {
224 return nil, fmt.Errorf("EOSE envelope must have exactly 2 elements")
225 }
226
227 env := &EOSEEnvelope{}
228 if err := json.Unmarshal(arr[1], &env.SubscriptionID); err != nil {
229 return nil, fmt.Errorf("invalid subscription ID: %w", err)
230 }
231
232 return env, nil
233}
234
235func parseClosedEnvelope(arr []json.RawMessage) (*ClosedEnvelope, error) {
236 if len(arr) != 3 {
237 return nil, fmt.Errorf("CLOSED envelope must have exactly 3 elements")
238 }
239
240 env := &ClosedEnvelope{}
241 if err := json.Unmarshal(arr[1], &env.SubscriptionID); err != nil {
242 return nil, fmt.Errorf("invalid subscription ID: %w", err)
243 }
244 if err := json.Unmarshal(arr[2], &env.Message); err != nil {
245 return nil, fmt.Errorf("invalid message: %w", err)
246 }
247
248 return env, nil
249}
250
251func parseNoticeEnvelope(arr []json.RawMessage) (*NoticeEnvelope, error) {
252 if len(arr) != 2 {
253 return nil, fmt.Errorf("NOTICE envelope must have exactly 2 elements")
254 }
255
256 env := &NoticeEnvelope{}
257 if err := json.Unmarshal(arr[1], &env.Message); err != nil {
258 return nil, fmt.Errorf("invalid message: %w", err)
259 }
260
261 return env, nil
262}
diff --git a/envelope_test.go b/envelope_test.go
new file mode 100644
index 0000000..8f79ad5
--- /dev/null
+++ b/envelope_test.go
@@ -0,0 +1,416 @@
1package nostr
2
3import (
4 "encoding/json"
5 "testing"
6)
7
8func TestEventEnvelopeMarshalJSON(t *testing.T) {
9 event := &Event{
10 ID: "abc123",
11 PubKey: "def456",
12 CreatedAt: 1704067200,
13 Kind: 1,
14 Tags: Tags{},
15 Content: "Hello",
16 Sig: "sig789",
17 }
18
19 t.Run("client to relay", func(t *testing.T) {
20 env := EventEnvelope{Event: event}
21 data, err := env.MarshalJSON()
22 if err != nil {
23 t.Fatalf("MarshalJSON() error = %v", err)
24 }
25
26 var arr []json.RawMessage
27 if err := json.Unmarshal(data, &arr); err != nil {
28 t.Fatalf("Invalid JSON: %v", err)
29 }
30
31 if len(arr) != 2 {
32 t.Errorf("Array length = %d, want 2", len(arr))
33 }
34
35 var label string
36 json.Unmarshal(arr[0], &label)
37 if label != "EVENT" {
38 t.Errorf("Label = %s, want EVENT", label)
39 }
40 })
41
42 t.Run("relay to client", func(t *testing.T) {
43 env := EventEnvelope{SubscriptionID: "sub1", Event: event}
44 data, err := env.MarshalJSON()
45 if err != nil {
46 t.Fatalf("MarshalJSON() error = %v", err)
47 }
48
49 var arr []json.RawMessage
50 if err := json.Unmarshal(data, &arr); err != nil {
51 t.Fatalf("Invalid JSON: %v", err)
52 }
53
54 if len(arr) != 3 {
55 t.Errorf("Array length = %d, want 3", len(arr))
56 }
57 })
58}
59
60func TestReqEnvelopeMarshalJSON(t *testing.T) {
61 env := ReqEnvelope{
62 SubscriptionID: "sub1",
63 Filters: []Filter{
64 {Kinds: []int{1}},
65 {Authors: []string{"abc123"}},
66 },
67 }
68
69 data, err := env.MarshalJSON()
70 if err != nil {
71 t.Fatalf("MarshalJSON() error = %v", err)
72 }
73
74 var arr []json.RawMessage
75 if err := json.Unmarshal(data, &arr); err != nil {
76 t.Fatalf("Invalid JSON: %v", err)
77 }
78
79 if len(arr) != 4 { // ["REQ", "sub1", filter1, filter2]
80 t.Errorf("Array length = %d, want 4", len(arr))
81 }
82
83 var label string
84 json.Unmarshal(arr[0], &label)
85 if label != "REQ" {
86 t.Errorf("Label = %s, want REQ", label)
87 }
88
89 var subID string
90 json.Unmarshal(arr[1], &subID)
91 if subID != "sub1" {
92 t.Errorf("SubscriptionID = %s, want sub1", subID)
93 }
94}
95
96func TestCloseEnvelopeMarshalJSON(t *testing.T) {
97 env := CloseEnvelope{SubscriptionID: "sub1"}
98 data, err := env.MarshalJSON()
99 if err != nil {
100 t.Fatalf("MarshalJSON() error = %v", err)
101 }
102
103 var arr []interface{}
104 if err := json.Unmarshal(data, &arr); err != nil {
105 t.Fatalf("Invalid JSON: %v", err)
106 }
107
108 if len(arr) != 2 {
109 t.Errorf("Array length = %d, want 2", len(arr))
110 }
111 if arr[0] != "CLOSE" {
112 t.Errorf("Label = %v, want CLOSE", arr[0])
113 }
114 if arr[1] != "sub1" {
115 t.Errorf("SubscriptionID = %v, want sub1", arr[1])
116 }
117}
118
119func TestOKEnvelopeMarshalJSON(t *testing.T) {
120 env := OKEnvelope{
121 EventID: "event123",
122 OK: true,
123 Message: "accepted",
124 }
125
126 data, err := env.MarshalJSON()
127 if err != nil {
128 t.Fatalf("MarshalJSON() error = %v", err)
129 }
130
131 var arr []interface{}
132 if err := json.Unmarshal(data, &arr); err != nil {
133 t.Fatalf("Invalid JSON: %v", err)
134 }
135
136 if len(arr) != 4 {
137 t.Errorf("Array length = %d, want 4", len(arr))
138 }
139 if arr[0] != "OK" {
140 t.Errorf("Label = %v, want OK", arr[0])
141 }
142 if arr[1] != "event123" {
143 t.Errorf("EventID = %v, want event123", arr[1])
144 }
145 if arr[2] != true {
146 t.Errorf("OK = %v, want true", arr[2])
147 }
148 if arr[3] != "accepted" {
149 t.Errorf("Message = %v, want accepted", arr[3])
150 }
151}
152
153func TestEOSEEnvelopeMarshalJSON(t *testing.T) {
154 env := EOSEEnvelope{SubscriptionID: "sub1"}
155 data, err := env.MarshalJSON()
156 if err != nil {
157 t.Fatalf("MarshalJSON() error = %v", err)
158 }
159
160 var arr []interface{}
161 if err := json.Unmarshal(data, &arr); err != nil {
162 t.Fatalf("Invalid JSON: %v", err)
163 }
164
165 if len(arr) != 2 {
166 t.Errorf("Array length = %d, want 2", len(arr))
167 }
168 if arr[0] != "EOSE" {
169 t.Errorf("Label = %v, want EOSE", arr[0])
170 }
171}
172
173func TestClosedEnvelopeMarshalJSON(t *testing.T) {
174 env := ClosedEnvelope{
175 SubscriptionID: "sub1",
176 Message: "rate limited",
177 }
178
179 data, err := env.MarshalJSON()
180 if err != nil {
181 t.Fatalf("MarshalJSON() error = %v", err)
182 }
183
184 var arr []interface{}
185 if err := json.Unmarshal(data, &arr); err != nil {
186 t.Fatalf("Invalid JSON: %v", err)
187 }
188
189 if len(arr) != 3 {
190 t.Errorf("Array length = %d, want 3", len(arr))
191 }
192 if arr[0] != "CLOSED" {
193 t.Errorf("Label = %v, want CLOSED", arr[0])
194 }
195}
196
197func TestNoticeEnvelopeMarshalJSON(t *testing.T) {
198 env := NoticeEnvelope{Message: "error: rate limited"}
199 data, err := env.MarshalJSON()
200 if err != nil {
201 t.Fatalf("MarshalJSON() error = %v", err)
202 }
203
204 var arr []interface{}
205 if err := json.Unmarshal(data, &arr); err != nil {
206 t.Fatalf("Invalid JSON: %v", err)
207 }
208
209 if len(arr) != 2 {
210 t.Errorf("Array length = %d, want 2", len(arr))
211 }
212 if arr[0] != "NOTICE" {
213 t.Errorf("Label = %v, want NOTICE", arr[0])
214 }
215}
216
217func TestParseEnvelopeEvent(t *testing.T) {
218 t.Run("client to relay", func(t *testing.T) {
219 data := `["EVENT",{"id":"abc123","pubkey":"def456","created_at":1704067200,"kind":1,"tags":[],"content":"Hello","sig":"sig789"}]`
220 env, err := ParseEnvelope([]byte(data))
221 if err != nil {
222 t.Fatalf("ParseEnvelope() error = %v", err)
223 }
224
225 eventEnv, ok := env.(*EventEnvelope)
226 if !ok {
227 t.Fatalf("Expected *EventEnvelope, got %T", env)
228 }
229
230 if eventEnv.SubscriptionID != "" {
231 t.Errorf("SubscriptionID = %s, want empty", eventEnv.SubscriptionID)
232 }
233 if eventEnv.Event.ID != "abc123" {
234 t.Errorf("Event.ID = %s, want abc123", eventEnv.Event.ID)
235 }
236 })
237
238 t.Run("relay to client", func(t *testing.T) {
239 data := `["EVENT","sub1",{"id":"abc123","pubkey":"def456","created_at":1704067200,"kind":1,"tags":[],"content":"Hello","sig":"sig789"}]`
240 env, err := ParseEnvelope([]byte(data))
241 if err != nil {
242 t.Fatalf("ParseEnvelope() error = %v", err)
243 }
244
245 eventEnv, ok := env.(*EventEnvelope)
246 if !ok {
247 t.Fatalf("Expected *EventEnvelope, got %T", env)
248 }
249
250 if eventEnv.SubscriptionID != "sub1" {
251 t.Errorf("SubscriptionID = %s, want sub1", eventEnv.SubscriptionID)
252 }
253 })
254}
255
256func TestParseEnvelopeReq(t *testing.T) {
257 data := `["REQ","sub1",{"kinds":[1]},{"authors":["abc123"]}]`
258 env, err := ParseEnvelope([]byte(data))
259 if err != nil {
260 t.Fatalf("ParseEnvelope() error = %v", err)
261 }
262
263 reqEnv, ok := env.(*ReqEnvelope)
264 if !ok {
265 t.Fatalf("Expected *ReqEnvelope, got %T", env)
266 }
267
268 if reqEnv.SubscriptionID != "sub1" {
269 t.Errorf("SubscriptionID = %s, want sub1", reqEnv.SubscriptionID)
270 }
271 if len(reqEnv.Filters) != 2 {
272 t.Errorf("Filters length = %d, want 2", len(reqEnv.Filters))
273 }
274}
275
276func TestParseEnvelopeClose(t *testing.T) {
277 data := `["CLOSE","sub1"]`
278 env, err := ParseEnvelope([]byte(data))
279 if err != nil {
280 t.Fatalf("ParseEnvelope() error = %v", err)
281 }
282
283 closeEnv, ok := env.(*CloseEnvelope)
284 if !ok {
285 t.Fatalf("Expected *CloseEnvelope, got %T", env)
286 }
287
288 if closeEnv.SubscriptionID != "sub1" {
289 t.Errorf("SubscriptionID = %s, want sub1", closeEnv.SubscriptionID)
290 }
291}
292
293func TestParseEnvelopeOK(t *testing.T) {
294 data := `["OK","event123",true,"accepted"]`
295 env, err := ParseEnvelope([]byte(data))
296 if err != nil {
297 t.Fatalf("ParseEnvelope() error = %v", err)
298 }
299
300 okEnv, ok := env.(*OKEnvelope)
301 if !ok {
302 t.Fatalf("Expected *OKEnvelope, got %T", env)
303 }
304
305 if okEnv.EventID != "event123" {
306 t.Errorf("EventID = %s, want event123", okEnv.EventID)
307 }
308 if !okEnv.OK {
309 t.Error("OK = false, want true")
310 }
311 if okEnv.Message != "accepted" {
312 t.Errorf("Message = %s, want accepted", okEnv.Message)
313 }
314}
315
316func TestParseEnvelopeEOSE(t *testing.T) {
317 data := `["EOSE","sub1"]`
318 env, err := ParseEnvelope([]byte(data))
319 if err != nil {
320 t.Fatalf("ParseEnvelope() error = %v", err)
321 }
322
323 eoseEnv, ok := env.(*EOSEEnvelope)
324 if !ok {
325 t.Fatalf("Expected *EOSEEnvelope, got %T", env)
326 }
327
328 if eoseEnv.SubscriptionID != "sub1" {
329 t.Errorf("SubscriptionID = %s, want sub1", eoseEnv.SubscriptionID)
330 }
331}
332
333func TestParseEnvelopeClosed(t *testing.T) {
334 data := `["CLOSED","sub1","rate limited"]`
335 env, err := ParseEnvelope([]byte(data))
336 if err != nil {
337 t.Fatalf("ParseEnvelope() error = %v", err)
338 }
339
340 closedEnv, ok := env.(*ClosedEnvelope)
341 if !ok {
342 t.Fatalf("Expected *ClosedEnvelope, got %T", env)
343 }
344
345 if closedEnv.SubscriptionID != "sub1" {
346 t.Errorf("SubscriptionID = %s, want sub1", closedEnv.SubscriptionID)
347 }
348 if closedEnv.Message != "rate limited" {
349 t.Errorf("Message = %s, want rate limited", closedEnv.Message)
350 }
351}
352
353func TestParseEnvelopeNotice(t *testing.T) {
354 data := `["NOTICE","error: rate limited"]`
355 env, err := ParseEnvelope([]byte(data))
356 if err != nil {
357 t.Fatalf("ParseEnvelope() error = %v", err)
358 }
359
360 noticeEnv, ok := env.(*NoticeEnvelope)
361 if !ok {
362 t.Fatalf("Expected *NoticeEnvelope, got %T", env)
363 }
364
365 if noticeEnv.Message != "error: rate limited" {
366 t.Errorf("Message = %s, want 'error: rate limited'", noticeEnv.Message)
367 }
368}
369
370func TestParseEnvelopeErrors(t *testing.T) {
371 tests := []struct {
372 name string
373 data string
374 }{
375 {"invalid json", "not json"},
376 {"not array", `{"type":"EVENT"}`},
377 {"empty array", `[]`},
378 {"single element", `["EVENT"]`},
379 {"unknown type", `["UNKNOWN","data"]`},
380 {"invalid event length", `["EVENT","a","b","c"]`},
381 {"invalid ok length", `["OK","id",true]`},
382 {"invalid eose length", `["EOSE"]`},
383 }
384
385 for _, tt := range tests {
386 t.Run(tt.name, func(t *testing.T) {
387 _, err := ParseEnvelope([]byte(tt.data))
388 if err == nil {
389 t.Error("ParseEnvelope() expected error, got nil")
390 }
391 })
392 }
393}
394
395func TestEnvelopeLabel(t *testing.T) {
396 tests := []struct {
397 env Envelope
398 label string
399 }{
400 {EventEnvelope{}, "EVENT"},
401 {ReqEnvelope{}, "REQ"},
402 {CloseEnvelope{}, "CLOSE"},
403 {OKEnvelope{}, "OK"},
404 {EOSEEnvelope{}, "EOSE"},
405 {ClosedEnvelope{}, "CLOSED"},
406 {NoticeEnvelope{}, "NOTICE"},
407 }
408
409 for _, tt := range tests {
410 t.Run(tt.label, func(t *testing.T) {
411 if got := tt.env.Label(); got != tt.label {
412 t.Errorf("Label() = %s, want %s", got, tt.label)
413 }
414 })
415 }
416}
diff --git a/event.go b/event.go
new file mode 100644
index 0000000..a8156bb
--- /dev/null
+++ b/event.go
@@ -0,0 +1,72 @@
1package nostr
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "fmt"
8)
9
10// Event represents a Nostr event as defined in NIP-01.
11type Event struct {
12 ID string `json:"id"`
13 PubKey string `json:"pubkey"`
14 CreatedAt int64 `json:"created_at"`
15 Kind int `json:"kind"`
16 Tags Tags `json:"tags"`
17 Content string `json:"content"`
18 Sig string `json:"sig"`
19}
20
21// Serialize returns the canonical JSON serialization of the event for ID computation.
22// Format: [0, "pubkey", created_at, kind, tags, "content"]
23func (e *Event) Serialize() []byte {
24 // Use json.Marshal for proper escaping of content and tags
25 arr := []interface{}{
26 0,
27 e.PubKey,
28 e.CreatedAt,
29 e.Kind,
30 e.Tags,
31 e.Content,
32 }
33 data, _ := json.Marshal(arr)
34 return data
35}
36
37// ComputeID calculates the SHA256 hash of the serialized event.
38// Returns the 64-character hex-encoded ID.
39func (e *Event) ComputeID() string {
40 serialized := e.Serialize()
41 hash := sha256.Sum256(serialized)
42 return hex.EncodeToString(hash[:])
43}
44
45// SetID computes and sets the event ID.
46func (e *Event) SetID() {
47 e.ID = e.ComputeID()
48}
49
50// CheckID verifies that the event ID matches the computed ID.
51func (e *Event) CheckID() bool {
52 return e.ID == e.ComputeID()
53}
54
55// MarshalJSON implements json.Marshaler with empty tags as [] instead of null.
56func (e Event) MarshalJSON() ([]byte, error) {
57 type eventAlias Event
58 ea := eventAlias(e)
59 if ea.Tags == nil {
60 ea.Tags = Tags{}
61 }
62 return json.Marshal(ea)
63}
64
65// String returns a JSON representation of the event for debugging.
66func (e *Event) String() string {
67 data, err := json.MarshalIndent(e, "", " ")
68 if err != nil {
69 return fmt.Sprintf("<Event error: %v>", err)
70 }
71 return string(data)
72}
diff --git a/event_test.go b/event_test.go
new file mode 100644
index 0000000..eff4103
--- /dev/null
+++ b/event_test.go
@@ -0,0 +1,194 @@
1package nostr
2
3import (
4 "encoding/json"
5 "testing"
6)
7
8func TestEventSerialize(t *testing.T) {
9 event := &Event{
10 PubKey: "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e",
11 CreatedAt: 1704067200,
12 Kind: 1,
13 Tags: Tags{{"e", "abc123"}, {"p", "def456"}},
14 Content: "Hello, Nostr!",
15 }
16
17 serialized := event.Serialize()
18
19 // Parse the JSON to verify structure
20 var arr []interface{}
21 if err := json.Unmarshal(serialized, &arr); err != nil {
22 t.Fatalf("Serialize() produced invalid JSON: %v", err)
23 }
24
25 if len(arr) != 6 {
26 t.Fatalf("Serialized array has %d elements, want 6", len(arr))
27 }
28
29 // Check each element
30 if arr[0].(float64) != 0 {
31 t.Errorf("arr[0] = %v, want 0", arr[0])
32 }
33 if arr[1].(string) != event.PubKey {
34 t.Errorf("arr[1] = %v, want %s", arr[1], event.PubKey)
35 }
36 if int64(arr[2].(float64)) != event.CreatedAt {
37 t.Errorf("arr[2] = %v, want %d", arr[2], event.CreatedAt)
38 }
39 if int(arr[3].(float64)) != event.Kind {
40 t.Errorf("arr[3] = %v, want %d", arr[3], event.Kind)
41 }
42 if arr[5].(string) != event.Content {
43 t.Errorf("arr[5] = %v, want %s", arr[5], event.Content)
44 }
45}
46
47func TestEventComputeID(t *testing.T) {
48 // Test with a known event (you can verify with other implementations)
49 event := &Event{
50 PubKey: "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e",
51 CreatedAt: 1704067200,
52 Kind: 1,
53 Tags: Tags{},
54 Content: "Hello, Nostr!",
55 }
56
57 id := event.ComputeID()
58
59 // ID should be 64 hex characters
60 if len(id) != 64 {
61 t.Errorf("ComputeID() returned ID of length %d, want 64", len(id))
62 }
63
64 // Verify it's valid hex
65 for _, c := range id {
66 if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
67 t.Errorf("ComputeID() returned invalid hex character: %c", c)
68 }
69 }
70
71 // Verify consistency
72 id2 := event.ComputeID()
73 if id != id2 {
74 t.Errorf("ComputeID() is not consistent: %s != %s", id, id2)
75 }
76}
77
78func TestEventSetID(t *testing.T) {
79 event := &Event{
80 PubKey: "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e",
81 CreatedAt: 1704067200,
82 Kind: 1,
83 Tags: Tags{},
84 Content: "Test",
85 }
86
87 event.SetID()
88 if event.ID == "" {
89 t.Error("SetID() did not set ID")
90 }
91 if !event.CheckID() {
92 t.Error("CheckID() returned false after SetID()")
93 }
94}
95
96func TestEventCheckID(t *testing.T) {
97 event := &Event{
98 PubKey: "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e",
99 CreatedAt: 1704067200,
100 Kind: 1,
101 Tags: Tags{},
102 Content: "Test",
103 }
104
105 event.SetID()
106
107 if !event.CheckID() {
108 t.Error("CheckID() returned false for valid ID")
109 }
110
111 // Corrupt the ID
112 event.ID = "0000000000000000000000000000000000000000000000000000000000000000"
113 if event.CheckID() {
114 t.Error("CheckID() returned true for invalid ID")
115 }
116}
117
118func TestEventMarshalJSON(t *testing.T) {
119 event := Event{
120 ID: "abc123",
121 PubKey: "def456",
122 CreatedAt: 1704067200,
123 Kind: 1,
124 Tags: nil, // nil tags
125 Content: "Test",
126 Sig: "sig789",
127 }
128
129 data, err := json.Marshal(event)
130 if err != nil {
131 t.Fatalf("MarshalJSON() error = %v", err)
132 }
133
134 // Verify tags is [] not null
135 var m map[string]interface{}
136 if err := json.Unmarshal(data, &m); err != nil {
137 t.Fatalf("Failed to unmarshal: %v", err)
138 }
139
140 tags, ok := m["tags"]
141 if !ok {
142 t.Error("tags field missing from JSON")
143 }
144 if tags == nil {
145 t.Error("tags is null, want []")
146 }
147 if arr, ok := tags.([]interface{}); !ok || len(arr) != 0 {
148 t.Errorf("tags = %v, want []", tags)
149 }
150}
151
152func TestEventJSONRoundTrip(t *testing.T) {
153 original := Event{
154 ID: "abc123def456",
155 PubKey: "pubkey123",
156 CreatedAt: 1704067200,
157 Kind: 1,
158 Tags: Tags{{"e", "event1"}, {"p", "pubkey1", "relay"}},
159 Content: "Hello with \"quotes\" and \n newlines",
160 Sig: "signature123",
161 }
162
163 data, err := json.Marshal(original)
164 if err != nil {
165 t.Fatalf("Marshal error: %v", err)
166 }
167
168 var decoded Event
169 if err := json.Unmarshal(data, &decoded); err != nil {
170 t.Fatalf("Unmarshal error: %v", err)
171 }
172
173 if decoded.ID != original.ID {
174 t.Errorf("ID mismatch: %s != %s", decoded.ID, original.ID)
175 }
176 if decoded.PubKey != original.PubKey {
177 t.Errorf("PubKey mismatch: %s != %s", decoded.PubKey, original.PubKey)
178 }
179 if decoded.CreatedAt != original.CreatedAt {
180 t.Errorf("CreatedAt mismatch: %d != %d", decoded.CreatedAt, original.CreatedAt)
181 }
182 if decoded.Kind != original.Kind {
183 t.Errorf("Kind mismatch: %d != %d", decoded.Kind, original.Kind)
184 }
185 if decoded.Content != original.Content {
186 t.Errorf("Content mismatch: %s != %s", decoded.Content, original.Content)
187 }
188 if decoded.Sig != original.Sig {
189 t.Errorf("Sig mismatch: %s != %s", decoded.Sig, original.Sig)
190 }
191 if len(decoded.Tags) != len(original.Tags) {
192 t.Errorf("Tags length mismatch: %d != %d", len(decoded.Tags), len(original.Tags))
193 }
194}
diff --git a/example_test.go b/example_test.go
new file mode 100644
index 0000000..90dae0f
--- /dev/null
+++ b/example_test.go
@@ -0,0 +1,100 @@
1package nostr_test
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "northwest.io/nostr"
9)
10
11// Example_basic demonstrates basic usage of the nostr library.
12func Example_basic() {
13 // Generate a new key pair
14 key, err := nostr.GenerateKey()
15 if err != nil {
16 fmt.Printf("Failed to generate key: %v\n", err)
17 return
18 }
19
20 fmt.Printf("Public key (hex): %s...\n", key.Public()[:16])
21 fmt.Printf("Public key (npub): %s...\n", key.Npub()[:20])
22
23 // Create an event
24 event := &nostr.Event{
25 CreatedAt: time.Now().Unix(),
26 Kind: nostr.KindTextNote,
27 Tags: nostr.Tags{{"t", "test"}},
28 Content: "Hello from nostr-go!",
29 }
30
31 // Sign the event
32 if err := key.Sign(event); err != nil {
33 fmt.Printf("Failed to sign event: %v\n", err)
34 return
35 }
36
37 // Verify the signature
38 if event.Verify() {
39 fmt.Println("Event signature verified!")
40 }
41
42 // Create a filter to match our event
43 filter := nostr.Filter{
44 Kinds: []int{nostr.KindTextNote},
45 Authors: []string{key.Public()[:8]}, // Prefix matching
46 }
47
48 if filter.Matches(event) {
49 fmt.Println("Filter matches the event!")
50 }
51}
52
53// ExampleRelay demonstrates connecting to a relay (requires network).
54// This is a documentation example - run with: go test -v -run ExampleRelay
55func ExampleRelay() {
56 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
57 defer cancel()
58
59 // Connect to a public relay
60 relay, err := nostr.Connect(ctx, "wss://relay.damus.io")
61 if err != nil {
62 fmt.Printf("Failed to connect: %v\n", err)
63 return
64 }
65 defer relay.Close()
66
67 fmt.Println("Connected to relay!")
68
69 // Subscribe to recent text notes
70 since := time.Now().Add(-1 * time.Hour).Unix()
71 sub, err := relay.Subscribe(ctx, "my-sub", nostr.Filter{
72 Kinds: []int{nostr.KindTextNote},
73 Since: &since,
74 Limit: 5,
75 })
76 if err != nil {
77 fmt.Printf("Failed to subscribe: %v\n", err)
78 return
79 }
80
81 // Listen for events in the background
82 go relay.Listen(ctx)
83
84 // Collect events until EOSE
85 eventCount := 0
86 for {
87 select {
88 case event := <-sub.Events:
89 eventCount++
90 fmt.Printf("Received event from %s...\n", event.PubKey[:8])
91 case <-sub.EOSE:
92 fmt.Printf("Received %d events before EOSE\n", eventCount)
93 sub.Close(ctx)
94 return
95 case <-ctx.Done():
96 fmt.Println("Timeout")
97 return
98 }
99 }
100}
diff --git a/examples/basic/main.go b/examples/basic/main.go
new file mode 100644
index 0000000..0c99dd9
--- /dev/null
+++ b/examples/basic/main.go
@@ -0,0 +1,103 @@
1package main
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "time"
8
9 "northwest.io/nostr"
10)
11
12// Example_basic demonstrates basic usage of the nostr library.
13func main() {
14 // Generate a new key pair
15 key, err := nostr.GenerateKey()
16 if err != nil {
17 fmt.Printf("Failed to generate key: %v\n", err)
18 os.Exit(1)
19 }
20
21 fmt.Printf("Public key (hex): %s...\n", key.Public()[:16])
22 fmt.Printf("Public key (npub): %s...\n", key.Npub()[:20])
23
24 // Create an event
25 event := &nostr.Event{
26 Kind: nostr.KindTextNote,
27 Tags: nostr.Tags{{"t", "test"}},
28 Content: "Hello from nostr-go!",
29 }
30
31 // Sign the event
32 if err := key.Sign(event); err != nil {
33 fmt.Printf("Failed to sign event: %v\n", err)
34 os.Exit(1)
35 }
36
37 // Verify the signature
38 if event.Verify() {
39 fmt.Println("Event signature verified!")
40 }
41
42 // Create a filter to match our event
43 filter := nostr.Filter{
44 Kinds: []int{nostr.KindTextNote},
45 Authors: []string{key.Public()[:8]}, // Prefix matching
46 }
47
48 if filter.Matches(event) {
49 fmt.Println("Filter matches the event!")
50 }
51
52 fmt.Println("connecting to relay...")
53 ExampleRelay()
54}
55
56// ExampleRelay demonstrates connecting to a relay (requires network).
57// This is a documentation example - run with: go test -v -run ExampleRelay
58func ExampleRelay() {
59 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
60 defer cancel()
61
62 // Connect to a public relay
63 relay, err := nostr.Connect(ctx, "wss://relay.damus.io")
64 if err != nil {
65 fmt.Printf("Failed to connect: %v\n", err)
66 return
67 }
68 defer relay.Close()
69
70 fmt.Println("Connected to relay!")
71
72 // Subscribe to recent text notes
73 since := time.Now().Add(-1 * time.Hour).Unix()
74 sub, err := relay.Subscribe(ctx, "my-sub", nostr.Filter{
75 Kinds: []int{nostr.KindTextNote},
76 Since: &since,
77 Limit: 5,
78 })
79 if err != nil {
80 fmt.Printf("Failed to subscribe: %v\n", err)
81 os.Exit(1)
82 }
83
84 // Listen for events in the background
85 go relay.Listen(ctx)
86
87 // Collect events until EOSE
88 eventCount := 0
89 for {
90 select {
91 case event := <-sub.Events:
92 eventCount++
93 fmt.Printf("Received event from %s...\n", event)
94 case <-sub.EOSE:
95 fmt.Printf("Received %d events before EOSE\n", eventCount)
96 sub.Close(ctx)
97 return
98 case <-ctx.Done():
99 fmt.Println("Timeout")
100 return
101 }
102 }
103}
diff --git a/filter.go b/filter.go
new file mode 100644
index 0000000..dde04a5
--- /dev/null
+++ b/filter.go
@@ -0,0 +1,224 @@
1package nostr
2
3import (
4 "encoding/json"
5 "strings"
6)
7
8// Filter represents a subscription filter as defined in NIP-01.
9type Filter struct {
10 IDs []string `json:"ids,omitempty"`
11 Kinds []int `json:"kinds,omitempty"`
12 Authors []string `json:"authors,omitempty"`
13 Tags map[string][]string `json:"-"` // Custom marshaling for #e, #p, etc.
14 Since *int64 `json:"since,omitempty"`
15 Until *int64 `json:"until,omitempty"`
16 Limit int `json:"limit,omitempty"`
17}
18
19// MarshalJSON implements json.Marshaler for Filter.
20// Converts Tags map to #e, #p format.
21func (f Filter) MarshalJSON() ([]byte, error) {
22 // Create a map for custom marshaling
23 m := make(map[string]interface{})
24
25 if len(f.IDs) > 0 {
26 m["ids"] = f.IDs
27 }
28 if len(f.Kinds) > 0 {
29 m["kinds"] = f.Kinds
30 }
31 if len(f.Authors) > 0 {
32 m["authors"] = f.Authors
33 }
34 if f.Since != nil {
35 m["since"] = *f.Since
36 }
37 if f.Until != nil {
38 m["until"] = *f.Until
39 }
40 if f.Limit > 0 {
41 m["limit"] = f.Limit
42 }
43
44 // Add tag filters with # prefix
45 for key, values := range f.Tags {
46 if len(values) > 0 {
47 m["#"+key] = values
48 }
49 }
50
51 return json.Marshal(m)
52}
53
54// UnmarshalJSON implements json.Unmarshaler for Filter.
55// Extracts #e, #p format into Tags map.
56func (f *Filter) UnmarshalJSON(data []byte) error {
57 // First unmarshal into a raw map
58 var raw map[string]json.RawMessage
59 if err := json.Unmarshal(data, &raw); err != nil {
60 return err
61 }
62
63 // Extract known fields
64 if v, ok := raw["ids"]; ok {
65 if err := json.Unmarshal(v, &f.IDs); err != nil {
66 return err
67 }
68 }
69 if v, ok := raw["kinds"]; ok {
70 if err := json.Unmarshal(v, &f.Kinds); err != nil {
71 return err
72 }
73 }
74 if v, ok := raw["authors"]; ok {
75 if err := json.Unmarshal(v, &f.Authors); err != nil {
76 return err
77 }
78 }
79 if v, ok := raw["since"]; ok {
80 var since int64
81 if err := json.Unmarshal(v, &since); err != nil {
82 return err
83 }
84 f.Since = &since
85 }
86 if v, ok := raw["until"]; ok {
87 var until int64
88 if err := json.Unmarshal(v, &until); err != nil {
89 return err
90 }
91 f.Until = &until
92 }
93 if v, ok := raw["limit"]; ok {
94 if err := json.Unmarshal(v, &f.Limit); err != nil {
95 return err
96 }
97 }
98
99 // Extract tag filters (fields starting with #)
100 f.Tags = make(map[string][]string)
101 for key, value := range raw {
102 if strings.HasPrefix(key, "#") {
103 tagKey := strings.TrimPrefix(key, "#")
104 var values []string
105 if err := json.Unmarshal(value, &values); err != nil {
106 return err
107 }
108 f.Tags[tagKey] = values
109 }
110 }
111
112 return nil
113}
114
115// Matches checks if an event matches this filter.
116func (f *Filter) Matches(event *Event) bool {
117 // Check IDs (prefix match)
118 if len(f.IDs) > 0 {
119 found := false
120 for _, id := range f.IDs {
121 if strings.HasPrefix(event.ID, id) {
122 found = true
123 break
124 }
125 }
126 if !found {
127 return false
128 }
129 }
130
131 // Check authors (prefix match)
132 if len(f.Authors) > 0 {
133 found := false
134 for _, author := range f.Authors {
135 if strings.HasPrefix(event.PubKey, author) {
136 found = true
137 break
138 }
139 }
140 if !found {
141 return false
142 }
143 }
144
145 // Check kinds
146 if len(f.Kinds) > 0 {
147 found := false
148 for _, kind := range f.Kinds {
149 if event.Kind == kind {
150 found = true
151 break
152 }
153 }
154 if !found {
155 return false
156 }
157 }
158
159 // Check since
160 if f.Since != nil && event.CreatedAt < *f.Since {
161 return false
162 }
163
164 // Check until
165 if f.Until != nil && event.CreatedAt > *f.Until {
166 return false
167 }
168
169 // Check tag filters
170 for tagKey, values := range f.Tags {
171 if len(values) == 0 {
172 continue
173 }
174 found := false
175 for _, val := range values {
176 if event.Tags.ContainsValue(tagKey, val) {
177 found = true
178 break
179 }
180 }
181 if !found {
182 return false
183 }
184 }
185
186 return true
187}
188
189// Clone creates a deep copy of the filter.
190func (f *Filter) Clone() *Filter {
191 clone := &Filter{
192 Limit: f.Limit,
193 }
194
195 if f.IDs != nil {
196 clone.IDs = make([]string, len(f.IDs))
197 copy(clone.IDs, f.IDs)
198 }
199 if f.Kinds != nil {
200 clone.Kinds = make([]int, len(f.Kinds))
201 copy(clone.Kinds, f.Kinds)
202 }
203 if f.Authors != nil {
204 clone.Authors = make([]string, len(f.Authors))
205 copy(clone.Authors, f.Authors)
206 }
207 if f.Since != nil {
208 since := *f.Since
209 clone.Since = &since
210 }
211 if f.Until != nil {
212 until := *f.Until
213 clone.Until = &until
214 }
215 if f.Tags != nil {
216 clone.Tags = make(map[string][]string)
217 for k, v := range f.Tags {
218 clone.Tags[k] = make([]string, len(v))
219 copy(clone.Tags[k], v)
220 }
221 }
222
223 return clone
224}
diff --git a/filter_test.go b/filter_test.go
new file mode 100644
index 0000000..ebe2b1d
--- /dev/null
+++ b/filter_test.go
@@ -0,0 +1,415 @@
1package nostr
2
3import (
4 "encoding/json"
5 "testing"
6)
7
8func TestFilterMarshalJSON(t *testing.T) {
9 since := int64(1704067200)
10 until := int64(1704153600)
11
12 filter := Filter{
13 IDs: []string{"abc123"},
14 Kinds: []int{1, 7},
15 Authors: []string{"def456"},
16 Tags: map[string][]string{
17 "e": {"event1", "event2"},
18 "p": {"pubkey1"},
19 },
20 Since: &since,
21 Until: &until,
22 Limit: 100,
23 }
24
25 data, err := filter.MarshalJSON()
26 if err != nil {
27 t.Fatalf("MarshalJSON() error = %v", err)
28 }
29
30 // Parse and check structure
31 var m map[string]interface{}
32 if err := json.Unmarshal(data, &m); err != nil {
33 t.Fatalf("Failed to unmarshal: %v", err)
34 }
35
36 // Check regular fields
37 if _, ok := m["ids"]; !ok {
38 t.Error("ids field missing")
39 }
40 if _, ok := m["kinds"]; !ok {
41 t.Error("kinds field missing")
42 }
43 if _, ok := m["authors"]; !ok {
44 t.Error("authors field missing")
45 }
46 if _, ok := m["since"]; !ok {
47 t.Error("since field missing")
48 }
49 if _, ok := m["until"]; !ok {
50 t.Error("until field missing")
51 }
52 if _, ok := m["limit"]; !ok {
53 t.Error("limit field missing")
54 }
55
56 // Check tag filters with # prefix
57 if _, ok := m["#e"]; !ok {
58 t.Error("#e field missing")
59 }
60 if _, ok := m["#p"]; !ok {
61 t.Error("#p field missing")
62 }
63}
64
65func TestFilterMarshalJSONOmitsEmpty(t *testing.T) {
66 filter := Filter{
67 Kinds: []int{1},
68 }
69
70 data, err := filter.MarshalJSON()
71 if err != nil {
72 t.Fatalf("MarshalJSON() error = %v", err)
73 }
74
75 var m map[string]interface{}
76 if err := json.Unmarshal(data, &m); err != nil {
77 t.Fatalf("Failed to unmarshal: %v", err)
78 }
79
80 if _, ok := m["ids"]; ok {
81 t.Error("empty ids should be omitted")
82 }
83 if _, ok := m["authors"]; ok {
84 t.Error("empty authors should be omitted")
85 }
86 if _, ok := m["since"]; ok {
87 t.Error("nil since should be omitted")
88 }
89 if _, ok := m["until"]; ok {
90 t.Error("nil until should be omitted")
91 }
92 if _, ok := m["limit"]; ok {
93 t.Error("zero limit should be omitted")
94 }
95}
96
97func TestFilterUnmarshalJSON(t *testing.T) {
98 jsonData := `{
99 "ids": ["abc123"],
100 "kinds": [1, 7],
101 "authors": ["def456"],
102 "#e": ["event1", "event2"],
103 "#p": ["pubkey1"],
104 "since": 1704067200,
105 "until": 1704153600,
106 "limit": 100
107 }`
108
109 var filter Filter
110 if err := json.Unmarshal([]byte(jsonData), &filter); err != nil {
111 t.Fatalf("UnmarshalJSON() error = %v", err)
112 }
113
114 if len(filter.IDs) != 1 || filter.IDs[0] != "abc123" {
115 t.Errorf("IDs = %v, want [abc123]", filter.IDs)
116 }
117 if len(filter.Kinds) != 2 {
118 t.Errorf("Kinds length = %d, want 2", len(filter.Kinds))
119 }
120 if len(filter.Authors) != 1 || filter.Authors[0] != "def456" {
121 t.Errorf("Authors = %v, want [def456]", filter.Authors)
122 }
123 if filter.Since == nil || *filter.Since != 1704067200 {
124 t.Errorf("Since = %v, want 1704067200", filter.Since)
125 }
126 if filter.Until == nil || *filter.Until != 1704153600 {
127 t.Errorf("Until = %v, want 1704153600", filter.Until)
128 }
129 if filter.Limit != 100 {
130 t.Errorf("Limit = %d, want 100", filter.Limit)
131 }
132
133 // Check tag filters
134 if len(filter.Tags["e"]) != 2 {
135 t.Errorf("Tags[e] length = %d, want 2", len(filter.Tags["e"]))
136 }
137 if len(filter.Tags["p"]) != 1 {
138 t.Errorf("Tags[p] length = %d, want 1", len(filter.Tags["p"]))
139 }
140}
141
142func TestFilterMatchesIDs(t *testing.T) {
143 filter := Filter{
144 IDs: []string{"abc", "def456"},
145 }
146
147 tests := []struct {
148 id string
149 want bool
150 }{
151 {"abc123", true}, // matches prefix "abc"
152 {"abcdef", true}, // matches prefix "abc"
153 {"def456", true}, // exact match
154 {"def456xyz", true}, // matches prefix "def456"
155 {"xyz789", false}, // no match
156 {"ab", false}, // "ab" doesn't start with "abc"
157 }
158
159 for _, tt := range tests {
160 event := &Event{ID: tt.id}
161 if got := filter.Matches(event); got != tt.want {
162 t.Errorf("Matches() with ID %s = %v, want %v", tt.id, got, tt.want)
163 }
164 }
165}
166
167func TestFilterMatchesAuthors(t *testing.T) {
168 filter := Filter{
169 Authors: []string{"pubkey1", "pubkey2"},
170 }
171
172 tests := []struct {
173 pubkey string
174 want bool
175 }{
176 {"pubkey1", true},
177 {"pubkey1abc", true}, // Prefix match
178 {"pubkey2", true},
179 {"pubkey3", false},
180 }
181
182 for _, tt := range tests {
183 event := &Event{PubKey: tt.pubkey}
184 if got := filter.Matches(event); got != tt.want {
185 t.Errorf("Matches() with PubKey %s = %v, want %v", tt.pubkey, got, tt.want)
186 }
187 }
188}
189
190func TestFilterMatchesKinds(t *testing.T) {
191 filter := Filter{
192 Kinds: []int{1, 7},
193 }
194
195 tests := []struct {
196 kind int
197 want bool
198 }{
199 {1, true},
200 {7, true},
201 {0, false},
202 {4, false},
203 }
204
205 for _, tt := range tests {
206 event := &Event{Kind: tt.kind}
207 if got := filter.Matches(event); got != tt.want {
208 t.Errorf("Matches() with Kind %d = %v, want %v", tt.kind, got, tt.want)
209 }
210 }
211}
212
213func TestFilterMatchesSince(t *testing.T) {
214 since := int64(1704067200)
215 filter := Filter{
216 Since: &since,
217 }
218
219 tests := []struct {
220 createdAt int64
221 want bool
222 }{
223 {1704067200, true}, // Equal
224 {1704067201, true}, // After
225 {1704067199, false}, // Before
226 }
227
228 for _, tt := range tests {
229 event := &Event{CreatedAt: tt.createdAt}
230 if got := filter.Matches(event); got != tt.want {
231 t.Errorf("Matches() with CreatedAt %d = %v, want %v", tt.createdAt, got, tt.want)
232 }
233 }
234}
235
236func TestFilterMatchesUntil(t *testing.T) {
237 until := int64(1704067200)
238 filter := Filter{
239 Until: &until,
240 }
241
242 tests := []struct {
243 createdAt int64
244 want bool
245 }{
246 {1704067200, true}, // Equal
247 {1704067199, true}, // Before
248 {1704067201, false}, // After
249 }
250
251 for _, tt := range tests {
252 event := &Event{CreatedAt: tt.createdAt}
253 if got := filter.Matches(event); got != tt.want {
254 t.Errorf("Matches() with CreatedAt %d = %v, want %v", tt.createdAt, got, tt.want)
255 }
256 }
257}
258
259func TestFilterMatchesTags(t *testing.T) {
260 filter := Filter{
261 Tags: map[string][]string{
262 "e": {"event1"},
263 "p": {"pubkey1", "pubkey2"},
264 },
265 }
266
267 tests := []struct {
268 name string
269 tags Tags
270 want bool
271 }{
272 {
273 name: "matches all",
274 tags: Tags{{"e", "event1"}, {"p", "pubkey1"}},
275 want: true,
276 },
277 {
278 name: "matches with different p",
279 tags: Tags{{"e", "event1"}, {"p", "pubkey2"}},
280 want: true,
281 },
282 {
283 name: "missing e tag",
284 tags: Tags{{"p", "pubkey1"}},
285 want: false,
286 },
287 {
288 name: "wrong e value",
289 tags: Tags{{"e", "event2"}, {"p", "pubkey1"}},
290 want: false,
291 },
292 {
293 name: "extra tags ok",
294 tags: Tags{{"e", "event1"}, {"p", "pubkey1"}, {"t", "test"}},
295 want: true,
296 },
297 }
298
299 for _, tt := range tests {
300 t.Run(tt.name, func(t *testing.T) {
301 event := &Event{Tags: tt.tags}
302 if got := filter.Matches(event); got != tt.want {
303 t.Errorf("Matches() = %v, want %v", got, tt.want)
304 }
305 })
306 }
307}
308
309func TestFilterMatchesEmpty(t *testing.T) {
310 // Empty filter matches everything
311 filter := Filter{}
312 event := &Event{
313 ID: "abc123",
314 PubKey: "pubkey1",
315 CreatedAt: 1704067200,
316 Kind: 1,
317 Tags: Tags{{"e", "event1"}},
318 Content: "test",
319 }
320
321 if !filter.Matches(event) {
322 t.Error("Empty filter should match all events")
323 }
324}
325
326func TestFilterClone(t *testing.T) {
327 since := int64(1704067200)
328 until := int64(1704153600)
329
330 original := &Filter{
331 IDs: []string{"id1", "id2"},
332 Kinds: []int{1, 7},
333 Authors: []string{"author1"},
334 Tags: map[string][]string{
335 "e": {"event1"},
336 },
337 Since: &since,
338 Until: &until,
339 Limit: 100,
340 }
341
342 clone := original.Clone()
343
344 // Modify original
345 original.IDs[0] = "modified"
346 original.Kinds[0] = 999
347 original.Authors[0] = "modified"
348 original.Tags["e"][0] = "modified"
349 *original.Since = 0
350 *original.Until = 0
351 original.Limit = 0
352
353 // Clone should be unchanged
354 if clone.IDs[0] != "id1" {
355 t.Error("Clone IDs was modified")
356 }
357 if clone.Kinds[0] != 1 {
358 t.Error("Clone Kinds was modified")
359 }
360 if clone.Authors[0] != "author1" {
361 t.Error("Clone Authors was modified")
362 }
363 if clone.Tags["e"][0] != "event1" {
364 t.Error("Clone Tags was modified")
365 }
366 if *clone.Since != 1704067200 {
367 t.Error("Clone Since was modified")
368 }
369 if *clone.Until != 1704153600 {
370 t.Error("Clone Until was modified")
371 }
372 if clone.Limit != 100 {
373 t.Error("Clone Limit was modified")
374 }
375}
376
377func TestFilterJSONRoundTrip(t *testing.T) {
378 since := int64(1704067200)
379 original := Filter{
380 IDs: []string{"abc123"},
381 Kinds: []int{1},
382 Authors: []string{"def456"},
383 Tags: map[string][]string{
384 "e": {"event1"},
385 },
386 Since: &since,
387 Limit: 50,
388 }
389
390 data, err := json.Marshal(original)
391 if err != nil {
392 t.Fatalf("Marshal error: %v", err)
393 }
394
395 var decoded Filter
396 if err := json.Unmarshal(data, &decoded); err != nil {
397 t.Fatalf("Unmarshal error: %v", err)
398 }
399
400 if len(decoded.IDs) != 1 || decoded.IDs[0] != "abc123" {
401 t.Errorf("IDs mismatch")
402 }
403 if len(decoded.Kinds) != 1 || decoded.Kinds[0] != 1 {
404 t.Errorf("Kinds mismatch")
405 }
406 if len(decoded.Tags["e"]) != 1 || decoded.Tags["e"][0] != "event1" {
407 t.Errorf("Tags mismatch")
408 }
409 if decoded.Since == nil || *decoded.Since != since {
410 t.Errorf("Since mismatch")
411 }
412 if decoded.Limit != 50 {
413 t.Errorf("Limit mismatch")
414 }
415}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2220a3f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,14 @@
1module northwest.io/nostr
2
3go 1.21
4
5require (
6 github.com/btcsuite/btcd/btcec/v2 v2.3.2
7 github.com/coder/websocket v1.8.12
8)
9
10require (
11 github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
12 github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect
13 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
14)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..69732b7
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,12 @@
1github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
2github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
3github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
4github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
5github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
6github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
7github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
8github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
10github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
11github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
12github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
diff --git a/keys.go b/keys.go
new file mode 100644
index 0000000..3a3fb9c
--- /dev/null
+++ b/keys.go
@@ -0,0 +1,217 @@
1package nostr
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "fmt"
7 "strings"
8 "time"
9
10 "github.com/btcsuite/btcd/btcec/v2"
11 "github.com/btcsuite/btcd/btcec/v2/schnorr"
12)
13
14// Key represents a Nostr key, which may be a full private key or public-only.
15// Use GenerateKey or ParseKey for private keys, ParsePublicKey for public-only.
16type Key struct {
17 priv *btcec.PrivateKey // nil for public-only keys
18 pub *btcec.PublicKey // always set
19}
20
21// GenerateKey generates a new random private key.
22func GenerateKey() (*Key, error) {
23 var keyBytes [32]byte
24 if _, err := rand.Read(keyBytes[:]); err != nil {
25 return nil, fmt.Errorf("failed to generate random bytes: %w", err)
26 }
27
28 priv, _ := btcec.PrivKeyFromBytes(keyBytes[:])
29 return &Key{
30 priv: priv,
31 pub: priv.PubKey(),
32 }, nil
33}
34
35// ParseKey parses a private key from hex or nsec (bech32) format.
36func ParseKey(s string) (*Key, error) {
37 var privBytes []byte
38
39 if strings.HasPrefix(s, "nsec1") {
40 hrp, data, err := Bech32Decode(s)
41 if err != nil {
42 return nil, fmt.Errorf("invalid nsec: %w", err)
43 }
44 if hrp != "nsec" {
45 return nil, fmt.Errorf("invalid prefix: expected nsec, got %s", hrp)
46 }
47 if len(data) != 32 {
48 return nil, fmt.Errorf("invalid nsec data length: %d", len(data))
49 }
50 privBytes = data
51 } else {
52 var err error
53 privBytes, err = hex.DecodeString(s)
54 if err != nil {
55 return nil, fmt.Errorf("invalid hex: %w", err)
56 }
57 }
58
59 if len(privBytes) != 32 {
60 return nil, fmt.Errorf("private key must be 32 bytes, got %d", len(privBytes))
61 }
62
63 priv, _ := btcec.PrivKeyFromBytes(privBytes)
64 return &Key{
65 priv: priv,
66 pub: priv.PubKey(),
67 }, nil
68}
69
70// ParsePublicKey parses a public key from hex or npub (bech32) format.
71// The returned Key can only verify, not sign.
72func ParsePublicKey(s string) (*Key, error) {
73 var pubBytes []byte
74
75 if strings.HasPrefix(s, "npub1") {
76 hrp, data, err := Bech32Decode(s)
77 if err != nil {
78 return nil, fmt.Errorf("invalid npub: %w", err)
79 }
80 if hrp != "npub" {
81 return nil, fmt.Errorf("invalid prefix: expected npub, got %s", hrp)
82 }
83 if len(data) != 32 {
84 return nil, fmt.Errorf("invalid npub data length: %d", len(data))
85 }
86 pubBytes = data
87 } else {
88 var err error
89 pubBytes, err = hex.DecodeString(s)
90 if err != nil {
91 return nil, fmt.Errorf("invalid hex: %w", err)
92 }
93 }
94
95 if len(pubBytes) != 32 {
96 return nil, fmt.Errorf("public key must be 32 bytes, got %d", len(pubBytes))
97 }
98
99 pub, err := schnorr.ParsePubKey(pubBytes)
100 if err != nil {
101 return nil, fmt.Errorf("invalid public key: %w", err)
102 }
103
104 return &Key{
105 priv: nil,
106 pub: pub,
107 }, nil
108}
109
110// CanSign returns true if this key can sign events (has private key).
111func (k *Key) CanSign() bool {
112 return k.priv != nil
113}
114
115// Public returns the public key as a 64-character hex string.
116func (k *Key) Public() string {
117 return hex.EncodeToString(schnorr.SerializePubKey(k.pub))
118}
119
120// Private returns the private key as a 64-character hex string.
121// Returns empty string if this is a public-only key.
122func (k *Key) Private() string {
123 if k.priv == nil {
124 return ""
125 }
126 return hex.EncodeToString(k.priv.Serialize())
127}
128
129// Npub returns the public key in bech32 npub format.
130func (k *Key) Npub() string {
131 pubBytes := schnorr.SerializePubKey(k.pub)
132 npub, _ := Bech32Encode("npub", pubBytes)
133 return npub
134}
135
136// Nsec returns the private key in bech32 nsec format.
137// Returns empty string if this is a public-only key.
138func (k *Key) Nsec() string {
139 if k.priv == nil {
140 return ""
141 }
142 nsec, _ := Bech32Encode("nsec", k.priv.Serialize())
143 return nsec
144}
145
146// Sign signs the event with this key.
147// Sets the PubKey, ID, and Sig fields on the event.
148// Returns an error if this is a public-only key.
149func (k *Key) Sign(event *Event) error {
150 if k.priv == nil {
151 return fmt.Errorf("cannot sign: public-only key")
152 }
153
154 // Set public key
155 event.PubKey = k.Public()
156
157 if event.CreatedAt == 0 {
158 event.CreatedAt = time.Now().Unix()
159 }
160
161 // Compute ID
162 event.SetID()
163
164 // Hash the ID for signing
165 idBytes, err := hex.DecodeString(event.ID)
166 if err != nil {
167 return fmt.Errorf("failed to decode event ID: %w", err)
168 }
169
170 // Sign with Schnorr
171 sig, err := schnorr.Sign(k.priv, idBytes)
172 if err != nil {
173 return fmt.Errorf("failed to sign event: %w", err)
174 }
175
176 event.Sig = hex.EncodeToString(sig.Serialize())
177 return nil
178}
179
180// Verify verifies the event signature.
181// Returns true if the signature is valid, false otherwise.
182func (e *Event) Verify() bool {
183 // Verify ID first
184 if !e.CheckID() {
185 return false
186 }
187
188 // Decode public key
189 pubKeyBytes, err := hex.DecodeString(e.PubKey)
190 if err != nil || len(pubKeyBytes) != 32 {
191 return false
192 }
193
194 pubKey, err := schnorr.ParsePubKey(pubKeyBytes)
195 if err != nil {
196 return false
197 }
198
199 // Decode signature
200 sigBytes, err := hex.DecodeString(e.Sig)
201 if err != nil {
202 return false
203 }
204
205 sig, err := schnorr.ParseSignature(sigBytes)
206 if err != nil {
207 return false
208 }
209
210 // Decode ID (message hash)
211 idBytes, err := hex.DecodeString(e.ID)
212 if err != nil {
213 return false
214 }
215
216 return sig.Verify(idBytes, pubKey)
217}
diff --git a/keys_test.go b/keys_test.go
new file mode 100644
index 0000000..6c3dd3d
--- /dev/null
+++ b/keys_test.go
@@ -0,0 +1,333 @@
1package nostr
2
3import (
4 "encoding/hex"
5 "strings"
6 "testing"
7)
8
9func TestGenerateKey(t *testing.T) {
10 key1, err := GenerateKey()
11 if err != nil {
12 t.Fatalf("GenerateKey() error = %v", err)
13 }
14
15 if !key1.CanSign() {
16 t.Error("Generated key should be able to sign")
17 }
18
19 // Private key should be 64 hex characters
20 if len(key1.Private()) != 64 {
21 t.Errorf("Private() length = %d, want 64", len(key1.Private()))
22 }
23
24 // Public key should be 64 hex characters
25 if len(key1.Public()) != 64 {
26 t.Errorf("Public() length = %d, want 64", len(key1.Public()))
27 }
28
29 // Should be valid hex
30 if _, err := hex.DecodeString(key1.Private()); err != nil {
31 t.Errorf("Private() is not valid hex: %v", err)
32 }
33 if _, err := hex.DecodeString(key1.Public()); err != nil {
34 t.Errorf("Public() is not valid hex: %v", err)
35 }
36
37 // Keys should be unique
38 key2, err := GenerateKey()
39 if err != nil {
40 t.Fatalf("GenerateKey() second call error = %v", err)
41 }
42 if key1.Private() == key2.Private() {
43 t.Error("GenerateKey() returned same private key twice")
44 }
45}
46
47func TestKeyNpubNsec(t *testing.T) {
48 key, err := GenerateKey()
49 if err != nil {
50 t.Fatalf("GenerateKey() error = %v", err)
51 }
52
53 npub := key.Npub()
54 nsec := key.Nsec()
55
56 // Check prefixes
57 if !strings.HasPrefix(npub, "npub1") {
58 t.Errorf("Npub() = %s, want prefix 'npub1'", npub)
59 }
60 if !strings.HasPrefix(nsec, "nsec1") {
61 t.Errorf("Nsec() = %s, want prefix 'nsec1'", nsec)
62 }
63
64 // Should be able to parse them back
65 keyFromNsec, err := ParseKey(nsec)
66 if err != nil {
67 t.Fatalf("ParseKey(nsec) error = %v", err)
68 }
69 if keyFromNsec.Private() != key.Private() {
70 t.Error("ParseKey(nsec) did not restore original private key")
71 }
72
73 keyFromNpub, err := ParsePublicKey(npub)
74 if err != nil {
75 t.Fatalf("ParsePublicKey(npub) error = %v", err)
76 }
77 if keyFromNpub.Public() != key.Public() {
78 t.Error("ParsePublicKey(npub) did not restore original public key")
79 }
80}
81
82func TestParseKey(t *testing.T) {
83 // Known test vector
84 hexKey := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
85
86 key, err := ParseKey(hexKey)
87 if err != nil {
88 t.Fatalf("ParseKey(hex) error = %v", err)
89 }
90
91 if !key.CanSign() {
92 t.Error("ParseKey should return key that can sign")
93 }
94
95 if key.Private() != hexKey {
96 t.Errorf("Private() = %s, want %s", key.Private(), hexKey)
97 }
98
99 // Parse the nsec back
100 nsec := key.Nsec()
101 key2, err := ParseKey(nsec)
102 if err != nil {
103 t.Fatalf("ParseKey(nsec) error = %v", err)
104 }
105 if key2.Private() != hexKey {
106 t.Error("Round-trip through nsec failed")
107 }
108}
109
110func TestParseKeyErrors(t *testing.T) {
111 tests := []struct {
112 name string
113 key string
114 }{
115 {"invalid hex", "not-hex"},
116 {"too short", "0123456789abcdef"},
117 {"too long", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef00"},
118 {"invalid nsec", "nsec1invalid"},
119 }
120
121 for _, tt := range tests {
122 t.Run(tt.name, func(t *testing.T) {
123 _, err := ParseKey(tt.key)
124 if err == nil {
125 t.Error("ParseKey() expected error, got nil")
126 }
127 })
128 }
129}
130
131func TestParsePublicKey(t *testing.T) {
132 // Generate a key and extract public
133 fullKey, _ := GenerateKey()
134 pubHex := fullKey.Public()
135
136 // Parse public key from hex
137 key, err := ParsePublicKey(pubHex)
138 if err != nil {
139 t.Fatalf("ParsePublicKey(hex) error = %v", err)
140 }
141
142 if key.CanSign() {
143 t.Error("ParsePublicKey should return key that cannot sign")
144 }
145
146 if key.Public() != pubHex {
147 t.Errorf("Public() = %s, want %s", key.Public(), pubHex)
148 }
149
150 if key.Private() != "" {
151 t.Error("Private() should return empty string for public-only key")
152 }
153
154 if key.Nsec() != "" {
155 t.Error("Nsec() should return empty string for public-only key")
156 }
157
158 // Parse from npub
159 npub := fullKey.Npub()
160 key2, err := ParsePublicKey(npub)
161 if err != nil {
162 t.Fatalf("ParsePublicKey(npub) error = %v", err)
163 }
164 if key2.Public() != pubHex {
165 t.Error("ParsePublicKey(npub) did not restore correct public key")
166 }
167}
168
169func TestParsePublicKeyErrors(t *testing.T) {
170 tests := []struct {
171 name string
172 key string
173 }{
174 {"invalid hex", "not-hex"},
175 {"too short", "0123456789abcdef"},
176 {"invalid npub", "npub1invalid"},
177 }
178
179 for _, tt := range tests {
180 t.Run(tt.name, func(t *testing.T) {
181 _, err := ParsePublicKey(tt.key)
182 if err == nil {
183 t.Error("ParsePublicKey() expected error, got nil")
184 }
185 })
186 }
187}
188
189func TestKeySign(t *testing.T) {
190 key, err := GenerateKey()
191 if err != nil {
192 t.Fatalf("GenerateKey() error = %v", err)
193 }
194
195 event := &Event{
196 CreatedAt: 1704067200,
197 Kind: 1,
198 Tags: Tags{},
199 Content: "Test message",
200 }
201
202 if err := key.Sign(event); err != nil {
203 t.Fatalf("Sign() error = %v", err)
204 }
205
206 // Check that all fields are set
207 if event.PubKey == "" {
208 t.Error("Sign() did not set PubKey")
209 }
210 if event.ID == "" {
211 t.Error("Sign() did not set ID")
212 }
213 if event.Sig == "" {
214 t.Error("Sign() did not set Sig")
215 }
216
217 // PubKey should match
218 if event.PubKey != key.Public() {
219 t.Errorf("PubKey = %s, want %s", event.PubKey, key.Public())
220 }
221
222 // Signature should be 128 hex characters (64 bytes)
223 if len(event.Sig) != 128 {
224 t.Errorf("Signature length = %d, want 128", len(event.Sig))
225 }
226}
227
228func TestKeySignPublicOnlyError(t *testing.T) {
229 fullKey, _ := GenerateKey()
230 pubOnlyKey, _ := ParsePublicKey(fullKey.Public())
231
232 event := &Event{
233 CreatedAt: 1704067200,
234 Kind: 1,
235 Tags: Tags{},
236 Content: "Test",
237 }
238
239 err := pubOnlyKey.Sign(event)
240 if err == nil {
241 t.Error("Sign() with public-only key should return error")
242 }
243}
244
245func TestEventVerify(t *testing.T) {
246 key, err := GenerateKey()
247 if err != nil {
248 t.Fatalf("GenerateKey() error = %v", err)
249 }
250
251 event := &Event{
252 CreatedAt: 1704067200,
253 Kind: 1,
254 Tags: Tags{{"test", "value"}},
255 Content: "Test message for verification",
256 }
257
258 if err := key.Sign(event); err != nil {
259 t.Fatalf("Sign() error = %v", err)
260 }
261
262 if !event.Verify() {
263 t.Error("Verify() returned false for valid signature")
264 }
265}
266
267func TestEventVerifyInvalid(t *testing.T) {
268 key, err := GenerateKey()
269 if err != nil {
270 t.Fatalf("GenerateKey() error = %v", err)
271 }
272
273 event := &Event{
274 CreatedAt: 1704067200,
275 Kind: 1,
276 Tags: Tags{},
277 Content: "Test message",
278 }
279
280 if err := key.Sign(event); err != nil {
281 t.Fatalf("Sign() error = %v", err)
282 }
283
284 // Corrupt the content (ID becomes invalid)
285 event.Content = "Modified content"
286 if event.Verify() {
287 t.Error("Verify() returned true for modified content")
288 }
289
290 // Restore content but corrupt signature
291 event.Content = "Test message"
292 event.SetID()
293 event.Sig = "0000000000000000000000000000000000000000000000000000000000000000" +
294 "0000000000000000000000000000000000000000000000000000000000000000"
295 if event.Verify() {
296 t.Error("Verify() returned true for invalid signature")
297 }
298}
299
300func TestSignAndVerifyRoundTrip(t *testing.T) {
301 // Generate key
302 key, err := GenerateKey()
303 if err != nil {
304 t.Fatalf("GenerateKey() error = %v", err)
305 }
306
307 // Create and sign event
308 event := &Event{
309 CreatedAt: 1704067200,
310 Kind: KindTextNote,
311 Tags: Tags{{"t", "test"}},
312 Content: "Integration test message",
313 }
314
315 if err := key.Sign(event); err != nil {
316 t.Fatalf("Sign() error = %v", err)
317 }
318
319 // Verify public key matches
320 if event.PubKey != key.Public() {
321 t.Errorf("Signed event PubKey = %s, want %s", event.PubKey, key.Public())
322 }
323
324 // Verify the signature
325 if !event.Verify() {
326 t.Error("Verify() failed for freshly signed event")
327 }
328
329 // Check ID is correct
330 if !event.CheckID() {
331 t.Error("CheckID() failed for freshly signed event")
332 }
333}
diff --git a/kinds.go b/kinds.go
new file mode 100644
index 0000000..cb76e88
--- /dev/null
+++ b/kinds.go
@@ -0,0 +1,51 @@
1package nostr
2
3// Event kind constants as defined in NIP-01 and related NIPs.
4const (
5 KindMetadata = 0
6 KindTextNote = 1
7 KindContactList = 3
8 KindEncryptedDM = 4
9 KindDeletion = 5
10 KindRepost = 6
11 KindReaction = 7
12)
13
14// IsRegular returns true if the kind is a regular event (stored, not replaced).
15// Regular events: 1000 <= kind < 10000 or kind in {0,1,2,...} except replaceable ones.
16func IsRegular(kind int) bool {
17 if kind == KindMetadata || kind == KindContactList {
18 return false
19 }
20 if kind >= 10000 && kind < 20000 {
21 return false // replaceable
22 }
23 if kind >= 20000 && kind < 30000 {
24 return false // ephemeral
25 }
26 if kind >= 30000 && kind < 40000 {
27 return false // addressable
28 }
29 return true
30}
31
32// IsReplaceable returns true if the kind is replaceable (NIP-01).
33// Replaceable events: 10000 <= kind < 20000, or kind 0 (metadata) or kind 3 (contact list).
34func IsReplaceable(kind int) bool {
35 if kind == KindMetadata || kind == KindContactList {
36 return true
37 }
38 return kind >= 10000 && kind < 20000
39}
40
41// IsEphemeral returns true if the kind is ephemeral (not stored).
42// Ephemeral events: 20000 <= kind < 30000.
43func IsEphemeral(kind int) bool {
44 return kind >= 20000 && kind < 30000
45}
46
47// IsAddressable returns true if the kind is addressable (parameterized replaceable).
48// Addressable events: 30000 <= kind < 40000.
49func IsAddressable(kind int) bool {
50 return kind >= 30000 && kind < 40000
51}
diff --git a/kinds_test.go b/kinds_test.go
new file mode 100644
index 0000000..2bf013d
--- /dev/null
+++ b/kinds_test.go
@@ -0,0 +1,128 @@
1package nostr
2
3import (
4 "testing"
5)
6
7func TestKindConstants(t *testing.T) {
8 // Verify constants match NIP-01 spec
9 tests := []struct {
10 name string
11 kind int
12 value int
13 }{
14 {"Metadata", KindMetadata, 0},
15 {"TextNote", KindTextNote, 1},
16 {"ContactList", KindContactList, 3},
17 {"EncryptedDM", KindEncryptedDM, 4},
18 {"Deletion", KindDeletion, 5},
19 {"Repost", KindRepost, 6},
20 {"Reaction", KindReaction, 7},
21 }
22
23 for _, tt := range tests {
24 t.Run(tt.name, func(t *testing.T) {
25 if tt.kind != tt.value {
26 t.Errorf("Kind%s = %d, want %d", tt.name, tt.kind, tt.value)
27 }
28 })
29 }
30}
31
32func TestIsRegular(t *testing.T) {
33 tests := []struct {
34 kind int
35 want bool
36 }{
37 {0, false}, // Metadata - replaceable
38 {1, true}, // TextNote - regular
39 {3, false}, // ContactList - replaceable
40 {4, true}, // EncryptedDM - regular
41 {5, true}, // Deletion - regular
42 {1000, true}, // Regular range
43 {9999, true}, // Regular range
44 {10000, false}, // Replaceable range
45 {19999, false}, // Replaceable range
46 {20000, false}, // Ephemeral range
47 {29999, false}, // Ephemeral range
48 {30000, false}, // Addressable range
49 {39999, false}, // Addressable range
50 {40000, true}, // Back to regular
51 }
52
53 for _, tt := range tests {
54 t.Run("kind_"+string(rune(tt.kind)), func(t *testing.T) {
55 if got := IsRegular(tt.kind); got != tt.want {
56 t.Errorf("IsRegular(%d) = %v, want %v", tt.kind, got, tt.want)
57 }
58 })
59 }
60}
61
62func TestIsReplaceable(t *testing.T) {
63 tests := []struct {
64 kind int
65 want bool
66 }{
67 {0, true}, // Metadata
68 {1, false}, // TextNote
69 {3, true}, // ContactList
70 {10000, true}, // Replaceable range start
71 {15000, true}, // Replaceable range middle
72 {19999, true}, // Replaceable range end
73 {20000, false}, // Ephemeral range
74 {30000, false}, // Addressable range
75 }
76
77 for _, tt := range tests {
78 t.Run("kind_"+string(rune(tt.kind)), func(t *testing.T) {
79 if got := IsReplaceable(tt.kind); got != tt.want {
80 t.Errorf("IsReplaceable(%d) = %v, want %v", tt.kind, got, tt.want)
81 }
82 })
83 }
84}
85
86func TestIsEphemeral(t *testing.T) {
87 tests := []struct {
88 kind int
89 want bool
90 }{
91 {1, false}, // TextNote
92 {19999, false}, // Replaceable range
93 {20000, true}, // Ephemeral range start
94 {25000, true}, // Ephemeral range middle
95 {29999, true}, // Ephemeral range end
96 {30000, false}, // Addressable range
97 }
98
99 for _, tt := range tests {
100 t.Run("kind_"+string(rune(tt.kind)), func(t *testing.T) {
101 if got := IsEphemeral(tt.kind); got != tt.want {
102 t.Errorf("IsEphemeral(%d) = %v, want %v", tt.kind, got, tt.want)
103 }
104 })
105 }
106}
107
108func TestIsAddressable(t *testing.T) {
109 tests := []struct {
110 kind int
111 want bool
112 }{
113 {1, false}, // TextNote
114 {29999, false}, // Ephemeral range
115 {30000, true}, // Addressable range start
116 {35000, true}, // Addressable range middle
117 {39999, true}, // Addressable range end
118 {40000, false}, // Beyond addressable range
119 }
120
121 for _, tt := range tests {
122 t.Run("kind_"+string(rune(tt.kind)), func(t *testing.T) {
123 if got := IsAddressable(tt.kind); got != tt.want {
124 t.Errorf("IsAddressable(%d) = %v, want %v", tt.kind, got, tt.want)
125 }
126 })
127 }
128}
diff --git a/relay.go b/relay.go
new file mode 100644
index 0000000..45f6119
--- /dev/null
+++ b/relay.go
@@ -0,0 +1,217 @@
1package nostr
2
3import (
4 "context"
5 "fmt"
6 "sync"
7
8 "github.com/coder/websocket"
9)
10
11// Relay represents a connection to a Nostr relay.
12type Relay struct {
13 URL string
14 conn *websocket.Conn
15 mu sync.Mutex
16
17 subscriptions map[string]*Subscription
18 subscriptionsMu sync.RWMutex
19}
20
21// Connect establishes a WebSocket connection to the relay.
22func Connect(ctx context.Context, url string) (*Relay, error) {
23 conn, _, err := websocket.Dial(ctx, url, nil)
24 if err != nil {
25 return nil, fmt.Errorf("failed to connect to relay: %w", err)
26 }
27
28 return &Relay{
29 URL: url,
30 conn: conn,
31 subscriptions: make(map[string]*Subscription),
32 }, nil
33}
34
35// Close closes the WebSocket connection.
36func (r *Relay) Close() error {
37 r.mu.Lock()
38 defer r.mu.Unlock()
39
40 if r.conn == nil {
41 return nil
42 }
43
44 err := r.conn.Close(websocket.StatusNormalClosure, "")
45 r.conn = nil
46 return err
47}
48
49// Send sends an envelope to the relay.
50func (r *Relay) Send(ctx context.Context, env Envelope) error {
51 data, err := env.MarshalJSON()
52 if err != nil {
53 return fmt.Errorf("failed to marshal envelope: %w", err)
54 }
55
56 r.mu.Lock()
57 defer r.mu.Unlock()
58
59 if r.conn == nil {
60 return fmt.Errorf("connection closed")
61 }
62
63 return r.conn.Write(ctx, websocket.MessageText, data)
64}
65
66// Receive reads the next envelope from the relay.
67func (r *Relay) Receive(ctx context.Context) (Envelope, error) {
68 r.mu.Lock()
69 conn := r.conn
70 r.mu.Unlock()
71
72 if conn == nil {
73 return nil, fmt.Errorf("connection closed")
74 }
75
76 _, data, err := conn.Read(ctx)
77 if err != nil {
78 return nil, fmt.Errorf("failed to read message: %w", err)
79 }
80
81 return ParseEnvelope(data)
82}
83
84// Publish sends an event to the relay and waits for the OK response.
85func (r *Relay) Publish(ctx context.Context, event *Event) error {
86 env := EventEnvelope{Event: event}
87 if err := r.Send(ctx, env); err != nil {
88 return fmt.Errorf("failed to send event: %w", err)
89 }
90
91 // Wait for OK response
92 for {
93 resp, err := r.Receive(ctx)
94 if err != nil {
95 return fmt.Errorf("failed to receive response: %w", err)
96 }
97
98 if ok, isOK := resp.(*OKEnvelope); isOK {
99 if ok.EventID == event.ID {
100 if !ok.OK {
101 return fmt.Errorf("event rejected: %s", ok.Message)
102 }
103 return nil
104 }
105 }
106
107 // Dispatch other messages to subscriptions
108 r.dispatchEnvelope(resp)
109 }
110}
111
112// Subscribe creates a subscription with the given filters.
113func (r *Relay) Subscribe(ctx context.Context, id string, filters ...Filter) (*Subscription, error) {
114 sub := &Subscription{
115 ID: id,
116 relay: r,
117 Filters: filters,
118 Events: make(chan *Event, 100),
119 EOSE: make(chan struct{}, 1),
120 closed: make(chan struct{}),
121 }
122
123 r.subscriptionsMu.Lock()
124 r.subscriptions[id] = sub
125 r.subscriptionsMu.Unlock()
126
127 env := ReqEnvelope{
128 SubscriptionID: id,
129 Filters: filters,
130 }
131 if err := r.Send(ctx, env); err != nil {
132 r.subscriptionsMu.Lock()
133 delete(r.subscriptions, id)
134 r.subscriptionsMu.Unlock()
135 return nil, fmt.Errorf("failed to send subscription request: %w", err)
136 }
137
138 return sub, nil
139}
140
141// dispatchEnvelope routes incoming messages to the appropriate subscription.
142func (r *Relay) dispatchEnvelope(env Envelope) {
143 switch e := env.(type) {
144 case *EventEnvelope:
145 r.subscriptionsMu.RLock()
146 sub, ok := r.subscriptions[e.SubscriptionID]
147 r.subscriptionsMu.RUnlock()
148 if ok {
149 select {
150 case sub.Events <- e.Event:
151 default:
152 // Channel full, drop event
153 }
154 }
155 case *EOSEEnvelope:
156 r.subscriptionsMu.RLock()
157 sub, ok := r.subscriptions[e.SubscriptionID]
158 r.subscriptionsMu.RUnlock()
159 if ok {
160 select {
161 case sub.EOSE <- struct{}{}:
162 default:
163 }
164 }
165 case *ClosedEnvelope:
166 r.subscriptionsMu.Lock()
167 if sub, ok := r.subscriptions[e.SubscriptionID]; ok {
168 close(sub.closed)
169 delete(r.subscriptions, e.SubscriptionID)
170 }
171 r.subscriptionsMu.Unlock()
172 }
173}
174
175// Listen reads messages from the relay and dispatches them to subscriptions.
176// This should be called in a goroutine when using multiple subscriptions.
177func (r *Relay) Listen(ctx context.Context) error {
178 for {
179 select {
180 case <-ctx.Done():
181 return ctx.Err()
182 default:
183 }
184
185 env, err := r.Receive(ctx)
186 if err != nil {
187 return err
188 }
189
190 r.dispatchEnvelope(env)
191 }
192}
193
194// Subscription represents an active subscription to a relay.
195type Subscription struct {
196 ID string
197 relay *Relay
198 Filters []Filter
199 Events chan *Event
200 EOSE chan struct{}
201 closed chan struct{}
202}
203
204// Close unsubscribes from the relay.
205func (s *Subscription) Close(ctx context.Context) error {
206 s.relay.subscriptionsMu.Lock()
207 delete(s.relay.subscriptions, s.ID)
208 s.relay.subscriptionsMu.Unlock()
209
210 env := CloseEnvelope{SubscriptionID: s.ID}
211 return s.relay.Send(ctx, env)
212}
213
214// Closed returns a channel that's closed when the subscription is terminated.
215func (s *Subscription) Closed() <-chan struct{} {
216 return s.closed
217}
diff --git a/relay_test.go b/relay_test.go
new file mode 100644
index 0000000..4ace956
--- /dev/null
+++ b/relay_test.go
@@ -0,0 +1,333 @@
1package nostr
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/coder/websocket"
13)
14
15// mockRelay creates a test WebSocket server that echoes messages
16func mockRelay(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server {
17 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18 conn, err := websocket.Accept(w, r, nil)
19 if err != nil {
20 t.Logf("Failed to accept WebSocket: %v", err)
21 return
22 }
23 defer conn.Close(websocket.StatusNormalClosure, "")
24
25 handler(conn)
26 }))
27}
28
29func TestConnect(t *testing.T) {
30 server := mockRelay(t, func(conn *websocket.Conn) {
31 // Just accept and wait
32 time.Sleep(100 * time.Millisecond)
33 })
34 defer server.Close()
35
36 url := "ws" + strings.TrimPrefix(server.URL, "http")
37 ctx := context.Background()
38
39 relay, err := Connect(ctx, url)
40 if err != nil {
41 t.Fatalf("Connect() error = %v", err)
42 }
43 defer relay.Close()
44
45 if relay.URL != url {
46 t.Errorf("Relay.URL = %s, want %s", relay.URL, url)
47 }
48}
49
50func TestConnectError(t *testing.T) {
51 ctx := context.Background()
52 _, err := Connect(ctx, "ws://localhost:99999")
53 if err == nil {
54 t.Error("Connect() expected error for invalid URL")
55 }
56}
57
58func TestRelaySendReceive(t *testing.T) {
59 server := mockRelay(t, func(conn *websocket.Conn) {
60 // Read message
61 _, data, err := conn.Read(context.Background())
62 if err != nil {
63 t.Logf("Read error: %v", err)
64 return
65 }
66
67 // Echo it back as NOTICE
68 var arr []interface{}
69 json.Unmarshal(data, &arr)
70
71 response, _ := json.Marshal([]interface{}{"NOTICE", "received: " + arr[0].(string)})
72 conn.Write(context.Background(), websocket.MessageText, response)
73 })
74 defer server.Close()
75
76 url := "ws" + strings.TrimPrefix(server.URL, "http")
77 ctx := context.Background()
78
79 relay, err := Connect(ctx, url)
80 if err != nil {
81 t.Fatalf("Connect() error = %v", err)
82 }
83 defer relay.Close()
84
85 // Send a CLOSE envelope
86 closeEnv := CloseEnvelope{SubscriptionID: "test"}
87 if err := relay.Send(ctx, closeEnv); err != nil {
88 t.Fatalf("Send() error = %v", err)
89 }
90
91 // Receive response
92 env, err := relay.Receive(ctx)
93 if err != nil {
94 t.Fatalf("Receive() error = %v", err)
95 }
96
97 noticeEnv, ok := env.(*NoticeEnvelope)
98 if !ok {
99 t.Fatalf("Expected *NoticeEnvelope, got %T", env)
100 }
101
102 if !strings.Contains(noticeEnv.Message, "CLOSE") {
103 t.Errorf("Message = %s, want to contain 'CLOSE'", noticeEnv.Message)
104 }
105}
106
107func TestRelayPublish(t *testing.T) {
108 server := mockRelay(t, func(conn *websocket.Conn) {
109 // Read the EVENT message
110 _, data, err := conn.Read(context.Background())
111 if err != nil {
112 t.Logf("Read error: %v", err)
113 return
114 }
115
116 // Parse to get event ID
117 var arr []json.RawMessage
118 json.Unmarshal(data, &arr)
119
120 var event Event
121 json.Unmarshal(arr[1], &event)
122
123 // Send OK response
124 response, _ := json.Marshal([]interface{}{"OK", event.ID, true, ""})
125 conn.Write(context.Background(), websocket.MessageText, response)
126 })
127 defer server.Close()
128
129 url := "ws" + strings.TrimPrefix(server.URL, "http")
130 ctx := context.Background()
131
132 relay, err := Connect(ctx, url)
133 if err != nil {
134 t.Fatalf("Connect() error = %v", err)
135 }
136 defer relay.Close()
137
138 // Create and sign event
139 key, _ := GenerateKey()
140 event := &Event{
141 CreatedAt: time.Now().Unix(),
142 Kind: KindTextNote,
143 Tags: Tags{},
144 Content: "Test publish",
145 }
146 key.Sign(event)
147
148 // Publish
149 if err := relay.Publish(ctx, event); err != nil {
150 t.Fatalf("Publish() error = %v", err)
151 }
152}
153
154func TestRelayPublishRejected(t *testing.T) {
155 server := mockRelay(t, func(conn *websocket.Conn) {
156 // Read the EVENT message
157 _, data, err := conn.Read(context.Background())
158 if err != nil {
159 return
160 }
161
162 var arr []json.RawMessage
163 json.Unmarshal(data, &arr)
164
165 var event Event
166 json.Unmarshal(arr[1], &event)
167
168 // Send rejection
169 response, _ := json.Marshal([]interface{}{"OK", event.ID, false, "blocked: spam"})
170 conn.Write(context.Background(), websocket.MessageText, response)
171 })
172 defer server.Close()
173
174 url := "ws" + strings.TrimPrefix(server.URL, "http")
175 ctx := context.Background()
176
177 relay, err := Connect(ctx, url)
178 if err != nil {
179 t.Fatalf("Connect() error = %v", err)
180 }
181 defer relay.Close()
182
183 key, _ := GenerateKey()
184 event := &Event{
185 CreatedAt: time.Now().Unix(),
186 Kind: KindTextNote,
187 Tags: Tags{},
188 Content: "Test",
189 }
190 key.Sign(event)
191
192 err = relay.Publish(ctx, event)
193 if err == nil {
194 t.Error("Publish() expected error for rejected event")
195 }
196 if !strings.Contains(err.Error(), "rejected") {
197 t.Errorf("Error = %v, want to contain 'rejected'", err)
198 }
199}
200
201func TestRelaySubscribe(t *testing.T) {
202 server := mockRelay(t, func(conn *websocket.Conn) {
203 // Read REQ
204 _, data, err := conn.Read(context.Background())
205 if err != nil {
206 return
207 }
208
209 var arr []json.RawMessage
210 json.Unmarshal(data, &arr)
211
212 var subID string
213 json.Unmarshal(arr[1], &subID)
214
215 // Send some events
216 for i := 0; i < 3; i++ {
217 event := Event{
218 ID: "event" + string(rune('0'+i)),
219 PubKey: "pubkey",
220 CreatedAt: time.Now().Unix(),
221 Kind: 1,
222 Tags: Tags{},
223 Content: "Test event",
224 Sig: "sig",
225 }
226 response, _ := json.Marshal([]interface{}{"EVENT", subID, event})
227 conn.Write(context.Background(), websocket.MessageText, response)
228 }
229
230 // Send EOSE
231 eose, _ := json.Marshal([]interface{}{"EOSE", subID})
232 conn.Write(context.Background(), websocket.MessageText, eose)
233 })
234 defer server.Close()
235
236 url := "ws" + strings.TrimPrefix(server.URL, "http")
237 ctx := context.Background()
238
239 relay, err := Connect(ctx, url)
240 if err != nil {
241 t.Fatalf("Connect() error = %v", err)
242 }
243 defer relay.Close()
244
245 sub, err := relay.Subscribe(ctx, "sub1", Filter{Kinds: []int{1}})
246 if err != nil {
247 t.Fatalf("Subscribe() error = %v", err)
248 }
249
250 // Start listening in background
251 go relay.Listen(ctx)
252
253 // Collect events
254 eventCount := 0
255 timeout := time.After(2 * time.Second)
256
257 for {
258 select {
259 case <-sub.Events:
260 eventCount++
261 case <-sub.EOSE:
262 if eventCount != 3 {
263 t.Errorf("Received %d events, want 3", eventCount)
264 }
265 return
266 case <-timeout:
267 t.Fatal("Timeout waiting for events")
268 }
269 }
270}
271
272func TestRelayClose(t *testing.T) {
273 server := mockRelay(t, func(conn *websocket.Conn) {
274 time.Sleep(100 * time.Millisecond)
275 })
276 defer server.Close()
277
278 url := "ws" + strings.TrimPrefix(server.URL, "http")
279 ctx := context.Background()
280
281 relay, err := Connect(ctx, url)
282 if err != nil {
283 t.Fatalf("Connect() error = %v", err)
284 }
285
286 if err := relay.Close(); err != nil {
287 t.Errorf("Close() error = %v", err)
288 }
289
290 // Second close should be safe
291 if err := relay.Close(); err != nil {
292 t.Errorf("Second Close() error = %v", err)
293 }
294}
295
296func TestSubscriptionClose(t *testing.T) {
297 server := mockRelay(t, func(conn *websocket.Conn) {
298 // Read REQ
299 conn.Read(context.Background())
300
301 // Wait for CLOSE
302 _, data, err := conn.Read(context.Background())
303 if err != nil {
304 return
305 }
306
307 var arr []interface{}
308 json.Unmarshal(data, &arr)
309
310 if arr[0] != "CLOSE" {
311 t.Errorf("Expected CLOSE, got %v", arr[0])
312 }
313 })
314 defer server.Close()
315
316 url := "ws" + strings.TrimPrefix(server.URL, "http")
317 ctx := context.Background()
318
319 relay, err := Connect(ctx, url)
320 if err != nil {
321 t.Fatalf("Connect() error = %v", err)
322 }
323 defer relay.Close()
324
325 sub, err := relay.Subscribe(ctx, "sub1", Filter{Kinds: []int{1}})
326 if err != nil {
327 t.Fatalf("Subscribe() error = %v", err)
328 }
329
330 if err := sub.Close(ctx); err != nil {
331 t.Errorf("Subscription.Close() error = %v", err)
332 }
333}
diff --git a/tags.go b/tags.go
new file mode 100644
index 0000000..4fe3d04
--- /dev/null
+++ b/tags.go
@@ -0,0 +1,64 @@
1package nostr
2
3// Tag represents a single Nostr tag, which is an array of strings.
4// The first element is the tag key, followed by its values.
5type Tag []string
6
7// Key returns the tag key (first element), or empty string if tag is empty.
8func (t Tag) Key() string {
9 if len(t) == 0 {
10 return ""
11 }
12 return t[0]
13}
14
15// Value returns the first value (second element), or empty string if not present.
16func (t Tag) Value() string {
17 if len(t) < 2 {
18 return ""
19 }
20 return t[1]
21}
22
23// Tags represents a collection of tags.
24type Tags []Tag
25
26// Find returns the first tag matching the given key, or nil if not found.
27func (tags Tags) Find(key string) Tag {
28 for _, tag := range tags {
29 if tag.Key() == key {
30 return tag
31 }
32 }
33 return nil
34}
35
36// FindAll returns all tags matching the given key.
37func (tags Tags) FindAll(key string) Tags {
38 var result Tags
39 for _, tag := range tags {
40 if tag.Key() == key {
41 result = append(result, tag)
42 }
43 }
44 return result
45}
46
47// GetD returns the value of the "d" tag, used for addressable events.
48func (tags Tags) GetD() string {
49 tag := tags.Find("d")
50 if tag == nil {
51 return ""
52 }
53 return tag.Value()
54}
55
56// ContainsValue checks if any tag with the given key contains the specified value.
57func (tags Tags) ContainsValue(key, value string) bool {
58 for _, tag := range tags {
59 if tag.Key() == key && tag.Value() == value {
60 return true
61 }
62 }
63 return false
64}
diff --git a/tags_test.go b/tags_test.go
new file mode 100644
index 0000000..7796606
--- /dev/null
+++ b/tags_test.go
@@ -0,0 +1,158 @@
1package nostr
2
3import (
4 "testing"
5)
6
7func TestTagKey(t *testing.T) {
8 tests := []struct {
9 name string
10 tag Tag
11 want string
12 }{
13 {"empty tag", Tag{}, ""},
14 {"single element", Tag{"e"}, "e"},
15 {"multiple elements", Tag{"p", "abc123", "relay"}, "p"},
16 }
17
18 for _, tt := range tests {
19 t.Run(tt.name, func(t *testing.T) {
20 if got := tt.tag.Key(); got != tt.want {
21 t.Errorf("Tag.Key() = %q, want %q", got, tt.want)
22 }
23 })
24 }
25}
26
27func TestTagValue(t *testing.T) {
28 tests := []struct {
29 name string
30 tag Tag
31 want string
32 }{
33 {"empty tag", Tag{}, ""},
34 {"single element", Tag{"e"}, ""},
35 {"two elements", Tag{"p", "abc123"}, "abc123"},
36 {"multiple elements", Tag{"e", "eventid", "relay", "marker"}, "eventid"},
37 }
38
39 for _, tt := range tests {
40 t.Run(tt.name, func(t *testing.T) {
41 if got := tt.tag.Value(); got != tt.want {
42 t.Errorf("Tag.Value() = %q, want %q", got, tt.want)
43 }
44 })
45 }
46}
47
48func TestTagsFind(t *testing.T) {
49 tags := Tags{
50 {"e", "event1"},
51 {"p", "pubkey1"},
52 {"e", "event2"},
53 {"d", "identifier"},
54 }
55
56 tests := []struct {
57 name string
58 key string
59 wantNil bool
60 wantVal string
61 }{
62 {"find first e", "e", false, "event1"},
63 {"find p", "p", false, "pubkey1"},
64 {"find d", "d", false, "identifier"},
65 {"find nonexistent", "x", true, ""},
66 }
67
68 for _, tt := range tests {
69 t.Run(tt.name, func(t *testing.T) {
70 got := tags.Find(tt.key)
71 if tt.wantNil {
72 if got != nil {
73 t.Errorf("Tags.Find(%q) = %v, want nil", tt.key, got)
74 }
75 } else {
76 if got == nil {
77 t.Errorf("Tags.Find(%q) = nil, want value %q", tt.key, tt.wantVal)
78 } else if got.Value() != tt.wantVal {
79 t.Errorf("Tags.Find(%q).Value() = %q, want %q", tt.key, got.Value(), tt.wantVal)
80 }
81 }
82 })
83 }
84}
85
86func TestTagsFindAll(t *testing.T) {
87 tags := Tags{
88 {"e", "event1"},
89 {"p", "pubkey1"},
90 {"e", "event2"},
91 {"e", "event3"},
92 }
93
94 found := tags.FindAll("e")
95 if len(found) != 3 {
96 t.Errorf("Tags.FindAll(\"e\") returned %d tags, want 3", len(found))
97 }
98
99 found = tags.FindAll("p")
100 if len(found) != 1 {
101 t.Errorf("Tags.FindAll(\"p\") returned %d tags, want 1", len(found))
102 }
103
104 found = tags.FindAll("x")
105 if len(found) != 0 {
106 t.Errorf("Tags.FindAll(\"x\") returned %d tags, want 0", len(found))
107 }
108}
109
110func TestTagsGetD(t *testing.T) {
111 tests := []struct {
112 name string
113 tags Tags
114 want string
115 }{
116 {"no d tag", Tags{{"e", "event1"}}, ""},
117 {"empty d tag", Tags{{"d"}}, ""},
118 {"d tag present", Tags{{"d", "my-identifier"}}, "my-identifier"},
119 {"d tag with extras", Tags{{"d", "id", "extra"}}, "id"},
120 }
121
122 for _, tt := range tests {
123 t.Run(tt.name, func(t *testing.T) {
124 if got := tt.tags.GetD(); got != tt.want {
125 t.Errorf("Tags.GetD() = %q, want %q", got, tt.want)
126 }
127 })
128 }
129}
130
131func TestTagsContainsValue(t *testing.T) {
132 tags := Tags{
133 {"e", "event1"},
134 {"p", "pubkey1"},
135 {"e", "event2"},
136 }
137
138 tests := []struct {
139 key string
140 value string
141 want bool
142 }{
143 {"e", "event1", true},
144 {"e", "event2", true},
145 {"e", "event3", false},
146 {"p", "pubkey1", true},
147 {"p", "pubkey2", false},
148 {"x", "anything", false},
149 }
150
151 for _, tt := range tests {
152 t.Run(tt.key+"="+tt.value, func(t *testing.T) {
153 if got := tags.ContainsValue(tt.key, tt.value); got != tt.want {
154 t.Errorf("Tags.ContainsValue(%q, %q) = %v, want %v", tt.key, tt.value, got, tt.want)
155 }
156 })
157 }
158}