Skip to content

Commit b0e8f18

Browse files
committed
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.
1 parent c5bf570 commit b0e8f18

5 files changed

Lines changed: 382 additions & 4 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { base64 } from 'rfc4648'
2+
3+
import {
4+
applyBip137Header,
5+
getBip137AddressKind,
6+
isBip137Supported
7+
} from '../../util/bitcoinMessageSignature'
8+
9+
describe('bitcoinMessageSignature', () => {
10+
describe('isBip137Supported', () => {
11+
it('is true for SegWit UTXO chains', () => {
12+
expect(isBip137Supported('bitcoin')).toBe(true)
13+
expect(isBip137Supported('litecoin')).toBe(true)
14+
expect(isBip137Supported('digibyte')).toBe(true)
15+
})
16+
17+
it('is false for non-SegWit chains', () => {
18+
expect(isBip137Supported('dogecoin')).toBe(false)
19+
expect(isBip137Supported('bitcoincash')).toBe(false)
20+
expect(isBip137Supported('dash')).toBe(false)
21+
})
22+
})
23+
24+
describe('getBip137AddressKind', () => {
25+
it('classifies native SegWit addresses', () => {
26+
expect(
27+
getBip137AddressKind(
28+
'bc1q8xcwww38eucj8d5zgzd8ymmameycs42xzh23qu',
29+
'bitcoin'
30+
)
31+
).toBe('p2wpkh')
32+
expect(
33+
getBip137AddressKind(
34+
'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',
35+
'litecoin'
36+
)
37+
).toBe('p2wpkh')
38+
})
39+
40+
it('classifies nested SegWit addresses', () => {
41+
expect(
42+
getBip137AddressKind('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', 'bitcoin')
43+
).toBe('p2sh-p2wpkh')
44+
expect(
45+
getBip137AddressKind('MLcbG7hUeXxTKfELi8fW9j2rTaGioLmt3H', 'litecoin')
46+
).toBe('p2sh-p2wpkh')
47+
})
48+
49+
it('classifies legacy addresses as legacy (BIP-137 leaves them standard)', () => {
50+
expect(
51+
getBip137AddressKind('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', 'bitcoin')
52+
).toBe('legacy')
53+
})
54+
55+
it('marks Taproot and other non-v0 bech32 addresses unsupported', () => {
56+
// Taproot (bc1p / witness v1) has no BIP-137 header encoding.
57+
expect(
58+
getBip137AddressKind(
59+
'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr',
60+
'bitcoin'
61+
)
62+
).toBe('unsupported')
63+
expect(
64+
getBip137AddressKind(
65+
'ltc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6',
66+
'litecoin'
67+
)
68+
).toBe('unsupported')
69+
})
70+
71+
it('marks native P2WSH (v0, longer than P2WPKH) unsupported', () => {
72+
// P2WSH shares the bc1q prefix but has a 32-byte program, so BIP-137's
73+
// P2WPKH header remap must not be applied to it.
74+
expect(
75+
getBip137AddressKind(
76+
'bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3',
77+
'bitcoin'
78+
)
79+
).toBe('unsupported')
80+
})
81+
82+
it('returns null for non-SegWit chains', () => {
83+
expect(
84+
getBip137AddressKind('DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L', 'dogecoin')
85+
).toBeNull()
86+
})
87+
})
88+
89+
describe('applyBip137Header', () => {
90+
// From the Asana task: the legacy signature for a native SegWit address
91+
// starts with `I` (header byte 32, recovery id 1). BIP-137 native SegWit
92+
// must land in the 39-42 range and start with `K`.
93+
const legacyNativeSignature =
94+
'ILDe+aXV9KBN3KIsoB68tMYiCbDzJtp2RBn3mpFzXPc1VG8jq2PbzWBS19lSaRFmEgmk2u7o2c69y5mlnKEhZEg='
95+
96+
it('remaps a native SegWit header into the 39-42 range', () => {
97+
const result = applyBip137Header(legacyNativeSignature, 'p2wpkh')
98+
expect(result.startsWith('K')).toBe(true)
99+
expect(base64.parse(result)[0]).toBe(40)
100+
})
101+
102+
it('remaps a nested SegWit header into the 35-38 range', () => {
103+
const result = applyBip137Header(legacyNativeSignature, 'p2sh-p2wpkh')
104+
expect(base64.parse(result)[0]).toBe(36)
105+
})
106+
107+
it('preserves the r/s components, changing only the header byte', () => {
108+
const original = base64.parse(legacyNativeSignature)
109+
const remapped = base64.parse(
110+
applyBip137Header(legacyNativeSignature, 'p2wpkh')
111+
)
112+
expect(remapped.slice(1)).toEqual(original.slice(1))
113+
})
114+
115+
it('leaves a signature with an unexpected header untouched', () => {
116+
const bytes = new Uint8Array(65)
117+
bytes[0] = 20 // outside the legacy-compressed 31-34 range
118+
const encoded = base64.stringify(bytes)
119+
expect(applyBip137Header(encoded, 'p2wpkh')).toBe(encoded)
120+
})
121+
})
122+
})

src/components/scenes/SignMessageScene.tsx

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { View } from 'react-native'
66
import { useHandler } from '../../hooks/useHandler'
77
import { lstrings } from '../../locales/strings'
88
import type { EdgeAppSceneProps } from '../../types/routerTypes'
9+
import {
10+
applyBip137Header,
11+
getBip137AddressKind,
12+
isBip137Supported
13+
} from '../../util/bitcoinMessageSignature'
914
import { SceneButtons } from '../buttons/SceneButtons'
1015
import { EdgeCard } from '../cards/EdgeCard'
1116
import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
@@ -16,6 +21,7 @@ import { showError } from '../services/AirshipInstance'
1621
import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
1722
import { EdgeText, Paragraph, SmallText } from '../themed/EdgeText'
1823
import { FilledTextInput } from '../themed/FilledTextInput'
24+
import { VectorIcon } from '../themed/VectorIcon'
1925

2026
export 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-
/data-layer address|scriptPubkey|invalid/i.test(error.message)
170+
/Missing data-layer address|Could not determine address type|invalid address type in address to script pubkey|failed converting address to scriptPubkey/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+
208321
const 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

src/locales/en_US.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ const strings = {
304304
sign_message_use_default_address: 'Use default address',
305305
sign_message_input_label: 'Message to Sign',
306306
sign_message_input_placeholder: 'Paste the message from the exchange',
307+
sign_message_format_label: 'Signature Format',
308+
sign_message_format_standard: 'Standard (Electrum)',
309+
sign_message_format_bip137: 'BIP-137',
310+
sign_message_format_helper:
311+
'Most verifiers accept Standard (Electrum). Choose BIP-137 if an exchange requires the strict SegWit signature format.',
307312
sign_message_sign_button: 'Sign Message',
308313
sign_message_signature_label: 'Signature',
309314
sign_message_safety_note:
@@ -312,6 +317,8 @@ const strings = {
312317
'Unable to load a wallet address to sign with.',
313318
sign_message_address_not_owned_error:
314319
'This wallet does not control that address. Enter an address that belongs to this wallet.',
320+
sign_message_bip137_unsupported_address_error:
321+
'BIP-137 does not support this address type. Use the Standard format, or sign with a SegWit address.',
315322
fragment_wallets_pubkey_copied_title: 'XPub Address Copied',
316323
fragment_wallets_export_transactions: 'Export Transactions',
317324
fragment_wallets_rename_wallet: 'Rename Wallet',

src/locales/strings/enUS.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,16 @@
204204
"sign_message_use_default_address": "Use default address",
205205
"sign_message_input_label": "Message to Sign",
206206
"sign_message_input_placeholder": "Paste the message from the exchange",
207+
"sign_message_format_label": "Signature Format",
208+
"sign_message_format_standard": "Standard (Electrum)",
209+
"sign_message_format_bip137": "BIP-137",
210+
"sign_message_format_helper": "Most verifiers accept Standard (Electrum). Choose BIP-137 if an exchange requires the strict SegWit signature format.",
207211
"sign_message_sign_button": "Sign Message",
208212
"sign_message_signature_label": "Signature",
209213
"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.",
210214
"sign_message_no_address_error": "Unable to load a wallet address to sign with.",
211215
"sign_message_address_not_owned_error": "This wallet does not control that address. Enter an address that belongs to this wallet.",
216+
"sign_message_bip137_unsupported_address_error": "BIP-137 does not support this address type. Use the Standard format, or sign with a SegWit address.",
212217
"fragment_wallets_pubkey_copied_title": "XPub Address Copied",
213218
"fragment_wallets_export_transactions": "Export Transactions",
214219
"fragment_wallets_rename_wallet": "Rename Wallet",

0 commit comments

Comments
 (0)