Skip to content

Commit bb08a13

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 2a248af commit bb08a13

6 files changed

Lines changed: 309 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Unreleased (develop)
44

5-
- 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).
5+
- 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).
66
- 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.
77
- changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings".
88
- 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.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { base64 } from 'rfc4648'
2+
3+
import {
4+
applyBip137Header,
5+
getSegwitAddressType,
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('getSegwitAddressType', () => {
25+
it('classifies native SegWit addresses', () => {
26+
expect(
27+
getSegwitAddressType(
28+
'bc1q8xcwww38eucj8d5zgzd8ymmameycs42xzh23qu',
29+
'bitcoin'
30+
)
31+
).toBe('p2wpkh')
32+
expect(
33+
getSegwitAddressType(
34+
'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',
35+
'litecoin'
36+
)
37+
).toBe('p2wpkh')
38+
})
39+
40+
it('classifies nested SegWit addresses', () => {
41+
expect(
42+
getSegwitAddressType('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', 'bitcoin')
43+
).toBe('p2sh-p2wpkh')
44+
expect(
45+
getSegwitAddressType('MLcbG7hUeXxTKfELi8fW9j2rTaGioLmt3H', 'litecoin')
46+
).toBe('p2sh-p2wpkh')
47+
})
48+
49+
it('returns null for legacy addresses', () => {
50+
expect(
51+
getSegwitAddressType('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', 'bitcoin')
52+
).toBeNull()
53+
})
54+
55+
it('returns null for non-SegWit chains', () => {
56+
expect(
57+
getSegwitAddressType('DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L', 'dogecoin')
58+
).toBeNull()
59+
})
60+
})
61+
62+
describe('applyBip137Header', () => {
63+
// From the Asana task: the legacy signature for a native SegWit address
64+
// starts with `I` (header byte 32, recovery id 1). BIP-137 native SegWit
65+
// must land in the 39-42 range and start with `K`.
66+
const legacyNativeSignature =
67+
'ILDe+aXV9KBN3KIsoB68tMYiCbDzJtp2RBn3mpFzXPc1VG8jq2PbzWBS19lSaRFmEgmk2u7o2c69y5mlnKEhZEg='
68+
69+
it('remaps a native SegWit header into the 39-42 range', () => {
70+
const result = applyBip137Header(legacyNativeSignature, 'p2wpkh')
71+
expect(result.startsWith('K')).toBe(true)
72+
expect(base64.parse(result)[0]).toBe(40)
73+
})
74+
75+
it('remaps a nested SegWit header into the 35-38 range', () => {
76+
const result = applyBip137Header(legacyNativeSignature, 'p2sh-p2wpkh')
77+
expect(base64.parse(result)[0]).toBe(36)
78+
})
79+
80+
it('preserves the r/s components, changing only the header byte', () => {
81+
const original = base64.parse(legacyNativeSignature)
82+
const remapped = base64.parse(
83+
applyBip137Header(legacyNativeSignature, 'p2wpkh')
84+
)
85+
expect(remapped.slice(1)).toEqual(original.slice(1))
86+
})
87+
88+
it('leaves a signature with an unexpected header untouched', () => {
89+
const bytes = new Uint8Array(65)
90+
bytes[0] = 20 // outside the legacy-compressed 31-34 range
91+
const encoded = base64.stringify(bytes)
92+
expect(applyBip137Header(encoded, 'p2wpkh')).toBe(encoded)
93+
})
94+
})
95+
})

src/components/scenes/SignMessageScene.tsx

Lines changed: 118 additions & 1 deletion
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+
getSegwitAddressType,
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,6 +115,18 @@ 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)
@@ -109,9 +138,20 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
109138
// verify. `signBytes` would base64-re-encode the bytes before signing and
110139
// produce a signature over the wrong data, so it is not usable here.
111140
// eslint-disable-next-line @typescript-eslint/no-deprecated
112-
const signedMessage = await wallet.signMessage(message, {
141+
let signedMessage = await wallet.signMessage(message, {
113142
otherParams: { publicAddress: address }
114143
})
144+
145+
// The plugin emits the legacy Electrum format. When the user selects
146+
// BIP-137 on a SegWit address, remap the header byte so strict verifiers
147+
// recognize the script type. Legacy addresses are left unchanged, since
148+
// BIP-137 does not alter their signatures.
149+
if (sigFormat === 'bip137') {
150+
const segwitType = getSegwitAddressType(address, pluginId)
151+
if (segwitType != null) {
152+
signedMessage = applyBip137Header(signedMessage, segwitType)
153+
}
154+
}
115155
setSignature(signedMessage)
116156
} catch (error: unknown) {
117157
// The plugin throws when the wallet does not own the address (or it is
@@ -177,6 +217,31 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
177217
onChangeText={handleChangeMessage}
178218
/>
179219

220+
{showFormatOptions ? (
221+
<View style={styles.formatSection}>
222+
<Paragraph>
223+
<SmallText>{lstrings.sign_message_format_label}</SmallText>
224+
</Paragraph>
225+
<SignatureFormatRow
226+
disabled={isSigning}
227+
label={lstrings.sign_message_format_standard}
228+
selected={sigFormat === 'standard'}
229+
testID="signMessageFormatStandard"
230+
onPress={handleSelectStandardFormat}
231+
/>
232+
<SignatureFormatRow
233+
disabled={isSigning}
234+
label={lstrings.sign_message_format_bip137}
235+
selected={sigFormat === 'bip137'}
236+
testID="signMessageFormatBip137"
237+
onPress={handleSelectBip137Format}
238+
/>
239+
<Paragraph>
240+
<SmallText>{lstrings.sign_message_format_helper}</SmallText>
241+
</Paragraph>
242+
</View>
243+
) : null}
244+
180245
{signature !== '' && (
181246
<EdgeCard>
182247
<EdgeRow
@@ -205,6 +270,43 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
205270
)
206271
}
207272

273+
interface SignatureFormatRowProps {
274+
disabled: boolean
275+
label: string
276+
selected: boolean
277+
testID: string
278+
onPress: () => void
279+
}
280+
281+
/**
282+
* A single radio option in the signature-format selector.
283+
*/
284+
const SignatureFormatRow: React.FC<SignatureFormatRowProps> = props => {
285+
const { disabled, label, selected, testID, onPress } = props
286+
const theme = useTheme()
287+
const styles = getStyles(theme)
288+
289+
return (
290+
<EdgeTouchableOpacity
291+
style={styles.formatRow}
292+
accessibilityRole="radio"
293+
accessibilityState={{ selected }}
294+
disabled={disabled}
295+
testID={testID}
296+
onPress={onPress}
297+
>
298+
<VectorIcon
299+
font="Ionicons"
300+
name={selected ? 'radio-button-on' : 'radio-button-off'}
301+
size={theme.rem(1.25)}
302+
color={theme.iconTappable}
303+
style={styles.formatRadioIcon}
304+
/>
305+
<EdgeText style={styles.formatRowLabel}>{label}</EdgeText>
306+
</EdgeTouchableOpacity>
307+
)
308+
}
309+
208310
const getStyles = cacheStyles((theme: Theme) => ({
209311
container: {
210312
padding: theme.rem(0.5)
@@ -217,6 +319,21 @@ const getStyles = cacheStyles((theme: Theme) => ({
217319
useDefaultText: {
218320
color: theme.iconTappable,
219321
fontSize: theme.rem(0.75)
322+
},
323+
formatSection: {
324+
paddingTop: theme.rem(0.5)
325+
},
326+
formatRow: {
327+
flexDirection: 'row',
328+
alignItems: 'center',
329+
paddingHorizontal: theme.rem(0.5),
330+
paddingVertical: theme.rem(0.5)
331+
},
332+
formatRadioIcon: {
333+
marginRight: theme.rem(0.75)
334+
},
335+
formatRowLabel: {
336+
flex: 1
220337
}
221338
}))
222339

src/locales/en_US.ts

Lines changed: 5 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:

src/locales/strings/enUS.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@
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.",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { base64 } from 'rfc4648'
2+
3+
/**
4+
* BIP-137 encodes the signing address' script type in the recoverable-signature
5+
* header byte, so a verifier can derive the address type without being told it.
6+
* The two SegWit variants:
7+
* - Native SegWit (P2WPKH, `bc1q…`): header 39-42 (Base64 prefix `K`/`L`)
8+
* - Nested SegWit (P2SH-P2WPKH, `3…`): header 35-38
9+
* Legacy (P2PKH) addresses are not remapped by BIP-137.
10+
*/
11+
export type SegwitAddressType = 'p2wpkh' | 'p2sh-p2wpkh'
12+
13+
interface SegwitChainInfo {
14+
// The bech32 human-readable prefix of the chain's native SegWit addresses.
15+
nativePrefix: string
16+
// The base58 leading character(s) of the chain's nested-SegWit (P2SH) addresses.
17+
nestedPrefixes: string[]
18+
}
19+
20+
// Only chains that actually issue SegWit addresses can produce BIP-137
21+
// signatures. Non-SegWit UTXO chains (Dogecoin, Bitcoin Cash, Dash) are absent
22+
// on purpose, so the UI hides the format option for them.
23+
const SEGWIT_SIGN_CHAINS: Record<string, SegwitChainInfo> = {
24+
bitcoin: { nativePrefix: 'bc1q', nestedPrefixes: ['3'] },
25+
litecoin: { nativePrefix: 'ltc1q', nestedPrefixes: ['M', '3'] },
26+
digibyte: { nativePrefix: 'dgb1q', nestedPrefixes: ['S'] }
27+
}
28+
29+
// The signing plugin emits a compressed-key legacy header of `27 + 4 + recid`.
30+
// BIP-137 keeps the recovery id but shifts the base by the script type.
31+
const LEGACY_COMPRESSED_HEADER_BASE = 31
32+
const NESTED_SEGWIT_HEADER_BASE = 35
33+
const NATIVE_SEGWIT_HEADER_BASE = 39
34+
35+
/**
36+
* Whether the chain issues SegWit addresses, and therefore whether the BIP-137
37+
* signature format is meaningful for it.
38+
*/
39+
export function isBip137Supported(pluginId: string): boolean {
40+
return SEGWIT_SIGN_CHAINS[pluginId] != null
41+
}
42+
43+
/**
44+
* Classifies an owned address as native or nested SegWit for the given chain,
45+
* or `null` when it is a legacy (or otherwise non-SegWit) address that BIP-137
46+
* does not remap.
47+
*/
48+
export function getSegwitAddressType(
49+
address: string,
50+
pluginId: string
51+
): SegwitAddressType | null {
52+
const chainInfo = SEGWIT_SIGN_CHAINS[pluginId]
53+
if (chainInfo == null) return null
54+
55+
const trimmed = address.trim()
56+
if (trimmed.toLowerCase().startsWith(chainInfo.nativePrefix)) {
57+
return 'p2wpkh'
58+
}
59+
if (chainInfo.nestedPrefixes.some(prefix => trimmed.startsWith(prefix))) {
60+
return 'p2sh-p2wpkh'
61+
}
62+
return null
63+
}
64+
65+
/**
66+
* Rewrites the header byte of a compact recoverable signature so it follows the
67+
* BIP-137 encoding for the given SegWit script type. The r/s components are
68+
* untouched, so the result verifies identically to a signature produced with
69+
* the matching `segwitType`. Returns the input unchanged when the header is not
70+
* the expected legacy-compressed byte.
71+
*/
72+
export function applyBip137Header(
73+
signatureBase64: string,
74+
segwitType: SegwitAddressType
75+
): string {
76+
const bytes = base64.parse(signatureBase64)
77+
const recoveryId = bytes[0] - LEGACY_COMPRESSED_HEADER_BASE
78+
if (recoveryId < 0 || recoveryId > 3) return signatureBase64
79+
80+
const headerBase =
81+
segwitType === 'p2wpkh'
82+
? NATIVE_SEGWIT_HEADER_BASE
83+
: NESTED_SEGWIT_HEADER_BASE
84+
bytes[0] = headerBase + recoveryId
85+
return base64.stringify(bytes)
86+
}

0 commit comments

Comments
 (0)