From da26ab14ab2b65c7842bde86f23f1d61d1be10d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 03:15:21 +0000 Subject: [PATCH 01/14] 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 --- .../LnZapPaymentRequestEvent.kt | 38 +++- .../nip47WalletConnect/Nip47WalletConnect.kt | 11 +- .../quartz/nip47WalletConnect/Notification.kt | 59 +++++++ .../quartz/nip47WalletConnect/NwcErrorCode.kt | 39 +++++ .../quartz/nip47WalletConnect/NwcInfoEvent.kt | 69 ++++++++ .../quartz/nip47WalletConnect/NwcMethod.kt | 34 ++++ .../NwcNotificationEvent.kt | 58 +++++++ .../nip47WalletConnect/NwcTransaction.kt | 42 +++++ .../quartz/nip47WalletConnect/Request.kt | 163 +++++++++++++++++- .../quartz/nip47WalletConnect/Response.kt | 112 ++++++++---- .../nip47WalletConnect/tags/EncryptionTag.kt | 41 +++++ .../tags/NotificationsTag.kt | 41 +++++ .../quartz/utils/EventFactory.kt | 5 + .../quartz/nip01Core/jackson/JacksonMapper.kt | 3 + .../jackson/NotificationDeserializer.kt | 49 ++++++ .../jackson/RequestDeserializer.kt | 25 ++- .../jackson/ResponseDeserializer.kt | 60 +++++-- 17 files changed, 784 insertions(+), 65 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt index 2a766ef0b..452c99b2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt @@ -52,25 +52,47 @@ class LnZapPaymentRequestEvent( return OptimizedJsonMapper.fromJsonTo(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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt index e3694b02a..53eb83d7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt new file mode 100644 index 000000000..2c1bbdfc3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt @@ -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, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt new file mode 100644 index 000000000..f88fd1892 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt @@ -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, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt new file mode 100644 index 000000000..42ca7529a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun capabilities(): List = 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, + encryptionSchemes: List? = null, + notificationTypes: List? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, capabilities.joinToString(" "), createdAt) { + alt(ALT_DESCRIPTION) + encryptionSchemes?.let { addUnique(EncryptionTag.assemble(it)) } + notificationTypes?.let { addUnique(NotificationsTag.assemble(it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt new file mode 100644 index 000000000..e6195639a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt @@ -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" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt new file mode 100644 index 000000000..77b16a3ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt @@ -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>, + 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(jsonText) + } + + companion object { + const val KIND = 23197 + const val LEGACY_KIND = 23196 + const val ALT = "Wallet notification" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt new file mode 100644 index 000000000..d2a043f59 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -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, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index b53f3482e..8d0ea548b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -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? = null, +) + +class PayKeysendMethod( + var params: PayKeysendParams? = null, +) : Request(NwcMethod.PAY_KEYSEND) { + companion object { + fun create( + amount: Long, + pubkey: String, + preimage: String? = null, + tlvRecords: List? = 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)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt index cb449136a..5c4d3d6a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt @@ -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? = 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? = null, + val notifications: List? = 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt new file mode 100644 index 000000000..3d732528f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): List? { + 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) = arrayOf(TAG_NAME, *schemes.toTypedArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt new file mode 100644 index 000000000..cb42cce54 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): List? { + 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) = arrayOf(TAG_NAME, *types.toTypedArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index eee5e8689..4679e4665 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index bec5f11b4..7729b4dd8 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -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()) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt new file mode 100644 index 000000000..6f3d2ccab --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt @@ -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::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 + } + } +} 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 8e0c5c177..4098a5dc9 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 @@ -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::class.java) { @@ -36,9 +46,18 @@ class RequestDeserializer : StdDeserializer(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 } } 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 f903f0171..72e7685fb 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 @@ -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::class.java) { @@ -36,25 +48,41 @@ class ResponseDeserializer : StdDeserializer(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 } } From 8c73dd8f14c359362adffc3dcea3a2107b046921 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 03:45:50 +0000 Subject: [PATCH 02/14] test: add comprehensive test suite for NIP-47 Wallet Connect implementation Covers all 10 request methods (create, serialize, deserialize), all success and error response types, notification deserialization, EncryptionTag and NotificationsTag parse/assemble/isTag, NwcInfoEvent build and capabilities, NwcNotificationEvent structure, LnZapPaymentRequestEvent create/decrypt with NIP-04 and NIP-44, and Nip47WalletConnect URI parsing and serialization. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../LnZapPaymentRequestEventTest.kt | 207 ++++++++++++ .../Nip47WalletConnectTest.kt | 155 +++++++++ .../nip47WalletConnect/NotificationTest.kt | 97 ++++++ .../nip47WalletConnect/NwcInfoEventTest.kt | 124 ++++++++ .../nip47WalletConnect/NwcMethodTest.kt | 70 ++++ .../NwcNotificationEventTest.kt | 144 +++++++++ .../quartz/nip47WalletConnect/RequestTest.kt | 299 ++++++++++++++++++ .../quartz/nip47WalletConnect/ResponseTest.kt | 268 ++++++++++++++++ .../quartz/nip47WalletConnect/TagsTest.kt | 138 ++++++++ 9 files changed, 1502 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt new file mode 100644 index 000000000..bc5af2432 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt @@ -0,0 +1,207 @@ +/* + * 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.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LnZapPaymentRequestEventTest { + @Test + fun testEventKind() { + assertEquals(23194, LnZapPaymentRequestEvent.KIND) + } + + @Test + fun testCreatePayInvoiceRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + ) + + assertEquals(23194, event.kind) + assertEquals(walletServicePubkey, event.walletServicePubKey()) + assertTrue(event.isContentEncoded()) + assertNotNull(event.content) + assertTrue(event.content.isNotEmpty()) + } + + @Test + fun testCreateGenericRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + ) + + assertEquals(23194, event.kind) + assertEquals(walletServicePubkey, event.walletServicePubKey()) + // Should not have encryption tag for NIP-04 + assertNull(event.encryptionScheme()) + } + + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptPayInvoiceRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + // Wallet service should be able to decrypt + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + assertEquals("lnbc50n1...", decrypted.params?.invoice) + } + + @Test + fun testDecryptGenericRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val request = MakeInvoiceMethod.create(5000L, "test payment") + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + assertEquals(5000L, decrypted.params?.amount) + assertEquals("test payment", decrypted.params?.description) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } + + @Test + fun testCanDecrypt() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val otherKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val otherSigner = NostrSignerInternal(otherKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + assertTrue(event.canDecrypt(clientSigner)) + assertTrue(event.canDecrypt(walletSigner)) + assertTrue(!event.canDecrypt(otherSigner)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt new file mode 100644 index 000000000..c31c376d7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt @@ -0,0 +1,155 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class Nip47WalletConnectTest { + @Test + fun testParseWalletConnectUri() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + assertEquals("wss://relay.damus.io/", parsed.relayUri.url) + assertEquals("71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5", parsed.secret) + assertNull(parsed.lud16) + } + + @Test + fun testParseWalletConnectUriWithLud16() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5&lud16=user%40example.com" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + assertNotNull(parsed.lud16) + assertEquals("user@example.com", parsed.lud16) + } + + @Test + fun testParseNostrWalletConnectScheme() { + val uri = + "nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=abc" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + } + + @Test + fun testParseAmethystWalletConnectScheme() { + val uri = + "amethyst+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=abc" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + } + + @Test + fun testParseWithoutSecret() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io" + val parsed = Nip47WalletConnect.parse(uri) + + assertNull(parsed.secret) + } + + @Test + fun testParseInvalidSchemeThrows() { + val uri = "https://example.com?relay=wss%3A%2F%2Frelay.damus.io" + assertFailsWith { + Nip47WalletConnect.parse(uri) + } + } + + @Test + fun testParseWithoutRelayThrows() { + val uri = "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4" + assertFailsWith { + Nip47WalletConnect.parse(uri) + } + } + + // --- Nip47URI serialization --- + + @Test + fun testNip47UriSerializationRoundTrip() { + val original = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", + relayUri = "wss://relay.damus.io", + secret = "abc123", + lud16 = "user@example.com", + ) + + val json = Nip47WalletConnect.Nip47URI.serializer(original) + val deserialized = Nip47WalletConnect.Nip47URI.parser(json) + + assertEquals(original.pubKeyHex, deserialized.pubKeyHex) + assertEquals(original.relayUri, deserialized.relayUri) + assertEquals(original.secret, deserialized.secret) + assertEquals(original.lud16, deserialized.lud16) + } + + @Test + fun testNip47UriSerializationWithNullLud16() { + val original = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "abc123", + relayUri = "wss://relay.damus.io", + secret = "secret", + ) + + val json = Nip47WalletConnect.Nip47URI.serializer(original) + val deserialized = Nip47WalletConnect.Nip47URI.parser(json) + + assertEquals(original.pubKeyHex, deserialized.pubKeyHex) + assertNull(deserialized.lud16) + } + + // --- Normalize/Denormalize --- + + @Test + fun testNormalizeDenormalizeRoundTrip() { + val uri = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "abc123", + relayUri = "wss://relay.damus.io", + secret = "secret", + lud16 = "user@example.com", + ) + + val normalized = uri.normalize() + assertNotNull(normalized) + assertEquals("user@example.com", normalized.lud16) + + val denormalized = normalized.denormalize() + assertNotNull(denormalized) + assertEquals("abc123", denormalized.pubKeyHex) + assertEquals("secret", denormalized.secret) + assertEquals("user@example.com", denormalized.lud16) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt new file mode 100644 index 000000000..b06d3710c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt @@ -0,0 +1,97 @@ +/* + * 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.OptimizedJsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class NotificationTest { + @Test + fun testPaymentReceivedDeserialization() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","invoice":"lnbc50n1...","description":"coffee","preimage":"abc","payment_hash":"hash123","amount":5000,"fees_paid":10,"created_at":1693876497,"expires_at":1694876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("incoming", notification.notification?.type) + assertEquals("lnbc50n1...", notification.notification?.invoice) + assertEquals("coffee", notification.notification?.description) + assertEquals("abc", notification.notification?.preimage) + assertEquals("hash123", notification.notification?.payment_hash) + assertEquals(5000L, notification.notification?.amount) + assertEquals(10L, notification.notification?.fees_paid) + assertEquals(1693876497L, notification.notification?.created_at) + assertEquals(1694876497L, notification.notification?.expires_at) + assertEquals(1694876500L, notification.notification?.settled_at) + } + + @Test + fun testPaymentSentDeserialization() { + val json = + """{"notification_type":"payment_sent","notification":{"type":"outgoing","invoice":"lnbc100n1...","preimage":"def456","payment_hash":"hash456","amount":10000,"fees_paid":50,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("outgoing", notification.notification?.type) + assertEquals("lnbc100n1...", notification.notification?.invoice) + assertEquals("def456", notification.notification?.preimage) + assertEquals(10000L, notification.notification?.amount) + assertEquals(50L, notification.notification?.fees_paid) + } + + @Test + fun testHoldInvoiceAcceptedDeserialization() { + val json = + """{"notification_type":"hold_invoice_accepted","notification":{"type":"incoming","invoice":"lnbc200n1...","payment_hash":"hash789","amount":20000,"created_at":1693876497,"expires_at":1694876497,"settle_deadline":800000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("incoming", notification.notification?.type) + assertEquals("lnbc200n1...", notification.notification?.invoice) + assertEquals("hash789", notification.notification?.payment_hash) + assertEquals(20000L, notification.notification?.amount) + assertEquals(800000L, notification.notification?.settle_deadline) + assertEquals(1693876497L, notification.notification?.created_at) + assertEquals(1694876497L, notification.notification?.expires_at) + } + + @Test + fun testPaymentReceivedMinimalFields() { + val json = """{"notification_type":"payment_received","notification":{"type":"incoming","amount":100}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertEquals("incoming", notification.notification?.type) + assertEquals(100L, notification.notification?.amount) + assertNull(notification.notification?.invoice) + assertNull(notification.notification?.preimage) + } + + @Test + fun testUnknownNotificationTypeReturnsNull() { + val json = """{"notification_type":"unknown_type","notification":{}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertNull(notification) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt new file mode 100644 index 000000000..3a8b15ca8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt @@ -0,0 +1,124 @@ +/* + * 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.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NwcInfoEventTest { + private val signer = DeterministicSigner("nsec1w4uucmeyyng0kegm7486r23sv4majkmvqsj6eypprq0xttxss55s5mgg9t".nsecToKeyPair()) + + @Test + fun testBuildInfoEvent() { + val capabilities = listOf("pay_invoice", "get_balance", "make_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertEquals(NwcInfoEvent.KIND, event.kind) + assertEquals("pay_invoice get_balance make_invoice notifications", event.content) + } + + @Test + fun testCapabilities() { + val capabilities = listOf("pay_invoice", "get_balance", "make_invoice") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + val parsed = event.capabilities() + assertEquals(3, parsed.size) + assertTrue(parsed.contains("pay_invoice")) + assertTrue(parsed.contains("get_balance")) + assertTrue(parsed.contains("make_invoice")) + } + + @Test + fun testSupportsMethod() { + val capabilities = listOf("pay_invoice", "get_balance") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.supportsMethod("pay_invoice")) + assertTrue(event.supportsMethod("get_balance")) + assertFalse(event.supportsMethod("make_invoice")) + assertFalse(event.supportsMethod("pay_keysend")) + } + + @Test + fun testSupportsNotifications() { + val capabilities = listOf("pay_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.supportsNotifications()) + } + + @Test + fun testDoesNotSupportNotifications() { + val capabilities = listOf("pay_invoice", "get_balance") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertFalse(event.supportsNotifications()) + } + + @Test + fun testEncryptionSchemes() { + val capabilities = listOf("pay_invoice") + val template = NwcInfoEvent.build(capabilities, encryptionSchemes = listOf("nip44_v2", "nip04")) + val event = signer.sign(template) + + val schemes = event.encryptionSchemes() + assertEquals(2, schemes.size) + assertTrue(schemes.contains("nip44_v2")) + assertTrue(schemes.contains("nip04")) + } + + @Test + fun testNotificationTypes() { + val capabilities = listOf("pay_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities, notificationTypes = listOf("payment_received", "payment_sent")) + val event = signer.sign(template) + + val types = event.notificationTypes() + assertEquals(2, types.size) + assertTrue(types.contains("payment_received")) + assertTrue(types.contains("payment_sent")) + } + + @Test + fun testInfoEventKind() { + assertEquals(13194, NwcInfoEvent.KIND) + } + + @Test + fun testBuildWithNoOptionalTags() { + val capabilities = listOf("pay_invoice") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.encryptionSchemes().isEmpty()) + assertTrue(event.notificationTypes().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt new file mode 100644 index 000000000..f088a2dbd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -0,0 +1,70 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals + +class NwcMethodTest { + @Test + fun testMethodConstants() { + assertEquals("pay_invoice", NwcMethod.PAY_INVOICE) + assertEquals("pay_keysend", NwcMethod.PAY_KEYSEND) + assertEquals("make_invoice", NwcMethod.MAKE_INVOICE) + assertEquals("lookup_invoice", NwcMethod.LOOKUP_INVOICE) + assertEquals("list_transactions", NwcMethod.LIST_TRANSACTIONS) + assertEquals("get_balance", NwcMethod.GET_BALANCE) + assertEquals("get_info", NwcMethod.GET_INFO) + assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE) + assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE) + assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE) + } + + @Test + fun testNotificationTypeConstants() { + assertEquals("payment_received", NwcNotificationType.PAYMENT_RECEIVED) + assertEquals("payment_sent", NwcNotificationType.PAYMENT_SENT) + assertEquals("hold_invoice_accepted", NwcNotificationType.HOLD_INVOICE_ACCEPTED) + } + + @Test + fun testErrorCodeValues() { + val codes = NwcErrorCode.entries + assertEquals(10, 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")) + assertEquals(NwcErrorCode.PAYMENT_FAILED, NwcErrorCode.valueOf("PAYMENT_FAILED")) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, NwcErrorCode.valueOf("QUOTA_EXCEEDED")) + assertEquals(NwcErrorCode.RESTRICTED, NwcErrorCode.valueOf("RESTRICTED")) + assertEquals(NwcErrorCode.UNAUTHORIZED, NwcErrorCode.valueOf("UNAUTHORIZED")) + assertEquals(NwcErrorCode.INTERNAL, NwcErrorCode.valueOf("INTERNAL")) + assertEquals(NwcErrorCode.UNSUPPORTED_ENCRYPTION, NwcErrorCode.valueOf("UNSUPPORTED_ENCRYPTION")) + assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER")) + } + + @Test + fun testNwcError() { + val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed") + assertEquals(NwcErrorCode.UNAUTHORIZED, error.code) + assertEquals("not allowed", error.message) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt new file mode 100644 index 000000000..3b392eb55 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt @@ -0,0 +1,144 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NwcNotificationEventTest { + @Test + fun testKindConstants() { + assertEquals(23197, NwcNotificationEvent.KIND) + assertEquals(23196, NwcNotificationEvent.LEGACY_KIND) + } + + @Test + fun testIsContentEncoded() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = arrayOf(arrayOf("p", "c".repeat(64))), + content = "encrypted_content", + sig = "d".repeat(128), + ) + assertTrue(event.isContentEncoded()) + } + + @Test + fun testClientPubKey() { + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + assertEquals(clientPubKey, event.clientPubKey()) + } + + @Test + fun testClientPubKeyMissing() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = emptyArray(), + content = "encrypted", + sig = "d".repeat(128), + ) + assertNull(event.clientPubKey()) + } + + @Test + fun testTalkingWithAsWalletService() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // Wallet service asking "who am I talking with?" -> client + assertEquals(clientPubKey, event.talkingWith(walletPubKey)) + } + + @Test + fun testTalkingWithAsClient() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // Client asking "who am I talking with?" -> wallet service (pubkey) + assertEquals(walletPubKey, event.talkingWith(clientPubKey)) + } + + @Test + fun testEventKindInFactory() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = emptyArray(), + content = "", + sig = "c".repeat(128), + ) + assertEquals(23197, event.kind) + } + + @Test + fun testCanDecryptReturnsFalseForUnrelatedSigner() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // A signer that is neither the wallet nor the client shouldn't be able to decrypt + assertFalse(event.clientPubKey() == "e".repeat(64)) + assertFalse(event.pubKey == "e".repeat(64)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt new file mode 100644 index 000000000..630f7addf --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -0,0 +1,299 @@ +/* + * 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.OptimizedJsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RequestTest { + // --- PayInvoice --- + + @Test + fun testPayInvoiceCreate() { + val request = PayInvoiceMethod.create("lnbc50n1...") + assertEquals(NwcMethod.PAY_INVOICE, request.method) + assertEquals("lnbc50n1...", request.params?.invoice) + assertNull(request.params?.amount) + } + + @Test + fun testPayInvoiceCreateWithAmount() { + val request = PayInvoiceMethod.create("lnbc50n1...", 1000L) + assertEquals(NwcMethod.PAY_INVOICE, request.method) + assertEquals("lnbc50n1...", request.params?.invoice) + assertEquals(1000L, request.params?.amount) + } + + @Test + fun testPayInvoiceSerialization() { + val request = PayInvoiceMethod.create("lnbc50n1...") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"pay_invoice\"")) + assertTrue(json.contains("\"invoice\":\"lnbc50n1...\"")) + } + + @Test + fun testPayInvoiceDeserialization() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc50n1..."}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc50n1...", request.params?.invoice) + } + + @Test + fun testPayInvoiceWithAmountDeserialization() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc50n1...","amount":1000}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc50n1...", request.params?.invoice) + assertEquals(1000L, request.params?.amount) + } + + // --- PayKeysend --- + + @Test + fun testPayKeysendCreate() { + val request = PayKeysendMethod.create(1000L, "abcdef1234567890") + assertEquals(NwcMethod.PAY_KEYSEND, request.method) + assertEquals(1000L, request.params?.amount) + assertEquals("abcdef1234567890", request.params?.pubkey) + assertNull(request.params?.preimage) + assertNull(request.params?.tlv_records) + } + + @Test + fun testPayKeysendWithTlvRecords() { + val tlvRecords = listOf(TlvRecord(7629169L, "hex_value")) + val request = PayKeysendMethod.create(1000L, "pubkey123", "preimage123", tlvRecords) + assertEquals(1000L, request.params?.amount) + assertEquals("pubkey123", request.params?.pubkey) + assertEquals("preimage123", request.params?.preimage) + assertNotNull(request.params?.tlv_records) + assertEquals(1, request.params?.tlv_records?.size) + assertEquals(7629169L, request.params?.tlv_records?.first()?.type) + assertEquals("hex_value", request.params?.tlv_records?.first()?.value) + } + + @Test + fun testPayKeysendSerialization() { + val request = PayKeysendMethod.create(1000L, "pubkey123") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"pay_keysend\"")) + assertTrue(json.contains("\"amount\":1000")) + assertTrue(json.contains("\"pubkey\":\"pubkey123\"")) + } + + @Test + fun testPayKeysendDeserialization() { + val json = """{"method":"pay_keysend","params":{"amount":1000,"pubkey":"pubkey123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("pubkey123", request.params?.pubkey) + } + + // --- MakeInvoice --- + + @Test + fun testMakeInvoiceCreate() { + val request = MakeInvoiceMethod.create(5000L, "test payment", null, 3600L) + assertEquals(NwcMethod.MAKE_INVOICE, request.method) + assertEquals(5000L, request.params?.amount) + assertEquals("test payment", request.params?.description) + assertNull(request.params?.description_hash) + assertEquals(3600L, request.params?.expiry) + } + + @Test + fun testMakeInvoiceSerialization() { + val request = MakeInvoiceMethod.create(5000L, "test") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"make_invoice\"")) + assertTrue(json.contains("\"amount\":5000")) + } + + @Test + fun testMakeInvoiceDeserialization() { + val json = """{"method":"make_invoice","params":{"amount":5000,"description":"test","expiry":3600}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(5000L, request.params?.amount) + assertEquals("test", request.params?.description) + assertEquals(3600L, request.params?.expiry) + } + + // --- LookupInvoice --- + + @Test + fun testLookupInvoiceByHash() { + val request = LookupInvoiceMethod.createByHash("abc123") + assertEquals(NwcMethod.LOOKUP_INVOICE, request.method) + assertEquals("abc123", request.params?.payment_hash) + assertNull(request.params?.invoice) + } + + @Test + fun testLookupInvoiceByInvoice() { + val request = LookupInvoiceMethod.createByInvoice("lnbc50n1...") + assertEquals(NwcMethod.LOOKUP_INVOICE, request.method) + assertNull(request.params?.payment_hash) + assertEquals("lnbc50n1...", request.params?.invoice) + } + + @Test + fun testLookupInvoiceDeserialization() { + val json = """{"method":"lookup_invoice","params":{"payment_hash":"abc123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.payment_hash) + } + + // --- ListTransactions --- + + @Test + fun testListTransactionsCreate() { + val request = ListTransactionsMethod.create(from = 1000L, until = 2000L, limit = 10, offset = 0, unpaid = false, type = "incoming") + assertEquals(NwcMethod.LIST_TRANSACTIONS, request.method) + assertEquals(1000L, request.params?.from) + assertEquals(2000L, request.params?.until) + assertEquals(10, request.params?.limit) + assertEquals(0, request.params?.offset) + assertEquals(false, request.params?.unpaid) + assertEquals("incoming", request.params?.type) + } + + @Test + fun testListTransactionsDeserialization() { + val json = """{"method":"list_transactions","params":{"from":1000,"until":2000,"limit":10}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.from) + assertEquals(2000L, request.params?.until) + assertEquals(10, request.params?.limit) + } + + // --- GetBalance --- + + @Test + fun testGetBalanceCreate() { + val request = GetBalanceMethod.create() + assertEquals(NwcMethod.GET_BALANCE, request.method) + } + + @Test + fun testGetBalanceSerialization() { + val request = GetBalanceMethod.create() + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"get_balance\"")) + } + + @Test + fun testGetBalanceDeserialization() { + val json = """{"method":"get_balance"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- GetInfo --- + + @Test + fun testGetInfoCreate() { + val request = GetInfoMethod.create() + assertEquals(NwcMethod.GET_INFO, request.method) + } + + @Test + fun testGetInfoDeserialization() { + val json = """{"method":"get_info"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- MakeHoldInvoice --- + + @Test + fun testMakeHoldInvoiceCreate() { + val request = MakeHoldInvoiceMethod.create(10000L, "payment_hash_abc", "hold invoice", null, 7200L, 144) + assertEquals(NwcMethod.MAKE_HOLD_INVOICE, request.method) + assertEquals(10000L, request.params?.amount) + assertEquals("payment_hash_abc", request.params?.payment_hash) + assertEquals("hold invoice", request.params?.description) + assertEquals(7200L, request.params?.expiry) + assertEquals(144, request.params?.min_cltv_expiry_delta) + } + + @Test + fun testMakeHoldInvoiceDeserialization() { + val json = """{"method":"make_hold_invoice","params":{"amount":10000,"payment_hash":"abc","description":"test"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(10000L, request.params?.amount) + assertEquals("abc", request.params?.payment_hash) + } + + // --- CancelHoldInvoice --- + + @Test + fun testCancelHoldInvoiceCreate() { + val request = CancelHoldInvoiceMethod.create("payment_hash_abc") + assertEquals(NwcMethod.CANCEL_HOLD_INVOICE, request.method) + assertEquals("payment_hash_abc", request.params?.payment_hash) + } + + @Test + fun testCancelHoldInvoiceDeserialization() { + val json = """{"method":"cancel_hold_invoice","params":{"payment_hash":"abc123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.payment_hash) + } + + // --- SettleHoldInvoice --- + + @Test + fun testSettleHoldInvoiceCreate() { + val request = SettleHoldInvoiceMethod.create("preimage_xyz") + assertEquals(NwcMethod.SETTLE_HOLD_INVOICE, request.method) + assertEquals("preimage_xyz", request.params?.preimage) + } + + @Test + fun testSettleHoldInvoiceDeserialization() { + val json = """{"method":"settle_hold_invoice","params":{"preimage":"preimage_xyz"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("preimage_xyz", request.params?.preimage) + } + + // --- Unknown method --- + + @Test + fun testUnknownMethodReturnsNull() { + val json = """{"method":"unknown_method","params":{}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertNull(request) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt new file mode 100644 index 000000000..6bf160d6a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -0,0 +1,268 @@ +/* + * 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.OptimizedJsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ResponseTest { + // --- PayInvoice Success --- + + @Test + fun testPayInvoiceSuccessDeserialization() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"0123456789abcdef"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("0123456789abcdef", response.result?.preimage) + } + + @Test + fun testPayInvoiceSuccessWithFeesPaid() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"abc","fees_paid":100}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("abc", response.result?.preimage) + assertEquals(100L, response.result?.fees_paid) + } + + @Test + fun testPayInvoiceSuccessGuessWithoutResultType() { + val json = """{"result":{"preimage":"0123456789abcdef"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("0123456789abcdef", response.result?.preimage) + } + + // --- PayInvoice Error --- + + @Test + fun testPayInvoiceErrorDeserialization() { + val json = """{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE","message":"Not enough funds"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, response.error?.code) + assertEquals("Not enough funds", response.error?.message) + } + + @Test + fun testPayInvoicePaymentFailedError() { + val json = """{"result_type":"pay_invoice","error":{"code":"PAYMENT_FAILED","message":"Route not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.PAYMENT_FAILED, response.error?.code) + } + + // --- PayKeysend Success --- + + @Test + fun testPayKeysendSuccessDeserialization() { + val json = """{"result_type":"pay_keysend","result":{"preimage":"abc123","fees_paid":50}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("abc123", response.result?.preimage) + assertEquals(50L, response.result?.fees_paid) + } + + // --- MakeInvoice Success --- + + @Test + fun testMakeInvoiceSuccessDeserialization() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","invoice":"lnbc50n1...","description":"test","payment_hash":"abc","amount":5000,"fees_paid":0,"created_at":1693876497,"expires_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("incoming", response.result?.type) + assertEquals("lnbc50n1...", response.result?.invoice) + assertEquals("test", response.result?.description) + assertEquals("abc", response.result?.payment_hash) + assertEquals(5000L, response.result?.amount) + assertEquals(1693876497L, response.result?.created_at) + assertEquals(1694876497L, response.result?.expires_at) + } + + // --- LookupInvoice Success --- + + @Test + fun testLookupInvoiceSuccessDeserialization() { + val json = """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("incoming", response.result?.type) + assertEquals("settled", response.result?.state) + assertEquals("hash123", response.result?.payment_hash) + assertEquals(1000L, response.result?.amount) + assertEquals(1694876497L, response.result?.settled_at) + } + + // --- ListTransactions Success --- + + @Test + fun testListTransactionsSuccessDeserialization() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","invoice":"lnbc1...","amount":100,"created_at":1000},{"type":"outgoing","invoice":"lnbc2...","amount":200,"created_at":2000}]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.transactions) + assertEquals(2, response.result?.transactions?.size) + assertEquals("incoming", response.result?.transactions?.get(0)?.type) + assertEquals(100L, response.result?.transactions?.get(0)?.amount) + assertEquals("outgoing", response.result?.transactions?.get(1)?.type) + assertEquals(200L, response.result?.transactions?.get(1)?.amount) + } + + @Test + fun testListTransactionsEmptyResult() { + val json = """{"result_type":"list_transactions","result":{"transactions":[]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.transactions) + assertEquals(0, response.result?.transactions?.size) + } + + // --- GetBalance Success --- + + @Test + fun testGetBalanceSuccessDeserialization() { + val json = """{"result_type":"get_balance","result":{"balance":21000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(21000L, response.result?.balance) + } + + @Test + fun testGetBalanceZero() { + val json = """{"result_type":"get_balance","result":{"balance":0}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.balance) + } + + // --- GetInfo Success --- + + @Test + fun testGetInfoSuccessDeserialization() { + val json = + """{"result_type":"get_info","result":{"alias":"MyNode","color":"#ff9900","pubkey":"abc123","network":"mainnet","block_height":800000,"block_hash":"hash","methods":["pay_invoice","get_balance"],"notifications":["payment_received"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("MyNode", response.result?.alias) + assertEquals("#ff9900", response.result?.color) + assertEquals("abc123", response.result?.pubkey) + assertEquals("mainnet", response.result?.network) + assertEquals(800000L, response.result?.block_height) + assertEquals("hash", response.result?.block_hash) + assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods) + assertEquals(listOf("payment_received"), response.result?.notifications) + } + + // --- MakeHoldInvoice Success --- + + @Test + fun testMakeHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"make_hold_invoice","result":{"type":"incoming","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"expires_at":2000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("lnbc...", response.result?.invoice) + assertEquals("hash", response.result?.payment_hash) + } + + // --- CancelHoldInvoice Success --- + + @Test + fun testCancelHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"cancel_hold_invoice","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + } + + // --- SettleHoldInvoice Success --- + + @Test + fun testSettleHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"settle_hold_invoice","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + } + + // --- Generic Error Response --- + + @Test + fun testGenericErrorForGetBalance() { + val json = """{"result_type":"get_balance","error":{"code":"UNAUTHORIZED","message":"No wallet connected"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("get_balance", response.resultType) + assertEquals(NwcErrorCode.UNAUTHORIZED, response.error?.code) + assertEquals("No wallet connected", response.error?.message) + } + + @Test + fun testGenericErrorForGetInfo() { + val json = """{"result_type":"get_info","error":{"code":"NOT_IMPLEMENTED","message":"Not supported"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("get_info", response.resultType) + assertEquals(NwcErrorCode.NOT_IMPLEMENTED, response.error?.code) + } + + @Test + fun testGenericErrorForMakeInvoice() { + val json = """{"result_type":"make_invoice","error":{"code":"QUOTA_EXCEEDED","message":"Spending limit reached"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, response.error?.code) + } + + @Test + fun testGenericErrorRateLimited() { + val json = """{"result_type":"pay_keysend","error":{"code":"RATE_LIMITED","message":"Too many requests"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.RATE_LIMITED, response.error?.code) + } + + @Test + fun testGenericErrorUnsupportedEncryption() { + val json = """{"result_type":"pay_invoice","error":{"code":"UNSUPPORTED_ENCRYPTION","message":"Use nip44"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + // pay_invoice errors go to PayInvoiceErrorResponse for backward compat + assertIs(response) + } + + // --- Null/missing result --- + + @Test + fun testResponseWithNoResultOrError() { + val json = """{"result_type":"pay_invoice"}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + // Should still deserialize since result_type is present + assertIs(response) + assertNull(response.result) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt new file mode 100644 index 000000000..801058935 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt @@ -0,0 +1,138 @@ +/* + * 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.nip47WalletConnect.tags.EncryptionTag +import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TagsTest { + // --- EncryptionTag --- + + @Test + fun testEncryptionTagParse() { + val tag = arrayOf("encryption", "nip44_v2", "nip04") + val result = EncryptionTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("nip44_v2", "nip04"), result) + } + + @Test + fun testEncryptionTagParseSingleScheme() { + val tag = arrayOf("encryption", "nip44_v2") + val result = EncryptionTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("nip44_v2"), result) + } + + @Test + fun testEncryptionTagParseWrongTagName() { + val tag = arrayOf("other", "nip44_v2") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagParseTooShort() { + val tag = arrayOf("encryption") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagParseEmptyValue() { + val tag = arrayOf("encryption", "") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagAssemble() { + val tag = EncryptionTag.assemble(listOf("nip44_v2", "nip04")) + assertEquals("encryption", tag[0]) + assertEquals("nip44_v2", tag[1]) + assertEquals("nip04", tag[2]) + assertEquals(3, tag.size) + } + + @Test + fun testEncryptionTagIsTag() { + assertTrue(EncryptionTag.isTag(arrayOf("encryption", "nip44_v2"))) + assertFalse(EncryptionTag.isTag(arrayOf("other", "nip44_v2"))) + assertFalse(EncryptionTag.isTag(arrayOf("encryption"))) + assertFalse(EncryptionTag.isTag(arrayOf("encryption", ""))) + } + + // --- NotificationsTag --- + + @Test + fun testNotificationsTagParse() { + val tag = arrayOf("notifications", "payment_received", "payment_sent") + val result = NotificationsTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("payment_received", "payment_sent"), result) + } + + @Test + fun testNotificationsTagParseSingleType() { + val tag = arrayOf("notifications", "payment_received") + val result = NotificationsTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("payment_received"), result) + } + + @Test + fun testNotificationsTagParseWrongTagName() { + val tag = arrayOf("other", "payment_received") + val result = NotificationsTag.parse(tag) + assertNull(result) + } + + @Test + fun testNotificationsTagParseTooShort() { + val tag = arrayOf("notifications") + val result = NotificationsTag.parse(tag) + assertNull(result) + } + + @Test + fun testNotificationsTagAssemble() { + val tag = NotificationsTag.assemble(listOf("payment_received", "payment_sent", "hold_invoice_accepted")) + assertEquals("notifications", tag[0]) + assertEquals("payment_received", tag[1]) + assertEquals("payment_sent", tag[2]) + assertEquals("hold_invoice_accepted", tag[3]) + assertEquals(4, tag.size) + } + + @Test + fun testNotificationsTagIsTag() { + assertTrue(NotificationsTag.isTag(arrayOf("notifications", "payment_received"))) + assertFalse(NotificationsTag.isTag(arrayOf("other", "payment_received"))) + assertFalse(NotificationsTag.isTag(arrayOf("notifications"))) + assertFalse(NotificationsTag.isTag(arrayOf("notifications", ""))) + } +} From 9e4cb446efdc06d29ff6ec7a3762628c2816ba4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 02:58:40 +0000 Subject: [PATCH 03/14] feat: add NIP-47 get_budget, sign_message, create_connection methods and Alby Hub interop - Add get_budget, sign_message, create_connection request/response types - Add NwcTransactionType (incoming/outgoing) and NwcTransactionState (PENDING/SETTLED/FAILED/ACCEPTED) constants - Add missing error codes: BAD_REQUEST, NOT_FOUND, EXPIRED - Add settle_deadline field to NwcTransaction - Add total_count to ListTransactionsResult - Add metadata and lud16 to GetInfoResult - Add unpaid_outgoing and unpaid_incoming to ListTransactionsParams - Update Jackson deserializers for new method types - Add AlbyInteropTest with 25+ tests verifying compatibility with Alby Hub's JSON formats for all request/response types - Update existing tests for new constants and error codes https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../quartz/nip47WalletConnect/NwcErrorCode.kt | 3 + .../quartz/nip47WalletConnect/NwcMethod.kt | 3 + .../nip47WalletConnect/NwcTransaction.kt | 13 + .../quartz/nip47WalletConnect/Request.kt | 56 +++ .../quartz/nip47WalletConnect/Response.kt | 34 ++ .../nip47WalletConnect/AlbyInteropTest.kt | 325 ++++++++++++++++++ .../nip47WalletConnect/NwcMethodTest.kt | 22 +- .../quartz/nip47WalletConnect/RequestTest.kt | 91 +++++ .../quartz/nip47WalletConnect/ResponseTest.kt | 111 ++++++ .../jackson/RequestDeserializer.kt | 6 + .../jackson/ResponseDeserializer.kt | 6 + 11 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt index f88fd1892..cef350602 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt @@ -30,6 +30,9 @@ enum class NwcErrorCode { UNAUTHORIZED, INTERNAL, UNSUPPORTED_ENCRYPTION, + BAD_REQUEST, + NOT_FOUND, + EXPIRED, OTHER, } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt index e6195639a..afc6474ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt @@ -28,6 +28,9 @@ object NwcMethod { const val LIST_TRANSACTIONS = "list_transactions" const val GET_BALANCE = "get_balance" const val GET_INFO = "get_info" + const val GET_BUDGET = "get_budget" + const val SIGN_MESSAGE = "sign_message" + const val CREATE_CONNECTION = "create_connection" const val MAKE_HOLD_INVOICE = "make_hold_invoice" const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice" const val SETTLE_HOLD_INVOICE = "settle_hold_invoice" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt index d2a043f59..9ba84991e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -20,6 +20,18 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +object NwcTransactionType { + const val INCOMING = "incoming" + const val OUTGOING = "outgoing" +} + +object NwcTransactionState { + const val PENDING = "PENDING" + const val SETTLED = "SETTLED" + const val FAILED = "FAILED" + const val ACCEPTED = "ACCEPTED" +} + class NwcTransaction( var type: String? = null, var state: String? = null, @@ -33,6 +45,7 @@ class NwcTransaction( var created_at: Long? = null, var expires_at: Long? = null, var settled_at: Long? = null, + var settle_deadline: Long? = null, var metadata: Any? = null, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index 8d0ea548b..cdce4c7ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -113,6 +113,8 @@ class ListTransactionsParams( var limit: Int? = null, var offset: Int? = null, var unpaid: Boolean? = null, + var unpaid_outgoing: Boolean? = null, + var unpaid_incoming: Boolean? = null, var type: String? = null, ) @@ -198,3 +200,57 @@ class SettleHoldInvoiceMethod( fun create(preimage: String): SettleHoldInvoiceMethod = SettleHoldInvoiceMethod(SettleHoldInvoiceParams(preimage)) } } + +// get_budget +class GetBudgetMethod : Request(NwcMethod.GET_BUDGET) { + companion object { + fun create(): GetBudgetMethod = GetBudgetMethod() + } +} + +// sign_message +class SignMessageParams( + var message: String? = null, +) + +class SignMessageMethod( + var params: SignMessageParams? = null, +) : Request(NwcMethod.SIGN_MESSAGE) { + companion object { + fun create(message: String): SignMessageMethod = SignMessageMethod(SignMessageParams(message)) + } +} + +// create_connection +class CreateConnectionParams( + var pubkey: String? = null, + var name: String? = null, + var request_methods: List? = null, + var notification_types: List? = null, + var max_amount: Long? = null, + var budget_renewal: String? = null, + var expires_at: Long? = null, + var isolated: Boolean? = null, + var metadata: Any? = null, +) + +class CreateConnectionMethod( + var params: CreateConnectionParams? = null, +) : Request(NwcMethod.CREATE_CONNECTION) { + companion object { + fun create( + pubkey: String, + name: String, + requestMethods: List? = null, + notificationTypes: List? = null, + maxAmount: Long? = null, + budgetRenewal: String? = null, + expiresAt: Long? = null, + isolated: Boolean? = null, + metadata: Any? = null, + ): CreateConnectionMethod = + CreateConnectionMethod( + CreateConnectionParams(pubkey, name, requestMethods, notificationTypes, maxAmount, budgetRenewal, expiresAt, isolated, metadata), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt index 5c4d3d6a0..623f0cee6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt @@ -79,6 +79,7 @@ class ListTransactionsSuccessResponse( ) : Response(NwcMethod.LIST_TRANSACTIONS) { class ListTransactionsResult( val transactions: List? = null, + val total_count: Long? = null, ) } @@ -104,6 +105,8 @@ class GetInfoSuccessResponse( val block_hash: String? = null, val methods: List? = null, val notifications: List? = null, + val metadata: Any? = null, + val lud16: String? = null, ) } @@ -121,3 +124,34 @@ class CancelHoldInvoiceSuccessResponse( class SettleHoldInvoiceSuccessResponse( val result: Any? = null, ) : Response(NwcMethod.SETTLE_HOLD_INVOICE) + +// get_budget success response +class GetBudgetSuccessResponse( + val result: GetBudgetResult? = null, +) : Response(NwcMethod.GET_BUDGET) { + class GetBudgetResult( + val used_budget: Long? = null, + val total_budget: Long? = null, + val renews_at: Long? = null, + val renewal_period: String? = null, + ) +} + +// sign_message success response +class SignMessageSuccessResponse( + val result: SignMessageResult? = null, +) : Response(NwcMethod.SIGN_MESSAGE) { + class SignMessageResult( + val message: String? = null, + val signature: String? = null, + ) +} + +// create_connection success response +class CreateConnectionSuccessResponse( + val result: CreateConnectionResult? = null, +) : Response(NwcMethod.CREATE_CONNECTION) { + class CreateConnectionResult( + val wallet_pubkey: String? = null, + ) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt new file mode 100644 index 000000000..babb67d67 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -0,0 +1,325 @@ +/* + * 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.OptimizedJsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Interoperability tests using JSON structures matching Alby Hub's NIP-47 implementation. + * These tests verify that Amethyst can correctly parse responses from Alby Hub wallets. + */ +class AlbyInteropTest { + // --- Alby Hub pay_invoice response format --- + + @Test + fun testAlbyPayInvoiceSuccess() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"6565656565656565656565656565656565656565656565656565656565656565","fees_paid":1}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("6565656565656565656565656565656565656565656565656565656565656565", response.result?.preimage) + assertEquals(1L, response.result?.fees_paid) + } + + @Test + fun testAlbyPayInvoiceInsufficientBalance() { + val json = """{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE","message":"insufficient funds available to send"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, response.error?.code) + assertEquals("insufficient funds available to send", response.error?.message) + } + + @Test + fun testAlbyPayInvoiceBadRequest() { + val json = """{"result_type":"pay_invoice","error":{"code":"BAD_REQUEST","message":"Failed to decode bolt11 invoice: bolt11 too short"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.BAD_REQUEST, response.error?.code) + } + + // --- Alby Hub get_balance response format --- + + @Test + fun testAlbyGetBalanceResponse() { + val json = """{"result_type":"get_balance","result":{"balance":21000000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(21000000L, response.result?.balance) + } + + // --- Alby Hub get_info response format (with extended fields) --- + + @Test + fun testAlbyGetInfoFullResponse() { + val json = + """{"result_type":"get_info","result":{"alias":"AlbyHub","color":"#3399ff","pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","network":"mainnet","block_height":800000,"block_hash":"00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f2e4b10","methods":["pay_invoice","pay_keysend","get_balance","get_budget","get_info","make_invoice","lookup_invoice","list_transactions","sign_message"],"notifications":["payment_received","payment_sent"],"lud16":"satoshi@getalby.com"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val result = response.result + assertNotNull(result) + assertEquals("AlbyHub", result.alias) + assertEquals("#3399ff", result.color) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", result.pubkey) + assertEquals("mainnet", result.network) + assertEquals(800000L, result.block_height) + assertEquals("00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f2e4b10", result.block_hash) + assertEquals(9, result.methods?.size) + assertEquals(2, result.notifications?.size) + assertEquals("satoshi@getalby.com", result.lud16) + } + + // --- Alby Hub get_budget response format --- + + @Test + fun testAlbyGetBudgetWithRenewal() { + val json = """{"result_type":"get_budget","result":{"used_budget":50000,"total_budget":100000,"renews_at":1700000000,"renewal_period":"monthly"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val result = response.result + assertNotNull(result) + assertEquals(50000L, result.used_budget) + assertEquals(100000L, result.total_budget) + assertEquals(1700000000L, result.renews_at) + assertEquals("monthly", result.renewal_period) + } + + @Test + fun testAlbyGetBudgetUnlimited() { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":0,"renewal_period":"never"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.used_budget) + assertEquals(0L, response.result?.total_budget) + assertNull(response.result?.renews_at) + assertEquals("never", response.result?.renewal_period) + } + + // --- Alby Hub make_invoice response format --- + + @Test + fun testAlbyMakeInvoiceResponse() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","state":"PENDING","invoice":"lnbc10n1pj3xyz...","description":"Test invoice","payment_hash":"abc123def456","amount":1000,"fees_paid":0,"created_at":1693876497,"expires_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.PENDING, txn.state) + assertEquals("lnbc10n1pj3xyz...", txn.invoice) + assertEquals("Test invoice", txn.description) + assertEquals("abc123def456", txn.payment_hash) + assertEquals(1000L, txn.amount) + assertEquals(0L, txn.fees_paid) + } + + // --- Alby Hub lookup_invoice with settled state --- + + @Test + fun testAlbyLookupInvoiceSettled() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"SETTLED","invoice":"lnbc50n1...","preimage":"preimage123","payment_hash":"hash456","amount":5000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals("preimage123", txn.preimage) + assertEquals(1694876500L, txn.settled_at) + } + + @Test + fun testAlbyLookupInvoiceNotFound() { + val json = """{"result_type":"lookup_invoice","error":{"code":"NOT_FOUND","message":"transaction not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.NOT_FOUND, response.error?.code) + } + + // --- Alby Hub list_transactions with total_count --- + + @Test + fun testAlbyListTransactionsWithTotalCount() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"SETTLED","invoice":"lnbc1...","payment_hash":"h1","amount":1000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500},{"type":"outgoing","state":"SETTLED","invoice":"lnbc2...","payment_hash":"h2","amount":2000,"fees_paid":10,"created_at":1693876400,"settled_at":1694876400}],"total_count":100}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(2, response.result?.transactions?.size) + assertEquals(100L, response.result?.total_count) + + val first = response.result?.transactions?.get(0) + assertEquals(NwcTransactionType.INCOMING, first?.type) + assertEquals(NwcTransactionState.SETTLED, first?.state) + + val second = response.result?.transactions?.get(1) + assertEquals(NwcTransactionType.OUTGOING, second?.type) + assertEquals(10L, second?.fees_paid) + } + + // --- Alby Hub sign_message response --- + + @Test + fun testAlbySignMessageResponse() { + val json = """{"result_type":"sign_message","result":{"message":"Hello Nostr","signature":"3045022100..."}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("Hello Nostr", response.result?.message) + assertEquals("3045022100...", response.result?.signature) + } + + // --- Alby Hub create_connection response --- + + @Test + fun testAlbyCreateConnectionResponse() { + val json = """{"result_type":"create_connection","result":{"wallet_pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", response.result?.wallet_pubkey) + } + + // --- Alby Hub notification formats --- + + @Test + fun testAlbyPaymentReceivedNotification() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","state":"SETTLED","invoice":"lnbc50n1...","preimage":"pre123","payment_hash":"hash123","amount":5000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val txn = notification.notification + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals(5000L, txn.amount) + } + + @Test + fun testAlbyPaymentSentNotification() { + val json = + """{"notification_type":"payment_sent","notification":{"type":"outgoing","state":"SETTLED","invoice":"lnbc100n1...","preimage":"pre456","payment_hash":"hash456","amount":10000,"fees_paid":10,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val txn = notification.notification + assertNotNull(txn) + assertEquals(NwcTransactionType.OUTGOING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals(10000L, txn.amount) + assertEquals(10L, txn.fees_paid) + } + + @Test + fun testAlbyHoldInvoiceAcceptedNotification() { + val json = + """{"notification_type":"hold_invoice_accepted","notification":{"type":"incoming","invoice":"lnbc200n1...","payment_hash":"hash789","amount":20000,"created_at":1693876497,"expires_at":1694876497,"settle_deadline":800000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val data = notification.notification + assertNotNull(data) + assertEquals("incoming", data.type) + assertEquals(20000L, data.amount) + assertEquals(800000L, data.settle_deadline) + } + + // --- Alby Hub error code interop --- + + @Test + fun testAlbyRestricted() { + val json = """{"result_type":"pay_invoice","error":{"code":"RESTRICTED","message":"This app does not have the pay_invoice scope"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.RESTRICTED, response.error?.code) + } + + @Test + fun testAlbyExpiredConnection() { + val json = """{"result_type":"get_balance","error":{"code":"EXPIRED","message":"This app has expired"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.EXPIRED, response.error?.code) + } + + @Test + fun testAlbyQuotaExceeded() { + val json = """{"result_type":"pay_invoice","error":{"code":"QUOTA_EXCEEDED","message":"Exceeded budget limit"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, response.error?.code) + } + + // --- Alby Hub request format interop --- + + @Test + fun testAlbyPayInvoiceRequestFormat() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc10u1pj3xyz..."}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc10u1pj3xyz...", request.params?.invoice) + } + + @Test + fun testAlbyGetBudgetRequestFormat() { + val json = """{"method":"get_budget","params":{}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + @Test + fun testAlbySignMessageRequestFormat() { + val json = """{"method":"sign_message","params":{"message":"Hello from Amethyst"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Hello from Amethyst", request.params?.message) + } + + @Test + fun testAlbyCreateConnectionRequestFormat() { + val json = + """{"method":"create_connection","params":{"pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","name":"Amethyst","request_methods":["pay_invoice","get_balance","get_info","make_invoice","lookup_invoice","list_transactions"],"notification_types":["payment_received","payment_sent"],"max_amount":100000,"budget_renewal":"monthly","isolated":false}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", request.params?.pubkey) + assertEquals("Amethyst", request.params?.name) + assertEquals(6, request.params?.request_methods?.size) + assertEquals(2, request.params?.notification_types?.size) + assertEquals(100000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + assertEquals(false, request.params?.isolated) + } + + // --- Transaction with settle_deadline from Alby hold invoice --- + + @Test + fun testAlbyMakeHoldInvoiceWithSettleDeadline() { + val json = + """{"result_type":"make_hold_invoice","result":{"type":"incoming","state":"PENDING","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"expires_at":2000,"settle_deadline":144}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(144L, txn.settle_deadline) + assertEquals(NwcTransactionState.PENDING, txn.state) + } +} 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 f088a2dbd..4d76bb926 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -33,6 +33,9 @@ class NwcMethodTest { assertEquals("list_transactions", NwcMethod.LIST_TRANSACTIONS) assertEquals("get_balance", NwcMethod.GET_BALANCE) assertEquals("get_info", NwcMethod.GET_INFO) + assertEquals("get_budget", NwcMethod.GET_BUDGET) + assertEquals("sign_message", NwcMethod.SIGN_MESSAGE) + assertEquals("create_connection", NwcMethod.CREATE_CONNECTION) assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE) assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE) assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE) @@ -48,7 +51,7 @@ class NwcMethodTest { @Test fun testErrorCodeValues() { val codes = NwcErrorCode.entries - assertEquals(10, codes.size) + assertEquals(13, 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")) @@ -58,9 +61,26 @@ class NwcMethodTest { assertEquals(NwcErrorCode.UNAUTHORIZED, NwcErrorCode.valueOf("UNAUTHORIZED")) assertEquals(NwcErrorCode.INTERNAL, NwcErrorCode.valueOf("INTERNAL")) assertEquals(NwcErrorCode.UNSUPPORTED_ENCRYPTION, NwcErrorCode.valueOf("UNSUPPORTED_ENCRYPTION")) + assertEquals(NwcErrorCode.BAD_REQUEST, NwcErrorCode.valueOf("BAD_REQUEST")) + assertEquals(NwcErrorCode.NOT_FOUND, NwcErrorCode.valueOf("NOT_FOUND")) + assertEquals(NwcErrorCode.EXPIRED, NwcErrorCode.valueOf("EXPIRED")) assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER")) } + @Test + fun testTransactionTypeConstants() { + assertEquals("incoming", NwcTransactionType.INCOMING) + assertEquals("outgoing", NwcTransactionType.OUTGOING) + } + + @Test + fun testTransactionStateConstants() { + assertEquals("PENDING", NwcTransactionState.PENDING) + assertEquals("SETTLED", NwcTransactionState.SETTLED) + assertEquals("FAILED", NwcTransactionState.FAILED) + assertEquals("ACCEPTED", NwcTransactionState.ACCEPTED) + } + @Test fun testNwcError() { val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed") 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 630f7addf..3299b0927 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -288,6 +288,97 @@ class RequestTest { assertEquals("preimage_xyz", request.params?.preimage) } + // --- GetBudget --- + + @Test + fun testGetBudgetCreate() { + val request = GetBudgetMethod.create() + assertEquals(NwcMethod.GET_BUDGET, request.method) + } + + @Test + fun testGetBudgetSerialization() { + val request = GetBudgetMethod.create() + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"get_budget\"")) + } + + @Test + fun testGetBudgetDeserialization() { + val json = """{"method":"get_budget"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- SignMessage --- + + @Test + fun testSignMessageCreate() { + val request = SignMessageMethod.create("Hello Nostr") + assertEquals(NwcMethod.SIGN_MESSAGE, request.method) + assertEquals("Hello Nostr", request.params?.message) + } + + @Test + fun testSignMessageSerialization() { + val request = SignMessageMethod.create("test message") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"sign_message\"")) + assertTrue(json.contains("\"message\":\"test message\"")) + } + + @Test + fun testSignMessageDeserialization() { + val json = """{"method":"sign_message","params":{"message":"Hello Nostr"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Hello Nostr", request.params?.message) + } + + // --- CreateConnection --- + + @Test + fun testCreateConnectionCreate() { + val request = + CreateConnectionMethod.create( + pubkey = "abc123", + name = "My App", + requestMethods = listOf("pay_invoice", "get_balance"), + notificationTypes = listOf("payment_received"), + maxAmount = 100000L, + budgetRenewal = "monthly", + ) + assertEquals(NwcMethod.CREATE_CONNECTION, request.method) + assertEquals("abc123", request.params?.pubkey) + assertEquals("My App", request.params?.name) + assertEquals(listOf("pay_invoice", "get_balance"), request.params?.request_methods) + assertEquals(listOf("payment_received"), request.params?.notification_types) + assertEquals(100000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + } + + @Test + fun testCreateConnectionSerialization() { + val request = CreateConnectionMethod.create(pubkey = "abc123", name = "My App") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"create_connection\"")) + assertTrue(json.contains("\"pubkey\":\"abc123\"")) + assertTrue(json.contains("\"name\":\"My App\"")) + } + + @Test + fun testCreateConnectionDeserialization() { + val json = + """{"method":"create_connection","params":{"pubkey":"abc123","name":"Test App","request_methods":["pay_invoice"],"max_amount":50000,"budget_renewal":"monthly"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.pubkey) + assertEquals("Test App", request.params?.name) + assertEquals(listOf("pay_invoice"), request.params?.request_methods) + assertEquals(50000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + } + // --- Unknown method --- @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 6bf160d6a..148483360 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -255,6 +255,117 @@ class ResponseTest { assertIs(response) } + // --- GetBudget Success --- + + @Test + fun testGetBudgetSuccessDeserialization() { + val json = """{"result_type":"get_budget","result":{"used_budget":50000,"total_budget":100000,"renews_at":1700000000,"renewal_period":"monthly"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals(50000L, response.result?.used_budget) + assertEquals(100000L, response.result?.total_budget) + assertEquals(1700000000L, response.result?.renews_at) + assertEquals("monthly", response.result?.renewal_period) + } + + @Test + fun testGetBudgetNoBudgetLimit() { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":0,"renewal_period":"never"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.used_budget) + assertEquals(0L, response.result?.total_budget) + assertNull(response.result?.renews_at) + assertEquals("never", response.result?.renewal_period) + } + + // --- SignMessage Success --- + + @Test + fun testSignMessageSuccessDeserialization() { + val json = """{"result_type":"sign_message","result":{"message":"Hello Nostr","signature":"sig123abc"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("Hello Nostr", response.result?.message) + assertEquals("sig123abc", response.result?.signature) + } + + // --- CreateConnection Success --- + + @Test + fun testCreateConnectionSuccessDeserialization() { + val json = """{"result_type":"create_connection","result":{"wallet_pubkey":"walletpub123"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("walletpub123", response.result?.wallet_pubkey) + } + + // --- GetInfo with extended fields --- + + @Test + fun testGetInfoWithMetadataAndLud16() { + val json = + """{"result_type":"get_info","result":{"alias":"AlbyHub","methods":["pay_invoice","get_balance"],"notifications":["payment_received"],"lud16":"user@getalby.com"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("AlbyHub", response.result?.alias) + assertEquals("user@getalby.com", response.result?.lud16) + assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods) + } + + // --- ListTransactions with total_count --- + + @Test + fun testListTransactionsWithTotalCount() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","amount":100,"created_at":1000}],"total_count":42}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(1, response.result?.transactions?.size) + assertEquals(42L, response.result?.total_count) + } + + // --- Transaction with settle_deadline --- + + @Test + fun testTransactionWithSettleDeadline() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"ACCEPTED","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settle_deadline":800000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(800000L, response.result?.settle_deadline) + assertEquals("ACCEPTED", response.result?.state) + } + + // --- Error responses for new error codes --- + + @Test + fun testBadRequestError() { + val json = """{"result_type":"pay_invoice","error":{"code":"BAD_REQUEST","message":"Invalid invoice"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.BAD_REQUEST, response.error?.code) + } + + @Test + fun testNotFoundError() { + val json = """{"result_type":"lookup_invoice","error":{"code":"NOT_FOUND","message":"Invoice not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.NOT_FOUND, response.error?.code) + } + + @Test + fun testExpiredError() { + val json = """{"result_type":"pay_invoice","error":{"code":"EXPIRED","message":"Connection expired"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.EXPIRED, response.error?.code) + } + // --- Null/missing result --- @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 4098a5dc9..07e6eac96 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 @@ -25,7 +25,9 @@ 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.CreateConnectionMethod import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod @@ -36,6 +38,7 @@ 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.nip47WalletConnect.SignMessageMethod import com.vitorpamplona.quartz.utils.asTextOrNull class RequestDeserializer : StdDeserializer(Request::class.java) { @@ -54,6 +57,9 @@ class RequestDeserializer : StdDeserializer(Request::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.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetMethod::class.java) + NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageMethod::class.java) + NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionMethod::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) 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 72e7685fb..c9b210ad9 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 @@ -25,7 +25,9 @@ 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.CreateConnectionSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse @@ -39,6 +41,7 @@ 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.nip47WalletConnect.SignMessageSuccessResponse import com.vitorpamplona.quartz.utils.asTextOrNull class ResponseDeserializer : StdDeserializer(Response::class.java) { @@ -70,6 +73,9 @@ class ResponseDeserializer : StdDeserializer(Response::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.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java) + NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java) + NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::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) From 4ee937ff0c1821caf7ffa8d6501446cb40e7021a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 03:00:56 +0000 Subject: [PATCH 04/14] test: add Alby Hub real test vectors to interop tests Add tests using exact JSON payloads from Alby Hub's test suite: - Real bolt11 invoice strings from Alby's mock data - pay_keysend with TLV records and preimage - make_invoice with nested metadata objects - make_hold_invoice with 64-char payment hash - settle_hold_invoice with preimage - list_transactions with unpaid_outgoing filter - create_connection with isolated=true https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../nip47WalletConnect/AlbyInteropTest.kt | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index babb67d67..8fea0211b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -309,6 +309,88 @@ class AlbyInteropTest { assertEquals(false, request.params?.isolated) } + // --- Alby Hub real bolt11 test vectors --- + + @Test + fun testAlbyRealBolt11PayInvoiceRequest() { + val json = + """{"method":"pay_invoice","params":{"invoice":"lntbs1230n1pnkqautdqyw3jsnp4q09a0z84kg4a2m38zjllw43h953fx5zvqe8qxfgw694ymkq26u8zcpp5yvnh6hsnlnj4xnuh2trzlnunx732dv8ta2wjr75pdfxf6p2vlyassp5hyeg97a3ft5u769kjwsn7p0e85h79pzz8kladmnqhpcypz2uawjs9qyysgqcqpcxq8zals8sq9yeg2pa9eywkgj50cyzxd5elatujuc0c0wh6j9nat5mn34pgk8u9ufpgs99tw9ldlfk42cqlkr48au3lmuh09269prg4qkggh4a8cyqpfl0y6j","metadata":{"a":123}}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertNotNull(request.params?.invoice) + assertNotNull(request.params?.metadata) + } + + @Test + fun testAlbyPayKeysendWithTlvRecords() { + val json = + """{"method":"pay_keysend","params":{"amount":123000,"pubkey":"123pubkey2","preimage":"018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b","tlv_records":[{"type":5482373484,"value":"fajsn341414fq"}]}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(123000L, request.params?.amount) + assertEquals("123pubkey2", request.params?.pubkey) + assertEquals("018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b", request.params?.preimage) + assertNotNull(request.params?.tlv_records) + assertEquals(1, request.params?.tlv_records?.size) + assertEquals(5482373484L, request.params?.tlv_records?.first()?.type) + assertEquals("fajsn341414fq", request.params?.tlv_records?.first()?.value) + } + + @Test + fun testAlbyMakeInvoiceWithNestedMetadata() { + val json = + """{"method":"make_invoice","params":{"amount":1000,"description":"Hello, world","expiry":3600,"metadata":{"a":1,"b":"2","c":{"d":3,"e":[{"f":"g"},{"h":"i"}]}}}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("Hello, world", request.params?.description) + assertEquals(3600L, request.params?.expiry) + assertNotNull(request.params?.metadata) + } + + @Test + fun testAlbyMakeHoldInvoiceWithPaymentHash() { + val json = + """{"method":"make_hold_invoice","params":{"amount":1000,"description":"Hello, world","payment_hash":"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","expiry":3600}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", request.params?.payment_hash) + assertEquals("Hello, world", request.params?.description) + } + + @Test + fun testAlbySettleHoldInvoiceWithPreimage() { + val json = + """{"method":"settle_hold_invoice","params":{"preimage":"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", request.params?.preimage) + } + + @Test + fun testAlbyListTransactionsWithUnpaidFilters() { + val json = """{"method":"list_transactions","params":{"from":0,"until":0,"limit":10,"offset":0,"unpaid_outgoing":true}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(10, request.params?.limit) + assertEquals(true, request.params?.unpaid_outgoing) + } + + @Test + fun testAlbyCreateConnectionIsolated() { + val json = + """{"method":"create_connection","params":{"pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","name":"Test 123","request_methods":["get_info","pay_invoice"],"notification_types":["payment_received"],"max_amount":100000000,"budget_renewal":"monthly","isolated":true}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Test 123", request.params?.name) + assertEquals(true, request.params?.isolated) + assertEquals(100000000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + assertEquals(listOf("get_info", "pay_invoice"), request.params?.request_methods) + assertEquals(listOf("payment_received"), request.params?.notification_types) + } + // --- Transaction with settle_deadline from Alby hold invoice --- @Test From 83a6feeca40b3b2c31a363487e8eec9e8c53c7c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 03:28:17 +0000 Subject: [PATCH 05/14] feat: add Alby JS SDK client interop improvements - Add NwcBudgetRenewal constants (daily/weekly/monthly/yearly/never) - Add case-insensitive NwcTransactionState helpers (isSettled, isPending, isFailed, isAccepted) for JS SDK lowercase state interop - Add URI test using Alby JS SDK test vector (69effe7b... pubkey) - Add JS SDK interop tests: lowercase transaction states in responses, notifications, and list_transactions; empty get_budget response; all budget renewal periods; structured transaction metadata; full 13-method get_info response https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../nip47WalletConnect/NwcTransaction.kt | 16 +++ .../nip47WalletConnect/AlbyInteropTest.kt | 101 +++++++++++++++++- .../Nip47WalletConnectTest.kt | 14 +++ .../nip47WalletConnect/NwcMethodTest.kt | 26 +++++ 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt index 9ba84991e..067f12cd9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -30,6 +30,22 @@ object NwcTransactionState { const val SETTLED = "SETTLED" const val FAILED = "FAILED" const val ACCEPTED = "ACCEPTED" + + fun isSettled(state: String?) = state.equals(SETTLED, ignoreCase = true) + + fun isPending(state: String?) = state.equals(PENDING, ignoreCase = true) + + fun isFailed(state: String?) = state.equals(FAILED, ignoreCase = true) + + fun isAccepted(state: String?) = state.equals(ACCEPTED, ignoreCase = true) +} + +object NwcBudgetRenewal { + const val DAILY = "daily" + const val WEEKLY = "weekly" + const val MONTHLY = "monthly" + const val YEARLY = "yearly" + const val NEVER = "never" } class NwcTransaction( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index 8fea0211b..c201ab285 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -26,10 +26,12 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue /** - * Interoperability tests using JSON structures matching Alby Hub's NIP-47 implementation. - * These tests verify that Amethyst can correctly parse responses from Alby Hub wallets. + * Interoperability tests using JSON structures matching Alby Hub (server) + * and Alby JS SDK (client) NIP-47 implementations. + * These tests verify that Amethyst can correctly parse responses from Alby wallets. */ class AlbyInteropTest { // --- Alby Hub pay_invoice response format --- @@ -404,4 +406,99 @@ class AlbyInteropTest { assertEquals(144L, txn.settle_deadline) assertEquals(NwcTransactionState.PENDING, txn.state) } + + // =================================================================== + // Alby JS SDK (client) interop tests + // The JS SDK uses lowercase transaction states while Hub uses uppercase. + // Both formats must be handled correctly. + // =================================================================== + + @Test + fun testJsSdkLowercaseSettledState() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("settled", response.result?.state) + assertTrue(NwcTransactionState.isSettled(response.result?.state)) + } + + @Test + fun testJsSdkLowercasePendingState() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","state":"pending","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("pending", response.result?.state) + assertTrue(NwcTransactionState.isPending(response.result?.state)) + } + + @Test + fun testJsSdkLowercaseStatesInListTransactions() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"settled","amount":1000,"created_at":1000},{"type":"outgoing","state":"failed","amount":2000,"created_at":2000},{"type":"incoming","state":"accepted","amount":3000,"created_at":3000}],"total_count":3}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txns = response.result?.transactions + assertNotNull(txns) + assertEquals(3, txns.size) + assertTrue(NwcTransactionState.isSettled(txns[0].state)) + assertTrue(NwcTransactionState.isFailed(txns[1].state)) + assertTrue(NwcTransactionState.isAccepted(txns[2].state)) + } + + @Test + fun testJsSdkLowercaseStatesInNotification() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","state":"settled","invoice":"lnbc...","amount":5000,"created_at":1000,"settled_at":2000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertTrue(NwcTransactionState.isSettled(notification.notification?.state)) + } + + @Test + fun testJsSdkEmptyGetBudgetResponse() { + // JS SDK allows get_budget to return empty object when no budget is set + val json = """{"result_type":"get_budget","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNull(response.result?.used_budget) + assertNull(response.result?.total_budget) + assertNull(response.result?.renews_at) + assertNull(response.result?.renewal_period) + } + + @Test + fun testJsSdkGetBudgetWithAllRenewalPeriods() { + for (period in listOf("daily", "weekly", "monthly", "yearly", "never")) { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":100000,"renewal_period":"$period"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(period, response.result?.renewal_period) + } + } + + @Test + fun testJsSdkTransactionWithMetadata() { + // JS SDK supports structured metadata with comment, payer_data, nostr fields + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Thanks!","payer_data":{"name":"Alice","pubkey":"abc123"},"nostr":{"pubkey":"npub1...","tags":[["p","def456"]]}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.metadata) + } + + @Test + fun testJsSdkGetInfoWithAllMethods() { + // JS SDK advertises all 13 single methods + notifications + val json = + """{"result_type":"get_info","result":{"alias":"TestNode","methods":["get_info","get_balance","get_budget","make_invoice","pay_invoice","pay_keysend","lookup_invoice","list_transactions","sign_message","create_connection","make_hold_invoice","settle_hold_invoice","cancel_hold_invoice"],"notifications":["payment_received","payment_sent","hold_invoice_accepted"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(13, response.result?.methods?.size) + assertEquals(3, response.result?.notifications?.size) + assertTrue(response.result?.methods?.contains(NwcMethod.GET_BUDGET) == true) + assertTrue(response.result?.methods?.contains(NwcMethod.SIGN_MESSAGE) == true) + assertTrue(response.result?.methods?.contains(NwcMethod.CREATE_CONNECTION) == true) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt index c31c376d7..825069de6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt @@ -93,6 +93,20 @@ class Nip47WalletConnectTest { } } + // --- Alby JS SDK URI test vector --- + + @Test + fun testParseAlbyJsSdkUri() { + // Test vector from @getalby/js-sdk NWCClient.test.ts + val uri = + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss%3A%2F%2Frelay.getalby.com%2Fv1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b&lud16=hello%40getalby.com" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", parsed.pubKeyHex) + assertEquals("e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", parsed.secret) + assertEquals("hello@getalby.com", parsed.lud16) + } + // --- Nip47URI serialization --- @Test 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 4d76bb926..f8806444b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip47WalletConnect import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class NwcMethodTest { @Test @@ -81,6 +83,30 @@ class NwcMethodTest { assertEquals("ACCEPTED", NwcTransactionState.ACCEPTED) } + @Test + fun testTransactionStateCaseInsensitive() { + // Alby Hub uses uppercase, Alby JS SDK uses lowercase + assertTrue(NwcTransactionState.isSettled("SETTLED")) + assertTrue(NwcTransactionState.isSettled("settled")) + assertTrue(NwcTransactionState.isPending("PENDING")) + assertTrue(NwcTransactionState.isPending("pending")) + assertTrue(NwcTransactionState.isFailed("FAILED")) + assertTrue(NwcTransactionState.isFailed("failed")) + assertTrue(NwcTransactionState.isAccepted("ACCEPTED")) + assertTrue(NwcTransactionState.isAccepted("accepted")) + assertFalse(NwcTransactionState.isSettled("pending")) + assertFalse(NwcTransactionState.isSettled(null)) + } + + @Test + fun testBudgetRenewalConstants() { + assertEquals("daily", NwcBudgetRenewal.DAILY) + assertEquals("weekly", NwcBudgetRenewal.WEEKLY) + assertEquals("monthly", NwcBudgetRenewal.MONTHLY) + assertEquals("yearly", NwcBudgetRenewal.YEARLY) + assertEquals("never", NwcBudgetRenewal.NEVER) + } + @Test fun testNwcError() { val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed") From 4b76c70c29fcce8ecd9402afc8c6141c5f23ea86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 03:52:41 +0000 Subject: [PATCH 06/14] feat: add NIP-47 wallet interface with balance, send, receive, and transactions Adds an Alby Go-inspired wallet interface accessible from the left drawer menu. Uses existing NWC connection from zap settings to communicate with the wallet via NIP-47 methods (get_balance, get_info, pay_invoice, make_invoice, list_transactions). New files: - WalletViewModel: manages wallet state and NWC requests - WalletScreen: balance display with send/receive action buttons - WalletSendScreen: paste BOLT-11 invoice and pay - WalletReceiveScreen: create invoice with QR code display - WalletTransactionsScreen: scrollable transaction history Also adds generic sendNwcRequest() to NwcSignerState and Account for sending arbitrary NIP-47 requests beyond just pay_invoice. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../vitorpamplona/amethyst/model/Account.kt | 9 + .../nip47WalletConnect/NwcSignerState.kt | 39 +++ .../amethyst/ui/navigation/AppNavigation.kt | 9 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 8 + .../loggedIn/wallet/WalletReceiveScreen.kt | 268 +++++++++++++++++ .../ui/screen/loggedIn/wallet/WalletScreen.kt | 279 ++++++++++++++++++ .../loggedIn/wallet/WalletSendScreen.kt | 219 ++++++++++++++ .../wallet/WalletTransactionsScreen.kt | 226 ++++++++++++++ .../screen/loggedIn/wallet/WalletViewModel.kt | 264 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 23 ++ 11 files changed, 1353 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 055cb6ae2..ca79bcf40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -171,6 +171,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -581,6 +582,14 @@ class Account( suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) + suspend fun sendNwcRequest( + request: Request, + onResponse: (Response?) -> Unit, + ) { + val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse) + client.send(event, setOf(relay)) + } + suspend fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index d4f012cbb..206f9bbfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -138,6 +138,45 @@ class NwcSignerState( return zapPaymentResponseDecryptionCache.value.decryptResponse(event) } + /** + * Sends a generic NIP-47 request to the connected wallet. + * Subscribes to responses and waits up to 60s for a reply. + * + * @param request the NIP-47 request to send + * @param onResponse callback to handle the response from the wallet + * @return a pair containing the request event and target relay URL + * @throws IllegalArgumentException if no NIP-47 wallet is set up + */ + suspend fun sendNwcRequest( + request: Request, + onResponse: (Response?) -> Unit, + ): Pair { + val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup") + + val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value) + + val filter = + NWCPaymentQueryState( + fromServiceHex = walletService.pubKeyHex, + toUserHex = event.pubKey, + replyingToHex = event.id, + relay = walletService.relayUri, + ) + + nwcFilterAssembler.subscribe(filter) + + scope.launch(Dispatchers.IO) { + delay(60000) + nwcFilterAssembler.unsubscribe(filter) + } + + cache.consume(event, null, true, walletService.relayUri) { + onResponse(decryptResponse(it)) + } + + return Pair(event, walletService.relayUri) + } + /** * Sends a zap payment request to a connected Lightning wallet. * Subscribes to responses and waits up to 60s for a reply. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 2bb646e98..c4d9d37e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -119,6 +119,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -151,6 +155,11 @@ fun AppNavigation( composable { NotificationScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } + composableFromEnd { WalletScreen(accountViewModel, nav) } + composableFromEnd { WalletSendScreen(accountViewModel, nav) } + composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } + composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } + composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 8ca73fb97..a3682c723 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -463,6 +464,14 @@ fun ListContent( route = Route.Drafts, ) + NavigationRow( + title = R.string.wallet, + icon = Icons.Outlined.AccountBalanceWallet, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Wallet, + ) + NavigationRow( title = R.string.route_chess, icon = R.drawable.ic_chess, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b55b4dd06..b7191759c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -43,6 +43,14 @@ sealed class Route { @Serializable object Chess : Route() + @Serializable object Wallet : Route() + + @Serializable object WalletSend : Route() + + @Serializable object WalletReceive : Route() + + @Serializable object WalletTransactions : Route() + @Serializable object Search : Route() @Serializable object SecurityFilters : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt new file mode 100644 index 000000000..c0451ede7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt @@ -0,0 +1,268 @@ +/* + * 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.amethyst.ui.screen.loggedIn.wallet + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.stringRes +import java.text.NumberFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletReceiveScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + } + + DisposableEffect(Unit) { + onDispose { walletViewModel.resetReceiveState() } + } + + val receiveState by walletViewModel.receiveState.collectAsState() + var amountText by remember { mutableStateOf("") } + var descriptionText by remember { mutableStateOf("") } + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_receive)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (val state = receiveState) { + is ReceiveState.Idle -> { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = amountText, + onValueChange = { amountText = it.filter { c -> c.isDigit() } }, + label = { Text(stringRes(R.string.wallet_amount_sats)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = descriptionText, + onValueChange = { descriptionText = it }, + label = { Text(stringRes(R.string.wallet_description)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + val amount = amountText.toLongOrNull() + if (amount != null && amount > 0) { + walletViewModel.createInvoice( + amountSats = amount, + description = descriptionText.ifBlank { null }, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + enabled = amountText.isNotBlank() && (amountText.toLongOrNull() ?: 0L) > 0, + ) { + Text( + stringRes(R.string.wallet_create_invoice), + fontWeight = FontWeight.SemiBold, + ) + } + } + + is ReceiveState.Creating -> { + Spacer(modifier = Modifier.weight(1f)) + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_creating_invoice), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } + + is ReceiveState.Created -> { + Spacer(modifier = Modifier.height(8.dp)) + + val formattedAmount = + remember(state.amount) { + val fmt = NumberFormat.getIntegerInstance() + fmt.format(state.amount) + } + + Text( + text = "$formattedAmount ${stringRes(R.string.wallet_sats)}", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + QrCodeDrawer( + contents = state.invoice, + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = state.invoice, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("invoice", state.invoice)) + }, + modifier = + Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_copy_invoice)) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + is ReceiveState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { walletViewModel.resetReceiveState() }) { + Text(stringRes(R.string.back)) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt new file mode 100644 index 000000000..ef0d94800 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -0,0 +1,279 @@ +/* + * 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.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import java.text.NumberFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + if (!walletViewModel.hasWalletSetup()) { + NoWalletSetup( + modifier = Modifier.padding(padding), + nav = nav, + ) + } else { + WalletHomeContent( + walletViewModel = walletViewModel, + modifier = Modifier.padding(padding), + nav = nav, + ) + } + } +} + +@Composable +private fun NoWalletSetup( + modifier: Modifier, + nav: INav, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringRes(R.string.wallet_no_connection), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringRes(R.string.wallet_no_connection_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { nav.nav(Route.Nip47NWCSetup()) }) { + Text(stringRes(R.string.wallet_setup)) + } + } +} + +@Composable +private fun WalletHomeContent( + walletViewModel: WalletViewModel, + modifier: Modifier, + nav: INav, +) { + val balance by walletViewModel.balanceSats.collectAsState() + val walletAlias by walletViewModel.walletAlias.collectAsState() + val isLoading by walletViewModel.isLoading.collectAsState() + val error by walletViewModel.error.collectAsState() + + LaunchedEffect(Unit) { + walletViewModel.fetchBalance() + walletViewModel.fetchInfo() + } + + Column( + modifier = + modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // Wallet name + if (walletAlias != null) { + Text( + text = walletAlias!!, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Balance display + if (isLoading && balance == null) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } else { + val formattedBalance = + remember(balance) { + val fmt = NumberFormat.getIntegerInstance() + fmt.format(balance ?: 0L) + } + Text( + text = formattedBalance, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringRes(R.string.wallet_sats), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Error + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button( + onClick = { nav.nav(Route.WalletReceive) }, + modifier = + Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.ArrowDownward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_receive), fontWeight = FontWeight.SemiBold) + } + + Button( + onClick = { nav.nav(Route.WalletSend) }, + modifier = + Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_send), fontWeight = FontWeight.SemiBold) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Transactions button + OutlinedButton( + onClick = { nav.nav(Route.WalletTransactions) }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_transactions)) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt new file mode 100644 index 000000000..ca5bd2609 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt @@ -0,0 +1,219 @@ +/* + * 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.amethyst.ui.screen.loggedIn.wallet + +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletSendScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + } + + DisposableEffect(Unit) { + onDispose { walletViewModel.resetSendState() } + } + + val sendState by walletViewModel.sendState.collectAsState() + var invoiceText by remember { mutableStateOf("") } + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_send)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (val state = sendState) { + is SendState.Idle -> { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = invoiceText, + onValueChange = { invoiceText = it }, + label = { Text(stringRes(R.string.wallet_paste_invoice)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + minLines = 3, + maxLines = 5, + trailingIcon = { + IconButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = clipboard.primaryClip + if (clip != null && clip.itemCount > 0) { + invoiceText = clip.getItemAt(0).text?.toString() ?: "" + } + }) { + Icon( + imageVector = Icons.Filled.ContentPaste, + contentDescription = "Paste", + ) + } + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + if (invoiceText.isNotBlank()) { + walletViewModel.sendPayment(invoiceText.trim()) + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + enabled = invoiceText.isNotBlank(), + ) { + Text( + stringRes(R.string.wallet_pay), + fontWeight = FontWeight.SemiBold, + ) + } + } + + is SendState.Sending -> { + Spacer(modifier = Modifier.weight(1f)) + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_payment_sending), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } + + is SendState.Success -> { + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_payment_success), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { nav.popBack() }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Text(stringRes(R.string.back)) + } + Spacer(modifier = Modifier.height(24.dp)) + } + + is SendState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { walletViewModel.resetSendState() }) { + Text(stringRes(R.string.back)) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt new file mode 100644 index 000000000..a7df6653b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -0,0 +1,226 @@ +/* + * 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.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransactionType +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletTransactionsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + walletViewModel.fetchTransactions() + } + + val transactions by walletViewModel.transactions.collectAsState() + val isLoading by walletViewModel.isLoading.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_transactions)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + actions = { + IconButton(onClick = { walletViewModel.fetchTransactions() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringRes(R.string.wallet_refresh), + ) + } + }, + ) + }, + ) { padding -> + if (isLoading && transactions.isEmpty()) { + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_loading), + style = MaterialTheme.typography.bodyLarge, + ) + } + } else if (transactions.isEmpty()) { + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + stringRes(R.string.wallet_no_transactions), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.padding(padding), + ) { + items(transactions) { tx -> + TransactionItem(tx) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun TransactionItem(tx: NwcTransaction) { + val isIncoming = tx.type == NwcTransactionType.INCOMING + val amountSats = (tx.amount ?: 0L) / 1000L + val formattedAmount = + remember(amountSats) { + val fmt = NumberFormat.getIntegerInstance() + (if (isIncoming) "+" else "-") + fmt.format(amountSats) + } + + val dateText = + remember(tx.created_at) { + tx.created_at?.let { + val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) + sdf.format(Date(it * 1000L)) + } ?: "" + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, + contentDescription = + if (isIncoming) { + stringRes(R.string.wallet_incoming) + } else { + stringRes(R.string.wallet_outgoing) + }, + modifier = Modifier.size(24.dp), + tint = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = dateText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = "$formattedAmount sats", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt new file mode 100644 index 000000000..817c296e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -0,0 +1,264 @@ +/* + * 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.amethyst.ui.screen.loggedIn.wallet + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed class SendState { + data object Idle : SendState() + + data object Sending : SendState() + + data class Success( + val preimage: String?, + ) : SendState() + + data class Error( + val message: String, + ) : SendState() +} + +sealed class ReceiveState { + data object Idle : ReceiveState() + + data object Creating : ReceiveState() + + data class Created( + val invoice: String, + val amount: Long, + ) : ReceiveState() + + data class Error( + val message: String, + ) : ReceiveState() +} + +class WalletViewModel : ViewModel() { + private var account: Account? = null + + private val _balanceSats = MutableStateFlow(null) + val balanceSats = _balanceSats.asStateFlow() + + private val _walletAlias = MutableStateFlow(null) + val walletAlias = _walletAlias.asStateFlow() + + private val _transactions = MutableStateFlow>(emptyList()) + val transactions = _transactions.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + private val _sendState = MutableStateFlow(SendState.Idle) + val sendState = _sendState.asStateFlow() + + private val _receiveState = MutableStateFlow(ReceiveState.Idle) + val receiveState = _receiveState.asStateFlow() + + fun init(account: Account) { + this.account = account + } + + fun hasWalletSetup(): Boolean = account?.nip47SignerState?.hasWalletConnectSetup() == true + + fun fetchBalance() { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + _error.value = null + try { + acc.sendNwcRequest(GetBalanceMethod.create()) { response -> + when (response) { + is GetBalanceSuccessResponse -> { + // NWC balance is in millisats, convert to sats + _balanceSats.value = (response.result?.balance ?: 0L) / 1000L + } + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Balance request failed" + } + else -> {} + } + _isLoading.value = false + } + } catch (e: Exception) { + _error.value = e.message + _isLoading.value = false + } + } + } + + fun fetchInfo() { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + acc.sendNwcRequest(GetInfoMethod.create()) { response -> + when (response) { + is GetInfoSuccessResponse -> { + _walletAlias.value = response.result?.alias + } + else -> {} + } + } + } catch (e: Exception) { + // ignore info errors + } + } + } + + fun fetchTransactions( + limit: Int = 20, + offset: Int = 0, + ) { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + try { + acc.sendNwcRequest( + ListTransactionsMethod.create( + limit = limit, + offset = offset, + unpaid = false, + ), + ) { response -> + when (response) { + is ListTransactionsSuccessResponse -> { + _transactions.value = response.result?.transactions ?: emptyList() + } + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Failed to load transactions" + } + else -> {} + } + _isLoading.value = false + } + } catch (e: Exception) { + _error.value = e.message + _isLoading.value = false + } + } + } + + fun sendPayment(bolt11: String) { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _sendState.value = SendState.Sending + try { + acc.sendNwcRequest(PayInvoiceMethod.create(bolt11)) { response -> + when (response) { + is PayInvoiceSuccessResponse -> { + _sendState.value = SendState.Success(response.result?.preimage) + // Refresh balance after payment + fetchBalance() + } + is PayInvoiceErrorResponse -> { + _sendState.value = SendState.Error( + response.error?.message ?: "Payment failed", + ) + } + is NwcErrorResponse -> { + _sendState.value = SendState.Error( + response.error?.message ?: "Payment failed", + ) + } + else -> { + _sendState.value = SendState.Error("Unexpected response") + } + } + } + } catch (e: Exception) { + _sendState.value = SendState.Error(e.message ?: "Payment failed") + } + } + } + + fun createInvoice( + amountSats: Long, + description: String? = null, + ) { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _receiveState.value = ReceiveState.Creating + try { + // NWC expects millisats + acc.sendNwcRequest( + MakeInvoiceMethod.create( + amount = amountSats * 1000L, + description = description, + ), + ) { response -> + when (response) { + is MakeInvoiceSuccessResponse -> { + val invoice = response.result?.invoice + if (invoice != null) { + _receiveState.value = ReceiveState.Created(invoice, amountSats) + } else { + _receiveState.value = ReceiveState.Error("No invoice returned") + } + } + is NwcErrorResponse -> { + _receiveState.value = ReceiveState.Error( + response.error?.message ?: "Invoice creation failed", + ) + } + else -> { + _receiveState.value = ReceiveState.Error("Unexpected response") + } + } + } + } catch (e: Exception) { + _receiveState.value = ReceiveState.Error(e.message ?: "Invoice creation failed") + } + } + } + + fun resetSendState() { + _sendState.value = SendState.Idle + } + + fun resetReceiveState() { + _receiveState.value = ReceiveState.Idle + } + + fun clearError() { + _error.value = null + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 72f3724dc..48d86db71 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1236,6 +1236,29 @@ Global Shorts Chess + Wallet + Balance + Send + Receive + Transactions + No wallet connected + Set up a Nostr Wallet Connect (NWC) connection in your zap settings to use the wallet. + Set Up Wallet + sats + Paste a BOLT-11 invoice + Pay + Payment successful + Sending payment… + Amount (sats) + Description (optional) + Create Invoice + Creating invoice… + Copy Invoice + No transactions yet + Loading… + Received + Sent + Refresh Security Filters Import Follows From 580678eed86a93d50e50f1b555e635eb970baee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 04:25:27 +0000 Subject: [PATCH 07/14] feat: add server-side event builders and NIP-47 README for quartz Adds missing server-side capabilities to the NIP-47 quartz module: - LnZapPaymentResponseEvent.createResponse(): builds encrypted response events (kind 23195) for wallet services to reply to client requests - NwcNotificationEvent.createNotification(): builds encrypted notification events (kind 23197) for wallet services to push payment notifications Creates comprehensive README.md documenting the full NIP-47 API with code examples for both wallet client and wallet service implementations, covering URI parsing, request/response building, encryption, notifications, error handling, transaction states, and caching. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../LnZapPaymentResponseEvent.kt | 41 +- .../NwcNotificationEvent.kt | 30 ++ .../quartz/nip47WalletConnect/README.md | 480 ++++++++++++++++++ 3 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt index 9a7de947c..a75ba45e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt @@ -26,6 +26,8 @@ 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 +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LnZapPaymentResponseEvent( @@ -55,6 +57,43 @@ class LnZapPaymentResponseEvent( companion object { const val KIND = 23195 - const val ALT = "Zap payment response" + const val ALT = "NWC response" + + /** + * Creates an NWC response event (server-side). + * + * @param response the NWC response object to send + * @param requestEvent the original request event being responded to + * @param signer the wallet service signer + * @param useNip44 whether to use NIP-44 encryption (default: false for NIP-04) + * @param createdAt event timestamp + */ + suspend fun createResponse( + response: Response, + requestEvent: LnZapPaymentRequestEvent, + signer: NostrSigner, + useNip44: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): LnZapPaymentResponseEvent { + val serializedResponse = OptimizedJsonMapper.toJson(response) + + val clientPubkey = requestEvent.pubKey + + val tags = + arrayOf( + arrayOf("p", clientPubkey), + arrayOf("e", requestEvent.id), + AltTag.assemble(ALT), + ) + + val encrypted = + if (useNip44) { + signer.nip44Encrypt(serializedResponse, clientPubkey) + } else { + signer.nip04Encrypt(serializedResponse, clientPubkey) + } + + return signer.sign(createdAt, KIND, tags, encrypted) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt index 77b16a3ec..9e9af6b2c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt @@ -26,6 +26,8 @@ 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 +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class NwcNotificationEvent( @@ -54,5 +56,33 @@ class NwcNotificationEvent( const val KIND = 23197 const val LEGACY_KIND = 23196 const val ALT = "Wallet notification" + + /** + * Creates an NWC notification event (server-side). + * Uses NIP-44 encryption (kind 23197). + * + * @param notification the notification to send + * @param clientPubkey the client's public key to encrypt to + * @param signer the wallet service signer + * @param createdAt event timestamp + */ + suspend fun createNotification( + notification: Notification, + clientPubkey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): NwcNotificationEvent { + val serialized = OptimizedJsonMapper.toJson(notification) + + val tags = + arrayOf( + arrayOf("p", clientPubkey), + AltTag.assemble(ALT), + ) + + val encrypted = signer.nip44Encrypt(serialized, clientPubkey) + + return signer.sign(createdAt, KIND, tags, encrypted) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md new file mode 100644 index 000000000..95907efa9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -0,0 +1,480 @@ +# NIP-47 Wallet Connect (Quartz) + +Quartz implementation of [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) — Nostr +Wallet Connect (NWC). This module provides everything needed to build both **wallet client apps** +(like Amethyst) and **wallet service backends** (like Alby Hub). + +## Architecture + +``` +nip47WalletConnect/ +├── Nip47WalletConnect.kt # URI parsing (nostr+walletconnect://) +├── Request.kt # All 13 NWC request methods + params +├── Response.kt # All response types (success + error) +├── Notification.kt # Wallet notification types +├── NwcMethod.kt # Method name constants +├── NwcErrorCode.kt # Error codes enum + NwcError +├── NwcTransaction.kt # Transaction, state, budget, TLV models +├── NwcInfoEvent.kt # Kind 13194 — wallet capabilities +├── LnZapPaymentRequestEvent.kt # Kind 23194 — client → wallet request +├── LnZapPaymentResponseEvent.kt # Kind 23195 — wallet → client response +├── NwcNotificationEvent.kt # Kind 23197 — wallet → client notification +├── NostrWalletConnectRequestCache.kt # Request decryption cache +├── NostrWalletConnectResponseCache.kt # Response decryption cache +└── tags/ + ├── EncryptionTag.kt # "encryption" tag parsing + └── NotificationsTag.kt # "notifications" tag parsing +``` + +## Event Kinds + +| Kind | Class | Direction | Purpose | +|-------|------------------------------|-----------------|----------------------| +| 13194 | `NwcInfoEvent` | Wallet → Relay | Service capabilities | +| 23194 | `LnZapPaymentRequestEvent` | Client → Wallet | NWC request | +| 23195 | `LnZapPaymentResponseEvent` | Wallet → Client | NWC response | +| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-04, legacy) | +| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-44) | + +## Supported Methods + +| Method | Request Class | Success Response Class | +|----------------------|---------------------------|-----------------------------------| +| `pay_invoice` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | +| `pay_keysend` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | +| `make_invoice` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | +| `lookup_invoice` | `LookupInvoiceMethod` | `LookupInvoiceSuccessResponse` | +| `list_transactions` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | +| `get_balance` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | +| `get_info` | `GetInfoMethod` | `GetInfoSuccessResponse` | +| `get_budget` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | +| `sign_message` | `SignMessageMethod` | `SignMessageSuccessResponse` | +| `create_connection` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | +| `make_hold_invoice` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | +| `cancel_hold_invoice`| `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| +| `settle_hold_invoice`| `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| + +Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`. + +## Implementing a Wallet Client + +A wallet client connects to a user's lightning wallet to send payments, check +balances, create invoices, and list transactions. + +### 1. Parse the NWC Connection URI + +Users provide an NWC connection string from their wallet provider: + +```kotlin +val uri = "nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c..." +val nwcConfig = Nip47WalletConnect.parse(uri) + +// nwcConfig.pubKeyHex — wallet service pubkey +// nwcConfig.relayUri — relay to communicate through (NormalizedRelayUrl) +// nwcConfig.secret — hex secret for the client signer +// nwcConfig.lud16 — optional lightning address +``` + +Supported URI schemes: `nostr+walletconnect://`, `nostrwalletconnect://`, +`amethyst+walletconnect://` + +### 2. Create the Client Signer + +The `secret` from the URI becomes the client's signing key: + +```kotlin +val clientSigner = NostrSignerInternal( + KeyPair(nwcConfig.secret!!.hexToByteArray()) +) +``` + +### 3. Build and Send Requests + +Use `LnZapPaymentRequestEvent.createRequest()` to create encrypted request events: + +```kotlin +// Get balance +val balanceRequest = GetBalanceMethod.create() +val event = LnZapPaymentRequestEvent.createRequest( + request = balanceRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) +// Send `event` to `nwcConfig.relayUri` + +// Pay an invoice +val payRequest = PayInvoiceMethod.create("lnbc50n1...") +val payEvent = LnZapPaymentRequestEvent.createRequest( + request = payRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) + +// Create an invoice (amount in millisats) +val invoiceRequest = MakeInvoiceMethod.create( + amount = 50000L, // 50 sats in millisats + description = "Coffee", +) +val invoiceEvent = LnZapPaymentRequestEvent.createRequest( + request = invoiceRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) + +// List transactions with pagination +val listRequest = ListTransactionsMethod.create( + limit = 20, + offset = 0, +) +val listEvent = LnZapPaymentRequestEvent.createRequest( + request = listRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) +``` + +To use NIP-44 encryption instead of NIP-04: + +```kotlin +val event = LnZapPaymentRequestEvent.createRequest( + request = GetInfoMethod.create(), + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, + useNip44 = true, +) +``` + +### 4. Receive and Parse Responses + +Subscribe to kind `23195` events on the NWC relay, filtered by the wallet +service pubkey and the request event ID. When a response arrives: + +```kotlin +// responseEvent is a LnZapPaymentResponseEvent (kind 23195) +val response: Response = responseEvent.decrypt(clientSigner) + +when (response) { + is GetBalanceSuccessResponse -> { + val balanceMillisats = response.result?.balance ?: 0L + val balanceSats = balanceMillisats / 1000L + } + is PayInvoiceSuccessResponse -> { + val preimage = response.result?.preimage + val feesPaid = response.result?.fees_paid + } + is MakeInvoiceSuccessResponse -> { + val bolt11 = response.result?.invoice + val paymentHash = response.result?.payment_hash + } + is ListTransactionsSuccessResponse -> { + val transactions: List = response.result?.transactions ?: emptyList() + transactions.forEach { tx -> + // tx.type — "incoming" or "outgoing" + // tx.amount — in millisats + // tx.description, tx.created_at, tx.state, etc. + } + } + is GetInfoSuccessResponse -> { + val alias = response.result?.alias + val methods = response.result?.methods // supported methods + val lud16 = response.result?.lud16 + } + is PayInvoiceErrorResponse -> { + val errorCode = response.error?.code // NwcErrorCode enum + val errorMessage = response.error?.message + } + is NwcErrorResponse -> { + val errorCode = response.error?.code + val errorMessage = response.error?.message + } +} +``` + +### 5. Listen for Notifications (Optional) + +Subscribe to kind `23196`/`23197` events from the wallet: + +```kotlin +// notificationEvent is an NwcNotificationEvent +val notification: Notification = notificationEvent.decryptNotification(clientSigner) + +when (notification) { + is PaymentReceivedNotification -> { + val tx: NwcTransaction? = notification.notification + // tx?.amount, tx?.description, tx?.payment_hash, etc. + } + is PaymentSentNotification -> { + val tx: NwcTransaction? = notification.notification + } + is HoldInvoiceAcceptedNotification -> { + val data = notification.notification + // data?.payment_hash, data?.amount, data?.settle_deadline + } +} +``` + +### 6. Transaction State Helpers + +Transaction states from different wallet implementations may use different +casing. Use the case-insensitive helpers: + +```kotlin +val tx: NwcTransaction = ... + +NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled" +NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending" +NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed" +NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted" +``` + +### 7. URI Persistence + +To save/restore the NWC connection: + +```kotlin +// Save +val nip47URI: Nip47WalletConnect.Nip47URI = nwcConfig.denormalize()!! +val json = Nip47WalletConnect.Nip47URI.serializer(nip47URI) + +// Restore +val restored = Nip47WalletConnect.Nip47URI.parser(json) +val normalized = restored.normalize()!! +``` + +## Implementing a Wallet Service + +A wallet service receives NWC requests from clients, processes them (e.g., +pays invoices via a Lightning node), and sends back responses. + +### 1. Publish Capabilities + +Advertise which methods your wallet supports by publishing a kind `13194` +event: + +```kotlin +val capabilities = listOf( + NwcMethod.PAY_INVOICE, + NwcMethod.GET_BALANCE, + NwcMethod.GET_INFO, + NwcMethod.MAKE_INVOICE, + NwcMethod.LOOKUP_INVOICE, + NwcMethod.LIST_TRANSACTIONS, +) + +val infoTemplate = NwcInfoEvent.build( + capabilities = capabilities, + encryptionSchemes = listOf("nip04", "nip44_v2"), + notificationTypes = listOf( + NwcNotificationType.PAYMENT_RECEIVED, + NwcNotificationType.PAYMENT_SENT, + ), +) +// Sign with wallet signer: walletSigner.sign(infoTemplate) +``` + +### 2. Receive and Parse Requests + +Subscribe to kind `23194` events on your relay, filtered by your wallet +service pubkey in the `p` tag. When a request arrives: + +```kotlin +// requestEvent is a LnZapPaymentRequestEvent (kind 23194) +val request: Request = requestEvent.decryptRequest(walletSigner) + +when (request) { + is PayInvoiceMethod -> { + val bolt11 = request.params?.invoice + val amount = request.params?.amount // optional override in millisats + // Process payment via your Lightning node... + } + is GetBalanceMethod -> { + // Query your Lightning node for balance... + } + is MakeInvoiceMethod -> { + val amount = request.params?.amount // in millisats + val description = request.params?.description + // Create invoice via your Lightning node... + } + is ListTransactionsMethod -> { + val limit = request.params?.limit + val offset = request.params?.offset + val type = request.params?.type // "incoming" or "outgoing" + // Query transaction history... + } + is GetInfoMethod -> { + // Return node info... + } + is GetBudgetMethod -> { + // Return budget info... + } + // ... handle other methods +} +``` + +### 3. Build and Send Responses + +Use `LnZapPaymentResponseEvent.createResponse()` to create encrypted +response events: + +```kotlin +// Success response for get_balance +val balanceResponse = GetBalanceSuccessResponse( + GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) // in millisats +) +val responseEvent = LnZapPaymentResponseEvent.createResponse( + response = balanceResponse, + requestEvent = requestEvent, + signer = walletSigner, +) +// Send responseEvent to the relay + +// Success response for pay_invoice +val payResponse = PayInvoiceSuccessResponse( + PayInvoiceSuccessResponse.PayInvoiceResultParams( + preimage = "0123456789abcdef", + fees_paid = 100L, + ) +) +val payResponseEvent = LnZapPaymentResponseEvent.createResponse( + response = payResponse, + requestEvent = requestEvent, + signer = walletSigner, +) + +// Error response +val errorResponse = NwcErrorResponse( + resultType = NwcMethod.PAY_INVOICE, + error = NwcError(NwcErrorCode.INSUFFICIENT_BALANCE, "Not enough funds"), +) +val errorEvent = LnZapPaymentResponseEvent.createResponse( + response = errorResponse, + requestEvent = requestEvent, + signer = walletSigner, +) +``` + +To use NIP-44 encryption for responses: + +```kotlin +val responseEvent = LnZapPaymentResponseEvent.createResponse( + response = balanceResponse, + requestEvent = requestEvent, + signer = walletSigner, + useNip44 = true, +) +``` + +### 4. Send Notifications + +Push notifications to clients for payment events: + +```kotlin +// Payment received notification +val notification = PaymentReceivedNotification( + notification = NwcTransaction( + type = NwcTransactionType.INCOMING, + state = NwcTransactionState.SETTLED, + invoice = "lnbc...", + amount = 50000L, // in millisats + payment_hash = "abc123", + settled_at = TimeUtils.now(), + created_at = TimeUtils.now(), + ), +) +val notifEvent = NwcNotificationEvent.createNotification( + notification = notification, + clientPubkey = clientPubkeyHex, + signer = walletSigner, +) +// Send notifEvent to the relay +``` + +### 5. Build Transaction Objects + +Transactions are used across multiple response types: + +```kotlin +val transaction = NwcTransaction( + type = NwcTransactionType.INCOMING, // or OUTGOING + state = NwcTransactionState.SETTLED, // PENDING, SETTLED, FAILED, ACCEPTED + invoice = "lnbc50n1...", + description = "Coffee payment", + payment_hash = "abc123def456", + preimage = "fedcba654321", + amount = 50000L, // in millisats + fees_paid = 100L, // in millisats + created_at = 1693876497L, // unix timestamp + settled_at = 1693876500L, + expires_at = 1694876497L, +) +``` + +## Error Codes + +| Code | When to Use | +|-------------------------|-------------------------------------------| +| `RATE_LIMITED` | Too many requests | +| `NOT_IMPLEMENTED` | Method not supported by wallet | +| `INSUFFICIENT_BALANCE` | Not enough funds for payment | +| `PAYMENT_FAILED` | Payment could not be completed | +| `QUOTA_EXCEEDED` | Budget/spending limit exceeded | +| `RESTRICTED` | Method not allowed for this connection | +| `UNAUTHORIZED` | Invalid or expired credentials | +| `INTERNAL` | Internal wallet error | +| `UNSUPPORTED_ENCRYPTION`| Requested encryption not supported | +| `BAD_REQUEST` | Malformed request parameters | +| `NOT_FOUND` | Invoice or resource not found | +| `EXPIRED` | Connection or invoice expired | +| `OTHER` | Unspecified error | + +## Encryption + +NWC supports two encryption schemes: + +- **NIP-04** (default): Set `useNip44 = false` in event builders +- **NIP-44 v2**: Set `useNip44 = true` in event builders + +When building requests with NIP-44, the event includes an `encryption` tag: +``` +["encryption", "nip44_v2"] +``` + +Clients can check a wallet's supported encryption via the info event: +```kotlin +val infoEvent: NwcInfoEvent = ... +val schemes: List = infoEvent.encryptionSchemes() +// e.g., ["nip04", "nip44_v2"] +``` + +## Caching + +For apps handling many concurrent NWC events, use the built-in LRU caches: + +```kotlin +val requestCache = NostrWalletConnectRequestCache(signer) +val responseCache = NostrWalletConnectResponseCache(signer) + +// These cache up to 50 decrypted results and handle +// async retry logic for permission dialogs and timeouts. +val request: Request? = requestCache.decryptRequest(requestEvent) +val response: Response? = responseCache.decryptResponse(responseEvent) +``` + +## Amounts + +All amounts in NWC are in **millisatoshis** (1 sat = 1000 msats). Convert for +display: + +```kotlin +val balanceMsats = response.result?.balance ?: 0L +val balanceSats = balanceMsats / 1000L +``` + +## Interoperability + +This implementation is tested against: +- **Alby Hub** (server) — uppercase transaction states, all error codes +- **Alby JS SDK** (client) — lowercase transaction states, budget renewal + periods, structured metadata + +See `AlbyInteropTest.kt` for real-world test vectors. From 1841e47c5ccd6a5728dc0bbb706f77d2e934b699 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 11:39:17 +0000 Subject: [PATCH 08/14] feat: add high-level Nip47Client and Nip47Server APIs for NIP-47 Simplifies the NWC developer experience by providing Nip47Client (for wallet client apps) and Nip47Server (for wallet service backends) that handle URI parsing, signer creation, event building, filter construction, and response decryption. Updates README with quick-start examples. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../quartz/nip47WalletConnect/Nip47Client.kt | 265 ++++++++++++ .../quartz/nip47WalletConnect/Nip47Server.kt | 294 +++++++++++++ .../quartz/nip47WalletConnect/README.md | 398 ++++++------------ 3 files changed, 699 insertions(+), 258 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt new file mode 100644 index 000000000..5686fc1d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -0,0 +1,265 @@ +/* + * 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.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal + +/** + * High-level NIP-47 Wallet Connect client. + * + * Simplifies the NWC protocol by handling URI parsing, signer creation, + * event building, filter construction, and response decryption. + * + * Usage: + * ```kotlin + * val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...") + * + * // Build a request event + * val requestEvent = client.payInvoice("lnbc50n1...") + * + * // Send requestEvent to client.relayUrl via your relay connection + * // Subscribe using client.responseFilter(requestEvent.id) for the response + * + * // When response arrives: + * val response = client.parseResponse(responseEvent) + * when (response) { + * is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}") + * is NwcErrorResponse -> println("Error: ${response.error?.message}") + * } + * ``` + */ +class Nip47Client( + val walletPubKeyHex: HexKey, + val relayUrl: NormalizedRelayUrl, + val signer: NostrSigner, + val useNip44: Boolean = false, +) { + companion object { + /** + * Creates an Nip47Client from a NWC connection URI string. + * + * @param uri NWC URI (e.g., "nostr+walletconnect://pubkey?relay=...&secret=...") + * @throws IllegalArgumentException if the URI is invalid or has no secret + */ + fun fromUri(uri: String): Nip47Client { + val config = Nip47WalletConnect.parse(uri) + return fromNip47URI(config) + } + + /** + * Creates an Nip47Client from parsed NWC connection details. + * + * @param config parsed NWC URI with wallet pubkey, relay, and secret + * @throws IllegalArgumentException if config has no secret + */ + fun fromNip47URI(config: Nip47WalletConnect.Nip47URINorm): Nip47Client { + val secret = config.secret ?: throw IllegalArgumentException("NWC connection requires a secret") + val signer = NostrSignerInternal(KeyPair(secret.hexToByteArray())) + return Nip47Client( + walletPubKeyHex = config.pubKeyHex, + relayUrl = config.relayUri, + signer = signer, + ) + } + } + + // --- Request builders --- + + /** + * Builds a pay_invoice request event. + */ + suspend fun payInvoice( + bolt11: String, + amount: Long? = null, + ): LnZapPaymentRequestEvent = + buildRequest( + if (amount != null) { + PayInvoiceMethod.create(bolt11, amount) + } else { + PayInvoiceMethod.create(bolt11) + }, + ) + + /** + * Builds a pay_keysend request event. + */ + suspend fun payKeysend( + amount: Long, + pubkey: String, + preimage: String? = null, + tlvRecords: List? = null, + ): LnZapPaymentRequestEvent = buildRequest(PayKeysendMethod.create(amount, pubkey, preimage, tlvRecords)) + + /** + * Builds a get_balance request event. + */ + suspend fun getBalance(): LnZapPaymentRequestEvent = buildRequest(GetBalanceMethod.create()) + + /** + * Builds a get_info request event. + */ + suspend fun getInfo(): LnZapPaymentRequestEvent = buildRequest(GetInfoMethod.create()) + + /** + * Builds a make_invoice request event. + */ + suspend fun makeInvoice( + amount: Long, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + ): LnZapPaymentRequestEvent = buildRequest(MakeInvoiceMethod.create(amount, description, descriptionHash, expiry)) + + /** + * Builds a lookup_invoice request event by payment hash. + */ + suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = + buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) + + /** + * Builds a lookup_invoice request event by BOLT11 invoice. + */ + suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = + buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) + + /** + * Builds a list_transactions request event. + */ + suspend fun listTransactions( + from: Long? = null, + until: Long? = null, + limit: Int? = null, + offset: Int? = null, + unpaid: Boolean? = null, + type: String? = null, + ): LnZapPaymentRequestEvent = buildRequest(ListTransactionsMethod.create(from, until, limit, offset, unpaid, type)) + + /** + * Builds a get_budget request event. + */ + suspend fun getBudget(): LnZapPaymentRequestEvent = buildRequest(GetBudgetMethod.create()) + + /** + * Builds a sign_message request event. + */ + suspend fun signMessage(message: String): LnZapPaymentRequestEvent = buildRequest(SignMessageMethod.create(message)) + + /** + * Builds a make_hold_invoice request event. + */ + suspend fun makeHoldInvoice( + amount: Long, + paymentHash: String, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + minCltvExpiryDelta: Int? = null, + ): LnZapPaymentRequestEvent = + buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) + + /** + * Builds a cancel_hold_invoice request event. + */ + suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = + buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) + + /** + * Builds a settle_hold_invoice request event. + */ + suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = + buildRequest(SettleHoldInvoiceMethod.create(preimage)) + + /** + * Builds a request event from any [Request] object. + * This is the low-level method used by all convenience methods above. + */ + suspend fun buildRequest(request: Request): LnZapPaymentRequestEvent = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletPubKeyHex, + signer = signer, + useNip44 = useNip44, + ) + + // --- Response handling --- + + /** + * Decrypts and parses a response event from the wallet. + */ + suspend fun parseResponse(event: LnZapPaymentResponseEvent): Response = event.decrypt(signer) + + /** + * Decrypts and parses a notification event from the wallet. + */ + suspend fun parseNotification(event: NwcNotificationEvent): Notification = event.decryptNotification(signer) + + // --- Filter helpers --- + + /** + * Creates a filter to subscribe for responses to a specific request. + * Use this to subscribe on [relayUrl] after sending a request event. + */ + fun responseFilter(requestEventId: HexKey): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("e" to listOf(requestEventId)), + ) + + /** + * Creates a filter to subscribe for all responses from the wallet + * directed to this client. + */ + fun allResponsesFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) + + /** + * Creates a filter to subscribe for wallet notifications. + */ + fun notificationsFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) + + /** + * Creates a filter to fetch the wallet's info event (kind 13194). + */ + fun infoFilter(): Filter = + Filter( + kinds = listOf(NwcInfoEvent.KIND), + authors = listOf(walletPubKeyHex), + limit = 1, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt new file mode 100644 index 000000000..6b032f3ae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -0,0 +1,294 @@ +/* + * 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.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** + * High-level NIP-47 Wallet Connect server (wallet service). + * + * Simplifies building a wallet service that receives NWC requests from clients, + * processes them, and sends back responses and notifications. + * + * Usage: + * ```kotlin + * val server = Nip47Server(walletSigner, supportedMethods, relayUrl) + * + * // Publish capabilities + * val infoEvent = server.buildInfoEvent() + * // Send infoEvent to relay + * + * // Subscribe using server.requestsFilter() on your relay + * + * // When a request arrives: + * val request = server.parseRequest(requestEvent) + * when (request) { + * is GetBalanceMethod -> { + * val response = server.respondGetBalance(requestEvent, balance = 2100000L) + * // Send response to relay + * } + * is PayInvoiceMethod -> { + * // Process payment, then: + * val response = server.respondPayInvoice(requestEvent, preimage = "abc123") + * // Or on error: + * val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found") + * // Send response to relay + * } + * } + * ``` + */ +class Nip47Server( + val signer: NostrSigner, + val capabilities: List = emptyList(), + val useNip44: Boolean = false, + val encryptionSchemes: List? = null, + val notificationTypes: List? = null, +) { + // --- Info event --- + + /** + * Builds a kind 13194 info event advertising wallet capabilities. + * Sign and publish this event to your relay. + */ + fun buildInfoEvent() = + NwcInfoEvent.build( + capabilities = capabilities, + encryptionSchemes = encryptionSchemes, + notificationTypes = notificationTypes, + ) + + // --- Request parsing --- + + /** + * Decrypts and parses an incoming client request. + */ + suspend fun parseRequest(event: LnZapPaymentRequestEvent): Request = event.decryptRequest(signer) + + // --- Response builders --- + + /** + * Builds a response event from any [Response] object. + */ + suspend fun buildResponse( + response: Response, + requestEvent: LnZapPaymentRequestEvent, + ): LnZapPaymentResponseEvent = + LnZapPaymentResponseEvent.createResponse( + response = response, + requestEvent = requestEvent, + signer = signer, + useNip44 = useNip44, + ) + + /** + * Builds an error response for any method. + */ + suspend fun respondError( + requestEvent: LnZapPaymentRequestEvent, + code: NwcErrorCode, + message: String, + resultType: String? = null, + ): LnZapPaymentResponseEvent { + val method = resultType ?: requestEvent.decryptRequest(signer).method ?: NwcMethod.PAY_INVOICE + return buildResponse(NwcErrorResponse(method, NwcError(code, message)), requestEvent) + } + + /** + * Builds a pay_invoice success response. + */ + suspend fun respondPayInvoice( + requestEvent: LnZapPaymentRequestEvent, + preimage: String? = null, + feesPaid: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + PayInvoiceSuccessResponse(PayInvoiceSuccessResponse.PayInvoiceResultParams(preimage, feesPaid)), + requestEvent, + ) + + /** + * Builds a pay_keysend success response. + */ + suspend fun respondPayKeysend( + requestEvent: LnZapPaymentRequestEvent, + preimage: String? = null, + feesPaid: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + PayKeysendSuccessResponse(PayKeysendSuccessResponse.PayKeysendResult(preimage, feesPaid)), + requestEvent, + ) + + /** + * Builds a get_balance success response. + */ + suspend fun respondGetBalance( + requestEvent: LnZapPaymentRequestEvent, + balance: Long, + ): LnZapPaymentResponseEvent = + buildResponse( + GetBalanceSuccessResponse(GetBalanceSuccessResponse.GetBalanceResult(balance)), + requestEvent, + ) + + /** + * Builds a get_info success response. + */ + suspend fun respondGetInfo( + requestEvent: LnZapPaymentRequestEvent, + alias: String? = null, + color: String? = null, + pubkey: String? = null, + network: String? = null, + blockHeight: Long? = null, + blockHash: String? = null, + methods: List? = null, + notifications: List? = null, + lud16: String? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + GetInfoSuccessResponse( + GetInfoSuccessResponse.GetInfoResult( + alias, color, pubkey, network, blockHeight, blockHash, methods, notifications, null, lud16, + ), + ), + requestEvent, + ) + + /** + * Builds a make_invoice success response. + */ + suspend fun respondMakeInvoice( + requestEvent: LnZapPaymentRequestEvent, + transaction: NwcTransaction, + ): LnZapPaymentResponseEvent = + buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) + + /** + * Builds a lookup_invoice success response. + */ + suspend fun respondLookupInvoice( + requestEvent: LnZapPaymentRequestEvent, + transaction: NwcTransaction, + ): LnZapPaymentResponseEvent = + buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) + + /** + * Builds a list_transactions success response. + */ + suspend fun respondListTransactions( + requestEvent: LnZapPaymentRequestEvent, + transactions: List, + totalCount: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + ListTransactionsSuccessResponse( + ListTransactionsSuccessResponse.ListTransactionsResult(transactions, totalCount), + ), + requestEvent, + ) + + /** + * Builds a get_budget success response. + */ + suspend fun respondGetBudget( + requestEvent: LnZapPaymentRequestEvent, + usedBudget: Long? = null, + totalBudget: Long? = null, + renewsAt: Long? = null, + renewalPeriod: String? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + GetBudgetSuccessResponse( + GetBudgetSuccessResponse.GetBudgetResult(usedBudget, totalBudget, renewsAt, renewalPeriod), + ), + requestEvent, + ) + + /** + * Builds a sign_message success response. + */ + suspend fun respondSignMessage( + requestEvent: LnZapPaymentRequestEvent, + message: String, + signature: String, + ): LnZapPaymentResponseEvent = + buildResponse( + SignMessageSuccessResponse(SignMessageSuccessResponse.SignMessageResult(message, signature)), + requestEvent, + ) + + // --- Notification builders --- + + /** + * Builds a payment_received notification event. + */ + suspend fun notifyPaymentReceived( + clientPubkey: HexKey, + transaction: NwcTransaction, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = PaymentReceivedNotification(transaction), + clientPubkey = clientPubkey, + signer = signer, + ) + + /** + * Builds a payment_sent notification event. + */ + suspend fun notifyPaymentSent( + clientPubkey: HexKey, + transaction: NwcTransaction, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = PaymentSentNotification(transaction), + clientPubkey = clientPubkey, + signer = signer, + ) + + /** + * Builds a notification event from any [Notification] object. + */ + suspend fun buildNotification( + notification: Notification, + clientPubkey: HexKey, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = notification, + clientPubkey = clientPubkey, + signer = signer, + ) + + // --- Filter helpers --- + + /** + * Creates a filter to subscribe for incoming client requests. + * Use this to subscribe on your relay. + */ + fun requestsFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(LnZapPaymentRequestEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md index 95907efa9..0d372e1a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -4,10 +4,86 @@ Quartz implementation of [NIP-47](https://github.com/nostr-protocol/nips/blob/ma Wallet Connect (NWC). This module provides everything needed to build both **wallet client apps** (like Amethyst) and **wallet service backends** (like Alby Hub). +## Quick Start — Wallet Client + +Use `Nip47Client` for a high-level API that handles URI parsing, signer creation, +event building, filter construction, and response decryption: + +```kotlin +// 1. Create client from NWC URI +val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...") + +// 2. Build request events — one method per NWC command +val payEvent = client.payInvoice("lnbc50n1...") +val balanceEvent = client.getBalance() +val infoEvent = client.getInfo() +val invoiceEvent = client.makeInvoice(amount = 50000L, description = "Coffee") +val txEvent = client.listTransactions(limit = 20) + +// 3. Send event to client.relayUrl via your relay connection +// 4. Subscribe using client.responseFilter(payEvent.id) for the response + +// 5. When response arrives, parse it +val response = client.parseResponse(responseEvent) +when (response) { + is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}") + is GetBalanceSuccessResponse -> println("Balance: ${response.result?.balance} msats") + is NwcErrorResponse -> println("Error: ${response.error?.message}") +} + +// Filter helpers for relay subscriptions +val filter = client.responseFilter(payEvent.id) // Filter for a specific response +val allFilter = client.allResponsesFilter() // Filter for all responses +val notifFilter = client.notificationsFilter() // Filter for notifications +val walletInfo = client.infoFilter() // Filter for wallet info event +``` + +## Quick Start — Wallet Service + +Use `Nip47Server` to build a wallet service that receives requests and sends responses: + +```kotlin +// 1. Create server +val server = Nip47Server( + signer = walletSigner, + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE, NwcMethod.GET_INFO), +) + +// 2. Publish capabilities (kind 13194) +val infoTemplate = server.buildInfoEvent() +// Sign and send: walletSigner.sign(infoTemplate) + +// 3. Subscribe using server.requestsFilter() on your relay + +// 4. When a request arrives, parse and respond +val request = server.parseRequest(requestEvent) +when (request) { + is GetBalanceMethod -> { + val response = server.respondGetBalance(requestEvent, balance = 2100000L) + // Send response to relay + } + is PayInvoiceMethod -> { + // Process payment, then: + val response = server.respondPayInvoice(requestEvent, preimage = "abc123") + // Or on error: + val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found") + } + is MakeInvoiceMethod -> { + val tx = NwcTransaction(type = NwcTransactionType.INCOMING, invoice = "lnbc...") + val response = server.respondMakeInvoice(requestEvent, tx) + } +} + +// 5. Send notifications +val notifEvent = server.notifyPaymentReceived(clientPubkey, transaction) +``` + ## Architecture ``` nip47WalletConnect/ +├── Nip47Client.kt # High-level client API (URI → requests → responses) +├── Nip47Server.kt # High-level server API (requests → responses → notifications) ├── Nip47WalletConnect.kt # URI parsing (nostr+walletconnect://) ├── Request.kt # All 13 NWC request methods + params ├── Response.kt # All response types (success + error) @@ -38,49 +114,42 @@ nip47WalletConnect/ ## Supported Methods -| Method | Request Class | Success Response Class | -|----------------------|---------------------------|-----------------------------------| -| `pay_invoice` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | -| `pay_keysend` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | -| `make_invoice` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | -| `lookup_invoice` | `LookupInvoiceMethod` | `LookupInvoiceSuccessResponse` | -| `list_transactions` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | -| `get_balance` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | -| `get_info` | `GetInfoMethod` | `GetInfoSuccessResponse` | -| `get_budget` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | -| `sign_message` | `SignMessageMethod` | `SignMessageSuccessResponse` | -| `create_connection` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | -| `make_hold_invoice` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | -| `cancel_hold_invoice`| `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| -| `settle_hold_invoice`| `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| +| Method | `Nip47Client` method | Request Class | Success Response Class | +|----------------------|-----------------------------|---------------------------|-----------------------------------| +| `pay_invoice` | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | +| `pay_keysend` | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | +| `make_invoice` | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | +| `lookup_invoice` | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` | +| `list_transactions` | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | +| `get_balance` | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | +| `get_info` | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` | +| `get_budget` | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | +| `sign_message` | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` | +| `create_connection` | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | +| `make_hold_invoice` | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | +| `cancel_hold_invoice`| `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| +| `settle_hold_invoice`| `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`. -## Implementing a Wallet Client +## Low-Level API -A wallet client connects to a user's lightning wallet to send payments, check -balances, create invoices, and list transactions. +The high-level `Nip47Client` and `Nip47Server` classes wrap the lower-level event +builders. You can use these directly if you need more control. -### 1. Parse the NWC Connection URI +### Wallet Client (Low-Level) -Users provide an NWC connection string from their wallet provider: +#### 1. Parse the NWC Connection URI ```kotlin val uri = "nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c..." val nwcConfig = Nip47WalletConnect.parse(uri) - -// nwcConfig.pubKeyHex — wallet service pubkey -// nwcConfig.relayUri — relay to communicate through (NormalizedRelayUrl) -// nwcConfig.secret — hex secret for the client signer -// nwcConfig.lud16 — optional lightning address ``` Supported URI schemes: `nostr+walletconnect://`, `nostrwalletconnect://`, `amethyst+walletconnect://` -### 2. Create the Client Signer - -The `secret` from the URI becomes the client's signing key: +#### 2. Create the Client Signer ```kotlin val clientSigner = NostrSignerInternal( @@ -88,12 +157,9 @@ val clientSigner = NostrSignerInternal( ) ``` -### 3. Build and Send Requests - -Use `LnZapPaymentRequestEvent.createRequest()` to create encrypted request events: +#### 3. Build and Send Requests ```kotlin -// Get balance val balanceRequest = GetBalanceMethod.create() val event = LnZapPaymentRequestEvent.createRequest( request = balanceRequest, @@ -101,36 +167,6 @@ val event = LnZapPaymentRequestEvent.createRequest( signer = clientSigner, ) // Send `event` to `nwcConfig.relayUri` - -// Pay an invoice -val payRequest = PayInvoiceMethod.create("lnbc50n1...") -val payEvent = LnZapPaymentRequestEvent.createRequest( - request = payRequest, - walletServicePubkey = nwcConfig.pubKeyHex, - signer = clientSigner, -) - -// Create an invoice (amount in millisats) -val invoiceRequest = MakeInvoiceMethod.create( - amount = 50000L, // 50 sats in millisats - description = "Coffee", -) -val invoiceEvent = LnZapPaymentRequestEvent.createRequest( - request = invoiceRequest, - walletServicePubkey = nwcConfig.pubKeyHex, - signer = clientSigner, -) - -// List transactions with pagination -val listRequest = ListTransactionsMethod.create( - limit = 20, - offset = 0, -) -val listEvent = LnZapPaymentRequestEvent.createRequest( - request = listRequest, - walletServicePubkey = nwcConfig.pubKeyHex, - signer = clientSigner, -) ``` To use NIP-44 encryption instead of NIP-04: @@ -144,202 +180,69 @@ val event = LnZapPaymentRequestEvent.createRequest( ) ``` -### 4. Receive and Parse Responses +#### 4. Receive and Parse Responses Subscribe to kind `23195` events on the NWC relay, filtered by the wallet -service pubkey and the request event ID. When a response arrives: +service pubkey and the request event ID: ```kotlin -// responseEvent is a LnZapPaymentResponseEvent (kind 23195) val response: Response = responseEvent.decrypt(clientSigner) when (response) { is GetBalanceSuccessResponse -> { - val balanceMillisats = response.result?.balance ?: 0L - val balanceSats = balanceMillisats / 1000L + val balanceSats = (response.result?.balance ?: 0L) / 1000L } is PayInvoiceSuccessResponse -> { val preimage = response.result?.preimage - val feesPaid = response.result?.fees_paid - } - is MakeInvoiceSuccessResponse -> { - val bolt11 = response.result?.invoice - val paymentHash = response.result?.payment_hash - } - is ListTransactionsSuccessResponse -> { - val transactions: List = response.result?.transactions ?: emptyList() - transactions.forEach { tx -> - // tx.type — "incoming" or "outgoing" - // tx.amount — in millisats - // tx.description, tx.created_at, tx.state, etc. - } - } - is GetInfoSuccessResponse -> { - val alias = response.result?.alias - val methods = response.result?.methods // supported methods - val lud16 = response.result?.lud16 - } - is PayInvoiceErrorResponse -> { - val errorCode = response.error?.code // NwcErrorCode enum - val errorMessage = response.error?.message } is NwcErrorResponse -> { - val errorCode = response.error?.code val errorMessage = response.error?.message } } ``` -### 5. Listen for Notifications (Optional) - -Subscribe to kind `23196`/`23197` events from the wallet: +#### 5. Listen for Notifications ```kotlin -// notificationEvent is an NwcNotificationEvent val notification: Notification = notificationEvent.decryptNotification(clientSigner) when (notification) { is PaymentReceivedNotification -> { val tx: NwcTransaction? = notification.notification - // tx?.amount, tx?.description, tx?.payment_hash, etc. } is PaymentSentNotification -> { val tx: NwcTransaction? = notification.notification } - is HoldInvoiceAcceptedNotification -> { - val data = notification.notification - // data?.payment_hash, data?.amount, data?.settle_deadline - } } ``` -### 6. Transaction State Helpers +### Wallet Service (Low-Level) -Transaction states from different wallet implementations may use different -casing. Use the case-insensitive helpers: +#### 1. Publish Capabilities ```kotlin -val tx: NwcTransaction = ... - -NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled" -NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending" -NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed" -NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted" -``` - -### 7. URI Persistence - -To save/restore the NWC connection: - -```kotlin -// Save -val nip47URI: Nip47WalletConnect.Nip47URI = nwcConfig.denormalize()!! -val json = Nip47WalletConnect.Nip47URI.serializer(nip47URI) - -// Restore -val restored = Nip47WalletConnect.Nip47URI.parser(json) -val normalized = restored.normalize()!! -``` - -## Implementing a Wallet Service - -A wallet service receives NWC requests from clients, processes them (e.g., -pays invoices via a Lightning node), and sends back responses. - -### 1. Publish Capabilities - -Advertise which methods your wallet supports by publishing a kind `13194` -event: - -```kotlin -val capabilities = listOf( - NwcMethod.PAY_INVOICE, - NwcMethod.GET_BALANCE, - NwcMethod.GET_INFO, - NwcMethod.MAKE_INVOICE, - NwcMethod.LOOKUP_INVOICE, - NwcMethod.LIST_TRANSACTIONS, -) - val infoTemplate = NwcInfoEvent.build( - capabilities = capabilities, + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE), encryptionSchemes = listOf("nip04", "nip44_v2"), - notificationTypes = listOf( - NwcNotificationType.PAYMENT_RECEIVED, - NwcNotificationType.PAYMENT_SENT, - ), + notificationTypes = listOf(NwcNotificationType.PAYMENT_RECEIVED), ) // Sign with wallet signer: walletSigner.sign(infoTemplate) ``` -### 2. Receive and Parse Requests - -Subscribe to kind `23194` events on your relay, filtered by your wallet -service pubkey in the `p` tag. When a request arrives: +#### 2. Parse Requests and Build Responses ```kotlin -// requestEvent is a LnZapPaymentRequestEvent (kind 23194) val request: Request = requestEvent.decryptRequest(walletSigner) -when (request) { - is PayInvoiceMethod -> { - val bolt11 = request.params?.invoice - val amount = request.params?.amount // optional override in millisats - // Process payment via your Lightning node... - } - is GetBalanceMethod -> { - // Query your Lightning node for balance... - } - is MakeInvoiceMethod -> { - val amount = request.params?.amount // in millisats - val description = request.params?.description - // Create invoice via your Lightning node... - } - is ListTransactionsMethod -> { - val limit = request.params?.limit - val offset = request.params?.offset - val type = request.params?.type // "incoming" or "outgoing" - // Query transaction history... - } - is GetInfoMethod -> { - // Return node info... - } - is GetBudgetMethod -> { - // Return budget info... - } - // ... handle other methods -} -``` - -### 3. Build and Send Responses - -Use `LnZapPaymentResponseEvent.createResponse()` to create encrypted -response events: - -```kotlin -// Success response for get_balance +// Build response val balanceResponse = GetBalanceSuccessResponse( - GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) // in millisats + GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) ) val responseEvent = LnZapPaymentResponseEvent.createResponse( response = balanceResponse, requestEvent = requestEvent, signer = walletSigner, ) -// Send responseEvent to the relay - -// Success response for pay_invoice -val payResponse = PayInvoiceSuccessResponse( - PayInvoiceSuccessResponse.PayInvoiceResultParams( - preimage = "0123456789abcdef", - fees_paid = 100L, - ) -) -val payResponseEvent = LnZapPaymentResponseEvent.createResponse( - response = payResponse, - requestEvent = requestEvent, - signer = walletSigner, -) // Error response val errorResponse = NwcErrorResponse( @@ -353,60 +256,46 @@ val errorEvent = LnZapPaymentResponseEvent.createResponse( ) ``` -To use NIP-44 encryption for responses: +#### 3. Send Notifications ```kotlin -val responseEvent = LnZapPaymentResponseEvent.createResponse( - response = balanceResponse, - requestEvent = requestEvent, - signer = walletSigner, - useNip44 = true, -) -``` - -### 4. Send Notifications - -Push notifications to clients for payment events: - -```kotlin -// Payment received notification -val notification = PaymentReceivedNotification( - notification = NwcTransaction( - type = NwcTransactionType.INCOMING, - state = NwcTransactionState.SETTLED, - invoice = "lnbc...", - amount = 50000L, // in millisats - payment_hash = "abc123", - settled_at = TimeUtils.now(), - created_at = TimeUtils.now(), - ), -) val notifEvent = NwcNotificationEvent.createNotification( - notification = notification, + notification = PaymentReceivedNotification( + notification = NwcTransaction( + type = NwcTransactionType.INCOMING, + state = NwcTransactionState.SETTLED, + invoice = "lnbc...", + amount = 50000L, + payment_hash = "abc123", + settled_at = TimeUtils.now(), + created_at = TimeUtils.now(), + ), + ), clientPubkey = clientPubkeyHex, signer = walletSigner, ) -// Send notifEvent to the relay ``` -### 5. Build Transaction Objects +## Transaction State Helpers -Transactions are used across multiple response types: +Transaction states from different wallet implementations may use different +casing. Use the case-insensitive helpers: ```kotlin -val transaction = NwcTransaction( - type = NwcTransactionType.INCOMING, // or OUTGOING - state = NwcTransactionState.SETTLED, // PENDING, SETTLED, FAILED, ACCEPTED - invoice = "lnbc50n1...", - description = "Coffee payment", - payment_hash = "abc123def456", - preimage = "fedcba654321", - amount = 50000L, // in millisats - fees_paid = 100L, // in millisats - created_at = 1693876497L, // unix timestamp - settled_at = 1693876500L, - expires_at = 1694876497L, -) +NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled" +NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending" +NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed" +NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted" +``` + +## URI Persistence + +```kotlin +// Save +val json = Nip47WalletConnect.Nip47URI.serializer(nwcConfig.denormalize()!!) + +// Restore +val restored = Nip47WalletConnect.Nip47URI.parser(json).normalize()!! ``` ## Error Codes @@ -431,13 +320,8 @@ val transaction = NwcTransaction( NWC supports two encryption schemes: -- **NIP-04** (default): Set `useNip44 = false` in event builders -- **NIP-44 v2**: Set `useNip44 = true` in event builders - -When building requests with NIP-44, the event includes an `encryption` tag: -``` -["encryption", "nip44_v2"] -``` +- **NIP-04** (default): `Nip47Client(useNip44 = false)` or `useNip44 = false` in event builders +- **NIP-44 v2**: `Nip47Client(useNip44 = true)` or `useNip44 = true` in event builders Clients can check a wallet's supported encryption via the info event: ```kotlin @@ -454,8 +338,6 @@ For apps handling many concurrent NWC events, use the built-in LRU caches: val requestCache = NostrWalletConnectRequestCache(signer) val responseCache = NostrWalletConnectResponseCache(signer) -// These cache up to 50 decrypted results and handle -// async retry logic for permission dialogs and timeouts. val request: Request? = requestCache.decryptRequest(requestEvent) val response: Response? = responseCache.decryptResponse(responseEvent) ``` From 493fe05ac41df28a6a8c72f0d8d5de6f32a6489d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 14 Mar 2026 10:18:08 -0400 Subject: [PATCH 09/14] Fixes missing parameter --- .../com/vitorpamplona/quartz/nip47WalletConnect/Request.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index cdce4c7ea..26e5711a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -129,7 +129,9 @@ class ListTransactionsMethod( offset: Int? = null, unpaid: Boolean? = null, type: String? = null, - ): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, type)) + unpaid_outgoing: Boolean? = null, + unpaid_incoming: Boolean? = null, + ): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type)) } } From 120981f8c4ecad4c17ec33da39741d5b054d8a89 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 14 Mar 2026 10:18:16 -0400 Subject: [PATCH 10/14] spotless apply --- .../ui/navigation/drawer/DrawerContent.kt | 2 +- .../screen/loggedIn/wallet/WalletViewModel.kt | 31 +++++--- .../quartz/nip47WalletConnect/Nip47Client.kt | 15 ++-- .../quartz/nip47WalletConnect/Nip47Server.kt | 17 +++-- .../nip47WalletConnect/AlbyInteropTest.kt | 16 ++++- .../quartz/nip47WalletConnect/RequestTest.kt | 16 ++++- .../quartz/nip47WalletConnect/ResponseTest.kt | 32 +++++++-- .../jackson/ResponseDeserializer.kt | 70 +++++++++++++++---- 8 files changed, 152 insertions(+), 47 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a3682c723..139ad10c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -46,10 +46,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd -import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 817c296e2..69a584025 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -112,9 +112,11 @@ class WalletViewModel : ViewModel() { // NWC balance is in millisats, convert to sats _balanceSats.value = (response.result?.balance ?: 0L) / 1000L } + is NwcErrorResponse -> { _error.value = response.error?.message ?: "Balance request failed" } + else -> {} } _isLoading.value = false @@ -135,6 +137,7 @@ class WalletViewModel : ViewModel() { is GetInfoSuccessResponse -> { _walletAlias.value = response.result?.alias } + else -> {} } } @@ -163,9 +166,11 @@ class WalletViewModel : ViewModel() { is ListTransactionsSuccessResponse -> { _transactions.value = response.result?.transactions ?: emptyList() } + is NwcErrorResponse -> { _error.value = response.error?.message ?: "Failed to load transactions" } + else -> {} } _isLoading.value = false @@ -189,16 +194,21 @@ class WalletViewModel : ViewModel() { // Refresh balance after payment fetchBalance() } + is PayInvoiceErrorResponse -> { - _sendState.value = SendState.Error( - response.error?.message ?: "Payment failed", - ) + _sendState.value = + SendState.Error( + response.error?.message ?: "Payment failed", + ) } + is NwcErrorResponse -> { - _sendState.value = SendState.Error( - response.error?.message ?: "Payment failed", - ) + _sendState.value = + SendState.Error( + response.error?.message ?: "Payment failed", + ) } + else -> { _sendState.value = SendState.Error("Unexpected response") } @@ -234,11 +244,14 @@ class WalletViewModel : ViewModel() { _receiveState.value = ReceiveState.Error("No invoice returned") } } + is NwcErrorResponse -> { - _receiveState.value = ReceiveState.Error( - response.error?.message ?: "Invoice creation failed", - ) + _receiveState.value = + ReceiveState.Error( + response.error?.message ?: "Invoice creation failed", + ) } + else -> { _receiveState.value = ReceiveState.Error("Unexpected response") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt index 5686fc1d5..05cb6e21f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -137,14 +137,12 @@ class Nip47Client( /** * Builds a lookup_invoice request event by payment hash. */ - suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = - buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) + suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) /** * Builds a lookup_invoice request event by BOLT11 invoice. */ - suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = - buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) + suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) /** * Builds a list_transactions request event. @@ -178,20 +176,17 @@ class Nip47Client( descriptionHash: String? = null, expiry: Long? = null, minCltvExpiryDelta: Int? = null, - ): LnZapPaymentRequestEvent = - buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) + ): LnZapPaymentRequestEvent = buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) /** * Builds a cancel_hold_invoice request event. */ - suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = - buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) + suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) /** * Builds a settle_hold_invoice request event. */ - suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = - buildRequest(SettleHoldInvoiceMethod.create(preimage)) + suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = buildRequest(SettleHoldInvoiceMethod.create(preimage)) /** * Builds a request event from any [Request] object. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt index 6b032f3ae..947bbecf8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -169,7 +169,16 @@ class Nip47Server( buildResponse( GetInfoSuccessResponse( GetInfoSuccessResponse.GetInfoResult( - alias, color, pubkey, network, blockHeight, blockHash, methods, notifications, null, lud16, + alias, + color, + pubkey, + network, + blockHeight, + blockHash, + methods, + notifications, + null, + lud16, ), ), requestEvent, @@ -181,8 +190,7 @@ class Nip47Server( suspend fun respondMakeInvoice( requestEvent: LnZapPaymentRequestEvent, transaction: NwcTransaction, - ): LnZapPaymentResponseEvent = - buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) + ): LnZapPaymentResponseEvent = buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) /** * Builds a lookup_invoice success response. @@ -190,8 +198,7 @@ class Nip47Server( suspend fun respondLookupInvoice( requestEvent: LnZapPaymentRequestEvent, transaction: NwcTransaction, - ): LnZapPaymentResponseEvent = - buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) + ): LnZapPaymentResponseEvent = buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) /** * Builds a list_transactions success response. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index c201ab285..6d1d0adb7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -334,8 +334,20 @@ class AlbyInteropTest { assertEquals("018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b", request.params?.preimage) assertNotNull(request.params?.tlv_records) assertEquals(1, request.params?.tlv_records?.size) - assertEquals(5482373484L, request.params?.tlv_records?.first()?.type) - assertEquals("fajsn341414fq", request.params?.tlv_records?.first()?.value) + assertEquals( + 5482373484L, + request.params + ?.tlv_records + ?.first() + ?.type, + ) + assertEquals( + "fajsn341414fq", + request.params + ?.tlv_records + ?.first() + ?.value, + ) } @Test 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 3299b0927..21816b647 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -93,8 +93,20 @@ class RequestTest { assertEquals("preimage123", request.params?.preimage) assertNotNull(request.params?.tlv_records) assertEquals(1, request.params?.tlv_records?.size) - assertEquals(7629169L, request.params?.tlv_records?.first()?.type) - assertEquals("hex_value", request.params?.tlv_records?.first()?.value) + assertEquals( + 7629169L, + request.params + ?.tlv_records + ?.first() + ?.type, + ) + assertEquals( + "hex_value", + request.params + ?.tlv_records + ?.first() + ?.value, + ) } @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 148483360..c26e05bf0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -128,10 +128,34 @@ class ResponseTest { assertIs(response) assertNotNull(response.result?.transactions) assertEquals(2, response.result?.transactions?.size) - assertEquals("incoming", response.result?.transactions?.get(0)?.type) - assertEquals(100L, response.result?.transactions?.get(0)?.amount) - assertEquals("outgoing", response.result?.transactions?.get(1)?.type) - assertEquals(200L, response.result?.transactions?.get(1)?.amount) + assertEquals( + "incoming", + response.result + ?.transactions + ?.get(0) + ?.type, + ) + assertEquals( + 100L, + response.result + ?.transactions + ?.get(0) + ?.amount, + ) + assertEquals( + "outgoing", + response.result + ?.transactions + ?.get(1) + ?.type, + ) + assertEquals( + 200L, + response.result + ?.transactions + ?.get(1) + ?.amount, + ) } @Test 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 c9b210ad9..ccb98c8b7 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 @@ -56,7 +56,10 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { if (hasError) { return when (resultType) { - NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + 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) @@ -66,19 +69,58 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { 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.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java) - NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java) - NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::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) + 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.GET_BUDGET -> { + jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java) + } + + NwcMethod.SIGN_MESSAGE -> { + jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java) + } + + NwcMethod.CREATE_CONNECTION -> { + jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::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) { From 2fd623a97d3c0f3bc2751f63e62c3c31933b4da4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 14:30:38 +0000 Subject: [PATCH 11/14] fix: wallet screen showing "No wallet connected" on first open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WalletViewModel.init() was called inside LaunchedEffect (async), but hasWalletSetup() was checked synchronously during the first composition frame—before init had run. Moved init to run synchronously during composition and made hasWalletSetup a reactive StateFlow so the UI updates when wallet state changes. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt | 7 +++---- .../amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt | 8 +++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index ef0d94800..8ef787815 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -73,10 +73,9 @@ fun WalletScreen( nav: INav, ) { val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel.account) - LaunchedEffect(accountViewModel) { - walletViewModel.init(accountViewModel.account) - } + val hasWallet by walletViewModel.hasWalletSetup.collectAsState() Scaffold( topBar = { @@ -93,7 +92,7 @@ fun WalletScreen( ) }, ) { padding -> - if (!walletViewModel.hasWalletSetup()) { + if (!hasWallet) { NoWalletSetup( modifier = Modifier.padding(padding), nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 69a584025..ff670665d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -73,6 +73,9 @@ sealed class ReceiveState { class WalletViewModel : ViewModel() { private var account: Account? = null + private val _hasWalletSetup = MutableStateFlow(false) + val hasWalletSetup = _hasWalletSetup.asStateFlow() + private val _balanceSats = MutableStateFlow(null) val balanceSats = _balanceSats.asStateFlow() @@ -96,9 +99,12 @@ class WalletViewModel : ViewModel() { fun init(account: Account) { this.account = account + _hasWalletSetup.value = account.nip47SignerState?.hasWalletConnectSetup() == true } - fun hasWalletSetup(): Boolean = account?.nip47SignerState?.hasWalletConnectSetup() == true + fun refreshWalletSetup() { + _hasWalletSetup.value = account?.nip47SignerState?.hasWalletConnectSetup() == true + } fun fetchBalance() { val acc = account ?: return From 84035f887a6cc90e01b9dab390a6dd175f65af29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:18:53 +0000 Subject: [PATCH 12/14] feat: parse NIP-47 transaction metadata for sender/recipient display Add NwcTransactionMetadata parser that extracts comment, payer_data, recipient_data, and nostr zap data from the untyped metadata field. The transaction list UI now shows the Nostr user profile picture and name for zap senders/recipients, falls back to payer name/email or lightning address, and displays comment when it differs from description. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../wallet/WalletTransactionsScreen.kt | 158 ++++++++++++++---- .../nip47WalletConnect/NwcTransaction.kt | 4 +- .../NwcTransactionMetadata.kt | 106 ++++++++++++ .../nip47WalletConnect/AlbyInteropTest.kt | 72 ++++++++ 4 files changed, 311 insertions(+), 29 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index a7df6653b..fb3a97ddd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -59,7 +59,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransactionType @@ -143,7 +146,7 @@ fun WalletTransactionsScreen( modifier = Modifier.padding(padding), ) { items(transactions) { tx -> - TransactionItem(tx) + TransactionItem(tx, accountViewModel, nav) HorizontalDivider() } } @@ -152,7 +155,11 @@ fun WalletTransactionsScreen( } @Composable -private fun TransactionItem(tx: NwcTransaction) { +private fun TransactionItem( + tx: NwcTransaction, + accountViewModel: AccountViewModel, + nav: INav, +) { val isIncoming = tx.type == NwcTransactionType.INCOMING val amountSats = (tx.amount ?: 0L) / 1000L val formattedAmount = @@ -169,6 +176,36 @@ private fun TransactionItem(tx: NwcTransaction) { } ?: "" } + val parsed = remember(tx.metadata) { tx.parsedMetadata() } + + // For incoming: show who sent it (nostr pubkey or payer name/email) + // For outgoing: show who received it (nostr recipient or recipient identifier) + val counterpartyPubkeyHex = + remember(parsed) { + if (isIncoming) parsed?.senderPubkeyHex() else parsed?.recipientPubkeyHex() + } + + val counterpartyDisplayName = + remember(parsed) { + if (isIncoming) { + parsed?.senderDisplayName() + } else { + parsed?.recipientIdentifier() + } + } + + // Show comment only if it differs from description + val commentText = + remember(parsed, tx.description) { + parsed?.comment?.let { comment -> + if (tx.description == null || !comment.equals(tx.description, ignoreCase = true)) { + comment + } else { + null + } + } + } + Row( modifier = Modifier @@ -176,34 +213,74 @@ private fun TransactionItem(tx: NwcTransaction) { .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = - if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, - contentDescription = - if (isIncoming) { - stringRes(R.string.wallet_incoming) - } else { - stringRes(R.string.wallet_outgoing) - }, - modifier = Modifier.size(24.dp), - tint = - if (isIncoming) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - - Spacer(modifier = Modifier.width(12.dp)) + if (counterpartyPubkeyHex != null) { + UserPicture( + userHex = counterpartyPubkeyHex, + size = 40.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = Modifier.width(12.dp)) + } else { + Icon( + imageVector = + if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, + contentDescription = + if (isIncoming) { + stringRes(R.string.wallet_incoming) + } else { + stringRes(R.string.wallet_outgoing) + }, + modifier = Modifier.size(24.dp), + tint = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + } Column(modifier = Modifier.weight(1f)) { - Text( - text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (counterpartyPubkeyHex != null) { + TransactionUserName(counterpartyPubkeyHex, counterpartyDisplayName, accountViewModel) + } else if (counterpartyDisplayName != null) { + Text( + text = counterpartyDisplayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (commentText != null) { + Text( + text = commentText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else if (counterpartyPubkeyHex != null || counterpartyDisplayName != null) { + val descOrType = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing) + Text( + text = descOrType, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( text = dateText, style = MaterialTheme.typography.bodySmall, @@ -224,3 +301,28 @@ private fun TransactionItem(tx: NwcTransaction) { ) } } + +@Composable +private fun TransactionUserName( + pubkeyHex: String, + fallbackName: String?, + accountViewModel: AccountViewModel, +) { + LoadUser(baseUserHex = pubkeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Medium, + accountViewModel = accountViewModel, + ) + } else { + Text( + text = fallbackName ?: pubkeyHex.take(8) + "...", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt index 067f12cd9..adff64b56 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -63,7 +63,9 @@ class NwcTransaction( var settled_at: Long? = null, var settle_deadline: Long? = null, var metadata: Any? = null, -) +) { + fun parsedMetadata(): NwcTransactionMetadata? = NwcTransactionMetadata.parse(metadata) +} class TlvRecord( var type: Long? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt new file mode 100644 index 000000000..a18be518a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt @@ -0,0 +1,106 @@ +/* + * 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.nip19Bech32.decodePublicKeyAsHexOrNull + +class NwcTransactionMetadata( + val comment: String?, + val payerData: PayerData?, + val recipientData: RecipientData?, + val nostr: NostrZapData?, +) { + class PayerData( + val name: String?, + val email: String?, + val pubkey: String?, + ) + + class RecipientData( + val identifier: String?, + ) + + class NostrZapData( + val pubkeyHex: String?, + val recipientPubkeyHex: String?, + ) + + fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) } + + fun senderDisplayName(): String? = payerData?.name ?: payerData?.email + + fun recipientIdentifier(): String? = recipientData?.identifier + + fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex + + companion object { + fun parse(metadata: Any?): NwcTransactionMetadata? { + val map = metadata as? Map<*, *> ?: return null + + val comment = map["comment"] as? String + + val payerData = (map["payer_data"] as? Map<*, *>)?.let { pd -> + PayerData( + name = pd["name"] as? String, + email = pd["email"] as? String, + pubkey = pd["pubkey"] as? String, + ) + } + + val recipientData = (map["recipient_data"] as? Map<*, *>)?.let { rd -> + RecipientData( + identifier = rd["identifier"] as? String, + ) + } + + val nostr = (map["nostr"] as? Map<*, *>)?.let { n -> + val rawPubkey = n["pubkey"] as? String + val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) } + + val tags = n["tags"] as? List<*> + val recipientHex = tags?.firstNotNullOfOrNull { tag -> + val tagList = tag as? List<*> + if (tagList != null && tagList.size >= 2 && tagList[0] == "p") { + tagList[1] as? String + } else { + null + } + } + + NostrZapData( + pubkeyHex = pubkeyHex, + recipientPubkeyHex = recipientHex, + ) + } + + if (comment == null && payerData == null && recipientData == null && nostr == null) { + return null + } + + return NwcTransactionMetadata( + comment = comment, + payerData = payerData, + recipientData = recipientData, + nostr = nostr, + ) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index 6d1d0adb7..f6221cdc6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -500,6 +500,78 @@ class AlbyInteropTest { assertNotNull(response.result?.metadata) } + @Test + fun testMetadataParserComment() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Great post!"}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("Great post!", parsed.comment) + assertNull(parsed.payerData) + assertNull(parsed.nostr) + } + + @Test + fun testMetadataParserPayerData() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"payer_data":{"name":"Alice","email":"alice@example.com","pubkey":"abc123"}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("Alice", parsed.payerData?.name) + assertEquals("alice@example.com", parsed.payerData?.email) + assertEquals("abc123", parsed.payerData?.pubkey) + assertEquals("Alice", parsed.senderDisplayName()) + } + + @Test + fun testMetadataParserNostrZap() { + val senderHex = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + val recipientHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":21000,"created_at":1000,"settled_at":2000,"metadata":{"nostr":{"pubkey":"$senderHex","tags":[["p","$recipientHex"],["amount","21000"]]}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals(senderHex, parsed.senderPubkeyHex()) + assertEquals(recipientHex, parsed.recipientPubkeyHex()) + } + + @Test + fun testMetadataParserRecipientData() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"outgoing","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"recipient_data":{"identifier":"alice@getalby.com"}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("alice@getalby.com", parsed.recipientIdentifier()) + } + + @Test + fun testMetadataParserNullForSimpleMetadata() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"a":123}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNull(parsed) + } + + @Test + fun testMetadataParserNullMetadata() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNull(parsed) + } + @Test fun testJsSdkGetInfoWithAllMethods() { // JS SDK advertises all 13 single methods + notifications From 03e1c32757035f65901f2561f5b97521fbf8b351 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 14 Mar 2026 11:20:33 -0400 Subject: [PATCH 13/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../quartz/nip47WalletConnect/NwcNotificationEvent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt index 9e9af6b2c..d5884bbb1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt @@ -48,7 +48,7 @@ class NwcNotificationEvent( suspend fun decryptNotification(signer: NostrSigner): Notification { if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - val jsonText = signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + val jsonText = signer.decrypt(content, talkingWith(signer.pubKey)) return OptimizedJsonMapper.fromJsonTo(jsonText) } From 78c66b00ebf0600d8805a325709fd3b5fd16fc9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:29:19 +0000 Subject: [PATCH 14/14] fix: align arrow icons to 40dp to match user picture size https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index fb3a97ddd..0f1818809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -231,7 +231,7 @@ private fun TransactionItem( } else { stringRes(R.string.wallet_outgoing) }, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(40.dp), tint = if (isIncoming) { MaterialTheme.colorScheme.primary