diff --git a/backend/backend.go b/backend/backend.go index 36fc3ad82a..2f7775bdd2 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -22,6 +22,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "strings" "time" @@ -71,14 +72,7 @@ var fixedURLWhitelist = []string{ "https://shiftcrypto.support/", // Exchange rates. "https://www.coingecko.com/", - // Block explorers. - "https://blockstream.info/tx/", - "https://blockstream.info/testnet/tx/", - "https://sochain.com/tx/LTCTEST/", - "https://blockchair.com/litecoin/transaction/", - "https://etherscan.io/tx/", - "https://goerli.etherscan.io/tx/", - "https://sepolia.etherscan.io/tx/", + // Moonpay onramp "https://www.moonpay.com/", "https://support.moonpay.com/", @@ -490,43 +484,51 @@ func (backend *Backend) Coin(code coinpkg.Code) (coinpkg.Coin, error) { servers := backend.defaultElectrumXServers(code) coin = btc.NewCoin(coinpkg.CodeRBTC, "Bitcoin Regtest", "RBTC", coinpkg.BtcUnitDefault, &chaincfg.RegressionNetParams, dbFolder, servers, "", backend.socksProxy) case code == coinpkg.CodeTBTC: + blockExplorerPrefix := backend.config.AppConfig().Backend.BlockExplorers.TBTC servers := backend.defaultElectrumXServers(code) coin = btc.NewCoin(coinpkg.CodeTBTC, "Bitcoin Testnet", "TBTC", btcFormatUnit, &chaincfg.TestNet3Params, dbFolder, servers, - "https://blockstream.info/testnet/tx/", backend.socksProxy) + blockExplorerPrefix, backend.socksProxy) case code == coinpkg.CodeBTC: + blockExplorerPrefix := backend.config.AppConfig().Backend.BlockExplorers.BTC servers := backend.defaultElectrumXServers(code) coin = btc.NewCoin(coinpkg.CodeBTC, "Bitcoin", "BTC", btcFormatUnit, &chaincfg.MainNetParams, dbFolder, servers, - "https://blockstream.info/tx/", backend.socksProxy) + blockExplorerPrefix, backend.socksProxy) case code == coinpkg.CodeTLTC: + blockExplorerPrefix := backend.config.AppConfig().Backend.BlockExplorers.TLTC servers := backend.defaultElectrumXServers(code) coin = btc.NewCoin(coinpkg.CodeTLTC, "Litecoin Testnet", "TLTC", coinpkg.BtcUnitDefault, <c.TestNet4Params, dbFolder, servers, - "https://sochain.com/tx/LTCTEST/", backend.socksProxy) + blockExplorerPrefix, backend.socksProxy) case code == coinpkg.CodeLTC: + blockExplorerPrefix := backend.config.AppConfig().Backend.BlockExplorers.LTC servers := backend.defaultElectrumXServers(code) coin = btc.NewCoin(coinpkg.CodeLTC, "Litecoin", "LTC", coinpkg.BtcUnitDefault, <c.MainNetParams, dbFolder, servers, - "https://blockchair.com/litecoin/transaction/", backend.socksProxy) + blockExplorerPrefix, backend.socksProxy) case code == coinpkg.CodeETH: + blockExplorerPrefix := backend.config.AppConfig().Backend.BlockExplorers.ETH etherScan := etherscan.NewEtherScan("https://api.etherscan.io/api", backend.etherScanHTTPClient) coin = eth.NewCoin(etherScan, code, "Ethereum", "ETH", "ETH", params.MainnetChainConfig, - "https://etherscan.io/tx/", + blockExplorerPrefix, etherScan, nil) case code == coinpkg.CodeGOETH: + blockExplorerPrefix := backend.config.AppConfig().Backend.BlockExplorers.GOETH etherScan := etherscan.NewEtherScan("https://api-goerli.etherscan.io/api", backend.etherScanHTTPClient) coin = eth.NewCoin(etherScan, code, "Ethereum Goerli", "GOETH", "GOETH", params.GoerliChainConfig, - "https://goerli.etherscan.io/tx/", + blockExplorerPrefix, etherScan, nil) case code == coinpkg.CodeSEPETH: + blockExplorerPrefix := backend.config.AppConfig().Backend.BlockExplorers.SEPETH etherScan := etherscan.NewEtherScan("https://api-sepolia.etherscan.io/api", backend.etherScanHTTPClient) coin = eth.NewCoin(etherScan, code, "Ethereum Sepolia", "SEPETH", "SEPETH", params.SepoliaChainConfig, - "https://sepolia.etherscan.io/tx/", + blockExplorerPrefix, etherScan, nil) case erc20Token != nil: + blockExplorerPrefix := backend.config.AppConfig().Backend.BlockExplorers.ETH etherScan := etherscan.NewEtherScan("https://api.etherscan.io/api", backend.etherScanHTTPClient) coin = eth.NewCoin(etherScan, erc20Token.code, erc20Token.name, erc20Token.unit, "ETH", params.MainnetChainConfig, - "https://etherscan.io/tx/", + blockExplorerPrefix, etherScan, erc20Token.token, ) @@ -827,6 +829,16 @@ func (backend *Backend) SystemOpen(url string) error { } } + // Block explorers are not defined in the fixedURLWhiteList but in AvailableBlockexplorers. + var allAvailableExplorers = reflect.ValueOf(config.AvailableExplorers) + for i := 0; i < allAvailableExplorers.NumField(); i++ { + coinAvailableExplorers := allAvailableExplorers.Field(i).Interface().([]config.BlockExplorer) + for _, explorer := range coinAvailableExplorers { + if strings.HasPrefix(url, explorer.Url) { + return backend.environment.SystemOpen(url) + } + } + } return errp.Newf("Blocked /open with url: %s", url) } @@ -997,4 +1009,10 @@ func (backend *Backend) ExportLogs() error { return err } return nil + +} + +// AvailableExplorers returns a struct containing all available block explorers for each coin. +func (backend *Backend) AvailableExplorers() config.AvailableBlockExplorers { + return config.AvailableExplorers } diff --git a/backend/config/blockexplorer.go b/backend/config/blockexplorer.go new file mode 100644 index 0000000000..0da302c305 --- /dev/null +++ b/backend/config/blockexplorer.go @@ -0,0 +1,91 @@ +package config + +// BlockExplorer defines a selectable block explorer. +type BlockExplorer struct { + // Name of the block explorer used for UI. + Name string `json:"name"` + // Url of the block explorer that the txid is appended. + Url string `json:"url"` +} + +// AvailableBlockExplorers defines all available block explorers for each coin. +type AvailableBlockExplorers struct { + Btc []BlockExplorer `json:"btc"` + Tbtc []BlockExplorer `json:"tbtc"` + Ltc []BlockExplorer `json:"ltc"` + Tltc []BlockExplorer `json:"tltc"` + Eth []BlockExplorer `json:"eth"` + GoEth []BlockExplorer `json:"goeth"` + SepEth []BlockExplorer `json:"sepeth"` +} + +// AvailableExplorers holds all available block explorers for each coin. +// It is returned from the available-explorers endpoint. +var AvailableExplorers = AvailableBlockExplorers{ + Btc: []BlockExplorer{ + { + Name: "blockstream.info", + Url: "https://blockstream.info/tx/", + }, + { + Name: "mempool.space", + Url: "https://mempool.space/tx", + }, + }, + Tbtc: []BlockExplorer{ + { + Name: "mempool.space", + Url: "https://mempool.space/testnet/tx/", + }, + { + Name: "blockstream.info", + Url: "https://blockstream.info/testnet/tx/", + }, + }, + Ltc: []BlockExplorer{ + { + Name: "sochain.com", + Url: "https://sochain.com/tx/", + }, + { + Name: "blockchair.com", + Url: "https://blockchair.com/litecoin/transaction", + }, + }, + Tltc: []BlockExplorer{ + { + Name: "sochain.com", + Url: "https://sochain.com/tx/LTCTEST/", + }, + }, + Eth: []BlockExplorer{ + { + Name: "etherscan.io", + Url: "https://etherscan.io/tx/", + }, + { + Name: "ethplorer.io", + Url: "https://ethplorer.io/tx/", + }, + }, + GoEth: []BlockExplorer{ + { + Name: "etherscan.io", + Url: "https://goerli.etherscan.io/tx/", + }, + { + Name: "ethplorer.io", + Url: "https://goerli.ethplorer.io/tx/", + }, + }, + SepEth: []BlockExplorer{ + { + Name: "etherscan.io", + Url: "https://sepolia.etherscan.io/tx/", + }, + { + Name: "ethplorer.io", + Url: "https://sepolia.ethplorer.io/tx/", + }, + }, +} diff --git a/backend/config/config.go b/backend/config/config.go index 8ea963402e..ead276e27e 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -25,6 +25,16 @@ import ( "github.com/BitBoxSwiss/bitbox-wallet-app/util/locker" ) +type blockExplorers struct { + BTC string `json:"btc"` + TBTC string `json:"tbtc"` + LTC string `json:"ltc"` + TLTC string `json:"tltc"` + ETH string `json:"eth"` + GOETH string `json:"goeth"` + SEPETH string `json:"sepeth"` +} + // ServerInfo holds information about the backend server(s). type ServerInfo struct { Server string `json:"server"` @@ -83,6 +93,8 @@ type Backend struct { TLTC btcCoinConfig `json:"tltc"` ETH ethCoinConfig `json:"eth"` + BlockExplorers blockExplorers `json:"blockExplorers"` + // Removed in v4.35 - don't reuse these two keys. TETH struct{} `json:"teth"` RETH struct{} `json:"reth"` @@ -228,6 +240,15 @@ func NewDefaultAppConfig() AppConfig { ETH: ethCoinConfig{ DeprecatedActiveERC20Tokens: []string{}, }, + BlockExplorers: blockExplorers{ + BTC: AvailableExplorers.Btc[0].Url, + TBTC: AvailableExplorers.Tbtc[0].Url, + LTC: AvailableExplorers.Ltc[0].Url, + TLTC: AvailableExplorers.Tltc[0].Url, + ETH: AvailableExplorers.Eth[0].Url, + GOETH: AvailableExplorers.GoEth[0].Url, + SEPETH: AvailableExplorers.SepEth[0].Url, + }, // Copied from frontend/web/src/components/rates/rates.tsx. FiatList: []string{rates.USD.String(), rates.EUR.String(), rates.CHF.String()}, MainFiat: rates.USD.String(), diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 318a910c31..55d51ba4ec 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -107,6 +107,7 @@ type Backend interface { AOPPCancel() AOPPApprove() AOPPChooseAccount(code accountsTypes.Code) + AvailableExplorers() config.AvailableBlockExplorers GetAccountFromCode(code accountsTypes.Code) (accounts.Interface, error) HTTPClient() *http.Client LookupInsuredAccounts(accountCode accountsTypes.Code) ([]bitsurance.AccountDetails, error) @@ -257,6 +258,7 @@ func NewHandlers( getAPIRouterNoError(apiRouter)("/accounts/eth-account-code", handlers.lookupEthAccountCode).Methods("POST") getAPIRouterNoError(apiRouter)("/notes/export", handlers.postExportNotes).Methods("POST") getAPIRouterNoError(apiRouter)("/notes/import", handlers.postImportNotes).Methods("POST") + getAPIRouterNoError(apiRouter)("/available-explorers", handlers.getAvailableExplorers).Methods("GET") devicesRouter := getAPIRouterNoError(apiRouter.PathPrefix("/devices").Subrouter()) devicesRouter("/registered", handlers.getDevicesRegistered).Methods("GET") @@ -1524,3 +1526,9 @@ func (handlers *Handlers) postImportNotes(r *http.Request) interface{} { } return result{Success: true, Data: data} } + +// getAvailableExplorers returns a struct containing arrays with block explorers for each +// individual coin code. +func (handlers *Handlers) getAvailableExplorers(*http.Request) interface{} { + return config.AvailableExplorers +} diff --git a/frontends/web/src/api/backend.ts b/frontends/web/src/api/backend.ts index 6ca02e7ff1..7d17586e2b 100644 --- a/frontends/web/src/api/backend.ts +++ b/frontends/web/src/api/backend.ts @@ -19,6 +19,10 @@ import type { FailResponse, SuccessResponse } from './response'; import { apiGet, apiPost } from '@/utils/request'; import { TSubscriptionCallback, subscribeEndpoint } from './subscribe'; + +export type TBlockExplorer = { name: string; url: string }; +export type TAvailableExplorers = Record; + export interface ICoin { coinCode: CoinCode; name: string; @@ -32,6 +36,10 @@ export interface ISuccess { errorCode?: string; } +export const getAvailableExplorers = (): Promise => { + return apiGet('available-explorers'); +}; + export const getSupportedCoins = (): Promise => { return apiGet('supported-coins'); }; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index e4a52c8aa9..bc1ffe9d11 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -1103,6 +1103,27 @@ "title": "Which servers does this app talk to?" } }, + "settingsBlockExplorer": { + "instructions": { + "link": { + "text": "Guide on how to use a block explorer" + }, + "text": "For a full tutorial, please visit our guide:", + "title": "How do I use a block explorer?" + }, + "options": { + "text": "The BitBoxApp features a protection mechanism designed to prevent the opening of arbitrary links. This serves as a security measure against malicious links.", + "title": "Why can't I enter my own block explorer URL?" + }, + "what": { + "text": "A block explorer lets you dive into the details of the blockchain, helping you understand what's happening. You can choose from various block explorers to find one that suits you.", + "title": "What is this?" + }, + "why": { + "text": "You can use your preferred block explorer to check out your transaction in more detail or to find additional information about the blockchain.", + "title": "Why should I use a block explorer?" + } + }, "title": "Guide", "toggle": { "close": "Close guide", @@ -1638,6 +1659,10 @@ "description": "You can connect to your own Electrum full node.", "title": "Connect your own full node" }, + "explorer": { + "description": "Change to your preferred block explorer.", + "title": "Choose block explorer" + }, "exportLogs": { "description": "Export log file to help with troubleshooting and support.", "title": "Export logs" @@ -1678,6 +1703,7 @@ "title": "Manage notes" }, "restart": "Please re-start the BitBoxApp for the changes to take effect.", + "save": "Save", "services": { "title": "Services" }, diff --git a/frontends/web/src/routes/account/account.tsx b/frontends/web/src/routes/account/account.tsx index cb637d1206..1d687f8ba6 100644 --- a/frontends/web/src/routes/account/account.tsx +++ b/frontends/web/src/routes/account/account.tsx @@ -72,6 +72,7 @@ export const Account = ({ const [uncoveredFunds, setUncoveredFunds] = useState([]); const [stateCode, setStateCode] = useState(); const supportedExchanges = useLoad(getExchangeBuySupported(code), [code]); + const [ blockExplorerTxPrefix, setBlockExplorerTxPrefix ] = useState(); const account = accounts && accounts.find(acct => acct.code === code); @@ -125,8 +126,17 @@ export const Account = ({ useEffect(() => { maybeCheckBitsuranceStatus(); - getConfig().then(({ backend }) => setUsesProxy(backend.proxy.useProxy)); - }, [maybeCheckBitsuranceStatus]); + getConfig().then(({ backend }) => { + setUsesProxy(backend.proxy.useProxy); + if (account) { + if (backend[account.coinCode]) { + setBlockExplorerTxPrefix(backend.blockExplorers[account.coinCode]); + } else { + setBlockExplorerTxPrefix(account.blockExplorerTxPrefix); + } + } + }); + }, [maybeCheckBitsuranceStatus, account]); const hasCard = useSDCard(devices, [code]); @@ -329,7 +339,7 @@ export const Account = ({ {!isAccountEmpty && } diff --git a/frontends/web/src/routes/router.tsx b/frontends/web/src/routes/router.tsx index 1458da3236..e93fe3dd05 100644 --- a/frontends/web/src/routes/router.tsx +++ b/frontends/web/src/routes/router.tsx @@ -45,6 +45,7 @@ import { BitsuranceWidget } from './bitsurance/widget'; import { BitsuranceDashboard } from './bitsurance/dashboard'; import { ConnectScreenWalletConnect } from './account/walletconnect/connect'; import { DashboardWalletConnect } from './account/walletconnect/dashboard'; +import { SelectExplorerSettings } from './settings/select-explorer'; type TAppRouterProps = { devices: TDevices; @@ -249,6 +250,7 @@ export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAcco } /> + } /> + { hasAccounts ? : null } diff --git a/frontends/web/src/routes/settings/block-explorers.tsx b/frontends/web/src/routes/settings/block-explorers.tsx new file mode 100644 index 0000000000..666bb6d9e9 --- /dev/null +++ b/frontends/web/src/routes/settings/block-explorers.tsx @@ -0,0 +1,62 @@ +/** + * Copyright 2022 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CoinCode } from '@/api/account'; +import { TBlockExplorer } from '@/api/backend'; +import { SingleDropdown } from './components/dropdowns/singledropdown'; + +type TOption = { + label: string; + value: string; +} + +type TProps = { + coin: CoinCode; + explorerOptions: TBlockExplorer[]; + handleOnChange: (value: string, coin: CoinCode) => void + selectedPrefix: string; +}; + +export const BlockExplorers = ({ coin, explorerOptions, handleOnChange, selectedPrefix }: TProps) => { + const options: TOption[] = explorerOptions.map(explorer => { + return { label: explorer.name, value: explorer.url }; + }); + + const fullCoinName = new Map([ + ['btc', 'Bitcoin'], + ['tbtc', 'Testnet Bitcoin'], + ['ltc', 'Litecoin'], + ['tltc', 'Testnet Litecoin'], + ['eth', 'Ethereum'], + ['goeth', 'Goerli Ethereum'], + ['sepeth', 'Sepolia Ethereum'], + ]); + + // find the index of the currently selected explorer. will be -1 if none is found. + const activeExplorerIndex = explorerOptions.findIndex(explorer => explorer.url === selectedPrefix); + + return ( + options.length > 0 && +
+

{fullCoinName.get(coin)}

+ handleOnChange(value, coin)} + value={options[activeExplorerIndex > 0 ? activeExplorerIndex : 0]} + /> +
+ ); +}; diff --git a/frontends/web/src/routes/settings/components/advanced-settings/select-explorer-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/select-explorer-setting.tsx new file mode 100644 index 0000000000..762d79c3a9 --- /dev/null +++ b/frontends/web/src/routes/settings/components/advanced-settings/select-explorer-setting.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2023 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { ChevronRightDark } from '@/components/icon'; +import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; + +export const SelectExplorerSetting = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + navigate('/settings/select-explorer')} + secondaryText={t('settings.expert.explorer.description')} + extraComponent={ + + } + /> + ); +}; diff --git a/frontends/web/src/routes/settings/select-explorer.tsx b/frontends/web/src/routes/settings/select-explorer.tsx new file mode 100644 index 0000000000..b1ddc9444b --- /dev/null +++ b/frontends/web/src/routes/settings/select-explorer.tsx @@ -0,0 +1,142 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 2022 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CoinCode, IAccount } from '@/api/account'; +import * as backendAPI from '@/api/backend'; +import { i18n } from '@/i18n/i18n'; +import { Guide } from '@/components/guide/guide'; +import { Entry } from '@/components/guide/entry'; +import { Button, ButtonLink } from '@/components/forms'; +import { Header } from '@/components/layout'; +import { getConfig, setConfig } from '@/utils/config'; +import { MobileHeader } from './components/mobile-header'; +import { BlockExplorers } from './block-explorers'; + +type TSelectExplorerSettingsProps = { + accounts: IAccount[]; +} + +export const SelectExplorerSettings = ({ accounts }: TSelectExplorerSettingsProps) => { + const { t } = useTranslation(); + + const initialConfig = useRef(); + const [config, setConfigState] = useState(); + + const availableCoins = new Set(accounts.map(account => account.coinCode)); + const [allSelections, setAllSelections] = useState(); + + const [saveDisabled, setSaveDisabled] = useState(true); + + const loadConfig = () => { + getConfig().then(setConfigState); + }; + + + const updateConfigState = useCallback((newConfig: any) => { + if (JSON.stringify(initialConfig.current) !== JSON.stringify(newConfig)) { + setConfigState(newConfig); + setSaveDisabled(false); + } else { + setSaveDisabled(true); + } + }, []); + + const handleChange = useCallback((selectedTxPrefix: string, coin: CoinCode) => { + if (config.backend.blockExplorers[coin] && config.backend.blockExplorers[coin] !== selectedTxPrefix) { + config.backend.blockExplorers[coin] = selectedTxPrefix; + updateConfigState(config); + } + }, [config, updateConfigState]); + + const save = async () => { + setSaveDisabled(true); + await setConfig(config); + initialConfig.current = await getConfig(); + }; + + useEffect(() => { + const fetchData = async () => { + const allExplorerSelection = await backendAPI.getAvailableExplorers(); + + // if set alongside config it will 'update' with it, but we want it to stay the same after initialization. + initialConfig.current = await getConfig(); + + setAllSelections(allExplorerSelection); + }; + + loadConfig(); + fetchData().catch(console.error); + }, []); + + if (config === undefined) { + return null; + } + + return ( +
+
+
+
+

{t('settings.expert.explorer.title')}

+ + + }/> +
+ { Array.from(availableCoins).map(coin => { + return ; + }) } +
+
+ + {t('button.back')} + + +
+
+
+ + + + + + +
+ ); +};