Build Your Own Nostr Relay with Quartz

A step-by-step guide to spinning up a NIP-01 compliant Nostr relay in Kotlin — transport-agnostic, policy-driven, and production-ready — using the Quartz library from the Amethyst project.
Build Your Own Nostr Relay
with Quartz

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 NostrServer and EventStore implement AutoCloseable, so they play nicely with Kotlin’s use { } 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 null as 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

No comments yet.