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:
M
2026-03-03 06:28:01 +11:00
parent f6447d2020
commit 3f39f96e81
9 changed files with 241 additions and 386 deletions
@@ -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
@@ -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))
}
}
@@ -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))
}
}
@@ -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 }
}
}
@@ -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)
}
}
@@ -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
}
+81 -41
View File
@@ -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.41.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:
@@ -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.
@@ -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) {