Builds out the Cashu wallet beyond the scaffold: a complete mint
protocol layer, the four user-facing wallet operations (mint, melt,
send-as-token, redeem), and auto-redemption of inbound NIP-61
nutzaps. Wires the relay subscription so the wallet state syncs
across devices.
quartz/ — mint protocol layer (commonMain + jvmAndroid)
* nip60Cashu/mintApi/MintApiDtos.kt — Kotlinx Serialization DTOs
for NUT-00..06 (info, keys, mint/quote/bolt11, mint/bolt11,
swap, melt/quote/bolt11, melt/bolt11, checkstate). ProofDto
carries the optional NUT-11 witness.
* nip60Cashu/mintApi/MintHttpClient.kt — OkHttp + kotlinx-json
client bound to a single mint URL; surfaces MintHttpException
with the mint's detail string preserved for the UI.
* nip60Cashu/mintApi/CashuMintOperations.kt — combines BDHKE +
HTTP + amount splitting. Exposes requestMintQuote / mintProofs
/ swap / requestMeltQuote / meltProofs / redeemNutzap. Power-
of-2 amount split per NUT-00.
* nip60Cashu/mintApi/AmountSplit.kt — extracted into commonMain
for testability.
* nip60Cashu/p2pk/P2PK.kt — NUT-11 locked-secret format and
BIP-340 Schnorr witness signing.
* CashuProof gains an optional witness field.
amethyst/ — wallet ops + UI
* model/nip60Cashu/CashuWalletOps.kt — Nostr publishing layer
over CashuMintOperations:
- publishWalletEvents (kind 17375 + kind 10019 together)
- startMintFromLightning / checkMintQuote /
completeMintFromLightning (kind 7374 lifecycle + 7375 +
7376 + NIP-09 deletion of the quote)
- meltToLightning (pre-swap if needed, melt, change rollover,
delete sources, history)
- sendAsToken (swap to exact split, V4Encoder for cashuB,
rollover, history)
- redeemToken (inbound cashuA/B via swap)
- redeemNutzap (NIP-61 P2PK unlock + swap, history with
unencrypted "redeemed" marker per spec)
* service/cashu/v4/V4Encoder.kt — inverse of the existing
V4Parser; encodes proofs to cashuB strings for send.
* ui/screen/loggedIn/wallet/CashuWalletScreen.kt — adds four
action buttons (Receive / Send LN / Send Token / Redeem) with
AlertDialog-based flows that poll the mint quote, paste/copy
from clipboard, and surface mint errors.
* ui/screen/loggedIn/wallet/CashuWalletViewModel.kt — new mint
/ melt / send-token / redeem state machines, subscribes via
CashuWalletFilterAssembler on init (auto-syncs the wallet
across devices), observes the wallet note's flow for reactive
refresh, and auto-redeems any inbound kind 9321 nutzap that
isn't already marked redeemed in our kind 7376 history.
relay subscription
* commons/.../CashuWalletFilterAssembler.kt refactored into the
standard ComposeSubscriptionManager + SingleSubEoseManager
pair (matches the NWC pattern). Now driven by subscribe(query)
/ unsubscribe(query) calls from the ViewModel.
* RelaySubscriptionsCoordinator.cashuWallet exposes a singleton
assembler reachable as Amethyst.instance.sources.cashuWallet.
Tests (jvmTest)
* BdhkeTest — 7/7
* AmountSplitTest — 7/7 (NUT-00 vectors + sum invariants)
* P2PKTest — 6/6 (secret round-trip, witness verifies under
BIP-340, compressed + x-only acceptance)
Total: 20 new NIP-60 jvm tests, all passing. Both playDebug and
fdroidDebug compile clean.
Deferred (clearly bounded follow-ups):
* Sending nutzaps (kind 9321) from the zap picker UI — requires
integrating with the existing LN zap chooser surface. The
underlying P2PK locking primitives are in place.
* Recovering an interrupted kind 7374 mint quote on next launch
— current flow keeps polling while the dialog stays open.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
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)
}
}
}
}