Refactors the old NIP-05 code on Quartz

New Caching system for User metadata
New Caching system for NIP-05 verifications
This commit is contained in:
Vitor Pamplona
2026-02-04 14:31:30 -05:00
parent 92d4654b20
commit 7bc7265757
98 changed files with 1425 additions and 891 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.tags.PronounsTag
import com.vitorpamplona.quartz.nip01Core.metadata.tags.WebsiteTag
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
@@ -62,6 +64,8 @@ class MetadataEvent(
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
fun contactMetadataJson() =
try {
Json.parseToJsonElement(content) as JsonObject
@@ -82,6 +86,13 @@ class MetadataEvent(
companion object {
const val KIND = 0
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun newUser(
name: String?,
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip01Core.metadata
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -38,21 +37,13 @@ class UserMetadata {
var about: String? = null
var bot: Boolean? = null
var pronouns: String? = null
var nip05: String? = null
var nip05Verified: Boolean = false
var nip05LastVerificationTime: Long? = 0
var domain: String? = null
var lud06: String? = null
var lud16: String? = null
var twitter: String? = null
@kotlinx.serialization.Transient
@kotlin.jvm.Transient
var tags: ImmutableListOfLists<String>? = null
fun anyName(): String? = displayName ?: name
fun anyNameStartsWith(prefix: String): Boolean =
@@ -64,6 +55,22 @@ class UserMetadata {
fun bestName(): String? = displayName ?: name
fun firstName(): String? {
val fullName = bestName() ?: return null
val names = fullName.split(' ')
val firstName =
if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
return firstName
}
fun nip05(): String? = nip05
fun profilePicture(): String? = picture
@@ -1,72 +0,0 @@
/**
* 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.nip05DnsIdentifiers
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
class Nip05 {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
}
if (parts.size == 1) {
return "https://${parts[0]}/.well-known/nostr.json?name=_"
}
return null
}
fun parseHexKeyFor(
nip05: String,
returnBody: String,
): Result<String?> {
val parts = nip05.split("@")
val user =
if (parts.size == 2) {
parts[0].lowercase()
} else {
"_"
}
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
return try {
val rootElement = Json.parseToJsonElement(returnBody.lowercase())
val hexKey =
rootElement.jsonObject["names"]
?.jsonObject[user]
?.jsonPrimitive
?.content
Result.success(hexKey)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("Nip05", "Unable to Parse NIP-05 for $nip05 with $returnBody", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,84 @@
/**
* 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.nip05DnsIdentifiers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CancellationException
data class Nip05KeyInfo(
val pubkey: HexKey,
val relays: List<String>,
)
class Nip05Client(
val fetcher: Nip05Fetcher,
) {
val parser = Nip05Parser()
suspend fun verify(
nip05: Nip05Id,
hexKey: HexKey,
): Boolean {
val json = fetchNip05Data(nip05)
val key =
try {
parser.parseHexKey(nip05, json)
} catch (e: Exception) {
if (e is CancellationException) throw e
throw IllegalStateException("Error Parsing JSON from NIP-05 address $nip05", e)
}
return if (key == null) {
false
} else {
key == hexKey
}
}
suspend fun get(nip05: Nip05Id) = parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05))
suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05))
suspend fun list(domain: String) = parser.parse(fetchNip05Data(domain))
suspend fun fetchNip05Data(nip05: Nip05Id): String {
val url = nip05.toUserUrl()
return try {
fetcher.fetch(url)
} catch (e: Exception) {
if (e is CancellationException) throw e
throw IllegalStateException("Error Fetching JSON from NIP-05 address $nip05 at $url", e)
}
}
suspend fun fetchNip05Data(domain: String): String {
val url = Nip05Id.domainUrl(domain)
return try {
fetcher.fetch(url)
} catch (e: Exception) {
if (e is CancellationException) throw e
throw IllegalStateException("Error Fetching JSON from the entire NIP-05 domain $domain at $url", e)
}
}
}
@@ -18,15 +18,8 @@
* 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.nip01Core.core
package com.vitorpamplona.quartz.nip05DnsIdentifiers
import androidx.compose.runtime.Stable
@Stable
class ImmutableListOfLists<T>(
val lists: Array<Array<T>>,
)
val EmptyTagList = ImmutableListOfLists<String>(emptyArray())
fun Array<Array<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> = ImmutableListOfLists(this)
interface Nip05Fetcher {
suspend fun fetch(url: String): String
}
@@ -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.nip05DnsIdentifiers
import androidx.compose.runtime.Stable
@Stable
data class Nip05Id(
val name: String,
val domain: String,
) {
fun toValue(): String = assemble(name, domain)
fun toUserUrl(): String = userUrl(name, domain)
fun toDomainUrl(): String = domainUrl(domain)
companion object {
fun parse(nip05address: String): Nip05Id? {
val parts = nip05address.trim().lowercase().split("@")
return when (parts.size) {
2 -> Nip05Id(parts[0], parts[1])
1 -> Nip05Id(parts[0], "_")
else -> null
}
}
fun assemble(
name: String,
domain: String,
) = "$name@$domain"
fun userUrl(
name: String,
domain: String,
) = "https://$domain/.well-known/nostr.json?name=$name"
fun domainUrl(domain: String) = "https://$domain/.well-known/nostr.json"
}
}
@@ -0,0 +1,79 @@
/**
* 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.nip05DnsIdentifiers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.text
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Serializable
data class KeyInfoSet(
val names: Map<String, String>,
val relays: Map<String, List<String>>,
)
class Nip05Parser {
fun toJson(keyInfo: KeyInfoSet) = Json.encodeToString(keyInfo)
fun parse(json: String) = Json.decodeFromString<KeyInfoSet>(json)
fun parseHexKey(
nip05: Nip05Id,
json: String,
): HexKey? =
Json
.parseToJsonElement(json)
.jsonObject["names"]
?.jsonObject[nip05.name]
?.jsonPrimitive
?.content
fun parseHexKeyAndRelays(
nip05: Nip05Id,
json: String,
): Nip05KeyInfo? {
val rootElement = Json.parseToJsonElement(json)
val hexKey =
rootElement.jsonObject["names"]
?.jsonObject[nip05.name]
?.jsonPrimitive
?.content
return if (!hexKey.isNullOrEmpty()) {
val relays =
rootElement.jsonObject["relays"]
?.jsonObject[hexKey]
?.jsonArray
?.map {
it.jsonPrimitive.text
} ?: emptyList()
Nip05KeyInfo(hexKey, relays)
} else {
null
}
}
}
@@ -45,6 +45,8 @@ object TimeUtils {
fun fiveMinutesAgo() = now() - FIVE_MINUTES
fun fiveMinutesAhead() = now() + FIVE_MINUTES
fun fifteenMinutesAgo() = now() - FIFTEEN_MINUTES
fun oneHourAgo() = now() - ONE_HOUR
@@ -23,45 +23,66 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.fail
class Nip05Test {
companion object {
private val ALL_UPPER_CASE_USER_NAME = "ONETWO"
private val ALL_LOWER_CASE_USER_NAME = "onetwo"
private const val ALL_UPPER_CASE_USER_NAME = "ONETWO"
private const val ALL_LOWER_CASE_USER_NAME = "onetwo"
}
var nip05Verifier = Nip05()
val parser = Nip05Parser()
val baseTestJson =
"""
{
"names": {
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
},
"relays": {
"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9": [ "wss://relay.example.com", "wss://relay2.example.com" ]
}
}
""".trimIndent()
@Test
fun `test with matching case on user name`() =
runTest {
// Set-up
val userNameToTest = ALL_UPPER_CASE_USER_NAME
val userNameToTest = ALL_LOWER_CASE_USER_NAME
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
val nostrJson = "{\n \"names\": {\n \"$userNameToTest\": \"$expectedPubKey\" \n }\n}"
val nip05 = "$userNameToTest@domain.com"
nip05Verifier.parseHexKeyFor(nip05, nostrJson).fold(
onSuccess = { assertEquals(expectedPubKey, it) },
onFailure = { fail("Test failure") },
)
val parsedNip05 = Nip05Id.parse(nip05)
assertNotNull(parsedNip05)
assertEquals(expectedPubKey, parser.parseHexKey(parsedNip05, nostrJson))
}
@Test
fun `test with NOT matching case on user name`() =
fun `test failure of lowercase name with uppercase in the json`() =
runTest {
// Set-up
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
val nostrJson = "{ \"names\": { \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" }}"
val nip05 = "$ALL_LOWER_CASE_USER_NAME@domain.com"
val parsedNip05 = Nip05Id.parse(nip05)
assertNotNull(parsedNip05)
assertEquals(null, parser.parseHexKey(parsedNip05, nostrJson))
}
nip05Verifier.parseHexKeyFor(nip05, nostrJson).fold(
onSuccess = { assertEquals(expectedPubKey, it) },
onFailure = { fail("Test failure") },
)
@Test
fun `test uppercase name with lowercase name in the json`() =
runTest {
// Set-up
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
val nostrJson = "{ \"names\": { \"$ALL_LOWER_CASE_USER_NAME\": \"$expectedPubKey\" }}"
val nip05 = "$ALL_UPPER_CASE_USER_NAME@domain.com"
val parsedNip05 = Nip05Id.parse(nip05)
assertNotNull(parsedNip05)
assertEquals(expectedPubKey, parser.parseHexKey(parsedNip05, nostrJson))
}
@Test
@@ -69,11 +90,8 @@ class Nip05Test {
// given
val nip05address = "this@that@that.com"
// when
val actualValue = nip05Verifier.assembleUrl(nip05address)
// then
assertNull(actualValue)
val parsedNip05 = Nip05Id.parse(nip05address)
assertNull(parsedNip05)
}
@Test
@@ -82,12 +100,52 @@ class Nip05Test {
val userName = "TheUser"
val domain = "domain.com"
val nip05address = "$userName@$domain"
val expectedValue = "https://$domain/.well-known/nostr.json?name=$userName"
// when
val actualValue = nip05Verifier.assembleUrl(nip05address)
val parsedNip05 = Nip05Id.parse(nip05address)
assertNotNull(parsedNip05)
// then
assertEquals(expectedValue, actualValue)
assertEquals("https://$domain/.well-known/nostr.json?name=${userName.lowercase()}", parsedNip05.toUserUrl())
assertEquals("https://$domain/.well-known/nostr.json", parsedNip05.toDomainUrl())
}
@Test
fun `test json parsing with relays`() {
val parsedNip05 = Nip05Id.parse("bob@test.com")
assertNotNull(parsedNip05)
val result = parser.parseHexKeyAndRelays(parsedNip05, baseTestJson)
assertNotNull(result)
assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result.pubkey)
assertEquals(
listOf("wss://relay.example.com", "wss://relay2.example.com"),
result.relays,
)
}
@Test
fun `test full json parsing with relays`() {
val result = parser.parse(baseTestJson)
assertNotNull(result)
assertEquals(1, result.names.size)
assertEquals(1, result.relays.size)
assertEquals("bob", result.names.keys.first())
assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result.names["bob"])
assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result.relays.keys.first())
assertEquals(
listOf("wss://relay.example.com", "wss://relay2.example.com"),
result.relays["b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"],
)
}
@Test
fun `test full json generation with relays`() {
val result = parser.parse(baseTestJson)
val newResult = parser.parse(parser.toJson(result))
assertEquals(result, newResult)
}
}
@@ -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.nip05DnsIdentifiers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
class OkHttpNip05Fetcher(
val okHttpClient: (String) -> OkHttpClient,
) : Nip05Fetcher {
override suspend fun fetch(url: String): String =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
val client = okHttpClient(url).newBuilder().followRedirects(false).build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
response.body.string()
} else {
throw IllegalStateException("Error: ${response.code}, ${response.message}")
}
}
}
}
}