NIP-13

Proof of Work

draft relay features

NIP-13 implements proof-of-work on Nostr by mining event IDs with leading zero bits, making spam economically infeasible while allowing legitimate use.

Author
jb55, cameri
Last Updated
15 September 2023
Official Spec
View on GitHub →

NIP-13: Proof of Work

Status: Draft Authors: jb55, cameri Category: Relay Features


Overview

NIP-13 defines proof-of-work (PoW) for Nostr events, requiring computational effort to publish, making spam economically infeasible.

Core Concept:

  • Mine event IDs with leading zero bits
  • Higher difficulty = more computational work
  • Relays can require minimum PoW
  • Spam becomes expensive to produce

How It Works:

  1. Add nonce tag to event
  2. Increment nonce and recalculate ID
  3. Repeat until ID has required leading zeros
  4. Publish event with proof

Benefits:

  • Spam prevention: Makes bulk posting expensive
  • Sybil resistance: Rate limiting without accounts
  • Fair access: Anyone can post with enough work
  • No registration: No accounts or credentials needed
  • Relay protection: Filters low-quality content

Status: Draft (optional, some relays require it)


Why Proof of Work Matters

Spam Problem

Without PoW:

  • Free to spam millions of events
  • Relays overwhelmed with junk
  • No cost to bad actors
  • Rate limiting requires authentication

With PoW:

  • Each event requires computational work
  • Bulk spam becomes prohibitively expensive
  • Legitimate users barely notice cost
  • No authentication needed

Real-World Impact

Without PoW:

  • 1,000,000 spam events = seconds to generate

With PoW (difficulty 20):

  • 1,000,000 spam events = ~11,574 days of CPU time
  • Legitimate post = ~1 second

Result: Spam becomes 1,000,000× more expensive


How It Works

Event ID Mining

Nostr event IDs are SHA-256 hashes. PoW requires mining an ID with leading zero bits:

Difficulty 0:  (any ID)
Difficulty 8:  00xxxxxx... (8 leading zero bits)
Difficulty 16: 0000xxxx... (16 leading zero bits)
Difficulty 20: 00000xxx... (20 leading zero bits)
Difficulty 32: 00000000... (32 leading zero bits)

Mining Process:

  1. Create event with nonce tag
  2. Calculate event ID (SHA-256 hash)
  3. Count leading zero bits in ID
  4. If insufficient, increment nonce and repeat

Mining Implementation

Basic PoW Miner

import { getEventHash } from 'nostr-tools';

function mineEvent(event, targetDifficulty) {
  let nonce = 0;
  let minedEvent = { ...event };

  while (true) {
    // Add nonce tag
    minedEvent.tags = event.tags.filter(t => t[0] !== 'nonce');
    minedEvent.tags.push(['nonce', String(nonce), String(targetDifficulty)]);

    // Calculate event ID
    minedEvent.id = getEventHash(minedEvent);

    // Check difficulty
    const difficulty = countLeadingZeroBits(minedEvent.id);

    if (difficulty >= targetDifficulty) {
      console.log(`Found valid PoW after ${nonce} attempts`);
      return minedEvent;
    }

    nonce++;

    // Progress indicator
    if (nonce % 10000 === 0) {
      console.log(`Mining... ${nonce} attempts, best: ${difficulty} bits`);
    }
  }
}

function countLeadingZeroBits(hex) {
  let count = 0;

  for (let i = 0; i < hex.length; i++) {
    const nibble = parseInt(hex[i], 16);

    if (nibble === 0) {
      count += 4;
    } else {
      // Count leading zeros in this nibble
      count += Math.clz32(nibble) - 28;
      break;
    }
  }

  return count;
}

// Usage
const event = {
  kind: 1,
  content: "This post has proof of work!",
  tags: [],
  created_at: Math.floor(Date.now() / 1000),
  pubkey: myPubkey
};

const minedEvent = mineEvent(event, 20); // Target 20 bits
const signedEvent = signEvent(minedEvent, privateKey);

Optimized Mining

class PoWMiner {
  constructor(targetDifficulty) {
    this.targetDifficulty = targetDifficulty;
    this.bestDifficulty = 0;
  }

  mine(event) {
    const startTime = Date.now();
    let nonce = 0;
    let attempts = 0;

    while (true) {
      // Update nonce tag
      const tags = event.tags.filter(t => t[0] !== 'nonce');
      tags.push(['nonce', String(nonce), String(this.targetDifficulty)]);

      const testEvent = {
        ...event,
        tags: tags
      };

      testEvent.id = getEventHash(testEvent);
      const difficulty = this.countLeadingZeroBits(testEvent.id);

      attempts++;

      // Track best found
      if (difficulty > this.bestDifficulty) {
        this.bestDifficulty = difficulty;
        console.log(`New best: ${difficulty} bits (nonce: ${nonce})`);
      }

      // Check if target met
      if (difficulty >= this.targetDifficulty) {
        const duration = (Date.now() - startTime) / 1000;
        const hashRate = attempts / duration;

        console.log(`✅ PoW found! Difficulty: ${difficulty}`);
        console.log(`Time: ${duration.toFixed(2)}s, Attempts: ${attempts}`);
        console.log(`Hash rate: ${hashRate.toFixed(0)} H/s`);

        return testEvent;
      }

      nonce++;
    }
  }

  countLeadingZeroBits(hexStr) {
    let bits = 0;

    for (let i = 0; i < hexStr.length; i++) {
      const nibble = parseInt(hexStr[i], 16);

      if (nibble === 0) {
        bits += 4;
      } else {
        // Count leading zeros in nibble
        const binary = nibble.toString(2).padStart(4, '0');
        for (const bit of binary) {
          if (bit === '0') bits++;
          else return bits;
        }
      }
    }

    return bits;
  }
}

// Usage
const miner = new PoWMiner(20);
const minedEvent = miner.mine(myEvent);

Web Worker Mining

For better UX, mine in a background thread:

// pow-worker.js
self.onmessage = function(e) {
  const { event, targetDifficulty } = e.data;

  const minedEvent = mineEvent(event, targetDifficulty);

  self.postMessage({ success: true, event: minedEvent });
};

function mineEvent(event, targetDifficulty) {
  // ... mining implementation ...
}

// main.js
function mineEventAsync(event, targetDifficulty) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('pow-worker.js');

    worker.onmessage = (e) => {
      if (e.data.success) {
        resolve(e.data.event);
        worker.terminate();
      }
    };

    worker.onerror = (error) => {
      reject(error);
      worker.terminate();
    };

    worker.postMessage({ event, targetDifficulty });
  });
}

// Usage (non-blocking UI)
async function postWithPoW(content) {
  console.log("Mining PoW...");

  const event = {
    kind: 1,
    content: content,
    tags: [],
    created_at: Math.floor(Date.now() / 1000),
    pubkey: myPubkey
  };

  const mined = await mineEventAsync(event, 20);
  const signed = signEvent(mined, privateKey);

  await publishToRelays(signed);
  console.log("Posted with PoW!");
}

Verification

Verify PoW on Event

function verifyPoW(event, minimumDifficulty) {
  // Find nonce tag
  const nonceTag = event.tags.find(t => t[0] === 'nonce');

  if (!nonceTag) {
    return { valid: false, difficulty: 0, reason: "No nonce tag" };
  }

  const [_, nonce, targetDiff] = nonceTag;

  // Verify event ID is correct
  const calculatedId = getEventHash(event);
  if (calculatedId !== event.id) {
    return { valid: false, difficulty: 0, reason: "Invalid event ID" };
  }

  // Count difficulty
  const difficulty = countLeadingZeroBits(event.id);

  // Check if meets minimum
  if (difficulty < minimumDifficulty) {
    return {
      valid: false,
      difficulty: difficulty,
      reason: `Insufficient PoW (${difficulty} < ${minimumDifficulty})`
    };
  }

  return { valid: true, difficulty: difficulty };
}

// Usage
const event = receivedEvent;
const result = verifyPoW(event, 20);

if (result.valid) {
  console.log(`✅ Valid PoW: ${result.difficulty} bits`);
} else {
  console.log(`❌ Invalid PoW: ${result.reason}`);
}

Relay Implementation

PoW-Required Relay

class PoWRelay {
  constructor(minimumDifficulty = 16) {
    this.minimumDifficulty = minimumDifficulty;
  }

  handleEvent(event, ws) {
    // Verify PoW
    const powResult = verifyPoW(event, this.minimumDifficulty);

    if (!powResult.valid) {
      // Reject event
      ws.send(JSON.stringify([
        "OK",
        event.id,
        false,
        `pow: ${powResult.reason}`
      ]));
      return;
    }

    // Log PoW quality
    console.log(`Event ${event.id} has PoW: ${powResult.difficulty} bits`);

    // Accept and store event
    this.storeEvent(event);

    ws.send(JSON.stringify([
      "OK",
      event.id,
      true,
      ""
    ]));
  }

  getRequiredDifficulty(eventKind) {
    // Different requirements per kind
    const difficulties = {
      1: 16,    // Text notes
      3: 20,    // Contact lists (more expensive to spam)
      4: 12,    // DMs
      7: 8      // Reactions (less important)
    };

    return difficulties[eventKind] || this.minimumDifficulty;
  }
}

Relays should advertise PoW requirements in NIP-11:

{
  "name": "PoW Relay",
  "supported_nips": [1, 11, 13],
  "limitation": {
    "pow_difficulty": 16,
    "pow_required": true
  }
}

Difficulty Recommendations

By Event Type

const RECOMMENDED_DIFFICULTY = {
  // Text notes
  KIND_1: 16,  // ~65,536 hashes (~0.1s)

  // Contact lists
  KIND_3: 20,  // ~1,048,576 hashes (~1.5s)

  // Encrypted DMs
  KIND_4: 12,  // ~4,096 hashes (~instant)

  // Deletions
  KIND_5: 20,  // Prevent spam deletions

  // Reactions
  KIND_7: 8,   // ~256 hashes (~instant)

  // Channel messages
  KIND_42: 16
};

function getRecommendedDifficulty(kind) {
  return RECOMMENDED_DIFFICULTY[`KIND_${kind}`] || 16;
}

Performance Metrics

Average mining times on modern CPU (~1M hashes/sec):

DifficultyAvg HashesAvg TimeUse Case
8 bits256<1msReactions, likes
12 bits4,096~4msDMs, low-priority
16 bits65,536~65msStandard posts
20 bits1,048,576~1sImportant posts
24 bits16,777,216~17sVery valuable posts
28 bits268,435,456~4.5minExtreme protection

Client Integration

Auto-PoW Feature

class NostrClient {
  constructor(options = {}) {
    this.autoPoW = options.autoPoW || false;
    this.defaultDifficulty = options.defaultDifficulty || 16;
  }

  async publishEvent(event) {
    // Check relay requirements
    const relayDifficulty = await this.getRelayPoWRequirement();

    const targetDifficulty = Math.max(
      relayDifficulty,
      this.autoPoW ? this.defaultDifficulty : 0
    );

    if (targetDifficulty > 0) {
      console.log(`Mining PoW (difficulty: ${targetDifficulty})...`);
      event = await this.mineEvent(event, targetDifficulty);
      console.log("PoW complete!");
    }

    // Sign and publish
    const signed = await this.signEvent(event);
    return await this.publishToRelays(signed);
  }

  async getRelayPoWRequirement() {
    // Fetch from NIP-11
    try {
      const info = await fetch(this.relayHttpUrl, {
        headers: { 'Accept': 'application/nostr+json' }
      });
      const data = await info.json();
      return data.limitation?.pow_difficulty || 0;
    } catch {
      return 0;
    }
  }
}

// Usage
const client = new NostrClient({
  autoPoW: true,
  defaultDifficulty: 16
});

await client.publishEvent({
  kind: 1,
  content: "This will automatically include PoW!",
  tags: []
});

Progressive Mining UI

function MiningStatus({ difficulty }) {
  const [progress, setProgress] = useState(0);
  const [attempts, setAttempts] = useState(0);
  const [hashRate, setHashRate] = useState(0);

  // Estimated attempts for difficulty
  const estimated = Math.pow(2, difficulty);

  return (
    <div className="mining-status">
      <h3>Mining Proof of Work...</h3>
      <div className="progress-bar">
        <div
          className="progress"
          style={{ width: `${(attempts / estimated) * 100}%` }}
        />
      </div>
      <div className="stats">
        <span>Difficulty: {difficulty} bits</span>
        <span>Attempts: {attempts.toLocaleString()}</span>
        <span>Hash rate: {hashRate.toLocaleString()} H/s</span>
        <span>Est. time: {(estimated / hashRate).toFixed(1)}s</span>
      </div>
    </div>
  );
}

Security Considerations

PoW Attacks

Pre-mining:

  • Attacker mines many events in advance
  • Mitigation: Check created_at timestamp is recent
function verifyPoWTimestamp(event, maxAge = 3600) {
  const now = Math.floor(Date.now() / 1000);
  const age = now - event.created_at;

  if (age > maxAge) {
    return false; // Event too old (possibly pre-mined)
  }

  if (age < -300) {
    return false; // Event in future (clock skew)
  }

  return true;
}

Mining Farms:

  • Dedicated hardware mines spam
  • Mitigation: Increase difficulty for suspicious patterns

Reuse:

  • Reuse same mined event multiple times
  • Mitigation: Track seen event IDs

Performance Optimization

Multi-threaded Mining

class ParallelMiner {
  constructor(numWorkers = 4) {
    this.workers = [];

    for (let i = 0; i < numWorkers; i++) {
      this.workers.push(new Worker('pow-worker.js'));
    }
  }

  async mine(event, targetDifficulty) {
    return new Promise((resolve) => {
      let resolved = false;

      // Divide nonce space among workers
      this.workers.forEach((worker, index) => {
        worker.onmessage = (e) => {
          if (e.data.success && !resolved) {
            resolved = true;

            // Terminate all workers
            this.workers.forEach(w => w.terminate());

            resolve(e.data.event);
          }
        };

        worker.postMessage({
          event,
          targetDifficulty,
          nonceStart: index * 1000000,
          nonceStep: this.workers.length
        });
      });
    });
  }
}

// 4x speedup with 4 cores
const miner = new ParallelMiner(4);
const minedEvent = await miner.mine(event, 20);

Client Support

Clients with PoW Support

  • Damus - Automatic PoW for protected relays
  • Amethyst - Configurable PoW mining
  • Snort - Manual PoW enable
  • Coracle - Relay-adaptive PoW

Relay Support

Relay SoftwarePoW SupportConfigurableDefault
nostreamOff
strfryOff
nostr-rs-relayOff
rnostrOff

Check our Relay Directory for PoW-enabled relays.


Common Questions

Does PoW prevent all spam?

No, but it makes spam economically infeasible at scale. A determined attacker with resources can still spam, but the cost is high.

Will PoW drain my battery?

Modern difficulty levels (16-20 bits) take <1 second on most devices. Minimal battery impact.

Can I disable PoW?

Yes, most clients make it optional. However, PoW-required relays won’t accept your events without it.

Why not just use rate limiting?

Rate limiting requires authentication (IP, accounts). PoW works without any registration.

What difficulty should relays require?

Start with 16 bits for regular posts. Increase if spam persists. Too high frustrates legitimate users.

Does PoW work on mobile?

Yes, but be mindful of battery. Use lower difficulties (12-16) for mobile.


  • NIP-01 - Basic protocol (event structure, IDs)
  • NIP-11 - Relay information (advertising PoW requirements)
  • NIP-42 - Authentication (alternative spam prevention)

Technical Specification

For the complete technical specification, see NIP-13 on GitHub.


Summary

NIP-13 implements proof-of-work on Nostr:

Mine event IDs with leading zero bits ✅ Configurable difficulty per relay/event type ✅ Spam prevention through computational cost ✅ No authentication required - fair access ✅ Optional feature - relay discretion

Mining example:

const miner = new PoWMiner(20); // 20-bit difficulty
const minedEvent = miner.mine(event);

// Event ID now has 20 leading zero bits
console.log(minedEvent.id); // 00000abc...

Status: Draft - optional, growing adoption by spam-protected relays.

Best practice: Use 16 bits for normal posts, higher for valuable content.


Next Steps:


Last updated: September 2023 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

damus nostream strfry rnostr
View all clients →

Related NIPs

NIP-01 NIP-11 NIP-42
← Browse All NIPs