diff --git a/CHANGELOG.md b/CHANGELOG.md index 86351209555..00475f3c4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - added: Verbose logging for exchange rate queries: the request body, resolved/rate-less counts, and errors are captured when the Verbose Logging setting is enabled. - added: Exchange-rate cache snapshot in the support log output, plus a `rates-cache-replay` script that re-runs those queries against the rates server and reports the result for each pair. - added: "-m" tag on the version number in the Help scene for Maestro test builds +- added: Sign Message option in the wallet list menu for Bitcoin-family wallets, letting users prove self-hosted wallet ownership to exchanges by signing an exchange-provided message. - changed: Sign MoonPay buy/sell widget URLs and bind them to the customer's IP via the info server, for MoonPay's on-ramp IP-matching security upgrade. - changed: Style the entire "Already have an account? Sign in" line in the getting-started USP carousel with the tertiary link color, not just "Sign in". - changed: Refresh the buy, sell, sort, scan-QR and FIO names icons to the updated design. diff --git a/src/actions/WalletListMenuActions.tsx b/src/actions/WalletListMenuActions.tsx index 41347254263..aa49dca469b 100644 --- a/src/actions/WalletListMenuActions.tsx +++ b/src/actions/WalletListMenuActions.tsx @@ -40,6 +40,7 @@ export type WalletListMenuKey = | 'exportWalletTransactions' | 'getSeed' | 'manageTokens' + | 'signMessage' | 'viewXPub' | 'goToParent' | 'getRawKeys' @@ -76,6 +77,14 @@ export function walletListMenuAction( } } + case 'signMessage': { + return async (dispatch, getState) => { + navigation.navigate('signMessage', { + walletId + }) + } + } + case 'rawDelete': { return async (dispatch, getState) => { const state = getState() diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 1d67d8131e3..90b6c02013b 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -140,6 +140,7 @@ import { ReviewTriggerTestScene } from './scenes/ReviewTriggerTestScene' import { SecurityAlertsScene as SecurityAlertsSceneComponent } from './scenes/SecurityAlertsScene' import { SendScene2 as SendScene2Component } from './scenes/SendScene2' import { SettingsScene as SettingsSceneComponent } from './scenes/SettingsScene' +import { SignMessageScene as SignMessageSceneComponent } from './scenes/SignMessageScene' import { SpendingLimitsScene as SpendingLimitsSceneComponent } from './scenes/SpendingLimitsScene' import { EarnScene as EarnSceneComponent } from './scenes/Staking/EarnScene' import { StakeModifyScene as StakeModifySceneComponent } from './scenes/Staking/StakeModifyScene' @@ -283,6 +284,7 @@ const SecurityAlertsScene = ifLoggedIn(SecurityAlertsSceneComponent) const SellScene = ifLoggedIn(SellSceneComponent) const SendScene2 = ifLoggedIn(SendScene2Component) const SettingsScene = ifLoggedIn(SettingsSceneComponent) +const SignMessageScene = ifLoggedIn(SignMessageSceneComponent) const SpendingLimitsScene = ifLoggedIn(SpendingLimitsSceneComponent) const StakeModifyScene = ifLoggedIn(StakeModifySceneComponent) const StakeOptionsScene = ifLoggedIn(StakeOptionsSceneComponent) @@ -1101,6 +1103,13 @@ const EdgeAppStack: React.FC = () => { title: lstrings.title_settings }} /> + = { goToParent: 'upcircleo', manageTokens: 'plus', rawDelete: 'warning', + signMessage: 'edit', walletSettings: 'control-panel-settings', resync: 'sync', split: 'arrowsalt', @@ -115,6 +116,36 @@ export const WALLET_LIST_MENU: Array<{ label: lstrings.fragment_wallets_view_xpub, value: 'viewXPub' }, + { + pluginIds: [ + 'badcoin', + 'bitcoin', + 'bitcoincash', + 'bitcoincashtestnet', + 'bitcoingold', + 'bitcoingoldtestnet', + 'bitcoinsv', + 'bitcointestnet', + 'bitcointestnet4', + 'dash', + 'digibyte', + 'dogecoin', + 'eboost', + 'ecash', + 'feathercoin', + 'groestlcoin', + 'litecoin', + 'pivx', + 'qtum', + 'ravencoin', + 'smartcash', + 'ufo', + 'vertcoin', + 'zcoin' + ], + label: lstrings.fragment_wallets_sign_message, + value: 'signMessage' + }, { pluginIds: ['monero', 'piratechain', 'zcash', 'zano'], label: lstrings.fragment_wallets_view_private_view_key, diff --git a/src/components/scenes/SignMessageScene.tsx b/src/components/scenes/SignMessageScene.tsx new file mode 100644 index 00000000000..0c75ca55852 --- /dev/null +++ b/src/components/scenes/SignMessageScene.tsx @@ -0,0 +1,337 @@ +import { useQuery } from '@tanstack/react-query' +import type { EdgeCurrencyWallet } from 'edge-core-js' +import * as React from 'react' +import { View } from 'react-native' + +import { getSpecialCurrencyInfo } from '../../constants/WalletAndCurrencyConstants' +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import type { EdgeAppSceneProps } from '../../types/routerTypes' +import { SceneButtons } from '../buttons/SceneButtons' +import { EdgeCard } from '../cards/EdgeCard' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' +import { SceneWrapper } from '../common/SceneWrapper' +import { withWallet } from '../hoc/withWallet' +import { EdgeRow } from '../rows/EdgeRow' +import { showError } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' +import { EdgeText, Paragraph, SmallText } from '../themed/EdgeText' +import { FilledTextInput } from '../themed/FilledTextInput' +import { VectorIcon } from '../themed/VectorIcon' + +export interface SignMessageParams { + walletId: string +} + +interface Props extends EdgeAppSceneProps<'signMessage'> { + wallet: EdgeCurrencyWallet +} + +// The signature encoding the user picks, passed straight through to the +// plugin, which owns the header-byte encoding. `electrum` is the legacy format +// every verifier understands; `bip137` additionally encodes the address' script +// type, which some exchanges require for SegWit addresses. +type SignatureFormat = 'electrum' | 'bip137' + +/** + * Lets a user sign an arbitrary message with an address they control, to prove + * self-hosted wallet ownership when withdrawing from a CEX/CASP (EU Travel + * Rule). BTC-first: the menu entry only appears for UTXO wallets whose plugin + * implements message signing. + * + * The signing address defaults to the wallet's current receive address, but is + * editable: an exchange usually asks the user to prove control of the specific + * address they already provided (often a previously-used one), so the user can + * replace the default with that address. The wallet must control whichever + * address is entered; the plugin signs with the key derived from that address's + * stored derivation path, and rejects any address it does not own. + */ +const SignMessageSceneComponent: React.FC = props => { + const { wallet } = props + + const theme = useTheme() + const styles = getStyles(theme) + + const [address, setAddress] = React.useState('') + const [addressTouched, setAddressTouched] = React.useState(false) + const [message, setMessage] = React.useState('') + const [signature, setSignature] = React.useState('') + const [isSigning, setIsSigning] = React.useState(false) + const [sigFormat, setSigFormat] = React.useState('electrum') + + // BIP-137 only maps SegWit script types, so the format choice is offered + // solely on chains that issue SegWit addresses. Other UTXO chains (Dogecoin, + // Bitcoin Cash, Dash) always sign in the standard format. + const { pluginId } = wallet.currencyInfo + const showFormatOptions = getSpecialCurrencyInfo(pluginId).hasSegwit === true + + // Default to the wallet's own receive address. Prefer the native segwit + // address (the canonical receive address the user hands the exchange), + // matching `segwitAddress ?? publicAddress` used elsewhere; the + // `publicAddress` type is the wrapped/legacy variant. + const { data: defaultAddress, error: addressError } = useQuery({ + queryKey: ['signMessageAddress', wallet.id], + queryFn: async () => { + const addresses = await wallet.getAddresses({ tokenId: null }) + const receiveAddress = + addresses.find(address => address.addressType === 'segwitAddress') ?? + addresses.find(address => address.addressType === 'publicAddress') ?? + addresses[0] + if (receiveAddress == null) { + throw new Error(lstrings.sign_message_no_address_error) + } + return receiveAddress.publicAddress + } + }) + + React.useEffect(() => { + if (addressError != null) showError(addressError) + }, [addressError]) + + // Seed the editable address with the default once it loads, unless the user + // has already typed their own address. + React.useEffect(() => { + if (!addressTouched && defaultAddress != null) setAddress(defaultAddress) + }, [addressTouched, defaultAddress]) + + // A signature is bound to both the message and the address, so clear it + // whenever either changes to prevent copying a stale signature. + const handleChangeAddress = useHandler((text: string) => { + setAddressTouched(true) + setAddress(text.trim()) + setSignature('') + }) + + const handleUseDefaultAddress = useHandler(() => { + setAddressTouched(false) + if (defaultAddress != null) setAddress(defaultAddress) + setSignature('') + }) + + const handleChangeMessage = useHandler((text: string) => { + setMessage(text) + setSignature('') + }) + + // The signature is bound to the chosen format, so clear it when the format + // changes to prevent copying a signature in the wrong encoding. Re-selecting + // the format already in effect changes nothing, so it must leave a signature + // the user may still be copying alone. + const handleSelectFormat = useHandler((nextFormat: SignatureFormat) => { + if (nextFormat === sigFormat) return + setSigFormat(nextFormat) + setSignature('') + }) + + const handleSelectStandardFormat = useHandler(() => { + handleSelectFormat('electrum') + }) + + const handleSelectBip137Format = useHandler(() => { + handleSelectFormat('bip137') + }) + + const handleSign = useHandler(async () => { + if (address === '') { + showError(lstrings.sign_message_no_address_error) + return + } + + setIsSigning(true) + try { + // `signMessage` signs the literal UTF-8 message, which is what exchanges + // verify. `signBytes` would base64-re-encode the bytes before signing and + // produce a signature over the wrong data, so it is not usable here. + // + // The plugin owns the header-byte encoding: it resolves the address to + // its derivation path, so it knows the script type authoritatively + // instead of inferring it from the address string. + // eslint-disable-next-line @typescript-eslint/no-deprecated + const signedMessage = await wallet.signMessage(message, { + otherParams: { publicAddress: address, signatureFormat: sigFormat } + }) + setSignature(signedMessage) + } catch (error: unknown) { + // The plugin names this error when the wallet cannot sign for the given + // address, either because it does not derive it or because the string is + // not an address of this chain. Anything else surfaces verbatim. + if (error instanceof Error && error.name === 'AddressNotOwnedError') { + showError(lstrings.sign_message_address_not_owned_error) + } else { + showError(error) + } + } finally { + setIsSigning(false) + } + }) + + const showUseDefault = + defaultAddress != null && address !== defaultAddress && !isSigning + + return ( + + + {lstrings.sign_message_instructions} + + + + {lstrings.sign_message_address_helper} + + {showUseDefault ? ( + + + {lstrings.sign_message_use_default_address} + + + ) : null} + + + + {showFormatOptions ? ( + + + {lstrings.sign_message_format_label} + + + + + {lstrings.sign_message_format_helper} + + + ) : null} + + {signature !== '' && ( + + + + )} + + + {lstrings.sign_message_safety_note} + + + + + + ) +} + +interface SignatureFormatRowProps { + disabled: boolean + label: string + selected: boolean + testID: string + onPress: () => void +} + +/** + * A single radio option in the signature-format selector. + */ +const SignatureFormatRow: React.FC = props => { + const { disabled, label, selected, testID, onPress } = props + const theme = useTheme() + const styles = getStyles(theme) + + return ( + + + {label} + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + padding: theme.rem(0.5) + }, + useDefault: { + alignSelf: 'flex-start', + paddingHorizontal: theme.rem(0.5), + paddingBottom: theme.rem(0.5) + }, + useDefaultText: { + color: theme.iconTappable, + fontSize: theme.rem(0.75) + }, + formatSection: { + paddingTop: theme.rem(0.5) + }, + formatRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: theme.rem(0.5), + paddingVertical: theme.rem(0.5) + }, + formatRadioIcon: { + marginRight: theme.rem(0.75) + }, + formatRowLabel: { + flex: 1 + } +})) + +export const SignMessageScene = withWallet(SignMessageSceneComponent) diff --git a/src/constants/WalletAndCurrencyConstants.ts b/src/constants/WalletAndCurrencyConstants.ts index c0320978ef6..4e43c3d4191 100644 --- a/src/constants/WalletAndCurrencyConstants.ts +++ b/src/constants/WalletAndCurrencyConstants.ts @@ -227,6 +227,11 @@ interface SpecialCurrencyInfo { // Flags that could move to EdgeCurrencyInfo: allowZeroTx?: boolean + /** + * Whether the chain issues SegWit addresses. Mirrors `coinInfo.segwit` in + * edge-currency-plugins, which is the authoritative list; keep the two in + * sync when a chain gains SegWit support. + */ hasSegwit?: boolean isAccountActivationRequired?: boolean tokenActivationAdditionalReserveText?: string @@ -342,6 +347,7 @@ export const SPECIAL_CURRENCY_INFO: Record = { unstoppableDomainsTicker: 'BSV' }, digibyte: { + hasSegwit: true, maxSpendTargets: UTXO_MAX_SPEND_TARGETS, initWalletName: lstrings.string_first_digibyte_wallet_name, displayBuyCrypto: true, @@ -839,11 +845,13 @@ export const SPECIAL_CURRENCY_INFO: Record = { unstoppableDomainsTicker: 'SMART' }, vertcoin: { + hasSegwit: true, maxSpendTargets: UTXO_MAX_SPEND_TARGETS, initWalletName: lstrings.string_first_vertcoin_wallet_name, isImportKeySupported: true }, bitcoingold: { + hasSegwit: true, maxSpendTargets: UTXO_MAX_SPEND_TARGETS, initWalletName: lstrings.string_first_bitcoin_gold_wallet_name, isImportKeySupported: true, @@ -852,11 +860,13 @@ export const SPECIAL_CURRENCY_INFO: Record = { unstoppableDomainsTicker: 'BTG' }, feathercoin: { + hasSegwit: true, maxSpendTargets: UTXO_MAX_SPEND_TARGETS, initWalletName: lstrings.string_first_feather_coin_wallet_name, isImportKeySupported: true }, groestlcoin: { + hasSegwit: true, maxSpendTargets: UTXO_MAX_SPEND_TARGETS, initWalletName: lstrings.string_first_groestlcoin_wallet_name, isImportKeySupported: true, @@ -875,6 +885,7 @@ export const SPECIAL_CURRENCY_INFO: Record = { keysOnlyMode: true }, ufo: { + hasSegwit: true, maxSpendTargets: UTXO_MAX_SPEND_TARGETS, initWalletName: lstrings.string_first_ufo_wallet_name, isImportKeySupported: true diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 4449312cefa..a1b1a715c7c 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -293,6 +293,28 @@ const strings = { fragment_wallets_view_private_view_key: 'Private View Key', fragment_wallets_view_private_view_key_warning_s: `The private view key allows the receiver to see the balance in your %1$s wallet. Do not share this key unless necessary, such as for tax purposes, accounting, or similar reasons.`, fragment_wallets_view_xpub: 'View XPub Address', + fragment_wallets_sign_message: 'Sign Message', + sign_message_title: 'Sign Message', + sign_message_instructions: + 'Some exchanges ask you to prove you control this wallet by signing a message they provide. Paste the exact message below and sign it with your wallet address, then copy the signature back to the exchange.', + sign_message_address_input_placeholder: 'Enter or paste the wallet address', + sign_message_address_helper: + 'Defaults to your current receive address. To match a specific address you already gave the exchange, enter it here. This wallet must control the address.', + sign_message_use_default_address: 'Use default address', + sign_message_input_placeholder: 'Paste the message from the exchange', + sign_message_format_label: 'Signature Format', + sign_message_format_standard: 'Standard (Electrum)', + sign_message_format_bip137: 'BIP-137', + sign_message_format_helper: + 'Most verifiers accept Standard (Electrum). Choose BIP-137 if an exchange requires the strict SegWit signature format.', + sign_message_sign_button: 'Sign Message', + sign_message_signature_label: 'Signature', + sign_message_safety_note: + 'Only sign messages from a service you trust. A signature proves you control this address but never reveals your private keys.', + sign_message_no_address_error: + 'Unable to load a wallet address to sign with.', + sign_message_address_not_owned_error: + 'This wallet does not control that address. Enter an address that belongs to this wallet.', fragment_wallets_pubkey_copied_title: 'XPub Address Copied', fragment_wallets_export_transactions: 'Export Transactions', fragment_wallets_rename_wallet: 'Rename Wallet', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index c4efa51a29a..96dacccaa1d 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -195,6 +195,22 @@ "fragment_wallets_view_private_view_key": "Private View Key", "fragment_wallets_view_private_view_key_warning_s": "The private view key allows the receiver to see the balance in your %1$s wallet. Do not share this key unless necessary, such as for tax purposes, accounting, or similar reasons.", "fragment_wallets_view_xpub": "View XPub Address", + "fragment_wallets_sign_message": "Sign Message", + "sign_message_title": "Sign Message", + "sign_message_instructions": "Some exchanges ask you to prove you control this wallet by signing a message they provide. Paste the exact message below and sign it with your wallet address, then copy the signature back to the exchange.", + "sign_message_address_input_placeholder": "Enter or paste the wallet address", + "sign_message_address_helper": "Defaults to your current receive address. To match a specific address you already gave the exchange, enter it here. This wallet must control the address.", + "sign_message_use_default_address": "Use default address", + "sign_message_input_placeholder": "Paste the message from the exchange", + "sign_message_format_label": "Signature Format", + "sign_message_format_standard": "Standard (Electrum)", + "sign_message_format_bip137": "BIP-137", + "sign_message_format_helper": "Most verifiers accept Standard (Electrum). Choose BIP-137 if an exchange requires the strict SegWit signature format.", + "sign_message_sign_button": "Sign Message", + "sign_message_signature_label": "Signature", + "sign_message_safety_note": "Only sign messages from a service you trust. A signature proves you control this address but never reveals your private keys.", + "sign_message_no_address_error": "Unable to load a wallet address to sign with.", + "sign_message_address_not_owned_error": "This wallet does not control that address. Enter an address that belongs to this wallet.", "fragment_wallets_pubkey_copied_title": "XPub Address Copied", "fragment_wallets_export_transactions": "Export Transactions", "fragment_wallets_rename_wallet": "Rename Wallet", diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx index d7d8ec55964..0656673799e 100644 --- a/src/types/routerTypes.tsx +++ b/src/types/routerTypes.tsx @@ -58,6 +58,7 @@ import type { RampPendingParams } from '../components/scenes/RampPendingScene' import type { RampSelectOptionParams } from '../components/scenes/RampSelectOptionScene' import type { RequestParams } from '../components/scenes/RequestScene' import type { SendScene2Params } from '../components/scenes/SendScene2' +import type { SignMessageParams } from '../components/scenes/SignMessageScene' import type { EarnSceneParams } from '../components/scenes/Staking/EarnScene' import type { StakeModifyParams } from '../components/scenes/Staking/StakeModifyScene' import type { StakeOptionsParams } from '../components/scenes/Staking/StakeOptionsScene' @@ -236,6 +237,7 @@ export type EdgeAppStackParamList = {} & { send2: SendScene2Params settingsOverview: undefined settingsOverviewTab: undefined + signMessage: SignMessageParams spendingLimits: undefined stakeModify: StakeModifyParams stakeOptions: StakeOptionsParams