9 Commits

Author SHA1 Message Date
jay 29ba275293 add individual value validator functions. 2026-04-22 14:20:57 -04:00
jay 747781f5bf Performant validation. Prevent redundant decoding. Remove unused errors. 2026-04-20 23:52:50 -04:00
jay 62aeef4eaf Performant event serialization. Update README. 2026-04-20 23:52:40 -04:00
jay b545f9370f Add bump script. 2026-02-25 13:04:01 -05:00
jay 8c7113c51b Update c2p script. 2026-02-06 10:47:06 -05:00
jay cda73bf6f2 Updated c2p script 2025-11-02 16:27:23 -05:00
jay 1e2a6f7777 Add license file. 2025-11-02 14:27:01 -05:00
jay d42d877ea2 Updated README. 2025-11-02 14:22:45 -05:00
jay 4df91938ef Refactored methods into pure functions. 2025-10-31 19:48:56 -04:00
14 changed files with 211 additions and 115 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 nostr:npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13 -22
View File
@@ -6,7 +6,7 @@ Mirror: https://github.com/wisehodl/go-roots
## What this library does
`go-roots` is a purposefully minimal Nostr protocol library for golang.
`go-roots` is a consensus-layer Nostr protocol library for golang.
It only provides primitives that define protocol compliance:
- Event Structure
@@ -96,10 +96,7 @@ event := events.Event{
}
// 2. Compute the event ID
id, err := event.GetID()
if err != nil {
log.Fatal(err)
}
id := events.GetID(event)
event.ID = id
// 3. Sign the event
@@ -114,19 +111,13 @@ event.Sig = sig
```go
// Returns canonical JSON: [0, pubkey, created_at, kind, tags, content]
serialized, err := event.Serialize()
if err != nil {
log.Fatal(err)
}
serialized := events.Serialize(event)
```
#### Compute event ID manually
```go
id, err := event.GetID()
if err != nil {
log.Fatal(err)
}
id := events.GetID(event)
// Returns lowercase hex SHA-256 hash of serialized form
```
@@ -138,7 +129,7 @@ if err != nil {
```go
// Checks structure, ID computation, and signature
if err := event.Validate(); err != nil {
if err := events.Validate(event); err != nil {
log.Printf("Invalid event: %v", err)
}
```
@@ -147,17 +138,17 @@ if err := event.Validate(); err != nil {
```go
// Check field formats and lengths
if err := event.ValidateStructure(); err != nil {
if err := events.ValidateStructure(event); err != nil {
log.Printf("Malformed structure: %v", err)
}
// Verify ID matches computed hash
if err := event.ValidateID(); err != nil {
if err := events.ValidateID(event); err != nil {
log.Printf("ID mismatch: %v", err)
}
// Verify cryptographic signature
if err := event.ValidateSignature(); err != nil {
if err := events.ValidateSignature(event); err != nil {
log.Printf("Invalid signature: %v", err)
}
```
@@ -186,7 +177,7 @@ if err != nil {
}
// Validate after unmarshaling
if err := event.Validate(); err != nil {
if err := events.Validate(event); err != nil {
log.Printf("Received invalid event: %v", err)
}
```
@@ -250,7 +241,7 @@ filter := filters.Filter{
Kinds: []int{1},
}
if filter.Matches(&event) {
if filters.Matches(filter, event) {
// Event satisfies all filter conditions
}
```
@@ -269,7 +260,7 @@ filter := filters.Filter{
var matches []events.Event
for _, event := range events {
if filter.Matches(&event) {
if filters.Matches(filter, event) {
matches = append(matches, event)
}
}
@@ -293,7 +284,7 @@ filter := filters.Filter{
},
}
jsonBytes, err := filter.MarshalJSON()
jsonBytes, err := filters.MarshalJSON(filter)
// Result: {"ids":["abc123"],"kinds":[1],"#e":["event-id"],"search":"nostr"}
```
@@ -309,7 +300,7 @@ jsonData := `{
}`
var filter filters.Filter
err := filter.UnmarshalJSON([]byte(jsonData))
err := filters.UnmarshalJSON([]byte(jsonData), &filter)
if err != nil {
log.Fatal(err)
}
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
latest=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
IFS='.' read -r major minor patch <<< "${latest#v}"
case ${1:-patch} in
major) new="v$((major+1)).0.0" ;;
minor) new="v${major}.$((minor+1)).0" ;;
patch) new="v${major}.${minor}.$((patch+1))" ;;
*) echo "Usage: bump.sh [major|minor|patch]" >&2; exit 1 ;;
esac
git tag -a "$new"
+1 -1
View File
@@ -1 +1 @@
code2prompt -e "go.sum" -e "README.md" -e "c2p" .
code2prompt -c -e "go.sum" -e "c2p"
-6
View File
@@ -20,12 +20,6 @@ var (
// MalformedTag indicates an event tag contains fewer than two elements.
MalformedTag = errors.New("tags must contain at least two elements")
// FailedIDComp indicates the event ID could not be computed during validation.
FailedIDComp = errors.New("failed to compute event id")
// NoEventID indicates the event ID field is empty.
NoEventID = errors.New("event id is empty")
// InvalidSig indicates the event signature failed cryptographic validation.
InvalidSig = errors.New("event signature is invalid")
)
-14
View File
@@ -3,10 +3,6 @@
// serialization, cryptographic signatures, and subscription filters.
package events
import (
"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.
@@ -23,13 +19,3 @@ type Event struct {
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}$")
)
+3 -3
View File
@@ -9,7 +9,7 @@ import (
func TestUnmarshalEventJSON(t *testing.T) {
event := Event{}
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)
@@ -37,7 +37,7 @@ func TestEventJSONRoundTrip(t *testing.T) {
}
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 +47,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)
+76 -27
View File
@@ -3,35 +3,84 @@ package events
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strconv"
)
// 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
func GetID(e Event) string {
hash := GetIDBytes(e)
return hex.EncodeToString(hash[:])
}
// GetIDBytes computes and returns the event ID as a raw SHA256 digest
func GetIDBytes(e Event) [32]byte {
return sha256.Sum256(Serialize(e))
}
// Serialize returns the canonical JSON array representation of the event.
// used for ID computation: [0, pubkey, created_at, kind, tags, content].
func Serialize(e Event) []byte {
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, '"')
}
+1 -2
View File
@@ -196,8 +196,7 @@ var idTestCases = []IDTestCase{
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)
actual := GetID(tc.event)
assert.Equal(t, tc.expected, actual)
})
}
+67 -25
View File
@@ -1,6 +1,7 @@
package events
import (
"bytes"
"encoding/hex"
"fmt"
"git.wisehodl.dev/jay/go-roots/errors"
@@ -9,30 +10,31 @@ import (
// 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 {
func Validate(e Event) error {
if err := ValidateStructure(e); err != nil {
return err
}
if err := e.ValidateID(); err != nil {
idBytes, err := checkIDMatch(e)
if err != nil {
return err
}
return e.ValidateSignature()
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 (e *Event) ValidateStructure() error {
if !Hex64Pattern.MatchString(e.PubKey) {
func ValidateStructure(e Event) error {
if !IsValidKey(e.PubKey) {
return errors.MalformedPubKey
}
if !Hex64Pattern.MatchString(e.ID) {
if !IsValidID(e.ID) {
return errors.MalformedID
}
if !Hex128Pattern.MatchString(e.Sig) {
if !IsValidSig(e.Sig) {
return errors.MalformedSig
}
@@ -46,34 +48,61 @@ func (e *Event) ValidateStructure() error {
}
// 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 errors.FailedIDComp
}
if e.ID == "" {
return errors.NoEventID
}
if computedID != e.ID {
return fmt.Errorf("event id %q does not match computed id %q", e.ID, computedID)
}
return nil
func ValidateID(e Event) error {
_, err := checkIDMatch(e)
return err
}
// ValidateSignature verifies the event signature is cryptographically valid
// for the event ID and public key using Schnorr verification.
func (e *Event) ValidateSignature() error {
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)
}
sigBytes, err := hex.DecodeString(e.Sig)
// Value validators
// IsValidKey verifies that a public or private key is properly formatted.
func IsValidKey(value string) bool {
return isLowerHex(value, 64)
}
// IsValidKey verifies that an event id is properly formatted.
func IsValidID(value string) bool {
return isLowerHex(value, 64)
}
// IsValidKey verifies that an event signature is properly formatted.
func IsValidSig(value string) bool {
return isLowerHex(value, 128)
}
// Helpers
func checkIDMatch(e Event) ([]byte, error) {
idHash := GetIDBytes(e)
idBytes, err := hex.DecodeString(e.ID)
if err != nil {
return nil, errors.MalformedID
}
if !bytes.Equal(idBytes, idHash[:]) {
return nil, fmt.Errorf(
"event id %q does not match computed id %q",
e.ID, hex.EncodeToString(idHash[:]))
}
return idBytes, nil
}
func validateSignatureBytes(idBytes []byte, sigHex, pkHex string) error {
sigBytes, err := hex.DecodeString(sigHex)
if err != nil {
return fmt.Errorf("invalid event signature hex: %w", err)
}
pkBytes, err := hex.DecodeString(e.PubKey)
pkBytes, err := hex.DecodeString(pkHex)
if err != nil {
return fmt.Errorf("invalid public key hex: %w", err)
}
@@ -90,7 +119,20 @@ func (e *Event) ValidateSignature() error {
if signature.Verify(idBytes, publicKey) {
return nil
} else {
return errors.InvalidSig
}
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
}
+6 -6
View File
@@ -184,7 +184,7 @@ var structureTestCases = []ValidateEventTestCase{
func TestValidateEventStructure(t *testing.T) {
for _, tc := range structureTestCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.event.ValidateStructure()
err := ValidateStructure(tc.event)
assert.ErrorContains(t, err, tc.expectedError)
})
}
@@ -201,7 +201,7 @@ func TestValidateEventIDFailure(t *testing.T) {
Sig: testEvent.Sig,
}
err := event.ValidateID()
err := ValidateID(event)
assert.ErrorContains(t, err, "does not match computed id")
}
@@ -211,7 +211,7 @@ func TestValidateSignature(t *testing.T) {
PubKey: testEvent.PubKey,
Sig: testEvent.Sig,
}
err := event.ValidateSignature()
err := ValidateSignature(event)
assert.NoError(t, err)
}
@@ -222,7 +222,7 @@ func TestValidateInvalidSignature(t *testing.T) {
PubKey: testEvent.PubKey,
Sig: "9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482",
}
err := event.ValidateSignature()
err := ValidateSignature(event)
assert.ErrorContains(t, err, "event signature is invalid")
}
@@ -281,7 +281,7 @@ 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()
err := ValidateSignature(event)
assert.ErrorContains(t, err, tc.expectedError)
})
}
@@ -301,6 +301,6 @@ func TestValidateEvent(t *testing.T) {
Sig: "668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14",
}
err := event.Validate()
err := Validate(event)
assert.NoError(t, err)
}
+3 -3
View File
@@ -29,7 +29,7 @@ type Filter struct {
// 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 MarshalJSON(f Filter) ([]byte, error) {
outputMap := make(map[string]interface{})
// Add standard fields
@@ -86,7 +86,7 @@ func (f *Filter) MarshalJSON() ([]byte, error) {
// UnmarshalJSON parses JSON into the filter, separating standard fields,
// tag filters (keys starting with "#"), and extensions.
func (f *Filter) UnmarshalJSON(data []byte) error {
func UnmarshalJSON(data []byte, f *Filter) error {
// Decode into raw map
raw := make(FilterExtensions)
if err := json.Unmarshal(data, &raw); err != nil {
@@ -182,7 +182,7 @@ 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 *events.Event) bool {
func Matches(f Filter, event events.Event) bool {
// Check ID
if len(f.IDs) > 0 {
if !matchesPrefix(event.ID, f.IDs) {
+4 -4
View File
@@ -584,7 +584,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 := MarshalJSON(tc.filter)
assert.NoError(t, err)
var expectedMap, actualMap map[string]interface{}
@@ -602,7 +602,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 := UnmarshalJSON([]byte(tc.input), &result)
assert.NoError(t, err)
expectEqualFilters(t, result, tc.expected)
@@ -613,11 +613,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 := MarshalJSON(tc.filter)
assert.NoError(t, err)
var result Filter
err = result.UnmarshalJSON(jsonBytes)
err = UnmarshalJSON(jsonBytes, &result)
assert.NoError(t, err)
expectEqualFilters(t, result, tc.filter)
+2 -2
View File
@@ -391,7 +391,7 @@ func TestEventFilterMatching(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
actualIDs := []string{}
for _, event := range testEvents {
if tc.filter.Matches(&event) {
if Matches(tc.filter, event) {
actualIDs = append(actualIDs, event.ID[:8])
}
}
@@ -416,5 +416,5 @@ func TestEventFilterMatchingSkipMalformedTags(t *testing.T) {
},
}
assert.True(t, filter.Matches(&event))
assert.True(t, Matches(filter, event))
}