feat: complete NIP-47 Nostr Wallet Connect implementation in Quartz

Implements the full NIP-47 specification with all request methods,
response types, notification events, and JSON serialization.

New request methods:
- pay_invoice (updated with amount, metadata params)
- pay_keysend (amount, pubkey, preimage, TLV records)
- make_invoice, lookup_invoice, list_transactions
- get_balance, get_info
- make_hold_invoice, cancel_hold_invoice, settle_hold_invoice

New response types:
- Success responses for all methods above
- NwcErrorResponse (generic error for any method)
- NwcErrorCode enum with UNSUPPORTED_ENCRYPTION

New event kinds:
- NwcInfoEvent (kind 13194) - wallet service capabilities
- NwcNotificationEvent (kind 23197) - NIP-44 encrypted notifications
- Legacy kind 23196 support for NIP-04 notifications

New data models:
- NwcTransaction (shared invoice/payment object)
- TlvRecord (for keysend TLV records)
- Notification types: payment_received, payment_sent, hold_invoice_accepted
- HoldInvoiceAcceptedData with settle_deadline

Tags (following NIP-88 pattern):
- EncryptionTag for encryption scheme negotiation
- NotificationsTag for supported notification types

Updated:
- LnZapPaymentRequestEvent: generic createRequest() with NIP-44 support
- URI parser: lud16 parameter support
- Jackson deserializers: all methods routed correctly
- EventFactory: new kinds registered

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
This commit is contained in:
Claude
2026-03-13 03:15:21 +00:00
parent 198aa0971c
commit da26ab14ab
17 changed files with 784 additions and 65 deletions
@@ -52,25 +52,47 @@ class LnZapPaymentRequestEvent(
return OptimizedJsonMapper.fromJsonTo<Request>(jsonText)
}
fun encryptionScheme() = tags.firstOrNull { it.size > 1 && it[0] == "encryption" }?.get(1)
companion object {
const val KIND = 23194
const val ALT = "Zap payment request"
const val ALT = "NWC request"
suspend fun create(
lnInvoice: String,
walletServicePubkey: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): LnZapPaymentRequestEvent {
val serializedRequest = OptimizedJsonMapper.toJson(PayInvoiceMethod.create(lnInvoice))
): LnZapPaymentRequestEvent =
createRequest(
PayInvoiceMethod.create(lnInvoice),
walletServicePubkey,
signer,
createdAt,
)
val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT))
suspend fun createRequest(
request: Request,
walletServicePubkey: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
useNip44: Boolean = false,
): LnZapPaymentRequestEvent {
val serializedRequest = OptimizedJsonMapper.toJson(request)
val tags =
if (useNip44) {
arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT), arrayOf("encryption", "nip44_v2"))
} else {
arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT))
}
val encrypted =
signer.nip04Encrypt(
serializedRequest,
walletServicePubkey,
)
if (useNip44) {
signer.nip44Encrypt(serializedRequest, walletServicePubkey)
} else {
signer.nip04Encrypt(serializedRequest, walletServicePubkey)
}
return signer.sign(createdAt, KIND, tags, encrypted)
}
@@ -30,11 +30,10 @@ import com.vitorpamplona.quartz.utils.UriParser
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
// Rename to the corect nip number when ready.
class Nip47WalletConnect {
companion object {
fun parse(uri: String): Nip47URINorm {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
// nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=...&lud16=user@example.com
val url = UriParser(uri)
@@ -55,8 +54,9 @@ class Nip47WalletConnect {
val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null")
val relayNorm = RelayUrlNormalizer.normalizeOrNull(relay) ?: throw IllegalArgumentException("Invalid relay Url")
val secret = url.getQueryParameter("secret")
val lud16 = url.getQueryParameter("lud16")
return Nip47URINorm(pubkeyHex, relayNorm, secret)
return Nip47URINorm(pubkeyHex, relayNorm, secret, lud16)
}
}
@@ -65,6 +65,7 @@ class Nip47WalletConnect {
val pubKeyHex: HexKey,
val relayUri: String,
val secret: HexKey?,
val lud16: String? = null,
) {
fun normalize(): Nip47URINorm? =
RelayUrlNormalizer.normalizeOrNull(relayUri)?.let {
@@ -72,6 +73,7 @@ class Nip47WalletConnect {
pubKeyHex,
it,
secret,
lud16,
)
}
@@ -86,7 +88,8 @@ class Nip47WalletConnect {
val pubKeyHex: HexKey,
val relayUri: NormalizedRelayUrl,
val secret: HexKey?,
val lud16: String? = null,
) {
fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret)
fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret, lud16)
}
}
@@ -0,0 +1,59 @@
/*
* 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.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
object NwcNotificationType {
const val PAYMENT_RECEIVED = "payment_received"
const val PAYMENT_SENT = "payment_sent"
const val HOLD_INVOICE_ACCEPTED = "hold_invoice_accepted"
}
// NOTIFICATION OBJECTS
abstract class Notification(
val notification_type: String,
) : OptimizedSerializable
// payment_received notification
class PaymentReceivedNotification(
val notification: NwcTransaction? = null,
) : Notification(NwcNotificationType.PAYMENT_RECEIVED)
// payment_sent notification
class PaymentSentNotification(
val notification: NwcTransaction? = null,
) : Notification(NwcNotificationType.PAYMENT_SENT)
// hold_invoice_accepted notification
class HoldInvoiceAcceptedNotification(
val notification: HoldInvoiceAcceptedData? = null,
) : Notification(NwcNotificationType.HOLD_INVOICE_ACCEPTED)
class HoldInvoiceAcceptedData(
var type: String? = null,
var invoice: String? = null,
var payment_hash: String? = null,
var amount: Long? = null,
var created_at: Long? = null,
var expires_at: Long? = null,
var settle_deadline: Long? = null,
)
@@ -0,0 +1,39 @@
/*
* 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.nip47WalletConnect
enum class NwcErrorCode {
RATE_LIMITED,
NOT_IMPLEMENTED,
INSUFFICIENT_BALANCE,
PAYMENT_FAILED,
QUOTA_EXCEEDED,
RESTRICTED,
UNAUTHORIZED,
INTERNAL,
UNSUPPORTED_ENCRYPTION,
OTHER,
}
class NwcError(
var code: NwcErrorCode? = null,
var message: String? = null,
)
@@ -0,0 +1,69 @@
/*
* 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.nip47WalletConnect
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag
import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class NwcInfoEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun capabilities(): List<String> = content.split(" ").filter { it.isNotBlank() }
fun supportsMethod(method: String): Boolean = capabilities().contains(method)
fun supportsNotifications(): Boolean = capabilities().contains("notifications")
fun encryptionSchemes() = tags.mapNotNull(EncryptionTag::parse).flatten()
fun notificationTypes() = tags.mapNotNull(NotificationsTag::parse).flatten()
companion object {
const val KIND = 13194
const val ALT_DESCRIPTION = "Wallet service info"
fun build(
capabilities: List<String>,
encryptionSchemes: List<String>? = null,
notificationTypes: List<String>? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NwcInfoEvent>.() -> Unit = {},
) = eventTemplate(KIND, capabilities.joinToString(" "), createdAt) {
alt(ALT_DESCRIPTION)
encryptionSchemes?.let { addUnique(EncryptionTag.assemble(it)) }
notificationTypes?.let { addUnique(NotificationsTag.assemble(it)) }
initializer()
}
}
}
@@ -0,0 +1,34 @@
/*
* 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.nip47WalletConnect
object NwcMethod {
const val PAY_INVOICE = "pay_invoice"
const val PAY_KEYSEND = "pay_keysend"
const val MAKE_INVOICE = "make_invoice"
const val LOOKUP_INVOICE = "lookup_invoice"
const val LIST_TRANSACTIONS = "list_transactions"
const val GET_BALANCE = "get_balance"
const val GET_INFO = "get_info"
const val MAKE_HOLD_INVOICE = "make_hold_invoice"
const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice"
const val SETTLE_HOLD_INVOICE = "settle_hold_invoice"
}
@@ -0,0 +1,58 @@
/*
* 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.nip47WalletConnect
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Immutable
class NwcNotificationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
fun clientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) clientPubKey() ?: pubKey else pubKey
fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || clientPubKey() == signer.pubKey
suspend fun decryptNotification(signer: NostrSigner): Notification {
if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException()
val jsonText = signer.nip44Decrypt(content, talkingWith(signer.pubKey))
return OptimizedJsonMapper.fromJsonTo<Notification>(jsonText)
}
companion object {
const val KIND = 23197
const val LEGACY_KIND = 23196
const val ALT = "Wallet notification"
}
}
@@ -0,0 +1,42 @@
/*
* 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.nip47WalletConnect
class NwcTransaction(
var type: String? = null,
var state: String? = null,
var invoice: String? = null,
var description: String? = null,
var description_hash: String? = null,
var preimage: String? = null,
var payment_hash: String? = null,
var amount: Long? = null,
var fees_paid: Long? = null,
var created_at: Long? = null,
var expires_at: Long? = null,
var settled_at: Long? = null,
var metadata: Any? = null,
)
class TlvRecord(
var type: Long? = null,
var value: String? = null,
)
@@ -27,15 +27,174 @@ abstract class Request(
var method: String? = null,
) : OptimizedSerializable
// PayInvoice Call
// pay_invoice
class PayInvoiceParams(
var invoice: String? = null,
var amount: Long? = null,
var metadata: Any? = null,
)
class PayInvoiceMethod(
var params: PayInvoiceParams? = null,
) : Request("pay_invoice") {
) : Request(NwcMethod.PAY_INVOICE) {
companion object {
fun create(bolt11: String): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11))
fun create(
bolt11: String,
amount: Long,
): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11, amount))
}
}
// pay_keysend
class PayKeysendParams(
var amount: Long? = null,
var pubkey: String? = null,
var preimage: String? = null,
var tlv_records: List<TlvRecord>? = null,
)
class PayKeysendMethod(
var params: PayKeysendParams? = null,
) : Request(NwcMethod.PAY_KEYSEND) {
companion object {
fun create(
amount: Long,
pubkey: String,
preimage: String? = null,
tlvRecords: List<TlvRecord>? = null,
): PayKeysendMethod = PayKeysendMethod(PayKeysendParams(amount, pubkey, preimage, tlvRecords))
}
}
// make_invoice
class MakeInvoiceParams(
var amount: Long? = null,
var description: String? = null,
var description_hash: String? = null,
var expiry: Long? = null,
var metadata: Any? = null,
)
class MakeInvoiceMethod(
var params: MakeInvoiceParams? = null,
) : Request(NwcMethod.MAKE_INVOICE) {
companion object {
fun create(
amount: Long,
description: String? = null,
descriptionHash: String? = null,
expiry: Long? = null,
): MakeInvoiceMethod = MakeInvoiceMethod(MakeInvoiceParams(amount, description, descriptionHash, expiry))
}
}
// lookup_invoice
class LookupInvoiceParams(
var payment_hash: String? = null,
var invoice: String? = null,
)
class LookupInvoiceMethod(
var params: LookupInvoiceParams? = null,
) : Request(NwcMethod.LOOKUP_INVOICE) {
companion object {
fun createByHash(paymentHash: String): LookupInvoiceMethod = LookupInvoiceMethod(LookupInvoiceParams(payment_hash = paymentHash))
fun createByInvoice(invoice: String): LookupInvoiceMethod = LookupInvoiceMethod(LookupInvoiceParams(invoice = invoice))
}
}
// list_transactions
class ListTransactionsParams(
var from: Long? = null,
var until: Long? = null,
var limit: Int? = null,
var offset: Int? = null,
var unpaid: Boolean? = null,
var type: String? = null,
)
class ListTransactionsMethod(
var params: ListTransactionsParams? = null,
) : Request(NwcMethod.LIST_TRANSACTIONS) {
companion object {
fun create(
from: Long? = null,
until: Long? = null,
limit: Int? = null,
offset: Int? = null,
unpaid: Boolean? = null,
type: String? = null,
): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, type))
}
}
// get_balance
class GetBalanceMethod : Request(NwcMethod.GET_BALANCE) {
companion object {
fun create(): GetBalanceMethod = GetBalanceMethod()
}
}
// get_info
class GetInfoMethod : Request(NwcMethod.GET_INFO) {
companion object {
fun create(): GetInfoMethod = GetInfoMethod()
}
}
// make_hold_invoice
class MakeHoldInvoiceParams(
var amount: Long? = null,
var description: String? = null,
var description_hash: String? = null,
var expiry: Long? = null,
var payment_hash: String? = null,
var min_cltv_expiry_delta: Int? = null,
)
class MakeHoldInvoiceMethod(
var params: MakeHoldInvoiceParams? = null,
) : Request(NwcMethod.MAKE_HOLD_INVOICE) {
companion object {
fun create(
amount: Long,
paymentHash: String,
description: String? = null,
descriptionHash: String? = null,
expiry: Long? = null,
minCltvExpiryDelta: Int? = null,
): MakeHoldInvoiceMethod =
MakeHoldInvoiceMethod(
MakeHoldInvoiceParams(amount, description, descriptionHash, expiry, paymentHash, minCltvExpiryDelta),
)
}
}
// cancel_hold_invoice
class CancelHoldInvoiceParams(
var payment_hash: String? = null,
)
class CancelHoldInvoiceMethod(
var params: CancelHoldInvoiceParams? = null,
) : Request(NwcMethod.CANCEL_HOLD_INVOICE) {
companion object {
fun create(paymentHash: String): CancelHoldInvoiceMethod = CancelHoldInvoiceMethod(CancelHoldInvoiceParams(paymentHash))
}
}
// settle_hold_invoice
class SettleHoldInvoiceParams(
var preimage: String? = null,
)
class SettleHoldInvoiceMethod(
var params: SettleHoldInvoiceParams? = null,
) : Request(NwcMethod.SETTLE_HOLD_INVOICE) {
companion object {
fun create(preimage: String): SettleHoldInvoiceMethod = SettleHoldInvoiceMethod(SettleHoldInvoiceParams(preimage))
}
}
@@ -27,49 +27,97 @@ abstract class Response(
val resultType: String,
) : OptimizedSerializable
// PayInvoice Call
// Generic error response for any method
class NwcErrorResponse(
resultType: String,
val error: NwcError? = null,
) : Response(resultType)
// pay_invoice success response
class PayInvoiceSuccessResponse(
val result: PayInvoiceResultParams? = null,
) : Response("pay_invoice") {
) : Response(NwcMethod.PAY_INVOICE) {
class PayInvoiceResultParams(
val preimage: String? = null,
val fees_paid: Long? = null,
)
}
// pay_invoice error response (kept for backward compatibility)
class PayInvoiceErrorResponse(
val error: PayInvoiceErrorParams? = null,
) : Response("pay_invoice") {
) : Response(NwcMethod.PAY_INVOICE) {
class PayInvoiceErrorParams(
val code: ErrorType? = null,
val code: NwcErrorCode? = null,
val message: String? = null,
)
enum class ErrorType {
RATE_LIMITED,
// The client is sending commands too fast. It should retry in a few seconds.
NOT_IMPLEMENTED,
// The command is not known or is intentionally not implemented.
INSUFFICIENT_BALANCE,
// The command is not known or is intentionally not implemented.
PAYMENT_FAILED,
// The wallet does not have enough funds to cover a fee reserve or the payment amount.
QUOTA_EXCEEDED,
// The wallet has exceeded its spending quota.
RESTRICTED,
// This public key is not allowed to do this operation.
UNAUTHORIZED,
// This public key has no wallet connected.
INTERNAL,
// An internal error.
OTHER, // Other error.
}
}
// pay_keysend success response
class PayKeysendSuccessResponse(
val result: PayKeysendResult? = null,
) : Response(NwcMethod.PAY_KEYSEND) {
class PayKeysendResult(
val preimage: String? = null,
val fees_paid: Long? = null,
)
}
// make_invoice success response
class MakeInvoiceSuccessResponse(
val result: NwcTransaction? = null,
) : Response(NwcMethod.MAKE_INVOICE)
// lookup_invoice success response
class LookupInvoiceSuccessResponse(
val result: NwcTransaction? = null,
) : Response(NwcMethod.LOOKUP_INVOICE)
// list_transactions success response
class ListTransactionsSuccessResponse(
val result: ListTransactionsResult? = null,
) : Response(NwcMethod.LIST_TRANSACTIONS) {
class ListTransactionsResult(
val transactions: List<NwcTransaction>? = null,
)
}
// get_balance success response
class GetBalanceSuccessResponse(
val result: GetBalanceResult? = null,
) : Response(NwcMethod.GET_BALANCE) {
class GetBalanceResult(
val balance: Long? = null,
)
}
// get_info success response
class GetInfoSuccessResponse(
val result: GetInfoResult? = null,
) : Response(NwcMethod.GET_INFO) {
class GetInfoResult(
val alias: String? = null,
val color: String? = null,
val pubkey: String? = null,
val network: String? = null,
val block_height: Long? = null,
val block_hash: String? = null,
val methods: List<String>? = null,
val notifications: List<String>? = null,
)
}
// make_hold_invoice success response
class MakeHoldInvoiceSuccessResponse(
val result: NwcTransaction? = null,
) : Response(NwcMethod.MAKE_HOLD_INVOICE)
// cancel_hold_invoice success response
class CancelHoldInvoiceSuccessResponse(
val result: Any? = null,
) : Response(NwcMethod.CANCEL_HOLD_INVOICE)
// settle_hold_invoice success response
class SettleHoldInvoiceSuccessResponse(
val result: Any? = null,
) : Response(NwcMethod.SETTLE_HOLD_INVOICE)
@@ -0,0 +1,41 @@
/*
* 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.nip47WalletConnect.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class EncryptionTag {
companion object {
const val TAG_NAME = "encryption"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): List<String>? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag.drop(1)
}
fun assemble(schemes: List<String>) = arrayOf(TAG_NAME, *schemes.toTypedArray())
}
}
@@ -0,0 +1,41 @@
/*
* 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.nip47WalletConnect.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class NotificationsTag {
companion object {
const val TAG_NAME = "notifications"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): List<String>? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag.drop(1)
}
fun assemble(types: List<String>) = arrayOf(TAG_NAME, *types.toTypedArray())
}
}
@@ -78,6 +78,8 @@ import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -256,6 +258,9 @@ class EventFactory {
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig)
NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig)
NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
@@ -52,8 +52,10 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestDeseriali
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestSerializer
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseDeserializer
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
@@ -93,6 +95,7 @@ class JacksonMapper {
// nip 47
.addDeserializer(Response::class.java, ResponseDeserializer())
.addDeserializer(Request::class.java, RequestDeserializer())
.addDeserializer(Notification::class.java, NotificationDeserializer())
// nip 46
.addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer())
.addSerializer(BunkerRequest::class.java, BunkerRequestSerializer())
@@ -0,0 +1,49 @@
/*
* 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.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification
import com.vitorpamplona.quartz.utils.asTextOrNull
class NotificationDeserializer : StdDeserializer<Notification>(Notification::class.java) {
override fun deserialize(
jp: JsonParser,
ctxt: DeserializationContext,
): Notification? {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val notificationType = jsonObject.get("notification_type")?.asTextOrNull()
return when (notificationType) {
NwcNotificationType.PAYMENT_RECEIVED -> jp.codec.treeToValue(jsonObject, PaymentReceivedNotification::class.java)
NwcNotificationType.PAYMENT_SENT -> jp.codec.treeToValue(jsonObject, PaymentSentNotification::class.java)
NwcNotificationType.HOLD_INVOICE_ACCEPTED -> jp.codec.treeToValue(jsonObject, HoldInvoiceAcceptedNotification::class.java)
else -> null
}
}
}
@@ -24,8 +24,18 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.utils.asTextOrNull
class RequestDeserializer : StdDeserializer<Request>(Request::class.java) {
@@ -36,9 +46,18 @@ class RequestDeserializer : StdDeserializer<Request>(Request::class.java) {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val method = jsonObject.get("method")?.asTextOrNull()
if (method == "pay_invoice") {
return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java)
return when (method) {
NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::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)
NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsMethod::class.java)
NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceMethod::class.java)
NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoMethod::class.java)
NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceMethod::class.java)
NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceMethod::class.java)
NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceMethod::class.java)
else -> null
}
return null
}
}
@@ -24,9 +24,21 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.utils.asTextOrNull
class ResponseDeserializer : StdDeserializer<Response>(Response::class.java) {
@@ -36,25 +48,41 @@ class ResponseDeserializer : StdDeserializer<Response>(Response::class.java) {
): Response? {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val resultType = jsonObject.get("result_type")?.asTextOrNull()
val hasError = jsonObject.has("error") && !jsonObject.get("error").isNull
val hasResult = jsonObject.has("result") && !jsonObject.get("result").isNull
if (resultType == "pay_invoice") {
val result = jsonObject.get("result")
val error = jsonObject.get("error")
if (result != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (error != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java)
}
} else {
// tries to guess
if (jsonObject.get("result")?.get("preimage") != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (jsonObject.get("error")?.get("code") != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java)
if (hasError) {
return when (resultType) {
NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java)
else -> {
val error = jp.codec.treeToValue(jsonObject.get("error"), NwcError::class.java)
NwcErrorResponse(resultType ?: "", error)
}
}
}
if (hasResult || resultType != null) {
return when (resultType) {
NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java)
NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceSuccessResponse::class.java)
NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceSuccessResponse::class.java)
NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java)
NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java)
NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java)
NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java)
NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java)
NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java)
else -> {
// tries to guess for backward compatibility
if (jsonObject.get("result")?.get("preimage") != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
null
}
}
}
return null
}
}