You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

14 KiB

Common Nostr Implementation Mistakes and How to Avoid Them

This document highlights frequent errors made when implementing Nostr clients and relays, along with solutions.

Event Creation and Signing

Mistake 1: Incorrect Event ID Calculation

Problem: Wrong serialization order or missing fields when calculating SHA256.

Correct Serialization:

[
  0,                    // Must be integer 0
  <pubkey>,            // Lowercase hex string
  <created_at>,        // Unix timestamp integer
  <kind>,              // Integer
  <tags>,              // Array of arrays
  <content>            // String
]

Common errors:

  • Using string "0" instead of integer 0
  • Including id or sig fields in serialization
  • Wrong field order
  • Not using compact JSON (no spaces)
  • Using uppercase hex

Fix: Serialize exactly as shown, compact JSON, SHA256 the UTF-8 bytes.

Mistake 2: Wrong Signature Algorithm

Problem: Using ECDSA instead of Schnorr signatures.

Correct:

  • Use Schnorr signatures (BIP-340)
  • Curve: secp256k1
  • Sign the 32-byte event ID

Libraries:

  • JavaScript: noble-secp256k1
  • Rust: secp256k1
  • Go: btcsuite/btcd/btcec/v2/schnorr
  • Python: secp256k1-py

Mistake 3: Invalid created_at Timestamps

Problem: Events with far-future timestamps or very old timestamps.

Best practices:

  • Use current Unix time: Math.floor(Date.now() / 1000)
  • Relays often reject if created_at > now + 15 minutes
  • Don't backdate events to manipulate ordering

Fix: Always use current time when creating events.

Mistake 4: Malformed Tags

Problem: Tags that aren't arrays or have wrong structure.

Correct format:

{
  "tags": [
    ["e", "event-id", "relay-url", "marker"],
    ["p", "pubkey", "relay-url"],
    ["t", "hashtag"]
  ]
}

Common errors:

  • Using objects instead of arrays: {"e": "..."}
  • Missing inner arrays: ["e", "event-id"] when nested in tags is wrong
  • Wrong nesting depth
  • Non-string values (except for specific NIPs)

Mistake 5: Not Handling Replaceable Events

Problem: Showing multiple versions of replaceable events.

Event types:

  • Replaceable (10000-19999): Same author + kind → replace
  • Parameterized Replaceable (30000-39999): Same author + kind + d-tag → replace

Fix:

// For replaceable events
const key = `${event.pubkey}:${event.kind}`
if (latestEvents[key]?.created_at < event.created_at) {
  latestEvents[key] = event
}

// For parameterized replaceable events
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${event.kind}:${dTag}`
if (latestEvents[key]?.created_at < event.created_at) {
  latestEvents[key] = event
}

WebSocket Communication

Mistake 6: Not Handling EOSE

Problem: Loading indicators never finish or show wrong state.

Solution:

const receivedEvents = new Set()
let eoseReceived = false

ws.onmessage = (msg) => {
  const [type, ...rest] = JSON.parse(msg.data)
  
  if (type === 'EVENT') {
    const [subId, event] = rest
    receivedEvents.add(event.id)
    displayEvent(event)
  }
  
  if (type === 'EOSE') {
    eoseReceived = true
    hideLoadingSpinner()
  }
}

Mistake 7: Not Closing Subscriptions

Problem: Memory leaks and wasted bandwidth from unclosed subscriptions.

Fix: Always send CLOSE when done:

ws.send(JSON.stringify(['CLOSE', subId]))

Best practices:

  • Close when component unmounts
  • Close before opening new subscription with same ID
  • Use unique subscription IDs
  • Track active subscriptions

Mistake 8: Ignoring OK Messages

Problem: Not knowing if events were accepted or rejected.

Solution:

ws.onmessage = (msg) => {
  const [type, eventId, accepted, message] = JSON.parse(msg.data)
  
  if (type === 'OK') {
    if (!accepted) {
      console.error(`Event ${eventId} rejected: ${message}`)
      handleRejection(eventId, message)
    }
  }
}

Common rejection reasons:

  • pow: - Insufficient proof of work
  • blocked: - Pubkey or content blocked
  • rate-limited: - Too many requests
  • invalid: - Failed validation

Mistake 9: Sending Events Before WebSocket Ready

Problem: Events lost because WebSocket not connected.

Fix:

const sendWhenReady = (ws, message) => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(message)
  } else {
    ws.addEventListener('open', () => ws.send(message), { once: true })
  }
}

Mistake 10: Not Handling WebSocket Disconnections

Problem: App breaks when relay goes offline.

Solution: Implement reconnection with exponential backoff:

let reconnectDelay = 1000
const maxDelay = 30000

const connect = () => {
  const ws = new WebSocket(relayUrl)
  
  ws.onclose = () => {
    setTimeout(() => {
      reconnectDelay = Math.min(reconnectDelay * 2, maxDelay)
      connect()
    }, reconnectDelay)
  }
  
  ws.onopen = () => {
    reconnectDelay = 1000 // Reset on successful connection
    resubscribe() // Re-establish subscriptions
  }
}

Filter Queries

Mistake 11: Overly Broad Filters

Problem: Requesting too many events, overwhelming relay and client.

Bad:

{
  "kinds": [1],
  "limit": 10000
}

Good:

{
  "kinds": [1],
  "authors": ["<followed-users>"],
  "limit": 50,
  "since": 1234567890
}

Best practices:

  • Always set reasonable limit (50-500)
  • Filter by authors when possible
  • Use since/until for time ranges
  • Be specific with kinds
  • Multiple smaller queries > one huge query

Mistake 12: Not Using Prefix Matching

Problem: Full hex strings in filters unnecessarily.

Optimization:

{
  "ids": ["abc12345"],  // 8 chars enough for uniqueness
  "authors": ["def67890"]
}

Relays support prefix matching for ids and authors.

Mistake 13: Duplicate Filter Fields

Problem: Redundant filter conditions.

Bad:

{
  "authors": ["pubkey1", "pubkey1"],
  "kinds": [1, 1]
}

Good:

{
  "authors": ["pubkey1"],
  "kinds": [1]
}

Deduplicate filter arrays.

Threading and References

Mistake 14: Incorrect Thread Structure

Problem: Missing root/reply markers or wrong tag order.

Correct reply structure (NIP-10):

{
  "kind": 1,
  "tags": [
    ["e", "<root-event-id>", "<relay>", "root"],
    ["e", "<parent-event-id>", "<relay>", "reply"],
    ["p", "<author1-pubkey>"],
    ["p", "<author2-pubkey>"]
  ]
}

Key points:

  • Root event should have "root" marker
  • Direct parent should have "reply" marker
  • Include p tags for all mentioned users
  • Relay hints are optional but helpful

Mistake 15: Missing p Tags in Replies

Problem: Authors not notified of replies.

Fix: Always add p tag for:

  • Original author
  • Authors mentioned in content
  • Authors in the thread chain
{
  "tags": [
    ["e", "event-id", "", "reply"],
    ["p", "original-author"],
    ["p", "mentioned-user1"],
    ["p", "mentioned-user2"]
  ]
}

Mistake 16: Not Using Markers

Problem: Ambiguous thread structure.

Solution: Always use markers in e tags:

  • root - Root of thread
  • reply - Direct parent
  • mention - Referenced but not replied to

Without markers, clients must guess thread structure.

Relay Management

Mistake 17: Relying on Single Relay

Problem: Single point of failure, censorship vulnerability.

Solution: Connect to multiple relays (5-15 common):

const relays = [
  'wss://relay1.com',
  'wss://relay2.com',
  'wss://relay3.com'
]

const connections = relays.map(url => connect(url))

Best practices:

  • Publish to 3-5 write relays
  • Read from 5-10 read relays
  • Use NIP-65 for user's preferred relays
  • Fall back to NIP-05 relays
  • Implement relay rotation on failure

Mistake 18: Not Implementing NIP-65

Problem: Querying wrong relays, missing user's events.

Correct flow:

  1. Fetch user's kind 10002 event (relay list)
  2. Connect to their read relays to fetch their content
  3. Connect to their write relays to send them messages
async function getUserRelays(pubkey) {
  // Fetch kind 10002
  const relayList = await fetchEvent({
    kinds: [10002],
    authors: [pubkey]
  })
  
  const readRelays = []
  const writeRelays = []
  
  relayList.tags.forEach(([tag, url, mode]) => {
    if (tag === 'r') {
      if (!mode || mode === 'read') readRelays.push(url)
      if (!mode || mode === 'write') writeRelays.push(url)
    }
  })
  
  return { readRelays, writeRelays }
}

Mistake 19: Not Respecting Relay Limitations

Problem: Violating relay policies, getting rate limited or banned.

Solution: Fetch and respect NIP-11 relay info:

const getRelayInfo = async (relayUrl) => {
  const url = relayUrl.replace('wss://', 'https://').replace('ws://', 'http://')
  const response = await fetch(url, {
    headers: { 'Accept': 'application/nostr+json' }
  })
  return response.json()
}

// Respect limitations
const info = await getRelayInfo(relayUrl)
const maxLimit = info.limitation?.max_limit || 500
const maxFilters = info.limitation?.max_filters || 10

Security

Mistake 20: Exposing Private Keys

Problem: Including nsec in client code, logs, or network requests.

Never:

  • Store nsec in localStorage without encryption
  • Log private keys
  • Send nsec over network
  • Display nsec to user unless explicitly requested
  • Hard-code private keys

Best practices:

  • Use NIP-07 (browser extension) when possible
  • Encrypt keys at rest
  • Use NIP-46 (remote signing) for web apps
  • Warn users when showing nsec

Mistake 21: Not Verifying Signatures

Problem: Accepting invalid events, vulnerability to attacks.

Always verify:

const verifyEvent = (event) => {
  // 1. Verify ID
  const calculatedId = sha256(serializeEvent(event))
  if (calculatedId !== event.id) return false
  
  // 2. Verify signature
  const signatureValid = schnorr.verify(
    event.sig,
    event.id,
    event.pubkey
  )
  if (!signatureValid) return false
  
  // 3. Check timestamp
  const now = Math.floor(Date.now() / 1000)
  if (event.created_at > now + 900) return false // 15 min future
  
  return true
}

Verify before:

  • Displaying to user
  • Storing in database
  • Using event data for logic

Mistake 22: Using NIP-04 Encryption

Problem: Weak encryption, vulnerable to attacks.

Solution: Use NIP-44 instead:

  • Modern authenticated encryption
  • ChaCha20-Poly1305 AEAD
  • Proper key derivation
  • Version byte for upgradability

Migration: Update to NIP-44 for all new encrypted messages.

Mistake 23: Not Sanitizing Content

Problem: XSS vulnerabilities in displayed content.

Solution: Sanitize before rendering:

import DOMPurify from 'dompurify'

const safeContent = DOMPurify.sanitize(event.content, {
  ALLOWED_TAGS: ['b', 'i', 'u', 'a', 'code', 'pre'],
  ALLOWED_ATTR: ['href', 'target', 'rel']
})

Especially critical for:

  • Markdown rendering
  • Link parsing
  • Image URLs
  • User-provided HTML

User Experience

Mistake 24: Not Caching Events

Problem: Re-fetching same events repeatedly, poor performance.

Solution: Implement event cache:

const eventCache = new Map()

const cacheEvent = (event) => {
  eventCache.set(event.id, event)
}

const getCachedEvent = (eventId) => {
  return eventCache.get(eventId)
}

Cache strategies:

  • LRU eviction for memory management
  • IndexedDB for persistence
  • Invalidate replaceable events on update
  • Cache metadata (kind 0) aggressively

Mistake 25: Not Implementing Optimistic UI

Problem: Slow feeling app, waiting for relay confirmation.

Solution: Show user's events immediately:

const publishEvent = async (event) => {
  // Immediately show to user
  displayEvent(event, { pending: true })
  
  // Publish to relays
  const results = await Promise.all(
    relays.map(relay => relay.publish(event))
  )
  
  // Update status based on results
  const success = results.some(r => r.accepted)
  displayEvent(event, { pending: false, success })
}

Mistake 26: Poor Loading States

Problem: User doesn't know if app is working.

Solution: Clear loading indicators:

  • Show spinner until EOSE
  • Display "Loading..." placeholder
  • Show how many relays responded
  • Indicate connection status per relay

Mistake 27: Not Handling Large Threads

Problem: Loading entire thread at once, performance issues.

Solution: Implement pagination:

const loadThread = async (eventId, cursor = null) => {
  const filter = {
    "#e": [eventId],
    kinds: [1],
    limit: 20,
    until: cursor
  }
  
  const replies = await fetchEvents(filter)
  return { replies, nextCursor: replies[replies.length - 1]?.created_at }
}

Testing

Mistake 28: Not Testing with Multiple Relays

Problem: App works with one relay but fails with others.

Solution: Test with:

  • Fast relays
  • Slow relays
  • Unreliable relays
  • Paid relays (auth required)
  • Relays with different NIP support

Mistake 29: Not Testing Edge Cases

Critical tests:

  • Empty filter results
  • WebSocket disconnections
  • Malformed events
  • Very long content
  • Invalid signatures
  • Relay errors
  • Rate limiting
  • Concurrent operations

Mistake 30: Not Monitoring Performance

Metrics to track:

  • Event verification time
  • WebSocket latency per relay
  • Events per second processed
  • Memory usage (event cache)
  • Subscription count
  • Failed publishes

Best Practices Checklist

Event Creation:

  • Correct serialization for ID
  • Schnorr signatures
  • Current timestamp
  • Valid tag structure
  • Handle replaceable events

WebSocket:

  • Handle EOSE
  • Close subscriptions
  • Process OK messages
  • Check WebSocket state
  • Reconnection logic

Filters:

  • Set reasonable limits
  • Specific queries
  • Deduplicate arrays
  • Use prefix matching

Threading:

  • Use root/reply markers
  • Include all p tags
  • Proper thread structure

Relays:

  • Multiple relays
  • Implement NIP-65
  • Respect limitations
  • Handle failures

Security:

  • Never expose nsec
  • Verify all signatures
  • Use NIP-44 encryption
  • Sanitize content

UX:

  • Cache events
  • Optimistic UI
  • Loading states
  • Pagination

Testing:

  • Multiple relays
  • Edge cases
  • Monitor performance

Resources

  • nostr-tools: JavaScript library with best practices
  • rust-nostr: Rust implementation with strong typing
  • NIPs Repository: Official specifications
  • Nostr Dev: Community resources and help