- 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.
658 lines
14 KiB
Markdown
658 lines
14 KiB
Markdown
# 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**:
|
|
```json
|
|
[
|
|
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**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```javascript
|
|
// 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**:
|
|
```javascript
|
|
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:
|
|
```javascript
|
|
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**:
|
|
```javascript
|
|
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**:
|
|
```javascript
|
|
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:
|
|
```javascript
|
|
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**:
|
|
```json
|
|
{
|
|
"kinds": [1],
|
|
"limit": 10000
|
|
}
|
|
```
|
|
|
|
**Good**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```json
|
|
{
|
|
"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**:
|
|
```json
|
|
{
|
|
"authors": ["pubkey1", "pubkey1"],
|
|
"kinds": [1, 1]
|
|
}
|
|
```
|
|
|
|
**Good**:
|
|
```json
|
|
{
|
|
"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):
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```json
|
|
{
|
|
"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):
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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:
|
|
```javascript
|
|
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**:
|
|
```javascript
|
|
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:
|
|
```javascript
|
|
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:
|
|
```javascript
|
|
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:
|
|
```javascript
|
|
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:
|
|
```javascript
|
|
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
|
|
|