From 62aeef4eaf3ddaa5b12ac90595f2936513273fa9 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 20 Apr 2026 22:59:28 -0400 Subject: [PATCH] Performant event serialization. Update README. --- README.md | 15 ++------ errors/errors.go | 3 -- events/id.go | 89 ++++++++++++++++++++++++++++++++++------------ events/id_test.go | 3 +- events/validate.go | 5 +-- 5 files changed, 72 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 6417838..56e8649 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,7 @@ event := events.Event{ } // 2. Compute the event ID -id, err := events.GetID(event) -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 := events.Serialize(event) -if err != nil { - log.Fatal(err) -} +serialized := events.Serialize(event) ``` #### Compute event ID manually ```go -id, err := events.GetID(event) -if err != nil { - log.Fatal(err) -} +id := events.GetID(event) // Returns lowercase hex SHA-256 hash of serialized form ``` diff --git a/errors/errors.go b/errors/errors.go index 101915c..34972a1 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -20,9 +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") diff --git a/events/id.go b/events/id.go index d0cd744..a01d319 100644 --- a/events/id.go +++ b/events/id.go @@ -3,35 +3,80 @@ 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 Serialize(e Event) ([]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 +func Serialize(e Event) []byte { + buf := make([]byte, 0, 100+len(e.Content)+len(e.Tags)*80) + return appendSerialized(buf, e) } // GetID computes and returns the event ID as a lowercase, hex-encoded SHA-256 hash // of the serialized event. -func GetID(e Event) (string, error) { - bytes, err := Serialize(e) - if err != nil { - return "", err - } +func GetID(e Event) string { + bytes := Serialize(e) hash := sha256.Sum256(bytes) - return hex.EncodeToString(hash[:]), nil + return hex.EncodeToString(hash[:]) +} + +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, '"') } diff --git a/events/id_test.go b/events/id_test.go index a72bbb2..7b7be3d 100644 --- a/events/id_test.go +++ b/events/id_test.go @@ -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 := GetID(tc.event) - assert.NoError(t, err) + actual := GetID(tc.event) assert.Equal(t, tc.expected, actual) }) } diff --git a/events/validate.go b/events/validate.go index be7fd45..accf41b 100644 --- a/events/validate.go +++ b/events/validate.go @@ -47,10 +47,7 @@ func ValidateStructure(e Event) error { // ValidateID recomputes the event ID and verifies it matches the stored ID field. func ValidateID(e Event) error { - computedID, err := GetID(e) - if err != nil { - return errors.FailedIDComp - } + computedID := GetID(e) if e.ID == "" { return errors.NoEventID }