From 7d3f7f7ca4d7d6d8f7ecdbf56bd3f2852c0defa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:38:32 +0000 Subject: [PATCH] feat(nwc): add pay/receive methods for BOLT12 (nostr-wallet-connect/nwc#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the generalized `pay` (BIP321 payment instruction, incl. BOLT12 `lno=`) and `receive` NIP-47 methods, plus the UNSUPPORTED_PAYMENT_INSTRUCTION / UNSUPPORTED_NETWORK error codes. The `pay` result carries `payer_proof` (lnp1…) — the proof a kind:9736 BOLT12 zap needs. Wired through both serialization backends (kotlinx + Jackson) with round-trip tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../Nip47RequestKSerializer.kt | 56 ++++++++++++++ .../Nip47ResponseKSerializer.kt | 74 +++++++++++++++++++ .../nip47WalletConnect/rpc/NwcErrorCode.kt | 4 + .../nip47WalletConnect/rpc/NwcMethod.kt | 4 + .../quartz/nip47WalletConnect/rpc/Request.kt | 42 +++++++++++ .../quartz/nip47WalletConnect/rpc/Response.kt | 32 ++++++++ .../nip47WalletConnect/NwcMethodTest.kt | 6 +- .../quartz/nip47WalletConnect/RequestTest.kt | 63 ++++++++++++++++ .../quartz/nip47WalletConnect/ResponseTest.kt | 49 ++++++++++++ .../jackson/RequestDeserializer.kt | 4 + .../jackson/RequestSerializer.kt | 14 ++++ .../jackson/ResponseDeserializer.kt | 10 +++ .../jackson/ResponseSerializer.kt | 14 ++++ 13 files changed, 371 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index e3d905a0ff..a26a3fae6d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -40,6 +40,10 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayParams +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveParams import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams @@ -85,6 +89,14 @@ object Nip47RequestKSerializer : KSerializer { value.params?.let { put("params", serializePayInvoiceParams(it)) } } + is PayMethod -> { + value.params?.let { put("params", serializePayParams(it)) } + } + + is ReceiveMethod -> { + value.params?.let { put("params", serializeReceiveParams(it)) } + } + is PayKeysendMethod -> { value.params?.let { put("params", serializePayKeysendParams(it)) } } @@ -138,6 +150,21 @@ object Nip47RequestKSerializer : KSerializer { params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } } + private fun serializePayParams(params: PayParams): JsonObject = + buildJsonObject { + params.payment?.let { put("payment", it) } + params.amount?.let { put("amount", it) } + params.payer_note?.let { put("payer_note", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + + private fun serializeReceiveParams(params: ReceiveParams): JsonObject = + buildJsonObject { + params.amount?.let { put("amount", it) } + params.description?.let { put("description", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + private fun serializePayKeysendParams(params: PayKeysendParams): JsonObject = buildJsonObject { params.amount?.let { put("amount", it) } @@ -236,6 +263,8 @@ object Nip47RequestKSerializer : KSerializer { return when (method) { NwcMethod.PAY_INVOICE -> parsePayInvoice(jsonObject) + NwcMethod.PAY -> parsePay(jsonObject) + NwcMethod.RECEIVE -> parseReceive(jsonObject) NwcMethod.PAY_KEYSEND -> parsePayKeysend(jsonObject) NwcMethod.MAKE_INVOICE -> parseMakeInvoice(jsonObject) NwcMethod.LOOKUP_INVOICE -> parseLookupInvoice(jsonObject) @@ -265,6 +294,33 @@ object Nip47RequestKSerializer : KSerializer { ) } + private fun parsePay(json: JsonObject): PayMethod { + val params = json["params"]?.jsonObject + return PayMethod( + params?.let { + PayParams( + payment = it["payment"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + payer_note = it["payer_note"]?.jsonPrimitive?.content, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), + ) + }, + ) + } + + private fun parseReceive(json: JsonObject): ReceiveMethod { + val params = json["params"]?.jsonObject + return ReceiveMethod( + params?.let { + ReceiveParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + description = it["description"]?.jsonPrimitive?.content, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), + ) + }, + ) + } + private fun parsePayKeysend(json: JsonObject): PayKeysendMethod { val params = json["params"]?.jsonObject return PayKeysendMethod( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index 30922c5439..ce77e90fa5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -37,6 +37,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse @@ -81,6 +83,14 @@ object Nip47ResponseKSerializer : KSerializer { value.result?.let { put("result", serializePayInvoiceResult(it)) } } + is PaySuccessResponse -> { + value.result?.let { put("result", serializePayResult(it)) } + } + + is ReceiveSuccessResponse -> { + value.result?.let { put("result", serializeReceiveResult(it)) } + } + is PayInvoiceErrorResponse -> { value.error?.let { put("error", serializePayInvoiceErrorParams(it)) } } @@ -155,6 +165,28 @@ object Nip47ResponseKSerializer : KSerializer { error.message?.let { put("message", it) } } + private fun serializePayResult(result: PaySuccessResponse.PayResult): JsonObject = + buildJsonObject { + result.transaction_id?.let { put("transaction_id", it) } + result.state?.let { put("state", it) } + result.instruction_type?.let { put("instruction_type", it) } + result.amount?.let { put("amount", it) } + result.fees_paid?.let { put("fees_paid", it) } + result.payment_hash?.let { put("payment_hash", it) } + result.preimage?.let { put("preimage", it) } + result.payer_proof?.let { put("payer_proof", it) } + result.txid?.let { put("txid", it) } + result.failure_reason?.let { put("failure_reason", it) } + result.created_at?.let { put("created_at", it) } + result.settled_at?.let { put("settled_at", it) } + } + + private fun serializeReceiveResult(result: ReceiveSuccessResponse.ReceiveResult): JsonObject = + buildJsonObject { + result.bip321?.let { put("bip321", it) } + result.transaction_id?.let { put("transaction_id", it) } + } + private fun serializePayKeysendResult(result: PayKeysendSuccessResponse.PayKeysendResult): JsonObject = buildJsonObject { result.preimage?.let { put("preimage", it) } @@ -242,6 +274,14 @@ object Nip47ResponseKSerializer : KSerializer { parsePayInvoiceSuccess(jsonObject) } + NwcMethod.PAY -> { + parsePaySuccess(jsonObject) + } + + NwcMethod.RECEIVE -> { + parseReceiveSuccess(jsonObject) + } + NwcMethod.PAY_KEYSEND -> { parsePayKeysendSuccess(jsonObject) } @@ -387,6 +427,40 @@ object Nip47ResponseKSerializer : KSerializer { ) } + private fun parsePaySuccess(json: JsonObject): PaySuccessResponse { + val result = json["result"]?.jsonObject + return PaySuccessResponse( + result?.let { + PaySuccessResponse.PayResult( + transaction_id = it["transaction_id"]?.jsonPrimitive?.content, + state = it["state"]?.jsonPrimitive?.content, + instruction_type = it["instruction_type"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + preimage = it["preimage"]?.jsonPrimitive?.content, + payer_proof = it["payer_proof"]?.jsonPrimitive?.content, + txid = it["txid"]?.jsonPrimitive?.content, + failure_reason = it["failure_reason"]?.jsonPrimitive?.content, + created_at = it["created_at"]?.jsonPrimitive?.longOrNull, + settled_at = it["settled_at"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseReceiveSuccess(json: JsonObject): ReceiveSuccessResponse { + val result = json["result"]?.jsonObject + return ReceiveSuccessResponse( + result?.let { + ReceiveSuccessResponse.ReceiveResult( + bip321 = it["bip321"]?.jsonPrimitive?.content, + transaction_id = it["transaction_id"]?.jsonPrimitive?.content, + ) + }, + ) + } + private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse { val result = json["result"]?.jsonObject return PayKeysendSuccessResponse( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt index 0a27468342..0ae9ed28cc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcErrorCode.kt @@ -33,6 +33,10 @@ enum class NwcErrorCode { BAD_REQUEST, NOT_FOUND, EXPIRED, + + // nostr-wallet-connect/nwc#2 — pay/receive payment-instruction errors. + UNSUPPORTED_PAYMENT_INSTRUCTION, + UNSUPPORTED_NETWORK, OTHER, } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt index 94d3470f14..6441c74927 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcMethod.kt @@ -34,4 +34,8 @@ object NwcMethod { const val MAKE_HOLD_INVOICE = "make_hold_invoice" const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice" const val SETTLE_HOLD_INVOICE = "settle_hold_invoice" + + // nostr-wallet-connect/nwc#2 — generalized payment instructions (BOLT12/BIP321). + const val PAY = "pay" + const val RECEIVE = "receive" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt index a0b3867d69..dfe5031ff3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Request.kt @@ -47,6 +47,48 @@ class PayInvoiceMethod( } } +// pay (nostr-wallet-connect/nwc#2) — settle any BIP321 payment instruction +// (bolt11 `lightning=`, BOLT12 `lno=`, or on-chain). `payment` is the BIP321 URI; +// `amount` (msats) is required only when the instruction has no amount; `payer_note` +// is delivered to the payee when the instruction supports payer messages (for a +// BOLT12 zap this carries `nostr:nipXX:`). +class PayParams( + var payment: String? = null, + var amount: Long? = null, + var payer_note: String? = null, + var metadata: Map? = null, +) + +class PayMethod( + var params: PayParams? = null, +) : Request(NwcMethod.PAY) { + companion object { + fun create( + payment: String, + amount: Long? = null, + payerNote: String? = null, + ): PayMethod = PayMethod(PayParams(payment, amount, payerNote)) + } +} + +// receive (nostr-wallet-connect/nwc#2) — mint a payment instruction to be paid. +class ReceiveParams( + var amount: Long? = null, + var description: String? = null, + var metadata: Map? = null, +) + +class ReceiveMethod( + var params: ReceiveParams? = null, +) : Request(NwcMethod.RECEIVE) { + companion object { + fun create( + amount: Long? = null, + description: String? = null, + ): ReceiveMethod = ReceiveMethod(ReceiveParams(amount, description)) + } +} + // pay_keysend class PayKeysendParams( var amount: Long? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt index 13110f3c74..363c65b47b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -68,6 +68,38 @@ class PayInvoiceErrorResponse( override fun errorMessage() = error?.message } +// pay success response (nostr-wallet-connect/nwc#2). For a settled BOLT12 payment +// `payer_proof` carries the `lnp1...` proof — the input a kind:9736 BOLT12 zap needs. +// It is best-effort ("optional if unavailable"), so callers must tolerate its absence. +class PaySuccessResponse( + val result: PayResult? = null, +) : Response(NwcMethod.PAY) { + class PayResult( + val transaction_id: String? = null, + val state: String? = null, + val instruction_type: String? = null, + val amount: Long? = null, + val fees_paid: Long? = null, + val payment_hash: String? = null, + val preimage: String? = null, + val payer_proof: String? = null, + val txid: String? = null, + val failure_reason: String? = null, + val created_at: Long? = null, + val settled_at: Long? = null, + ) +} + +// receive success response (nostr-wallet-connect/nwc#2). +class ReceiveSuccessResponse( + val result: ReceiveResult? = null, +) : Response(NwcMethod.RECEIVE) { + class ReceiveResult( + val bip321: String? = null, + val transaction_id: String? = null, + ) +} + // pay_keysend success response class PayKeysendSuccessResponse( val result: PayKeysendResult? = null, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt index 7da485fb8a..532d38f692 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -48,6 +48,8 @@ class NwcMethodTest { assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE) assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE) assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE) + assertEquals("pay", NwcMethod.PAY) + assertEquals("receive", NwcMethod.RECEIVE) } @Test @@ -60,7 +62,7 @@ class NwcMethodTest { @Test fun testErrorCodeValues() { val codes = NwcErrorCode.entries - assertEquals(13, codes.size) + assertEquals(15, codes.size) assertEquals(NwcErrorCode.RATE_LIMITED, NwcErrorCode.valueOf("RATE_LIMITED")) assertEquals(NwcErrorCode.NOT_IMPLEMENTED, NwcErrorCode.valueOf("NOT_IMPLEMENTED")) assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, NwcErrorCode.valueOf("INSUFFICIENT_BALANCE")) @@ -73,6 +75,8 @@ class NwcMethodTest { assertEquals(NwcErrorCode.BAD_REQUEST, NwcErrorCode.valueOf("BAD_REQUEST")) assertEquals(NwcErrorCode.NOT_FOUND, NwcErrorCode.valueOf("NOT_FOUND")) assertEquals(NwcErrorCode.EXPIRED, NwcErrorCode.valueOf("EXPIRED")) + assertEquals(NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, NwcErrorCode.valueOf("UNSUPPORTED_PAYMENT_INSTRUCTION")) + assertEquals(NwcErrorCode.UNSUPPORTED_NETWORK, NwcErrorCode.valueOf("UNSUPPORTED_NETWORK")) assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER")) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt index 8fbaa3a31c..fe0b9ae39d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod @@ -89,6 +91,67 @@ class RequestTest { assertEquals(1000L, request.params?.amount) } + // --- Pay (nwc#2 BOLT12/BIP321) --- + + @Test + fun testPayCreateWithOfferAndPayerNote() { + val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipXX:deadbeef") + assertEquals(NwcMethod.PAY, request.method) + assertEquals("bitcoin:?lno=lno1abc", request.params?.payment) + assertEquals(21_000L, request.params?.amount) + assertEquals("nostr:nipXX:deadbeef", request.params?.payer_note) + } + + @Test + fun testPaySerialization() { + val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipXX:deadbeef") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"pay\"")) + assertTrue(json.contains("\"payment\":\"bitcoin:?lno=lno1abc\"")) + assertTrue(json.contains("\"amount\":21000")) + assertTrue(json.contains("\"payer_note\":\"nostr:nipXX:deadbeef\"")) + } + + @Test + fun testPayDeserialization() { + val json = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc","amount":21000,"payer_note":"nostr:nipXX:deadbeef"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("bitcoin:?lno=lno1abc", request.params?.payment) + assertEquals(21_000L, request.params?.amount) + assertEquals("nostr:nipXX:deadbeef", request.params?.payer_note) + } + + @Test + fun testPayAmountlessDeserialization() { + val json = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("bitcoin:?lno=lno1abc", request.params?.payment) + assertNull(request.params?.amount) + assertNull(request.params?.payer_note) + } + + // --- Receive (nwc#2) --- + + @Test + fun testReceiveSerialization() { + val request = ReceiveMethod.create(amount = 21_000L, description = "tip jar") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"receive\"")) + assertTrue(json.contains("\"amount\":21000")) + assertTrue(json.contains("\"description\":\"tip jar\"")) + } + + @Test + fun testReceiveDeserialization() { + val json = """{"method":"receive","params":{"amount":21000,"description":"tip jar"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(21_000L, request.params?.amount) + assertEquals("tip jar", request.params?.description) + } + // --- PayKeysend --- @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt index 21680a2197..49951ead95 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse @@ -91,6 +93,53 @@ class ResponseTest { assertEquals(NwcErrorCode.PAYMENT_FAILED, response.error?.code) } + // --- Pay Success (nwc#2 BOLT12) --- + + @Test + fun testPaySuccessWithPayerProofDeserialization() { + val json = + """{"result_type":"pay","result":{"state":"settled","instruction_type":"bolt12","amount":21000,"fees_paid":100,"payment_hash":"abc","preimage":"def","payer_proof":"lnp1xyz","settled_at":1693876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("settled", response.result?.state) + assertEquals("bolt12", response.result?.instruction_type) + assertEquals(21_000L, response.result?.amount) + assertEquals(100L, response.result?.fees_paid) + assertEquals("abc", response.result?.payment_hash) + assertEquals("def", response.result?.preimage) + assertEquals("lnp1xyz", response.result?.payer_proof) + assertEquals(1693876497L, response.result?.settled_at) + } + + @Test + fun testPaySuccessWithoutPayerProof() { + // payer_proof is best-effort ("optional if unavailable") — absence must parse fine. + val json = """{"result_type":"pay","result":{"state":"settled","preimage":"def"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("def", response.result?.preimage) + assertNull(response.result?.payer_proof) + } + + @Test + fun testPayErrorUnsupportedInstruction() { + val json = """{"result_type":"pay","error":{"code":"UNSUPPORTED_PAYMENT_INSTRUCTION","message":"no bolt12"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, response.error?.code) + } + + // --- Receive Success (nwc#2) --- + + @Test + fun testReceiveSuccessDeserialization() { + val json = """{"result_type":"receive","result":{"bip321":"bitcoin:?lightning=lnbc1&lno=lno1","transaction_id":"tx1"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("bitcoin:?lightning=lnbc1&lno=lno1", response.result?.bip321) + assertEquals("tx1", response.result?.transaction_id) + } + // --- PayKeysend Success --- @Test diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt index 2fe07f584e..27ec2cc38f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod @@ -51,6 +53,8 @@ class RequestDeserializer : StdDeserializer(Request::class.java) { return when (method) { NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) + NwcMethod.PAY -> jp.codec.treeToValue(jsonObject, PayMethod::class.java) + NwcMethod.RECEIVE -> jp.codec.treeToValue(jsonObject, ReceiveMethod::class.java) NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendMethod::class.java) NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceMethod::class.java) NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceMethod::class.java) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt index 098740be17..053e2be001 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt @@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod @@ -52,6 +54,18 @@ class RequestSerializer : StdSerializer(Request::class.java) { } } + is PayMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is ReceiveMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + is PayKeysendMethod -> { if (value.params != null) { gen.writeObjectField("params", value.params) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt index 275daa5c1a..8c503e7140 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse @@ -73,6 +75,14 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) } + NwcMethod.PAY -> { + jp.codec.treeToValue(jsonObject, PaySuccessResponse::class.java) + } + + NwcMethod.RECEIVE -> { + jp.codec.treeToValue(jsonObject, ReceiveSuccessResponse::class.java) + } + NwcMethod.PAY_KEYSEND -> { jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt index 94fa792972..99bc7613b8 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse @@ -69,6 +71,18 @@ class ResponseSerializer : StdSerializer(Response::class.java) { } } + is PaySuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is ReceiveSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + is PayKeysendSuccessResponse -> { if (value.result != null) { gen.writeObjectField("result", value.result)