From 741a2eadeee762fc7a2adfb3d8d931c1273ae0ef 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 | 2 + src/actions/WalletListMenuActions.tsx | 9 + src/components/Main.tsx | 9 + src/components/modals/WalletListMenuModal.tsx | 29 ++++ 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, 229 insertions(+) create mode 100644 src/components/scenes/SignMessageScene.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7d8fa1261..a0e9d9e5e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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. + ## 4.49.0 (staging) - added: Monero wallet import support 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..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 49926310b8e..b6065f36072 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 fe138434cfc..6240ad0460e 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 4f82bb0d1c2c912879140752bffc714c30282306 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 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/scenes/SignMessageScene.tsx b/src/components/scenes/SignMessageScene.tsx index f6020c8c35d..e08b011e7ef 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} /> @@ -138,7 +139,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 281465168c6476ab684616b8c06ded3f3ef6e4d0 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. --- CHANGELOG.md | 2 +- src/components/scenes/SignMessageScene.tsx | 106 +++++++++++++++++---- src/locales/en_US.ts | 8 +- src/locales/strings/enUS.json | 6 +- 4 files changed, 99 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e9d9e5e40..b17e74819dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +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. +- 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). ## 4.49.0 (staging) diff --git a/src/components/scenes/SignMessageScene.tsx b/src/components/scenes/SignMessageScene.tsx index e08b011e7ef..9070cd46bbe 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' }} @@ -151,6 +208,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 b6065f36072..e4f5d2078d1 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 6240ad0460e..bfaeed8fe24 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 752e288429f4b820eb3fa7632baabcee00835186 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 +- .../util/bitcoinMessageSignature.test.ts | 122 ++++++++++++++++ src/components/scenes/SignMessageScene.tsx | 136 +++++++++++++++++- src/locales/en_US.ts | 7 + src/locales/strings/enUS.json | 5 + src/util/bitcoinMessageSignature.ts | 116 +++++++++++++++ 6 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/util/bitcoinMessageSignature.test.ts create mode 100644 src/util/bitcoinMessageSignature.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b17e74819dd..2e9f1f1a1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +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: 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), and choosing between the Standard (Electrum) and BIP-137 signature formats on SegWit chains (Bitcoin, Litecoin, DigiByte). ## 4.49.0 (staging) diff --git a/src/__tests__/util/bitcoinMessageSignature.test.ts b/src/__tests__/util/bitcoinMessageSignature.test.ts new file mode 100644 index 00000000000..45b43ff1224 --- /dev/null +++ b/src/__tests__/util/bitcoinMessageSignature.test.ts @@ -0,0 +1,122 @@ +import { base64 } from 'rfc4648' + +import { + applyBip137Header, + getBip137AddressKind, + isBip137Supported +} from '../../util/bitcoinMessageSignature' + +describe('bitcoinMessageSignature', () => { + describe('isBip137Supported', () => { + it('is true for SegWit UTXO chains', () => { + expect(isBip137Supported('bitcoin')).toBe(true) + expect(isBip137Supported('litecoin')).toBe(true) + expect(isBip137Supported('digibyte')).toBe(true) + }) + + it('is false for non-SegWit chains', () => { + expect(isBip137Supported('dogecoin')).toBe(false) + expect(isBip137Supported('bitcoincash')).toBe(false) + expect(isBip137Supported('dash')).toBe(false) + }) + }) + + describe('getBip137AddressKind', () => { + it('classifies native SegWit addresses', () => { + expect( + getBip137AddressKind( + 'bc1q8xcwww38eucj8d5zgzd8ymmameycs42xzh23qu', + 'bitcoin' + ) + ).toBe('p2wpkh') + expect( + getBip137AddressKind( + 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + 'litecoin' + ) + ).toBe('p2wpkh') + }) + + it('classifies nested SegWit addresses', () => { + expect( + getBip137AddressKind('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', 'bitcoin') + ).toBe('p2sh-p2wpkh') + expect( + getBip137AddressKind('MLcbG7hUeXxTKfELi8fW9j2rTaGioLmt3H', 'litecoin') + ).toBe('p2sh-p2wpkh') + }) + + it('classifies legacy addresses as legacy (BIP-137 leaves them standard)', () => { + expect( + getBip137AddressKind('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', 'bitcoin') + ).toBe('legacy') + }) + + it('marks Taproot and other non-v0 bech32 addresses unsupported', () => { + // Taproot (bc1p / witness v1) has no BIP-137 header encoding. + expect( + getBip137AddressKind( + 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr', + 'bitcoin' + ) + ).toBe('unsupported') + expect( + getBip137AddressKind( + 'ltc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6', + 'litecoin' + ) + ).toBe('unsupported') + }) + + it('marks native P2WSH (v0, longer than P2WPKH) unsupported', () => { + // P2WSH shares the bc1q prefix but has a 32-byte program, so BIP-137's + // P2WPKH header remap must not be applied to it. + expect( + getBip137AddressKind( + 'bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3', + 'bitcoin' + ) + ).toBe('unsupported') + }) + + it('returns null for non-SegWit chains', () => { + expect( + getBip137AddressKind('DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L', 'dogecoin') + ).toBeNull() + }) + }) + + describe('applyBip137Header', () => { + // From the Asana task: the legacy signature for a native SegWit address + // starts with `I` (header byte 32, recovery id 1). BIP-137 native SegWit + // must land in the 39-42 range and start with `K`. + const legacyNativeSignature = + 'ILDe+aXV9KBN3KIsoB68tMYiCbDzJtp2RBn3mpFzXPc1VG8jq2PbzWBS19lSaRFmEgmk2u7o2c69y5mlnKEhZEg=' + + it('remaps a native SegWit header into the 39-42 range', () => { + const result = applyBip137Header(legacyNativeSignature, 'p2wpkh') + expect(result.startsWith('K')).toBe(true) + expect(base64.parse(result)[0]).toBe(40) + }) + + it('remaps a nested SegWit header into the 35-38 range', () => { + const result = applyBip137Header(legacyNativeSignature, 'p2sh-p2wpkh') + expect(base64.parse(result)[0]).toBe(36) + }) + + it('preserves the r/s components, changing only the header byte', () => { + const original = base64.parse(legacyNativeSignature) + const remapped = base64.parse( + applyBip137Header(legacyNativeSignature, 'p2wpkh') + ) + expect(remapped.slice(1)).toEqual(original.slice(1)) + }) + + it('leaves a signature with an unexpected header untouched', () => { + const bytes = new Uint8Array(65) + bytes[0] = 20 // outside the legacy-compressed 31-34 range + const encoded = base64.stringify(bytes) + expect(applyBip137Header(encoded, 'p2wpkh')).toBe(encoded) + }) + }) +}) diff --git a/src/components/scenes/SignMessageScene.tsx b/src/components/scenes/SignMessageScene.tsx index 9070cd46bbe..9f41cee6486 100644 --- a/src/components/scenes/SignMessageScene.tsx +++ b/src/components/scenes/SignMessageScene.tsx @@ -6,6 +6,11 @@ import { View } from 'react-native' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' import type { EdgeAppSceneProps } from '../../types/routerTypes' +import { + applyBip137Header, + getBip137AddressKind, + isBip137Supported +} from '../../util/bitcoinMessageSignature' import { SceneButtons } from '../buttons/SceneButtons' import { EdgeCard } from '../cards/EdgeCard' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' @@ -16,6 +21,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 +31,10 @@ interface Props extends EdgeAppSceneProps<'signMessage'> { wallet: EdgeCurrencyWallet } +// The signature encoding the user picks. `standard` is the legacy Electrum +// format the plugin emits; `bip137` re-encodes the header byte per BIP-137. +type SignatureFormat = 'standard' | '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 +59,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('standard') + + // BIP-137 only maps SegWit script types, so the format choice is offered + // solely on chains that issue SegWit addresses (BTC, LTC, DGB). Other UTXO + // chains (Dogecoin, Bitcoin Cash, Dash) always sign in the standard format. + const { pluginId } = wallet.currencyInfo + const showFormatOptions = isBip137Supported(pluginId) // Default to the wallet's own receive address. Prefer the native segwit // address (the canonical receive address the user hands the exchange), @@ -98,27 +115,61 @@ 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. + const handleSelectStandardFormat = useHandler(() => { + setSigFormat('standard') + setSignature('') + }) + + const handleSelectBip137Format = useHandler(() => { + setSigFormat('bip137') + setSignature('') + }) + const handleSign = useHandler(async () => { if (address === '') { showError(lstrings.sign_message_no_address_error) return } + + // BIP-137 defines no header for Taproot or other non-v0-SegWit bech32 + // addresses, so reject them up front rather than returning a legacy-header + // signature the exchange would fail to verify. + const bip137Kind = + sigFormat === 'bip137' ? getBip137AddressKind(address, pluginId) : null + if (bip137Kind === 'unsupported') { + showError(lstrings.sign_message_bip137_unsupported_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, { + let signedMessage = await wallet.signMessage(message, { otherParams: { publicAddress: address } }) + + // The plugin emits the legacy Electrum format. For BIP-137, remap the + // header byte on SegWit addresses so strict verifiers recognize the + // script type. Legacy addresses stay unchanged, since BIP-137 does not + // alter their signatures. + if (bip137Kind === 'p2wpkh' || bip137Kind === 'p2sh-p2wpkh') { + signedMessage = applyBip137Header(signedMessage, bip137Kind) + } 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. + // These are the plugin's errors for an address the wallet cannot sign + // with: a valid address it does not own, or one it cannot parse. Map only + // those to the friendly message so unrelated failures surface verbatim. if ( error instanceof Error && - /data-layer address|scriptPubkey|invalid/i.test(error.message) + /Missing data-layer address|Could not determine address type|invalid address type in address to script pubkey|failed converting address to scriptPubkey/i.test( + error.message + ) ) { showError(lstrings.sign_message_address_not_owned_error) } else { @@ -177,6 +228,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) @@ -217,6 +330,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/locales/en_US.ts b/src/locales/en_US.ts index e4f5d2078d1..3eee1a28025 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -304,6 +304,11 @@ const strings = { 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: @@ -312,6 +317,8 @@ const strings = { '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.', + sign_message_bip137_unsupported_address_error: + 'BIP-137 does not support this address type. Use the Standard format, or sign with a SegWit address.', 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 bfaeed8fe24..72dd11846e1 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -204,11 +204,16 @@ "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.", "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.", + "sign_message_bip137_unsupported_address_error": "BIP-137 does not support this address type. Use the Standard format, or sign with a SegWit address.", "fragment_wallets_pubkey_copied_title": "XPub Address Copied", "fragment_wallets_export_transactions": "Export Transactions", "fragment_wallets_rename_wallet": "Rename Wallet", diff --git a/src/util/bitcoinMessageSignature.ts b/src/util/bitcoinMessageSignature.ts new file mode 100644 index 00000000000..ebc715e8111 --- /dev/null +++ b/src/util/bitcoinMessageSignature.ts @@ -0,0 +1,116 @@ +import { base64 } from 'rfc4648' + +/** + * BIP-137 encodes the signing address' script type in the recoverable-signature + * header byte, so a verifier can derive the address type without being told it. + * The two SegWit variants: + * - Native SegWit (P2WPKH, `bc1q…`): header 39-42 (Base64 prefix `K`/`L`) + * - Nested SegWit (P2SH-P2WPKH, `3…`): header 35-38 + * Legacy (P2PKH) addresses are not remapped by BIP-137. + */ +export type SegwitAddressType = 'p2wpkh' | 'p2sh-p2wpkh' + +/** + * How BIP-137 treats a given address: + * - `p2wpkh` / `p2sh-p2wpkh`: remap the header byte to the SegWit range. + * - `legacy`: a P2PKH address, which BIP-137 leaves in the standard format. + * - `unsupported`: a bech32 address that is not v0 P2WPKH (e.g. Taproot + * `bc1p`), for which BIP-137 defines no header, so it must be rejected + * rather than signed with a misleading legacy header. + */ +export type Bip137AddressKind = SegwitAddressType | 'legacy' | 'unsupported' + +interface SegwitChainInfo { + // The bech32 human-readable prefix of the chain's native SegWit addresses, + // including the witness-v0 separator (e.g. `bc1q`). + nativePrefix: string + // The base58 leading character(s) of the chain's nested-SegWit (P2SH) addresses. + nestedPrefixes: string[] +} + +// Only chains that actually issue SegWit addresses can produce BIP-137 +// signatures. Non-SegWit UTXO chains (Dogecoin, Bitcoin Cash, Dash) are absent +// on purpose, so the UI hides the format option for them. +const SEGWIT_SIGN_CHAINS: Record = { + bitcoin: { nativePrefix: 'bc1q', nestedPrefixes: ['3'] }, + litecoin: { nativePrefix: 'ltc1q', nestedPrefixes: ['M', '3'] }, + digibyte: { nativePrefix: 'dgb1q', nestedPrefixes: ['S'] } +} + +// The signing plugin emits a compressed-key legacy header of `27 + 4 + recid`. +// BIP-137 keeps the recovery id but shifts the base by the script type. +const LEGACY_COMPRESSED_HEADER_BASE = 31 +const NESTED_SEGWIT_HEADER_BASE = 35 +const NATIVE_SEGWIT_HEADER_BASE = 39 + +// A bech32 P2WPKH address is a fixed length: the native prefix (hrp + `1q`) +// plus a 20-byte witness program (32 chars) and a 6-char checksum. Native +// P2WSH shares the `…1q` prefix but has a 32-byte program, so it is longer; +// distinguishing by length keeps single-key P2WPKH from the multisig P2WSH +// script type, which BIP-137 does not cover. +const P2WPKH_CHARS_AFTER_PREFIX = 38 + +/** + * Whether the chain issues SegWit addresses, and therefore whether the BIP-137 + * signature format is meaningful for it. + */ +export function isBip137Supported(pluginId: string): boolean { + return SEGWIT_SIGN_CHAINS[pluginId] != null +} + +/** + * Classifies how BIP-137 should treat an address on the given chain, or `null` + * when the chain issues no SegWit addresses. Native and nested SegWit get their + * header byte remapped; legacy P2PKH is left standard; any other bech32 address + * (Taproot and later witness versions) is `unsupported`, since BIP-137 defines + * no header for it. + */ +export function getBip137AddressKind( + address: string, + pluginId: string +): Bip137AddressKind | null { + const chainInfo = SEGWIT_SIGN_CHAINS[pluginId] + if (chainInfo == null) return null + + const trimmed = address.trim() + const lower = trimmed.toLowerCase() + // Strip the witness-v0 separator to get the chain's bech32 prefix (`bc1`). + const bech32Prefix = chainInfo.nativePrefix.slice(0, -1) + + if (lower.startsWith(bech32Prefix)) { + // A bech32 (SegWit) address. BIP-137 only encodes single-key v0 P2WPKH; + // longer `…1q` addresses are P2WSH, and other witness versions (Taproot + // `bc1p`, …) have no BIP-137 header, so both are unsupported. + const isNativeP2wpkh = + lower.startsWith(chainInfo.nativePrefix) && + lower.length === chainInfo.nativePrefix.length + P2WPKH_CHARS_AFTER_PREFIX + return isNativeP2wpkh ? 'p2wpkh' : 'unsupported' + } + if (chainInfo.nestedPrefixes.some(prefix => trimmed.startsWith(prefix))) { + return 'p2sh-p2wpkh' + } + return 'legacy' +} + +/** + * Rewrites the header byte of a compact recoverable signature so it follows the + * BIP-137 encoding for the given SegWit script type. The r/s components are + * untouched, so the result verifies identically to a signature produced with + * the matching `segwitType`. Returns the input unchanged when the header is not + * the expected legacy-compressed byte. + */ +export function applyBip137Header( + signatureBase64: string, + segwitType: SegwitAddressType +): string { + const bytes = base64.parse(signatureBase64) + const recoveryId = bytes[0] - LEGACY_COMPRESSED_HEADER_BASE + if (recoveryId < 0 || recoveryId > 3) return signatureBase64 + + const headerBase = + segwitType === 'p2wpkh' + ? NATIVE_SEGWIT_HEADER_BASE + : NESTED_SEGWIT_HEADER_BASE + bytes[0] = headerBase + recoveryId + return base64.stringify(bytes) +}