@orangecheck/relay-filter
v0.5.4
Published
Sybil filter for Nostr relays — reject events from pubkeys without an OrangeCheck proof meeting configurable thresholds.
Maintainers
Readme
@orangecheck/relay-filter
Full reference: ochk.io/docs/sdk/relay-filter — auto-generated from the TypeScript source on every release. Hand-written prose below is the high-level overview; the docs site is the source of truth for every export, type, and signature.
Sybil filter for Nostr relays. Reject events from pubkeys whose OrangeCheck attestation doesn't meet your thresholds. Honest users pay nothing; attackers pay real Bitcoin opportunity cost at scale.
This is the reference implementation for VISION.md pathway 3 — "sybil-filtered infra." Run it on your own relay; license it for commercial deployments; or adapt the primitive into whatever pipeline fits your stack.
Install
yarn add @orangecheck/relay-filterThe framework-agnostic primitive
import { filterEvent } from '@orangecheck/relay-filter';
// On your relay's EVENT write path:
const decision = await filterEvent(event, {
minSats: 100_000, // at least 100k sats bonded
minDays: 30, // unspent for at least 30 days
allowKinds: [0, 3, 10002], // profile meta, contacts, relay list
allowPubkeys: [operatorHexPubkey], // your own key, always
});
if (decision.action === 'reject') {
// Nostr OK message: ['OK', event.id, false, msg]
socket.send(JSON.stringify(['OK', event.id, false, decision.message]));
return;
}
// accept — store the eventOn each call, the filter:
- Checks bypass rules (allowed kinds, allowed pubkeys).
- Hits an in-process TTL+LRU cache keyed on
(pubkey, thresholds). - On a miss, calls
@orangecheck/sdk'scheck()withidentity: nostr:<hex pubkey>— discovers the attestation via Nostr relays, verifies the Bitcoin signature, recomputes sats + days from live chain state. - Returns
{ action: 'accept' | 'reject' | 'shadowReject', reason, message?, check? }.
No state beyond the cache. No secrets. No OrangeCheck server in the relay's data path — the SDK talks directly to public Bitcoin explorers and Nostr discovery relays.
Strfry plugin
Strfry is the most widely-deployed relay implementation. It accepts external policy plugins via a simple stdin/stdout JSON protocol. We ship a ready-made plugin as oc-strfry:
# strfry.conf
writePolicy = {
plugin = "/usr/local/bin/oc-strfry"
}Strfry asks the plugin about one event at a time and waits for the answer, so
the plugin never touches the network on that path. It holds every OC
attestation that binds a Nostr key in memory, kept current by one standing
subscription (kinds: [30078], #t: ["oc-attest"]), and re-reads each bond from
the chain in the background. Measured on the built binary against live relays:
p50 0.06 ms, max 1.6 ms per decision for 50 fresh pubkeys. A lookup per
event took about 6 s, which would let anyone rotating pubkeys stall your relay.
For the first few seconds after start, before the backlog has loaded, an
unknown key gets verifying your proof, try again shortly rather than a
refusal. After that, an unseen key is refused at once and looked up by
identity in the background, so an attested key that the subscription missed
is admitted on its next event.
Configure via environment variables in the Strfry unit file (or wherever Strfry starts):
| 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 | Accept a verified signature whose bond the chain API cannot read. Never a signature not yet checked |
| OC_REFRESH_MS | 600000 | How often each bond is re-read from the chain. A spent bond keeps passing for at most this long. |
| OC_LOG | true | Emit one log line per decision on stderr |
Install globally for Strfry
yarn global add @orangecheck/relay-filter
# makes `oc-strfry` available on PATHOr run via npx
# strfry.conf
writePolicy = {
plugin = "npx -y @orangecheck/relay-filter"
}Strfry plugin protocol (for the curious)
The plugin reads one JSON event per line on stdin:
{ "type": "new", "event": { "id": "...", "pubkey": "...", "kind": 1, ... } }And writes one decision per line on stdout:
{ "id": "<event_id>", "action": "accept" }
{ "id": "<event_id>", "action": "reject", "msg": "orangecheck: below threshold" }
{ "id": "<event_id>", "action": "shadowReject" }Strfry forwards msg to the client as the OK message on reject.
nostr-tools relay
If your relay is a long-running JS process, use the same in-memory index the
Strfry plugin uses. decide() is synchronous:
import { AttestationIndex } from '@orangecheck/relay-filter';
const index = new AttestationIndex({ minSats: 100_000 }); // { relays?, refreshMs?, minSats? }
index.start();
// in the EVENT handler, after verifyEvent():
const decision = index.decide(event, { minSats: 100_000, minDays: 30 });A bond's age is that of the coins covering minSats, so give the index the
minSats you decide with; a larger one at decide() re-reads every bond first.
filterEvent below looks each pubkey up over the network instead. It suits a
serverless handler that cannot hold state, and costs seconds on a cache miss:
import { filterEvent } from '@orangecheck/relay-filter';
import { Event, verifyEvent } from 'nostr-tools';
async function handleIncomingEvent(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;
}
if (decision.action === 'shadowReject') {
socket.send(JSON.stringify(['OK', event.id, true, ''])); // lie to client
return; // do not store
}
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 — users need to publish those before they can create an OC proof. Gating them creates a chicken-and-egg problem. Ephemeral / bootstrap kinds are the only things that bypass by default; everything else (posts, DMs, reactions, zaps) is gated. - Bypass
allowPubkeys. The operator's own key should never be filtered. - Fail closed by default. If the verifier is unreachable, we reject.
failOpen: trueopts into degraded mode, and it covers only that: the lookup throws, or the signature verified and no Esplora endpoint answered. A key whose signature has not been checked yet, including in the first seconds after the plugin starts, is never let through. - One bond, one key. An address that backs more than one Nostr key admits none of them through that bond (
stake_shared), as SECURITY.md §3 of the protocol requires. A holder who wants two keys gated uses two addresses. - Only the signed message counts. Event tags are indexes. A key is admitted only if the attestation's signed
identities:line binds it, in npub or hex form. - Freshness is bounded, not live. The index re-reads bonds every
OC_REFRESH_MS.filterEventcaches per pubkey forcacheTtlMs, at most ten minutes. Either way, pick the window your relay can tolerate a spent bond passing for. - Unknown options throw.
filterEvent,AttestationIndex.decideandnew AttestationIndex()refuse a key they do not read, so a misspeltminSatsnever reads as 0.
What this doesn't do
- Doesn't verify Nostr event signatures. Use
nostr-tools'verifyEvent()before callingfilterEvent(). We only care about the author's OC proof, not the event integrity. - Doesn't authenticate the caller. This is a write-time filter, not a NIP-42
AUTHimplementation. Combine with NIP-42 if you also want AUTH-gated reads. - Doesn't fetch profiles or resolve NIP-05. The filter matches the event's hex pubkey against the npub or hex key the attestation binds.
License
MIT. The OrangeCheck protocol specifications are MIT too.
