Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a765a2262a | |||
| d699feb236 | |||
| 17789a7dbd | |||
| 0ca44c7e20 | |||
| c6145d6020 | |||
| 12699a1630 | |||
| 60c8e8256b | |||
| 047fc9d9a1 | |||
| 48dde86abd | |||
| 29ba275293 |
@@ -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
|
||||
|
||||
@@ -6,22 +6,18 @@ Mirror: https://github.com/wisehodl/go-roots
|
||||
|
||||
## What this library does
|
||||
|
||||
`go-roots` is a consensus-layer Nostr protocol library for golang.
|
||||
It only provides primitives that define protocol compliance:
|
||||
`go-roots` is a consensus-layer Nostr protocol library for Go.
|
||||
It provides primitives that define protocol compliance:
|
||||
|
||||
- Event Structure
|
||||
- Serialization
|
||||
- Cryptographic Signatures
|
||||
- Subscription Filters
|
||||
- Event structure and serialization
|
||||
- Cryptographic signing and validation
|
||||
- Subscription filters
|
||||
|
||||
## What this library does not do
|
||||
|
||||
`go-roots` serves a foundation for other libraries and applications to
|
||||
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` 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.
|
||||
|
||||
`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
|
||||
|
||||
@@ -31,7 +27,7 @@ mechanisms, and user interfaces.
|
||||
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
|
||||
@@ -39,7 +35,7 @@ replace git.wisehodl.dev/jay/go-roots => github.com/wisehodl/go-roots latest
|
||||
|
||||
2. Import the packages:
|
||||
|
||||
```golang
|
||||
```go
|
||||
import (
|
||||
"git.wisehodl.dev/jay/go-roots/errors"
|
||||
"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
|
||||
|
||||
@@ -68,7 +64,7 @@ if err != nil {
|
||||
}
|
||||
```
|
||||
|
||||
#### Derive public key from existing private key
|
||||
#### Derive public key from an existing private key
|
||||
|
||||
```go
|
||||
privateKey := "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167"
|
||||
@@ -76,132 +72,99 @@ publicKey, err := keys.GetPublicKey(privateKey)
|
||||
// publicKey: "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"
|
||||
```
|
||||
|
||||
---
|
||||
### Flow 1: Creating an event
|
||||
|
||||
### Event Creation and Signing
|
||||
|
||||
#### Create and sign a complete event
|
||||
Build the event with `NewEvent`, compute the ID with `GetID`, sign it with `SignEvent`, then promote to `ValidatedEvent` with `NewValidatedEvent`.
|
||||
|
||||
```go
|
||||
// 1. Build the event structure
|
||||
event := events.Event{
|
||||
PubKey: publicKey,
|
||||
CreatedAt: int(time.Now().Unix()),
|
||||
Kind: 1,
|
||||
Tags: []events.Tag{
|
||||
{"e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"},
|
||||
{"p", "91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"},
|
||||
},
|
||||
Content: "Hello, Nostr!",
|
||||
}
|
||||
// 1. Build the event
|
||||
event := events.NewEvent(
|
||||
events.WithPubKey(publicKey),
|
||||
events.WithCreatedAt(time.Now().Unix()),
|
||||
events.WithKind(1),
|
||||
events.WithTag(events.Tag{"e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}),
|
||||
events.WithTag(events.Tag{"p", "91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}),
|
||||
events.WithContent("Hello, Nostr!"),
|
||||
)
|
||||
|
||||
// 2. Compute the event ID
|
||||
id := events.GetID(event)
|
||||
event.ID = id
|
||||
// 2. Compute and assign the ID
|
||||
event.ID = events.GetID(event)
|
||||
|
||||
// 3. Sign the event
|
||||
sig, err := events.SignEvent(id, privateKey)
|
||||
sig, err := events.SignEvent(event.ID, privateKey)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
event.Sig = sig
|
||||
```
|
||||
|
||||
#### Serialize an event for ID computation
|
||||
|
||||
```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)
|
||||
// 4. Promote to ValidatedEvent
|
||||
validated, err := events.NewValidatedEvent(event)
|
||||
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
|
||||
var event events.Event
|
||||
err := json.Unmarshal(jsonBytes, &event)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
if err := json.Unmarshal(data, &event); err != nil {
|
||||
log.Printf("Malformed JSON: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate after unmarshaling
|
||||
if err := events.Validate(event); err != nil {
|
||||
log.Printf("Received invalid event: %v", err)
|
||||
validated, err := events.NewValidatedEvent(event)
|
||||
if err != nil {
|
||||
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
|
||||
since := int(time.Now().Add(-24 * time.Hour).Unix())
|
||||
limit := 50
|
||||
|
||||
filter := filters.Filter{
|
||||
IDs: []string{"abc123", "def456"}, // Prefix match
|
||||
Authors: []string{"cfa87f35"}, // Prefix match
|
||||
IDs: []string{"abc123", "def456"}, // prefix match
|
||||
Authors: []string{"cfa87f35"}, // prefix match
|
||||
Kinds: []int{1, 6, 7},
|
||||
Since: &since,
|
||||
Limit: &limit,
|
||||
}
|
||||
```
|
||||
|
||||
#### Filter with tag conditions
|
||||
#### Tag filters
|
||||
|
||||
```go
|
||||
filter := filters.Filter{
|
||||
@@ -213,27 +176,9 @@ filter := filters.Filter{
|
||||
}
|
||||
```
|
||||
|
||||
#### Filter with extensions (custom fields)
|
||||
#### Match events against a filter
|
||||
|
||||
```go
|
||||
// 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
|
||||
`Matches` accepts a `ValidatedEvent`. Unvalidated events cannot be passed to it.
|
||||
|
||||
```go
|
||||
filter := filters.Filter{
|
||||
@@ -241,88 +186,33 @@ filter := filters.Filter{
|
||||
Kinds: []int{1},
|
||||
}
|
||||
|
||||
if filters.Matches(filter, event) {
|
||||
// Event satisfies all filter conditions
|
||||
}
|
||||
```
|
||||
|
||||
#### 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 {
|
||||
var matched []events.ValidatedEvent
|
||||
for _, event := range incoming {
|
||||
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
|
||||
filter := filters.Filter{
|
||||
IDs: []string{"abc123"},
|
||||
Kinds: []int{1},
|
||||
Tags: filters.TagFilters{
|
||||
"e": {"event-id"},
|
||||
},
|
||||
Extensions: filters.FilterExtensions{
|
||||
"search": json.RawMessage(`"nostr"`),
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal
|
||||
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
|
||||
|
||||
```go
|
||||
jsonData := `{
|
||||
"authors": ["cfa87f35"],
|
||||
"kinds": [1],
|
||||
"#e": ["abc123"],
|
||||
"since": 1234567890,
|
||||
"search": "bitcoin"
|
||||
}`
|
||||
|
||||
var filter filters.Filter
|
||||
err := filters.UnmarshalJSON([]byte(jsonData), &filter)
|
||||
if err != nil {
|
||||
// Unmarshal
|
||||
var f filters.Filter
|
||||
if err := filters.UnmarshalJSON(data, &f); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Standard fields populated: Authors, Kinds, Since
|
||||
// Tag filters populated: Tags["e"] = ["abc123"]
|
||||
// Unknown fields populated: Extensions["search"] = "bitcoin"
|
||||
```
|
||||
|
||||
#### Extensions field behavior
|
||||
|
||||
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:
|
||||
Extensions example:
|
||||
|
||||
```go
|
||||
filter := filters.Filter{
|
||||
@@ -334,15 +224,26 @@ filter := filters.Filter{
|
||||
|
||||
// In a storage layer (not this library):
|
||||
if searchRaw, ok := filter.Extensions["search"]; ok {
|
||||
var searchTerm string
|
||||
json.Unmarshal(searchRaw, &searchTerm)
|
||||
// Apply full-text search using searchTerm
|
||||
var term string
|
||||
json.Unmarshal(searchRaw, &term)
|
||||
// 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
|
||||
go test ./...
|
||||
|
||||
+56
-1
@@ -3,6 +3,8 @@
|
||||
// serialization, cryptographic signatures, and subscription filters.
|
||||
package events
|
||||
|
||||
|
||||
|
||||
// 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,
|
||||
// and subsequent elements are optional.
|
||||
@@ -10,12 +12,65 @@ type Tag []string
|
||||
|
||||
// Event represents a Nostr protocol event, with its seven required fields.
|
||||
// All fields must be present for a valid event.
|
||||
//easyjson:json
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
PubKey string `json:"pubkey"`
|
||||
CreatedAt int `json:"created_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Kind int `json:"kind"`
|
||||
Tags []Tag `json:"tags"`
|
||||
Content string `json:"content"`
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestUnmarshalEventJSON(t *testing.T) {
|
||||
event := Event{}
|
||||
event := NewEvent()
|
||||
json.Unmarshal(testEventJSONBytes, &event)
|
||||
if err := Validate(event); err != nil {
|
||||
t.Error("unmarshalled event is invalid")
|
||||
@@ -22,19 +22,17 @@ func TestMarshalEventJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEventJSONRoundTrip(t *testing.T) {
|
||||
event := Event{
|
||||
ID: "86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad",
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: []Tag{
|
||||
{"a", "value"},
|
||||
{"b", "value", "optional"},
|
||||
{"name", "value", "optional", "optional"},
|
||||
},
|
||||
Content: testEvent.Content,
|
||||
Sig: "c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557",
|
||||
}
|
||||
event := NewEvent(
|
||||
WithID("86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad"),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithTag(Tag{"a", "value"}),
|
||||
WithTag(Tag{"b", "value", "optional"}),
|
||||
WithTag(Tag{"name", "value", "optional", "optional"}),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig("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 {
|
||||
|
||||
+9
-10
@@ -8,17 +8,16 @@ import (
|
||||
const testSK = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167"
|
||||
const testPK = "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"
|
||||
|
||||
var testEvent = Event{
|
||||
ID: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad",
|
||||
PubKey: testPK,
|
||||
CreatedAt: 1760740551,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "hello world",
|
||||
Sig: "83b71e15649c9e9da362c175f988c36404cabf357a976d869102a74451cfb8af486f6088b5631033b4927bd46cad7a0d90d7f624aefc0ac260364aa65c36071a",
|
||||
}
|
||||
var testEvent = NewEvent(
|
||||
WithID("c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad"),
|
||||
WithPubKey(testPK),
|
||||
WithCreatedAt(1760740551),
|
||||
WithKind(1),
|
||||
WithContent("hello world"),
|
||||
WithSig("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)
|
||||
|
||||
func expectEqualEvents(t *testing.T, got, want Event) {
|
||||
|
||||
+1
-6
@@ -9,15 +9,10 @@ import (
|
||||
// GetID computes and returns the event ID as a lowercase, hex-encoded SHA-256 hash
|
||||
// of the serialized event.
|
||||
func GetID(e Event) string {
|
||||
hash := GetIDBytes(e)
|
||||
hash := sha256.Sum256(Serialize(e))
|
||||
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.
|
||||
// used for ID computation: [0, pubkey, created_at, kind, tags, content].
|
||||
func Serialize(e Event) []byte {
|
||||
|
||||
+90
-110
@@ -14,181 +14,161 @@ type IDTestCase struct {
|
||||
var idTestCases = []IDTestCase{
|
||||
{
|
||||
name: "minimal event",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
),
|
||||
expected: "13a55672a600398894592f4cb338652d4936caffe5d3718d11597582bb030c39",
|
||||
},
|
||||
|
||||
{
|
||||
name: "alphanumeric content",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "hello world",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithContent("hello world"),
|
||||
),
|
||||
expected: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad",
|
||||
},
|
||||
|
||||
{
|
||||
name: "unicode content",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "hello world 😀",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithContent("hello world 😀"),
|
||||
),
|
||||
expected: "e42083fafbf9a39f97914fd9a27cedb38c429ac3ca8814288414eaad1f472fe8",
|
||||
},
|
||||
|
||||
{
|
||||
name: "escaped content",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "\"You say yes.\"\\n\\t\"I say no.\"",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithContent("\"You say yes.\"\\n\\t\"I say no.\""),
|
||||
),
|
||||
expected: "343de133996a766bf00561945b6f2b2717d4905275976ca75c1d7096b7d1900c",
|
||||
},
|
||||
|
||||
{
|
||||
name: "json content",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "{\"field\": [\"value\",\"value\"],\"numeral\": 123}",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithContent("{\"field\": [\"value\",\"value\"],\"numeral\": 123}"),
|
||||
),
|
||||
expected: "c6140190453ee947efb790e70541a9d37c41604d1f29e4185da4325621ed5270",
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty tag",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{
|
||||
{"a", ""},
|
||||
},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithTag(Tag{"a", ""}),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "7d3e394c75916362436f11c603b1a89b40b50817550cfe522a90d769655007a4",
|
||||
},
|
||||
|
||||
{
|
||||
name: "single tag",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{
|
||||
{"a", "value"},
|
||||
},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithTag(Tag{"a", "value"}),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "7db394e274fb893edbd9f4aa9ff189d4f3264bf1a29cef8f614e83ebf6fa19fe",
|
||||
},
|
||||
|
||||
{
|
||||
name: "optional tag values",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{
|
||||
{"a", "value", "optional"},
|
||||
},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithTag(Tag{"a", "value", "optional"}),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "656b47884200959e0c03054292c453cfc4beea00b592d92c0f557bff765e9d34",
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple tags",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{
|
||||
{"a", "value", "optional"},
|
||||
{"b", "another"},
|
||||
{"c", "data"},
|
||||
},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithTag(Tag{"a", "value", "optional"}),
|
||||
WithTag(Tag{"b", "another"}),
|
||||
WithTag(Tag{"c", "data"}),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "f7c27f2eacda7ece5123a4f82db56145ba59f7c9e6c5eeb88552763664506b06",
|
||||
},
|
||||
|
||||
{
|
||||
name: "unicode tag",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 1,
|
||||
Tags: []Tag{
|
||||
{"a", "😀"},
|
||||
},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(1),
|
||||
WithTag(Tag{"a", "😀"}),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "fd2798d165d9bf46acbe817735dc8cedacd4c42dfd9380792487d4902539e986",
|
||||
},
|
||||
|
||||
{
|
||||
name: "zero timestamp",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: 0,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(0),
|
||||
WithKind(1),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "9ca742f2e2eea72ad6e0277a6287e2bb16a3e47d64b8468bc98474e266cf0ec2",
|
||||
},
|
||||
|
||||
{
|
||||
name: "negative timestamp",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: -1760740551,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(-1760740551),
|
||||
WithKind(1),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "4740b027040bb4d0ee8e885f567a80277097da70cddd143d8a6dadf97f6faaa3",
|
||||
},
|
||||
|
||||
{
|
||||
name: "max int64 timestamp",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: 9223372036854775807,
|
||||
Kind: 1,
|
||||
Tags: []Tag{},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(9223372036854775807),
|
||||
WithKind(1),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "b28cdd44496acb49e36c25859f0f819122829a12dc57c07612d5f44cb121d2a7",
|
||||
},
|
||||
|
||||
{
|
||||
name: "different kind",
|
||||
event: Event{
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: 20021,
|
||||
Tags: []Tag{},
|
||||
Content: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(20021),
|
||||
WithContent(""),
|
||||
),
|
||||
expected: "995c4894c264e6b9558cb94b7b34008768d53801b99960b47298d4e3e23fadd3",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package events
|
||||
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
+28
-24
@@ -2,6 +2,7 @@ package events
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"git.wisehodl.dev/jay/go-roots/errors"
|
||||
@@ -15,9 +16,15 @@ func Validate(e Event) error {
|
||||
return err
|
||||
}
|
||||
|
||||
idBytes, err := checkIDMatch(e)
|
||||
idHash := sha256.Sum256(Serialize(e))
|
||||
idBytes, err := hex.DecodeString(e.ID)
|
||||
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)
|
||||
@@ -26,15 +33,15 @@ func Validate(e Event) error {
|
||||
// ValidateStructure checks that all event fields conform to the protocol
|
||||
// specification: hex lengths, tag structure, and field formats.
|
||||
func ValidateStructure(e Event) error {
|
||||
if !isLowerHex(e.PubKey, 64) {
|
||||
if !IsValidKey(e.PubKey) {
|
||||
return errors.MalformedPubKey
|
||||
}
|
||||
|
||||
if !isLowerHex(e.ID, 64) {
|
||||
if !IsValidID(e.ID) {
|
||||
return errors.MalformedID
|
||||
}
|
||||
|
||||
if !isLowerHex(e.Sig, 128) {
|
||||
if !IsValidSig(e.Sig) {
|
||||
return errors.MalformedSig
|
||||
}
|
||||
|
||||
@@ -47,12 +54,6 @@ func ValidateStructure(e Event) error {
|
||||
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
|
||||
// for the event ID and public key using Schnorr verification.
|
||||
func ValidateSignature(e Event) error {
|
||||
@@ -63,22 +64,25 @@ func ValidateSignature(e Event) error {
|
||||
return validateSignatureBytes(idBytes, e.Sig, e.PubKey)
|
||||
}
|
||||
|
||||
// Helpers
|
||||
// Value validators
|
||||
|
||||
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
|
||||
// IsValidKey verifies that a public or private key is properly formatted.
|
||||
func IsValidKey(value string) bool {
|
||||
return isLowerHex(value, 64)
|
||||
}
|
||||
|
||||
// IsValidID verifies that an event id is properly formatted.
|
||||
func IsValidID(value string) bool {
|
||||
return isLowerHex(value, 64)
|
||||
}
|
||||
|
||||
// IsValidSig verifies that an event signature is properly formatted.
|
||||
func IsValidSig(value string) bool {
|
||||
return isLowerHex(value, 128)
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
func validateSignatureBytes(idBytes []byte, sigHex, pkHex string) error {
|
||||
sigBytes, err := hex.DecodeString(sigHex)
|
||||
if err != nil {
|
||||
|
||||
+125
-146
@@ -14,169 +14,161 @@ type ValidateEventTestCase struct {
|
||||
var structureTestCases = []ValidateEventTestCase{
|
||||
{
|
||||
name: "empty pubkey",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: "",
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(""),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "public key must be 64 lowercase hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "short pubkey",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: "abc123",
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey("abc123"),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "public key must be 64 lowercase hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "long pubkey",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48adabc",
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey("c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48adabc"),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "public key must be 64 lowercase hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "non-hex pubkey",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: "zyx-!2e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad",
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey("zyx-!2e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad"),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "public key must be 64 lowercase hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "uppercase pubkey",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: "C7A702E6158744CA03508BBB4C90F9DBB0D6E88FEFBFAA511D5AB24B4E3C48AD",
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey("C7A702E6158744CA03508BBB4C90F9DBB0D6E88FEFBFAA511D5AB24B4E3C48AD"),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "public key must be 64 lowercase hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty id",
|
||||
event: Event{
|
||||
ID: "",
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(""),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "id must be 64 hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "short id",
|
||||
event: Event{
|
||||
ID: "abc123",
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID("abc123"),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "id must be 64 hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty signature",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: "",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(""),
|
||||
),
|
||||
expectedError: "signature must be 128 hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "short signature",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: testEvent.Tags,
|
||||
Content: testEvent.Content,
|
||||
Sig: "abc123",
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig("abc123"),
|
||||
),
|
||||
expectedError: "signature must be 128 hex characters",
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty tag",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: []Tag{{}},
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithTag(Tag{}),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "tags must contain at least two elements",
|
||||
},
|
||||
|
||||
{
|
||||
name: "single element tag",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: []Tag{{"a"}},
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithTag(Tag{"a"}),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
expectedError: "tags must contain at least two elements",
|
||||
},
|
||||
|
||||
{
|
||||
name: "one good tag, one single element tag",
|
||||
event: Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: []Tag{{"a", "value"}, {"b"}},
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
},
|
||||
event: NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithTag(Tag{"a", "value"}),
|
||||
WithTag(Tag{"b"}),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
),
|
||||
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) {
|
||||
event := Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: testEvent.PubKey,
|
||||
Sig: testEvent.Sig,
|
||||
}
|
||||
event := NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithSig(testEvent.Sig),
|
||||
)
|
||||
err := ValidateSignature(event)
|
||||
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateInvalidSignature(t *testing.T) {
|
||||
event := Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: testEvent.PubKey,
|
||||
Sig: "9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482",
|
||||
}
|
||||
event := NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithSig("9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482"),
|
||||
)
|
||||
err := ValidateSignature(event)
|
||||
|
||||
assert.ErrorContains(t, err, "event signature is invalid")
|
||||
@@ -280,7 +257,11 @@ var validateSignatureTestCases = []ValidateSignatureTestCase{
|
||||
func TestValidateSignatureInvalidEventSignature(t *testing.T) {
|
||||
for _, tc := range validateSignatureTestCases {
|
||||
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)
|
||||
assert.ErrorContains(t, err, tc.expectedError)
|
||||
})
|
||||
@@ -288,18 +269,16 @@ func TestValidateSignatureInvalidEventSignature(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateEvent(t *testing.T) {
|
||||
event := Event{
|
||||
ID: "c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400",
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: []Tag{
|
||||
{"a", "value"},
|
||||
{"b", "value", "optional"},
|
||||
},
|
||||
Content: "valid event",
|
||||
Sig: "668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14",
|
||||
}
|
||||
event := NewEvent(
|
||||
WithID("c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400"),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithTag(Tag{"a", "value"}),
|
||||
WithTag(Tag{"b", "value", "optional"}),
|
||||
WithContent("valid event"),
|
||||
WithSig("668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14"),
|
||||
)
|
||||
|
||||
err := Validate(event)
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -20,16 +20,83 @@ type Filter struct {
|
||||
IDs []string
|
||||
Authors []string
|
||||
Kinds []int
|
||||
Since *int
|
||||
Until *int
|
||||
Since *int64
|
||||
Until *int64
|
||||
Limit *int
|
||||
Tags TagFilters
|
||||
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
|
||||
// (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{})
|
||||
|
||||
// Add standard fields
|
||||
@@ -86,7 +153,7 @@ func MarshalJSON(f Filter) ([]byte, error) {
|
||||
|
||||
// UnmarshalJSON parses JSON into the filter, separating standard fields,
|
||||
// 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
|
||||
raw := make(FilterExtensions)
|
||||
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" {
|
||||
f.Since = nil
|
||||
} else {
|
||||
var val int
|
||||
var val int64
|
||||
if err := json.Unmarshal(v, &val); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -132,7 +199,7 @@ func UnmarshalJSON(data []byte, f *Filter) error {
|
||||
if len(v) == 4 && string(v) == "null" {
|
||||
f.Until = nil
|
||||
} else {
|
||||
var val int
|
||||
var val int64
|
||||
if err := json.Unmarshal(v, &val); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -182,36 +249,37 @@ func UnmarshalJSON(data []byte, f *Filter) error {
|
||||
// Matches returns true if the event satisfies all filter conditions.
|
||||
// Supports prefix matching for IDs and authors, and tag filtering.
|
||||
// Does not account for custom extensions.
|
||||
func Matches(f Filter, event events.Event) bool {
|
||||
func Matches(f Filter, event events.ValidatedEvent) bool {
|
||||
// Check ID
|
||||
if len(f.IDs) > 0 {
|
||||
if !matchesPrefix(event.ID, f.IDs) {
|
||||
if !matchesPrefix(event.ID(), f.IDs) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check Author
|
||||
if len(f.Authors) > 0 {
|
||||
if !matchesPrefix(event.PubKey, f.Authors) {
|
||||
if !matchesPrefix(event.PubKey(), f.Authors) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check Kind
|
||||
if len(f.Kinds) > 0 {
|
||||
if !matchesKinds(event.Kind, f.Kinds) {
|
||||
if !matchesKinds(event.Kind(), f.Kinds) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check Timestamp
|
||||
if !matchesTimeRange(event.CreatedAt, f.Since, f.Until) {
|
||||
if !matchesTimeRange(event.CreatedAt(), f.Since, f.Until) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check Tags
|
||||
if len(f.Tags) > 0 {
|
||||
if !matchesTags(event.Tags, &f.Tags) {
|
||||
tags := event.Tags()
|
||||
if !matchesTags(tags, &f.Tags) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -237,7 +305,7 @@ func matchesKinds(candidate int, kinds []int) bool {
|
||||
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 {
|
||||
return false
|
||||
}
|
||||
|
||||
+155
-192
@@ -37,57 +37,57 @@ var marshalTestCases = []FilterMarshalTestCase{
|
||||
// ID cases
|
||||
{
|
||||
name: "nil IDs",
|
||||
filter: Filter{IDs: nil},
|
||||
filter: NewFilter(WithIDs(nil)),
|
||||
expected: `{}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty IDs",
|
||||
filter: Filter{IDs: []string{}},
|
||||
filter: NewFilter(WithIDs([]string{})),
|
||||
expected: `{"ids":[]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "populated IDs",
|
||||
filter: Filter{IDs: []string{"abc", "123"}},
|
||||
filter: NewFilter(WithIDs([]string{"abc", "123"})),
|
||||
expected: `{"ids":["abc","123"]}`,
|
||||
},
|
||||
|
||||
// Author cases
|
||||
{
|
||||
name: "nil Authors",
|
||||
filter: Filter{Authors: nil},
|
||||
filter: NewFilter(WithAuthors(nil)),
|
||||
expected: `{}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty Authors",
|
||||
filter: Filter{Authors: []string{}},
|
||||
filter: NewFilter(WithAuthors([]string{})),
|
||||
expected: `{"authors":[]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "populated Authors",
|
||||
filter: Filter{Authors: []string{"abc", "123"}},
|
||||
filter: NewFilter(WithAuthors([]string{"abc", "123"})),
|
||||
expected: `{"authors":["abc","123"]}`,
|
||||
},
|
||||
|
||||
// Kind cases
|
||||
{
|
||||
name: "nil Kinds",
|
||||
filter: Filter{Kinds: nil},
|
||||
filter: NewFilter(WithKinds(nil)),
|
||||
expected: `{}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty Kinds",
|
||||
filter: Filter{Kinds: []int{}},
|
||||
filter: NewFilter(WithKinds([]int{})),
|
||||
expected: `{"kinds":[]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "populated Kinds",
|
||||
filter: Filter{Kinds: []int{1, 20001}},
|
||||
filter: NewFilter(WithKinds([]int{1, 20001})),
|
||||
expected: `{"kinds":[1,20001]}`,
|
||||
},
|
||||
|
||||
@@ -100,7 +100,7 @@ var marshalTestCases = []FilterMarshalTestCase{
|
||||
|
||||
{
|
||||
name: "populated Since",
|
||||
filter: Filter{Since: intPtr(1000)},
|
||||
filter: NewFilter(WithSince(1000)),
|
||||
expected: `{"since":1000}`,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,7 @@ var marshalTestCases = []FilterMarshalTestCase{
|
||||
|
||||
{
|
||||
name: "populated Until",
|
||||
filter: Filter{Until: intPtr(1000)},
|
||||
filter: NewFilter(WithUntil(1000)),
|
||||
expected: `{"until":1000}`,
|
||||
},
|
||||
|
||||
@@ -126,27 +126,31 @@ var marshalTestCases = []FilterMarshalTestCase{
|
||||
|
||||
{
|
||||
name: "populated Limit",
|
||||
filter: Filter{Limit: intPtr(100)},
|
||||
filter: NewFilter(WithLimit(100)),
|
||||
expected: `{"limit":100}`,
|
||||
},
|
||||
|
||||
// All standard fields
|
||||
{
|
||||
name: "all standard fields",
|
||||
filter: Filter{
|
||||
IDs: []string{"abc", "123"},
|
||||
Authors: []string{"def", "456"},
|
||||
Kinds: []int{1, 200, 3000},
|
||||
Since: intPtr(1000),
|
||||
Until: intPtr(2000),
|
||||
Limit: intPtr(100),
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithIDs([]string{"abc", "123"}),
|
||||
WithAuthors([]string{"def", "456"}),
|
||||
WithKinds([]int{1, 200, 3000}),
|
||||
WithSince(1000),
|
||||
WithUntil(2000),
|
||||
WithLimit(100),
|
||||
),
|
||||
expected: `{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}`,
|
||||
},
|
||||
|
||||
{
|
||||
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]}`,
|
||||
},
|
||||
|
||||
@@ -159,164 +163,138 @@ var marshalTestCases = []FilterMarshalTestCase{
|
||||
|
||||
{
|
||||
name: "single-letter tag",
|
||||
filter: Filter{Tags: map[string][]string{
|
||||
"e": {"event1"},
|
||||
}},
|
||||
filter: NewFilter(
|
||||
WithTag("e", []string{"event1"}),
|
||||
),
|
||||
expected: `{"#e":["event1"]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "multi-letter tag",
|
||||
filter: Filter{Tags: map[string][]string{
|
||||
"emoji": {"🔥", "💧"},
|
||||
}},
|
||||
filter: NewFilter(
|
||||
WithTag("emoji", []string{"🔥", "💧"}),
|
||||
),
|
||||
expected: `{"#emoji":["🔥","💧"]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty tag array",
|
||||
filter: Filter{Tags: map[string][]string{
|
||||
"p": {},
|
||||
}},
|
||||
filter: NewFilter(
|
||||
WithTag("p", []string{}),
|
||||
),
|
||||
expected: `{"#p":[]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple tags",
|
||||
filter: Filter{Tags: map[string][]string{
|
||||
"e": {"event1", "event2"},
|
||||
"p": {"pubkey1", "pubkey2"},
|
||||
}},
|
||||
filter: NewFilter(
|
||||
WithTag("e", []string{"event1", "event2"}),
|
||||
WithTag("p", []string{"pubkey1", "pubkey2"}),
|
||||
),
|
||||
expected: `{"#e":["event1","event2"],"#p":["pubkey1","pubkey2"]}`,
|
||||
},
|
||||
|
||||
// Extensions
|
||||
{
|
||||
name: "simple extension",
|
||||
filter: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"search": json.RawMessage(`"query"`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithExtension("search", json.RawMessage(`"query"`)),
|
||||
),
|
||||
expected: `{"search":"query"}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension with nested object",
|
||||
filter: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"meta": json.RawMessage(`{"author":"alice","score":99}`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithExtension("meta", json.RawMessage(`{"author":"alice","score":99}`)),
|
||||
),
|
||||
expected: `{"meta":{"author":"alice","score":99}}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension with nested array",
|
||||
filter: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"items": json.RawMessage(`[1,2,3]`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithExtension("items", json.RawMessage(`[1,2,3]`)),
|
||||
),
|
||||
expected: `{"items":[1,2,3]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension with complex nested structure",
|
||||
filter: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"data": json.RawMessage(`{"users":[{"id":1}],"count":5}`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithExtension("data", json.RawMessage(`{"users":[{"id":1}],"count":5}`)),
|
||||
),
|
||||
expected: `{"data":{"users":[{"id":1}],"count":5}}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple extensions",
|
||||
filter: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"search": json.RawMessage(`"x"`),
|
||||
"depth": json.RawMessage(`3`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithExtension("search", json.RawMessage(`"x"`)),
|
||||
WithExtension("depth", json.RawMessage(`3`)),
|
||||
),
|
||||
expected: `{"search":"x","depth":3}`,
|
||||
},
|
||||
|
||||
// Extension Collisions
|
||||
{
|
||||
name: "extension collides with standard field - IDs",
|
||||
filter: Filter{
|
||||
IDs: []string{"real"},
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"ids": json.RawMessage(`["fake"]`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithIDs([]string{"real"}),
|
||||
WithExtension("ids", json.RawMessage(`["fake"]`)),
|
||||
),
|
||||
expected: `{"ids":["real"]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension collides with standard field - Since",
|
||||
filter: Filter{
|
||||
Since: intPtr(100),
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"since": json.RawMessage(`999`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithSince(100),
|
||||
WithExtension("since", json.RawMessage(`999`)),
|
||||
),
|
||||
expected: `{"since":100}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension collides with multiple standard fields",
|
||||
filter: Filter{
|
||||
Authors: []string{"a"},
|
||||
Kinds: []int{1},
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"authors": json.RawMessage(`["b"]`),
|
||||
"kinds": json.RawMessage(`[2]`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithAuthors([]string{"a"}),
|
||||
WithKinds([]int{1}),
|
||||
WithExtension("authors", json.RawMessage(`["b"]`)),
|
||||
WithExtension("kinds", json.RawMessage(`[2]`)),
|
||||
),
|
||||
expected: `{"authors":["a"],"kinds":[1]}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension collides with tag field - #e",
|
||||
filter: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"#e": json.RawMessage(`["fakeevent"]`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithExtension("#e", json.RawMessage(`["fakeevent"]`)),
|
||||
),
|
||||
expected: `{}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension collides with standard and tag fields",
|
||||
filter: Filter{
|
||||
Authors: []string{"realauthor"},
|
||||
Tags: map[string][]string{
|
||||
"e": {"realevent"},
|
||||
},
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"authors": json.RawMessage(`["fakeauthor"]`),
|
||||
"#e": json.RawMessage(`["fakeevent"]`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithAuthors([]string{"realauthor"}),
|
||||
WithTag("e", []string{"realevent"}),
|
||||
WithExtension("authors", json.RawMessage(`["fakeauthor"]`)),
|
||||
WithExtension("#e", json.RawMessage(`["fakeevent"]`)),
|
||||
),
|
||||
expected: `{"authors":["realauthor"],"#e":["realevent"]}`,
|
||||
},
|
||||
|
||||
// Kitchen Sink
|
||||
{
|
||||
name: "filter with all field types",
|
||||
filter: Filter{
|
||||
IDs: []string{"x"},
|
||||
Since: intPtr(100),
|
||||
Tags: map[string][]string{
|
||||
"e": {"y"},
|
||||
},
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"search": json.RawMessage(`"z"`),
|
||||
"ids": json.RawMessage(`["fakeid"]`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithIDs([]string{"x"}),
|
||||
WithSince(100),
|
||||
WithTag("e", []string{"y"}),
|
||||
WithExtension("search", json.RawMessage(`"z"`)),
|
||||
WithExtension("ids", json.RawMessage(`["fakeid"]`)),
|
||||
),
|
||||
expected: `{"ids":["x"],"since":100,"#e":["y"],"search":"z"}`,
|
||||
},
|
||||
}
|
||||
@@ -325,64 +303,64 @@ var unmarshalTestCases = []FilterUnmarshalTestCase{
|
||||
{
|
||||
name: "empty object",
|
||||
input: `{}`,
|
||||
expected: Filter{},
|
||||
expected: NewFilter(),
|
||||
},
|
||||
|
||||
// ID cases
|
||||
{
|
||||
name: "null IDs",
|
||||
input: `{"ids": null}`,
|
||||
expected: Filter{IDs: nil},
|
||||
expected: NewFilter(WithIDs(nil)),
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty IDs",
|
||||
input: `{"ids": []}`,
|
||||
expected: Filter{IDs: []string{}},
|
||||
expected: NewFilter(WithIDs([]string{})),
|
||||
},
|
||||
|
||||
{
|
||||
name: "populated IDs",
|
||||
input: `{"ids": ["abc","123"]}`,
|
||||
expected: Filter{IDs: []string{"abc", "123"}},
|
||||
expected: NewFilter(WithIDs([]string{"abc", "123"})),
|
||||
},
|
||||
|
||||
// Author cases
|
||||
{
|
||||
name: "null Authors",
|
||||
input: `{"authors": null}`,
|
||||
expected: Filter{Authors: nil},
|
||||
expected: NewFilter(WithAuthors(nil)),
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty Authors",
|
||||
input: `{"authors": []}`,
|
||||
expected: Filter{Authors: []string{}},
|
||||
expected: NewFilter(WithAuthors([]string{})),
|
||||
},
|
||||
|
||||
{
|
||||
name: "populated Authors",
|
||||
input: `{"authors": ["abc","123"]}`,
|
||||
expected: Filter{Authors: []string{"abc", "123"}},
|
||||
expected: NewFilter(WithAuthors([]string{"abc", "123"})),
|
||||
},
|
||||
|
||||
// Kind cases
|
||||
{
|
||||
name: "null Kinds",
|
||||
input: `{"kinds": null}`,
|
||||
expected: Filter{Kinds: nil},
|
||||
expected: NewFilter(WithKinds(nil)),
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty Kinds",
|
||||
input: `{"kinds": []}`,
|
||||
expected: Filter{Kinds: []int{}},
|
||||
expected: NewFilter(WithKinds([]int{})),
|
||||
},
|
||||
|
||||
{
|
||||
name: "populated Kinds",
|
||||
input: `{"kinds": [1,2,3]}`,
|
||||
expected: Filter{Kinds: []int{1, 2, 3}},
|
||||
expected: NewFilter(WithKinds([]int{1, 2, 3})),
|
||||
},
|
||||
|
||||
// Since cases
|
||||
@@ -395,7 +373,7 @@ var unmarshalTestCases = []FilterUnmarshalTestCase{
|
||||
{
|
||||
name: "populated Since",
|
||||
input: `{"since": 1000}`,
|
||||
expected: Filter{Since: intPtr(1000)},
|
||||
expected: NewFilter(WithSince(1000)),
|
||||
},
|
||||
|
||||
// Until cases
|
||||
@@ -408,7 +386,7 @@ var unmarshalTestCases = []FilterUnmarshalTestCase{
|
||||
{
|
||||
name: "populated Until",
|
||||
input: `{"until": 1000}`,
|
||||
expected: Filter{Until: intPtr(1000)},
|
||||
expected: NewFilter(WithUntil(1000)),
|
||||
},
|
||||
|
||||
// Limit cases
|
||||
@@ -421,161 +399,146 @@ var unmarshalTestCases = []FilterUnmarshalTestCase{
|
||||
{
|
||||
name: "populated Limit",
|
||||
input: `{"limit": 1000}`,
|
||||
expected: Filter{Limit: intPtr(1000)},
|
||||
expected: NewFilter(WithLimit(1000)),
|
||||
},
|
||||
|
||||
// All standard fields
|
||||
{
|
||||
name: "all standard fields",
|
||||
input: `{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}`,
|
||||
expected: Filter{
|
||||
IDs: []string{"abc", "123"},
|
||||
Authors: []string{"def", "456"},
|
||||
Kinds: []int{1, 200, 3000},
|
||||
Since: intPtr(1000),
|
||||
Until: intPtr(2000),
|
||||
Limit: intPtr(100),
|
||||
},
|
||||
expected: NewFilter(
|
||||
WithIDs([]string{"abc", "123"}),
|
||||
WithAuthors([]string{"def", "456"}),
|
||||
WithKinds([]int{1, 200, 3000}),
|
||||
WithSince(1000),
|
||||
WithUntil(2000),
|
||||
WithLimit(100),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: "mixed fields",
|
||||
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",
|
||||
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
|
||||
{
|
||||
name: "single-letter tag",
|
||||
input: `{"#e":["event1"]}`,
|
||||
expected: Filter{Tags: map[string][]string{"e": {"event1"}}},
|
||||
expected: NewFilter(WithTag("e", []string{"event1"})),
|
||||
},
|
||||
|
||||
{
|
||||
name: "multi-letter tag",
|
||||
input: `{"#emoji":["🔥","💧"]}`,
|
||||
expected: Filter{Tags: map[string][]string{"emoji": {"🔥", "💧"}}},
|
||||
expected: NewFilter(WithTag("emoji", []string{"🔥", "💧"})),
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty tag array",
|
||||
input: `{"#p":[]}`,
|
||||
expected: Filter{Tags: map[string][]string{"p": {}}},
|
||||
expected: NewFilter(WithTag("p", []string{})),
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple tags",
|
||||
input: `{"#p":["pubkey1","pubkey2"],"#e":["event1","event2"]}`,
|
||||
expected: Filter{Tags: map[string][]string{
|
||||
"p": {"pubkey1", "pubkey2"},
|
||||
"e": {"event1", "event2"},
|
||||
}},
|
||||
expected: NewFilter(
|
||||
WithTag("p", []string{"pubkey1", "pubkey2"}),
|
||||
WithTag("e", []string{"event1", "event2"}),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: "null tag",
|
||||
input: `{"#p":null}`,
|
||||
expected: Filter{Tags: map[string][]string{"p": nil}},
|
||||
expected: NewFilter(WithTag("p", nil)),
|
||||
},
|
||||
|
||||
// Extensions
|
||||
{
|
||||
name: "simple extension",
|
||||
input: `{"search":"query"}`,
|
||||
expected: Filter{Extensions: map[string]json.RawMessage{
|
||||
"search": json.RawMessage(`"query"`),
|
||||
},
|
||||
},
|
||||
expected: NewFilter(
|
||||
WithExtension("search", json.RawMessage(`"query"`)),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension with nested object",
|
||||
input: `{"meta":{"author":"alice","score":99}}`,
|
||||
expected: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"meta": json.RawMessage(`{"author":"alice","score":99}`),
|
||||
},
|
||||
},
|
||||
expected: NewFilter(
|
||||
WithExtension("meta", json.RawMessage(`{"author":"alice","score":99}`)),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension with nested array",
|
||||
input: `{"items":[1,2,3]}`,
|
||||
expected: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"items": json.RawMessage(`[1,2,3]`),
|
||||
},
|
||||
},
|
||||
expected: NewFilter(
|
||||
WithExtension("items", json.RawMessage(`[1,2,3]`)),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension with complex nested structure",
|
||||
input: `{"data":{"level1":{"level2":[{"id":1}]}}}`,
|
||||
expected: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"data": json.RawMessage(`{"level1":{"level2":[{"id":1}]}}`),
|
||||
},
|
||||
},
|
||||
expected: NewFilter(
|
||||
WithExtension("data", json.RawMessage(`{"level1":{"level2":[{"id":1}]}}`)),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple extensions",
|
||||
input: `{"search":"x","custom":true,"depth":3}`,
|
||||
expected: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"search": json.RawMessage(`"x"`),
|
||||
"custom": json.RawMessage(`true`),
|
||||
"depth": json.RawMessage(`3`),
|
||||
},
|
||||
},
|
||||
expected: NewFilter(
|
||||
WithExtension("search", json.RawMessage(`"x"`)),
|
||||
WithExtension("custom", json.RawMessage(`true`)),
|
||||
WithExtension("depth", json.RawMessage(`3`)),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: "extension with null value",
|
||||
input: `{"optional":null}`,
|
||||
expected: Filter{
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"optional": json.RawMessage(`null`),
|
||||
},
|
||||
},
|
||||
expected: NewFilter(
|
||||
WithExtension("optional", json.RawMessage(`null`)),
|
||||
),
|
||||
},
|
||||
|
||||
// Kitchen Sink
|
||||
{
|
||||
name: "extension with null value",
|
||||
input: `{"ids":["x"],"since":100,"#e":["y"],"search":"z"}`,
|
||||
expected: Filter{
|
||||
IDs: []string{"x"},
|
||||
Since: intPtr(100),
|
||||
Tags: map[string][]string{
|
||||
"e": {"y"},
|
||||
},
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"search": json.RawMessage(`"z"`),
|
||||
},
|
||||
},
|
||||
expected: NewFilter(
|
||||
WithIDs([]string{"x"}),
|
||||
WithSince(100),
|
||||
WithTag("e", []string{"y"}),
|
||||
WithExtension("search", json.RawMessage(`"z"`)),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
var roundTripTestCases = []FilterRoundTripTestCase{
|
||||
{
|
||||
name: "fully populated filter",
|
||||
filter: Filter{
|
||||
IDs: []string{"x"},
|
||||
Since: intPtr(100),
|
||||
Tags: map[string][]string{
|
||||
"e": {"y"},
|
||||
},
|
||||
Extensions: map[string]json.RawMessage{
|
||||
"search": json.RawMessage(`"z"`),
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithIDs([]string{"x"}),
|
||||
WithSince(100),
|
||||
WithTag("e", []string{"y"}),
|
||||
WithExtension("search", json.RawMessage(`"z"`)),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -584,7 +547,7 @@ var roundTripTestCases = []FilterRoundTripTestCase{
|
||||
func TestFilterMarshalJSON(t *testing.T) {
|
||||
for _, tc := range marshalTestCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := MarshalJSON(tc.filter)
|
||||
result, err := json.Marshal(tc.filter)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var expectedMap, actualMap map[string]interface{}
|
||||
@@ -602,7 +565,7 @@ func TestFilterUnmarshalJSON(t *testing.T) {
|
||||
for _, tc := range unmarshalTestCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var result Filter
|
||||
err := UnmarshalJSON([]byte(tc.input), &result)
|
||||
err := json.Unmarshal([]byte(tc.input), &result)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectEqualFilters(t, result, tc.expected)
|
||||
@@ -613,11 +576,11 @@ func TestFilterUnmarshalJSON(t *testing.T) {
|
||||
func TestFilterRoundTrip(t *testing.T) {
|
||||
for _, tc := range roundTripTestCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
jsonBytes, err := MarshalJSON(tc.filter)
|
||||
jsonBytes, err := json.Marshal(tc.filter)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var result Filter
|
||||
err = UnmarshalJSON(jsonBytes, &result)
|
||||
err = json.Unmarshal(jsonBytes, &result)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectEqualFilters(t, result, tc.filter)
|
||||
|
||||
+104
-133
@@ -8,20 +8,28 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testEvents []events.Event
|
||||
var testEvents []events.ValidatedEvent
|
||||
|
||||
func init() {
|
||||
data, err := os.ReadFile("testdata/test_events.json")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &testEvents); err != nil {
|
||||
var raw []events.Event
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
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.
|
||||
var (
|
||||
const (
|
||||
nayru_sk = "1784be782585dfa97712afe12585d13ee608b624cf564116fa143c31a124d31e"
|
||||
nayru_pk = "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe"
|
||||
farore_sk = "03d0611c41048a9108a75bf5d023180b5cf2d2d24e2e6b83def29de977315bb3"
|
||||
@@ -39,10 +47,10 @@ type FilterTestCase struct {
|
||||
var filterTestCases = []FilterTestCase{
|
||||
{
|
||||
name: "empty filter",
|
||||
filter: Filter{},
|
||||
filter: NewFilter(),
|
||||
expectedIDs: []string{
|
||||
"e751d41f",
|
||||
"562bc378",
|
||||
"2e06c187",
|
||||
"e67fa7b8",
|
||||
"5e4c64f1",
|
||||
"7a5d83d4",
|
||||
@@ -55,10 +63,10 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "empty id",
|
||||
filter: Filter{IDs: []string{}},
|
||||
filter: NewFilter(WithIDs([]string{})),
|
||||
expectedIDs: []string{
|
||||
"e751d41f",
|
||||
"562bc378",
|
||||
"2e06c187",
|
||||
"e67fa7b8",
|
||||
"5e4c64f1",
|
||||
"7a5d83d4",
|
||||
@@ -71,34 +79,37 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "single id prefix",
|
||||
filter: Filter{IDs: []string{"e751d41f"}},
|
||||
filter: NewFilter(WithIDs([]string{"e751d41f"})),
|
||||
expectedIDs: []string{"e751d41f"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "single full id",
|
||||
filter: Filter{IDs: []string{"e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b"}},
|
||||
filter: NewFilter(
|
||||
WithIDs([]string{
|
||||
"e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b"}),
|
||||
),
|
||||
expectedIDs: []string{"e67fa7b8"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple id prefixes",
|
||||
filter: Filter{IDs: []string{"562bc378", "5e4c64f1"}},
|
||||
expectedIDs: []string{"562bc378", "5e4c64f1"},
|
||||
filter: NewFilter(WithIDs([]string{"2e06c187", "5e4c64f1"})),
|
||||
expectedIDs: []string{"2e06c187", "5e4c64f1"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "no id match",
|
||||
filter: Filter{IDs: []string{"ffff"}},
|
||||
filter: NewFilter(WithIDs([]string{"ffff"})),
|
||||
expectedIDs: []string{},
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty author",
|
||||
filter: Filter{Authors: []string{}},
|
||||
filter: NewFilter(WithAuthors([]string{})),
|
||||
expectedIDs: []string{
|
||||
"e751d41f",
|
||||
"562bc378",
|
||||
"2e06c187",
|
||||
"e67fa7b8",
|
||||
"5e4c64f1",
|
||||
"7a5d83d4",
|
||||
@@ -111,16 +122,16 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "single author prefix",
|
||||
filter: Filter{Authors: []string{"d877e187"}},
|
||||
expectedIDs: []string{"e751d41f", "562bc378", "e67fa7b8"},
|
||||
filter: NewFilter(WithAuthors([]string{"d877e187"})),
|
||||
expectedIDs: []string{"e751d41f", "2e06c187", "e67fa7b8"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple author prefixex",
|
||||
filter: Filter{Authors: []string{"d877e187", "9e4b726a"}},
|
||||
filter: NewFilter(WithAuthors([]string{"d877e187", "9e4b726a"})),
|
||||
expectedIDs: []string{
|
||||
"e751d41f",
|
||||
"562bc378",
|
||||
"2e06c187",
|
||||
"e67fa7b8",
|
||||
"5e4c64f1",
|
||||
"7a5d83d4",
|
||||
@@ -130,22 +141,25 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "single author full",
|
||||
filter: Filter{Authors: []string{"d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe"}},
|
||||
expectedIDs: []string{"e751d41f", "562bc378", "e67fa7b8"},
|
||||
filter: NewFilter(
|
||||
WithAuthors([]string{
|
||||
"d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe"}),
|
||||
),
|
||||
expectedIDs: []string{"e751d41f", "2e06c187", "e67fa7b8"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "no author match",
|
||||
filter: Filter{Authors: []string{"ffff"}},
|
||||
filter: NewFilter(WithAuthors([]string{"ffff"})),
|
||||
expectedIDs: []string{},
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty kind",
|
||||
filter: Filter{Kinds: []int{}},
|
||||
filter: NewFilter(WithKinds([]int{})),
|
||||
expectedIDs: []string{
|
||||
"e751d41f",
|
||||
"562bc378",
|
||||
"2e06c187",
|
||||
"e67fa7b8",
|
||||
"5e4c64f1",
|
||||
"7a5d83d4",
|
||||
@@ -158,13 +172,13 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "single kind",
|
||||
filter: Filter{Kinds: []int{1}},
|
||||
expectedIDs: []string{"562bc378", "7a5d83d4", "4b03b69a"},
|
||||
filter: NewFilter(WithKinds([]int{1})),
|
||||
expectedIDs: []string{"2e06c187", "7a5d83d4", "4b03b69a"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple kinds",
|
||||
filter: Filter{Kinds: []int{0, 2}},
|
||||
filter: NewFilter(WithKinds([]int{0, 2})),
|
||||
expectedIDs: []string{
|
||||
"e751d41f",
|
||||
"e67fa7b8",
|
||||
@@ -177,13 +191,13 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "no kind match",
|
||||
filter: Filter{Kinds: []int{99}},
|
||||
filter: NewFilter(WithKinds([]int{99})),
|
||||
expectedIDs: []string{},
|
||||
},
|
||||
|
||||
{
|
||||
name: "since only",
|
||||
filter: Filter{Since: intPtr(5000)},
|
||||
filter: NewFilter(WithSince(5000)),
|
||||
expectedIDs: []string{
|
||||
"7a5d83d4",
|
||||
"3a122100",
|
||||
@@ -195,20 +209,17 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "until only",
|
||||
filter: Filter{Until: intPtr(3000)},
|
||||
filter: NewFilter(WithUntil(3000)),
|
||||
expectedIDs: []string{
|
||||
"e751d41f",
|
||||
"562bc378",
|
||||
"2e06c187",
|
||||
"e67fa7b8",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "time range",
|
||||
filter: Filter{
|
||||
Since: intPtr(4000),
|
||||
Until: intPtr(6000),
|
||||
},
|
||||
filter: NewFilter(WithSince(4000), WithUntil(6000)),
|
||||
expectedIDs: []string{
|
||||
"5e4c64f1",
|
||||
"7a5d83d4",
|
||||
@@ -218,22 +229,16 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "outside time range",
|
||||
filter: Filter{
|
||||
Since: intPtr(10000),
|
||||
},
|
||||
filter: NewFilter(WithSince(10000)),
|
||||
expectedIDs: []string{},
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty tag filter",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"e": {},
|
||||
},
|
||||
},
|
||||
filter: NewFilter(WithTag("e", []string{})),
|
||||
expectedIDs: []string{
|
||||
"e751d41f",
|
||||
"562bc378",
|
||||
"2e06c187",
|
||||
"e67fa7b8",
|
||||
"5e4c64f1",
|
||||
"7a5d83d4",
|
||||
@@ -246,110 +251,98 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "single letter tag filter: e",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"e": {"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"},
|
||||
},
|
||||
},
|
||||
expectedIDs: []string{"562bc378"},
|
||||
filter: NewFilter(
|
||||
WithTag("e", []string{
|
||||
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}),
|
||||
),
|
||||
expectedIDs: []string{"2e06c187"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple tag matches",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"e": {
|
||||
filter: NewFilter(
|
||||
WithTag("e", []string{
|
||||
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
|
||||
"ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedIDs: []string{"562bc378", "3a122100"},
|
||||
}),
|
||||
),
|
||||
expectedIDs: []string{"2e06c187", "3a122100"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple tag matches - single event match",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"e": {
|
||||
filter: NewFilter(
|
||||
WithTag("e", []string{
|
||||
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
|
||||
"cb7787c460a79187d6a13e75a0f19240e05fafca8ea42288f5765773ea69cf2f",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedIDs: []string{"562bc378"},
|
||||
}),
|
||||
),
|
||||
expectedIDs: []string{"2e06c187"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "single letter tag filter: p",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"p": {"91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"},
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithTag("p", []string{
|
||||
"91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}),
|
||||
),
|
||||
expectedIDs: []string{"e67fa7b8"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "multi letter tag filter",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"emoji": {"🌊"},
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithTag("emoji", []string{"🌊"}),
|
||||
),
|
||||
expectedIDs: []string{"e67fa7b8"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple tag filters",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"e": {"ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7"},
|
||||
"p": {"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"},
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithTag("e", []string{
|
||||
"ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7"}),
|
||||
WithTag("p", []string{
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"}),
|
||||
),
|
||||
expectedIDs: []string{"3a122100"},
|
||||
},
|
||||
|
||||
{
|
||||
name: "prefix tag filter",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"p": {"ae3f2a91"},
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithTag("p", []string{"ae3f2a91"}),
|
||||
),
|
||||
expectedIDs: []string{},
|
||||
},
|
||||
|
||||
{
|
||||
name: "unknown tag filter",
|
||||
filter: Filter{
|
||||
Tags: TagFilters{
|
||||
"z": {"anything"},
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithTag("z", []string{"anything"}),
|
||||
),
|
||||
expectedIDs: []string{},
|
||||
},
|
||||
|
||||
{
|
||||
name: "combined author+kind tag filter",
|
||||
filter: Filter{
|
||||
Authors: []string{"d877e187"},
|
||||
Kinds: []int{1, 2},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithAuthors([]string{"d877e187"}),
|
||||
WithKinds([]int{1, 2}),
|
||||
),
|
||||
expectedIDs: []string{
|
||||
"562bc378",
|
||||
"2e06c187",
|
||||
"e67fa7b8",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "combined kind+time range tag filter",
|
||||
filter: Filter{
|
||||
Kinds: []int{0},
|
||||
Since: intPtr(2000),
|
||||
Until: intPtr(7000),
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithKinds([]int{0}),
|
||||
WithSince(2000),
|
||||
WithUntil(7000),
|
||||
),
|
||||
expectedIDs: []string{
|
||||
"5e4c64f1",
|
||||
"4a15d963",
|
||||
@@ -358,12 +351,10 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "combined author+tag tag filter",
|
||||
filter: Filter{
|
||||
Authors: []string{"e719e8f8"},
|
||||
Tags: TagFilters{
|
||||
"power": {"fire"},
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithAuthors([]string{"e719e8f8"}),
|
||||
WithTag("power", []string{"fire"}),
|
||||
),
|
||||
expectedIDs: []string{
|
||||
"4a15d963",
|
||||
},
|
||||
@@ -371,15 +362,13 @@ var filterTestCases = []FilterTestCase{
|
||||
|
||||
{
|
||||
name: "combined tag filter",
|
||||
filter: Filter{
|
||||
Authors: []string{"e719e8f8"},
|
||||
Kinds: []int{0},
|
||||
Since: intPtr(5000),
|
||||
Until: intPtr(10000),
|
||||
Tags: TagFilters{
|
||||
"power": {"fire"},
|
||||
},
|
||||
},
|
||||
filter: NewFilter(
|
||||
WithAuthors([]string{"e719e8f8"}),
|
||||
WithKinds([]int{0}),
|
||||
WithSince(5000),
|
||||
WithUntil(10000),
|
||||
WithTag("power", []string{"fire"}),
|
||||
),
|
||||
expectedIDs: []string{
|
||||
"4a15d963",
|
||||
},
|
||||
@@ -392,7 +381,7 @@ func TestEventFilterMatching(t *testing.T) {
|
||||
actualIDs := []string{}
|
||||
for _, event := range testEvents {
|
||||
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))
|
||||
}
|
||||
|
||||
Vendored
+14
-14
@@ -1,18 +1,18 @@
|
||||
[
|
||||
{
|
||||
"kind": 0,
|
||||
"id": "e751d41fa31e3a115634b41fb587cbd8270d10333a6d5330b1de24737448de70",
|
||||
"pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe",
|
||||
"created_at": 1000,
|
||||
"tags": [],
|
||||
"kind": 0,
|
||||
"tags": null,
|
||||
"content": "Nayru profile",
|
||||
"sig": "b3ba1ef2b4143e8c2fabc66bfd26839d6f3a14b5f8d24a8b96ce9c1aa41a53536444be61ed3e502cbeb04d34f8b893c84fa40bac408878c57ee4054d629c1452"
|
||||
},
|
||||
{
|
||||
"kind": 1,
|
||||
"id": "562bc378fc1a254b053b0cc1b8d61afec8e931ba79f0110ba9dd617496260758",
|
||||
"id": "2e06c18793abeb368ffadecce7de8a3c5a50857907c91dc8b9f6b5f2f7b44a28",
|
||||
"pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe",
|
||||
"created_at": 2000,
|
||||
"kind": 1,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
@@ -24,13 +24,13 @@
|
||||
]
|
||||
],
|
||||
"content": "Hello from Nayru",
|
||||
"sig": "18e48bf6be4e4104f95bfe90bd61e33c3d8cc5bf3e776ba8182fafe3f84b2e4ef6ce10256865cce556016e1b14ebad3079d3d0a3afcb0f690f12fa01e8f64201"
|
||||
"sig": "5d90570e0dbf3cd8a9c44c5b25b03cf0005d9d3d79d17b43de39144343858b1a21cef22f07ac95ba9770ec39a87f428d860521ee9b7e1b33b973947382bed5bb"
|
||||
},
|
||||
{
|
||||
"kind": 2,
|
||||
"id": "e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b",
|
||||
"pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe",
|
||||
"created_at": 3000,
|
||||
"kind": 2,
|
||||
"tags": [
|
||||
[
|
||||
"emoji",
|
||||
@@ -45,10 +45,10 @@
|
||||
"sig": "00e2c74374670b7623b793ddf4e9903ace17be621bbad74b808232eec1473271fa3e3d5e4ad01100f6c48bf36baa4e4dbaa012cd5ff060b644caac4e9a9c6b1e"
|
||||
},
|
||||
{
|
||||
"kind": 0,
|
||||
"id": "5e4c64f15a1ad510409e5cb3dc519dcde5416fbb8621bf65559f6b98f729a0d4",
|
||||
"pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9",
|
||||
"created_at": 4000,
|
||||
"kind": 0,
|
||||
"tags": [
|
||||
[
|
||||
"website",
|
||||
@@ -59,19 +59,19 @@
|
||||
"sig": "6998b03fba4787ca6a44c4042143592bb9670ded905c06c1b258a7c1630666d7b033b7f5586f7a64ed92e912b555193112e8a590326f38809c46fe104907823e"
|
||||
},
|
||||
{
|
||||
"kind": 1,
|
||||
"id": "7a5d83d475576963f81e21d67208d6cf90c42b6a0c3a642c100a3571c5c96b68",
|
||||
"pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9",
|
||||
"created_at": 5000,
|
||||
"tags": [],
|
||||
"kind": 1,
|
||||
"tags": null,
|
||||
"content": "Farore's message",
|
||||
"sig": "e9f4986264c7eb7800b7a7d0e0de2928242cb4e93f8ba099fc1564b893dd7a77d2277dc3e8b67724c3887ccadbf14a656c80a229107eb2b5a44a20a00bc436d6"
|
||||
},
|
||||
{
|
||||
"kind": 2,
|
||||
"id": "3a122100196b065ec6c5e1e75dd5140eeb292ef96d2acd56354eb8c23c47649a",
|
||||
"pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9",
|
||||
"created_at": 6000,
|
||||
"kind": 2,
|
||||
"tags": [
|
||||
[
|
||||
"category",
|
||||
@@ -91,10 +91,10 @@
|
||||
"sig": "1f5ccdd14b1313a39b6fabfc85a3535ba4f10ad99067803804c9478d63ef2cf53723fcee7041fcbaad4f846d4500183e92305d59b3e6ccb504ce291ad7f982e2"
|
||||
},
|
||||
{
|
||||
"kind": 0,
|
||||
"id": "4a15d963de8d26e8c4377e17fcf6daec499c454338951716a7d14cae1f7be835",
|
||||
"pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7",
|
||||
"created_at": 7000,
|
||||
"kind": 0,
|
||||
"tags": [
|
||||
[
|
||||
"location",
|
||||
@@ -109,10 +109,10 @@
|
||||
"sig": "5a731404105aee9a04bd4d05024cb994a8d500edfceaeb83773438a70d376e6bb638e82e70380558f66aa078ab01f5c4ca86d8c37d291aafb7e33da053c856a9"
|
||||
},
|
||||
{
|
||||
"kind": 1,
|
||||
"id": "4b03b69a7e89796e1021ad3b7f914e6868a6e900b5e6edfa09d9019a05898ed3",
|
||||
"pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7",
|
||||
"created_at": 8000,
|
||||
"kind": 1,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
@@ -123,11 +123,11 @@
|
||||
"sig": "7330fd35e0be4a2a64a940a2841474f60b15d5dec9d4c4129905d97bd91cc8e6a97eec66091580b7351a807b7c250544cf500d0e2d47f5744387b1ce4ac49c4d"
|
||||
},
|
||||
{
|
||||
"kind": 2,
|
||||
"id": "d39e6f3f593bd754a45a6e2f77b1b0669cdfe89c19fb2a4b252ea095caa9874b",
|
||||
"pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7",
|
||||
"created_at": 9000,
|
||||
"tags": [],
|
||||
"kind": 2,
|
||||
"tags": null,
|
||||
"content": "Final event",
|
||||
"sig": "917d3fa8111cd9dfdc9acad121e7f71e4358d6a4eb0979eadc744b55f78d2647bf839282f6c10afacd64798007c3ef09b8a925c9b73f97c5219098eca1bacc4d"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package filters
|
||||
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
@@ -12,6 +12,8 @@ require (
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // 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
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
Reference in New Issue
Block a user