Skip to content

Commit 6a9dcea

Browse files
committed
Add wallet Sign Message flow for CEX ownership verification
Adds a Sign Message option to the wallet list menu for Bitcoin-family (UTXO) wallets. The new scene shows the wallet's receive address, lets the user paste an exchange-provided message, signs it with the wallet key, and returns a copyable signature. This lets users prove control of a self-hosted wallet for CEX/CASP withdrawal checks (EU Travel Rule) without manual ID/selfie verification.
1 parent 6cd8d59 commit 6a9dcea

8 files changed

Lines changed: 225 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +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.
56
- 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.
67
- changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings".
78
- 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.

src/actions/WalletListMenuActions.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type WalletListMenuKey =
4040
| 'exportWalletTransactions'
4141
| 'getSeed'
4242
| 'manageTokens'
43+
| 'signMessage'
4344
| 'viewXPub'
4445
| 'goToParent'
4546
| 'getRawKeys'
@@ -76,6 +77,14 @@ export function walletListMenuAction(
7677
}
7778
}
7879

80+
case 'signMessage': {
81+
return async (dispatch, getState) => {
82+
navigation.navigate('signMessage', {
83+
walletId
84+
})
85+
}
86+
}
87+
7988
case 'rawDelete': {
8089
return async (dispatch, getState) => {
8190
const state = getState()

src/components/Main.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import { ReviewTriggerTestScene } from './scenes/ReviewTriggerTestScene'
140140
import { SecurityAlertsScene as SecurityAlertsSceneComponent } from './scenes/SecurityAlertsScene'
141141
import { SendScene2 as SendScene2Component } from './scenes/SendScene2'
142142
import { SettingsScene as SettingsSceneComponent } from './scenes/SettingsScene'
143+
import { SignMessageScene as SignMessageSceneComponent } from './scenes/SignMessageScene'
143144
import { SpendingLimitsScene as SpendingLimitsSceneComponent } from './scenes/SpendingLimitsScene'
144145
import { EarnScene as EarnSceneComponent } from './scenes/Staking/EarnScene'
145146
import { StakeModifyScene as StakeModifySceneComponent } from './scenes/Staking/StakeModifyScene'
@@ -283,6 +284,7 @@ const SecurityAlertsScene = ifLoggedIn(SecurityAlertsSceneComponent)
283284
const SellScene = ifLoggedIn(SellSceneComponent)
284285
const SendScene2 = ifLoggedIn(SendScene2Component)
285286
const SettingsScene = ifLoggedIn(SettingsSceneComponent)
287+
const SignMessageScene = ifLoggedIn(SignMessageSceneComponent)
286288
const SpendingLimitsScene = ifLoggedIn(SpendingLimitsSceneComponent)
287289
const StakeModifyScene = ifLoggedIn(StakeModifySceneComponent)
288290
const StakeOptionsScene = ifLoggedIn(StakeOptionsSceneComponent)
@@ -1101,6 +1103,13 @@ const EdgeAppStack: React.FC = () => {
11011103
title: lstrings.title_settings
11021104
}}
11031105
/>
1106+
<AppStack.Screen
1107+
name="signMessage"
1108+
component={SignMessageScene}
1109+
options={{
1110+
title: lstrings.sign_message_title
1111+
}}
1112+
/>
11041113
<AppStack.Screen
11051114
name="spendingLimits"
11061115
component={SpendingLimitsScene}

src/components/modals/WalletListMenuModal.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const icons: Record<string, string> = {
5151
goToParent: 'upcircleo',
5252
manageTokens: 'plus',
5353
rawDelete: 'warning',
54+
signMessage: 'edit',
5455
walletSettings: 'control-panel-settings',
5556
resync: 'sync',
5657
split: 'arrowsalt',
@@ -115,6 +116,34 @@ export const WALLET_LIST_MENU: Array<{
115116
label: lstrings.fragment_wallets_view_xpub,
116117
value: 'viewXPub'
117118
},
119+
{
120+
pluginIds: [
121+
'badcoin',
122+
'bitcoin',
123+
'bitcoincash',
124+
'bitcoincashtestnet',
125+
'bitcoingold',
126+
'bitcoingoldtestnet',
127+
'bitcoinsv',
128+
'bitcointestnet',
129+
'bitcointestnet4',
130+
'dash',
131+
'digibyte',
132+
'dogecoin',
133+
'eboost',
134+
'feathercoin',
135+
'groestlcoin',
136+
'litecoin',
137+
'qtum',
138+
'ravencoin',
139+
'smartcash',
140+
'ufo',
141+
'vertcoin',
142+
'zcoin'
143+
],
144+
label: lstrings.fragment_wallets_sign_message,
145+
value: 'signMessage'
146+
},
118147
{
119148
pluginIds: ['monero', 'piratechain', 'zcash', 'zano'],
120149
label: lstrings.fragment_wallets_view_private_view_key,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import type { EdgeCurrencyWallet } from 'edge-core-js'
3+
import * as React from 'react'
4+
import { View } from 'react-native'
5+
6+
import { useHandler } from '../../hooks/useHandler'
7+
import { lstrings } from '../../locales/strings'
8+
import type { EdgeAppSceneProps } from '../../types/routerTypes'
9+
import { SceneButtons } from '../buttons/SceneButtons'
10+
import { EdgeCard } from '../cards/EdgeCard'
11+
import { SceneWrapper } from '../common/SceneWrapper'
12+
import { withWallet } from '../hoc/withWallet'
13+
import { EdgeRow } from '../rows/EdgeRow'
14+
import { showError } from '../services/AirshipInstance'
15+
import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
16+
import { Paragraph, SmallText } from '../themed/EdgeText'
17+
import { FilledTextInput } from '../themed/FilledTextInput'
18+
19+
export interface SignMessageParams {
20+
walletId: string
21+
}
22+
23+
interface Props extends EdgeAppSceneProps<'signMessage'> {
24+
wallet: EdgeCurrencyWallet
25+
}
26+
27+
/**
28+
* Lets a user sign an arbitrary message with an address they control, to prove
29+
* self-hosted wallet ownership when withdrawing from a CEX/CASP (EU Travel
30+
* Rule). BTC-first: the menu entry only appears for UTXO wallets whose plugin
31+
* implements message signing.
32+
*/
33+
const SignMessageSceneComponent: React.FC<Props> = props => {
34+
const { wallet } = props
35+
36+
const theme = useTheme()
37+
const styles = getStyles(theme)
38+
39+
const [message, setMessage] = React.useState('')
40+
const [signature, setSignature] = React.useState('')
41+
const [isSigning, setIsSigning] = React.useState(false)
42+
43+
// The signing address must be one the wallet owns, so we default to the
44+
// wallet's own receive address rather than accepting an arbitrary one.
45+
// Prefer the native segwit address (the canonical receive address the user
46+
// hands the exchange), matching `segwitAddress ?? publicAddress` used
47+
// elsewhere; the `publicAddress` type is the wrapped/legacy variant.
48+
const { data: publicAddress, error: addressError } = useQuery({
49+
queryKey: ['signMessageAddress', wallet.id],
50+
queryFn: async () => {
51+
const addresses = await wallet.getAddresses({ tokenId: null })
52+
const receiveAddress =
53+
addresses.find(address => address.addressType === 'segwitAddress') ??
54+
addresses.find(address => address.addressType === 'publicAddress') ??
55+
addresses[0]
56+
if (receiveAddress == null) {
57+
throw new Error(lstrings.sign_message_no_address_error)
58+
}
59+
return receiveAddress.publicAddress
60+
}
61+
})
62+
63+
React.useEffect(() => {
64+
if (addressError != null) showError(addressError)
65+
}, [addressError])
66+
67+
// Clear any prior signature when the message changes, so a stale signature
68+
// that no longer matches the message can never be copied.
69+
const handleChangeMessage = useHandler((text: string) => {
70+
setMessage(text)
71+
setSignature('')
72+
})
73+
74+
const handleSign = useHandler(async () => {
75+
if (publicAddress == null) {
76+
showError(lstrings.sign_message_no_address_error)
77+
return
78+
}
79+
setIsSigning(true)
80+
try {
81+
// `signMessage` signs the literal UTF-8 message, which is what exchanges
82+
// verify. `signBytes` would base64-re-encode the bytes before signing and
83+
// produce a signature over the wrong data, so it is not usable here.
84+
// eslint-disable-next-line @typescript-eslint/no-deprecated
85+
const signedMessage = await wallet.signMessage(message, {
86+
otherParams: { publicAddress }
87+
})
88+
setSignature(signedMessage)
89+
} catch (error: unknown) {
90+
showError(error)
91+
} finally {
92+
setIsSigning(false)
93+
}
94+
})
95+
96+
return (
97+
<SceneWrapper scroll>
98+
<View style={styles.container}>
99+
<Paragraph>{lstrings.sign_message_instructions}</Paragraph>
100+
101+
<EdgeCard>
102+
<EdgeRow
103+
rightButtonType="copy"
104+
title={lstrings.sign_message_address_label}
105+
body={publicAddress ?? ''}
106+
/>
107+
</EdgeCard>
108+
109+
<FilledTextInput
110+
aroundRem={0.5}
111+
autoCorrect={false}
112+
multiline
113+
numberOfLines={4}
114+
placeholder={lstrings.sign_message_input_placeholder}
115+
value={message}
116+
onChangeText={handleChangeMessage}
117+
/>
118+
119+
{signature !== '' && (
120+
<EdgeCard>
121+
<EdgeRow
122+
rightButtonType="copy"
123+
title={lstrings.sign_message_signature_label}
124+
body={signature}
125+
/>
126+
</EdgeCard>
127+
)}
128+
129+
<Paragraph>
130+
<SmallText>{lstrings.sign_message_safety_note}</SmallText>
131+
</Paragraph>
132+
133+
<SceneButtons
134+
primary={{
135+
label: lstrings.sign_message_sign_button,
136+
onPress: handleSign,
137+
disabled: message === '' || publicAddress == null,
138+
spinner: isSigning
139+
}}
140+
/>
141+
</View>
142+
</SceneWrapper>
143+
)
144+
}
145+
146+
const getStyles = cacheStyles((theme: Theme) => ({
147+
container: {
148+
padding: theme.rem(0.5)
149+
}
150+
}))
151+
152+
export const SignMessageScene = withWallet(SignMessageSceneComponent)

src/locales/en_US.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,19 @@ const strings = {
293293
fragment_wallets_view_private_view_key: 'Private View Key',
294294
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.`,
295295
fragment_wallets_view_xpub: 'View XPub Address',
296+
fragment_wallets_sign_message: 'Sign Message',
297+
sign_message_title: 'Sign Message',
298+
sign_message_instructions:
299+
'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.',
300+
sign_message_address_label: 'Wallet Address',
301+
sign_message_input_label: 'Message to Sign',
302+
sign_message_input_placeholder: 'Paste the message from the exchange',
303+
sign_message_sign_button: 'Sign Message',
304+
sign_message_signature_label: 'Signature',
305+
sign_message_safety_note:
306+
'Only sign messages from a service you trust. A signature proves you control this address but never reveals your private keys.',
307+
sign_message_no_address_error:
308+
'Unable to load a wallet address to sign with.',
296309
fragment_wallets_pubkey_copied_title: 'XPub Address Copied',
297310
fragment_wallets_export_transactions: 'Export Transactions',
298311
fragment_wallets_rename_wallet: 'Rename Wallet',

src/locales/strings/enUS.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,16 @@
195195
"fragment_wallets_view_private_view_key": "Private View Key",
196196
"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.",
197197
"fragment_wallets_view_xpub": "View XPub Address",
198+
"fragment_wallets_sign_message": "Sign Message",
199+
"sign_message_title": "Sign Message",
200+
"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.",
201+
"sign_message_address_label": "Wallet Address",
202+
"sign_message_input_label": "Message to Sign",
203+
"sign_message_input_placeholder": "Paste the message from the exchange",
204+
"sign_message_sign_button": "Sign Message",
205+
"sign_message_signature_label": "Signature",
206+
"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.",
207+
"sign_message_no_address_error": "Unable to load a wallet address to sign with.",
198208
"fragment_wallets_pubkey_copied_title": "XPub Address Copied",
199209
"fragment_wallets_export_transactions": "Export Transactions",
200210
"fragment_wallets_rename_wallet": "Rename Wallet",

src/types/routerTypes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import type { RampPendingParams } from '../components/scenes/RampPendingScene'
5858
import type { RampSelectOptionParams } from '../components/scenes/RampSelectOptionScene'
5959
import type { RequestParams } from '../components/scenes/RequestScene'
6060
import type { SendScene2Params } from '../components/scenes/SendScene2'
61+
import type { SignMessageParams } from '../components/scenes/SignMessageScene'
6162
import type { EarnSceneParams } from '../components/scenes/Staking/EarnScene'
6263
import type { StakeModifyParams } from '../components/scenes/Staking/StakeModifyScene'
6364
import type { StakeOptionsParams } from '../components/scenes/Staking/StakeOptionsScene'
@@ -236,6 +237,7 @@ export type EdgeAppStackParamList = {} & {
236237
send2: SendScene2Params
237238
settingsOverview: undefined
238239
settingsOverviewTab: undefined
240+
signMessage: SignMessageParams
239241
spendingLimits: undefined
240242
stakeModify: StakeModifyParams
241243
stakeOptions: StakeOptionsParams

0 commit comments

Comments
 (0)