@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:
- Checks bypass rules (allowed kinds, allowed pubkeys).
- Hits an in-process TTL+LRU cache keyed on
(pubkey, thresholds). - On miss, calls
@orangecheck/sdk'scheck()withidentity: nostr:<hex pubkey>. - 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 var | Default | Meaning |
|---|---|---|
OC_MIN_SATS | 0 | Minimum sats bonded. |
OC_MIN_DAYS | 0 | Minimum days unspent. |
OC_ALLOW_KINDS | 0,3,10002 | Event kinds that bypass the filter. |
OC_ALLOW_PUBKEYS | none | Comma-separated hex pubkeys that bypass. |
OC_RELAYS | SDK default | Discovery relays for lookups. |
OC_FAIL_OPEN | false | Allow events through on lookup failure. |
OC_CACHE_TTL_MS | 60000 | Cache TTL. |
OC_LOG | true | Emit 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
allowKindson 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: trueopts 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.