mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+136
@@ -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,
|
||||
)
|
||||
+239
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user