feat(cashu): NUT-17 WebSocket subscription protocol (JSON-RPC layer)

NUT-17 lets a wallet subscribe to mint events instead of polling. The
big win is the receive flow: instead of `LaunchedEffect { while (true)
{ delay(3000); viewModel.checkAndCompleteMint() } }` hammering the
mint every three seconds, a single subscribe at quote-open time and a
push-notification at PAID time. Smaller wins for melt status and
proof-state tracking.

This commit lands the protocol-message layer + tests; the actual
WebSocket client wrapper and integration into the receive flow follow
in a separate commit. Splitting these because the framing is normative
(any mismatch is a wire-format bug we'd rather catch in unit tests
than against a real mint).

What's here:

- WsRequest / WsResponse / WsNotification — JSON-RPC 2.0 framing as
  data classes. Wallet sends WsRequest; mint replies WsResponse for
  acknowledgement and pushes WsNotification for state updates.

- WsRequestParams unifies the two shapes (subscribe needs kind +
  filters + subId; unsubscribe needs only subId). kotlinx default
  null-omission keeps unsubscribe payloads clean.

- WsNotificationParams.payload is left as `JsonElement` rather than
  pre-deserialised to a discriminated union: the caller already knows
  the kind (it owns the subId it created), so it decodes directly to
  the typed DTO without a wasted intermediate parse.

- NutSeventeenKinds constants match the on-wire strings verbatim
  (`bolt11_mint_quote`, `bolt11_melt_quote`, `bolt12_mint_quote`,
  `bolt12_melt_quote`, `proof_state`). Renaming any of these would
  break interop with every mint — a test pins the values.

- ProofStateNotificationDto for the proof-state push payload (NUT-07
  shape: Y, state, optional witness).

Subtle bit: kotlinx.serialization omits fields whose value equals
the default. We need `jsonrpc: "2.0"` to ALWAYS appear on the wire —
mints reject anything else — so the field is annotated
`@EncodeDefault` (ExperimentalSerializationApi). Without this, the
first test of the round-trip showed `jsonrpc` missing in the encoded
payload, which a strict mint would reject before parsing further.

Tests: 10 cases covering subscribe / unsubscribe round-trip, the
unsubscribe shape omitting kind+filters cleanly, mint-quote and
proof-state notification decode, response with result vs error,
the wire-string constants, forwards-compat with unknown payload
fields, and a JsonObject-builder spoof for downstream tests that
want to simulate mint notifications without a real WS connection.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:44 +00:00
parent 4747c0f039
commit d971ff4da7
2 changed files with 375 additions and 0 deletions
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.mintApi.ws
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* NUT-17 subscription kinds — what mint events the wallet wants pushed.
* Values match the on-the-wire string verbatim; don't rename.
*/
object NutSeventeenKinds {
const val BOLT11_MINT_QUOTE = "bolt11_mint_quote"
const val BOLT11_MELT_QUOTE = "bolt11_melt_quote"
const val BOLT12_MINT_QUOTE = "bolt12_mint_quote"
const val BOLT12_MELT_QUOTE = "bolt12_melt_quote"
const val PROOF_STATE = "proof_state"
}
/**
* NUT-17 JSON-RPC 2.0 framing. The mint speaks a tiny subset of JSON-RPC
* over WebSocket:
*
* - **Request** (wallet→mint): subscribe / unsubscribe, with [id] for
* correlation. Mint replies with [WsResponse].
* - **Notification** (mint→wallet): unsolicited state push using
* method="subscribe" and a [subId] reference back to the original
* request. No `id` field — distinguishing notifications from
* responses is what the discriminator helper below is for.
*/
@Serializable
data class WsRequest(
@OptIn(ExperimentalSerializationApi::class)
@EncodeDefault
val jsonrpc: String = "2.0",
val method: String,
val params: WsRequestParams,
val id: Long,
)
/**
* Either a subscribe (kind + filters + subId) or unsubscribe (subId only).
* The mint discriminates on the [method] field of the parent [WsRequest];
* we keep both shapes on one DTO so a single serializer round-trips both.
*/
@Serializable
data class WsRequestParams(
val kind: String? = null,
val filters: List<String>? = null,
val subId: String,
)
/**
* Response to a wallet-initiated request. The mint echoes the `id` from
* the request; `result` shape depends on the method (status field on
* subscribe acknowledgements, etc.).
*/
@Serializable
data class WsResponse(
@OptIn(ExperimentalSerializationApi::class)
@EncodeDefault
val jsonrpc: String = "2.0",
val result: JsonElement? = null,
val error: WsError? = null,
val id: Long,
)
@Serializable
data class WsError(
val code: Int,
val message: String,
val data: JsonElement? = null,
)
/**
* Mint-pushed state update. `method` is always the literal string
* "subscribe" per NUT-17; [params] carries the original subId plus the
* payload, whose shape depends on the subscription kind (mint-quote
* response, melt-quote response, or check-state response).
*/
@Serializable
data class WsNotification(
@OptIn(ExperimentalSerializationApi::class)
@EncodeDefault
val jsonrpc: String = "2.0",
val method: String,
val params: WsNotificationParams,
)
@Serializable
data class WsNotificationParams(
val subId: String,
/**
* Payload is one of:
* - kind=bolt11_mint_quote → MintQuoteBolt11ResponseDto
* - kind=bolt11_melt_quote → MeltQuoteBolt11ResponseDto
* - kind=proof_state → ProofStateNotificationDto (NUT-07 shape)
*
* Kept as `JsonElement` so we don't have to deserialize-then-discriminate;
* the caller knows the kind from the subId it created and decodes
* directly.
*/
val payload: JsonElement,
)
/**
* NUT-07 proof state notification payload — one row from the
* /v1/checkstate response, but pushed individually per-proof.
*/
@Serializable
data class ProofStateNotificationDto(
@SerialName("Y") val y: String,
val state: String,
val witness: String? = null,
)
@@ -0,0 +1,239 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.mintApi.ws
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
private val json = Json { ignoreUnknownKeys = true }
class NutSeventeenMessagesTest {
@Test
fun subscribeRequestRoundTrips() {
val req =
WsRequest(
method = "subscribe",
params =
WsRequestParams(
kind = NutSeventeenKinds.BOLT11_MINT_QUOTE,
filters = listOf("quote-xyz"),
subId = "sub-1",
),
id = 0L,
)
val text = json.encodeToString(WsRequest.serializer(), req)
val parsed = json.decodeFromString(WsRequest.serializer(), text)
assertEquals(req, parsed)
// Make sure the on-wire shape has the expected literal fields —
// mints will reject anything that doesn't say jsonrpc="2.0".
val tree = json.parseToJsonElement(text).jsonObject
assertEquals("2.0", tree["jsonrpc"]?.jsonPrimitive?.content)
assertEquals("subscribe", tree["method"]?.jsonPrimitive?.content)
}
@Test
fun unsubscribeRequestOmitsKindAndFilters() {
val req =
WsRequest(
method = "unsubscribe",
params = WsRequestParams(subId = "sub-1"),
id = 1L,
)
val text = json.encodeToString(WsRequest.serializer(), req)
val tree = json.parseToJsonElement(text).jsonObject
// params.kind / filters MUST be absent (or null) for unsubscribe —
// some mints reject ambiguous combinations.
val params = tree["params"]?.jsonObject
assertNotNull(params)
// kotlinx.serialization defaults to omitting nulls; verify that.
assertNull(params["kind"], "kind should be omitted on unsubscribe")
assertNull(params["filters"], "filters should be omitted on unsubscribe")
assertEquals("sub-1", params["subId"]?.jsonPrimitive?.content)
}
@Test
fun mintQuoteNotificationDeserializes() {
val raw =
"""
{
"jsonrpc": "2.0",
"method": "subscribe",
"params": {
"subId": "sub-1",
"payload": {
"quote": "quote-xyz",
"request": "lnbc100n1pj...",
"state": "PAID"
}
}
}
""".trimIndent()
val notif = json.decodeFromString(WsNotification.serializer(), raw)
assertEquals("subscribe", notif.method)
assertEquals("sub-1", notif.params.subId)
val payload = notif.params.payload.jsonObject
assertEquals("quote-xyz", payload["quote"]?.jsonPrimitive?.content)
assertEquals("PAID", payload["state"]?.jsonPrimitive?.content)
}
@Test
fun proofStateNotificationDeserializes() {
val raw =
"""
{
"jsonrpc": "2.0",
"method": "subscribe",
"params": {
"subId": "sub-2",
"payload": {
"Y": "02abc...",
"state": "SPENT",
"witness": "{\"signatures\":[\"abc\"]}"
}
}
}
""".trimIndent()
val notif = json.decodeFromString(WsNotification.serializer(), raw)
val proofState =
json.decodeFromJsonElement(
ProofStateNotificationDto.serializer(),
notif.params.payload,
)
assertEquals("02abc...", proofState.y)
assertEquals("SPENT", proofState.state)
assertEquals("{\"signatures\":[\"abc\"]}", proofState.witness)
}
@Test
fun responseWithoutErrorRoundTrips() {
val raw =
"""{"jsonrpc":"2.0","result":{"status":"OK","subId":"sub-1"},"id":0}"""
val resp = json.decodeFromString(WsResponse.serializer(), raw)
assertEquals(0L, resp.id)
assertNull(resp.error)
val result = resp.result?.jsonObject
assertNotNull(result)
assertEquals("OK", result["status"]?.jsonPrimitive?.contentOrNull)
}
@Test
fun responseWithErrorRoundTrips() {
val raw =
"""{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":7}"""
val resp = json.decodeFromString(WsResponse.serializer(), raw)
assertEquals(7L, resp.id)
assertNull(resp.result)
assertEquals(-32600, resp.error?.code)
assertEquals("Invalid request", resp.error?.message)
}
@Test
fun kindConstantsMatchSpecExactly() {
// The wire format is normative — any rename here would break
// interop with every mint. Belt-and-braces against accidental edits.
assertEquals("bolt11_mint_quote", NutSeventeenKinds.BOLT11_MINT_QUOTE)
assertEquals("bolt11_melt_quote", NutSeventeenKinds.BOLT11_MELT_QUOTE)
assertEquals("bolt12_mint_quote", NutSeventeenKinds.BOLT12_MINT_QUOTE)
assertEquals("bolt12_melt_quote", NutSeventeenKinds.BOLT12_MELT_QUOTE)
assertEquals("proof_state", NutSeventeenKinds.PROOF_STATE)
}
@Test
fun extraFieldsOnIncomingPayloadAreIgnored() {
// Forwards compat: a newer mint can add fields without breaking us.
val raw =
"""
{
"jsonrpc": "2.0",
"method": "subscribe",
"params": {
"subId": "sub-1",
"payload": {
"quote": "q",
"state": "PAID",
"future_field": "ignored",
"another": 42
}
}
}
""".trimIndent()
val notif = json.decodeFromString(WsNotification.serializer(), raw)
assertEquals("sub-1", notif.params.subId)
val payload = notif.params.payload.jsonObject
// Original fields still present; new fields silently kept around.
assertEquals("q", payload["quote"]?.jsonPrimitive?.content)
assertEquals("PAID", payload["state"]?.jsonPrimitive?.content)
assertNotNull(payload["future_field"])
}
@Test
fun encodingProducesCompactSubscribeRequest() {
// Reference shape from NUT-17 §4 example — minus pretty-printing.
val req =
WsRequest(
method = "subscribe",
params =
WsRequestParams(
kind = NutSeventeenKinds.PROOF_STATE,
filters = listOf("0234abcd"),
subId = "subA",
),
id = 0L,
)
val text = json.encodeToString(WsRequest.serializer(), req)
// Field order is implementation-defined by kotlinx.serialization but
// is stable; just check the discriminator + ID land. Mints don't
// care about key order.
val tree = json.parseToJsonElement(text).jsonObject
val params = tree["params"]?.jsonObject
assertNotNull(params)
assertEquals("proof_state", params["kind"]?.jsonPrimitive?.content)
assertEquals(0L, tree["id"]?.jsonPrimitive?.content?.toLong())
}
@Test
fun jsonObjectCanBeBuiltManuallyForTopicSpecificPayloads() {
// Sanity check: a hand-built JsonObject payload is acceptable input
// — useful for tests that need to spoof mint notifications without
// first serializing/deserializing a full DTO.
val payload =
buildJsonObject {
put("Y", JsonPrimitive("02ff"))
put("state", JsonPrimitive("UNSPENT"))
}
val proofState =
json.decodeFromJsonElement(ProofStateNotificationDto.serializer(), payload)
assertEquals("02ff", proofState.y)
assertEquals("UNSPENT", proofState.state)
assertNull(proofState.witness)
}
private fun JsonObject.containsField(name: String): Boolean = this.containsKey(name)
}