From f27572ac6fc5f339bb67d651eb30f82504706383 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 1 Jul 2026 14:43:18 -0700 Subject: [PATCH 1/4] Add wallet Sign Message flow for CEX ownership verification Adds a Sign Message option to the wallet list menu for Bitcoin-family (UTXO) wallets. The new scene shows the wallet's receive address, lets the user paste an exchange-provided message, signs it with the wallet key, and returns a copyable signature. This lets users prove control of a self-hosted wallet for CEX/CASP withdrawal checks (EU Travel Rule) without manual ID/selfie verification. --- CHANGELOG.md | 1 + src/actions/WalletListMenuActions.tsx | 9 + src/components/Main.tsx | 9 + src/components/modals/WalletListMenuModal.tsx | 31 ++++ src/components/scenes/SignMessageScene.tsx | 155 ++++++++++++++++++ src/locales/en_US.ts | 13 ++ src/locales/strings/enUS.json | 10 ++ src/types/routerTypes.tsx | 2 + 8 files changed, 230 insertions(+) create mode 100644 src/components/scenes/SignMessageScene.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 86351209555..290dc4ceef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ ## 4.50.0 (2026-07-21) +- 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. - added: Changelly swap provider - added: NYM swap provider (`nymswap`) - added: Optional Bridgeless swap referral id via the `BRIDGELESS_INIT` env config, passed through to the swap plugin. 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..f6020c8c35d --- /dev/null +++ b/src/components/scenes/SignMessageScene.tsx @@ -0,0 +1,155 @@ +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 { 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 { 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. + */ +const SignMessageSceneComponent: React.FC = props => { + const { wallet } = props + + const theme = useTheme() + const styles = getStyles(theme) + + const [message, setMessage] = React.useState('') + const [signature, setSignature] = React.useState('') + const [isSigning, setIsSigning] = React.useState(false) + + // The signing address must be one the wallet owns, so we default to the + // wallet's own receive address rather than accepting an arbitrary one. + // 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: publicAddress, 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]) + + // Clear any prior signature when the message changes, so a stale signature + // that no longer matches the message can never be copied. + const handleChangeMessage = useHandler((text: string) => { + setMessage(text) + setSignature('') + }) + + const handleSign = useHandler(async () => { + if (publicAddress == null) { + 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 } + }) + setSignature(signedMessage) + } catch (error: unknown) { + showError(error) + } finally { + setIsSigning(false) + } + }) + + return ( + + + {lstrings.sign_message_instructions} + + + + + + + + {signature !== '' && ( + + + + )} + + + {lstrings.sign_message_safety_note} + + + + + + ) +} + +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + padding: theme.rem(0.5) + } +})) + +export const SignMessageScene = withWallet(SignMessageSceneComponent) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 4449312cefa..2c01b7b36f0 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -293,6 +293,19 @@ 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: 'Wallet 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.', 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..d9f1911413e 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -195,6 +195,16 @@ "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": "Wallet 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.", "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 From e125e51bd55b8f6ac6372e744f387dc8f1d9b1c5 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 1 Jul 2026 14:54:01 -0700 Subject: [PATCH 2/4] test: add missing testIDs for maestro selectors Add signMessageInput and signMessageButton testIDs to the Sign Message scene so UI automation can drive the message field and sign action by a stable selector. --- src/components/scenes/SignMessageScene.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/scenes/SignMessageScene.tsx b/src/components/scenes/SignMessageScene.tsx index f6020c8c35d..0884dcc1101 100644 --- a/src/components/scenes/SignMessageScene.tsx +++ b/src/components/scenes/SignMessageScene.tsx @@ -115,6 +115,7 @@ const SignMessageSceneComponent: React.FC = props => { multiline numberOfLines={4} placeholder={lstrings.sign_message_input_placeholder} + testID="signMessageInput" value={message} onChangeText={handleChangeMessage} /> @@ -123,6 +124,7 @@ const SignMessageSceneComponent: React.FC = props => { @@ -138,7 +140,8 @@ const SignMessageSceneComponent: React.FC = props => { label: lstrings.sign_message_sign_button, onPress: handleSign, disabled: message === '' || publicAddress == null, - spinner: isSigning + spinner: isSigning, + testID: 'signMessageButton' }} /> From f30e15b3ff3f174b50e629af416d4e1e9da1bb21 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 2 Jul 2026 17:34:37 -0700 Subject: [PATCH 3/4] Let users choose the signing address in the Sign Message flow The Sign Message scene now shows the wallet's receive address in an editable field instead of a read-only row. Exchanges typically ask a 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, surfaced as a clear error. Editing the address clears any prior signature, and a Use default address link restores the auto-detected receive address. --- src/components/scenes/SignMessageScene.tsx | 106 +++++++++++++++++---- src/locales/en_US.ts | 8 +- src/locales/strings/enUS.json | 6 +- 3 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/components/scenes/SignMessageScene.tsx b/src/components/scenes/SignMessageScene.tsx index 0884dcc1101..c6fd05812d0 100644 --- a/src/components/scenes/SignMessageScene.tsx +++ b/src/components/scenes/SignMessageScene.tsx @@ -8,12 +8,13 @@ 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 { Paragraph, SmallText } from '../themed/EdgeText' +import { EdgeText, Paragraph, SmallText } from '../themed/EdgeText' import { FilledTextInput } from '../themed/FilledTextInput' export interface SignMessageParams { @@ -29,6 +30,13 @@ interface Props extends EdgeAppSceneProps<'signMessage'> { * 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 @@ -36,16 +44,17 @@ const SignMessageSceneComponent: React.FC = 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) - // The signing address must be one the wallet owns, so we default to the - // wallet's own receive address rather than accepting an arbitrary one. - // 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: publicAddress, error: addressError } = useQuery({ + // 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 }) @@ -64,15 +73,33 @@ const SignMessageSceneComponent: React.FC = props => { if (addressError != null) showError(addressError) }, [addressError]) - // Clear any prior signature when the message changes, so a stale signature - // that no longer matches the message can never be copied. + // 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 (publicAddress == null) { + if (address === '') { showError(lstrings.sign_message_no_address_error) return } @@ -83,28 +110,58 @@ const SignMessageSceneComponent: React.FC = props => { // 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 } + otherParams: { publicAddress: address } }) setSignature(signedMessage) } catch (error: unknown) { - showError(error) + // 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} = props => { primary={{ label: lstrings.sign_message_sign_button, onPress: handleSign, - disabled: message === '' || publicAddress == null, + disabled: message === '' || address === '', spinner: isSigning, testID: 'signMessageButton' }} @@ -152,6 +209,15 @@ const SignMessageSceneComponent: React.FC = props => { 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) } })) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 2c01b7b36f0..099374d3066 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -297,7 +297,11 @@ const strings = { 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: 'Wallet Address', + 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', @@ -306,6 +310,8 @@ const strings = { '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 d9f1911413e..b7602a9364f 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -198,13 +198,17 @@ "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": "Wallet Address", + "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", From be9e9e817e9fe671c801d568ab1334e5b876ec17 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 9 Jul 2026 14:45:33 -0700 Subject: [PATCH 4/4] Add BIP-137 signature format option to Sign Message Let users on SegWit chains (Bitcoin, Litecoin, DigiByte) choose between the Standard (Electrum) and BIP-137 signature formats. BIP-137 re-encodes the signature header byte by address script type (native SegWit 39-42, nested SegWit 35-38) so strict external verifiers recognize the address type. The option is hidden on non-SegWit UTXO chains, and legacy addresses are never remapped. --- CHANGELOG.md | 2 +- src/components/scenes/SignMessageScene.tsx | 127 ++++++++++++++++++-- src/constants/WalletAndCurrencyConstants.ts | 11 ++ src/locales/en_US.ts | 7 +- src/locales/strings/enUS.json | 6 +- 5 files changed, 141 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 290dc4ceef4..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. @@ -33,7 +34,6 @@ ## 4.50.0 (2026-07-21) -- 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. - added: Changelly swap provider - added: NYM swap provider (`nymswap`) - added: Optional Bridgeless swap referral id via the `BRIDGELESS_INIT` env config, passed through to the swap plugin. diff --git a/src/components/scenes/SignMessageScene.tsx b/src/components/scenes/SignMessageScene.tsx index c6fd05812d0..0c75ca55852 100644 --- a/src/components/scenes/SignMessageScene.tsx +++ b/src/components/scenes/SignMessageScene.tsx @@ -3,6 +3,7 @@ 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' @@ -16,6 +17,7 @@ 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 @@ -25,6 +27,12 @@ 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 @@ -49,6 +57,13 @@ const SignMessageSceneComponent: React.FC = props => { 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), @@ -98,28 +113,49 @@ const SignMessageSceneComponent: React.FC = props => { 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 } + otherParams: { publicAddress: address, signatureFormat: sigFormat } }) 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) - ) { + // 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) @@ -177,6 +213,31 @@ const SignMessageSceneComponent: React.FC = props => { onChangeText={handleChangeMessage} /> + {showFormatOptions ? ( + + + {lstrings.sign_message_format_label} + + + + + {lstrings.sign_message_format_helper} + + + ) : null} + {signature !== '' && ( = props => { ) } +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) @@ -218,6 +316,21 @@ const getStyles = cacheStyles((theme: Theme) => ({ 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 } })) 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 099374d3066..a1b1a715c7c 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -297,13 +297,16 @@ const strings = { 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_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: diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index b7602a9364f..96dacccaa1d 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -198,12 +198,14 @@ "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_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.",