OverviewFilesCommitsIssuesDocsReleasesPeers

p2pbuilders / web / js / pow.js

81 lines · javascript ref:
// pow.js — proof-of-work spam gate (hashcash). The terminal app uses blake2b;
// the browser port uses SHA-256 (SubtleCrypto, available everywhere). To submit
// a post/comment/board you must find a nonce whose SHA-256(content|nonce) has at
// least N leading zero bits — cheap once, expensive to spam. The pow {bits,nonce}
// is part of the signed record, and is re-verified by every peer on ingest (via
// the gossip `validate` hook), so unworked posts never enter the network.

// Minimum difficulty per record type. Tuned for the browser (SubtleCrypto is
// slower per hash than native blake2b, so these are lower than the terminal app's
// 18/16/22). Empirically a few hundred ms to a couple seconds.
export const MIN_BITS = { post: 16, comment: 14, board: 18 }

// The bytes hashed for PoW: each op's IMMUTABLE identity (unique cid + author +
// createdAt), not its mutable body. So creating an op costs work, editing/deleting
// it reuses that work, and one op's proof can't be reused for another (unique cid).
// The signature separately covers the whole record (incl. pow), so pow can't be
// swapped or altered after the fact.
export function powTarget (type, data) {
  switch (type) {
    case 'post': return `post|${data.board}|${data.cid}|${data.author}|${data.createdAt}`
    case 'comment': return `comment|${data.postCid}|${data.cid}|${data.author}|${data.createdAt}`
    case 'board': return `board|${data.name}|${data.creator}|${data.createdAt}`
    default: return type + '|' + (data.author || '')
  }
}

function leadingZeroBits (u8) {
  let n = 0
  for (let i = 0; i < u8.length; i++) {
    const b = u8[i]
    if (b === 0) { n += 8; continue }
    n += Math.clz32(b) - 24
    break
  }
  return n
}

async function sha256 (str) {
  const buf = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
  return new Uint8Array(buf)
}

// Mint a proof for `data`. Returns { bits, nonce }. Yields to the event loop
// periodically and reports progress so the UI stays responsive.
export async function mint (type, data, bits, opts = {}) {
  const target = powTarget(type, data)
  let nonce = 0
  for (;;) {
    const h = await sha256(target + '|' + nonce)
    if (leadingZeroBits(h) >= bits) return { bits, nonce }
    nonce++
    if ((nonce & 1023) === 0) {
      if (opts.onProgress) opts.onProgress(nonce)
      if (opts.signal && opts.signal.aborted) throw new Error('proof-of-work cancelled')
      await new Promise(r => setTimeout(r, 0))
    }
  }
}

// Verify a record carries valid PoW of at least minBits.
export async function verify (type, data, minBits) {
  const pow = data && data.pow
  if (!pow || typeof pow.bits !== 'number' || typeof pow.nonce !== 'number') return false
  if (pow.bits < minBits) return false
  const h = await sha256(powTarget(type, data) + '|' + pow.nonce)
  return leadingZeroBits(h) >= pow.bits
}

// Build the gossip `validate` gate: posts/comments/boards must carry valid PoW;
// votes/profiles/follows/blocks/blocklists are exempt (rate/reputation-gated).
export function makeValidator (minBits = MIN_BITS) {
  return async (type, val) => {
    if (type === 'post') return verify(type, val, minBits.post)
    if (type === 'comment') return verify(type, val, minBits.comment)
    if (type === 'board') return verify(type, val, minBits.board)
    return true
  }
}

export { leadingZeroBits }