Build Your Own Nostr Relay with Quartz
- Getting Started in Minutes
- The Event Store
- Policy System
- Testing Your Relay
- Source Map
- Closing Thoughts
Quartz is the core library powering Amethyst, one of the most popular Nostr clients for Android. Beyond client-side features, Quartz ships a fully self-contained, transport-agnostic relay engine — meaning you can wire it to Ktor WebSockets, raw TCP sockets, or any other transport layer.
The design is elegantly simple: you provide a send callback per connection and Quartz hands back a RelaySession that processes raw JSON strings. Plug, play, and relay.
Note: Both
NostrServerandEventStoreimplementAutoCloseable, so they play nicely with Kotlin’suse { }blocks and structured concurrency.
Getting Started in Minutes
1. Add Dependencies
Add Ktor and the Quartz module to your Gradle build file:
dependencies {
implementation("io.ktor:ktor-server-core:3.1.1")
implementation("io.ktor:ktor-server-netty:3.1.1")
implementation("io.ktor:ktor-server-websockets:3.1.1")
implementation(project(":quartz"))
}
2. Stand Up the Relay
The entire relay — event storage, WebSocket routing, and NIP-01 compliance — fits in a single main() function:
fun main() {
// Persistent SQLite-backed event store
val store = EventStore(dbName = "relay-events.db")
val server = NostrServer(store)
embeddedServer(Netty, port = 7777) {
install(WebSockets)
routing {
webSocket("/") {
server.serve(
send = { json -> launch { send(Frame.Text(json)) } },
) { session ->
for (frame in incoming) {
if (frame is Frame.Text) {
session.receive(frame.readText())
}
}
}
}
}
}.start(wait = true)
}
That’s it — you now have a NIP-01 compliant relay listening on ws://localhost:7777. Any Nostr client (Amethyst, Damus, Iris…) can connect immediately.
Tip: Need an ephemeral store for testing? Pass
nullas the db name:EventStore(null). All events live in memory and vanish when the process stops.
The Event Store
Quartz ships a SQLite-backed EventStore built on androidx.sqlite with WAL journal mode and a 32 MB memory cache baked in — solid enough for production use on modest hardware.
Tuning the Indexing Strategy
By default, all single-letter tags with values are indexed. You can fine-tune this trade-off between query speed and database size:
val store = EventStore(
dbName = "relay-events.db",
indexStrategy = DefaultIndexingStrategy(
indexEventsByCreatedAtAlone = false,
indexTagsByCreatedAtAlone = false,
indexTagsWithKindAndPubkey = false,
useAndIndexIdOnOrderBy = false,
),
)
Override shouldIndex(kind, tag) on the strategy to apply custom rules per event kind. More indexes means faster queries but a larger database.
Policy System
Policies are the heart of Quartz’s extensibility. They intercept every client command — EVENT, REQ, COUNT, AUTH — and either pass it through or reject it with a reason string.
Built-in Policies
| Policy | Description |
|---|---|
VerifyPolicy |
The sensible default. Verifies event signatures and IDs, rejecting malformed events before they touch the store. |
EmptyPolicy |
Accepts everything without validation. Perfect for unit tests where you control all inputs. |
FullAuthPolicy |
Enforces NIP-42 authentication. No command is processed until the client proves ownership of a public key. |
Composing Policies
Combine policies with the + operator (or PolicyStack). All must approve — the first rejection short-circuits the chain:
val server = NostrServer(
store = store,
policyBuilder = {
VerifyPolicy + FullAuthPolicy(
relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!
)
},
)
Writing a Custom Policy
Implement IRelayPolicy to restrict your relay to specific event kinds — for example, a curated social relay that only accepts profiles, notes, follows, reactions, and long-form articles:
class KindWhitelistPolicy(
private val allowedKinds: Set<Int>,
) : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) { }
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> =
if (cmd.event.kind in allowedKinds) {
PolicyResult.Accepted(cmd)
} else {
PolicyResult.Rejected("blocked: kind ${cmd.event.kind} not allowed")
}
override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd)
}
// Wire it up
val server = NostrServer(
store = store,
policyBuilder = {
VerifyPolicy + KindWhitelistPolicy(
allowedKinds = setOf(0, 1, 3, 7, 30023)
)
},
)
Testing Your Relay
Because Quartz is transport-agnostic, you can test the relay engine entirely in-process — no HTTP server, no WebSocket handshake, no network. server.connect { … } returns a session you can feed raw JSON directly:
@OptIn(ExperimentalCoroutinesApi::class)
class MyRelayTest {
@Test
fun clientCanPublishAndSubscribe() = runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
NostrServer(
store = EventStore(null), // in-memory
policyBuilder = { EmptyPolicy }, // accept all
parentContext = dispatcher,
).use { server ->
val messages = mutableListOf<String>()
val session = server.connect { messages.add(it) }
// Publish an event
session.receive("""["EVENT",{"id":"${"0".repeat(64)}","pubkey":"${"a".repeat(64)}","created_at":1000,"kind":1,"tags":[],"content":"hello","sig":"${"b".repeat(128)}"}]""")
assertTrue(messages.any { it.contains("OK") })
// Subscribe and check the event comes back
session.receive("""["REQ","sub1",{"kinds":[1]}]""")
assertTrue(messages.any { it.contains("EVENT") })
assertTrue(messages.any { it.contains("EOSE") })
}
}
}
Source Map
Here’s where everything lives inside the Quartz module:
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/
├── relay/server/
│ ├── NostrServer.kt # Main entry point
│ ├── RelaySession.kt # Per-connection handler
│ ├── LiveEventStore.kt # Reactive event streaming
│ ├── IRelayPolicy.kt # Policy interface + PolicyResult
│ └── policies/
│ ├── EmptyPolicy.kt # Accept everything
│ ├── VerifyPolicy.kt # Signature verification (default)
│ ├── FullAuthPolicy.kt # NIP-42 auth required
│ └── PolicyStack.kt # Chain multiple policies
├── store/
│ ├── IEventStore.kt # Storage interface
│ └── sqlite/
│ ├── EventStore.kt # Public SQLite store wrapper
│ ├── SQLiteEventStore.kt # Full implementation
│ └── IndexingStrategy.kt # Index configuration
└── relay/filters/
├── Filter.kt # NIP-01 subscription filters
└── FilterMatcher.kt # Event-to-filter matching
Closing Thoughts
Quartz makes building a Nostr relay genuinely approachable. The policy system is the real gem — it lets you start with a permissive relay and progressively layer in authentication, kind restrictions, rate limiting, or any custom logic your use case demands, all without touching transport code.
Whether you’re running a private relay for a community, experimenting with NIP implementations, or building relay infrastructure for a Nostr app, Quartz gives you a solid, battle-tested foundation that the Amethyst team has been running in production. Give it a spin.
Based on amethyst/quartz/RELAY.md by Vitor Pamplona · MIT License
- Reference: https://ieventstore.kt/
- Reference: https://policyresult.rejected/
- Reference: https://`server.connect/
- Reference: https://64)}","created_at":1000/
- Reference: https://quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/
- Reference: https://store/
- Reference: https://"${"b".repeat/
- Reference: https://irelaypolicy.kt/
- Reference: https://indexingstrategy.kt/
- Reference: https://asserttrue(messages.any/
- Reference: https://github.com/vitorpamplona/amethyst
- Reference: https://verifypolicy.kt/
- Reference: https://frame.readtext/
- Reference: https://sqlite/
- Reference: https://relay/filters/
- Reference: https://ktor-server-netty:3/
- Reference: https://send(frame.text/
- Reference: https://frame.text/
- Reference: https://relay/server/
- Reference: https://policystack.kt/
- Reference: https://"relay-events.db/
- Reference: https://fullauthpolicy.kt/
- Reference: https://policies/
- Reference: https://policyresult.accepted/
- Reference: https://liveeventstore.kt/
- Reference: https://relaysession.kt/
- Reference: https://ktor-server-core:3/
- Reference: https://`androidx.sqlite/
- Reference: https://messages.add/
- Reference: https://filter.kt/
- Reference: https://eventstore.kt/
- Reference: https://filtermatcher.kt/
- Reference: https://server.connect/
- Reference: https://cmd.event.kind/
- Reference: https://"${"0".repeat/
- Reference: https://it.contains/
- Reference: https://implementation("io.ktor/
- Reference: https://"${"a".repeat/
- Reference: https://"kind":1/
- Reference: https://session.receive/
- Reference: https://${cmd.event.kind/
- Reference: https://nostrserver.kt/
- Reference: https://ktor-server-websockets:3/
- Reference: https://emptypolicy.kt/
- Reference: https://sqliteeventstore.kt/
- Reference: https://server.serve/