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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
This commit is contained in:
Claude
2026-07-24 21:34:32 +00:00
parent 5d72a0415c
commit c2f6aa9992
3 changed files with 235 additions and 0 deletions
@@ -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()
}
@@ -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(
@@ -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"))
}
}