Files
amethyst/quartz
Claude 5cd756cea2 refactor(cashu): lift wallet state to Account, react to live cache updates
Addresses the critical findings from the post-implementation audit:

A1. State holder lives on Account, not the ViewModel
  New CashuWalletState owns the wallet event, decrypted token contents,
  history, mint-quote, and inbound-nutzap indexes. It's constructed on
  Account and runs for the lifetime of the login session — so nutzaps
  arriving while the user is on Home/DMs/etc. get auto-redeemed without
  requiring the wallet screen to be open. ViewModel becomes a thin
  presenter that forwards flows + holds per-flow UI state (mint quote
  in progress, melt confirmation pending).

A2. Reactive observation via LocalCache.live.newEventBundles
  The state object backfills once from cache.notes at construction time,
  then receives incremental updates from the live new/deleted event
  bundles for any NIP-60/61 event authored by us (or addressed to us
  via #p for nutzaps). NIP-44 decryption results for kind:7375 events
  are cached by event-id, so the per-refresh re-decrypt is gone (D2).

A3. Mutex-guarded auto-redeem (no more duplicate /v1/swap races)
  redeemPendingNutzapsSerialized uses tryLock so a sweep already in
  flight short-circuits any new triggers; subsequent cache updates
  catch up via the next bundle.

A4. Mint-quote recovery on launch
  pendingQuotes flow surfaces unfulfilled kind:7374 events whose
  expiration hasn't passed and whose id isn't yet referenced with a
  "destroyed" marker in any kind:7376. ViewModel.resumeMintQuote()
  re-polls the mint for the original quote and rebuilds the flow.

B1. NutzapInfoEvent now carries the wallet's outbox relays so senders
  publish nutzaps where our assembler is actually listening.

B2. Subscription tracks the outboxRelaysFlow — when the relay list
  changes, the assembler subscription is rebuilt with the new set.

B5. New MintProtocolException distinguishes "HTTP fine, protocol said
  no" (e.g. melt state != PAID) from "HTTP error". Both surface
  through describeMintError() (now top-level — C4).

B7. redeemNutzap now pre-checks the P2PK secret's pubkey matches our
  wallet pubkey before signing — saves a wasted mint round-trip when
  the lock targets someone else.

B8. Melt is a two-phase flow: startMelt() returns a Quoted state with
  amount + fee_reserve so the UI confirms before paying; confirmMelt()
  actually spends. No more silent fee acceptance.

C1. MintHttpClient + CashuMintOperations cached per mint URL via a
  ConcurrentHashMap.

C3. AddCashuWalletScreen has a "Verify" button that pings /v1/info
  before adding, with inline success / failure feedback.

C7. Inline JsonObject FQN in P2PK.kt replaced with proper import.

C8. Dead .also { _ -> secretJson } removed from redeemNutzap.

D1. runCatching {}.getOrNull() callsites in the state holder now log
  via Log.w("CashuWallet") so silent failures surface in logcat.

D5. CashuWalletQueryState made @Immutable + data class for Compose
  stability hygiene.

Touched files: Account.kt (state field + constructor params),
AccountCacheState.kt + AppModules.kt (wire the assembler factory +
okHttpClientForMoney through), CashuWalletOps.kt (decouples from
Account, takes signer + publish callback), CashuWalletState.kt (new),
CashuWalletViewModel.kt (presenter rewrite), CashuWalletScreen.kt
(two-phase melt UI), AddCashuWalletScreen.kt (Verify button),
strings.xml (new keys).

All 20 NIP-60 jvm tests still pass; playDebug + fdroidDebug compile
clean.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:40 +00:00
..
2026-04-30 17:37:29 -04:00

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)
                }
        }
    }
}