9 Commits

20 changed files with 1268 additions and 884 deletions
+4
View File
@@ -0,0 +1,4 @@
# go-roots
## Testing
- `go test ./...`
+99 -198
View File
@@ -1,4 +1,4 @@
# Go-Roots - Nostr Protocol Library for Golang # go-roots Nostr Protocol Library for Go
Source: https://git.wisehodl.dev/jay/go-roots Source: https://git.wisehodl.dev/jay/go-roots
@@ -6,22 +6,18 @@ Mirror: https://github.com/wisehodl/go-roots
## What this library does ## What this library does
`go-roots` is a consensus-layer Nostr protocol library for golang. `go-roots` is a consensus-layer Nostr protocol library for Go.
It only provides primitives that define protocol compliance: It provides primitives that define protocol compliance:
- Event Structure - Event structure and serialization
- Serialization - Cryptographic signing and validation
- Cryptographic Signatures - Subscription filters
- Subscription Filters
## What this library does not do ## What this library does not do
`go-roots` serves a foundation for other libraries and applications to `go-roots` serves as a foundation for libraries and applications that implement higher-level abstractions of the Nostr protocol, including message transport, semantic event definitions, event storage, and user interfaces.
implement higher level abstractions of the Nostr protocol on top of it,
including message transport, semantic event definitions, event storage
mechanisms, and user interfaces.
`go-roots` prioritizes correctness and clarity over optimization and efficiency. For high performance applications, it is recommended to implement optimizations in a separate library or in the application which requires them. `go-roots` prioritizes correctness and clarity over optimization and efficiency. High-performance applications should implement optimizations in a separate library or in the application itself.
## Installation ## Installation
@@ -31,7 +27,7 @@ mechanisms, and user interfaces.
go get git.wisehodl.dev/jay/go-roots go get git.wisehodl.dev/jay/go-roots
``` ```
If the primary repository is unavailable, use the `replace` directive in your go.mod file to get the package from the github mirror: If the primary repository is unavailable, use the `replace` directive in your go.mod file to get the package from the GitHub mirror:
``` ```
replace git.wisehodl.dev/jay/go-roots => github.com/wisehodl/go-roots latest replace git.wisehodl.dev/jay/go-roots => github.com/wisehodl/go-roots latest
@@ -39,7 +35,7 @@ replace git.wisehodl.dev/jay/go-roots => github.com/wisehodl/go-roots latest
2. Import the packages: 2. Import the packages:
```golang ```go
import ( import (
"git.wisehodl.dev/jay/go-roots/errors" "git.wisehodl.dev/jay/go-roots/errors"
"git.wisehodl.dev/jay/go-roots/events" "git.wisehodl.dev/jay/go-roots/events"
@@ -48,9 +44,9 @@ import (
) )
``` ```
3. Access functions with appropriate namespaces. ## General Use
## Usage Examples `ValidatedEvent` is the primary type consumers work with. A plain `Event` is a mutable scratch pad used during construction or deserialization. Once an event passes cryptographic validation, it is promoted to `ValidatedEvent` through a single gate: `NewValidatedEvent`. After that point, the event is immutable and its fields are accessed through methods.
### Key Management ### Key Management
@@ -68,7 +64,7 @@ if err != nil {
} }
``` ```
#### Derive public key from existing private key #### Derive public key from an existing private key
```go ```go
privateKey := "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167" privateKey := "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167"
@@ -76,132 +72,99 @@ publicKey, err := keys.GetPublicKey(privateKey)
// publicKey: "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef" // publicKey: "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"
``` ```
--- ### Flow 1: Creating an event
### Event Creation and Signing Build the event with `NewEvent`, compute the ID with `GetID`, sign it with `SignEvent`, then promote to `ValidatedEvent` with `NewValidatedEvent`.
#### Create and sign a complete event
```go ```go
// 1. Build the event structure // 1. Build the event
event := events.Event{ event := events.NewEvent(
PubKey: publicKey, events.WithPubKey(publicKey),
CreatedAt: int(time.Now().Unix()), events.WithCreatedAt(time.Now().Unix()),
Kind: 1, events.WithKind(1),
Tags: []events.Tag{ events.WithTag(events.Tag{"e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}),
{"e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}, events.WithTag(events.Tag{"p", "91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}),
{"p", "91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}, events.WithContent("Hello, Nostr!"),
}, )
Content: "Hello, Nostr!",
}
// 2. Compute the event ID // 2. Compute and assign the ID
id := events.GetID(event) event.ID = events.GetID(event)
event.ID = id
// 3. Sign the event // 3. Sign the event
sig, err := events.SignEvent(id, privateKey) sig, err := events.SignEvent(event.ID, privateKey)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
event.Sig = sig event.Sig = sig
```
#### Serialize an event for ID computation // 4. Promote to ValidatedEvent
validated, err := events.NewValidatedEvent(event)
```go
// Returns canonical JSON: [0, pubkey, created_at, kind, tags, content]
serialized := events.Serialize(event)
```
#### Compute event ID manually
```go
id := events.GetID(event)
// Returns lowercase hex SHA-256 hash of serialized form
```
---
### Event Validation
#### Validate complete event
```go
// Checks structure, ID computation, and signature
if err := events.Validate(event); err != nil {
log.Printf("Invalid event: %v", err)
}
```
#### Validate individual aspects
```go
// Check field formats and lengths
if err := events.ValidateStructure(event); err != nil {
log.Printf("Malformed structure: %v", err)
}
// Verify ID matches computed hash
if err := events.ValidateID(event); err != nil {
log.Printf("ID mismatch: %v", err)
}
// Verify cryptographic signature
if err := events.ValidateSignature(event); err != nil {
log.Printf("Invalid signature: %v", err)
}
```
---
### Event JSON
#### Marshal event to JSON
```go
jsonBytes, err := json.Marshal(event)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err) // construction bug — treat as a defect, not a runtime condition
} }
// Standard encoding/json works with Event struct tags
``` ```
#### Unmarshal event from JSON For applications that delegate signing to a remote signer (NIP-46, hardware device, custody service): build the unsigned `Event`, send it to the signer, receive the signed `Event` back, then call `NewValidatedEvent` on what you received.
### Flow 2: Receiving an event from an external source
Unmarshal into a plain `Event`, then immediately promote to `ValidatedEvent` with `NewValidatedEvent`.
```go ```go
var event events.Event var event events.Event
err := json.Unmarshal(jsonBytes, &event) if err := json.Unmarshal(data, &event); err != nil {
if err != nil { log.Printf("Malformed JSON: %v", err)
log.Fatal(err) return
} }
// Validate after unmarshaling validated, err := events.NewValidatedEvent(event)
if err := events.Validate(event); err != nil { if err != nil {
log.Printf("Received invalid event: %v", err) log.Printf("Invalid event: %v", err) // reject from peer; investigate if from database
return
} }
``` ```
--- ### Flow 3: Serializing a validated event to JSON
### Filter Creation Call `json.Marshal` on a `ValidatedEvent`. The output is guaranteed to reflect a cryptographically verified event.
#### Basic filter with standard fields ```go
jsonBytes, err := json.Marshal(validated)
if err != nil {
log.Fatal(err)
}
```
Calling `json.Marshal` on a plain `Event` is discouraged: the resulting JSON carries no integrity guarantee.
### The `NewValidatedEvent` gate
`NewValidatedEvent` is the single promotion point. It verifies:
- Valid field structure (hex lengths, tag shape, field formats)
- ID matches the SHA-256 hash of the canonical serialization
- Signature is a valid Schnorr signature over the ID for the given public key
It does not verify semantic validity. A `ValidatedEvent` is valid according to the base Nostr protocol. Whether it is valid for your application — correct kind, trusted author, within a relevant time window — is your responsibility.
### Filters
#### Build a filter
```go ```go
since := int(time.Now().Add(-24 * time.Hour).Unix()) since := int(time.Now().Add(-24 * time.Hour).Unix())
limit := 50 limit := 50
filter := filters.Filter{ filter := filters.Filter{
IDs: []string{"abc123", "def456"}, // Prefix match IDs: []string{"abc123", "def456"}, // prefix match
Authors: []string{"cfa87f35"}, // Prefix match Authors: []string{"cfa87f35"}, // prefix match
Kinds: []int{1, 6, 7}, Kinds: []int{1, 6, 7},
Since: &since, Since: &since,
Limit: &limit, Limit: &limit,
} }
``` ```
#### Filter with tag conditions #### Tag filters
```go ```go
filter := filters.Filter{ filter := filters.Filter{
@@ -213,27 +176,9 @@ filter := filters.Filter{
} }
``` ```
#### Filter with extensions (custom fields) #### Match events against a filter
```go `Matches` accepts a `ValidatedEvent`. Unvalidated events cannot be passed to it.
// Extensions allow arbitrary JSON fields beyond the standard filter spec.
// For example, this is how to implement non-standard filters like 'search'.
filter := filters.Filter{
Kinds: []int{1},
Extensions: filters.FilterExtensions{
"search": json.RawMessage(`"bitcoin"`),
},
}
// Extensions are preserved during marshal/unmarshal but ignored by Matches().
// Storage/transport layers can inspect Extensions to implement custom behavior.
```
---
### Filter Matching
#### Match single event
```go ```go
filter := filters.Filter{ filter := filters.Filter{
@@ -241,88 +186,33 @@ filter := filters.Filter{
Kinds: []int{1}, Kinds: []int{1},
} }
if filters.Matches(filter, event) { var matched []events.ValidatedEvent
// Event satisfies all filter conditions for _, event := range incoming {
}
```
#### Filter event collection
```go
since := int(time.Now().Add(-1 * time.Hour).Unix())
filter := filters.Filter{
Kinds: []int{1},
Since: &since,
Tags: filters.TagFilters{
"p": {"abc123", "def456"}, // OR within tag values
},
}
var matches []events.Event
for _, event := range events {
if filters.Matches(filter, event) { if filters.Matches(filter, event) {
matches = append(matches, event) matched = append(matched, event)
} }
} }
``` ```
--- `Matches` covers the standard NIP-01 filter fields. For non-trivial matching logic, implement your own.
### Filter JSON #### Filter JSON
#### Marshal filter to JSON Tag filter keys are prefixed with `#` in JSON (`#e`, `#p`). Unrecognized fields are captured in `Extensions` and are retained as-found. `Matches` ignores `Extensions`; higher layers can inspect them for custom behavior.
```go ```go
filter := filters.Filter{ // Marshal
IDs: []string{"abc123"},
Kinds: []int{1},
Tags: filters.TagFilters{
"e": {"event-id"},
},
Extensions: filters.FilterExtensions{
"search": json.RawMessage(`"nostr"`),
},
}
jsonBytes, err := filters.MarshalJSON(filter) jsonBytes, err := filters.MarshalJSON(filter)
// Result: {"ids":["abc123"],"kinds":[1],"#e":["event-id"],"search":"nostr"} // {"authors":["cfa87f35"],"kinds":[1],"#e":["..."],"since":...}
```
#### Unmarshal filter from JSON // Unmarshal
var f filters.Filter
```go if err := filters.UnmarshalJSON(data, &f); err != nil {
jsonData := `{
"authors": ["cfa87f35"],
"kinds": [1],
"#e": ["abc123"],
"since": 1234567890,
"search": "bitcoin"
}`
var filter filters.Filter
err := filters.UnmarshalJSON([]byte(jsonData), &filter)
if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Standard fields populated: Authors, Kinds, Since
// Tag filters populated: Tags["e"] = ["abc123"]
// Unknown fields populated: Extensions["search"] = "bitcoin"
``` ```
#### Extensions field behavior Extensions example:
The `Extensions` field captures any JSON properties not recognized as standard filter fields or tag filters. This design allows the core library to remain frozen while storage and transport layers implement custom filtering behavior.
**Standard fields**: `ids`, `authors`, `kinds`, `since`, `until`, `limit`
**Tag filters**: Any key starting with `#` (e.g., `#e`, `#p`, `#emoji`)
**Extensions**: Everything else
During marshaling, Extensions merge into the output JSON. During unmarshaling, unrecognized fields populate Extensions. The `Matches()` method ignores Extensions, and the library expects higher protocol layers to implement their usage.
Example implementing search filter:
```go ```go
filter := filters.Filter{ filter := filters.Filter{
@@ -334,15 +224,26 @@ filter := filters.Filter{
// In a storage layer (not this library): // In a storage layer (not this library):
if searchRaw, ok := filter.Extensions["search"]; ok { if searchRaw, ok := filter.Extensions["search"]; ok {
var searchTerm string var term string
json.Unmarshal(searchRaw, &searchTerm) json.Unmarshal(searchRaw, &term)
// Apply full-text search using searchTerm // apply full-text search
} }
``` ```
## Testing ---
This library contains a comprehensive suite of unit tests. Run them with: ## Specialized / Low-Level Tools
These are building blocks for custom pipelines. General use does not require them.
- **`Validate(e Event) error`** — full validation in one call; used internally by `NewValidatedEvent`
- **`ValidateStructure(e Event) error`** — field format checks only
- **`ValidateSignature(e Event) error`** — Schnorr signature verification only
- **`Serialize(e Event) []byte`** — canonical `[0, pubkey, created_at, kind, tags, content]` JSON array; used for ID computation
- **`GetID(e Event) string`** — SHA-256 of `Serialize`, returned as lowercase hex
- **`IsValidKey(s string) bool`**, **`IsValidID`**, **`IsValidSig`** — format validators for individual field values; useful when handling these value types outside of event validation
## Testing
```bash ```bash
go test ./... go test ./...
+56 -1
View File
@@ -3,6 +3,8 @@
// serialization, cryptographic signatures, and subscription filters. // serialization, cryptographic signatures, and subscription filters.
package events package events
// Tag represents a single tag within an event as an array of strings. // Tag represents a single tag within an event as an array of strings.
// The first element identifies the tag name, the second contains the value, // The first element identifies the tag name, the second contains the value,
// and subsequent elements are optional. // and subsequent elements are optional.
@@ -10,12 +12,65 @@ type Tag []string
// Event represents a Nostr protocol event, with its seven required fields. // Event represents a Nostr protocol event, with its seven required fields.
// All fields must be present for a valid event. // All fields must be present for a valid event.
//easyjson:json
type Event struct { type Event struct {
ID string `json:"id"` ID string `json:"id"`
PubKey string `json:"pubkey"` PubKey string `json:"pubkey"`
CreatedAt int `json:"created_at"` CreatedAt int64 `json:"created_at"`
Kind int `json:"kind"` Kind int `json:"kind"`
Tags []Tag `json:"tags"` Tags []Tag `json:"tags"`
Content string `json:"content"` Content string `json:"content"`
Sig string `json:"sig"` Sig string `json:"sig"`
} }
func NewEvent(opts ...EventOption) Event {
e := Event{}
for _, opt := range opts {
opt(&e)
}
return e
}
type EventOption func(*Event)
func WithID(id string) EventOption {
return func(e *Event) {
e.ID = id
}
}
func WithPubKey(pk string) EventOption {
return func(e *Event) {
e.PubKey = pk
}
}
func WithCreatedAt(t int64) EventOption {
return func(e *Event) {
e.CreatedAt = t
}
}
func WithKind(k int) EventOption {
return func(e *Event) {
e.Kind = k
}
}
func WithTag(t Tag) EventOption {
return func(e *Event) {
e.Tags = append(e.Tags, t)
}
}
func WithContent(c string) EventOption {
return func(e *Event) {
e.Content = c
}
}
func WithSig(s string) EventOption {
return func(e *Event) {
e.Sig = s
}
}
+214
View File
@@ -0,0 +1,214 @@
// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.
package events
import (
json "encoding/json"
easyjson "github.com/mailru/easyjson"
jlexer "github.com/mailru/easyjson/jlexer"
jwriter "github.com/mailru/easyjson/jwriter"
)
// suppress unused package warning
var (
_ *json.RawMessage
_ *jlexer.Lexer
_ *jwriter.Writer
_ easyjson.Marshaler
)
func easyjsonF642ad3eDecodeGitWisehodlDevJayGoRootsEvents(in *jlexer.Lexer, out *Event) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
in.Consumed()
}
in.Skip()
return
}
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeFieldName(false)
in.WantColon()
switch key {
case "id":
if in.IsNull() {
in.Skip()
} else {
out.ID = string(in.String())
}
case "pubkey":
if in.IsNull() {
in.Skip()
} else {
out.PubKey = string(in.String())
}
case "created_at":
if in.IsNull() {
in.Skip()
} else {
out.CreatedAt = int64(in.Int64())
}
case "kind":
if in.IsNull() {
in.Skip()
} else {
out.Kind = int(in.Int())
}
case "tags":
if in.IsNull() {
in.Skip()
out.Tags = nil
} else {
in.Delim('[')
if out.Tags == nil {
if !in.IsDelim(']') {
out.Tags = make([]Tag, 0, 2)
} else {
out.Tags = []Tag{}
}
} else {
out.Tags = (out.Tags)[:0]
}
for !in.IsDelim(']') {
var v1 Tag
if in.IsNull() {
in.Skip()
v1 = nil
} else {
in.Delim('[')
if v1 == nil {
if !in.IsDelim(']') {
v1 = make(Tag, 0, 4)
} else {
v1 = Tag{}
}
} else {
v1 = (v1)[:0]
}
for !in.IsDelim(']') {
var v2 string
if in.IsNull() {
in.Skip()
} else {
v2 = string(in.String())
}
v1 = append(v1, v2)
in.WantComma()
}
in.Delim(']')
}
out.Tags = append(out.Tags, v1)
in.WantComma()
}
in.Delim(']')
}
case "content":
if in.IsNull() {
in.Skip()
} else {
out.Content = string(in.String())
}
case "sig":
if in.IsNull() {
in.Skip()
} else {
out.Sig = string(in.String())
}
default:
in.SkipRecursive()
}
in.WantComma()
}
in.Delim('}')
if isTopLevel {
in.Consumed()
}
}
func easyjsonF642ad3eEncodeGitWisehodlDevJayGoRootsEvents(out *jwriter.Writer, in Event) {
out.RawByte('{')
first := true
_ = first
{
const prefix string = ",\"id\":"
out.RawString(prefix[1:])
out.String(string(in.ID))
}
{
const prefix string = ",\"pubkey\":"
out.RawString(prefix)
out.String(string(in.PubKey))
}
{
const prefix string = ",\"created_at\":"
out.RawString(prefix)
out.Int64(int64(in.CreatedAt))
}
{
const prefix string = ",\"kind\":"
out.RawString(prefix)
out.Int(int(in.Kind))
}
{
const prefix string = ",\"tags\":"
out.RawString(prefix)
if in.Tags == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v3, v4 := range in.Tags {
if v3 > 0 {
out.RawByte(',')
}
if v4 == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v5, v6 := range v4 {
if v5 > 0 {
out.RawByte(',')
}
out.String(string(v6))
}
out.RawByte(']')
}
}
out.RawByte(']')
}
}
{
const prefix string = ",\"content\":"
out.RawString(prefix)
out.String(string(in.Content))
}
{
const prefix string = ",\"sig\":"
out.RawString(prefix)
out.String(string(in.Sig))
}
out.RawByte('}')
}
// MarshalJSON supports json.Marshaler interface
func (v Event) MarshalJSON() ([]byte, error) {
w := jwriter.Writer{}
easyjsonF642ad3eEncodeGitWisehodlDevJayGoRootsEvents(&w, v)
return w.Buffer.BuildBytes(), w.Error
}
// MarshalEasyJSON supports easyjson.Marshaler interface
func (v Event) MarshalEasyJSON(w *jwriter.Writer) {
easyjsonF642ad3eEncodeGitWisehodlDevJayGoRootsEvents(w, v)
}
// UnmarshalJSON supports json.Unmarshaler interface
func (v *Event) UnmarshalJSON(data []byte) error {
r := jlexer.Lexer{Data: data}
easyjsonF642ad3eDecodeGitWisehodlDevJayGoRootsEvents(&r, v)
return r.Error()
}
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
func (v *Event) UnmarshalEasyJSON(l *jlexer.Lexer) {
easyjsonF642ad3eDecodeGitWisehodlDevJayGoRootsEvents(l, v)
}
+12 -14
View File
@@ -7,7 +7,7 @@ import (
) )
func TestUnmarshalEventJSON(t *testing.T) { func TestUnmarshalEventJSON(t *testing.T) {
event := Event{} event := NewEvent()
json.Unmarshal(testEventJSONBytes, &event) json.Unmarshal(testEventJSONBytes, &event)
if err := Validate(event); err != nil { if err := Validate(event); err != nil {
t.Error("unmarshalled event is invalid") t.Error("unmarshalled event is invalid")
@@ -22,19 +22,17 @@ func TestMarshalEventJSON(t *testing.T) {
} }
func TestEventJSONRoundTrip(t *testing.T) { func TestEventJSONRoundTrip(t *testing.T) {
event := Event{ event := NewEvent(
ID: "86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad", WithID("86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad"),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: []Tag{ WithTag(Tag{"a", "value"}),
{"a", "value"}, WithTag(Tag{"b", "value", "optional"}),
{"b", "value", "optional"}, WithTag(Tag{"name", "value", "optional", "optional"}),
{"name", "value", "optional", "optional"}, WithContent(testEvent.Content),
}, WithSig("c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557"),
Content: testEvent.Content, )
Sig: "c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557",
}
expectedJSON := `{"id":"86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad","pubkey":"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef","created_at":1760740551,"kind":1,"tags":[["a","value"],["b","value","optional"],["name","value","optional","optional"]],"content":"hello world","sig":"c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557"}` expectedJSON := `{"id":"86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad","pubkey":"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef","created_at":1760740551,"kind":1,"tags":[["a","value"],["b","value","optional"],["name","value","optional","optional"]],"content":"hello world","sig":"c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557"}`
if err := Validate(event); err != nil { if err := Validate(event); err != nil {
+9 -10
View File
@@ -8,17 +8,16 @@ import (
const testSK = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167" const testSK = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167"
const testPK = "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef" const testPK = "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"
var testEvent = Event{ var testEvent = NewEvent(
ID: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad", WithID("c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad"),
PubKey: testPK, WithPubKey(testPK),
CreatedAt: 1760740551, WithCreatedAt(1760740551),
Kind: 1, WithKind(1),
Tags: []Tag{}, WithContent("hello world"),
Content: "hello world", WithSig("83b71e15649c9e9da362c175f988c36404cabf357a976d869102a74451cfb8af486f6088b5631033b4927bd46cad7a0d90d7f624aefc0ac260364aa65c36071a"),
Sig: "83b71e15649c9e9da362c175f988c36404cabf357a976d869102a74451cfb8af486f6088b5631033b4927bd46cad7a0d90d7f624aefc0ac260364aa65c36071a", )
}
var testEventJSON = `{"id":"c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad","pubkey":"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef","created_at":1760740551,"kind":1,"tags":[],"content":"hello world","sig":"83b71e15649c9e9da362c175f988c36404cabf357a976d869102a74451cfb8af486f6088b5631033b4927bd46cad7a0d90d7f624aefc0ac260364aa65c36071a"}` var testEventJSON = `{"id":"c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad","pubkey":"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef","created_at":1760740551,"kind":1,"tags":null,"content":"hello world","sig":"83b71e15649c9e9da362c175f988c36404cabf357a976d869102a74451cfb8af486f6088b5631033b4927bd46cad7a0d90d7f624aefc0ac260364aa65c36071a"}`
var testEventJSONBytes = []byte(testEventJSON) var testEventJSONBytes = []byte(testEventJSON)
func expectEqualEvents(t *testing.T, got, want Event) { func expectEqualEvents(t *testing.T, got, want Event) {
+1 -6
View File
@@ -9,15 +9,10 @@ import (
// GetID computes and returns the event ID as a lowercase, hex-encoded SHA-256 hash // GetID computes and returns the event ID as a lowercase, hex-encoded SHA-256 hash
// of the serialized event. // of the serialized event.
func GetID(e Event) string { func GetID(e Event) string {
hash := GetIDBytes(e) hash := sha256.Sum256(Serialize(e))
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
// GetIDBytes computes and returns the event ID as a raw SHA256 digest
func GetIDBytes(e Event) [32]byte {
return sha256.Sum256(Serialize(e))
}
// Serialize returns the canonical JSON array representation of the event. // Serialize returns the canonical JSON array representation of the event.
// used for ID computation: [0, pubkey, created_at, kind, tags, content]. // used for ID computation: [0, pubkey, created_at, kind, tags, content].
func Serialize(e Event) []byte { func Serialize(e Event) []byte {
+90 -110
View File
@@ -14,181 +14,161 @@ type IDTestCase struct {
var idTestCases = []IDTestCase{ var idTestCases = []IDTestCase{
{ {
name: "minimal event", name: "minimal event",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{}, ),
Content: "",
},
expected: "13a55672a600398894592f4cb338652d4936caffe5d3718d11597582bb030c39", expected: "13a55672a600398894592f4cb338652d4936caffe5d3718d11597582bb030c39",
}, },
{ {
name: "alphanumeric content", name: "alphanumeric content",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{}, WithContent("hello world"),
Content: "hello world", ),
},
expected: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad", expected: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad",
}, },
{ {
name: "unicode content", name: "unicode content",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{}, WithContent("hello world 😀"),
Content: "hello world 😀", ),
},
expected: "e42083fafbf9a39f97914fd9a27cedb38c429ac3ca8814288414eaad1f472fe8", expected: "e42083fafbf9a39f97914fd9a27cedb38c429ac3ca8814288414eaad1f472fe8",
}, },
{ {
name: "escaped content", name: "escaped content",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{}, WithContent("\"You say yes.\"\\n\\t\"I say no.\""),
Content: "\"You say yes.\"\\n\\t\"I say no.\"", ),
},
expected: "343de133996a766bf00561945b6f2b2717d4905275976ca75c1d7096b7d1900c", expected: "343de133996a766bf00561945b6f2b2717d4905275976ca75c1d7096b7d1900c",
}, },
{ {
name: "json content", name: "json content",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{}, WithContent("{\"field\": [\"value\",\"value\"],\"numeral\": 123}"),
Content: "{\"field\": [\"value\",\"value\"],\"numeral\": 123}", ),
},
expected: "c6140190453ee947efb790e70541a9d37c41604d1f29e4185da4325621ed5270", expected: "c6140190453ee947efb790e70541a9d37c41604d1f29e4185da4325621ed5270",
}, },
{ {
name: "empty tag", name: "empty tag",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{ WithTag(Tag{"a", ""}),
{"a", ""}, WithContent(""),
}, ),
Content: "",
},
expected: "7d3e394c75916362436f11c603b1a89b40b50817550cfe522a90d769655007a4", expected: "7d3e394c75916362436f11c603b1a89b40b50817550cfe522a90d769655007a4",
}, },
{ {
name: "single tag", name: "single tag",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{ WithTag(Tag{"a", "value"}),
{"a", "value"}, WithContent(""),
}, ),
Content: "",
},
expected: "7db394e274fb893edbd9f4aa9ff189d4f3264bf1a29cef8f614e83ebf6fa19fe", expected: "7db394e274fb893edbd9f4aa9ff189d4f3264bf1a29cef8f614e83ebf6fa19fe",
}, },
{ {
name: "optional tag values", name: "optional tag values",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{ WithTag(Tag{"a", "value", "optional"}),
{"a", "value", "optional"}, WithContent(""),
}, ),
Content: "",
},
expected: "656b47884200959e0c03054292c453cfc4beea00b592d92c0f557bff765e9d34", expected: "656b47884200959e0c03054292c453cfc4beea00b592d92c0f557bff765e9d34",
}, },
{ {
name: "multiple tags", name: "multiple tags",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{ WithTag(Tag{"a", "value", "optional"}),
{"a", "value", "optional"}, WithTag(Tag{"b", "another"}),
{"b", "another"}, WithTag(Tag{"c", "data"}),
{"c", "data"}, WithContent(""),
}, ),
Content: "",
},
expected: "f7c27f2eacda7ece5123a4f82db56145ba59f7c9e6c5eeb88552763664506b06", expected: "f7c27f2eacda7ece5123a4f82db56145ba59f7c9e6c5eeb88552763664506b06",
}, },
{ {
name: "unicode tag", name: "unicode tag",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 1, WithKind(1),
Tags: []Tag{ WithTag(Tag{"a", "😀"}),
{"a", "😀"}, WithContent(""),
}, ),
Content: "",
},
expected: "fd2798d165d9bf46acbe817735dc8cedacd4c42dfd9380792487d4902539e986", expected: "fd2798d165d9bf46acbe817735dc8cedacd4c42dfd9380792487d4902539e986",
}, },
{ {
name: "zero timestamp", name: "zero timestamp",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: 0, WithCreatedAt(0),
Kind: 1, WithKind(1),
Tags: []Tag{}, WithContent(""),
Content: "", ),
},
expected: "9ca742f2e2eea72ad6e0277a6287e2bb16a3e47d64b8468bc98474e266cf0ec2", expected: "9ca742f2e2eea72ad6e0277a6287e2bb16a3e47d64b8468bc98474e266cf0ec2",
}, },
{ {
name: "negative timestamp", name: "negative timestamp",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: -1760740551, WithCreatedAt(-1760740551),
Kind: 1, WithKind(1),
Tags: []Tag{}, WithContent(""),
Content: "", ),
},
expected: "4740b027040bb4d0ee8e885f567a80277097da70cddd143d8a6dadf97f6faaa3", expected: "4740b027040bb4d0ee8e885f567a80277097da70cddd143d8a6dadf97f6faaa3",
}, },
{ {
name: "max int64 timestamp", name: "max int64 timestamp",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: 9223372036854775807, WithCreatedAt(9223372036854775807),
Kind: 1, WithKind(1),
Tags: []Tag{}, WithContent(""),
Content: "", ),
},
expected: "b28cdd44496acb49e36c25859f0f819122829a12dc57c07612d5f44cb121d2a7", expected: "b28cdd44496acb49e36c25859f0f819122829a12dc57c07612d5f44cb121d2a7",
}, },
{ {
name: "different kind", name: "different kind",
event: Event{ event: NewEvent(
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: 20021, WithKind(20021),
Tags: []Tag{}, WithContent(""),
Content: "", ),
},
expected: "995c4894c264e6b9558cb94b7b34008768d53801b99960b47298d4e3e23fadd3", expected: "995c4894c264e6b9558cb94b7b34008768d53801b99960b47298d4e3e23fadd3",
}, },
} }
-5
View File
@@ -1,5 +0,0 @@
package events
func intPtr(i int) *int {
return &i
}
+11 -24
View File
@@ -2,6 +2,7 @@ package events
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"git.wisehodl.dev/jay/go-roots/errors" "git.wisehodl.dev/jay/go-roots/errors"
@@ -15,9 +16,15 @@ func Validate(e Event) error {
return err return err
} }
idBytes, err := checkIDMatch(e) idHash := sha256.Sum256(Serialize(e))
idBytes, err := hex.DecodeString(e.ID)
if err != nil { if err != nil {
return err return errors.MalformedID
}
if !bytes.Equal(idBytes, idHash[:]) {
return fmt.Errorf(
"event id %q does not match computed id %q",
e.ID, hex.EncodeToString(idHash[:]))
} }
return validateSignatureBytes(idBytes, e.Sig, e.PubKey) return validateSignatureBytes(idBytes, e.Sig, e.PubKey)
@@ -47,12 +54,6 @@ func ValidateStructure(e Event) error {
return nil return nil
} }
// ValidateID recomputes the event ID and verifies it matches the stored ID field.
func ValidateID(e Event) error {
_, err := checkIDMatch(e)
return err
}
// ValidateSignature verifies the event signature is cryptographically valid // ValidateSignature verifies the event signature is cryptographically valid
// for the event ID and public key using Schnorr verification. // for the event ID and public key using Schnorr verification.
func ValidateSignature(e Event) error { func ValidateSignature(e Event) error {
@@ -70,32 +71,18 @@ func IsValidKey(value string) bool {
return isLowerHex(value, 64) return isLowerHex(value, 64)
} }
// IsValidKey verifies that an event id is properly formatted. // IsValidID verifies that an event id is properly formatted.
func IsValidID(value string) bool { func IsValidID(value string) bool {
return isLowerHex(value, 64) return isLowerHex(value, 64)
} }
// IsValidKey verifies that an event signature is properly formatted. // IsValidSig verifies that an event signature is properly formatted.
func IsValidSig(value string) bool { func IsValidSig(value string) bool {
return isLowerHex(value, 128) return isLowerHex(value, 128)
} }
// Helpers // Helpers
func checkIDMatch(e Event) ([]byte, error) {
idHash := GetIDBytes(e)
idBytes, err := hex.DecodeString(e.ID)
if err != nil {
return nil, errors.MalformedID
}
if !bytes.Equal(idBytes, idHash[:]) {
return nil, fmt.Errorf(
"event id %q does not match computed id %q",
e.ID, hex.EncodeToString(idHash[:]))
}
return idBytes, nil
}
func validateSignatureBytes(idBytes []byte, sigHex, pkHex string) error { func validateSignatureBytes(idBytes []byte, sigHex, pkHex string) error {
sigBytes, err := hex.DecodeString(sigHex) sigBytes, err := hex.DecodeString(sigHex)
if err != nil { if err != nil {
+125 -146
View File
@@ -14,169 +14,161 @@ type ValidateEventTestCase struct {
var structureTestCases = []ValidateEventTestCase{ var structureTestCases = []ValidateEventTestCase{
{ {
name: "empty pubkey", name: "empty pubkey",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: "", WithPubKey(""),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig(testEvent.Sig),
Sig: testEvent.Sig, ),
},
expectedError: "public key must be 64 lowercase hex characters", expectedError: "public key must be 64 lowercase hex characters",
}, },
{ {
name: "short pubkey", name: "short pubkey",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: "abc123", WithPubKey("abc123"),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig(testEvent.Sig),
Sig: testEvent.Sig, ),
},
expectedError: "public key must be 64 lowercase hex characters", expectedError: "public key must be 64 lowercase hex characters",
}, },
{ {
name: "long pubkey", name: "long pubkey",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48adabc", WithPubKey("c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48adabc"),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig(testEvent.Sig),
Sig: testEvent.Sig, ),
},
expectedError: "public key must be 64 lowercase hex characters", expectedError: "public key must be 64 lowercase hex characters",
}, },
{ {
name: "non-hex pubkey", name: "non-hex pubkey",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: "zyx-!2e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad", WithPubKey("zyx-!2e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad"),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig(testEvent.Sig),
Sig: testEvent.Sig, ),
},
expectedError: "public key must be 64 lowercase hex characters", expectedError: "public key must be 64 lowercase hex characters",
}, },
{ {
name: "uppercase pubkey", name: "uppercase pubkey",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: "C7A702E6158744CA03508BBB4C90F9DBB0D6E88FEFBFAA511D5AB24B4E3C48AD", WithPubKey("C7A702E6158744CA03508BBB4C90F9DBB0D6E88FEFBFAA511D5AB24B4E3C48AD"),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig(testEvent.Sig),
Sig: testEvent.Sig, ),
},
expectedError: "public key must be 64 lowercase hex characters", expectedError: "public key must be 64 lowercase hex characters",
}, },
{ {
name: "empty id", name: "empty id",
event: Event{ event: NewEvent(
ID: "", WithID(""),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig(testEvent.Sig),
Sig: testEvent.Sig, ),
},
expectedError: "id must be 64 hex characters", expectedError: "id must be 64 hex characters",
}, },
{ {
name: "short id", name: "short id",
event: Event{ event: NewEvent(
ID: "abc123", WithID("abc123"),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig(testEvent.Sig),
Sig: testEvent.Sig, ),
},
expectedError: "id must be 64 hex characters", expectedError: "id must be 64 hex characters",
}, },
{ {
name: "empty signature", name: "empty signature",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig(""),
Sig: "", ),
},
expectedError: "signature must be 128 hex characters", expectedError: "signature must be 128 hex characters",
}, },
{ {
name: "short signature", name: "short signature",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: testEvent.Tags, WithContent(testEvent.Content),
Content: testEvent.Content, WithSig("abc123"),
Sig: "abc123", ),
},
expectedError: "signature must be 128 hex characters", expectedError: "signature must be 128 hex characters",
}, },
{ {
name: "empty tag", name: "empty tag",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: []Tag{{}}, WithTag(Tag{}),
Content: testEvent.Content, WithContent(testEvent.Content),
Sig: testEvent.Sig, WithSig(testEvent.Sig),
}, ),
expectedError: "tags must contain at least two elements", expectedError: "tags must contain at least two elements",
}, },
{ {
name: "single element tag", name: "single element tag",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: []Tag{{"a"}}, WithTag(Tag{"a"}),
Content: testEvent.Content, WithContent(testEvent.Content),
Sig: testEvent.Sig, WithSig(testEvent.Sig),
}, ),
expectedError: "tags must contain at least two elements", expectedError: "tags must contain at least two elements",
}, },
{ {
name: "one good tag, one single element tag", name: "one good tag, one single element tag",
event: Event{ event: NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: []Tag{{"a", "value"}, {"b"}}, WithTag(Tag{"a", "value"}),
Content: testEvent.Content, WithTag(Tag{"b"}),
Sig: testEvent.Sig, WithContent(testEvent.Content),
}, WithSig(testEvent.Sig),
),
expectedError: "tags must contain at least two elements", expectedError: "tags must contain at least two elements",
}, },
} }
@@ -190,38 +182,23 @@ func TestValidateEventStructure(t *testing.T) {
} }
} }
func TestValidateEventIDFailure(t *testing.T) {
event := Event{
ID: "7f661c2a3c1ed67dc959d6cd968d743d5e6e334313df44724bca939e2aa42c9e",
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: testEvent.Kind,
Tags: testEvent.Tags,
Content: testEvent.Content,
Sig: testEvent.Sig,
}
err := ValidateID(event)
assert.ErrorContains(t, err, "does not match computed id")
}
func TestValidateSignature(t *testing.T) { func TestValidateSignature(t *testing.T) {
event := Event{ event := NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
Sig: testEvent.Sig, WithSig(testEvent.Sig),
} )
err := ValidateSignature(event) err := ValidateSignature(event)
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestValidateInvalidSignature(t *testing.T) { func TestValidateInvalidSignature(t *testing.T) {
event := Event{ event := NewEvent(
ID: testEvent.ID, WithID(testEvent.ID),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
Sig: "9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482", WithSig("9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482"),
} )
err := ValidateSignature(event) err := ValidateSignature(event)
assert.ErrorContains(t, err, "event signature is invalid") assert.ErrorContains(t, err, "event signature is invalid")
@@ -280,7 +257,11 @@ var validateSignatureTestCases = []ValidateSignatureTestCase{
func TestValidateSignatureInvalidEventSignature(t *testing.T) { func TestValidateSignatureInvalidEventSignature(t *testing.T) {
for _, tc := range validateSignatureTestCases { for _, tc := range validateSignatureTestCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
event := Event{ID: tc.id, PubKey: tc.pubkey, Sig: tc.sig} event := NewEvent(
WithID(tc.id),
WithPubKey(tc.pubkey),
WithSig(tc.sig),
)
err := ValidateSignature(event) err := ValidateSignature(event)
assert.ErrorContains(t, err, tc.expectedError) assert.ErrorContains(t, err, tc.expectedError)
}) })
@@ -288,18 +269,16 @@ func TestValidateSignatureInvalidEventSignature(t *testing.T) {
} }
func TestValidateEvent(t *testing.T) { func TestValidateEvent(t *testing.T) {
event := Event{ event := NewEvent(
ID: "c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400", WithID("c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400"),
PubKey: testEvent.PubKey, WithPubKey(testEvent.PubKey),
CreatedAt: testEvent.CreatedAt, WithCreatedAt(testEvent.CreatedAt),
Kind: testEvent.Kind, WithKind(testEvent.Kind),
Tags: []Tag{ WithTag(Tag{"a", "value"}),
{"a", "value"}, WithTag(Tag{"b", "value", "optional"}),
{"b", "value", "optional"}, WithContent("valid event"),
}, WithSig("668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14"),
Content: "valid event", )
Sig: "668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14",
}
err := Validate(event) err := Validate(event)
assert.NoError(t, err) assert.NoError(t, err)
+58
View File
@@ -0,0 +1,58 @@
package events
// ValidatedEvent is an immutable wrapper around a fully validated event. It
// shares no memory with the original Event struct.
//
// When created with NewValidatedEvent, the wrapped event is guaranteed to:
// - have a valid structure
// - have a valid ID
// - have a valid signature
type ValidatedEvent struct {
event Event
}
// NewValidatedEvent validates the provided Event. If valid, returns an
// immutable ValidatedEvent. Otherwise returns an error.
func NewValidatedEvent(e Event) (ValidatedEvent, error) {
if err := Validate(e); err != nil {
return ValidatedEvent{}, err
}
e.Tags = deepCopyTags(e.Tags)
return ValidatedEvent{event: e}, nil
}
func (v ValidatedEvent) ID() string { return v.event.ID }
func (v ValidatedEvent) PubKey() string { return v.event.PubKey }
func (v ValidatedEvent) CreatedAt() int64 { return v.event.CreatedAt }
func (v ValidatedEvent) Kind() int { return v.event.Kind }
func (v ValidatedEvent) Content() string { return v.event.Content }
func (v ValidatedEvent) Sig() string { return v.event.Sig }
// Tags returns a deep copy of the event's tag list. The returned slice shares
// no memory with the ValidatedEvent.
func (v ValidatedEvent) Tags() []Tag { return deepCopyTags(v.event.Tags) }
// Event returns a deep copy of the underlying Event. The returned Event shares
// no memory with the ValidatedEvent.
func (v ValidatedEvent) Event() Event {
e := v.event
e.Tags = deepCopyTags(v.event.Tags)
return e
}
func deepCopyTags(tags []Tag) []Tag {
if tags == nil {
return nil
}
cp := make([]Tag, len(tags))
for i, tag := range tags {
tagcp := make(Tag, len(tag))
copy(tagcp, tag)
cp[i] = tagcp
}
return cp
}
func (v ValidatedEvent) MarshalJSON() ([]byte, error) {
return v.event.MarshalJSON()
}
+216
View File
@@ -0,0 +1,216 @@
package events
import (
"encoding/json"
"github.com/stretchr/testify/assert"
"testing"
)
var testEventWithTags = NewEvent(
WithID("c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400"),
WithPubKey(testPK),
WithCreatedAt(1760740551),
WithKind(1),
WithTag(Tag{"a", "value"}),
WithTag(Tag{"b", "value", "optional"}),
WithContent("valid event"),
WithSig("668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14"),
)
func TestValidatedEventConstruction(t *testing.T) {
t.Run("accepts valid event", func(t *testing.T) {
_, err := NewValidatedEvent(testEvent)
assert.NoError(t, err)
})
t.Run("rejects invalid structure", func(t *testing.T) {
bad := NewEvent(
WithID(testEvent.ID),
WithPubKey("notavalidpubkey"),
WithCreatedAt(testEvent.CreatedAt),
WithKind(testEvent.Kind),
WithContent(testEvent.Content),
WithSig(testEvent.Sig),
)
_, err := NewValidatedEvent(bad)
assert.ErrorContains(t, err, "public key must be 64 lowercase hex characters")
})
t.Run("rejects id mismatch", func(t *testing.T) {
bad := NewEvent(
WithID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(testEvent.Kind),
WithContent(testEvent.Content),
WithSig(testEvent.Sig),
)
_, err := NewValidatedEvent(bad)
assert.ErrorContains(t, err, "does not match computed id")
})
t.Run("rejects invalid signature", func(t *testing.T) {
bad := NewEvent(
WithID(testEvent.ID),
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(testEvent.Kind),
WithContent(testEvent.Content),
WithSig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
)
_, err := NewValidatedEvent(bad)
assert.ErrorContains(t, err, "event signature is invalid")
})
}
func TestValidatedEventAccessors(t *testing.T) {
ve, err := NewValidatedEvent(testEventWithTags)
assert.NoError(t, err)
t.Run("ID", func(t *testing.T) {
assert.Equal(t, testEventWithTags.ID, ve.ID())
})
t.Run("PubKey", func(t *testing.T) {
assert.Equal(t, testEventWithTags.PubKey, ve.PubKey())
})
t.Run("CreatedAt", func(t *testing.T) {
assert.Equal(t, testEventWithTags.CreatedAt, ve.CreatedAt())
})
t.Run("Kind", func(t *testing.T) {
assert.Equal(t, testEventWithTags.Kind, ve.Kind())
})
t.Run("Content", func(t *testing.T) {
assert.Equal(t, testEventWithTags.Content, ve.Content())
})
t.Run("Sig", func(t *testing.T) {
assert.Equal(t, testEventWithTags.Sig, ve.Sig())
})
t.Run("Tags", func(t *testing.T) {
assert.Equal(t, testEventWithTags.Tags, ve.Tags())
})
}
func TestValidatedEventTagsImmutability(t *testing.T) {
t.Run("outer slice mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
tags := ve.Tags()
tags[0] = Tag{"z", "injected"}
assert.Equal(t, Tag{"a", "value"}, ve.Tags()[0])
})
t.Run("inner slice mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
tags := ve.Tags()
tags[0][1] = "mutated"
assert.Equal(t, "value", ve.Tags()[0][1])
})
t.Run("append to outer slice is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
originalLen := len(ve.Tags())
tags := ve.Tags()
tags = append(tags, Tag{"new", "tag"})
_ = tags
assert.Equal(t, originalLen, len(ve.Tags()))
})
t.Run("append to inner slice is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
originalLen := len(ve.Tags()[1])
tags := ve.Tags()
tags[1] = append(tags[1], "extra")
assert.Equal(t, originalLen, len(ve.Tags()[1]))
})
t.Run("successive calls return independent copies", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
first := ve.Tags()
second := ve.Tags()
first[0][0] = "z"
assert.Equal(t, "a", second[0][0])
})
t.Run("nil tags remain nil", func(t *testing.T) {
nil_tags_event := Event{
ID: testEvent.ID,
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: testEvent.Kind,
Tags: nil,
Content: testEvent.Content,
Sig: testEvent.Sig,
}
ve, err := NewValidatedEvent(nil_tags_event)
assert.NoError(t, err)
assert.Nil(t, ve.Tags())
assert.Nil(t, ve.Event().Tags)
})
}
func TestValidatedEventEventImmutability(t *testing.T) {
t.Run("outer tags mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
e := ve.Event()
e.Tags[0] = Tag{"z", "injected"}
assert.Equal(t, Tag{"a", "value"}, ve.Tags()[0])
})
t.Run("inner tags mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
e := ve.Event()
e.Tags[0][1] = "mutated"
assert.Equal(t, "value", ve.Tags()[0][1])
})
t.Run("scalar field mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEvent)
e := ve.Event()
e.Content = "tampered"
assert.Equal(t, testEvent.Content, ve.Content())
})
t.Run("successive calls return independent copies", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
first := ve.Event()
second := ve.Event()
first.Tags[0][0] = "z"
assert.Equal(t, "a", second.Tags[0][0])
})
t.Run("returned event passes validation", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
assert.NoError(t, Validate(ve.Event()))
})
t.Run("source event mutation after construction is isolated", func(t *testing.T) {
source := NewEvent(
WithID(testEventWithTags.ID),
WithPubKey(testEventWithTags.PubKey),
WithCreatedAt(testEventWithTags.CreatedAt),
WithKind(testEventWithTags.Kind),
WithTag(Tag{"a", "value"}),
WithTag(Tag{"b", "value", "optional"}),
WithContent(testEventWithTags.Content),
WithSig(testEventWithTags.Sig),
)
ve, err := NewValidatedEvent(source)
assert.NoError(t, err)
source.Tags[0][1] = "mutated"
source.Content = "tampered"
assert.Equal(t, "value", ve.Tags()[0][1])
assert.Equal(t, testEventWithTags.Content, ve.Content())
})
}
func TestValidatedEventMarshalJSON(t *testing.T) {
ve, _ := NewValidatedEvent(testEvent)
jsonBytes, err := json.Marshal(ve)
assert.NoError(t, err)
assert.Equal(t, testEventJSON, string(jsonBytes))
}
+81 -13
View File
@@ -20,16 +20,83 @@ type Filter struct {
IDs []string IDs []string
Authors []string Authors []string
Kinds []int Kinds []int
Since *int Since *int64
Until *int Until *int64
Limit *int Limit *int
Tags TagFilters Tags TagFilters
Extensions FilterExtensions Extensions FilterExtensions
} }
func NewFilter(opts ...FilterOption) Filter {
f := Filter{}
for _, opt := range opts {
opt(&f)
}
return f
}
type FilterOption func(*Filter)
func WithIDs(ids []string) FilterOption {
return func(f *Filter) {
f.IDs = ids
}
}
func WithAuthors(authors []string) FilterOption {
return func(f *Filter) {
f.Authors = authors
}
}
func WithKinds(kinds []int) FilterOption {
return func(f *Filter) {
f.Kinds = kinds
}
}
func WithSince(since int64) FilterOption {
return func(f *Filter) {
ptr := since
f.Since = &ptr
}
}
func WithUntil(until int64) FilterOption {
return func(f *Filter) {
ptr := until
f.Until = &ptr
}
}
func WithLimit(limit int) FilterOption {
return func(f *Filter) {
ptr := limit
f.Limit = &ptr
}
}
func WithTag(l string, v []string) FilterOption {
return func(f *Filter) {
if f.Tags == nil {
f.Tags = make(TagFilters)
}
f.Tags[l] = v
}
}
func WithExtension(l string, e json.RawMessage) FilterOption {
return func(f *Filter) {
if f.Extensions == nil {
f.Extensions = make(FilterExtensions)
}
f.Extensions[l] = e
}
}
// MarshalJSON converts the filter to JSON with standard fields, tag filters // MarshalJSON converts the filter to JSON with standard fields, tag filters
// (prefixed with "#"), and extensions merged into a single object. // (prefixed with "#"), and extensions merged into a single object.
func MarshalJSON(f Filter) ([]byte, error) { func (f Filter) MarshalJSON() ([]byte, error) {
outputMap := make(map[string]interface{}) outputMap := make(map[string]interface{})
// Add standard fields // Add standard fields
@@ -86,7 +153,7 @@ func MarshalJSON(f Filter) ([]byte, error) {
// UnmarshalJSON parses JSON into the filter, separating standard fields, // UnmarshalJSON parses JSON into the filter, separating standard fields,
// tag filters (keys starting with "#"), and extensions. // tag filters (keys starting with "#"), and extensions.
func UnmarshalJSON(data []byte, f *Filter) error { func (f *Filter) UnmarshalJSON(data []byte) error {
// Decode into raw map // Decode into raw map
raw := make(FilterExtensions) raw := make(FilterExtensions)
if err := json.Unmarshal(data, &raw); err != nil { if err := json.Unmarshal(data, &raw); err != nil {
@@ -119,7 +186,7 @@ func UnmarshalJSON(data []byte, f *Filter) error {
if len(v) == 4 && string(v) == "null" { if len(v) == 4 && string(v) == "null" {
f.Since = nil f.Since = nil
} else { } else {
var val int var val int64
if err := json.Unmarshal(v, &val); err != nil { if err := json.Unmarshal(v, &val); err != nil {
return err return err
} }
@@ -132,7 +199,7 @@ func UnmarshalJSON(data []byte, f *Filter) error {
if len(v) == 4 && string(v) == "null" { if len(v) == 4 && string(v) == "null" {
f.Until = nil f.Until = nil
} else { } else {
var val int var val int64
if err := json.Unmarshal(v, &val); err != nil { if err := json.Unmarshal(v, &val); err != nil {
return err return err
} }
@@ -182,36 +249,37 @@ func UnmarshalJSON(data []byte, f *Filter) error {
// Matches returns true if the event satisfies all filter conditions. // Matches returns true if the event satisfies all filter conditions.
// Supports prefix matching for IDs and authors, and tag filtering. // Supports prefix matching for IDs and authors, and tag filtering.
// Does not account for custom extensions. // Does not account for custom extensions.
func Matches(f Filter, event events.Event) bool { func Matches(f Filter, event events.ValidatedEvent) bool {
// Check ID // Check ID
if len(f.IDs) > 0 { if len(f.IDs) > 0 {
if !matchesPrefix(event.ID, f.IDs) { if !matchesPrefix(event.ID(), f.IDs) {
return false return false
} }
} }
// Check Author // Check Author
if len(f.Authors) > 0 { if len(f.Authors) > 0 {
if !matchesPrefix(event.PubKey, f.Authors) { if !matchesPrefix(event.PubKey(), f.Authors) {
return false return false
} }
} }
// Check Kind // Check Kind
if len(f.Kinds) > 0 { if len(f.Kinds) > 0 {
if !matchesKinds(event.Kind, f.Kinds) { if !matchesKinds(event.Kind(), f.Kinds) {
return false return false
} }
} }
// Check Timestamp // Check Timestamp
if !matchesTimeRange(event.CreatedAt, f.Since, f.Until) { if !matchesTimeRange(event.CreatedAt(), f.Since, f.Until) {
return false return false
} }
// Check Tags // Check Tags
if len(f.Tags) > 0 { if len(f.Tags) > 0 {
if !matchesTags(event.Tags, &f.Tags) { tags := event.Tags()
if !matchesTags(tags, &f.Tags) {
return false return false
} }
} }
@@ -237,7 +305,7 @@ func matchesKinds(candidate int, kinds []int) bool {
return false return false
} }
func matchesTimeRange(timestamp int, since *int, until *int) bool { func matchesTimeRange(timestamp int64, since *int64, until *int64) bool {
if since != nil && timestamp < *since { if since != nil && timestamp < *since {
return false return false
} }
+158 -195
View File
@@ -37,57 +37,57 @@ var marshalTestCases = []FilterMarshalTestCase{
// ID cases // ID cases
{ {
name: "nil IDs", name: "nil IDs",
filter: Filter{IDs: nil}, filter: NewFilter(WithIDs(nil)),
expected: `{}`, expected: `{}`,
}, },
{ {
name: "empty IDs", name: "empty IDs",
filter: Filter{IDs: []string{}}, filter: NewFilter(WithIDs([]string{})),
expected: `{"ids":[]}`, expected: `{"ids":[]}`,
}, },
{ {
name: "populated IDs", name: "populated IDs",
filter: Filter{IDs: []string{"abc", "123"}}, filter: NewFilter(WithIDs([]string{"abc", "123"})),
expected: `{"ids":["abc","123"]}`, expected: `{"ids":["abc","123"]}`,
}, },
// Author cases // Author cases
{ {
name: "nil Authors", name: "nil Authors",
filter: Filter{Authors: nil}, filter: NewFilter(WithAuthors(nil)),
expected: `{}`, expected: `{}`,
}, },
{ {
name: "empty Authors", name: "empty Authors",
filter: Filter{Authors: []string{}}, filter: NewFilter(WithAuthors([]string{})),
expected: `{"authors":[]}`, expected: `{"authors":[]}`,
}, },
{ {
name: "populated Authors", name: "populated Authors",
filter: Filter{Authors: []string{"abc", "123"}}, filter: NewFilter(WithAuthors([]string{"abc", "123"})),
expected: `{"authors":["abc","123"]}`, expected: `{"authors":["abc","123"]}`,
}, },
// Kind cases // Kind cases
{ {
name: "nil Kinds", name: "nil Kinds",
filter: Filter{Kinds: nil}, filter: NewFilter(WithKinds(nil)),
expected: `{}`, expected: `{}`,
}, },
{ {
name: "empty Kinds", name: "empty Kinds",
filter: Filter{Kinds: []int{}}, filter: NewFilter(WithKinds([]int{})),
expected: `{"kinds":[]}`, expected: `{"kinds":[]}`,
}, },
{ {
name: "populated Kinds", name: "populated Kinds",
filter: Filter{Kinds: []int{1, 20001}}, filter: NewFilter(WithKinds([]int{1, 20001})),
expected: `{"kinds":[1,20001]}`, expected: `{"kinds":[1,20001]}`,
}, },
@@ -100,7 +100,7 @@ var marshalTestCases = []FilterMarshalTestCase{
{ {
name: "populated Since", name: "populated Since",
filter: Filter{Since: intPtr(1000)}, filter: NewFilter(WithSince(1000)),
expected: `{"since":1000}`, expected: `{"since":1000}`,
}, },
@@ -113,7 +113,7 @@ var marshalTestCases = []FilterMarshalTestCase{
{ {
name: "populated Until", name: "populated Until",
filter: Filter{Until: intPtr(1000)}, filter: NewFilter(WithUntil(1000)),
expected: `{"until":1000}`, expected: `{"until":1000}`,
}, },
@@ -126,27 +126,31 @@ var marshalTestCases = []FilterMarshalTestCase{
{ {
name: "populated Limit", name: "populated Limit",
filter: Filter{Limit: intPtr(100)}, filter: NewFilter(WithLimit(100)),
expected: `{"limit":100}`, expected: `{"limit":100}`,
}, },
// All standard fields // All standard fields
{ {
name: "all standard fields", name: "all standard fields",
filter: Filter{ filter: NewFilter(
IDs: []string{"abc", "123"}, WithIDs([]string{"abc", "123"}),
Authors: []string{"def", "456"}, WithAuthors([]string{"def", "456"}),
Kinds: []int{1, 200, 3000}, WithKinds([]int{1, 200, 3000}),
Since: intPtr(1000), WithSince(1000),
Until: intPtr(2000), WithUntil(2000),
Limit: intPtr(100), WithLimit(100),
}, ),
expected: `{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}`, expected: `{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}`,
}, },
{ {
name: "mixed fields", name: "mixed fields",
filter: Filter{IDs: nil, Authors: []string{}, Kinds: []int{1}}, filter: NewFilter(
WithIDs(nil),
WithAuthors([]string{}),
WithKinds([]int{1}),
),
expected: `{"authors":[],"kinds":[1]}`, expected: `{"authors":[],"kinds":[1]}`,
}, },
@@ -159,164 +163,138 @@ var marshalTestCases = []FilterMarshalTestCase{
{ {
name: "single-letter tag", name: "single-letter tag",
filter: Filter{Tags: map[string][]string{ filter: NewFilter(
"e": {"event1"}, WithTag("e", []string{"event1"}),
}}, ),
expected: `{"#e":["event1"]}`, expected: `{"#e":["event1"]}`,
}, },
{ {
name: "multi-letter tag", name: "multi-letter tag",
filter: Filter{Tags: map[string][]string{ filter: NewFilter(
"emoji": {"🔥", "💧"}, WithTag("emoji", []string{"🔥", "💧"}),
}}, ),
expected: `{"#emoji":["🔥","💧"]}`, expected: `{"#emoji":["🔥","💧"]}`,
}, },
{ {
name: "empty tag array", name: "empty tag array",
filter: Filter{Tags: map[string][]string{ filter: NewFilter(
"p": {}, WithTag("p", []string{}),
}}, ),
expected: `{"#p":[]}`, expected: `{"#p":[]}`,
}, },
{ {
name: "multiple tags", name: "multiple tags",
filter: Filter{Tags: map[string][]string{ filter: NewFilter(
"e": {"event1", "event2"}, WithTag("e", []string{"event1", "event2"}),
"p": {"pubkey1", "pubkey2"}, WithTag("p", []string{"pubkey1", "pubkey2"}),
}}, ),
expected: `{"#e":["event1","event2"],"#p":["pubkey1","pubkey2"]}`, expected: `{"#e":["event1","event2"],"#p":["pubkey1","pubkey2"]}`,
}, },
// Extensions // Extensions
{ {
name: "simple extension", name: "simple extension",
filter: Filter{ filter: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("search", json.RawMessage(`"query"`)),
"search": json.RawMessage(`"query"`), ),
},
},
expected: `{"search":"query"}`, expected: `{"search":"query"}`,
}, },
{ {
name: "extension with nested object", name: "extension with nested object",
filter: Filter{ filter: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("meta", json.RawMessage(`{"author":"alice","score":99}`)),
"meta": json.RawMessage(`{"author":"alice","score":99}`), ),
},
},
expected: `{"meta":{"author":"alice","score":99}}`, expected: `{"meta":{"author":"alice","score":99}}`,
}, },
{ {
name: "extension with nested array", name: "extension with nested array",
filter: Filter{ filter: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("items", json.RawMessage(`[1,2,3]`)),
"items": json.RawMessage(`[1,2,3]`), ),
},
},
expected: `{"items":[1,2,3]}`, expected: `{"items":[1,2,3]}`,
}, },
{ {
name: "extension with complex nested structure", name: "extension with complex nested structure",
filter: Filter{ filter: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("data", json.RawMessage(`{"users":[{"id":1}],"count":5}`)),
"data": json.RawMessage(`{"users":[{"id":1}],"count":5}`), ),
},
},
expected: `{"data":{"users":[{"id":1}],"count":5}}`, expected: `{"data":{"users":[{"id":1}],"count":5}}`,
}, },
{ {
name: "multiple extensions", name: "multiple extensions",
filter: Filter{ filter: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("search", json.RawMessage(`"x"`)),
"search": json.RawMessage(`"x"`), WithExtension("depth", json.RawMessage(`3`)),
"depth": json.RawMessage(`3`), ),
},
},
expected: `{"search":"x","depth":3}`, expected: `{"search":"x","depth":3}`,
}, },
// Extension Collisions // Extension Collisions
{ {
name: "extension collides with standard field - IDs", name: "extension collides with standard field - IDs",
filter: Filter{ filter: NewFilter(
IDs: []string{"real"}, WithIDs([]string{"real"}),
Extensions: map[string]json.RawMessage{ WithExtension("ids", json.RawMessage(`["fake"]`)),
"ids": json.RawMessage(`["fake"]`), ),
},
},
expected: `{"ids":["real"]}`, expected: `{"ids":["real"]}`,
}, },
{ {
name: "extension collides with standard field - Since", name: "extension collides with standard field - Since",
filter: Filter{ filter: NewFilter(
Since: intPtr(100), WithSince(100),
Extensions: map[string]json.RawMessage{ WithExtension("since", json.RawMessage(`999`)),
"since": json.RawMessage(`999`), ),
},
},
expected: `{"since":100}`, expected: `{"since":100}`,
}, },
{ {
name: "extension collides with multiple standard fields", name: "extension collides with multiple standard fields",
filter: Filter{ filter: NewFilter(
Authors: []string{"a"}, WithAuthors([]string{"a"}),
Kinds: []int{1}, WithKinds([]int{1}),
Extensions: map[string]json.RawMessage{ WithExtension("authors", json.RawMessage(`["b"]`)),
"authors": json.RawMessage(`["b"]`), WithExtension("kinds", json.RawMessage(`[2]`)),
"kinds": json.RawMessage(`[2]`), ),
},
},
expected: `{"authors":["a"],"kinds":[1]}`, expected: `{"authors":["a"],"kinds":[1]}`,
}, },
{ {
name: "extension collides with tag field - #e", name: "extension collides with tag field - #e",
filter: Filter{ filter: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("#e", json.RawMessage(`["fakeevent"]`)),
"#e": json.RawMessage(`["fakeevent"]`), ),
},
},
expected: `{}`, expected: `{}`,
}, },
{ {
name: "extension collides with standard and tag fields", name: "extension collides with standard and tag fields",
filter: Filter{ filter: NewFilter(
Authors: []string{"realauthor"}, WithAuthors([]string{"realauthor"}),
Tags: map[string][]string{ WithTag("e", []string{"realevent"}),
"e": {"realevent"}, WithExtension("authors", json.RawMessage(`["fakeauthor"]`)),
}, WithExtension("#e", json.RawMessage(`["fakeevent"]`)),
Extensions: map[string]json.RawMessage{ ),
"authors": json.RawMessage(`["fakeauthor"]`),
"#e": json.RawMessage(`["fakeevent"]`),
},
},
expected: `{"authors":["realauthor"],"#e":["realevent"]}`, expected: `{"authors":["realauthor"],"#e":["realevent"]}`,
}, },
// Kitchen Sink // Kitchen Sink
{ {
name: "filter with all field types", name: "filter with all field types",
filter: Filter{ filter: NewFilter(
IDs: []string{"x"}, WithIDs([]string{"x"}),
Since: intPtr(100), WithSince(100),
Tags: map[string][]string{ WithTag("e", []string{"y"}),
"e": {"y"}, WithExtension("search", json.RawMessage(`"z"`)),
}, WithExtension("ids", json.RawMessage(`["fakeid"]`)),
Extensions: map[string]json.RawMessage{ ),
"search": json.RawMessage(`"z"`),
"ids": json.RawMessage(`["fakeid"]`),
},
},
expected: `{"ids":["x"],"since":100,"#e":["y"],"search":"z"}`, expected: `{"ids":["x"],"since":100,"#e":["y"],"search":"z"}`,
}, },
} }
@@ -325,64 +303,64 @@ var unmarshalTestCases = []FilterUnmarshalTestCase{
{ {
name: "empty object", name: "empty object",
input: `{}`, input: `{}`,
expected: Filter{}, expected: NewFilter(),
}, },
// ID cases // ID cases
{ {
name: "null IDs", name: "null IDs",
input: `{"ids": null}`, input: `{"ids": null}`,
expected: Filter{IDs: nil}, expected: NewFilter(WithIDs(nil)),
}, },
{ {
name: "empty IDs", name: "empty IDs",
input: `{"ids": []}`, input: `{"ids": []}`,
expected: Filter{IDs: []string{}}, expected: NewFilter(WithIDs([]string{})),
}, },
{ {
name: "populated IDs", name: "populated IDs",
input: `{"ids": ["abc","123"]}`, input: `{"ids": ["abc","123"]}`,
expected: Filter{IDs: []string{"abc", "123"}}, expected: NewFilter(WithIDs([]string{"abc", "123"})),
}, },
// Author cases // Author cases
{ {
name: "null Authors", name: "null Authors",
input: `{"authors": null}`, input: `{"authors": null}`,
expected: Filter{Authors: nil}, expected: NewFilter(WithAuthors(nil)),
}, },
{ {
name: "empty Authors", name: "empty Authors",
input: `{"authors": []}`, input: `{"authors": []}`,
expected: Filter{Authors: []string{}}, expected: NewFilter(WithAuthors([]string{})),
}, },
{ {
name: "populated Authors", name: "populated Authors",
input: `{"authors": ["abc","123"]}`, input: `{"authors": ["abc","123"]}`,
expected: Filter{Authors: []string{"abc", "123"}}, expected: NewFilter(WithAuthors([]string{"abc", "123"})),
}, },
// Kind cases // Kind cases
{ {
name: "null Kinds", name: "null Kinds",
input: `{"kinds": null}`, input: `{"kinds": null}`,
expected: Filter{Kinds: nil}, expected: NewFilter(WithKinds(nil)),
}, },
{ {
name: "empty Kinds", name: "empty Kinds",
input: `{"kinds": []}`, input: `{"kinds": []}`,
expected: Filter{Kinds: []int{}}, expected: NewFilter(WithKinds([]int{})),
}, },
{ {
name: "populated Kinds", name: "populated Kinds",
input: `{"kinds": [1,2,3]}`, input: `{"kinds": [1,2,3]}`,
expected: Filter{Kinds: []int{1, 2, 3}}, expected: NewFilter(WithKinds([]int{1, 2, 3})),
}, },
// Since cases // Since cases
@@ -395,7 +373,7 @@ var unmarshalTestCases = []FilterUnmarshalTestCase{
{ {
name: "populated Since", name: "populated Since",
input: `{"since": 1000}`, input: `{"since": 1000}`,
expected: Filter{Since: intPtr(1000)}, expected: NewFilter(WithSince(1000)),
}, },
// Until cases // Until cases
@@ -408,7 +386,7 @@ var unmarshalTestCases = []FilterUnmarshalTestCase{
{ {
name: "populated Until", name: "populated Until",
input: `{"until": 1000}`, input: `{"until": 1000}`,
expected: Filter{Until: intPtr(1000)}, expected: NewFilter(WithUntil(1000)),
}, },
// Limit cases // Limit cases
@@ -421,161 +399,146 @@ var unmarshalTestCases = []FilterUnmarshalTestCase{
{ {
name: "populated Limit", name: "populated Limit",
input: `{"limit": 1000}`, input: `{"limit": 1000}`,
expected: Filter{Limit: intPtr(1000)}, expected: NewFilter(WithLimit(1000)),
}, },
// All standard fields // All standard fields
{ {
name: "all standard fields", name: "all standard fields",
input: `{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}`, input: `{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}`,
expected: Filter{ expected: NewFilter(
IDs: []string{"abc", "123"}, WithIDs([]string{"abc", "123"}),
Authors: []string{"def", "456"}, WithAuthors([]string{"def", "456"}),
Kinds: []int{1, 200, 3000}, WithKinds([]int{1, 200, 3000}),
Since: intPtr(1000), WithSince(1000),
Until: intPtr(2000), WithUntil(2000),
Limit: intPtr(100), WithLimit(100),
}, ),
}, },
{ {
name: "mixed fields", name: "mixed fields",
input: `{"ids": null, "authors": [], "kinds": [1]}`, input: `{"ids": null, "authors": [], "kinds": [1]}`,
expected: Filter{IDs: nil, Authors: []string{}, Kinds: []int{1}}, expected: NewFilter(
WithIDs(nil),
WithAuthors([]string{}),
WithKinds([]int{1}),
),
}, },
{ {
name: "zero int pointers", name: "zero int pointers",
input: `{"since": 0, "until": 0, "limit": 0}`, input: `{"since": 0, "until": 0, "limit": 0}`,
expected: Filter{Since: intPtr(0), Until: intPtr(0), Limit: intPtr(0)}, expected: NewFilter(WithSince(0), WithUntil(0), WithLimit(0)),
}, },
// Tags // Tags
{ {
name: "single-letter tag", name: "single-letter tag",
input: `{"#e":["event1"]}`, input: `{"#e":["event1"]}`,
expected: Filter{Tags: map[string][]string{"e": {"event1"}}}, expected: NewFilter(WithTag("e", []string{"event1"})),
}, },
{ {
name: "multi-letter tag", name: "multi-letter tag",
input: `{"#emoji":["🔥","💧"]}`, input: `{"#emoji":["🔥","💧"]}`,
expected: Filter{Tags: map[string][]string{"emoji": {"🔥", "💧"}}}, expected: NewFilter(WithTag("emoji", []string{"🔥", "💧"})),
}, },
{ {
name: "empty tag array", name: "empty tag array",
input: `{"#p":[]}`, input: `{"#p":[]}`,
expected: Filter{Tags: map[string][]string{"p": {}}}, expected: NewFilter(WithTag("p", []string{})),
}, },
{ {
name: "multiple tags", name: "multiple tags",
input: `{"#p":["pubkey1","pubkey2"],"#e":["event1","event2"]}`, input: `{"#p":["pubkey1","pubkey2"],"#e":["event1","event2"]}`,
expected: Filter{Tags: map[string][]string{ expected: NewFilter(
"p": {"pubkey1", "pubkey2"}, WithTag("p", []string{"pubkey1", "pubkey2"}),
"e": {"event1", "event2"}, WithTag("e", []string{"event1", "event2"}),
}}, ),
}, },
{ {
name: "null tag", name: "null tag",
input: `{"#p":null}`, input: `{"#p":null}`,
expected: Filter{Tags: map[string][]string{"p": nil}}, expected: NewFilter(WithTag("p", nil)),
}, },
// Extensions // Extensions
{ {
name: "simple extension", name: "simple extension",
input: `{"search":"query"}`, input: `{"search":"query"}`,
expected: Filter{Extensions: map[string]json.RawMessage{ expected: NewFilter(
"search": json.RawMessage(`"query"`), WithExtension("search", json.RawMessage(`"query"`)),
}, ),
},
}, },
{ {
name: "extension with nested object", name: "extension with nested object",
input: `{"meta":{"author":"alice","score":99}}`, input: `{"meta":{"author":"alice","score":99}}`,
expected: Filter{ expected: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("meta", json.RawMessage(`{"author":"alice","score":99}`)),
"meta": json.RawMessage(`{"author":"alice","score":99}`), ),
},
},
}, },
{ {
name: "extension with nested array", name: "extension with nested array",
input: `{"items":[1,2,3]}`, input: `{"items":[1,2,3]}`,
expected: Filter{ expected: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("items", json.RawMessage(`[1,2,3]`)),
"items": json.RawMessage(`[1,2,3]`), ),
},
},
}, },
{ {
name: "extension with complex nested structure", name: "extension with complex nested structure",
input: `{"data":{"level1":{"level2":[{"id":1}]}}}`, input: `{"data":{"level1":{"level2":[{"id":1}]}}}`,
expected: Filter{ expected: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("data", json.RawMessage(`{"level1":{"level2":[{"id":1}]}}`)),
"data": json.RawMessage(`{"level1":{"level2":[{"id":1}]}}`), ),
},
},
}, },
{ {
name: "multiple extensions", name: "multiple extensions",
input: `{"search":"x","custom":true,"depth":3}`, input: `{"search":"x","custom":true,"depth":3}`,
expected: Filter{ expected: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("search", json.RawMessage(`"x"`)),
"search": json.RawMessage(`"x"`), WithExtension("custom", json.RawMessage(`true`)),
"custom": json.RawMessage(`true`), WithExtension("depth", json.RawMessage(`3`)),
"depth": json.RawMessage(`3`), ),
},
},
}, },
{ {
name: "extension with null value", name: "extension with null value",
input: `{"optional":null}`, input: `{"optional":null}`,
expected: Filter{ expected: NewFilter(
Extensions: map[string]json.RawMessage{ WithExtension("optional", json.RawMessage(`null`)),
"optional": json.RawMessage(`null`), ),
},
},
}, },
// Kitchen Sink // Kitchen Sink
{ {
name: "extension with null value", name: "extension with null value",
input: `{"ids":["x"],"since":100,"#e":["y"],"search":"z"}`, input: `{"ids":["x"],"since":100,"#e":["y"],"search":"z"}`,
expected: Filter{ expected: NewFilter(
IDs: []string{"x"}, WithIDs([]string{"x"}),
Since: intPtr(100), WithSince(100),
Tags: map[string][]string{ WithTag("e", []string{"y"}),
"e": {"y"}, WithExtension("search", json.RawMessage(`"z"`)),
}, ),
Extensions: map[string]json.RawMessage{
"search": json.RawMessage(`"z"`),
},
},
}, },
} }
var roundTripTestCases = []FilterRoundTripTestCase{ var roundTripTestCases = []FilterRoundTripTestCase{
{ {
name: "fully populated filter", name: "fully populated filter",
filter: Filter{ filter: NewFilter(
IDs: []string{"x"}, WithIDs([]string{"x"}),
Since: intPtr(100), WithSince(100),
Tags: map[string][]string{ WithTag("e", []string{"y"}),
"e": {"y"}, WithExtension("search", json.RawMessage(`"z"`)),
}, ),
Extensions: map[string]json.RawMessage{
"search": json.RawMessage(`"z"`),
},
},
}, },
} }
@@ -584,7 +547,7 @@ var roundTripTestCases = []FilterRoundTripTestCase{
func TestFilterMarshalJSON(t *testing.T) { func TestFilterMarshalJSON(t *testing.T) {
for _, tc := range marshalTestCases { for _, tc := range marshalTestCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
result, err := MarshalJSON(tc.filter) result, err := json.Marshal(tc.filter)
assert.NoError(t, err) assert.NoError(t, err)
var expectedMap, actualMap map[string]interface{} var expectedMap, actualMap map[string]interface{}
@@ -602,7 +565,7 @@ func TestFilterUnmarshalJSON(t *testing.T) {
for _, tc := range unmarshalTestCases { for _, tc := range unmarshalTestCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
var result Filter var result Filter
err := UnmarshalJSON([]byte(tc.input), &result) err := json.Unmarshal([]byte(tc.input), &result)
assert.NoError(t, err) assert.NoError(t, err)
expectEqualFilters(t, result, tc.expected) expectEqualFilters(t, result, tc.expected)
@@ -613,11 +576,11 @@ func TestFilterUnmarshalJSON(t *testing.T) {
func TestFilterRoundTrip(t *testing.T) { func TestFilterRoundTrip(t *testing.T) {
for _, tc := range roundTripTestCases { for _, tc := range roundTripTestCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
jsonBytes, err := MarshalJSON(tc.filter) jsonBytes, err := json.Marshal(tc.filter)
assert.NoError(t, err) assert.NoError(t, err)
var result Filter var result Filter
err = UnmarshalJSON(jsonBytes, &result) err = json.Unmarshal(jsonBytes, &result)
assert.NoError(t, err) assert.NoError(t, err)
expectEqualFilters(t, result, tc.filter) expectEqualFilters(t, result, tc.filter)
+113 -142
View File
@@ -8,20 +8,28 @@ import (
"testing" "testing"
) )
var testEvents []events.Event var testEvents []events.ValidatedEvent
func init() { func init() {
data, err := os.ReadFile("testdata/test_events.json") data, err := os.ReadFile("testdata/test_events.json")
if err != nil { if err != nil {
panic(err) panic(err)
} }
if err := json.Unmarshal(data, &testEvents); err != nil { var raw []events.Event
if err := json.Unmarshal(data, &raw); err != nil {
panic(err) panic(err)
} }
for _, e := range raw {
ve, err := events.NewValidatedEvent(e)
if err != nil {
panic(err)
}
testEvents = append(testEvents, ve)
}
} }
// Test keypairs corresponding to test events, for reference. // Test keypairs corresponding to test events, for reference.
var ( const (
nayru_sk = "1784be782585dfa97712afe12585d13ee608b624cf564116fa143c31a124d31e" nayru_sk = "1784be782585dfa97712afe12585d13ee608b624cf564116fa143c31a124d31e"
nayru_pk = "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe" nayru_pk = "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe"
farore_sk = "03d0611c41048a9108a75bf5d023180b5cf2d2d24e2e6b83def29de977315bb3" farore_sk = "03d0611c41048a9108a75bf5d023180b5cf2d2d24e2e6b83def29de977315bb3"
@@ -39,10 +47,10 @@ type FilterTestCase struct {
var filterTestCases = []FilterTestCase{ var filterTestCases = []FilterTestCase{
{ {
name: "empty filter", name: "empty filter",
filter: Filter{}, filter: NewFilter(),
expectedIDs: []string{ expectedIDs: []string{
"e751d41f", "e751d41f",
"562bc378", "2e06c187",
"e67fa7b8", "e67fa7b8",
"5e4c64f1", "5e4c64f1",
"7a5d83d4", "7a5d83d4",
@@ -55,10 +63,10 @@ var filterTestCases = []FilterTestCase{
{ {
name: "empty id", name: "empty id",
filter: Filter{IDs: []string{}}, filter: NewFilter(WithIDs([]string{})),
expectedIDs: []string{ expectedIDs: []string{
"e751d41f", "e751d41f",
"562bc378", "2e06c187",
"e67fa7b8", "e67fa7b8",
"5e4c64f1", "5e4c64f1",
"7a5d83d4", "7a5d83d4",
@@ -71,34 +79,37 @@ var filterTestCases = []FilterTestCase{
{ {
name: "single id prefix", name: "single id prefix",
filter: Filter{IDs: []string{"e751d41f"}}, filter: NewFilter(WithIDs([]string{"e751d41f"})),
expectedIDs: []string{"e751d41f"}, expectedIDs: []string{"e751d41f"},
}, },
{ {
name: "single full id", name: "single full id",
filter: Filter{IDs: []string{"e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b"}}, filter: NewFilter(
WithIDs([]string{
"e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b"}),
),
expectedIDs: []string{"e67fa7b8"}, expectedIDs: []string{"e67fa7b8"},
}, },
{ {
name: "multiple id prefixes", name: "multiple id prefixes",
filter: Filter{IDs: []string{"562bc378", "5e4c64f1"}}, filter: NewFilter(WithIDs([]string{"2e06c187", "5e4c64f1"})),
expectedIDs: []string{"562bc378", "5e4c64f1"}, expectedIDs: []string{"2e06c187", "5e4c64f1"},
}, },
{ {
name: "no id match", name: "no id match",
filter: Filter{IDs: []string{"ffff"}}, filter: NewFilter(WithIDs([]string{"ffff"})),
expectedIDs: []string{}, expectedIDs: []string{},
}, },
{ {
name: "empty author", name: "empty author",
filter: Filter{Authors: []string{}}, filter: NewFilter(WithAuthors([]string{})),
expectedIDs: []string{ expectedIDs: []string{
"e751d41f", "e751d41f",
"562bc378", "2e06c187",
"e67fa7b8", "e67fa7b8",
"5e4c64f1", "5e4c64f1",
"7a5d83d4", "7a5d83d4",
@@ -111,16 +122,16 @@ var filterTestCases = []FilterTestCase{
{ {
name: "single author prefix", name: "single author prefix",
filter: Filter{Authors: []string{"d877e187"}}, filter: NewFilter(WithAuthors([]string{"d877e187"})),
expectedIDs: []string{"e751d41f", "562bc378", "e67fa7b8"}, expectedIDs: []string{"e751d41f", "2e06c187", "e67fa7b8"},
}, },
{ {
name: "multiple author prefixex", name: "multiple author prefixex",
filter: Filter{Authors: []string{"d877e187", "9e4b726a"}}, filter: NewFilter(WithAuthors([]string{"d877e187", "9e4b726a"})),
expectedIDs: []string{ expectedIDs: []string{
"e751d41f", "e751d41f",
"562bc378", "2e06c187",
"e67fa7b8", "e67fa7b8",
"5e4c64f1", "5e4c64f1",
"7a5d83d4", "7a5d83d4",
@@ -129,23 +140,26 @@ var filterTestCases = []FilterTestCase{
}, },
{ {
name: "single author full", name: "single author full",
filter: Filter{Authors: []string{"d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe"}}, filter: NewFilter(
expectedIDs: []string{"e751d41f", "562bc378", "e67fa7b8"}, WithAuthors([]string{
"d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe"}),
),
expectedIDs: []string{"e751d41f", "2e06c187", "e67fa7b8"},
}, },
{ {
name: "no author match", name: "no author match",
filter: Filter{Authors: []string{"ffff"}}, filter: NewFilter(WithAuthors([]string{"ffff"})),
expectedIDs: []string{}, expectedIDs: []string{},
}, },
{ {
name: "empty kind", name: "empty kind",
filter: Filter{Kinds: []int{}}, filter: NewFilter(WithKinds([]int{})),
expectedIDs: []string{ expectedIDs: []string{
"e751d41f", "e751d41f",
"562bc378", "2e06c187",
"e67fa7b8", "e67fa7b8",
"5e4c64f1", "5e4c64f1",
"7a5d83d4", "7a5d83d4",
@@ -158,13 +172,13 @@ var filterTestCases = []FilterTestCase{
{ {
name: "single kind", name: "single kind",
filter: Filter{Kinds: []int{1}}, filter: NewFilter(WithKinds([]int{1})),
expectedIDs: []string{"562bc378", "7a5d83d4", "4b03b69a"}, expectedIDs: []string{"2e06c187", "7a5d83d4", "4b03b69a"},
}, },
{ {
name: "multiple kinds", name: "multiple kinds",
filter: Filter{Kinds: []int{0, 2}}, filter: NewFilter(WithKinds([]int{0, 2})),
expectedIDs: []string{ expectedIDs: []string{
"e751d41f", "e751d41f",
"e67fa7b8", "e67fa7b8",
@@ -177,13 +191,13 @@ var filterTestCases = []FilterTestCase{
{ {
name: "no kind match", name: "no kind match",
filter: Filter{Kinds: []int{99}}, filter: NewFilter(WithKinds([]int{99})),
expectedIDs: []string{}, expectedIDs: []string{},
}, },
{ {
name: "since only", name: "since only",
filter: Filter{Since: intPtr(5000)}, filter: NewFilter(WithSince(5000)),
expectedIDs: []string{ expectedIDs: []string{
"7a5d83d4", "7a5d83d4",
"3a122100", "3a122100",
@@ -195,20 +209,17 @@ var filterTestCases = []FilterTestCase{
{ {
name: "until only", name: "until only",
filter: Filter{Until: intPtr(3000)}, filter: NewFilter(WithUntil(3000)),
expectedIDs: []string{ expectedIDs: []string{
"e751d41f", "e751d41f",
"562bc378", "2e06c187",
"e67fa7b8", "e67fa7b8",
}, },
}, },
{ {
name: "time range", name: "time range",
filter: Filter{ filter: NewFilter(WithSince(4000), WithUntil(6000)),
Since: intPtr(4000),
Until: intPtr(6000),
},
expectedIDs: []string{ expectedIDs: []string{
"5e4c64f1", "5e4c64f1",
"7a5d83d4", "7a5d83d4",
@@ -217,23 +228,17 @@ var filterTestCases = []FilterTestCase{
}, },
{ {
name: "outside time range", name: "outside time range",
filter: Filter{ filter: NewFilter(WithSince(10000)),
Since: intPtr(10000),
},
expectedIDs: []string{}, expectedIDs: []string{},
}, },
{ {
name: "empty tag filter", name: "empty tag filter",
filter: Filter{ filter: NewFilter(WithTag("e", []string{})),
Tags: TagFilters{
"e": {},
},
},
expectedIDs: []string{ expectedIDs: []string{
"e751d41f", "e751d41f",
"562bc378", "2e06c187",
"e67fa7b8", "e67fa7b8",
"5e4c64f1", "5e4c64f1",
"7a5d83d4", "7a5d83d4",
@@ -246,110 +251,98 @@ var filterTestCases = []FilterTestCase{
{ {
name: "single letter tag filter: e", name: "single letter tag filter: e",
filter: Filter{ filter: NewFilter(
Tags: TagFilters{ WithTag("e", []string{
"e": {"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}, "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}),
}, ),
}, expectedIDs: []string{"2e06c187"},
expectedIDs: []string{"562bc378"},
}, },
{ {
name: "multiple tag matches", name: "multiple tag matches",
filter: Filter{ filter: NewFilter(
Tags: TagFilters{ WithTag("e", []string{
"e": { "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7",
"ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7", }),
}, ),
}, expectedIDs: []string{"2e06c187", "3a122100"},
},
expectedIDs: []string{"562bc378", "3a122100"},
}, },
{ {
name: "multiple tag matches - single event match", name: "multiple tag matches - single event match",
filter: Filter{ filter: NewFilter(
Tags: TagFilters{ WithTag("e", []string{
"e": { "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "cb7787c460a79187d6a13e75a0f19240e05fafca8ea42288f5765773ea69cf2f",
"cb7787c460a79187d6a13e75a0f19240e05fafca8ea42288f5765773ea69cf2f", }),
}, ),
}, expectedIDs: []string{"2e06c187"},
},
expectedIDs: []string{"562bc378"},
}, },
{ {
name: "single letter tag filter: p", name: "single letter tag filter: p",
filter: Filter{ filter: NewFilter(
Tags: TagFilters{ WithTag("p", []string{
"p": {"91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}, "91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}),
}, ),
},
expectedIDs: []string{"e67fa7b8"}, expectedIDs: []string{"e67fa7b8"},
}, },
{ {
name: "multi letter tag filter", name: "multi letter tag filter",
filter: Filter{ filter: NewFilter(
Tags: TagFilters{ WithTag("emoji", []string{"🌊"}),
"emoji": {"🌊"}, ),
},
},
expectedIDs: []string{"e67fa7b8"}, expectedIDs: []string{"e67fa7b8"},
}, },
{ {
name: "multiple tag filters", name: "multiple tag filters",
filter: Filter{ filter: NewFilter(
Tags: TagFilters{ WithTag("e", []string{
"e": {"ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7"}, "ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7"}),
"p": {"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"}, WithTag("p", []string{
}, "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"}),
}, ),
expectedIDs: []string{"3a122100"}, expectedIDs: []string{"3a122100"},
}, },
{ {
name: "prefix tag filter", name: "prefix tag filter",
filter: Filter{ filter: NewFilter(
Tags: TagFilters{ WithTag("p", []string{"ae3f2a91"}),
"p": {"ae3f2a91"}, ),
},
},
expectedIDs: []string{}, expectedIDs: []string{},
}, },
{ {
name: "unknown tag filter", name: "unknown tag filter",
filter: Filter{ filter: NewFilter(
Tags: TagFilters{ WithTag("z", []string{"anything"}),
"z": {"anything"}, ),
},
},
expectedIDs: []string{}, expectedIDs: []string{},
}, },
{ {
name: "combined author+kind tag filter", name: "combined author+kind tag filter",
filter: Filter{ filter: NewFilter(
Authors: []string{"d877e187"}, WithAuthors([]string{"d877e187"}),
Kinds: []int{1, 2}, WithKinds([]int{1, 2}),
}, ),
expectedIDs: []string{ expectedIDs: []string{
"562bc378", "2e06c187",
"e67fa7b8", "e67fa7b8",
}, },
}, },
{ {
name: "combined kind+time range tag filter", name: "combined kind+time range tag filter",
filter: Filter{ filter: NewFilter(
Kinds: []int{0}, WithKinds([]int{0}),
Since: intPtr(2000), WithSince(2000),
Until: intPtr(7000), WithUntil(7000),
}, ),
expectedIDs: []string{ expectedIDs: []string{
"5e4c64f1", "5e4c64f1",
"4a15d963", "4a15d963",
@@ -358,12 +351,10 @@ var filterTestCases = []FilterTestCase{
{ {
name: "combined author+tag tag filter", name: "combined author+tag tag filter",
filter: Filter{ filter: NewFilter(
Authors: []string{"e719e8f8"}, WithAuthors([]string{"e719e8f8"}),
Tags: TagFilters{ WithTag("power", []string{"fire"}),
"power": {"fire"}, ),
},
},
expectedIDs: []string{ expectedIDs: []string{
"4a15d963", "4a15d963",
}, },
@@ -371,15 +362,13 @@ var filterTestCases = []FilterTestCase{
{ {
name: "combined tag filter", name: "combined tag filter",
filter: Filter{ filter: NewFilter(
Authors: []string{"e719e8f8"}, WithAuthors([]string{"e719e8f8"}),
Kinds: []int{0}, WithKinds([]int{0}),
Since: intPtr(5000), WithSince(5000),
Until: intPtr(10000), WithUntil(10000),
Tags: TagFilters{ WithTag("power", []string{"fire"}),
"power": {"fire"}, ),
},
},
expectedIDs: []string{ expectedIDs: []string{
"4a15d963", "4a15d963",
}, },
@@ -392,7 +381,7 @@ func TestEventFilterMatching(t *testing.T) {
actualIDs := []string{} actualIDs := []string{}
for _, event := range testEvents { for _, event := range testEvents {
if Matches(tc.filter, event) { if Matches(tc.filter, event) {
actualIDs = append(actualIDs, event.ID[:8]) actualIDs = append(actualIDs, event.ID()[:8])
} }
} }
@@ -400,21 +389,3 @@ func TestEventFilterMatching(t *testing.T) {
}) })
} }
} }
// TestEventFilterMatchingSkipMalformedTags documents that filter.Matches()
// skips malformed tags during tag matching
func TestEventFilterMatchingSkipMalformedTags(t *testing.T) {
event := events.Event{
Tags: []events.Tag{
{"malformed"},
{"valid", "value"},
},
}
filter := Filter{
Tags: TagFilters{
"valid": {"value"},
},
}
assert.True(t, Matches(filter, event))
}
+15 -15
View File
@@ -1,18 +1,18 @@
[ [
{ {
"kind": 0,
"id": "e751d41fa31e3a115634b41fb587cbd8270d10333a6d5330b1de24737448de70", "id": "e751d41fa31e3a115634b41fb587cbd8270d10333a6d5330b1de24737448de70",
"pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe", "pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe",
"created_at": 1000, "created_at": 1000,
"tags": [], "kind": 0,
"tags": null,
"content": "Nayru profile", "content": "Nayru profile",
"sig": "b3ba1ef2b4143e8c2fabc66bfd26839d6f3a14b5f8d24a8b96ce9c1aa41a53536444be61ed3e502cbeb04d34f8b893c84fa40bac408878c57ee4054d629c1452" "sig": "b3ba1ef2b4143e8c2fabc66bfd26839d6f3a14b5f8d24a8b96ce9c1aa41a53536444be61ed3e502cbeb04d34f8b893c84fa40bac408878c57ee4054d629c1452"
}, },
{ {
"kind": 1, "id": "2e06c18793abeb368ffadecce7de8a3c5a50857907c91dc8b9f6b5f2f7b44a28",
"id": "562bc378fc1a254b053b0cc1b8d61afec8e931ba79f0110ba9dd617496260758",
"pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe", "pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe",
"created_at": 2000, "created_at": 2000,
"kind": 1,
"tags": [ "tags": [
[ [
"e", "e",
@@ -24,13 +24,13 @@
] ]
], ],
"content": "Hello from Nayru", "content": "Hello from Nayru",
"sig": "18e48bf6be4e4104f95bfe90bd61e33c3d8cc5bf3e776ba8182fafe3f84b2e4ef6ce10256865cce556016e1b14ebad3079d3d0a3afcb0f690f12fa01e8f64201" "sig": "5d90570e0dbf3cd8a9c44c5b25b03cf0005d9d3d79d17b43de39144343858b1a21cef22f07ac95ba9770ec39a87f428d860521ee9b7e1b33b973947382bed5bb"
}, },
{ {
"kind": 2,
"id": "e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b", "id": "e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b",
"pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe", "pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe",
"created_at": 3000, "created_at": 3000,
"kind": 2,
"tags": [ "tags": [
[ [
"emoji", "emoji",
@@ -45,10 +45,10 @@
"sig": "00e2c74374670b7623b793ddf4e9903ace17be621bbad74b808232eec1473271fa3e3d5e4ad01100f6c48bf36baa4e4dbaa012cd5ff060b644caac4e9a9c6b1e" "sig": "00e2c74374670b7623b793ddf4e9903ace17be621bbad74b808232eec1473271fa3e3d5e4ad01100f6c48bf36baa4e4dbaa012cd5ff060b644caac4e9a9c6b1e"
}, },
{ {
"kind": 0,
"id": "5e4c64f15a1ad510409e5cb3dc519dcde5416fbb8621bf65559f6b98f729a0d4", "id": "5e4c64f15a1ad510409e5cb3dc519dcde5416fbb8621bf65559f6b98f729a0d4",
"pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9", "pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9",
"created_at": 4000, "created_at": 4000,
"kind": 0,
"tags": [ "tags": [
[ [
"website", "website",
@@ -59,19 +59,19 @@
"sig": "6998b03fba4787ca6a44c4042143592bb9670ded905c06c1b258a7c1630666d7b033b7f5586f7a64ed92e912b555193112e8a590326f38809c46fe104907823e" "sig": "6998b03fba4787ca6a44c4042143592bb9670ded905c06c1b258a7c1630666d7b033b7f5586f7a64ed92e912b555193112e8a590326f38809c46fe104907823e"
}, },
{ {
"kind": 1,
"id": "7a5d83d475576963f81e21d67208d6cf90c42b6a0c3a642c100a3571c5c96b68", "id": "7a5d83d475576963f81e21d67208d6cf90c42b6a0c3a642c100a3571c5c96b68",
"pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9", "pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9",
"created_at": 5000, "created_at": 5000,
"tags": [], "kind": 1,
"tags": null,
"content": "Farore's message", "content": "Farore's message",
"sig": "e9f4986264c7eb7800b7a7d0e0de2928242cb4e93f8ba099fc1564b893dd7a77d2277dc3e8b67724c3887ccadbf14a656c80a229107eb2b5a44a20a00bc436d6" "sig": "e9f4986264c7eb7800b7a7d0e0de2928242cb4e93f8ba099fc1564b893dd7a77d2277dc3e8b67724c3887ccadbf14a656c80a229107eb2b5a44a20a00bc436d6"
}, },
{ {
"kind": 2,
"id": "3a122100196b065ec6c5e1e75dd5140eeb292ef96d2acd56354eb8c23c47649a", "id": "3a122100196b065ec6c5e1e75dd5140eeb292ef96d2acd56354eb8c23c47649a",
"pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9", "pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9",
"created_at": 6000, "created_at": 6000,
"kind": 2,
"tags": [ "tags": [
[ [
"category", "category",
@@ -91,10 +91,10 @@
"sig": "1f5ccdd14b1313a39b6fabfc85a3535ba4f10ad99067803804c9478d63ef2cf53723fcee7041fcbaad4f846d4500183e92305d59b3e6ccb504ce291ad7f982e2" "sig": "1f5ccdd14b1313a39b6fabfc85a3535ba4f10ad99067803804c9478d63ef2cf53723fcee7041fcbaad4f846d4500183e92305d59b3e6ccb504ce291ad7f982e2"
}, },
{ {
"kind": 0,
"id": "4a15d963de8d26e8c4377e17fcf6daec499c454338951716a7d14cae1f7be835", "id": "4a15d963de8d26e8c4377e17fcf6daec499c454338951716a7d14cae1f7be835",
"pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7", "pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7",
"created_at": 7000, "created_at": 7000,
"kind": 0,
"tags": [ "tags": [
[ [
"location", "location",
@@ -109,10 +109,10 @@
"sig": "5a731404105aee9a04bd4d05024cb994a8d500edfceaeb83773438a70d376e6bb638e82e70380558f66aa078ab01f5c4ca86d8c37d291aafb7e33da053c856a9" "sig": "5a731404105aee9a04bd4d05024cb994a8d500edfceaeb83773438a70d376e6bb638e82e70380558f66aa078ab01f5c4ca86d8c37d291aafb7e33da053c856a9"
}, },
{ {
"kind": 1,
"id": "4b03b69a7e89796e1021ad3b7f914e6868a6e900b5e6edfa09d9019a05898ed3", "id": "4b03b69a7e89796e1021ad3b7f914e6868a6e900b5e6edfa09d9019a05898ed3",
"pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7", "pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7",
"created_at": 8000, "created_at": 8000,
"kind": 1,
"tags": [ "tags": [
[ [
"e", "e",
@@ -123,12 +123,12 @@
"sig": "7330fd35e0be4a2a64a940a2841474f60b15d5dec9d4c4129905d97bd91cc8e6a97eec66091580b7351a807b7c250544cf500d0e2d47f5744387b1ce4ac49c4d" "sig": "7330fd35e0be4a2a64a940a2841474f60b15d5dec9d4c4129905d97bd91cc8e6a97eec66091580b7351a807b7c250544cf500d0e2d47f5744387b1ce4ac49c4d"
}, },
{ {
"kind": 2,
"id": "d39e6f3f593bd754a45a6e2f77b1b0669cdfe89c19fb2a4b252ea095caa9874b", "id": "d39e6f3f593bd754a45a6e2f77b1b0669cdfe89c19fb2a4b252ea095caa9874b",
"pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7", "pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7",
"created_at": 9000, "created_at": 9000,
"tags": [], "kind": 2,
"tags": null,
"content": "Final event", "content": "Final event",
"sig": "917d3fa8111cd9dfdc9acad121e7f71e4358d6a4eb0979eadc744b55f78d2647bf839282f6c10afacd64798007c3ef09b8a925c9b73f97c5219098eca1bacc4d" "sig": "917d3fa8111cd9dfdc9acad121e7f71e4358d6a4eb0979eadc744b55f78d2647bf839282f6c10afacd64798007c3ef09b8a925c9b73f97c5219098eca1bacc4d"
} }
] ]
-5
View File
@@ -1,5 +0,0 @@
package filters
func intPtr(i int) *int {
return &i
}
+2
View File
@@ -12,6 +12,8 @@ require (
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+4
View File
@@ -9,6 +9,10 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=