oc · docs
nostr publication

Nostr publication

Every OrangeCheck sibling publishes its public artifacts as NIP-78 parameterized-replaceable addressable events. The original kind, 30078, is the namespake of this page and still carries Attest attestations, Lock device records, and Pledge envelopes. As more siblings shipped, the family expanded into the 30078–30115 range with one kind per artifact type. This page is the public roll-up.

Source of truth: the family kind registry (KINDS.md in the workspace), mirrored in oc-agent-protocol/SPEC.md §4. Each protocol's own spec normatively claims the kind(s) it owns.

Kind registry

KindOwnerPurpose
30078OC AttestAttestation envelope (the original family event kind)
30078OC LockDevice record (binds an X25519 device key to a Bitcoin address)
30078OC PledgePledge envelope, outcome envelope, abandonment envelope (disambiguated by d tag)
30078OC ChatDevice record (oc-chat:device:)
30079—Archived; do not use
30080OC VotePoll
30081OC VoteBallot
30082OC VoteReveal (secret-mode tally)
30083OC StampStamp envelope
30083OC AgentAgent delegation (co-claim with OC Stamp; disambiguated by disjoint d-tag prefixes)
30084OC AgentAgent action (claimed exclusively; reuses OC Stamp envelope structure but on a distinct kind)
30085OC AgentAgent revocation
30086OC AgentAgent sub-delegation (v1.1) — chained narrowing of authority
30087OC Meme.ochk.io billable-event, payment, rebind, payout-binding, distribution and drop envelopes
30088–30093—Free
30094–30109—Reserved, unratified (OC Find, an unbuilt research idea)
30110OC ChatChannel descriptor
30111OC ChatChannel post
30112OC ChatSeal / block-height anchor
30113OC ChatReserved (group-key rotation, private channels)
30114OC ChatDiscoverability directory
30115OC ChatReserved (pay-to-post postage policy)

OC Chat direct messages travel as NIP-59 gift wraps (kind 1059), a standard Nostr kind rather than a family allocation.

If you're allocating a new kind for a new sibling, claim the next free number (currently 30088) and update the family registry and oc-agent-protocol/SPEC.md §4. Kinds are never reused across sub-protocols.

Why NIP-78 / kind 30078

NIP-78 is the "application-specific data" event kind, designed precisely for what we need:

  • Addressable — (pubkey, kind, d) uniquely identifies an event across all relays. Republishing with the same d tag replaces the old version.
  • No reserved format — the content field is application-defined, and we fill it with the JSON envelope each protocol specifies.
  • First-class in every Nostr client — no custom relay patches required.

The 30080–30087 and 30110–30115 kinds claimed by Vote, Stamp, Agent, Me and Chat are also in NIP-78's parameterized-replaceable range and behave identically; the only reason they exist as separate kinds (rather than reusing 30078) is so a relay query like {kinds: [30080]} returns only polls without the integrator having to post-filter on the d tag.

d-tag conventions

Each protocol reserves a d-tag namespace so events from different siblings don't collide on overlapping kinds.

ProtocolKindd tag format
OC Attest30078<attestation_id> (64-char hex SHA-256)
OC Lock30078oc-lock:device:<btc_address> (or <addr>:<device_id> for multi-device)
OC Pledge30078oc-pledge:<pledge_id>
OC Pledge30078oc-pledge-outcome:<pledge_id>
OC Pledge30078oc-pledge-abandonment:<pledge_id>
OC Vote30080oc-vote:poll:<poll_id>
OC Vote30081oc-vote:ballot:<poll_id>:<voter>
OC Vote30082oc-vote:reveal:<poll_id>
OC Stamp30083oc-stamp:<envelope_id>
OC Agent30083oc-agent-del:<delegation_id> (co-claim with OC Stamp; disjoint prefix)
OC Agent30084oc-agent-act:<action_id>
OC Agent30085oc-agent-rev:<revocation_id>
OC Agent30086oc-agent-sub:<subdelegation_id> (v1.1)

A public relay doesn't enforce any of this — d is just a string. Tag filters match exactly, so #d finds one artifact when you know its full d value; there is no prefix query. The prefix is what tells you, after fetching, which protocol an event on a shared kind belongs to. Check it, and the envelope's kind field, before you use the event.

Required tags per protocol

Each sibling's spec defines which tags MUST appear on its events. Relays index single-letter tag names only, so the tags a query can use are d and t. Longer tag names (address, agent, delegation) are for people reading the event; a filter on one fails (see Reading events).

Common across all protocols:

TagValue
dThe addressable identifier (per table above)

OC Attest example:

TagPurpose
tThe Bitcoin address, and oc-attest — the tags a query uses
addressBitcoin address the attestation is for (not indexed)
schemebip322 or legacy
iOne per identity binding, formatted as <protocol>:<identifier>
expiresISO timestamp (optional)

For the exact normative tag set per protocol, consult that protocol's spec:

Author pubkey — real npub vs. ephemeral

OrangeCheck events are authored by one of two key types:

  • Real Nostr pubkey — when the user has a NIP-07 extension installed (nos2x, Alby, etc.), we sign with their actual key so they can find their own events in their Nostr client's history.
  • Ephemeral key — when there's no NIP-07, the SDK generates a fresh BIP-340 Schnorr keypair per publish, signs the event, and throws the key away. The event is still valid (relays check only that the signature matches the pubkey); the pubkey is just a throwaway. OC Vote derives the ephemeral key deterministically via HKDF so the same poll re-publishes under the same npub.

In both cases, the artifact inside the event's content is cryptographically bound to the Bitcoin address via BIP-322. Who authored the Nostr event is independent from who controls the Bitcoin address. The inner artifact is what matters.

Reading events

Any Nostr client or relay library works. With nostr-tools:

import { Relay } from 'nostr-tools/relay';

const relay = await Relay.connect('wss://relay.damus.io');

// Every filter below uses `#t` or `#d`. Relays index single-letter tags only.

// Find an OC Attest attestation by Bitcoin address
const sub = relay.subscribe([{ kinds: [30078], '#t': ['bc1qalice…'] }], {
    onevent(event) {
        const envelope = JSON.parse(event.content);
        // Verify locally — see /attest/verification
    },
});

// Find an OC Vote poll by id
relay.subscribe([{ kinds: [30080], '#d': ['oc-vote:poll:<poll_id>'] }], {
    /*…*/
});

// Find every OC Stamp by a specific signer. Kind 30083 also carries OC Agent
// delegations: keep events whose `d` starts with `oc-stamp:` and whose
// envelope `kind` is `stamp`.
relay.subscribe([{ kinds: [30083], '#t': ['bc1qsigner…'] }], {
    /*…*/
});

// Find every OC Agent delegation naming an address, as principal or agent.
// Keep events whose envelope `kind` is `agent-delegation`, then check which
// side the address is on.
relay.subscribe([{ kinds: [30083], '#t': ['bc1qagent…'] }], {
    /*…*/
});

// Find every revocation of a specific delegation (then keep only events whose
// content.delegation_id matches, since `t` also carries the signer's address)
relay.subscribe([{ kinds: [30085], '#t': ['<64-hex delegation id>'] }], {
    /*…*/
});

// Find what cites a delegation: its actions (30084), revocations (30085) and
// sub-delegations (30086) all carry its id as `t`. On actions and
// sub-delegations that tag is SHOULD (spec 1.5.0), so this lists only events
// whose publisher set it. Route on the envelope `kind` and check the id.
relay.subscribe(
    [{ kinds: [30084, 30085, 30086], '#t': ['<64-hex delegation id>'] }],
    {
        /*…*/
    }
);

A filter on a multi-letter tag name does not quietly return nothing. Most relays reject the whole request with CLOSED or NOTICE (unindexed tag filter), taking any other filter in the same request with it, and some ignore the filter and return unrelated events. So:

  • Treat a rejection as "not heard back", never as "nothing there." A relay has answered only when it sent EOSE and every event it returned matches your filter. A CLOSED, an error NOTICE, a dropped connection or a timeout says nothing about what exists.
  • Decide absence only from relays that answered. This matters most for revocations: if no relay answered, you have not checked revocation, so do not report the delegation as unrevoked. See OC Agent SPEC §10.4.
  • Treat every returned event as a candidate. Anyone can publish an event tagged with anyone's address. Verify the envelope inside before using it.

@orangecheck/sdk wraps the attestation query as getAttestationsForAddress. @orangecheck/nostr-core's queryEvents checks each event's id, signature and filter match, and reports per relay whether it reached EOSE. OC Agent's core has no network by design: fetch with any client and pass the envelopes in.

Default relay set

@orangecheck/nostr-core defaults to four public relays plus the family's own:

  • wss://nos.lol
  • wss://relay.primal.net
  • wss://offchain.pub
  • wss://relay.snort.social
  • wss://relay.ochk.io

Override with relays: [...] on any SDK call. For production deployments you SHOULD query ≥3 distinct relay operators — a single slow or partitioned relay shouldn't break discovery. See Security — relay censorship / partition for why this matters.

See also