20 Commits

Author SHA1 Message Date
Jay a765a2262a Rewrite README around ValidatedEvent as the primary consumer type 2026-05-22 13:05:24 -04:00
Jay d699feb236 Update Matches to accept ValidatedEvent; regenerate test fixtures with valid signatures 2026-05-22 11:41:04 -04:00
Jay 17789a7dbd Fix IsValidID/IsValidSig doc comments; remove ValidateID, GetIDBytes, checkIDMatch 2026-05-22 11:35:30 -04:00
Jay 0ca44c7e20 Drop WithCreatedAtTime, WithSinceTime, WithUntilTime; nil Tags on NewEvent 2026-05-22 11:32:55 -04:00
Jay c6145d6020 deepCopyTags preserves nil tags slice 2026-05-22 10:08:10 -04:00
Jay 12699a1630 Wrote ValidatedEvent 2026-05-22 10:06:32 -04:00
jay 60c8e8256b Update filter to use json package interfaces. 2026-05-08 09:54:31 -04:00
jay 047fc9d9a1 generate easyjson for Event struct 2026-05-08 09:41:45 -04:00
jay 48dde86abd add constructor functions with options. update tests. 2026-05-04 13:41:56 -04:00
jay 29ba275293 add individual value validator functions. 2026-04-22 14:20:57 -04:00
jay 747781f5bf Performant validation. Prevent redundant decoding. Remove unused errors. 2026-04-20 23:52:50 -04:00
jay 62aeef4eaf Performant event serialization. Update README. 2026-04-20 23:52:40 -04:00
jay b545f9370f Add bump script. 2026-02-25 13:04:01 -05:00
jay 8c7113c51b Update c2p script. 2026-02-06 10:47:06 -05:00
jay cda73bf6f2 Updated c2p script 2025-11-02 16:27:23 -05:00
jay 1e2a6f7777 Add license file. 2025-11-02 14:27:01 -05:00
jay d42d877ea2 Updated README. 2025-11-02 14:22:45 -05:00
jay 4df91938ef Refactored methods into pure functions. 2025-10-31 19:48:56 -04:00
jay 67db088981 Refactored into namespaced packages. 2025-10-31 19:12:21 -04:00
jay 223c9faec0 Add test. 2025-10-27 17:40:46 -04:00
32 changed files with 1853 additions and 1340 deletions
+4
View File
@@ -0,0 +1,4 @@
# go-roots
## Testing
- `go test ./...`
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 nostr:npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+124 -221
View File
@@ -1,4 +1,4 @@
# Go-Roots - Nostr Protocol Library for Golang
# go-roots Nostr Protocol Library for Go
Source: https://git.wisehodl.dev/jay/go-roots
@@ -6,22 +6,18 @@ Mirror: https://github.com/wisehodl/go-roots
## What this library does
`go-roots` is a purposefully minimal 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,317 +27,224 @@ mechanisms, and user interfaces.
go get git.wisehodl.dev/jay/go-roots
```
2. Import it with:
If the primary repository is unavailable, use the `replace` directive in your go.mod file to get the package from the GitHub mirror:
```golang
import "git.wisehodl.dev/jay/go-roots"
```
replace git.wisehodl.dev/jay/go-roots => github.com/wisehodl/go-roots latest
```
3. Access it with the `roots` namespace.
2. Import the packages:
## Usage Examples
```go
import (
"git.wisehodl.dev/jay/go-roots/errors"
"git.wisehodl.dev/jay/go-roots/events"
"git.wisehodl.dev/jay/go-roots/filters"
"git.wisehodl.dev/jay/go-roots/keys"
)
```
## General Use
`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
#### Generate a new keypair
```go
privateKey, err := roots.GeneratePrivateKey()
privateKey, err := keys.GeneratePrivateKey()
if err != nil {
log.Fatal(err)
}
publicKey, err := roots.GetPublicKey(privateKey)
publicKey, err := keys.GetPublicKey(privateKey)
if err != nil {
log.Fatal(err)
}
```
#### Derive public key from existing private key
#### Derive public key from an existing private key
```go
privateKey := "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167"
publicKey, err := roots.GetPublicKey(privateKey)
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 := roots.Event{
PubKey: publicKey,
CreatedAt: int(time.Now().Unix()),
Kind: 1,
Tags: []roots.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, err := event.GetID()
if err != nil {
log.Fatal(err)
}
event.ID = id
// 2. Compute and assign the ID
event.ID = events.GetID(event)
// 3. Sign the event
sig, err := roots.SignEvent(id, privateKey)
sig, err := events.SignEvent(event.ID, privateKey)
if err != nil {
log.Fatal(err)
}
event.Sig = sig
// 4. Promote to ValidatedEvent
validated, err := events.NewValidatedEvent(event)
if err != nil {
log.Fatal(err) // construction bug — treat as a defect, not a runtime condition
}
```
#### Serialize an event for ID computation
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
// Returns canonical JSON: [0, pubkey, created_at, kind, tags, content]
serialized, err := event.Serialize()
var event events.Event
if err := json.Unmarshal(data, &event); err != nil {
log.Printf("Malformed JSON: %v", err)
return
}
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
Call `json.Marshal` on a `ValidatedEvent`. The output is guaranteed to reflect a cryptographically verified event.
```go
jsonBytes, err := json.Marshal(validated)
if err != nil {
log.Fatal(err)
}
```
#### Compute event ID manually
Calling `json.Marshal` on a plain `Event` is discouraged: the resulting JSON carries no integrity guarantee.
```go
id, err := event.GetID()
if err != nil {
log.Fatal(err)
}
// Returns lowercase hex SHA-256 hash of serialized form
```
### The `NewValidatedEvent` gate
---
`NewValidatedEvent` is the single promotion point. It verifies:
### Event Validation
- 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
#### Validate complete event
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.
```go
// Checks structure, ID computation, and signature
if err := event.Validate(); err != nil {
log.Printf("Invalid event: %v", err)
}
```
### Filters
#### Validate individual aspects
```go
// Check field formats and lengths
if err := event.ValidateStructure(); err != nil {
log.Printf("Malformed structure: %v", err)
}
// Verify ID matches computed hash
if err := event.ValidateID(); err != nil {
log.Printf("ID mismatch: %v", err)
}
// Verify cryptographic signature
if err := event.ValidateSignature(); err != nil {
log.Printf("Invalid signature: %v", err)
}
```
---
### Event JSON
#### Marshal event to JSON
```go
jsonBytes, err := json.Marshal(event)
if err != nil {
log.Fatal(err)
}
// Standard encoding/json works with Event struct tags
```
#### Unmarshal event from JSON
```go
var event roots.Event
err := json.Unmarshal(jsonBytes, &event)
if err != nil {
log.Fatal(err)
}
// Validate after unmarshaling
if err := event.Validate(); err != nil {
log.Printf("Received invalid event: %v", err)
}
```
---
### Filter Creation
#### Basic filter with standard fields
#### Build a filter
```go
since := int(time.Now().Add(-24 * time.Hour).Unix())
limit := 50
filter := roots.Filter{
IDs: []string{"abc123", "def456"}, // Prefix match
Authors: []string{"cfa87f35"}, // Prefix match
filter := filters.Filter{
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 := roots.Filter{
filter := filters.Filter{
Kinds: []int{1},
Tags: roots.TagFilters{
Tags: filters.TagFilters{
"e": {"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"},
"p": {"91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"},
},
}
```
#### Filter with extensions (custom fields)
#### Match events against a filter
`Matches` accepts a `ValidatedEvent`. Unvalidated events cannot be passed to it.
```go
// Extensions allow arbitrary JSON fields beyond the standard filter spec.
// For example, this is how to implement non-standard filters like 'search'.
filter := roots.Filter{
Kinds: []int{1},
Extensions: roots.FilterExtensions{
"search": json.RawMessage(`"bitcoin"`),
},
}
// Extensions are preserved during marshal/unmarshal but ignored by Matches().
// Storage/transport layers can inspect Extensions to implement custom behavior.
```
---
### Filter Matching
#### Match single event
```go
filter := roots.Filter{
filter := filters.Filter{
Authors: []string{"cfa87f35"},
Kinds: []int{1},
}
if filter.Matches(&event) {
// Event satisfies all filter conditions
}
```
#### Filter event collection
```go
since := int(time.Now().Add(-1 * time.Hour).Unix())
filter := roots.Filter{
Kinds: []int{1},
Since: &since,
Tags: roots.TagFilters{
"p": {"abc123", "def456"}, // OR within tag values
},
}
var matches []roots.Event
for _, event := range events {
if filter.Matches(&event) {
matches = append(matches, event)
var matched []events.ValidatedEvent
for _, event := range incoming {
if filters.Matches(filter, 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 := roots.Filter{
IDs: []string{"abc123"},
Kinds: []int{1},
Tags: roots.TagFilters{
"e": {"event-id"},
},
Extensions: roots.FilterExtensions{
"search": json.RawMessage(`"nostr"`),
},
}
// Marshal
jsonBytes, err := filters.MarshalJSON(filter)
// {"authors":["cfa87f35"],"kinds":[1],"#e":["..."],"since":...}
jsonBytes, err := filter.MarshalJSON()
// Result: {"ids":["abc123"],"kinds":[1],"#e":["event-id"],"search":"nostr"}
```
#### Unmarshal filter from JSON
```go
jsonData := `{
"authors": ["cfa87f35"],
"kinds": [1],
"#e": ["abc123"],
"since": 1234567890,
"search": "bitcoin"
}`
var filter roots.Filter
err := filter.UnmarshalJSON([]byte(jsonData))
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 := roots.Filter{
filter := filters.Filter{
Kinds: []int{1},
Extensions: roots.FilterExtensions{
Extensions: filters.FilterExtensions{
"search": json.RawMessage(`"bitcoin"`),
},
}
// 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
}
```
---
## 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
This library contains a comprehensive suite of unit tests. Run them with:
```bash
go test
go test ./...
```
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
latest=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
IFS='.' read -r major minor patch <<< "${latest#v}"
case ${1:-patch} in
major) new="v$((major+1)).0.0" ;;
minor) new="v${major}.$((minor+1)).0" ;;
patch) new="v${major}.${minor}.$((patch+1))" ;;
*) echo "Usage: bump.sh [major|minor|patch]" >&2; exit 1 ;;
esac
git tag -a "$new"
+1 -1
View File
@@ -1 +1 @@
code2prompt -e "go.sum" -e "README.md" -e "c2p" .
code2prompt -c -e "go.sum" -e "c2p"
+25
View File
@@ -0,0 +1,25 @@
package errors
import (
"errors"
)
var (
// MalformedPubKey indicates a public key is not 64 lowercase hex characters.
MalformedPubKey = errors.New("public key must be 64 lowercase hex characters")
// MalformedPrivKey indicates a private key is not 64 lowercase hex characters.
MalformedPrivKey = errors.New("private key must be 64 lowercase hex characters")
// MalformedID indicates an event id is not 64 hex characters.
MalformedID = errors.New("event id must be 64 hex characters")
// MalformedSig indicates an event signature is not 128 hex characters.
MalformedSig = errors.New("event signature must be 128 hex characters")
// MalformedTag indicates an event tag contains fewer than two elements.
MalformedTag = errors.New("tags must contain at least two elements")
// InvalidSig indicates the event signature failed cryptographic validation.
InvalidSig = errors.New("event signature is invalid")
)
-62
View File
@@ -1,62 +0,0 @@
// Roots is a purposefully minimal Nostr protocol library that provides only
// the primitives that define protocol compliance: event structure,
// serialization, cryptographic signatures, and subscription filters.
package roots
import (
"errors"
"regexp"
)
// 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.
type Tag []string
// Event represents a Nostr protocol event, with its seven required fields.
// All fields must be present for a valid event.
type Event struct {
ID string `json:"id"`
PubKey string `json:"pubkey"`
CreatedAt int `json:"created_at"`
Kind int `json:"kind"`
Tags []Tag `json:"tags"`
Content string `json:"content"`
Sig string `json:"sig"`
}
var (
// Hex64Pattern matches 64-character, lowercase, hexadecimal strings.
// Used for validating event IDs and cryptographic keys.
Hex64Pattern = regexp.MustCompile("^[a-f0-9]{64}$")
// Hex128Pattern matches 128-character, lowercase, hexadecimal strings.
// Used for validating signatures.
Hex128Pattern = regexp.MustCompile("^[a-f0-9]{128}$")
)
var (
// ErrMalformedPubKey indicates a public key is not 64 lowercase hex characters.
ErrMalformedPubKey = errors.New("public key must be 64 lowercase hex characters")
// ErrMalformedPrivKey indicates a private key is not 64 lowercase hex characters.
ErrMalformedPrivKey = errors.New("private key must be 64 lowercase hex characters")
// ErrMalformedID indicates an event id is not 64 hex characters.
ErrMalformedID = errors.New("event id must be 64 hex characters")
// ErrMalformedSig indicates an event signature is not 128 hex characters.
ErrMalformedSig = errors.New("event signature must be 128 hex characters")
// ErrMalformedTag indicates an event tag contains fewer than two elements.
ErrMalformedTag = errors.New("tags must contain at least two elements")
// ErrFailedIDComp indicates the event ID could not be computed during validation.
ErrFailedIDComp = errors.New("failed to compute event id")
// ErrNoEventID indicates the event ID field is empty.
ErrNoEventID = errors.New("event id is empty")
// ErrInvalidSig indicates the event signature failed cryptographic validation.
ErrInvalidSig = errors.New("event signature is invalid")
)
+76
View File
@@ -0,0 +1,76 @@
// Roots is a purposefully minimal Nostr protocol library that provides only
// the primitives that define protocol compliance: event structure,
// 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.
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 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
}
}
+214
View File
@@ -0,0 +1,214 @@
// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.
package events
import (
json "encoding/json"
easyjson "github.com/mailru/easyjson"
jlexer "github.com/mailru/easyjson/jlexer"
jwriter "github.com/mailru/easyjson/jwriter"
)
// suppress unused package warning
var (
_ *json.RawMessage
_ *jlexer.Lexer
_ *jwriter.Writer
_ easyjson.Marshaler
)
func easyjsonF642ad3eDecodeGitWisehodlDevJayGoRootsEvents(in *jlexer.Lexer, out *Event) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
in.Consumed()
}
in.Skip()
return
}
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeFieldName(false)
in.WantColon()
switch key {
case "id":
if in.IsNull() {
in.Skip()
} else {
out.ID = string(in.String())
}
case "pubkey":
if in.IsNull() {
in.Skip()
} else {
out.PubKey = string(in.String())
}
case "created_at":
if in.IsNull() {
in.Skip()
} else {
out.CreatedAt = int64(in.Int64())
}
case "kind":
if in.IsNull() {
in.Skip()
} else {
out.Kind = int(in.Int())
}
case "tags":
if in.IsNull() {
in.Skip()
out.Tags = nil
} else {
in.Delim('[')
if out.Tags == nil {
if !in.IsDelim(']') {
out.Tags = make([]Tag, 0, 2)
} else {
out.Tags = []Tag{}
}
} else {
out.Tags = (out.Tags)[:0]
}
for !in.IsDelim(']') {
var v1 Tag
if in.IsNull() {
in.Skip()
v1 = nil
} else {
in.Delim('[')
if v1 == nil {
if !in.IsDelim(']') {
v1 = make(Tag, 0, 4)
} else {
v1 = Tag{}
}
} else {
v1 = (v1)[:0]
}
for !in.IsDelim(']') {
var v2 string
if in.IsNull() {
in.Skip()
} else {
v2 = string(in.String())
}
v1 = append(v1, v2)
in.WantComma()
}
in.Delim(']')
}
out.Tags = append(out.Tags, v1)
in.WantComma()
}
in.Delim(']')
}
case "content":
if in.IsNull() {
in.Skip()
} else {
out.Content = string(in.String())
}
case "sig":
if in.IsNull() {
in.Skip()
} else {
out.Sig = string(in.String())
}
default:
in.SkipRecursive()
}
in.WantComma()
}
in.Delim('}')
if isTopLevel {
in.Consumed()
}
}
func easyjsonF642ad3eEncodeGitWisehodlDevJayGoRootsEvents(out *jwriter.Writer, in Event) {
out.RawByte('{')
first := true
_ = first
{
const prefix string = ",\"id\":"
out.RawString(prefix[1:])
out.String(string(in.ID))
}
{
const prefix string = ",\"pubkey\":"
out.RawString(prefix)
out.String(string(in.PubKey))
}
{
const prefix string = ",\"created_at\":"
out.RawString(prefix)
out.Int64(int64(in.CreatedAt))
}
{
const prefix string = ",\"kind\":"
out.RawString(prefix)
out.Int(int(in.Kind))
}
{
const prefix string = ",\"tags\":"
out.RawString(prefix)
if in.Tags == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v3, v4 := range in.Tags {
if v3 > 0 {
out.RawByte(',')
}
if v4 == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v5, v6 := range v4 {
if v5 > 0 {
out.RawByte(',')
}
out.String(string(v6))
}
out.RawByte(']')
}
}
out.RawByte(']')
}
}
{
const prefix string = ",\"content\":"
out.RawString(prefix)
out.String(string(in.Content))
}
{
const prefix string = ",\"sig\":"
out.RawString(prefix)
out.String(string(in.Sig))
}
out.RawByte('}')
}
// MarshalJSON supports json.Marshaler interface
func (v Event) MarshalJSON() ([]byte, error) {
w := jwriter.Writer{}
easyjsonF642ad3eEncodeGitWisehodlDevJayGoRootsEvents(&w, v)
return w.Buffer.BuildBytes(), w.Error
}
// MarshalEasyJSON supports easyjson.Marshaler interface
func (v Event) MarshalEasyJSON(w *jwriter.Writer) {
easyjsonF642ad3eEncodeGitWisehodlDevJayGoRootsEvents(w, v)
}
// UnmarshalJSON supports json.Unmarshaler interface
func (v *Event) UnmarshalJSON(data []byte) error {
r := jlexer.Lexer{Data: data}
easyjsonF642ad3eDecodeGitWisehodlDevJayGoRootsEvents(&r, v)
return r.Error()
}
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
func (v *Event) UnmarshalEasyJSON(l *jlexer.Lexer) {
easyjsonF642ad3eDecodeGitWisehodlDevJayGoRootsEvents(l, v)
}
@@ -1,4 +1,4 @@
package roots
package events
import (
"encoding/json"
@@ -7,9 +7,9 @@ import (
)
func TestUnmarshalEventJSON(t *testing.T) {
event := Event{}
event := NewEvent()
json.Unmarshal(testEventJSONBytes, &event)
if err := event.Validate(); err != nil {
if err := Validate(event); err != nil {
t.Error("unmarshalled event is invalid")
}
expectEqualEvents(t, event, testEvent)
@@ -22,22 +22,20 @@ 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 := event.Validate(); err != nil {
if err := Validate(event); err != nil {
t.Error("test event is invalid")
}
eventJSON, err := json.Marshal(event)
@@ -47,7 +45,7 @@ func TestEventJSONRoundTrip(t *testing.T) {
unmarshalledEvent := Event{}
json.Unmarshal(eventJSON, &unmarshalledEvent)
if err := unmarshalledEvent.Validate(); err != nil {
if err := Validate(unmarshalledEvent); err != nil {
t.Error("unmarshalled event is invalid")
}
expectEqualEvents(t, unmarshalledEvent, event)
+10 -11
View File
@@ -1,4 +1,4 @@
package roots
package events
import (
"github.com/stretchr/testify/assert"
@@ -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) {
+81
View File
@@ -0,0 +1,81 @@
package events
import (
"crypto/sha256"
"encoding/hex"
"strconv"
)
// 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 := sha256.Sum256(Serialize(e))
return hex.EncodeToString(hash[:])
}
// 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 {
buf := make([]byte, 0, 100+len(e.Content)+len(e.Tags)*80)
return appendSerialized(buf, e)
}
func appendSerialized(dst []byte, e Event) []byte {
dst = append(dst, "[0,\""...)
dst = append(dst, e.PubKey...)
dst = append(dst, "\","...)
dst = strconv.AppendInt(dst, int64(e.CreatedAt), 10)
dst = append(dst, ',')
dst = strconv.AppendInt(dst, int64(e.Kind), 10)
dst = append(dst, ",["...)
for i, tag := range e.Tags {
if i > 0 {
dst = append(dst, ',')
}
dst = append(dst, '[')
for j, s := range tag {
if j > 0 {
dst = append(dst, ',')
}
dst = appendEscapedString(dst, s)
}
dst = append(dst, ']')
}
dst = append(dst, "],"...)
dst = appendEscapedString(dst, e.Content)
return append(dst, ']')
}
func appendEscapedString(dst []byte, s string) []byte {
dst = append(dst, '"')
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '"':
dst = append(dst, '\\', '"')
case c == '\\':
dst = append(dst, '\\', '\\')
case c >= 0x20:
dst = append(dst, c)
case c == 0x08:
dst = append(dst, '\\', 'b')
case c == 0x09:
dst = append(dst, '\\', 't')
case c == 0x0a:
dst = append(dst, '\\', 'n')
case c == 0x0c:
dst = append(dst, '\\', 'f')
case c == 0x0d:
dst = append(dst, '\\', 'r')
case c < 0x09:
dst = append(dst, '\\', 'u', '0', '0', '0', '0'+c)
case c < 0x10:
dst = append(dst, '\\', 'u', '0', '0', '0', 0x57+c)
case c < 0x1a:
dst = append(dst, '\\', 'u', '0', '0', '1', 0x20+c)
case c < 0x20:
dst = append(dst, '\\', 'u', '0', '0', '1', 0x47+c)
}
}
return append(dst, '"')
}
+183
View File
@@ -0,0 +1,183 @@
package events
import (
"github.com/stretchr/testify/assert"
"testing"
)
type IDTestCase struct {
name string
event Event
expected string
}
var idTestCases = []IDTestCase{
{
name: "minimal event",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(1),
),
expected: "13a55672a600398894592f4cb338652d4936caffe5d3718d11597582bb030c39",
},
{
name: "alphanumeric content",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(1),
WithContent("hello world"),
),
expected: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad",
},
{
name: "unicode content",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(1),
WithContent("hello world 😀"),
),
expected: "e42083fafbf9a39f97914fd9a27cedb38c429ac3ca8814288414eaad1f472fe8",
},
{
name: "escaped content",
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: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(1),
WithContent("{\"field\": [\"value\",\"value\"],\"numeral\": 123}"),
),
expected: "c6140190453ee947efb790e70541a9d37c41604d1f29e4185da4325621ed5270",
},
{
name: "empty tag",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(1),
WithTag(Tag{"a", ""}),
WithContent(""),
),
expected: "7d3e394c75916362436f11c603b1a89b40b50817550cfe522a90d769655007a4",
},
{
name: "single tag",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(1),
WithTag(Tag{"a", "value"}),
WithContent(""),
),
expected: "7db394e274fb893edbd9f4aa9ff189d4f3264bf1a29cef8f614e83ebf6fa19fe",
},
{
name: "optional tag values",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(1),
WithTag(Tag{"a", "value", "optional"}),
WithContent(""),
),
expected: "656b47884200959e0c03054292c453cfc4beea00b592d92c0f557bff765e9d34",
},
{
name: "multiple tags",
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: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(1),
WithTag(Tag{"a", "😀"}),
WithContent(""),
),
expected: "fd2798d165d9bf46acbe817735dc8cedacd4c42dfd9380792487d4902539e986",
},
{
name: "zero timestamp",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(0),
WithKind(1),
WithContent(""),
),
expected: "9ca742f2e2eea72ad6e0277a6287e2bb16a3e47d64b8468bc98474e266cf0ec2",
},
{
name: "negative timestamp",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(-1760740551),
WithKind(1),
WithContent(""),
),
expected: "4740b027040bb4d0ee8e885f567a80277097da70cddd143d8a6dadf97f6faaa3",
},
{
name: "max int64 timestamp",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(9223372036854775807),
WithKind(1),
WithContent(""),
),
expected: "b28cdd44496acb49e36c25859f0f819122829a12dc57c07612d5f44cb121d2a7",
},
{
name: "different kind",
event: NewEvent(
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(20021),
WithContent(""),
),
expected: "995c4894c264e6b9558cb94b7b34008768d53801b99960b47298d4e3e23fadd3",
},
}
func TestEventGetId(t *testing.T) {
for _, tc := range idTestCases {
t.Run(tc.name, func(t *testing.T) {
actual := GetID(tc.event)
assert.Equal(t, tc.expected, actual)
})
}
}
+4 -3
View File
@@ -1,8 +1,9 @@
package roots
package events
import (
"encoding/hex"
"fmt"
"git.wisehodl.dev/jay/go-roots/errors"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
)
@@ -12,12 +13,12 @@ import (
func SignEvent(eventID, privateKeyHex string) (string, error) {
skBytes, err := hex.DecodeString(privateKeyHex)
if err != nil {
return "", ErrMalformedPrivKey
return "", errors.MalformedPrivKey
}
idBytes, err := hex.DecodeString(eventID)
if err != nil {
return "", ErrMalformedID
return "", errors.MalformedID
}
// discard public key return value
+1 -1
View File
@@ -1,4 +1,4 @@
package roots
package events
import (
"github.com/stretchr/testify/assert"
+125
View File
@@ -0,0 +1,125 @@
package events
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"git.wisehodl.dev/jay/go-roots/errors"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
)
// Validate performs a complete event validation: structure, ID computation,
// and signature verification. Returns the first error encountered.
func Validate(e Event) error {
if err := ValidateStructure(e); err != nil {
return err
}
idHash := sha256.Sum256(Serialize(e))
idBytes, err := hex.DecodeString(e.ID)
if err != nil {
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)
}
// ValidateStructure checks that all event fields conform to the protocol
// specification: hex lengths, tag structure, and field formats.
func ValidateStructure(e Event) error {
if !IsValidKey(e.PubKey) {
return errors.MalformedPubKey
}
if !IsValidID(e.ID) {
return errors.MalformedID
}
if !IsValidSig(e.Sig) {
return errors.MalformedSig
}
for _, tag := range e.Tags {
if len(tag) < 2 {
return errors.MalformedTag
}
}
return nil
}
// ValidateSignature verifies the event signature is cryptographically valid
// for the event ID and public key using Schnorr verification.
func ValidateSignature(e Event) error {
idBytes, err := hex.DecodeString(e.ID)
if err != nil {
return fmt.Errorf("invalid event id hex: %w", err)
}
return validateSignatureBytes(idBytes, e.Sig, e.PubKey)
}
// Value validators
// 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 {
return fmt.Errorf("invalid event signature hex: %w", err)
}
pkBytes, err := hex.DecodeString(pkHex)
if err != nil {
return fmt.Errorf("invalid public key hex: %w", err)
}
signature, err := schnorr.ParseSignature(sigBytes)
if err != nil {
return fmt.Errorf("malformed signature: %w", err)
}
publicKey, err := schnorr.ParsePubKey(pkBytes)
if err != nil {
return fmt.Errorf("malformed public key: %w", err)
}
if signature.Verify(idBytes, publicKey) {
return nil
}
return errors.InvalidSig
}
func isLowerHex(s string, n int) bool {
if len(s) != n {
return false
}
for i := 0; i < n; i++ {
c := s[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
+285
View File
@@ -0,0 +1,285 @@
package events
import (
"github.com/stretchr/testify/assert"
"testing"
)
type ValidateEventTestCase struct {
name string
event Event
expectedError string
}
var structureTestCases = []ValidateEventTestCase{
{
name: "empty pubkey",
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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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",
},
}
func TestValidateEventStructure(t *testing.T) {
for _, tc := range structureTestCases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateStructure(tc.event)
assert.ErrorContains(t, err, tc.expectedError)
})
}
}
func TestValidateSignature(t *testing.T) {
event := NewEvent(
WithID(testEvent.ID),
WithPubKey(testEvent.PubKey),
WithSig(testEvent.Sig),
)
err := ValidateSignature(event)
assert.NoError(t, err)
}
func TestValidateInvalidSignature(t *testing.T) {
event := NewEvent(
WithID(testEvent.ID),
WithPubKey(testEvent.PubKey),
WithSig("9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482"),
)
err := ValidateSignature(event)
assert.ErrorContains(t, err, "event signature is invalid")
}
type ValidateSignatureTestCase struct {
name string
id string
sig string
pubkey string
expectedError string
}
var validateSignatureTestCases = []ValidateSignatureTestCase{
{
name: "bad event id",
id: "badeventid",
sig: testEvent.Sig,
pubkey: testEvent.PubKey,
expectedError: "invalid event id hex",
},
{
name: "bad event signature",
id: testEvent.ID,
sig: "badeventsignature",
pubkey: testEvent.PubKey,
expectedError: "invalid event signature hex",
},
{
name: "bad public key",
id: testEvent.ID,
sig: testEvent.Sig,
pubkey: "badpublickey",
expectedError: "invalid public key hex",
},
{
name: "malformed event signature",
id: testEvent.ID,
sig: "abc123",
pubkey: testEvent.PubKey,
expectedError: "malformed signature",
},
{
name: "malformed public key",
id: testEvent.ID,
sig: testEvent.Sig,
pubkey: "abc123",
expectedError: "malformed public key",
},
}
func TestValidateSignatureInvalidEventSignature(t *testing.T) {
for _, tc := range validateSignatureTestCases {
t.Run(tc.name, func(t *testing.T) {
event := NewEvent(
WithID(tc.id),
WithPubKey(tc.pubkey),
WithSig(tc.sig),
)
err := ValidateSignature(event)
assert.ErrorContains(t, err, tc.expectedError)
})
}
}
func TestValidateEvent(t *testing.T) {
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)
}
+58
View File
@@ -0,0 +1,58 @@
package events
// ValidatedEvent is an immutable wrapper around a fully validated event. It
// shares no memory with the original Event struct.
//
// When created with NewValidatedEvent, the wrapped event is guaranteed to:
// - have a valid structure
// - have a valid ID
// - have a valid signature
type ValidatedEvent struct {
event Event
}
// NewValidatedEvent validates the provided Event. If valid, returns an
// immutable ValidatedEvent. Otherwise returns an error.
func NewValidatedEvent(e Event) (ValidatedEvent, error) {
if err := Validate(e); err != nil {
return ValidatedEvent{}, err
}
e.Tags = deepCopyTags(e.Tags)
return ValidatedEvent{event: e}, nil
}
func (v ValidatedEvent) ID() string { return v.event.ID }
func (v ValidatedEvent) PubKey() string { return v.event.PubKey }
func (v ValidatedEvent) CreatedAt() int64 { return v.event.CreatedAt }
func (v ValidatedEvent) Kind() int { return v.event.Kind }
func (v ValidatedEvent) Content() string { return v.event.Content }
func (v ValidatedEvent) Sig() string { return v.event.Sig }
// Tags returns a deep copy of the event's tag list. The returned slice shares
// no memory with the ValidatedEvent.
func (v ValidatedEvent) Tags() []Tag { return deepCopyTags(v.event.Tags) }
// Event returns a deep copy of the underlying Event. The returned Event shares
// no memory with the ValidatedEvent.
func (v ValidatedEvent) Event() Event {
e := v.event
e.Tags = deepCopyTags(v.event.Tags)
return e
}
func deepCopyTags(tags []Tag) []Tag {
if tags == nil {
return nil
}
cp := make([]Tag, len(tags))
for i, tag := range tags {
tagcp := make(Tag, len(tag))
copy(tagcp, tag)
cp[i] = tagcp
}
return cp
}
func (v ValidatedEvent) MarshalJSON() ([]byte, error) {
return v.event.MarshalJSON()
}
+216
View File
@@ -0,0 +1,216 @@
package events
import (
"encoding/json"
"github.com/stretchr/testify/assert"
"testing"
)
var testEventWithTags = NewEvent(
WithID("c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400"),
WithPubKey(testPK),
WithCreatedAt(1760740551),
WithKind(1),
WithTag(Tag{"a", "value"}),
WithTag(Tag{"b", "value", "optional"}),
WithContent("valid event"),
WithSig("668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14"),
)
func TestValidatedEventConstruction(t *testing.T) {
t.Run("accepts valid event", func(t *testing.T) {
_, err := NewValidatedEvent(testEvent)
assert.NoError(t, err)
})
t.Run("rejects invalid structure", func(t *testing.T) {
bad := NewEvent(
WithID(testEvent.ID),
WithPubKey("notavalidpubkey"),
WithCreatedAt(testEvent.CreatedAt),
WithKind(testEvent.Kind),
WithContent(testEvent.Content),
WithSig(testEvent.Sig),
)
_, err := NewValidatedEvent(bad)
assert.ErrorContains(t, err, "public key must be 64 lowercase hex characters")
})
t.Run("rejects id mismatch", func(t *testing.T) {
bad := NewEvent(
WithID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(testEvent.Kind),
WithContent(testEvent.Content),
WithSig(testEvent.Sig),
)
_, err := NewValidatedEvent(bad)
assert.ErrorContains(t, err, "does not match computed id")
})
t.Run("rejects invalid signature", func(t *testing.T) {
bad := NewEvent(
WithID(testEvent.ID),
WithPubKey(testEvent.PubKey),
WithCreatedAt(testEvent.CreatedAt),
WithKind(testEvent.Kind),
WithContent(testEvent.Content),
WithSig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
)
_, err := NewValidatedEvent(bad)
assert.ErrorContains(t, err, "event signature is invalid")
})
}
func TestValidatedEventAccessors(t *testing.T) {
ve, err := NewValidatedEvent(testEventWithTags)
assert.NoError(t, err)
t.Run("ID", func(t *testing.T) {
assert.Equal(t, testEventWithTags.ID, ve.ID())
})
t.Run("PubKey", func(t *testing.T) {
assert.Equal(t, testEventWithTags.PubKey, ve.PubKey())
})
t.Run("CreatedAt", func(t *testing.T) {
assert.Equal(t, testEventWithTags.CreatedAt, ve.CreatedAt())
})
t.Run("Kind", func(t *testing.T) {
assert.Equal(t, testEventWithTags.Kind, ve.Kind())
})
t.Run("Content", func(t *testing.T) {
assert.Equal(t, testEventWithTags.Content, ve.Content())
})
t.Run("Sig", func(t *testing.T) {
assert.Equal(t, testEventWithTags.Sig, ve.Sig())
})
t.Run("Tags", func(t *testing.T) {
assert.Equal(t, testEventWithTags.Tags, ve.Tags())
})
}
func TestValidatedEventTagsImmutability(t *testing.T) {
t.Run("outer slice mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
tags := ve.Tags()
tags[0] = Tag{"z", "injected"}
assert.Equal(t, Tag{"a", "value"}, ve.Tags()[0])
})
t.Run("inner slice mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
tags := ve.Tags()
tags[0][1] = "mutated"
assert.Equal(t, "value", ve.Tags()[0][1])
})
t.Run("append to outer slice is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
originalLen := len(ve.Tags())
tags := ve.Tags()
tags = append(tags, Tag{"new", "tag"})
_ = tags
assert.Equal(t, originalLen, len(ve.Tags()))
})
t.Run("append to inner slice is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
originalLen := len(ve.Tags()[1])
tags := ve.Tags()
tags[1] = append(tags[1], "extra")
assert.Equal(t, originalLen, len(ve.Tags()[1]))
})
t.Run("successive calls return independent copies", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
first := ve.Tags()
second := ve.Tags()
first[0][0] = "z"
assert.Equal(t, "a", second[0][0])
})
t.Run("nil tags remain nil", func(t *testing.T) {
nil_tags_event := Event{
ID: testEvent.ID,
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: testEvent.Kind,
Tags: nil,
Content: testEvent.Content,
Sig: testEvent.Sig,
}
ve, err := NewValidatedEvent(nil_tags_event)
assert.NoError(t, err)
assert.Nil(t, ve.Tags())
assert.Nil(t, ve.Event().Tags)
})
}
func TestValidatedEventEventImmutability(t *testing.T) {
t.Run("outer tags mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
e := ve.Event()
e.Tags[0] = Tag{"z", "injected"}
assert.Equal(t, Tag{"a", "value"}, ve.Tags()[0])
})
t.Run("inner tags mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
e := ve.Event()
e.Tags[0][1] = "mutated"
assert.Equal(t, "value", ve.Tags()[0][1])
})
t.Run("scalar field mutation is isolated", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEvent)
e := ve.Event()
e.Content = "tampered"
assert.Equal(t, testEvent.Content, ve.Content())
})
t.Run("successive calls return independent copies", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
first := ve.Event()
second := ve.Event()
first.Tags[0][0] = "z"
assert.Equal(t, "a", second.Tags[0][0])
})
t.Run("returned event passes validation", func(t *testing.T) {
ve, _ := NewValidatedEvent(testEventWithTags)
assert.NoError(t, Validate(ve.Event()))
})
t.Run("source event mutation after construction is isolated", func(t *testing.T) {
source := NewEvent(
WithID(testEventWithTags.ID),
WithPubKey(testEventWithTags.PubKey),
WithCreatedAt(testEventWithTags.CreatedAt),
WithKind(testEventWithTags.Kind),
WithTag(Tag{"a", "value"}),
WithTag(Tag{"b", "value", "optional"}),
WithContent(testEventWithTags.Content),
WithSig(testEventWithTags.Sig),
)
ve, err := NewValidatedEvent(source)
assert.NoError(t, err)
source.Tags[0][1] = "mutated"
source.Content = "tampered"
assert.Equal(t, "value", ve.Tags()[0][1])
assert.Equal(t, testEventWithTags.Content, ve.Content())
})
}
func TestValidatedEventMarshalJSON(t *testing.T) {
ve, _ := NewValidatedEvent(testEvent)
jsonBytes, err := json.Marshal(ve)
assert.NoError(t, err)
assert.Equal(t, testEventJSON, string(jsonBytes))
}
+83 -14
View File
@@ -1,7 +1,8 @@
package roots
package filters
import (
"encoding/json"
"git.wisehodl.dev/jay/go-roots/events"
"strings"
)
@@ -19,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 (f *Filter) MarshalJSON() ([]byte, error) {
func (f Filter) MarshalJSON() ([]byte, error) {
outputMap := make(map[string]interface{})
// Add standard fields
@@ -118,7 +186,7 @@ func (f *Filter) UnmarshalJSON(data []byte) 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
}
@@ -131,7 +199,7 @@ func (f *Filter) UnmarshalJSON(data []byte) 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
}
@@ -181,36 +249,37 @@ func (f *Filter) UnmarshalJSON(data []byte) 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 (f *Filter) Matches(event *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
}
}
@@ -236,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
}
@@ -246,7 +315,7 @@ func matchesTimeRange(timestamp int, since *int, until *int) bool {
return true
}
func matchesTags(eventTags []Tag, tagFilters *TagFilters) bool {
func matchesTags(eventTags []events.Tag, tagFilters *TagFilters) bool {
// Build index of tags and values
eventIndex := make(map[string][]string, len(eventTags))
for _, tag := range eventTags {
@@ -1,4 +1,4 @@
package roots
package filters
import (
"encoding/json"
@@ -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}},
name: "mixed fields",
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}},
name: "mixed fields",
input: `{"ids": null, "authors": [], "kinds": [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 := tc.filter.MarshalJSON()
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 := result.UnmarshalJSON([]byte(tc.input))
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 := tc.filter.MarshalJSON()
jsonBytes, err := json.Marshal(tc.filter)
assert.NoError(t, err)
var result Filter
err = result.UnmarshalJSON(jsonBytes)
err = json.Unmarshal(jsonBytes, &result)
assert.NoError(t, err)
expectEqualFilters(t, result, tc.filter)
@@ -1,26 +1,35 @@
package roots
package filters
import (
"encoding/json"
"git.wisehodl.dev/jay/go-roots/events"
"github.com/stretchr/testify/assert"
"os"
"testing"
)
var testEvents []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"
@@ -38,10 +47,10 @@ type FilterTestCase struct {
var filterTestCases = []FilterTestCase{
{
name: "empty filter",
filter: Filter{},
filter: NewFilter(),
expectedIDs: []string{
"e751d41f",
"562bc378",
"2e06c187",
"e67fa7b8",
"5e4c64f1",
"7a5d83d4",
@@ -54,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",
@@ -70,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"}},
name: "single full id",
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",
@@ -110,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",
@@ -128,23 +140,26 @@ var filterTestCases = []FilterTestCase{
},
{
name: "single author full",
filter: Filter{Authors: []string{"d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe"}},
expectedIDs: []string{"e751d41f", "562bc378", "e67fa7b8"},
name: "single author full",
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",
@@ -157,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",
@@ -176,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",
@@ -194,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),
},
name: "time range",
filter: NewFilter(WithSince(4000), WithUntil(6000)),
expectedIDs: []string{
"5e4c64f1",
"7a5d83d4",
@@ -216,23 +228,17 @@ var filterTestCases = []FilterTestCase{
},
{
name: "outside time range",
filter: Filter{
Since: intPtr(10000),
},
name: "outside time range",
filter: NewFilter(WithSince(10000)),
expectedIDs: []string{},
},
{
name: "empty tag filter",
filter: Filter{
Tags: TagFilters{
"e": {},
},
},
name: "empty tag filter",
filter: NewFilter(WithTag("e", []string{})),
expectedIDs: []string{
"e751d41f",
"562bc378",
"2e06c187",
"e67fa7b8",
"5e4c64f1",
"7a5d83d4",
@@ -245,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": {
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7",
},
},
},
expectedIDs: []string{"562bc378", "3a122100"},
filter: NewFilter(
WithTag("e", []string{
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7",
}),
),
expectedIDs: []string{"2e06c187", "3a122100"},
},
{
name: "multiple tag matches - single event match",
filter: Filter{
Tags: TagFilters{
"e": {
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"cb7787c460a79187d6a13e75a0f19240e05fafca8ea42288f5765773ea69cf2f",
},
},
},
expectedIDs: []string{"562bc378"},
filter: NewFilter(
WithTag("e", []string{
"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"cb7787c460a79187d6a13e75a0f19240e05fafca8ea42288f5765773ea69cf2f",
}),
),
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",
@@ -357,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",
},
@@ -370,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",
},
@@ -390,8 +380,8 @@ func TestEventFilterMatching(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
actualIDs := []string{}
for _, event := range testEvents {
if tc.filter.Matches(&event) {
actualIDs = append(actualIDs, event.ID[:8])
if Matches(tc.filter, event) {
actualIDs = append(actualIDs, event.ID()[:8])
}
}
@@ -399,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 := Event{
Tags: []Tag{
{"malformed"},
{"valid", "value"},
},
}
filter := Filter{
Tags: TagFilters{
"valid": {"value"},
},
}
assert.True(t, filter.Matches(&event))
}
@@ -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,12 +123,12 @@
"sig": "7330fd35e0be4a2a64a940a2841474f60b15d5dec9d4c4129905d97bd91cc8e6a97eec66091580b7351a807b7c250544cf500d0e2d47f5744387b1ce4ac49c4d"
},
{
"kind": 2,
"id": "d39e6f3f593bd754a45a6e2f77b1b0669cdfe89c19fb2a4b252ea095caa9874b",
"pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7",
"created_at": 9000,
"tags": [],
"kind": 2,
"tags": null,
"content": "Final event",
"sig": "917d3fa8111cd9dfdc9acad121e7f71e4358d6a4eb0979eadc744b55f78d2647bf839282f6c10afacd64798007c3ef09b8a925c9b73f97c5219098eca1bacc4d"
}
]
]
+2
View File
@@ -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
)
+4
View File
@@ -9,6 +9,10 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/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=
-37
View File
@@ -1,37 +0,0 @@
package roots
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
)
// Serialize returns the canonical JSON array representation of the event.
// used for ID computation: [0, pubkey, created_at, kind, tags, content].
func (e *Event) Serialize() ([]byte, error) {
serialized := []interface{}{
0,
e.PubKey,
e.CreatedAt,
e.Kind,
e.Tags,
e.Content,
}
bytes, err := json.Marshal(serialized)
if err != nil {
return []byte{}, err
}
return bytes, nil
}
// GetID computes and returns the event ID as a lowercase, hex-encoded SHA-256 hash
// of the serialized event.
func (e *Event) GetID() (string, error) {
bytes, err := e.Serialize()
if err != nil {
return "", err
}
hash := sha256.Sum256(bytes)
return hex.EncodeToString(hash[:]), nil
}
-204
View File
@@ -1,204 +0,0 @@
package roots
import (
"github.com/stretchr/testify/assert"
"testing"
)
type IDTestCase struct {
name string
event Event
expected string
}
var idTestCases = []IDTestCase{
{
name: "minimal event",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 1,
Tags: []Tag{},
Content: "",
},
expected: "13a55672a600398894592f4cb338652d4936caffe5d3718d11597582bb030c39",
},
{
name: "alphanumeric content",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 1,
Tags: []Tag{},
Content: "hello world",
},
expected: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad",
},
{
name: "unicode content",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 1,
Tags: []Tag{},
Content: "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.\"",
},
expected: "343de133996a766bf00561945b6f2b2717d4905275976ca75c1d7096b7d1900c",
},
{
name: "json content",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 1,
Tags: []Tag{},
Content: "{\"field\": [\"value\",\"value\"],\"numeral\": 123}",
},
expected: "c6140190453ee947efb790e70541a9d37c41604d1f29e4185da4325621ed5270",
},
{
name: "empty tag",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 1,
Tags: []Tag{
{"a", ""},
},
Content: "",
},
expected: "7d3e394c75916362436f11c603b1a89b40b50817550cfe522a90d769655007a4",
},
{
name: "single tag",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 1,
Tags: []Tag{
{"a", "value"},
},
Content: "",
},
expected: "7db394e274fb893edbd9f4aa9ff189d4f3264bf1a29cef8f614e83ebf6fa19fe",
},
{
name: "optional tag values",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 1,
Tags: []Tag{
{"a", "value", "optional"},
},
Content: "",
},
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: "",
},
expected: "f7c27f2eacda7ece5123a4f82db56145ba59f7c9e6c5eeb88552763664506b06",
},
{
name: "unicode tag",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 1,
Tags: []Tag{
{"a", "😀"},
},
Content: "",
},
expected: "fd2798d165d9bf46acbe817735dc8cedacd4c42dfd9380792487d4902539e986",
},
{
name: "zero timestamp",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: 0,
Kind: 1,
Tags: []Tag{},
Content: "",
},
expected: "9ca742f2e2eea72ad6e0277a6287e2bb16a3e47d64b8468bc98474e266cf0ec2",
},
{
name: "negative timestamp",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: -1760740551,
Kind: 1,
Tags: []Tag{},
Content: "",
},
expected: "4740b027040bb4d0ee8e885f567a80277097da70cddd143d8a6dadf97f6faaa3",
},
{
name: "max int64 timestamp",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: 9223372036854775807,
Kind: 1,
Tags: []Tag{},
Content: "",
},
expected: "b28cdd44496acb49e36c25859f0f819122829a12dc57c07612d5f44cb121d2a7",
},
{
name: "different kind",
event: Event{
PubKey: testEvent.PubKey,
CreatedAt: testEvent.CreatedAt,
Kind: 20021,
Tags: []Tag{},
Content: "",
},
expected: "995c4894c264e6b9558cb94b7b34008768d53801b99960b47298d4e3e23fadd3",
},
}
func TestEventGetId(t *testing.T) {
for _, tc := range idTestCases {
t.Run(tc.name, func(t *testing.T) {
actual, err := tc.event.GetID()
assert.NoError(t, err)
assert.Equal(t, tc.expected, actual)
})
}
}
+4 -3
View File
@@ -1,7 +1,8 @@
package roots
package keys
import (
"encoding/hex"
"git.wisehodl.dev/jay/go-roots/errors"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
@@ -20,11 +21,11 @@ func GeneratePrivateKey() (string, error) {
// and returns the x-coordinate as 64 lowercase hex characters.
func GetPublicKey(privateKeyHex string) (string, error) {
if len(privateKeyHex) != 64 {
return "", ErrMalformedPrivKey
return "", errors.MalformedPrivKey
}
skBytes, err := hex.DecodeString(privateKeyHex)
if err != nil {
return "", ErrMalformedPrivKey
return "", errors.MalformedPrivKey
}
pk := secp256k1.PrivKeyFromBytes(skBytes).PubKey()
+12 -3
View File
@@ -1,4 +1,4 @@
package roots
package keys
import (
"github.com/stretchr/testify/assert"
@@ -6,17 +6,26 @@ import (
"testing"
)
var hexPattern = regexp.MustCompile("^[a-f0-9]{64}$")
const testSK = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167"
const testPK = "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"
var Hex64Pattern = regexp.MustCompile("^[a-f0-9]{64}$")
func TestGeneratePrivateKey(t *testing.T) {
sk, err := GeneratePrivateKey()
assert.NoError(t, err)
if !hexPattern.MatchString(sk) {
if !Hex64Pattern.MatchString(sk) {
t.Errorf("invalid private key format: %s", sk)
}
}
func TestGenerateUniquePrivateKeys(t *testing.T) {
sk1, _ := GeneratePrivateKey()
sk2, _ := GeneratePrivateKey()
assert.NotEqual(t, sk1, sk2)
}
func TestGetPublicKey(t *testing.T) {
pk, err := GetPublicKey(testSK)
-5
View File
@@ -1,5 +0,0 @@
package roots
func intPtr(i int) *int {
return &i
}
-96
View File
@@ -1,96 +0,0 @@
package roots
import (
"encoding/hex"
"fmt"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
)
// Validate performs a complete event validation: structure, ID computation,
// and signature verification. Returns the first error encountered.
func (e *Event) Validate() error {
if err := e.ValidateStructure(); err != nil {
return err
}
if err := e.ValidateID(); err != nil {
return err
}
return e.ValidateSignature()
}
// ValidateStructure checks that all event fields conform to the protocol
// specification: hex lengths, tag structure, and field formats.
func (e *Event) ValidateStructure() error {
if !Hex64Pattern.MatchString(e.PubKey) {
return ErrMalformedPubKey
}
if !Hex64Pattern.MatchString(e.ID) {
return ErrMalformedID
}
if !Hex128Pattern.MatchString(e.Sig) {
return ErrMalformedSig
}
for _, tag := range e.Tags {
if len(tag) < 2 {
return ErrMalformedTag
}
}
return nil
}
// ValidateID recomputes the event ID and verifies it matches the stored ID field.
func (e *Event) ValidateID() error {
computedID, err := e.GetID()
if err != nil {
return ErrFailedIDComp
}
if e.ID == "" {
return ErrNoEventID
}
if computedID != e.ID {
return fmt.Errorf("event id %q does not match computed id %q", e.ID, computedID)
}
return nil
}
// ValidateSignature verifies the event signature is cryptographically valid
// for the event ID and public key using Schnorr verification.
func (e *Event) ValidateSignature() error {
idBytes, err := hex.DecodeString(e.ID)
if err != nil {
return fmt.Errorf("invalid event id hex: %w", err)
}
sigBytes, err := hex.DecodeString(e.Sig)
if err != nil {
return fmt.Errorf("invalid event signature hex: %w", err)
}
pkBytes, err := hex.DecodeString(e.PubKey)
if err != nil {
return fmt.Errorf("invalid public key hex: %w", err)
}
signature, err := schnorr.ParseSignature(sigBytes)
if err != nil {
return fmt.Errorf("malformed signature: %w", err)
}
publicKey, err := schnorr.ParsePubKey(pkBytes)
if err != nil {
return fmt.Errorf("malformed public key: %w", err)
}
if signature.Verify(idBytes, publicKey) {
return nil
} else {
return ErrInvalidSig
}
}
-306
View File
@@ -1,306 +0,0 @@
package roots
import (
"github.com/stretchr/testify/assert"
"testing"
)
type ValidateEventTestCase struct {
name string
event Event
expectedError string
}
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,
},
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,
},
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,
},
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,
},
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,
},
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,
},
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,
},
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: "",
},
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",
},
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,
},
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,
},
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,
},
expectedError: "tags must contain at least two elements",
},
}
func TestValidateEventStructure(t *testing.T) {
for _, tc := range structureTestCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.event.ValidateStructure()
assert.ErrorContains(t, err, tc.expectedError)
})
}
}
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 := event.ValidateID()
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,
}
err := event.ValidateSignature()
assert.NoError(t, err)
}
func TestValidateInvalidSignature(t *testing.T) {
event := Event{
ID: testEvent.ID,
PubKey: testEvent.PubKey,
Sig: "9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482",
}
err := event.ValidateSignature()
assert.ErrorContains(t, err, "event signature is invalid")
}
type ValidateSignatureTestCase struct {
name string
id string
sig string
pubkey string
expectedError string
}
var validateSignatureTestCases = []ValidateSignatureTestCase{
{
name: "bad event id",
id: "badeventid",
sig: testEvent.Sig,
pubkey: testEvent.PubKey,
expectedError: "invalid event id hex",
},
{
name: "bad event signature",
id: testEvent.ID,
sig: "badeventsignature",
pubkey: testEvent.PubKey,
expectedError: "invalid event signature hex",
},
{
name: "bad public key",
id: testEvent.ID,
sig: testEvent.Sig,
pubkey: "badpublickey",
expectedError: "invalid public key hex",
},
{
name: "malformed event signature",
id: testEvent.ID,
sig: "abc123",
pubkey: testEvent.PubKey,
expectedError: "malformed signature",
},
{
name: "malformed public key",
id: testEvent.ID,
sig: testEvent.Sig,
pubkey: "abc123",
expectedError: "malformed public key",
},
}
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}
err := event.ValidateSignature()
assert.ErrorContains(t, err, tc.expectedError)
})
}
}
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",
}
err := event.Validate()
assert.NoError(t, err)
}