Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions src/__tests__/util/bitcoinMessageSignature.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
9 changes: 9 additions & 0 deletions src/actions/WalletListMenuActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type WalletListMenuKey =
| 'exportWalletTransactions'
| 'getSeed'
| 'manageTokens'
| 'signMessage'
| 'viewXPub'
| 'goToParent'
| 'getRawKeys'
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions src/components/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1101,6 +1103,13 @@ const EdgeAppStack: React.FC = () => {
title: lstrings.title_settings
}}
/>
<AppStack.Screen
name="signMessage"
component={SignMessageScene}
options={{
title: lstrings.sign_message_title
}}
/>
<AppStack.Screen
name="spendingLimits"
component={SpendingLimitsScene}
Expand Down
29 changes: 29 additions & 0 deletions src/components/modals/WalletListMenuModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const icons: Record<string, string> = {
goToParent: 'upcircleo',
manageTokens: 'plus',
rawDelete: 'warning',
signMessage: 'edit',
walletSettings: 'control-panel-settings',
resync: 'sync',
split: 'arrowsalt',
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading