Fix follow list import: validate pubkey length + add crash monitoring

Root cause: decodePublicKeyAsHexOrNull() returns truncated hex for
non-bech32 input (its else branch runs Hex.decode on arbitrary strings).
The relay then rejects the filter with 'Invalid author length'.

Fixes:
- Check raw 64-char hex FIRST (before bech32 parsing)
- Only attempt bech32 decode for strings starting with npub1/nprofile1/nsec1
- Validate all resolved pubkeys are exactly 64 hex chars
- Show specific error messages per identifier type instead of generic fallback
- Improved debug logging with pubkey prefix for each resolution path

Crash monitoring:
- Added Thread.setDefaultUncaughtExceptionHandler in Main.kt
- Logs crashes to ~/.amethyst-desktop-crash.log with timestamp and full stack trace
- Also prints to stderr for Gradle console visibility
This commit is contained in:
M
2026-03-24 09:27:03 +11:00
committed by m
parent 0bc1c2838d
commit 0b636d62aa
@@ -71,7 +71,6 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -79,7 +78,6 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -193,6 +191,15 @@ fun ImportFollowListDialog(
activeSubscriptions.add(subId)
var receivedContactList = false
// Use connected relays only — available relays may include disconnected ones
val relays = relayManager.connectedRelays.value
println("[ImportFollows] Subscribing for kind 3 of $pubkey on ${relays.size} connected relays: $relays")
if (relays.isEmpty()) {
importState = ImportState.Error("No relays connected. Check your relay settings.")
return@LaunchedEffect
}
relayManager.subscribe(
subId = subId,
filters = listOf(
@@ -202,6 +209,7 @@ fun ImportFollowListDialog(
limit = 1,
),
),
relays = relays,
listener = object : IRequestListener {
override fun onEvent(
event: Event,
@@ -209,6 +217,7 @@ fun ImportFollowListDialog(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
println("[ImportFollows] Received event kind=${event.kind} from $relay id=${event.id.take(12)}")
if (event.kind == ContactListEvent.KIND && !receivedContactList) {
receivedContactList = true
val contactList = ContactListEvent(
@@ -220,11 +229,16 @@ fun ImportFollowListDialog(
event.sig,
)
val follows = contactList.unverifiedFollowKeySet()
followEntries.clear()
followEntries.addAll(
follows.map { FollowEntry(pubkey = it, selected = true) },
)
importState = ImportState.FollowListLoaded(sourcePubkey = pubkey)
// Dispatch state updates to main thread (relay callbacks
// run on arbitrary threads; Compose state is not thread-safe)
scope.launch(Dispatchers.Main) {
followEntries.clear()
followEntries.addAll(
follows.map { FollowEntry(pubkey = it, selected = true) },
)
importState = ImportState.FollowListLoaded(sourcePubkey = pubkey)
}
// Clean up the contact list subscription
try {
@@ -253,16 +267,20 @@ fun ImportFollowListDialog(
forFilters: List<Filter>?,
) {
if (event.kind == MetadataEvent.KIND) {
val metadata = try {
JsonMapper.fromJson<UserMetadata>(event.content)
val bestName = try {
val metaJson = jacksonObjectMapper().readTree(event.content)
metaJson.get("display_name")?.asText()?.takeIf { it.isNotBlank() }
?: metaJson.get("name")?.asText()?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
val bestName = metadata?.bestName()
if (bestName != null) {
val idx = followEntries.indexOfFirst { it.pubkey == event.pubKey }
if (idx >= 0) {
followEntries[idx] = followEntries[idx].copy(displayName = bestName)
// Dispatch to main thread
scope.launch(Dispatchers.Main) {
val idx = followEntries.indexOfFirst { it.pubkey == event.pubKey }
if (idx >= 0) {
followEntries[idx] = followEntries[idx].copy(displayName = bestName)
}
}
}
}
@@ -296,13 +314,12 @@ fun ImportFollowListDialog(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// If we got EOSE from all relays without a contact list,
// the timeout below will handle showing the error
println("[ImportFollows] EOSE from $relay receivedContactList=$receivedContactList")
}
},
)
// Timeout: if no contact list received after 15s, show error or empty result
// Timeout: if no contact list received after 15s, show error
scope.launch {
delay(15_000)
if (importState is ImportState.FetchingFollowList) {
@@ -338,38 +355,52 @@ fun ImportFollowListDialog(
scope.launch {
try {
// Try bech32 (npub/nprofile) first
val bech32Result = decodePublicKeyAsHexOrNull(trimmed)
if (bech32Result != null) {
importState = ImportState.IdentifierResolved(bech32Result)
return@launch
}
// Try raw hex pubkey
if (trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) {
// Try raw hex pubkey first (exact 64-char hex)
if (trimmed.length == 64 && trimmed.matches(Regex("^[0-9a-fA-F]{64}$"))) {
println("[ImportFollows] Resolved raw hex: ${trimmed.take(16)}...")
importState = ImportState.IdentifierResolved(trimmed.lowercase())
return@launch
}
// Try Namecoin
// Try bech32 (npub/nprofile) — only accept if it starts with known prefixes
if (trimmed.startsWith("npub1") || trimmed.startsWith("nprofile1") || trimmed.startsWith("nsec1")) {
val bech32Result = decodePublicKeyAsHexOrNull(trimmed)
if (bech32Result != null && bech32Result.length == 64) {
println("[ImportFollows] Resolved bech32 to ${bech32Result.take(16)}...")
importState = ImportState.IdentifierResolved(bech32Result)
return@launch
}
importState = ImportState.Error("Invalid npub/nprofile — could not decode")
return@launch
}
// Try Namecoin (.bit, d/, id/)
if (NamecoinNameResolver.isNamecoinIdentifier(trimmed) && namecoinService != null) {
println("[ImportFollows] Resolving Namecoin identifier: $trimmed")
val result = namecoinService.resolvePubkey(trimmed)
if (result != null) {
if (result != null && result.length == 64) {
println("[ImportFollows] Namecoin resolved to ${result.take(16)}...")
importState = ImportState.IdentifierResolved(result)
return@launch
}
importState = ImportState.Error("Namecoin name not found or has no Nostr pubkey")
return@launch
}
// Try NIP-05 HTTP (user@domain)
if (trimmed.contains("@")) {
println("[ImportFollows] Resolving NIP-05 HTTP: $trimmed")
val result = resolveNip05Http(trimmed)
if (result != null) {
if (result != null && result.length == 64) {
println("[ImportFollows] NIP-05 resolved to ${result.take(16)}...")
importState = ImportState.IdentifierResolved(result)
return@launch
}
importState = ImportState.Error("NIP-05 lookup failed — user not found at that domain")
return@launch
}
importState = ImportState.Error("Could not resolve identifier")
importState = ImportState.Error("Unrecognized identifier format. Use npub1..., hex pubkey, user@domain, or .bit/d//id/")
} catch (e: Exception) {
importState = ImportState.Error(e.message ?: "Resolution failed")
}