Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a765a2262a | |||
| d699feb236 | |||
| 17789a7dbd | |||
| 0ca44c7e20 | |||
| c6145d6020 | |||
| 12699a1630 | |||
| 60c8e8256b | |||
| 047fc9d9a1 | |||
| 48dde86abd | |||
| 29ba275293 | |||
| 747781f5bf | |||
| 62aeef4eaf | |||
| b545f9370f | |||
| 8c7113c51b | |||
| cda73bf6f2 | |||
| 1e2a6f7777 | |||
| d42d877ea2 | |||
| 4df91938ef | |||
| 67db088981 | |||
| 223c9faec0 |
@@ -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.
|
||||
@@ -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 ./...
|
||||
```
|
||||
|
||||
@@ -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 @@
|
||||
code2prompt -e "go.sum" -e "README.md" -e "c2p" .
|
||||
code2prompt -c -e "go.sum" -e "c2p"
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) {
|
||||
@@ -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, '"')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
package roots
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package events
|
||||
|
||||
// ValidatedEvent is an immutable wrapper around a fully validated event. It
|
||||
// shares no memory with the original Event struct.
|
||||
//
|
||||
// When created with NewValidatedEvent, the wrapped event is guaranteed to:
|
||||
// - have a valid structure
|
||||
// - have a valid ID
|
||||
// - have a valid signature
|
||||
type ValidatedEvent struct {
|
||||
event Event
|
||||
}
|
||||
|
||||
// NewValidatedEvent validates the provided Event. If valid, returns an
|
||||
// immutable ValidatedEvent. Otherwise returns an error.
|
||||
func NewValidatedEvent(e Event) (ValidatedEvent, error) {
|
||||
if err := Validate(e); err != nil {
|
||||
return ValidatedEvent{}, err
|
||||
}
|
||||
e.Tags = deepCopyTags(e.Tags)
|
||||
return ValidatedEvent{event: e}, nil
|
||||
}
|
||||
|
||||
func (v ValidatedEvent) ID() string { return v.event.ID }
|
||||
func (v ValidatedEvent) PubKey() string { return v.event.PubKey }
|
||||
func (v ValidatedEvent) CreatedAt() int64 { return v.event.CreatedAt }
|
||||
func (v ValidatedEvent) Kind() int { return v.event.Kind }
|
||||
func (v ValidatedEvent) Content() string { return v.event.Content }
|
||||
func (v ValidatedEvent) Sig() string { return v.event.Sig }
|
||||
|
||||
// Tags returns a deep copy of the event's tag list. The returned slice shares
|
||||
// no memory with the ValidatedEvent.
|
||||
func (v ValidatedEvent) Tags() []Tag { return deepCopyTags(v.event.Tags) }
|
||||
|
||||
// Event returns a deep copy of the underlying Event. The returned Event shares
|
||||
// no memory with the ValidatedEvent.
|
||||
func (v ValidatedEvent) Event() Event {
|
||||
e := v.event
|
||||
e.Tags = deepCopyTags(v.event.Tags)
|
||||
return e
|
||||
}
|
||||
|
||||
func deepCopyTags(tags []Tag) []Tag {
|
||||
if tags == nil {
|
||||
return nil
|
||||
}
|
||||
cp := make([]Tag, len(tags))
|
||||
for i, tag := range tags {
|
||||
tagcp := make(Tag, len(tag))
|
||||
copy(tagcp, tag)
|
||||
cp[i] = tagcp
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func (v ValidatedEvent) MarshalJSON() ([]byte, error) {
|
||||
return v.event.MarshalJSON()
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testEventWithTags = NewEvent(
|
||||
WithID("c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400"),
|
||||
WithPubKey(testPK),
|
||||
WithCreatedAt(1760740551),
|
||||
WithKind(1),
|
||||
WithTag(Tag{"a", "value"}),
|
||||
WithTag(Tag{"b", "value", "optional"}),
|
||||
WithContent("valid event"),
|
||||
WithSig("668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14"),
|
||||
)
|
||||
|
||||
func TestValidatedEventConstruction(t *testing.T) {
|
||||
t.Run("accepts valid event", func(t *testing.T) {
|
||||
_, err := NewValidatedEvent(testEvent)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("rejects invalid structure", func(t *testing.T) {
|
||||
bad := NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey("notavalidpubkey"),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
)
|
||||
_, err := NewValidatedEvent(bad)
|
||||
assert.ErrorContains(t, err, "public key must be 64 lowercase hex characters")
|
||||
})
|
||||
|
||||
t.Run("rejects id mismatch", func(t *testing.T) {
|
||||
bad := NewEvent(
|
||||
WithID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig(testEvent.Sig),
|
||||
)
|
||||
_, err := NewValidatedEvent(bad)
|
||||
assert.ErrorContains(t, err, "does not match computed id")
|
||||
})
|
||||
|
||||
t.Run("rejects invalid signature", func(t *testing.T) {
|
||||
bad := NewEvent(
|
||||
WithID(testEvent.ID),
|
||||
WithPubKey(testEvent.PubKey),
|
||||
WithCreatedAt(testEvent.CreatedAt),
|
||||
WithKind(testEvent.Kind),
|
||||
WithContent(testEvent.Content),
|
||||
WithSig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||
)
|
||||
_, err := NewValidatedEvent(bad)
|
||||
assert.ErrorContains(t, err, "event signature is invalid")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatedEventAccessors(t *testing.T) {
|
||||
ve, err := NewValidatedEvent(testEventWithTags)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("ID", func(t *testing.T) {
|
||||
assert.Equal(t, testEventWithTags.ID, ve.ID())
|
||||
})
|
||||
|
||||
t.Run("PubKey", func(t *testing.T) {
|
||||
assert.Equal(t, testEventWithTags.PubKey, ve.PubKey())
|
||||
})
|
||||
|
||||
t.Run("CreatedAt", func(t *testing.T) {
|
||||
assert.Equal(t, testEventWithTags.CreatedAt, ve.CreatedAt())
|
||||
})
|
||||
|
||||
t.Run("Kind", func(t *testing.T) {
|
||||
assert.Equal(t, testEventWithTags.Kind, ve.Kind())
|
||||
})
|
||||
|
||||
t.Run("Content", func(t *testing.T) {
|
||||
assert.Equal(t, testEventWithTags.Content, ve.Content())
|
||||
})
|
||||
|
||||
t.Run("Sig", func(t *testing.T) {
|
||||
assert.Equal(t, testEventWithTags.Sig, ve.Sig())
|
||||
})
|
||||
|
||||
t.Run("Tags", func(t *testing.T) {
|
||||
assert.Equal(t, testEventWithTags.Tags, ve.Tags())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatedEventTagsImmutability(t *testing.T) {
|
||||
t.Run("outer slice mutation is isolated", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
tags := ve.Tags()
|
||||
tags[0] = Tag{"z", "injected"}
|
||||
assert.Equal(t, Tag{"a", "value"}, ve.Tags()[0])
|
||||
})
|
||||
|
||||
t.Run("inner slice mutation is isolated", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
tags := ve.Tags()
|
||||
tags[0][1] = "mutated"
|
||||
assert.Equal(t, "value", ve.Tags()[0][1])
|
||||
})
|
||||
|
||||
t.Run("append to outer slice is isolated", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
originalLen := len(ve.Tags())
|
||||
tags := ve.Tags()
|
||||
tags = append(tags, Tag{"new", "tag"})
|
||||
_ = tags
|
||||
assert.Equal(t, originalLen, len(ve.Tags()))
|
||||
})
|
||||
|
||||
t.Run("append to inner slice is isolated", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
originalLen := len(ve.Tags()[1])
|
||||
tags := ve.Tags()
|
||||
tags[1] = append(tags[1], "extra")
|
||||
assert.Equal(t, originalLen, len(ve.Tags()[1]))
|
||||
})
|
||||
|
||||
t.Run("successive calls return independent copies", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
first := ve.Tags()
|
||||
second := ve.Tags()
|
||||
first[0][0] = "z"
|
||||
assert.Equal(t, "a", second[0][0])
|
||||
})
|
||||
|
||||
t.Run("nil tags remain nil", func(t *testing.T) {
|
||||
nil_tags_event := Event{
|
||||
ID: testEvent.ID,
|
||||
PubKey: testEvent.PubKey,
|
||||
CreatedAt: testEvent.CreatedAt,
|
||||
Kind: testEvent.Kind,
|
||||
Tags: nil,
|
||||
Content: testEvent.Content,
|
||||
Sig: testEvent.Sig,
|
||||
}
|
||||
ve, err := NewValidatedEvent(nil_tags_event)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, ve.Tags())
|
||||
assert.Nil(t, ve.Event().Tags)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatedEventEventImmutability(t *testing.T) {
|
||||
t.Run("outer tags mutation is isolated", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
e := ve.Event()
|
||||
e.Tags[0] = Tag{"z", "injected"}
|
||||
assert.Equal(t, Tag{"a", "value"}, ve.Tags()[0])
|
||||
})
|
||||
|
||||
t.Run("inner tags mutation is isolated", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
e := ve.Event()
|
||||
e.Tags[0][1] = "mutated"
|
||||
assert.Equal(t, "value", ve.Tags()[0][1])
|
||||
})
|
||||
|
||||
t.Run("scalar field mutation is isolated", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEvent)
|
||||
e := ve.Event()
|
||||
e.Content = "tampered"
|
||||
assert.Equal(t, testEvent.Content, ve.Content())
|
||||
})
|
||||
|
||||
t.Run("successive calls return independent copies", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
first := ve.Event()
|
||||
second := ve.Event()
|
||||
first.Tags[0][0] = "z"
|
||||
assert.Equal(t, "a", second.Tags[0][0])
|
||||
})
|
||||
|
||||
t.Run("returned event passes validation", func(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEventWithTags)
|
||||
assert.NoError(t, Validate(ve.Event()))
|
||||
})
|
||||
|
||||
t.Run("source event mutation after construction is isolated", func(t *testing.T) {
|
||||
source := NewEvent(
|
||||
WithID(testEventWithTags.ID),
|
||||
WithPubKey(testEventWithTags.PubKey),
|
||||
WithCreatedAt(testEventWithTags.CreatedAt),
|
||||
WithKind(testEventWithTags.Kind),
|
||||
WithTag(Tag{"a", "value"}),
|
||||
WithTag(Tag{"b", "value", "optional"}),
|
||||
WithContent(testEventWithTags.Content),
|
||||
WithSig(testEventWithTags.Sig),
|
||||
)
|
||||
ve, err := NewValidatedEvent(source)
|
||||
assert.NoError(t, err)
|
||||
source.Tags[0][1] = "mutated"
|
||||
source.Content = "tampered"
|
||||
assert.Equal(t, "value", ve.Tags()[0][1])
|
||||
assert.Equal(t, testEventWithTags.Content, ve.Content())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatedEventMarshalJSON(t *testing.T) {
|
||||
ve, _ := NewValidatedEvent(testEvent)
|
||||
jsonBytes, err := json.Marshal(ve)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testEventJSON, string(jsonBytes))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
+15
-15
@@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -12,6 +12,8 @@ require (
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -9,6 +9,10 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package roots
|
||||
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
-96
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user