This document specifies how Nostr events (specifically kind 0, 3, 1984, and 10000) are processed to maintain NostrUser vertices and social graph relationships (FOLLOWS, MUTES, REPORTS) in Neo4j, with full event traceability for diff-based updates.
The event processing system must:
Every relationship in the graph must be traceable to the event that created it. This enables:
All social graph relationships must include these properties:
// FOLLOWS relationship properties
(:NostrUser)-[:FOLLOWS {
created_by_event: "event_id_hex", // Event ID that created this relationship
created_at: timestamp, // Event created_at timestamp
relay_received_at: timestamp // When relay received the event
}]->(:NostrUser)
// MUTES relationship properties
(:NostrUser)-[:MUTES {
created_by_event: "event_id_hex",
created_at: timestamp,
relay_received_at: timestamp
}]->(:NostrUser)
// REPORTS relationship properties
(:NostrUser)-[:REPORTS {
created_by_event: "event_id_hex",
created_at: timestamp,
relay_received_at: timestamp,
report_type: "spam|impersonation|illegal|..." // NIP-56 report type
}]->(:NostrUser)
Create a node to track which events have been processed and what relationships they created:
(:ProcessedSocialEvent {
event_id: "hex_id", // Event ID
event_kind: 3|1984|10000, // Event kind
pubkey: "author_pubkey", // Event author
created_at: timestamp, // Event timestamp
processed_at: timestamp, // When we processed it
relationship_count: integer, // How many relationships created
superseded_by: "newer_event_id"|null // If replaced by newer event
})
Event Structure:
{
"kind": 3,
"pubkey": "user_pubkey",
"created_at": 1234567890,
"tags": [
["p", "followed_pubkey_1", "relay_hint", "petname"],
["p", "followed_pubkey_2", "relay_hint", "petname"],
...
]
}
Processing Steps:
`cypher
MATCH (existing:ProcessedSocialEvent {
event_kind: 3,
pubkey: $pubkey
})
WHERE existing.superseded_by IS NULL
RETURN existing
`
`
if existing.createdat >= newevent.created_at:
return EventRejected("Older event")
`
`
new_follows = set(tag[1] for tag in tags if tag[0] == 'p')
`
`cypher
MATCH (author:NostrUser {pubkey: $pubkey})-[r:FOLLOWS]->()
WHERE r.createdbyevent = $existingeventid
RETURN collect(endNode(r).pubkey) as old_follows
`
`python
addedfollows = newfollows - old_follows
removedfollows = oldfollows - new_follows
`
`cypher
// Begin transaction
// A. Mark old event as superseded MATCH (old:ProcessedSocialEvent {eventid: $oldevent_id}) SET old.supersededby = $newevent_id
// B. Create new event tracking node CREATE (new:ProcessedSocialEvent { eventid: $newevent_id, event_kind: 3, pubkey: $pubkey, createdat: $createdat, processed_at: timestamp(), relationshipcount: $newfollows_count, superseded_by: null })
// C. Remove old FOLLOWS relationships MATCH (author:NostrUser {pubkey: $pubkey})-[r:FOLLOWS]->(followed:NostrUser) WHERE r.createdbyevent = $oldeventid AND followed.pubkey IN $removed_follows DELETE r
// D. Create new FOLLOWS relationships MERGE (author:NostrUser {pubkey: $pubkey}) WITH author UNWIND $addedfollows AS followedpubkey MERGE (followed:NostrUser {pubkey: followed_pubkey}) CREATE (author)-[:FOLLOWS { createdbyevent: $neweventid, createdat: $createdat, relayreceivedat: $now }]->(followed)
// Commit transaction
`
Edge Cases:
Event Structure:
{
"kind": 10000,
"pubkey": "user_pubkey",
"created_at": 1234567890,
"tags": [
["p", "muted_pubkey_1"],
["p", "muted_pubkey_2"],
...
]
}
Processing Steps:
Same pattern as kind 3, but with MUTES relationships:
added_mutes, removed_mutes- Mark old event as superseded - Create new ProcessedSocialEvent node - Delete removed MUTES relationships - Create added MUTES relationships
Note on Privacy:
muted_pubkey = "encrypted" placeholderEvent Structure:
{
"kind": 1984,
"pubkey": "reporter_pubkey",
"created_at": 1234567890,
"tags": [
["p", "reported_pubkey", "report_type"]
],
"content": "Optional reason"
}
Processing Steps:
Kind 1984 is NOT replaceable, so each report creates a separate relationship:
`python
for tag in tags:
if tag[0] == 'p':
reported_pubkey = tag[1]
report_type = tag[2] if len(tag) > 2 else "other"
`
`cypher
// Transaction
// Create event tracking node CREATE (evt:ProcessedSocialEvent { eventid: $eventid, event_kind: 1984, pubkey: $reporter_pubkey, createdat: $createdat, processed_at: timestamp(), relationship_count: 1, superseded_by: null })
// Create or get reporter and reported users MERGE (reporter:NostrUser {pubkey: $reporter_pubkey}) MERGE (reported:NostrUser {pubkey: $reported_pubkey})
// Create REPORTS relationship
CREATE (reporter)-[:REPORTS {
createdbyevent: $event_id,
createdat: $createdat,
relayreceivedat: timestamp(),
reporttype: $reporttype
}]->(reported)
`
Multiple Reports:
Report Types (NIP-56):
nudity - Depictions of nudity, porn, etcprofanity - Profanity, hateful speech, etcillegal - Illegal contentspam - Spamimpersonation - Someone pretending to be someone elsemalware - Links to malwareother - Other reasonsEvent Structure:
{
"kind": 0,
"pubkey": "user_pubkey",
"created_at": 1234567890,
"content": "{\"name\":\"Alice\",\"about\":\"...\",\"picture\":\"...\"}"
}
Processing Steps:
`python
profile = json.loads(event.content)
`
`cypher
MERGE (user:NostrUser {pubkey: $pubkey})
ON CREATE SET
user.created_at = $now,
user.firstseenevent = $event_id
ON MATCH SET
user.lastprofileupdate = $created_at
SET
user.name = $profile.name,
user.about = $profile.about,
user.picture = $profile.picture,
user.nip05 = $profile.nip05,
user.lud16 = $profile.lud16,
user.displayname = $profile.displayname
`
Note: Kind 0 is replaceable, but we typically keep only latest profile data (not diffing relationships).
package neo4j
import (
"context"
"git.mleku.dev/mleku/nostr/encoders/event"
)
// SocialEventProcessor handles kind 0, 3, 1984, 10000 events
type SocialEventProcessor struct {
db *N
}
// ProcessSocialEvent routes events to appropriate handlers
func (p *SocialEventProcessor) ProcessSocialEvent(ctx context.Context, ev *event.E) error {
switch ev.Kind {
case 0:
return p.processProfileMetadata(ctx, ev)
case 3:
return p.processContactList(ctx, ev)
case 1984:
return p.processReport(ctx, ev)
case 10000:
return p.processMuteList(ctx, ev)
default:
return fmt.Errorf("unsupported social event kind: %d", ev.Kind)
}
}
func (p *SocialEventProcessor) processContactList(ctx context.Context, ev *event.E) error {
authorPubkey := hex.Enc(ev.Pubkey[:])
eventID := hex.Enc(ev.ID[:])
// 1. Check for existing contact list
existingEvent, err := p.getLatestSocialEvent(ctx, authorPubkey, 3)
if err != nil {
return err
}
// 2. Reject if older
if existingEvent != nil && existingEvent.CreatedAt >= ev.CreatedAt {
return fmt.Errorf("older contact list event rejected")
}
// 3. Extract p-tags
newFollows := extractPTags(ev)
// 4. Get old follows (if replacing)
var oldFollows []string
if existingEvent != nil {
oldFollows, err = p.getFollowsForEvent(ctx, existingEvent.EventID)
if err != nil {
return err
}
}
// 5. Compute diff
added, removed := diffStringSlices(oldFollows, newFollows)
// 6. Update graph in transaction
return p.updateContactListGraph(ctx, UpdateContactListParams{
AuthorPubkey: authorPubkey,
NewEventID: eventID,
OldEventID: existingEvent.EventID,
CreatedAt: ev.CreatedAt,
AddedFollows: added,
RemovedFollows: removed,
})
}
type UpdateContactListParams struct {
AuthorPubkey string
NewEventID string
OldEventID string
CreatedAt int64
AddedFollows []string
RemovedFollows []string
}
func (p *SocialEventProcessor) updateContactListGraph(ctx context.Context, params UpdateContactListParams) error {
// Build complex Cypher transaction
cypher := `
// Mark old event as superseded (if exists)
OPTIONAL MATCH (old:ProcessedSocialEvent {event_id: $old_event_id})
SET old.superseded_by = $new_event_id
// Create new event tracking node
CREATE (new:ProcessedSocialEvent {
event_id: $new_event_id,
event_kind: 3,
pubkey: $author_pubkey,
created_at: $created_at,
processed_at: timestamp(),
relationship_count: $follows_count,
superseded_by: null
})
// Get or create author node
MERGE (author:NostrUser {pubkey: $author_pubkey})
// Remove old FOLLOWS relationships
WITH author
OPTIONAL MATCH (author)-[old_follows:FOLLOWS]->(followed:NostrUser)
WHERE old_follows.created_by_event = $old_event_id
AND followed.pubkey IN $removed_follows
DELETE old_follows
// Create new FOLLOWS relationships
WITH author
UNWIND $added_follows AS followed_pubkey
MERGE (followed:NostrUser {pubkey: followed_pubkey})
CREATE (author)-[:FOLLOWS {
created_by_event: $new_event_id,
created_at: $created_at,
relay_received_at: timestamp()
}]->(followed)
`
cypherParams := map[string]any{
"author_pubkey": params.AuthorPubkey,
"new_event_id": params.NewEventID,
"old_event_id": params.OldEventID,
"created_at": params.CreatedAt,
"follows_count": len(params.AddedFollows) + len(params.RemovedFollows),
"added_follows": params.AddedFollows,
"removed_follows": params.RemovedFollows,
}
_, err := p.db.ExecuteWrite(ctx, cypher, cypherParams)
return err
}
// extractPTags extracts unique pubkeys from p-tags
func extractPTags(ev *event.E) []string {
seen := make(map[string]bool)
var pubkeys []string
for _, tag := range *ev.Tags {
if len(tag.T) >= 2 && string(tag.T[0]) == "p" {
pubkey := string(tag.T[1])
if !seen[pubkey] {
seen[pubkey] = true
pubkeys = append(pubkeys, pubkey)
}
}
}
return pubkeys
}
// diffStringSlices computes added and removed elements
func diffStringSlices(old, new []string) (added, removed []string) {
oldSet := make(map[string]bool)
for _, s := range old {
oldSet[s] = true
}
newSet := make(map[string]bool)
for _, s := range new {
newSet[s] = true
if !oldSet[s] {
added = append(added, s)
}
}
for _, s := range old {
if !newSet[s] {
removed = append(removed, s)
}
}
return
}
The social event processor should be called from the existing SaveEvent method:
// In pkg/neo4j/save-event.go
func (n *N) SaveEvent(c context.Context, ev *event.E) (exists bool, err error) {
// ... existing event save logic ...
// After saving base event, process social graph updates
if ev.Kind == 0 || ev.Kind == 3 || ev.Kind == 1984 || ev.Kind == 10000 {
processor := &SocialEventProcessor{db: n}
if err := processor.ProcessSocialEvent(c, ev); err != nil {
n.Logger.Errorf("failed to process social event: %v", err)
// Decide: fail the whole save or just log error?
}
}
return false, nil
}
Add ProcessedSocialEvent node to schema.go:
// In applySchema, add:
constraints = append(constraints,
// Unique constraint on ProcessedSocialEvent.event_id
"CREATE CONSTRAINT processedSocialEvent_event_id IF NOT EXISTS FOR (e:ProcessedSocialEvent) REQUIRE e.event_id IS UNIQUE",
)
indexes = append(indexes,
// Index on ProcessedSocialEvent for quick lookup
"CREATE INDEX processedSocialEvent_pubkey_kind IF NOT EXISTS FOR (e:ProcessedSocialEvent) ON (e.pubkey, e.event_kind)",
"CREATE INDEX processedSocialEvent_superseded IF NOT EXISTS FOR (e:ProcessedSocialEvent) ON (e.superseded_by)",
)
// Get all users followed by a user (from most recent event)
MATCH (user:NostrUser {pubkey: $pubkey})-[f:FOLLOWS]->(followed:NostrUser)
WHERE NOT EXISTS {
MATCH (old:ProcessedSocialEvent {event_id: f.created_by_event})
WHERE old.superseded_by IS NOT NULL
}
RETURN followed.pubkey, f.created_at
ORDER BY f.created_at DESC
MATCH (user:NostrUser {pubkey: $pubkey})-[m:MUTES]->(muted:NostrUser)
WHERE NOT EXISTS {
MATCH (old:ProcessedSocialEvent {event_id: m.created_by_event})
WHERE old.superseded_by IS NOT NULL
}
RETURN muted.pubkey
MATCH (reporter:NostrUser)-[r:REPORTS]->(reported:NostrUser {pubkey: $pubkey})
RETURN r.report_type, count(*) as report_count
ORDER BY report_count DESC
// Get all contact list events for a user, in order
MATCH (evt:ProcessedSocialEvent {pubkey: $pubkey, event_kind: 3})
RETURN evt.event_id, evt.created_at, evt.relationship_count, evt.superseded_by
ORDER BY evt.created_at DESC
- Empty lists - No changes - All new follows - All removed follows - Mixed additions and removals
- Newer event replaces older - Older event rejected - Same timestamp handling
- Valid tags - Duplicate pubkeys - Malformed tags - Empty tag lists
- Create initial contact list - Update with additions - Update with removals - Verify graph state at each step
- Alice follows Bob - Bob follows Charlie - Alice unfollows Bob - Verify relationships
- Multiple events for same user - Verify transaction isolation
For initial graph population from existing events:
func (p *SocialEventProcessor) BatchProcessContactLists(ctx context.Context, events []*event.E) error {
// Group by author
byAuthor := make(map[string][]*event.E)
for _, ev := range events {
pubkey := hex.Enc(ev.Pubkey[:])
byAuthor[pubkey] = append(byAuthor[pubkey], ev)
}
// Process each author's events in order
for pubkey, authorEvents := range byAuthor {
// Sort by created_at
sort.Slice(authorEvents, func(i, j int) bool {
return authorEvents[i].CreatedAt < authorEvents[j].CreatedAt
})
// Process in order (older to newer)
for _, ev := range authorEvents {
if err := p.processContactList(ctx, ev); err != nil {
return fmt.Errorf("batch process failed for %s: %w", pubkey, err)
}
}
}
return nil
}
(pubkey, event_kind) for fast lookup of latest eventsuperseded_by to filter active relationshipscreated_by_event for diff operationsn.Logger.Infof("processed contact list: author=%s, event=%s, added=%d, removed=%d",
authorPubkey, eventID, len(added), len(removed))
This specification provides a complete event-driven vertex management system that:
The system is ready to be implemented as the foundation for WoT trust metrics computation.