diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e7d8fa1261..2e9f1f1a1af 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 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)
- added: Monero wallet import support
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/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..9f41cee6486
--- /dev/null
+++ b/src/components/scenes/SignMessageScene.tsx
@@ -0,0 +1,351 @@
+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 {
+ applyBip137Header,
+ getBip137AddressKind,
+ isBip137Supported
+} from '../../util/bitcoinMessageSignature'
+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. `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
+ * 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('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),
+ // 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.
+ 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
+ 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) {
+ // 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 &&
+ /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 {
+ 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/locales/en_US.ts b/src/locales/en_US.ts
index 49926310b8e..3eee1a28025 100644
--- a/src/locales/en_US.ts
+++ b/src/locales/en_US.ts
@@ -293,6 +293,32 @@ 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_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/locales/strings/enUS.json b/src/locales/strings/enUS.json
index fe138434cfc..72dd11846e1 100644
--- a/src/locales/strings/enUS.json
+++ b/src/locales/strings/enUS.json
@@ -195,6 +195,25 @@
"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_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/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
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)
+}