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