docs / @orangecheck/relay-filter

@orangecheck/relay-filter

Reject events from Nostr pubkeys whose OrangeCheck attestation doesn't meet your thresholds. Ships a Strfry plugin + a framework-agnostic primitive.

yarn add @orangecheck/relay-filter

Framework-agnostic primitive

import { filterEvent } from '@orangecheck/relay-filter';

// On your relay's EVENT write path
const decision = await filterEvent(event, {
  minSats: 100_000,
  minDays: 30,
  allowKinds:    [0, 3, 10002],       // profile meta, contacts, relay list
  allowPubkeys:  [operatorHexPubkey],  // you, always
});

if (decision.action === 'reject') {
  socket.send(JSON.stringify(['OK', event.id, false, decision.message]));
  return;
}
// accept — store the event

The filter:

  1. Checks bypass rules (allowed kinds, allowed pubkeys).
  2. Hits an in-process TTL+LRU cache keyed on (pubkey, thresholds).
  3. On miss, calls @orangecheck/sdk's check() with identity: nostr:<hex pubkey>.
  4. Returns { action: 'accept' | 'reject' | 'shadowReject', reason, message?, check? }.

Strfry plugin

Strfry is the most widely-deployed Nostr relay. It accepts external policy plugins via stdin/stdout JSON. We ship one:

# strfry.conf
writePolicy = {
  plugin = "/usr/local/bin/oc-strfry"
}

Install

yarn global add @orangecheck/relay-filter
# makes `oc-strfry` available on PATH

Or via npx:

writePolicy = { plugin = "npx -y @orangecheck/relay-filter" }

Configure via environment

Env varDefaultMeaning
OC_MIN_SATS0Minimum sats bonded.
OC_MIN_DAYS0Minimum days unspent.
OC_ALLOW_KINDS0,3,10002Event kinds that bypass the filter.
OC_ALLOW_PUBKEYSnoneComma-separated hex pubkeys that bypass.
OC_RELAYSSDK defaultDiscovery relays for lookups.
OC_FAIL_OPENfalseAllow events through on lookup failure.
OC_CACHE_TTL_MS60000Cache TTL.
OC_LOGtrueEmit one log line per decision on stderr.

See Sybil-filtered Nostr relay guide for a complete setup.

nostr-tools relay

import { filterEvent } from '@orangecheck/relay-filter';
import { verifyEvent } from 'nostr-tools';

async function handleEvent(socket: WebSocket, event: Event) {
  if (!verifyEvent(event)) {
    socket.send(JSON.stringify(['OK', event.id, false, 'invalid signature']));
    return;
  }

  const decision = await filterEvent(event, {
    minSats: 100_000,
    minDays: 30,
  });

  if (decision.action === 'reject') {
    socket.send(JSON.stringify(['OK', event.id, false, decision.message]));
    return;
  }

  await store.put(event);
  socket.send(JSON.stringify(['OK', event.id, true, '']));
}

Design notes

  • Bypass allowKinds on purpose. Kind 0 (profile metadata), kind 3 (contacts), and kind 10002 (relay list) are bootstrap data. Gating them creates a chicken-and-egg problem — users need to publish those before they can create an OC proof.
  • Bypass allowPubkeys. The operator's own key never gets filtered.
  • Fail closed. If lookups throw (relays down, network split), reject. failOpen: true opts into degraded-mode.
  • Per-pubkey cache. The SDK's check() caches for 60 s via the hosted API. This package adds a second LRU so a busy relay doesn't hit the network for hot pubkeys.

Threat model

Raises the cost floor of sybil attacks. Does not prevent targeted abuse by an attacker with real Bitcoin. Pair with rate limits, spam detection, and moderation as appropriate.

License

MIT.