Add electrumx2.testls.space:50012 to DEFAULT_ELECTRUMX_SERVERS and TOR_ELECTRUMX_SERVERS as a redundancy endpoint for the testls.space operator. It runs on the same box as relay.testls.bit (23.158.233.10) but terminates TLS at nginx with a publicly-trusted Let's Encrypt cert (CN=electrumx2.testls.space, issuer LE YE1) instead of the self-signed relay.testls.bit cert on the standard ports. usePinnedTrustStore is left at the default (false) since the system trust store is sufficient, same as electrum.nmc.ethicnology.com. The same nginx vhost also exposes WSS on port 50014, making it the second browser-viable public Namecoin ElectrumX endpoint (alongside electrum.nmc.ethicnology.com) for pure-browser Nostr clients that cannot use self-signed certs. (cherry picked from commit 645382a95b9a314eb6a4e1221ba97dfc1c13f1ae) Applied from nostr proposal c0eb8d1a09651c377827f4aa97c1ed7a2f93fafceb823233c5650506b661b8ba (branch feat/electrumx2-le-server). Cherry-picked rather than merged via `ngit pr merge` because that proposal is not surfaced by `ngit pr list` -- it is absent from all statuses and `ngit pr view` reports "proposal not found", though the event is well formed on relay.ngit.dev with the correct a-tag and r-tag. It appears to collide with a stale earlier proposal for the same branch name. Endpoint verified before applying: - electrumx2.testls.space resolves to 23.158.233.10, the same host as the existing relay.testls.bit / 23.158.233.10 entries, as the commit claims. - TLS on :50012 presents CN=electrumx2.testls.space issued by Let's Encrypt (C=US, O=Let's Encrypt, CN=YE1), so usePinnedTrustStore = false is correct. - server.version reports ElectrumX 1.16.0 and server.features reports genesis_hash 000000000062b72c5e2ceb45fbc8587e807c155b0da735e6483dfba2f0a9c770, i.e. it indexes Namecoin rather than Bitcoin. Note this is the third default entry pointing at host 23.158.233.10, so it adds certificate-path redundancy (works where a self-signed cert is stripped) rather than host redundancy. Low risk: nameShowWithFallback tries servers sequentially and returns on first success, and this entry is appended last in both lists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
Quartz Guide for Clients
Here's how to structure a new Twitter-like client.
Architecture
Set up a Context class to wire Quartz components together. Usually there is only one instance of this class.
object AppGraph {
// application-wide scope
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// the local db
val sqlite = EventStore(dbName = "demo-events.db")
// the local cache that keeps only one copy of each event in memory
val interned = InterningEventStore(sqlite)
// the observable db, that you can produce flows that auto update
val db = ObservableEventStore(interned)
// the client to access relays
val client = NostrClient(websocketBuilder = KtorWebSocket.Builder())
// sends all events, regardless of the subscription, to the local db
val collector = EventCollector(client) { event, _ ->
runCatching {
db.insert(event)
}
}
// update this variable when a user logs in, starts with a guest
var signer: NostrSigner = NostrSignerInternal(KeyPair())
init {
// Periodic NIP-40 sweep — drops expired events from SQLite and
// emits StoreChange.DeleteExpired so live projections drop them
// too. Without this the on-disk store grows monotonically.
scope.launch {
while (isActive) {
delay(15.minutes)
runCatching { db.deleteExpiredEvents() }
}
}
}
}
Then use a view model to subscribe to relays and the local db at the same time, like this:
class NotesFeed(
private val db: ObservableEventStore,
private val client: NostrClient,
) {
private val subId = newSubId()
private val filter = Filter(kinds = listOf(TextNoteEvent.KIND), limit = 100)
private val relays =
setOf(
"wss://relay.damus.io".normalizeRelayUrl(),
"wss://nos.lol".normalizeRelayUrl(),
"wss://relay.nostr.band".normalizeRelayUrl(),
)
val notes: Flow<ProjectionState<TextNoteEvent>> =
db
.project<TextNoteEvent>(filter)
.filterItems { it.value.isNewThread() }
.onStart { client.subscribe(subId, relays.associateWith { listOf(filter) }) }
.onCompletion { client.unsubscribe(subId) }
}
class FeedViewModel(
private val db: ObservableEventStore,
private val client: NostrClient,
) : ViewModel() {
val notesFeed = NotesFeed(db, client)
val feed = notesFeed
.flow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProjectionState.Loading)
fun send(text: String, signer: NostrSigner) {
viewModelScope.launch {
val signed = signer.sign<TextNoteEvent>(TextNoteEvent.build(text))
// Hits the bus → projection picks it up alongside any inbound relay copy.
db.insert(signed)
client.publish(signed, relays)
}
}
}
Notice that the notes flow is ready for the UI and automatically subscribes
and unsubscribes to any group of relays and filters the user wants. Similarly,
the send function updates both the local db and the relay.
NostrClient connects on-demand: the first subscribe(...) or publish(...) to a relay triggers the socket. There's no need to call client.connect() at startup — it's only useful for resuming after a prior disconnect().
Building a reactive feed UI
A feed screen reads from the view model's feed flow, which only updates when new events arrive or are deleted due to kind 5 deletions, vanish requests or expirations.
fun main() {
application {
val state = rememberWindowState(size = DpSize(560.dp, 720.dp))
Window(onCloseRequest = ::exitApplication, state = state, title = "Nostr Kind 1 Demo") {
MaterialTheme {
val viewModel = remember {
FeedViewModel(AppGraph.db, AppGraph.client, AppGraph.signer)
}
val noteState by viewModel.feed.collectAsStateWithLifecycle()
when (noteState) {
is ProjectionState.Loading -> LoadingFeed()
is ProjectionState.Loaded -> Feed(noteState.items)
}
}
}
}
}
@Composable
private fun LoadingFeed() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
private fun Feed(items: List<MutableStateFlow<TextNoteEvent>>) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = items, key = { it.value.id }) { handle ->
NoteRow(handle)
HorizontalDivider()
}
}
}
@Composable
private fun NoteRow(handle: MutableStateFlow<TextNoteEvent>) {
val event by handle.collectAsStateWithLifecycle()
Text(
text = event.content,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp),
)
}
Notice how each how also subscribe for changes. This is important to receive updates from replaceable and addressable events.
Appendix A
Quartz doesn't offer a Ktor websocket, but you can use this one as reference.
/**
* Ktor-based [WebSocket] for talking to a Nostr relay.
*
* Quartz exposes [WebsocketBuilder] as the only seam between its relay-pool
* and the underlying transport, so all this class has to do is open a Ktor
* websocket session, forward incoming text frames to [out], and let Quartz
* drive sends.
*/
class KtorWebSocket(
private val url: NormalizedRelayUrl,
private val httpClient: HttpClient,
private val out: WebSocketListener,
) : WebSocket {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var session: DefaultWebSocketSession? = null
private var readerJob: Job? = null
override fun needsReconnect(): Boolean = session == null
override fun connect() {
readerJob =
scope.launch {
try {
val s = httpClient.webSocketSession(urlString = url.url)
session = s
out.onOpen(0, false)
for (frame in s.incoming) {
if (frame is Frame.Text) {
out.onMessage(frame.readText())
}
}
val reason = s.closeReason.await()
out.onClosed(
code =
reason?.code?.toInt() ?: CloseReason.Codes.NORMAL.code
.toInt(),
reason = reason?.message ?: "",
)
} catch (t: Throwable) {
out.onFailure(t, null, null)
} finally {
session = null
}
}
}
override fun disconnect() {
val s = session
session = null
readerJob?.cancel()
readerJob = null
if (s != null) {
runBlocking { s.close(CloseReason(CloseReason.Codes.NORMAL, "client disconnect")) }
}
scope.cancel()
}
override fun send(msg: String): Boolean {
val s = session ?: return false
scope.launch { s.send(msg) }
return true
}
/**
* The factory Quartz hands to [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient].
* One [HttpClient] is shared by every relay in the pool.
*/
class Builder(
private val httpClient: HttpClient = defaultClient(),
) : WebsocketBuilder {
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket = KtorWebSocket(url, httpClient, out)
companion object {
fun defaultClient() =
HttpClient(CIO) {
install(WebSockets)
}
}
}
}