From c2f6aa9992cd1427a589d83db95463945feddce2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:34:32 +0000 Subject: [PATCH] feat(nip47): add NWC-07 deep-link helper and NIP-44 request opt-in Add Nip47DeepLink for the NWC-07 same-device pairing convention: build/parse the `nostrnwc://connect` request (client -> wallet) and the callback URI that returns the `nostr+walletconnect://` pairing code (wallet -> client). All params are URI-encoded per the spec. Also thread `useNip44` through LnZapPaymentRequestEvent.create so pay_invoice requests can opt into NIP-44, matching createRequest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs --- .../nip47WalletConnect/Nip47DeepLink.kt | 119 ++++++++++++++++++ .../events/LnZapPaymentRequestEvent.kt | 2 + .../nip47WalletConnect/Nip47DeepLinkTest.kt | 114 +++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLink.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLinkTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLink.kt new file mode 100644 index 0000000000..4d1f26b026 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLink.kt @@ -0,0 +1,119 @@ +/* + * 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.UriParser +import com.vitorpamplona.quartz.utils.UrlEncoder + +/** + * NWC-07 deep-link pairing conventions. + * + * These deep links let a NWC **client** (e.g. Amethyst) and a NWC **wallet** app + * installed on the *same device* pair without QR codes or manual copy/paste: + * + * 1. The client opens `nostrnwc://connect?appname=…&appicon=…&callback=…` (or the + * app-scoped `nostrnwc+{app}://connect` variant to target a specific wallet). + * 2. The wallet creates a connection and opens the client's `callback` URI with + * the resulting `nostr+walletconnect://…` pairing code in a `value` parameter. + * 3. The client parses `value` with [Nip47WalletConnect.parse] and stores it. + * + * The pairing code carried in `value` is exactly the connection string this module + * already understands — this deep link is only the transport used to obtain one. + * + * All URI parameters MUST be URI-encoded (NWC-07). + */ +object Nip47DeepLink { + const val SCHEME = "nostrnwc" + const val HOST = "connect" + + /** + * A parsed `nostrnwc://connect` request (wallet side). + */ + class ConnectRequest( + val callback: String, + val appName: String? = null, + val appIcon: String? = null, + ) + + /** + * Builds the outgoing deep link a NWC client opens to ask a wallet app on the + * same device to create a connection. + * + * @param callback the URI scheme the wallet must open to return the pairing code + * @param appName human-readable name of the requesting client + * @param appIcon URL of the requesting client's icon + * @param walletAppName optional wallet selector; when set, targets + * `nostrnwc+{walletAppName}://connect` instead of the generic + * `nostrnwc://connect`. + */ + fun buildConnectUri( + callback: String, + appName: String? = null, + appIcon: String? = null, + walletAppName: String? = null, + ): String { + val scheme = if (walletAppName.isNullOrBlank()) SCHEME else "$SCHEME+$walletAppName" + val params = + buildList { + appName?.let { add("appname=" + UrlEncoder.encode(it)) } + appIcon?.let { add("appicon=" + UrlEncoder.encode(it)) } + add("callback=" + UrlEncoder.encode(callback)) + } + return "$scheme://$HOST?" + params.joinToString("&") + } + + /** + * Parses an incoming `nostrnwc://connect` (or `nostrnwc+{app}://connect`) + * request. Returns null when the URI is not a NWC connect deep link or has no + * callback. + */ + fun parseConnectUri(uri: String): ConnectRequest? { + val parser = UriParser(uri) + val scheme = parser.scheme() ?: return null + if (scheme != SCHEME && !scheme.startsWith("$SCHEME+")) return null + + val callback = parser.getQueryParameter("callback")?.firstOrNull() ?: return null + return ConnectRequest( + callback = callback, + appName = parser.getQueryParameter("appname")?.firstOrNull(), + appIcon = parser.getQueryParameter("appicon")?.firstOrNull(), + ) + } + + /** + * Builds the callback URI a wallet opens to return a pairing code to the + * client (wallet side). The pairing code is placed in a `value` parameter. + */ + fun buildCallbackUri( + callback: String, + pairingCode: String, + ): String { + val separator = if (callback.contains('?')) '&' else '?' + return callback + separator + "value=" + UrlEncoder.encode(pairingCode) + } + + /** + * Extracts the `nostr+walletconnect://…` pairing code returned by a wallet in + * a callback deep link (client side). Returns null when the `value` parameter + * is absent. + */ + fun parseCallbackValue(uri: String): String? = UriParser(uri).getQueryParameter("value")?.firstOrNull() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt index e9de87634e..010ebbef74 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt @@ -63,12 +63,14 @@ class LnZapPaymentRequestEvent( walletServicePubkey: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), + useNip44: Boolean = false, ): LnZapPaymentRequestEvent = createRequest( PayInvoiceMethod.create(lnInvoice), walletServicePubkey, signer, createdAt, + useNip44, ) suspend fun createRequest( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLinkTest.kt new file mode 100644 index 0000000000..b0f1c773de --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47DeepLinkTest.kt @@ -0,0 +1,114 @@ +/* + * 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.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Nip47DeepLinkTest { + private val nwcUri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4" + + "?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5" + + @Test + fun testBuildConnectUri() { + val uri = + Nip47DeepLink.buildConnectUri( + callback = "amethystnwc://callback", + appName = "Amethyst", + appIcon = "https://amethyst.social/icon.png", + ) + + assertTrue(uri.startsWith("nostrnwc://connect?")) + // All params URI-encoded. + assertTrue(uri.contains("appname=Amethyst")) + assertTrue(uri.contains("appicon=https%3A%2F%2Famethyst.social%2Ficon.png")) + assertTrue(uri.contains("callback=amethystnwc%3A%2F%2Fcallback")) + } + + @Test + fun testBuildConnectUriWithWalletSelector() { + val uri = + Nip47DeepLink.buildConnectUri( + callback = "amethystnwc://callback", + appName = "Amethyst", + walletAppName = "alby", + ) + + assertTrue(uri.startsWith("nostrnwc+alby://connect?")) + } + + @Test + fun testConnectUriRoundTrip() { + val uri = + Nip47DeepLink.buildConnectUri( + callback = "amethystnwc://callback", + appName = "Amethyst", + appIcon = "https://amethyst.social/icon.png", + ) + + val parsed = Nip47DeepLink.parseConnectUri(uri) + assertNotNull(parsed) + assertEquals("amethystnwc://callback", parsed.callback) + assertEquals("Amethyst", parsed.appName) + assertEquals("https://amethyst.social/icon.png", parsed.appIcon) + } + + @Test + fun testParseConnectUriRejectsNonNwcScheme() { + assertNull(Nip47DeepLink.parseConnectUri("https://example.com/connect?callback=x")) + } + + @Test + fun testParseConnectUriRequiresCallback() { + assertNull(Nip47DeepLink.parseConnectUri("nostrnwc://connect?appname=Amethyst")) + } + + @Test + fun testCallbackRoundTrip() { + val callbackUri = Nip47DeepLink.buildCallbackUri("amethystnwc://callback", nwcUri) + + // The pairing code must be URI-encoded inside the value param. + assertTrue(callbackUri.contains("value=nostr%2Bwalletconnect")) + + val value = Nip47DeepLink.parseCallbackValue(callbackUri) + assertEquals(nwcUri, value) + + // And the returned value parses as a normal NWC connection URI. + val config = Nip47WalletConnect.parse(value!!) + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", config.pubKeyHex) + } + + @Test + fun testBuildCallbackUriWhenCallbackAlreadyHasQuery() { + val callbackUri = Nip47DeepLink.buildCallbackUri("myapp://cb?foo=bar", nwcUri) + assertTrue(callbackUri.contains("myapp://cb?foo=bar&value=")) + assertEquals(nwcUri, Nip47DeepLink.parseCallbackValue(callbackUri)) + } + + @Test + fun testParseCallbackValueAbsent() { + assertNull(Nip47DeepLink.parseCallbackValue("amethystnwc://callback")) + } +}