diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cebfd37327..647087ec9ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## Unreleased (develop)
+- added: Sign Message option in the wallet list menu for Bitcoin-family wallets, letting users prove self-hosted wallet ownership to exchanges by signing an exchange-provided message from a chosen wallet address (defaulting to the current receive address, or a specific address the user enters).
- added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze.
- changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings".
- fixed: Prevent imported Monero wallets from using the Edge LWS backend. Choosing to import now prompts the user to continue with a full node or configure a custom LWS server, matching the wallet settings rule.
diff --git a/src/actions/WalletListMenuActions.tsx b/src/actions/WalletListMenuActions.tsx
index 41347254263..aa49dca469b 100644
--- a/src/actions/WalletListMenuActions.tsx
+++ b/src/actions/WalletListMenuActions.tsx
@@ -40,6 +40,7 @@ export type WalletListMenuKey =
| 'exportWalletTransactions'
| 'getSeed'
| 'manageTokens'
+ | 'signMessage'
| 'viewXPub'
| 'goToParent'
| 'getRawKeys'
@@ -76,6 +77,14 @@ export function walletListMenuAction(
}
}
+ case 'signMessage': {
+ return async (dispatch, getState) => {
+ navigation.navigate('signMessage', {
+ walletId
+ })
+ }
+ }
+
case 'rawDelete': {
return async (dispatch, getState) => {
const state = getState()
diff --git a/src/components/Main.tsx b/src/components/Main.tsx
index 1d67d8131e3..90b6c02013b 100644
--- a/src/components/Main.tsx
+++ b/src/components/Main.tsx
@@ -140,6 +140,7 @@ import { ReviewTriggerTestScene } from './scenes/ReviewTriggerTestScene'
import { SecurityAlertsScene as SecurityAlertsSceneComponent } from './scenes/SecurityAlertsScene'
import { SendScene2 as SendScene2Component } from './scenes/SendScene2'
import { SettingsScene as SettingsSceneComponent } from './scenes/SettingsScene'
+import { SignMessageScene as SignMessageSceneComponent } from './scenes/SignMessageScene'
import { SpendingLimitsScene as SpendingLimitsSceneComponent } from './scenes/SpendingLimitsScene'
import { EarnScene as EarnSceneComponent } from './scenes/Staking/EarnScene'
import { StakeModifyScene as StakeModifySceneComponent } from './scenes/Staking/StakeModifyScene'
@@ -283,6 +284,7 @@ const SecurityAlertsScene = ifLoggedIn(SecurityAlertsSceneComponent)
const SellScene = ifLoggedIn(SellSceneComponent)
const SendScene2 = ifLoggedIn(SendScene2Component)
const SettingsScene = ifLoggedIn(SettingsSceneComponent)
+const SignMessageScene = ifLoggedIn(SignMessageSceneComponent)
const SpendingLimitsScene = ifLoggedIn(SpendingLimitsSceneComponent)
const StakeModifyScene = ifLoggedIn(StakeModifySceneComponent)
const StakeOptionsScene = ifLoggedIn(StakeOptionsSceneComponent)
@@ -1101,6 +1103,13 @@ const EdgeAppStack: React.FC = () => {
title: lstrings.title_settings
}}
/>
+
= {
goToParent: 'upcircleo',
manageTokens: 'plus',
rawDelete: 'warning',
+ signMessage: 'edit',
walletSettings: 'control-panel-settings',
resync: 'sync',
split: 'arrowsalt',
@@ -115,6 +116,34 @@ export const WALLET_LIST_MENU: Array<{
label: lstrings.fragment_wallets_view_xpub,
value: 'viewXPub'
},
+ {
+ pluginIds: [
+ 'badcoin',
+ 'bitcoin',
+ 'bitcoincash',
+ 'bitcoincashtestnet',
+ 'bitcoingold',
+ 'bitcoingoldtestnet',
+ 'bitcoinsv',
+ 'bitcointestnet',
+ 'bitcointestnet4',
+ 'dash',
+ 'digibyte',
+ 'dogecoin',
+ 'eboost',
+ 'feathercoin',
+ 'groestlcoin',
+ 'litecoin',
+ 'qtum',
+ 'ravencoin',
+ 'smartcash',
+ 'ufo',
+ 'vertcoin',
+ 'zcoin'
+ ],
+ label: lstrings.fragment_wallets_sign_message,
+ value: 'signMessage'
+ },
{
pluginIds: ['monero', 'piratechain', 'zcash', 'zano'],
label: lstrings.fragment_wallets_view_private_view_key,
diff --git a/src/components/scenes/SignMessageScene.tsx b/src/components/scenes/SignMessageScene.tsx
new file mode 100644
index 00000000000..9070cd46bbe
--- /dev/null
+++ b/src/components/scenes/SignMessageScene.tsx
@@ -0,0 +1,223 @@
+import { useQuery } from '@tanstack/react-query'
+import type { EdgeCurrencyWallet } from 'edge-core-js'
+import * as React from 'react'
+import { View } from 'react-native'
+
+import { useHandler } from '../../hooks/useHandler'
+import { lstrings } from '../../locales/strings'
+import type { EdgeAppSceneProps } from '../../types/routerTypes'
+import { SceneButtons } from '../buttons/SceneButtons'
+import { EdgeCard } from '../cards/EdgeCard'
+import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
+import { SceneWrapper } from '../common/SceneWrapper'
+import { withWallet } from '../hoc/withWallet'
+import { EdgeRow } from '../rows/EdgeRow'
+import { showError } from '../services/AirshipInstance'
+import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
+import { EdgeText, Paragraph, SmallText } from '../themed/EdgeText'
+import { FilledTextInput } from '../themed/FilledTextInput'
+
+export interface SignMessageParams {
+ walletId: string
+}
+
+interface Props extends EdgeAppSceneProps<'signMessage'> {
+ wallet: EdgeCurrencyWallet
+}
+
+/**
+ * Lets a user sign an arbitrary message with an address they control, to prove
+ * self-hosted wallet ownership when withdrawing from a CEX/CASP (EU Travel
+ * Rule). BTC-first: the menu entry only appears for UTXO wallets whose plugin
+ * implements message signing.
+ *
+ * The signing address defaults to the wallet's current receive address, but is
+ * editable: an exchange usually asks the user to prove control of the specific
+ * address they already provided (often a previously-used one), so the user can
+ * replace the default with that address. The wallet must control whichever
+ * address is entered; the plugin signs with the key derived from that address's
+ * stored derivation path, and rejects any address it does not own.
+ */
+const SignMessageSceneComponent: React.FC = props => {
+ const { wallet } = props
+
+ const theme = useTheme()
+ const styles = getStyles(theme)
+
+ const [address, setAddress] = React.useState('')
+ const [addressTouched, setAddressTouched] = React.useState(false)
+ const [message, setMessage] = React.useState('')
+ const [signature, setSignature] = React.useState('')
+ const [isSigning, setIsSigning] = React.useState(false)
+
+ // Default to the wallet's own receive address. Prefer the native segwit
+ // address (the canonical receive address the user hands the exchange),
+ // matching `segwitAddress ?? publicAddress` used elsewhere; the
+ // `publicAddress` type is the wrapped/legacy variant.
+ const { data: defaultAddress, error: addressError } = useQuery({
+ queryKey: ['signMessageAddress', wallet.id],
+ queryFn: async () => {
+ const addresses = await wallet.getAddresses({ tokenId: null })
+ const receiveAddress =
+ addresses.find(address => address.addressType === 'segwitAddress') ??
+ addresses.find(address => address.addressType === 'publicAddress') ??
+ addresses[0]
+ if (receiveAddress == null) {
+ throw new Error(lstrings.sign_message_no_address_error)
+ }
+ return receiveAddress.publicAddress
+ }
+ })
+
+ React.useEffect(() => {
+ if (addressError != null) showError(addressError)
+ }, [addressError])
+
+ // Seed the editable address with the default once it loads, unless the user
+ // has already typed their own address.
+ React.useEffect(() => {
+ if (!addressTouched && defaultAddress != null) setAddress(defaultAddress)
+ }, [addressTouched, defaultAddress])
+
+ // A signature is bound to both the message and the address, so clear it
+ // whenever either changes to prevent copying a stale signature.
+ const handleChangeAddress = useHandler((text: string) => {
+ setAddressTouched(true)
+ setAddress(text.trim())
+ setSignature('')
+ })
+
+ const handleUseDefaultAddress = useHandler(() => {
+ setAddressTouched(false)
+ if (defaultAddress != null) setAddress(defaultAddress)
+ setSignature('')
+ })
+
+ const handleChangeMessage = useHandler((text: string) => {
+ setMessage(text)
+ setSignature('')
+ })
+
+ const handleSign = useHandler(async () => {
+ if (address === '') {
+ showError(lstrings.sign_message_no_address_error)
+ return
+ }
+ setIsSigning(true)
+ try {
+ // `signMessage` signs the literal UTF-8 message, which is what exchanges
+ // verify. `signBytes` would base64-re-encode the bytes before signing and
+ // produce a signature over the wrong data, so it is not usable here.
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ const signedMessage = await wallet.signMessage(message, {
+ otherParams: { publicAddress: address }
+ })
+ setSignature(signedMessage)
+ } catch (error: unknown) {
+ // The plugin throws when the wallet does not own the address (or it is
+ // malformed). Surface a clear, actionable message for that common case.
+ if (
+ error instanceof Error &&
+ /data-layer address|scriptPubkey|invalid/i.test(error.message)
+ ) {
+ showError(lstrings.sign_message_address_not_owned_error)
+ } else {
+ showError(error)
+ }
+ } finally {
+ setIsSigning(false)
+ }
+ })
+
+ const showUseDefault =
+ defaultAddress != null && address !== defaultAddress && !isSigning
+
+ return (
+
+
+ {lstrings.sign_message_instructions}
+
+
+
+ {lstrings.sign_message_address_helper}
+
+ {showUseDefault ? (
+
+
+ {lstrings.sign_message_use_default_address}
+
+
+ ) : null}
+
+
+
+ {signature !== '' && (
+
+
+
+ )}
+
+
+ {lstrings.sign_message_safety_note}
+
+
+
+
+
+ )
+}
+
+const getStyles = cacheStyles((theme: Theme) => ({
+ container: {
+ padding: theme.rem(0.5)
+ },
+ useDefault: {
+ alignSelf: 'flex-start',
+ paddingHorizontal: theme.rem(0.5),
+ paddingBottom: theme.rem(0.5)
+ },
+ useDefaultText: {
+ color: theme.iconTappable,
+ fontSize: theme.rem(0.75)
+ }
+}))
+
+export const SignMessageScene = withWallet(SignMessageSceneComponent)
diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts
index 49926310b8e..e4f5d2078d1 100644
--- a/src/locales/en_US.ts
+++ b/src/locales/en_US.ts
@@ -293,6 +293,25 @@ const strings = {
fragment_wallets_view_private_view_key: 'Private View Key',
fragment_wallets_view_private_view_key_warning_s: `The private view key allows the receiver to see the balance in your %1$s wallet. Do not share this key unless necessary, such as for tax purposes, accounting, or similar reasons.`,
fragment_wallets_view_xpub: 'View XPub Address',
+ fragment_wallets_sign_message: 'Sign Message',
+ sign_message_title: 'Sign Message',
+ sign_message_instructions:
+ 'Some exchanges ask you to prove you control this wallet by signing a message they provide. Paste the exact message below and sign it with your wallet address, then copy the signature back to the exchange.',
+ sign_message_address_label: 'Signing Address',
+ sign_message_address_input_placeholder: 'Enter or paste the wallet address',
+ sign_message_address_helper:
+ 'Defaults to your current receive address. To match a specific address you already gave the exchange, enter it here. This wallet must control the address.',
+ sign_message_use_default_address: 'Use default address',
+ sign_message_input_label: 'Message to Sign',
+ sign_message_input_placeholder: 'Paste the message from the exchange',
+ sign_message_sign_button: 'Sign Message',
+ sign_message_signature_label: 'Signature',
+ sign_message_safety_note:
+ 'Only sign messages from a service you trust. A signature proves you control this address but never reveals your private keys.',
+ sign_message_no_address_error:
+ 'Unable to load a wallet address to sign with.',
+ sign_message_address_not_owned_error:
+ 'This wallet does not control that address. Enter an address that belongs to this wallet.',
fragment_wallets_pubkey_copied_title: 'XPub Address Copied',
fragment_wallets_export_transactions: 'Export Transactions',
fragment_wallets_rename_wallet: 'Rename Wallet',
diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json
index fe138434cfc..bfaeed8fe24 100644
--- a/src/locales/strings/enUS.json
+++ b/src/locales/strings/enUS.json
@@ -195,6 +195,20 @@
"fragment_wallets_view_private_view_key": "Private View Key",
"fragment_wallets_view_private_view_key_warning_s": "The private view key allows the receiver to see the balance in your %1$s wallet. Do not share this key unless necessary, such as for tax purposes, accounting, or similar reasons.",
"fragment_wallets_view_xpub": "View XPub Address",
+ "fragment_wallets_sign_message": "Sign Message",
+ "sign_message_title": "Sign Message",
+ "sign_message_instructions": "Some exchanges ask you to prove you control this wallet by signing a message they provide. Paste the exact message below and sign it with your wallet address, then copy the signature back to the exchange.",
+ "sign_message_address_label": "Signing Address",
+ "sign_message_address_input_placeholder": "Enter or paste the wallet address",
+ "sign_message_address_helper": "Defaults to your current receive address. To match a specific address you already gave the exchange, enter it here. This wallet must control the address.",
+ "sign_message_use_default_address": "Use default address",
+ "sign_message_input_label": "Message to Sign",
+ "sign_message_input_placeholder": "Paste the message from the exchange",
+ "sign_message_sign_button": "Sign Message",
+ "sign_message_signature_label": "Signature",
+ "sign_message_safety_note": "Only sign messages from a service you trust. A signature proves you control this address but never reveals your private keys.",
+ "sign_message_no_address_error": "Unable to load a wallet address to sign with.",
+ "sign_message_address_not_owned_error": "This wallet does not control that address. Enter an address that belongs to this wallet.",
"fragment_wallets_pubkey_copied_title": "XPub Address Copied",
"fragment_wallets_export_transactions": "Export Transactions",
"fragment_wallets_rename_wallet": "Rename Wallet",
diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx
index d7d8ec55964..0656673799e 100644
--- a/src/types/routerTypes.tsx
+++ b/src/types/routerTypes.tsx
@@ -58,6 +58,7 @@ import type { RampPendingParams } from '../components/scenes/RampPendingScene'
import type { RampSelectOptionParams } from '../components/scenes/RampSelectOptionScene'
import type { RequestParams } from '../components/scenes/RequestScene'
import type { SendScene2Params } from '../components/scenes/SendScene2'
+import type { SignMessageParams } from '../components/scenes/SignMessageScene'
import type { EarnSceneParams } from '../components/scenes/Staking/EarnScene'
import type { StakeModifyParams } from '../components/scenes/Staking/StakeModifyScene'
import type { StakeOptionsParams } from '../components/scenes/Staking/StakeOptionsScene'
@@ -236,6 +237,7 @@ export type EdgeAppStackParamList = {} & {
send2: SendScene2Params
settingsOverview: undefined
settingsOverviewTab: undefined
+ signMessage: SignMessageParams
spendingLimits: undefined
stakeModify: StakeModifyParams
stakeOptions: StakeOptionsParams