- Introduced a comprehensive skill for the Nostr protocol, covering client and relay implementation, event structure, cryptographic operations, and best practices. - Added detailed reference files including an overview of NIPs, event kinds, common mistakes, and a complete guide to the Nostr protocol. - Included quick start examples and a checklist for implementing Nostr features effectively. - Ensured documentation is aligned with the latest standards and practices for Nostr development.
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
idorsigfields 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 workblocked:- Pubkey or content blockedrate-limited:- Too many requestsinvalid:- 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
authorswhen possible - Use
since/untilfor 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
ptags 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 threadreply- Direct parentmention- 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:
- Fetch user's kind
10002event (relay list) - Connect to their read relays to fetch their content
- 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