fix(onchain-zaps): share the OTS Tor-aware explorer endpoint + server setting

The NIP-BC EsploraBackend was wired with a hardcoded mempool.space URL,
silently bypassing the user's Tor preference and ignoring the explorer
server they may have configured for OpenTimestamps.

- New BitcoinExplorerEndpoint: the single source of truth for the Bitcoin
  explorer base URL, shared by OTS verification and onchain zaps. A
  user-configured custom URL always wins; otherwise mempool.space when Tor
  is active, blockstream.info when not.
- TorAwareOkHttpOtsResolverBuilder.getAPI now delegates to it (behaviour
  unchanged for OTS).
- AppModules wires EsploraBackend through BitcoinExplorerEndpoint using the
  same OTS server setting (otsPrefs) and the same money-flavoured Tor-aware
  OkHttp client OTS uses — so onchain zaps honour Tor and the user's
  explorer choice.
- Removed the dead AccountSettings.onchainEsploraEndpoint field and
  DEFAULT_ESPLORA_ENDPOINT const: the OTS explorer setting is now the single
  configuration point. The field was never persisted or read.

Adds BitcoinExplorerEndpointTest. amethyst compiles; the new test passes.
This commit is contained in:
Claude
2026-05-14 17:26:44 +00:00
parent db6254872e
commit 07724e410b
5 changed files with 155 additions and 18 deletions
@@ -28,10 +28,10 @@ import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DEFAULT_ESPLORA_ENDPOINT
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
@@ -92,6 +92,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
@@ -337,15 +338,26 @@ class AppModules(
// LocalCache.consume(OnchainZapEvent) can sum the on-chain output values
// that pay the recipient's derived Taproot address. Wrapped in a caching
// decorator so a feed full of onchain zaps doesn't fan out into one HTTP
// request per event. Endpoint is currently a fixed default; per-account
// override (AccountSettings.onchainEsploraEndpoint) is plumbed for a future
// Settings UI.
// request per event.
//
// The explorer endpoint is shared with OpenTimestamps: it honours the same
// user-configured server (OTS settings) and the same Tor-aware default
// selection, via BitcoinExplorerEndpoint — onchain zaps must not silently
// bypass the user's Tor preference.
init {
cache.onchainBackend =
CachingOnchainBackend(
EsploraBackend(
baseUrl = { DEFAULT_ESPLORA_ENDPOINT },
client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT),
baseUrl = {
BitcoinExplorerEndpoint.resolveNormalized(
customExplorerUrl = otsPrefs.current.normalizedUrl(),
usingTor =
roleBasedHttpClientBuilder.shouldUseTorForMoneyOperations(
OkHttpBitcoinExplorer.MEMPOOL_API_URL,
),
)
},
client = roleBasedHttpClientBuilder.okHttpClientForMoney(OkHttpBitcoinExplorer.MEMPOOL_API_URL),
),
)
}
@@ -141,9 +141,6 @@ sealed class TopFilter(
) : TopFilter("InterestSet/${address.toValue()}")
}
/** Default Esplora-compatible Bitcoin chain backend for NIP-BC onchain zaps. */
const val DEFAULT_ESPLORA_ENDPOINT = "https://mempool.space/api"
@Stable
class AccountSettings(
val keyPair: KeyPair,
@@ -179,8 +176,6 @@ class AccountSettings(
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
// NIP-BC onchain zap configuration.
val onchainEsploraEndpoint: MutableStateFlow<String> = MutableStateFlow(DEFAULT_ESPLORA_ENDPOINT),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
@@ -0,0 +1,64 @@
/*
* 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.nip03Timestamp
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
/**
* Picks the Bitcoin block-explorer (Esplora) base URL.
*
* This is the single source of truth shared by two features that both talk to
* the same Esplora API:
* - OpenTimestamps verification ([TorAwareOkHttpOtsResolverBuilder]).
* - NIP-BC onchain zaps (the `EsploraBackend` wired in `AppModules`).
*
* So a user who configures a custom explorer in the OTS settings gets it for
* onchain zaps too, and both honour the same Tor preference: a configured
* [OtsSettings] custom URL always wins; otherwise mempool.space is used when
* Tor is active (it is reachable over Tor) and blockstream.info when it is not.
*/
object BitcoinExplorerEndpoint {
/**
* @param customExplorerUrl A user-configured explorer base URL, or null/blank
* for the automatic Tor-aware default. Typically
* `OtsSettings.normalizedUrl()`.
* @param usingTor Whether Bitcoin/"money" traffic is currently routed over Tor.
*/
fun resolve(
customExplorerUrl: String?,
usingTor: Boolean,
): String =
customExplorerUrl?.takeIf { it.isNotBlank() }
?: if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
/**
* Same as [resolve] but with any trailing slash stripped, for callers that
* join request paths with a leading `/` (e.g. `"$base/tx/$txid"`).
*/
fun resolveNormalized(
customExplorerUrl: String?,
usingTor: Boolean,
): String = resolve(customExplorerUrl, usingTor).trimEnd('/')
}
@@ -33,13 +33,7 @@ class TorAwareOkHttpOtsResolverBuilder(
val cache: OtsBlockHeightCache,
val customExplorerUrl: () -> String? = { null },
) : OtsResolverBuilder {
fun getAPI(usingTor: Boolean): String =
customExplorerUrl()
?: if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
fun getAPI(usingTor: Boolean): String = BitcoinExplorerEndpoint.resolve(customExplorerUrl(), usingTor)
override fun build(): OtsResolver =
OtsResolver(
@@ -0,0 +1,72 @@
/*
* 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.nip03Timestamp
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import org.junit.Assert.assertEquals
import org.junit.Test
class BitcoinExplorerEndpointTest {
@Test
fun defaultsToMempoolWhenTorIsActive() {
assertEquals(
OkHttpBitcoinExplorer.MEMPOOL_API_URL,
BitcoinExplorerEndpoint.resolve(customExplorerUrl = null, usingTor = true),
)
}
@Test
fun defaultsToBlockstreamWhenTorIsInactive() {
assertEquals(
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL,
BitcoinExplorerEndpoint.resolve(customExplorerUrl = null, usingTor = false),
)
}
@Test
fun aConfiguredCustomUrlAlwaysWins() {
val custom = "https://my.esplora.example/api/"
assertEquals(custom, BitcoinExplorerEndpoint.resolve(custom, usingTor = true))
assertEquals(custom, BitcoinExplorerEndpoint.resolve(custom, usingTor = false))
}
@Test
fun blankCustomUrlFallsBackToTheDefault() {
assertEquals(
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL,
BitcoinExplorerEndpoint.resolve(customExplorerUrl = " ", usingTor = false),
)
}
@Test
fun normalizedFormStripsTrailingSlashForPathJoining() {
assertEquals(
"https://my.esplora.example/api",
BitcoinExplorerEndpoint.resolveNormalized("https://my.esplora.example/api/", usingTor = true),
)
// The mempool default carries a trailing slash; normalized form drops it
// so callers can safely do "$base/tx/$txid".
assertEquals(
OkHttpBitcoinExplorer.MEMPOOL_API_URL.trimEnd('/'),
BitcoinExplorerEndpoint.resolveNormalized(customExplorerUrl = null, usingTor = true),
)
}
}