@@ -6,6 +6,11 @@ import { View } from 'react-native'
66import { useHandler } from '../../hooks/useHandler'
77import { lstrings } from '../../locales/strings'
88import type { EdgeAppSceneProps } from '../../types/routerTypes'
9+ import {
10+ applyBip137Header ,
11+ getBip137AddressKind ,
12+ isBip137Supported
13+ } from '../../util/bitcoinMessageSignature'
914import { SceneButtons } from '../buttons/SceneButtons'
1015import { EdgeCard } from '../cards/EdgeCard'
1116import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
@@ -16,6 +21,7 @@ import { showError } from '../services/AirshipInstance'
1621import { cacheStyles , type Theme , useTheme } from '../services/ThemeContext'
1722import { EdgeText , Paragraph , SmallText } from '../themed/EdgeText'
1823import { FilledTextInput } from '../themed/FilledTextInput'
24+ import { VectorIcon } from '../themed/VectorIcon'
1925
2026export interface SignMessageParams {
2127 walletId : string
@@ -25,6 +31,10 @@ interface Props extends EdgeAppSceneProps<'signMessage'> {
2531 wallet : EdgeCurrencyWallet
2632}
2733
34+ // The signature encoding the user picks. `standard` is the legacy Electrum
35+ // format the plugin emits; `bip137` re-encodes the header byte per BIP-137.
36+ type SignatureFormat = 'standard' | 'bip137'
37+
2838/**
2939 * Lets a user sign an arbitrary message with an address they control, to prove
3040 * self-hosted wallet ownership when withdrawing from a CEX/CASP (EU Travel
@@ -49,6 +59,13 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
4959 const [ message , setMessage ] = React . useState ( '' )
5060 const [ signature , setSignature ] = React . useState ( '' )
5161 const [ isSigning , setIsSigning ] = React . useState ( false )
62+ const [ sigFormat , setSigFormat ] = React . useState < SignatureFormat > ( 'standard' )
63+
64+ // BIP-137 only maps SegWit script types, so the format choice is offered
65+ // solely on chains that issue SegWit addresses (BTC, LTC, DGB). Other UTXO
66+ // chains (Dogecoin, Bitcoin Cash, Dash) always sign in the standard format.
67+ const { pluginId } = wallet . currencyInfo
68+ const showFormatOptions = isBip137Supported ( pluginId )
5269
5370 // Default to the wallet's own receive address. Prefer the native segwit
5471 // address (the canonical receive address the user hands the exchange),
@@ -98,27 +115,61 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
98115 setSignature ( '' )
99116 } )
100117
118+ // The signature is bound to the chosen format, so clear it when the format
119+ // changes to prevent copying a signature in the wrong encoding.
120+ const handleSelectStandardFormat = useHandler ( ( ) => {
121+ setSigFormat ( 'standard' )
122+ setSignature ( '' )
123+ } )
124+
125+ const handleSelectBip137Format = useHandler ( ( ) => {
126+ setSigFormat ( 'bip137' )
127+ setSignature ( '' )
128+ } )
129+
101130 const handleSign = useHandler ( async ( ) => {
102131 if ( address === '' ) {
103132 showError ( lstrings . sign_message_no_address_error )
104133 return
105134 }
135+
136+ // BIP-137 defines no header for Taproot or other non-v0-SegWit bech32
137+ // addresses, so reject them up front rather than returning a legacy-header
138+ // signature the exchange would fail to verify.
139+ const bip137Kind =
140+ sigFormat === 'bip137' ? getBip137AddressKind ( address , pluginId ) : null
141+ if ( bip137Kind === 'unsupported' ) {
142+ showError ( lstrings . sign_message_bip137_unsupported_address_error )
143+ return
144+ }
145+
106146 setIsSigning ( true )
107147 try {
108148 // `signMessage` signs the literal UTF-8 message, which is what exchanges
109149 // verify. `signBytes` would base64-re-encode the bytes before signing and
110150 // produce a signature over the wrong data, so it is not usable here.
111151 // eslint-disable-next-line @typescript-eslint/no-deprecated
112- const signedMessage = await wallet . signMessage ( message , {
152+ let signedMessage = await wallet . signMessage ( message , {
113153 otherParams : { publicAddress : address }
114154 } )
155+
156+ // The plugin emits the legacy Electrum format. For BIP-137, remap the
157+ // header byte on SegWit addresses so strict verifiers recognize the
158+ // script type. Legacy addresses stay unchanged, since BIP-137 does not
159+ // alter their signatures.
160+ if ( bip137Kind === 'p2wpkh' || bip137Kind === 'p2sh-p2wpkh' ) {
161+ signedMessage = applyBip137Header ( signedMessage , bip137Kind )
162+ }
115163 setSignature ( signedMessage )
116164 } catch ( error : unknown ) {
117- // The plugin throws when the wallet does not own the address (or it is
118- // malformed). Surface a clear, actionable message for that common case.
165+ // These are the plugin's errors for an address the wallet cannot sign
166+ // with: a valid address it does not own, or one it cannot parse. Map only
167+ // those to the friendly message so unrelated failures surface verbatim.
119168 if (
120169 error instanceof Error &&
121- / d a t a - l a y e r a d d r e s s | s c r i p t P u b k e y | i n v a l i d / i. test ( error . message )
170+ / M i s s i n g d a t a - l a y e r a d d r e s s | C o u l d n o t d e t e r m i n e a d d r e s s t y p e | i n v a l i d a d d r e s s t y p e i n a d d r e s s t o s c r i p t p u b k e y | f a i l e d c o n v e r t i n g a d d r e s s t o s c r i p t P u b k e y / i. test (
171+ error . message
172+ )
122173 ) {
123174 showError ( lstrings . sign_message_address_not_owned_error )
124175 } else {
@@ -177,6 +228,31 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
177228 onChangeText = { handleChangeMessage }
178229 />
179230
231+ { showFormatOptions ? (
232+ < View style = { styles . formatSection } >
233+ < Paragraph >
234+ < SmallText > { lstrings . sign_message_format_label } </ SmallText >
235+ </ Paragraph >
236+ < SignatureFormatRow
237+ disabled = { isSigning }
238+ label = { lstrings . sign_message_format_standard }
239+ selected = { sigFormat === 'standard' }
240+ testID = "signMessageFormatStandard"
241+ onPress = { handleSelectStandardFormat }
242+ />
243+ < SignatureFormatRow
244+ disabled = { isSigning }
245+ label = { lstrings . sign_message_format_bip137 }
246+ selected = { sigFormat === 'bip137' }
247+ testID = "signMessageFormatBip137"
248+ onPress = { handleSelectBip137Format }
249+ />
250+ < Paragraph >
251+ < SmallText > { lstrings . sign_message_format_helper } </ SmallText >
252+ </ Paragraph >
253+ </ View >
254+ ) : null }
255+
180256 { signature !== '' && (
181257 < EdgeCard >
182258 < EdgeRow
@@ -205,6 +281,43 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
205281 )
206282}
207283
284+ interface SignatureFormatRowProps {
285+ disabled : boolean
286+ label : string
287+ selected : boolean
288+ testID : string
289+ onPress : ( ) => void
290+ }
291+
292+ /**
293+ * A single radio option in the signature-format selector.
294+ */
295+ const SignatureFormatRow : React . FC < SignatureFormatRowProps > = props => {
296+ const { disabled, label, selected, testID, onPress } = props
297+ const theme = useTheme ( )
298+ const styles = getStyles ( theme )
299+
300+ return (
301+ < EdgeTouchableOpacity
302+ style = { styles . formatRow }
303+ accessibilityRole = "radio"
304+ accessibilityState = { { selected } }
305+ disabled = { disabled }
306+ testID = { testID }
307+ onPress = { onPress }
308+ >
309+ < VectorIcon
310+ font = "Ionicons"
311+ name = { selected ? 'radio-button-on' : 'radio-button-off' }
312+ size = { theme . rem ( 1.25 ) }
313+ color = { theme . iconTappable }
314+ style = { styles . formatRadioIcon }
315+ />
316+ < EdgeText style = { styles . formatRowLabel } > { label } </ EdgeText >
317+ </ EdgeTouchableOpacity >
318+ )
319+ }
320+
208321const getStyles = cacheStyles ( ( theme : Theme ) => ( {
209322 container : {
210323 padding : theme . rem ( 0.5 )
@@ -217,6 +330,21 @@ const getStyles = cacheStyles((theme: Theme) => ({
217330 useDefaultText : {
218331 color : theme . iconTappable ,
219332 fontSize : theme . rem ( 0.75 )
333+ } ,
334+ formatSection : {
335+ paddingTop : theme . rem ( 0.5 )
336+ } ,
337+ formatRow : {
338+ flexDirection : 'row' ,
339+ alignItems : 'center' ,
340+ paddingHorizontal : theme . rem ( 0.5 ) ,
341+ paddingVertical : theme . rem ( 0.5 )
342+ } ,
343+ formatRadioIcon : {
344+ marginRight : theme . rem ( 0.75 )
345+ } ,
346+ formatRowLabel : {
347+ flex : 1
220348 }
221349} ) )
222350
0 commit comments