diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e2a59d29a..cf65c1904 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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), ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 1e6e0135a..4a5511df5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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 = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), - // NIP-BC onchain zap configuration. - val onchainEsploraEndpoint: MutableStateFlow = MutableStateFlow(DEFAULT_ESPLORA_ENDPOINT), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt new file mode 100644 index 000000000..a9a118647 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt @@ -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('/') +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt index dcf34bd96..a2b78880e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt @@ -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( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt new file mode 100644 index 000000000..31b26e07d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt @@ -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), + ) + } +}