- Add live camera QR scanner for nsec/ncryptsec login - Replace browser prompt() with proper password dialog for ncryptsec - Add missing /notes/:id route for thread view navigation - Remove explore section entirely (button, page, routes) - Remove profile button from bottom nav, avatar now opens profile - Remove "Notes" tab from feed, default to showing all posts/replies - Add PasswordPromptProvider for secure password input - Add SidebarDrawer for mobile navigation - Add domain layer with value objects and adapters - Various UI and navigation improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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