mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+119
@@ -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()
|
||||
}
|
||||
+2
@@ -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(
|
||||
|
||||
+114
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user