diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cebfd37327..647087ec9ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- 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 from a chosen wallet address (defaulting to the current receive address, or a specific address the user enters). - added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze. - changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings". - fixed: Prevent imported Monero wallets from using the Edge LWS backend. Choosing to import now prompts the user to continue with a full node or configure a custom LWS server, matching the wallet settings rule. 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,34 @@ 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', + 'feathercoin', + 'groestlcoin', + 'litecoin', + '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..9070cd46bbe --- /dev/null +++ b/src/components/scenes/SignMessageScene.tsx @@ -0,0 +1,223 @@ +import { useQuery } from '@tanstack/react-query' +import type { EdgeCurrencyWallet } from 'edge-core-js' +import * as React from 'react' +import { View } from 'react-native' + +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' + +export interface SignMessageParams { + walletId: string +} + +interface Props extends EdgeAppSceneProps<'signMessage'> { + wallet: EdgeCurrencyWallet +} + +/** + * 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) + + // 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('') + }) + + 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. + // eslint-disable-next-line @typescript-eslint/no-deprecated + const signedMessage = await wallet.signMessage(message, { + otherParams: { publicAddress: address } + }) + setSignature(signedMessage) + } catch (error: unknown) { + // The plugin throws when the wallet does not own the address (or it is + // malformed). Surface a clear, actionable message for that common case. + if ( + error instanceof Error && + /data-layer address|scriptPubkey|invalid/i.test(error.message) + ) { + 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} + + + + {signature !== '' && ( + + + + )} + + + {lstrings.sign_message_safety_note} + + + + + + ) +} + +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) + } +})) + +export const SignMessageScene = withWallet(SignMessageSceneComponent) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 49926310b8e..e4f5d2078d1 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -293,6 +293,25 @@ 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_label: 'Signing Address', + 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_label: 'Message to Sign', + sign_message_input_placeholder: 'Paste the message from the exchange', + 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 fe138434cfc..bfaeed8fe24 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -195,6 +195,20 @@ "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_label": "Signing Address", + "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_label": "Message to Sign", + "sign_message_input_placeholder": "Paste the message from the exchange", + "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