fix: route ElectrumX through Tor proxy, add onion server
- ElectrumxClient accepts injected SocketFactory (lambda) so connections respect the user's Tor/proxy settings instead of leaking their IP through raw sockets - Add ProxiedSocketFactory for SOCKS5 proxy routing - Add .onion ElectrumX server as primary when Tor is enabled, with electrumx.testls.space as clearnet fallback - NamecoinNameResolver accepts serverListProvider lambda for dynamic server selection based on current Tor settings - RoleBasedHttpClientBuilder.socketFactoryForNip05() bridges Amethyst's Tor settings to the socket factory - NamecoinNameService now requires explicit init with proxy-aware client - Remove dead code: Nip05NamecoinAdapter (never referenced), NamecoinVerificationDisplay (never called) - Update design documentation
This commit is contained in:
@@ -142,11 +142,23 @@ class AppModules(
|
||||
|
||||
// Custom fetcher that considers tor settings and avoids forwarding.
|
||||
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
|
||||
val namecoinElectrumxClient =
|
||||
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient(
|
||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||
)
|
||||
val namecoinResolver =
|
||||
com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver(
|
||||
com.vitorpamplona.quartz.nip05.namecoin
|
||||
.ElectrumxClient(),
|
||||
electrumxClient = namecoinElectrumxClient,
|
||||
serverListProvider = {
|
||||
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
|
||||
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.TOR_SERVERS
|
||||
} else {
|
||||
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.DEFAULT_SERVERS
|
||||
}
|
||||
},
|
||||
)
|
||||
val namecoinNameService =
|
||||
com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService.init(namecoinElectrumxClient)
|
||||
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
|
||||
|
||||
// Application-wide block height request cache
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.amethyst.model.privacyOptions
|
||||
|
||||
import java.net.InetAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import javax.net.SocketFactory
|
||||
|
||||
/**
|
||||
* A [SocketFactory] that creates sockets routed through a SOCKS proxy.
|
||||
*
|
||||
* Used to ensure raw TCP connections (e.g. ElectrumX for Namecoin)
|
||||
* respect the user's Tor/proxy settings, preventing IP leaks.
|
||||
*/
|
||||
class ProxiedSocketFactory(
|
||||
private val proxy: Proxy,
|
||||
) : SocketFactory() {
|
||||
override fun createSocket(): Socket = Socket(proxy)
|
||||
|
||||
override fun createSocket(
|
||||
host: String,
|
||||
port: Int,
|
||||
): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) }
|
||||
|
||||
override fun createSocket(
|
||||
host: String,
|
||||
port: Int,
|
||||
localHost: InetAddress,
|
||||
localPort: Int,
|
||||
): Socket =
|
||||
Socket(proxy).apply {
|
||||
bind(java.net.InetSocketAddress(localHost, localPort))
|
||||
connect(java.net.InetSocketAddress(host, port))
|
||||
}
|
||||
|
||||
override fun createSocket(
|
||||
host: InetAddress,
|
||||
port: Int,
|
||||
): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) }
|
||||
|
||||
override fun createSocket(
|
||||
address: InetAddress,
|
||||
port: Int,
|
||||
localAddress: InetAddress,
|
||||
localPort: Int,
|
||||
): Socket =
|
||||
Socket(proxy).apply {
|
||||
bind(java.net.InetSocketAddress(localAddress, localPort))
|
||||
connect(java.net.InetSocketAddress(address, port))
|
||||
}
|
||||
}
|
||||
+23
@@ -25,6 +25,9 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import javax.net.SocketFactory
|
||||
|
||||
class RoleBasedHttpClientBuilder(
|
||||
val okHttpClient: DualHttpClientManager,
|
||||
@@ -141,4 +144,24 @@ class RoleBasedHttpClientBuilder(
|
||||
override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))
|
||||
|
||||
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())
|
||||
|
||||
/**
|
||||
* Returns a [SocketFactory] that routes through the user's Tor proxy
|
||||
* when NIP-05 verification traffic should use Tor.
|
||||
*
|
||||
* Used by [ElectrumxClient] so that Namecoin lookups respect the
|
||||
* same proxy/Tor settings as HTTP-based NIP-05 verification,
|
||||
* preventing IP leaks through direct socket connections.
|
||||
*/
|
||||
fun socketFactoryForNip05(): SocketFactory {
|
||||
// ElectrumX servers are always external, so we use a dummy
|
||||
// non-localhost, non-onion URL to query the Tor policy.
|
||||
val useTor = shouldUseTorForNIP05("https://electrumx.example.com")
|
||||
if (!useTor) return SocketFactory.getDefault()
|
||||
|
||||
val proxy = okHttpClient.getCurrentProxy() ?: return SocketFactory.getDefault()
|
||||
val proxyAddr = proxy.address() as? InetSocketAddress ?: return SocketFactory.getDefault()
|
||||
|
||||
return ProxiedSocketFactory(Proxy(Proxy.Type.SOCKS, proxyAddr))
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -38,10 +38,11 @@ import kotlinx.coroutines.launch
|
||||
* Thread-safe, lifecycle-aware, and designed for integration
|
||||
* into Amethyst's existing `ServiceManager` infrastructure.
|
||||
*/
|
||||
class NamecoinNameService private constructor() {
|
||||
class NamecoinNameService(
|
||||
electrumxClient: ElectrumxClient = ElectrumxClient(),
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val electrumxClient = ElectrumxClient()
|
||||
private val resolver = NamecoinNameResolver(electrumxClient)
|
||||
private val cache = NamecoinLookupCache()
|
||||
|
||||
@@ -53,8 +54,14 @@ class NamecoinNameService private constructor() {
|
||||
private var instance: NamecoinNameService? = null
|
||||
|
||||
fun getInstance(): NamecoinNameService =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: NamecoinNameService().also { instance = it }
|
||||
instance ?: throw IllegalStateException(
|
||||
"NamecoinNameService not initialized. Call init() first.",
|
||||
)
|
||||
|
||||
fun init(electrumxClient: ElectrumxClient): NamecoinNameService =
|
||||
synchronized(this) {
|
||||
instance?.let { return it }
|
||||
NamecoinNameService(electrumxClient).also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-62
@@ -1,62 +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.amethyst.service.namecoin
|
||||
|
||||
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver
|
||||
|
||||
/**
|
||||
* Static adapter for hooking Namecoin into NIP-05 verification.
|
||||
*/
|
||||
object Nip05NamecoinAdapter {
|
||||
/**
|
||||
* Attempt Namecoin-based NIP-05 verification.
|
||||
*
|
||||
* @param nip05Address The `nip05` field from a kind-0 event
|
||||
* @param expectedPubkeyHex The hex pubkey from the same event
|
||||
* @return `true` if verified via Namecoin, `false` if Namecoin says
|
||||
* the mapping is wrong, or `null` if this identifier is not
|
||||
* a Namecoin identifier (caller should fall through to HTTP NIP-05).
|
||||
*/
|
||||
suspend fun tryVerify(
|
||||
nip05Address: String,
|
||||
expectedPubkeyHex: String,
|
||||
): Boolean? {
|
||||
if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) {
|
||||
return null // Not a Namecoin identifier → let caller handle via HTTP
|
||||
}
|
||||
return NamecoinNameService.getInstance().verifyNip05(nip05Address, expectedPubkeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to resolve a Namecoin identifier from the search bar.
|
||||
*
|
||||
* Called from the search/discovery flow when the user types an
|
||||
* identifier. Returns the hex pubkey if found, null otherwise.
|
||||
*
|
||||
* @param query The user's search input
|
||||
* @return Pair of (pubkey, relayList) or null
|
||||
*/
|
||||
suspend fun tryResolveSearch(query: String): Pair<String, List<String>>? {
|
||||
if (!NamecoinNameResolver.isNamecoinIdentifier(query)) return null
|
||||
val result = NamecoinNameService.getInstance().resolve(query) ?: return null
|
||||
return Pair(result.pubkey, result.relays)
|
||||
}
|
||||
}
|
||||
-259
@@ -1,259 +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.amethyst.ui.note.namecoin
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService
|
||||
import com.vitorpamplona.amethyst.service.namecoin.NamecoinResolveState
|
||||
|
||||
/**
|
||||
* Display a Namecoin-verified identity badge.
|
||||
*
|
||||
* Shows the Namecoin name with a blockchain icon when verified.
|
||||
* Renders nothing if verification fails or is still loading.
|
||||
*
|
||||
* @param nip05 The nip05 field value ending in .bit or starting with id/
|
||||
* @param expectedPubkeyHex The profile's pubkey to verify against
|
||||
* @param modifier Standard compose modifier
|
||||
*/
|
||||
@Composable
|
||||
fun NamecoinVerificationDisplay(
|
||||
nip05: String,
|
||||
expectedPubkeyHex: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isVerified by remember(nip05, expectedPubkeyHex) { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
LaunchedEffect(nip05, expectedPubkeyHex) {
|
||||
isVerified = NamecoinNameService.getInstance().verifyNip05(nip05, expectedPubkeyHex)
|
||||
}
|
||||
|
||||
if (isVerified == true) {
|
||||
NamecoinBadge(
|
||||
displayName = formatDisplayName(nip05),
|
||||
namespace = inferNamespace(nip05),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual badge component.
|
||||
*
|
||||
* Shows: [chain icon] [display name]
|
||||
* With namespace-appropriate styling.
|
||||
*/
|
||||
@Composable
|
||||
private fun NamecoinBadge(
|
||||
displayName: String,
|
||||
namespace: String, // "d/" or "id/"
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.padding(top = 1.dp, bottom = 1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
// Blockchain chain-link icon
|
||||
// In production, replace with a proper vector drawable.
|
||||
// For now, use a text glyph as placeholder.
|
||||
Text(
|
||||
text = "\u26D3", // ⛓ chain link emoji
|
||||
fontSize = 12.sp,
|
||||
color = NamecoinColors.chainIcon,
|
||||
modifier = Modifier.padding(end = 2.dp),
|
||||
)
|
||||
|
||||
// Namespace indicator
|
||||
Text(
|
||||
text = displayName,
|
||||
fontSize = 14.sp,
|
||||
color = NamecoinColors.verifiedText,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for the search result when a Namecoin name resolves.
|
||||
*
|
||||
* Shows the resolved Namecoin name, the Nostr pubkey, and relay hints.
|
||||
* Used in the search results list.
|
||||
*/
|
||||
@Composable
|
||||
fun NamecoinSearchResult(
|
||||
identifier: String,
|
||||
onProfileClick: (pubkeyHex: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val resolveState by NamecoinNameService
|
||||
.getInstance()
|
||||
.resolveLive(identifier)
|
||||
.collectAsState()
|
||||
|
||||
when (val state = resolveState) {
|
||||
is NamecoinResolveState.Loading -> {
|
||||
Row(
|
||||
modifier = modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "\u26D3", // ⛓
|
||||
fontSize = 16.sp,
|
||||
color = NamecoinColors.chainIcon,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Resolving $identifier via Namecoin…",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NamecoinResolveState.Resolved -> {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.clickable { onProfileClick(state.result.pubkey) }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "\u26D3", // ⛓
|
||||
fontSize = 16.sp,
|
||||
color = NamecoinColors.verified,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// The resolved identity
|
||||
Text(
|
||||
text = formatDisplayName(identifier),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "(${state.result.pubkey.take(8)}…)",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NamecoinResolveState.NotFound -> {
|
||||
Row(
|
||||
modifier = modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "No Nostr identity found for $identifier",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NamecoinResolveState.Error -> {
|
||||
Row(
|
||||
modifier = modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Namecoin lookup failed: ${state.message}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Display Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a raw identifier for user-friendly display.
|
||||
*
|
||||
* "alice@example.bit" → "alice@example.bit"
|
||||
* "_@example.bit" → "example.bit" (NIP-05 root convention)
|
||||
* "d/example" → "example.bit"
|
||||
* "id/alice" → "id/alice"
|
||||
*/
|
||||
private fun formatDisplayName(identifier: String): String {
|
||||
val input = identifier.trim()
|
||||
|
||||
// _@domain.bit → show just domain.bit
|
||||
if (input.startsWith("_@")) {
|
||||
return input.removePrefix("_@")
|
||||
}
|
||||
|
||||
// d/name → name.bit
|
||||
if (input.startsWith("d/", ignoreCase = true)) {
|
||||
return input.removePrefix("d/").removePrefix("D/") + ".bit"
|
||||
}
|
||||
|
||||
// id/name stays as-is
|
||||
if (input.startsWith("id/", ignoreCase = true)) {
|
||||
return input
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
private fun inferNamespace(identifier: String): String {
|
||||
val input = identifier.trim().lowercase()
|
||||
return when {
|
||||
input.startsWith("id/") -> "id/"
|
||||
else -> "d/"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Color palette for Namecoin verification UI.
|
||||
*/
|
||||
private object NamecoinColors {
|
||||
val chainIcon = Color(0xFF4A90D9) // Namecoin blue
|
||||
val verified = Color(0xFF2E8B57) // Sea green — distinct from NIP-05 blue
|
||||
val verifiedText = Color(0xFF4A90D9) // Namecoin blue for the text
|
||||
}
|
||||
@@ -17,43 +17,80 @@ This is censorship-resistant identity verification: no web server to seize, no D
|
||||
│ └── Namecoin path (new) ── NamecoinNameResolver │
|
||||
│ │ │
|
||||
│ NamecoinNameService (singleton) │ │
|
||||
│ ├── NamecoinLookupCache │ │
|
||||
│ └── Nip05NamecoinAdapter │ │
|
||||
│ └── NamecoinLookupCache │ │
|
||||
│ │ │
|
||||
│ UI: NamecoinVerificationDisplay │ │
|
||||
│ NamecoinSearchResult │ │
|
||||
│ RoleBasedHttpClientBuilder │ │
|
||||
│ └── socketFactoryForNip05() ───┤ (Tor-aware sockets) │
|
||||
│ │ │
|
||||
│ ProxiedSocketFactory │ │
|
||||
│ └── SOCKS5 proxy routing ───┘ │
|
||||
├────────────────────────────────────┼────────────────────────┤
|
||||
│ Quartz Library │
|
||||
├────────────────────────────────────┼────────────────────────┤
|
||||
│ NamecoinNameResolver │ │
|
||||
│ ├── parseIdentifier() │ │
|
||||
│ ├── extractFromDomainValue() │ (d/ namespace) │
|
||||
│ └── extractFromIdentityValue() │ (id/ namespace) │
|
||||
│ ├── extractFromIdentityValue() │ (id/ namespace) │
|
||||
│ └── serverListProvider() │ (Tor/clearnet routing) │
|
||||
│ │ │
|
||||
│ ElectrumxClient │ │
|
||||
│ ├── buildNameIndexScript() │ │
|
||||
│ ├── electrumScriptHash() │ │
|
||||
│ └── parseNameScript() │ │
|
||||
│ ├── parseNameScript() │ │
|
||||
│ └── socketFactory() ───┤ (injected, proxy-aware)│
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ ElectrumX Server │ │
|
||||
│ │ (Namecoin node) │ │
|
||||
│ └──────────────────┘ │
|
||||
│ ┌───────────────────────────┐ │
|
||||
│ │ ElectrumX Server │ │
|
||||
│ │ (clearnet or .onion) │ │
|
||||
│ └───────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer Separation
|
||||
|
||||
- **`quartz/` (library)** — Protocol-level logic. No Android dependencies.
|
||||
- `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing
|
||||
- `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping
|
||||
- `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing. Accepts an injected `SocketFactory` lambda for proxy/Tor support.
|
||||
- `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping. Accepts a `serverListProvider` lambda for dynamic server selection.
|
||||
- `NamecoinLookupCache` — LRU cache with TTL
|
||||
- `NamecoinNameResolverTest` — Unit tests for parsing and value extraction
|
||||
|
||||
- **`amethyst/` (app)** — Android integration and UI.
|
||||
- `NamecoinNameService` — Application singleton, lifecycle management
|
||||
- `Nip05NamecoinAdapter` — Static bridge for NIP-05 verification hooks
|
||||
- `NamecoinVerificationDisplay` — Compose UI for verified badge + search results
|
||||
- **`amethyst/` (app)** — Android integration and Tor-aware wiring.
|
||||
- `NamecoinNameService` — Application singleton, initialized with a proxy-aware `ElectrumxClient`
|
||||
- `ProxiedSocketFactory` — `SocketFactory` implementation that routes through a SOCKS5 proxy (Tor)
|
||||
- `RoleBasedHttpClientBuilder.socketFactoryForNip05()` — Returns a proxy-aware or default `SocketFactory` based on current Tor settings
|
||||
|
||||
## Tor & Proxy Integration
|
||||
|
||||
The ElectrumX connection respects the user's Tor settings to prevent IP leaks:
|
||||
|
||||
### Problem
|
||||
|
||||
The original `ElectrumxClient` used raw `java.net.Socket` / `SSLSocket` directly, bypassing OkHttp entirely. This meant Namecoin lookups would leak the user's real IP even when they had configured Tor for NIP-05 verification traffic.
|
||||
|
||||
### Solution
|
||||
|
||||
1. **`ElectrumxClient`** accepts a `socketFactory: () -> SocketFactory` lambda (evaluated at each connection, not captured at construction)
|
||||
2. **`ProxiedSocketFactory`** creates sockets routed through a `java.net.Proxy` (SOCKS5)
|
||||
3. **`RoleBasedHttpClientBuilder.socketFactoryForNip05()`** checks the user's NIP-05 Tor settings and returns either `SocketFactory.getDefault()` or a `ProxiedSocketFactory` with the active Tor SOCKS proxy
|
||||
4. SSL is layered on top of the (possibly proxied) base socket via `SSLSocketFactory.createSocket(socket, host, port, autoClose)`, preserving the proxy tunnel
|
||||
|
||||
### Server Selection
|
||||
|
||||
When Tor is enabled for NIP-05 traffic, the server list switches to prioritize onion routing:
|
||||
|
||||
| Setting | Primary server | Fallback |
|
||||
|---|---|---|
|
||||
| **Tor off** | `electrumx.testls.space:50002` | `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` |
|
||||
| **Tor on** | `.onion:50002` (see below) | `electrumx.testls.space:50002` (via Tor) |
|
||||
|
||||
The `serverListProvider` lambda in `NamecoinNameResolver` is evaluated at resolution time, so toggling Tor settings takes effect immediately without restarting the app.
|
||||
|
||||
### Dynamic Evaluation
|
||||
|
||||
Both the socket factory and server list are provided as lambdas, not captured values. This means:
|
||||
- Toggling Tor on/off in settings takes effect on the next Namecoin lookup
|
||||
- The proxy port is read from `DualHttpClientManager`'s live `StateFlow`
|
||||
- No app restart or singleton reconstruction needed
|
||||
|
||||
## ElectrumX Protocol — How Name Resolution Works
|
||||
|
||||
@@ -152,7 +189,7 @@ The integration is minimal and non-invasive:
|
||||
1. **`Nip05Client`** gains an optional `namecoinResolver` parameter
|
||||
2. On `verify()` and `get()`, if the identifier matches `.bit` / `d/` / `id/`, it routes to `NamecoinNameResolver` instead of the HTTP fetcher
|
||||
3. Non-Namecoin identifiers are completely unaffected
|
||||
4. **`AppModules`** wires up the resolver at construction time
|
||||
4. **`AppModules`** wires up the resolver with Tor-aware socket factory and dynamic server selection
|
||||
|
||||
## Search Integration
|
||||
|
||||
@@ -165,15 +202,20 @@ The search bar resolves Namecoin identifiers in real-time via `SearchBarViewMode
|
||||
|
||||
This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` into the search bar will query the Namecoin blockchain and show the resolved user profile at the top of results.
|
||||
|
||||
## Default ElectrumX Server
|
||||
## Default ElectrumX Servers
|
||||
|
||||
```
|
||||
electrumx.testls.space:50002 (TLS, self-signed certificate)
|
||||
```
|
||||
### Clearnet (Tor off)
|
||||
| Server | Port | TLS | Notes |
|
||||
|---|---|---|---|
|
||||
| `electrumx.testls.space` | 50002 | Yes (self-signed) | Primary |
|
||||
| `ulrichard.ch` | 50006 | Yes | Fallback |
|
||||
| `nmc2.lelux.fi` | 50006 | Yes | Fallback |
|
||||
|
||||
- ElectrumX 1.16.0, Namecoin chain, protocol 1.4–1.4.3
|
||||
- Also available via Tor: `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002`
|
||||
- Fallback servers: `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` (currently offline)
|
||||
### Tor (Tor on for NIP-05)
|
||||
| Server | Port | TLS | Notes |
|
||||
|---|---|---|---|
|
||||
| `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion` | 50002 | Yes (self-signed) | Primary — onion service for `electrumx.testls.space` |
|
||||
| `electrumx.testls.space` | 50002 | Yes (self-signed) | Fallback (routed through Tor SOCKS proxy) |
|
||||
|
||||
The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`.
|
||||
|
||||
@@ -184,38 +226,31 @@ The `trustAllCerts` flag is set for servers with self-signed certificates. Users
|
||||
- Both positive and negative results are cached
|
||||
- Cache is invalidated on TTL expiry; manual `invalidate()` and `clear()` are available
|
||||
|
||||
## UI Components
|
||||
|
||||
### NamecoinVerificationDisplay
|
||||
Shows a ⛓ chain-link badge next to profiles verified via Namecoin. Distinct from the standard NIP-05 checkmark — uses Namecoin blue (#4A90D9) and sea green (#2E8B57).
|
||||
|
||||
### NamecoinSearchResult
|
||||
Search bar integration. When a user types a `.bit` identifier, shows a loading state during resolution, then the resolved pubkey with a clickable profile link.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Tor integration**: ElectrumX connections are routed through the user's Tor SOCKS proxy when NIP-05 Tor settings are enabled. This prevents IP leaks to ElectrumX servers. The onion server is preferred when Tor is active, providing end-to-end onion routing.
|
||||
- **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations.
|
||||
- **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height.
|
||||
- **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future.
|
||||
- **Dynamic proxy evaluation**: Socket factory and server list are evaluated per-request (via lambdas), ensuring Tor setting changes take effect immediately without stale socket reuse.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New files (quartz/)
|
||||
- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client, scripthash-based name resolution
|
||||
- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction
|
||||
- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client with injected `SocketFactory` for proxy support
|
||||
- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction, dynamic server selection
|
||||
- `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL
|
||||
- `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests
|
||||
|
||||
### New files (amethyst/)
|
||||
- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, coroutine scope
|
||||
- `amethyst/.../service/namecoin/Nip05NamecoinAdapter.kt` — Static bridge for NIP-05 hooks
|
||||
- `amethyst/.../ui/note/namecoin/NamecoinVerificationDisplay.kt` — Compose UI components
|
||||
- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, initialized with proxy-aware ElectrumxClient
|
||||
- `amethyst/.../model/privacyOptions/ProxiedSocketFactory.kt` — `SocketFactory` that routes through SOCKS5 proxy (Tor)
|
||||
|
||||
### Modified files
|
||||
- `amethyst/.../AppModules.kt` — Wire up `NamecoinNameResolver` into `Nip05Client`
|
||||
- `amethyst/.../AppModules.kt` — Wires up resolver with Tor-aware socket factory and server list provider
|
||||
- `amethyst/.../model/privacyOptions/RoleBasedHttpClientBuilder.kt` — Added `socketFactoryForNip05()` for proxy-aware socket creation
|
||||
- `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution
|
||||
- `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver
|
||||
- `amethyst/.../relays/RelayInformationScreen.kt` — Import reordering (spotless)
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -241,15 +276,20 @@ adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-deb
|
||||
| `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace |
|
||||
| `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace |
|
||||
|
||||
**Tor tests** — enable Tor and set "NIP-05 verifications via Tor" to on:
|
||||
1. Search for `m@testls.bit` — should resolve via onion server
|
||||
2. Verify no direct clearnet connections to ElectrumX servers (use `tcpdump`)
|
||||
3. Toggle Tor off — next search should use clearnet servers
|
||||
|
||||
**Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP.
|
||||
|
||||
**Network verification** — to confirm ElectrumX calls are being made:
|
||||
```bash
|
||||
# Monitor traffic to ElectrumX ports on the emulator
|
||||
adb root
|
||||
adb shell tcpdump -i any -nn port 50002 or port 50006
|
||||
```
|
||||
You should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space) when searching for `.bit` identifiers.
|
||||
With Tor off, you should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space).
|
||||
With Tor on, you should see connections to the local Tor SOCKS port only (no direct ElectrumX connections).
|
||||
|
||||
### Live test data
|
||||
The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value:
|
||||
|
||||
+40
-17
@@ -42,6 +42,7 @@ import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.net.SocketFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
@@ -97,6 +98,7 @@ data class ElectrumxServer(
|
||||
class ElectrumxClient(
|
||||
private val connectTimeoutMs: Long = 10_000L,
|
||||
private val readTimeoutMs: Long = 15_000L,
|
||||
private val socketFactory: () -> SocketFactory = { SocketFactory.getDefault() },
|
||||
) {
|
||||
private val json =
|
||||
Json {
|
||||
@@ -107,7 +109,7 @@ class ElectrumxClient(
|
||||
private val mutex = Mutex()
|
||||
|
||||
companion object {
|
||||
/** Well-known public Namecoin ElectrumX servers. */
|
||||
/** Well-known public Namecoin ElectrumX servers (clearnet). */
|
||||
val DEFAULT_SERVERS =
|
||||
listOf(
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
@@ -115,6 +117,18 @@ class ElectrumxClient(
|
||||
ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true),
|
||||
)
|
||||
|
||||
/** Tor-preferred server list: onion primary, clearnet fallback. */
|
||||
val TOR_SERVERS =
|
||||
listOf(
|
||||
ElectrumxServer(
|
||||
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
|
||||
50002,
|
||||
useSsl = true,
|
||||
trustAllCerts = true,
|
||||
),
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
)
|
||||
|
||||
private const val PROTOCOL_VERSION = "1.4"
|
||||
|
||||
// Namecoin script opcodes
|
||||
@@ -155,10 +169,16 @@ class ElectrumxClient(
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each default server in order until one succeeds.
|
||||
* Try each server in order until one succeeds.
|
||||
*
|
||||
* @param identifier Full Namecoin name, e.g. "d/example"
|
||||
* @param servers Ordered server list to try; defaults to [DEFAULT_SERVERS]
|
||||
*/
|
||||
suspend fun nameShowWithFallback(identifier: String): NameShowResult? {
|
||||
for (server in DEFAULT_SERVERS) {
|
||||
suspend fun nameShowWithFallback(
|
||||
identifier: String,
|
||||
servers: List<ElectrumxServer> = DEFAULT_SERVERS,
|
||||
): NameShowResult? {
|
||||
for (server in servers) {
|
||||
val result = nameShow(identifier, server)
|
||||
if (result != null) return result
|
||||
}
|
||||
@@ -412,22 +432,25 @@ class ElectrumxClient(
|
||||
return data
|
||||
}
|
||||
|
||||
private fun createSocket(server: ElectrumxServer): Socket =
|
||||
if (server.useSsl) {
|
||||
val factory =
|
||||
if (server.trustAllCerts) {
|
||||
trustAllSslFactory()
|
||||
} else {
|
||||
SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||
}
|
||||
factory.createSocket().apply {
|
||||
private fun createSocket(server: ElectrumxServer): Socket {
|
||||
// Create the base socket through the injected factory, which
|
||||
// may route through a SOCKS proxy (e.g. Tor) if configured.
|
||||
val baseSocket =
|
||||
socketFactory().createSocket().apply {
|
||||
connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt())
|
||||
}
|
||||
} else {
|
||||
Socket().apply {
|
||||
connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt())
|
||||
|
||||
if (!server.useSsl) return baseSocket
|
||||
|
||||
// Upgrade to TLS over the already-connected (possibly proxied) socket.
|
||||
val sslFactory =
|
||||
if (server.trustAllCerts) {
|
||||
trustAllSslFactory()
|
||||
} else {
|
||||
SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||
}
|
||||
}
|
||||
return sslFactory.createSocket(baseSocket, server.host, server.port, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSLSocketFactory that accepts any certificate.
|
||||
|
||||
+2
-1
@@ -52,6 +52,7 @@ data class NamecoinNostrResult(
|
||||
class NamecoinNameResolver(
|
||||
private val electrumxClient: ElectrumxClient = ElectrumxClient(),
|
||||
private val lookupTimeoutMs: Long = 20_000L,
|
||||
private val serverListProvider: () -> List<ElectrumxServer> = { ElectrumxClient.DEFAULT_SERVERS },
|
||||
) {
|
||||
private val json =
|
||||
Json {
|
||||
@@ -165,7 +166,7 @@ class NamecoinNameResolver(
|
||||
// ── Lookup & Value Parsing ─────────────────────────────────────────
|
||||
|
||||
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
|
||||
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName) ?: return null
|
||||
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null
|
||||
val valueJson = tryParseJson(nameResult.value) ?: return null
|
||||
|
||||
return when (parsed.namespace) {
|
||||
|
||||
Reference in New Issue
Block a user