Skip to content

Commit 2814651

Browse files
committed
Let users choose the signing address in the Sign Message flow
The Sign Message scene now shows the wallet's receive address in an editable field instead of a read-only row. Exchanges typically ask a user to prove control of the specific address they already provided (often a previously-used one), so the user can replace the default with that address. The wallet must control whichever address is entered; the plugin signs with the key derived from that address's stored derivation path and rejects any address it does not own, surfaced as a clear error. Editing the address clears any prior signature, and a Use default address link restores the auto-detected receive address.
1 parent 4f82bb0 commit 2814651

4 files changed

Lines changed: 99 additions & 23 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.
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).
66

77
## 4.49.0 (staging)
88

src/components/scenes/SignMessageScene.tsx

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import { lstrings } from '../../locales/strings'
88
import type { EdgeAppSceneProps } from '../../types/routerTypes'
99
import { SceneButtons } from '../buttons/SceneButtons'
1010
import { EdgeCard } from '../cards/EdgeCard'
11+
import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
1112
import { SceneWrapper } from '../common/SceneWrapper'
1213
import { withWallet } from '../hoc/withWallet'
1314
import { EdgeRow } from '../rows/EdgeRow'
1415
import { showError } from '../services/AirshipInstance'
1516
import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
16-
import { Paragraph, SmallText } from '../themed/EdgeText'
17+
import { EdgeText, Paragraph, SmallText } from '../themed/EdgeText'
1718
import { FilledTextInput } from '../themed/FilledTextInput'
1819

1920
export interface SignMessageParams {
@@ -29,23 +30,31 @@ interface Props extends EdgeAppSceneProps<'signMessage'> {
2930
* self-hosted wallet ownership when withdrawing from a CEX/CASP (EU Travel
3031
* Rule). BTC-first: the menu entry only appears for UTXO wallets whose plugin
3132
* implements message signing.
33+
*
34+
* The signing address defaults to the wallet's current receive address, but is
35+
* editable: an exchange usually asks the user to prove control of the specific
36+
* address they already provided (often a previously-used one), so the user can
37+
* replace the default with that address. The wallet must control whichever
38+
* address is entered; the plugin signs with the key derived from that address's
39+
* stored derivation path, and rejects any address it does not own.
3240
*/
3341
const SignMessageSceneComponent: React.FC<Props> = props => {
3442
const { wallet } = props
3543

3644
const theme = useTheme()
3745
const styles = getStyles(theme)
3846

47+
const [address, setAddress] = React.useState('')
48+
const [addressTouched, setAddressTouched] = React.useState(false)
3949
const [message, setMessage] = React.useState('')
4050
const [signature, setSignature] = React.useState('')
4151
const [isSigning, setIsSigning] = React.useState(false)
4252

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({
53+
// Default to the wallet's own receive address. Prefer the native segwit
54+
// address (the canonical receive address the user hands the exchange),
55+
// matching `segwitAddress ?? publicAddress` used elsewhere; the
56+
// `publicAddress` type is the wrapped/legacy variant.
57+
const { data: defaultAddress, error: addressError } = useQuery({
4958
queryKey: ['signMessageAddress', wallet.id],
5059
queryFn: async () => {
5160
const addresses = await wallet.getAddresses({ tokenId: null })
@@ -64,15 +73,33 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
6473
if (addressError != null) showError(addressError)
6574
}, [addressError])
6675

67-
// Clear any prior signature when the message changes, so a stale signature
68-
// that no longer matches the message can never be copied.
76+
// Seed the editable address with the default once it loads, unless the user
77+
// has already typed their own address.
78+
React.useEffect(() => {
79+
if (!addressTouched && defaultAddress != null) setAddress(defaultAddress)
80+
}, [addressTouched, defaultAddress])
81+
82+
// A signature is bound to both the message and the address, so clear it
83+
// whenever either changes to prevent copying a stale signature.
84+
const handleChangeAddress = useHandler((text: string) => {
85+
setAddressTouched(true)
86+
setAddress(text.trim())
87+
setSignature('')
88+
})
89+
90+
const handleUseDefaultAddress = useHandler(() => {
91+
setAddressTouched(false)
92+
if (defaultAddress != null) setAddress(defaultAddress)
93+
setSignature('')
94+
})
95+
6996
const handleChangeMessage = useHandler((text: string) => {
7097
setMessage(text)
7198
setSignature('')
7299
})
73100

74101
const handleSign = useHandler(async () => {
75-
if (publicAddress == null) {
102+
if (address === '') {
76103
showError(lstrings.sign_message_no_address_error)
77104
return
78105
}
@@ -83,28 +110,58 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
83110
// produce a signature over the wrong data, so it is not usable here.
84111
// eslint-disable-next-line @typescript-eslint/no-deprecated
85112
const signedMessage = await wallet.signMessage(message, {
86-
otherParams: { publicAddress }
113+
otherParams: { publicAddress: address }
87114
})
88115
setSignature(signedMessage)
89116
} catch (error: unknown) {
90-
showError(error)
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.
119+
if (
120+
error instanceof Error &&
121+
/data-layer address|scriptPubkey|invalid/i.test(error.message)
122+
) {
123+
showError(lstrings.sign_message_address_not_owned_error)
124+
} else {
125+
showError(error)
126+
}
91127
} finally {
92128
setIsSigning(false)
93129
}
94130
})
95131

132+
const showUseDefault =
133+
defaultAddress != null && address !== defaultAddress && !isSigning
134+
96135
return (
97136
<SceneWrapper scroll>
98137
<View style={styles.container}>
99138
<Paragraph>{lstrings.sign_message_instructions}</Paragraph>
100139

101-
<EdgeCard>
102-
<EdgeRow
103-
rightButtonType="copy"
104-
title={lstrings.sign_message_address_label}
105-
body={publicAddress ?? ''}
106-
/>
107-
</EdgeCard>
140+
<FilledTextInput
141+
aroundRem={0.5}
142+
autoCapitalize="none"
143+
autoCorrect={false}
144+
// Lock the field while signing so the address cannot change mid-flight
145+
// and leave the produced signature bound to a different address.
146+
disabled={isSigning}
147+
placeholder={lstrings.sign_message_address_input_placeholder}
148+
testID="signMessageAddressInput"
149+
value={address}
150+
onChangeText={handleChangeAddress}
151+
/>
152+
<Paragraph>
153+
<SmallText>{lstrings.sign_message_address_helper}</SmallText>
154+
</Paragraph>
155+
{showUseDefault ? (
156+
<EdgeTouchableOpacity
157+
style={styles.useDefault}
158+
onPress={handleUseDefaultAddress}
159+
>
160+
<EdgeText style={styles.useDefaultText}>
161+
{lstrings.sign_message_use_default_address}
162+
</EdgeText>
163+
</EdgeTouchableOpacity>
164+
) : null}
108165

109166
<FilledTextInput
110167
aroundRem={0.5}
@@ -138,7 +195,7 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
138195
primary={{
139196
label: lstrings.sign_message_sign_button,
140197
onPress: handleSign,
141-
disabled: message === '' || publicAddress == null,
198+
disabled: message === '' || address === '',
142199
spinner: isSigning,
143200
testID: 'signMessageButton'
144201
}}
@@ -151,6 +208,15 @@ const SignMessageSceneComponent: React.FC<Props> = props => {
151208
const getStyles = cacheStyles((theme: Theme) => ({
152209
container: {
153210
padding: theme.rem(0.5)
211+
},
212+
useDefault: {
213+
alignSelf: 'flex-start',
214+
paddingHorizontal: theme.rem(0.5),
215+
paddingBottom: theme.rem(0.5)
216+
},
217+
useDefaultText: {
218+
color: theme.iconTappable,
219+
fontSize: theme.rem(0.75)
154220
}
155221
}))
156222

src/locales/en_US.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,11 @@ const strings = {
297297
sign_message_title: 'Sign Message',
298298
sign_message_instructions:
299299
'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',
300+
sign_message_address_label: 'Signing Address',
301+
sign_message_address_input_placeholder: 'Enter or paste the wallet address',
302+
sign_message_address_helper:
303+
'Defaults to your current receive address. To match a specific address you already gave the exchange, enter it here. This wallet must control the address.',
304+
sign_message_use_default_address: 'Use default address',
301305
sign_message_input_label: 'Message to Sign',
302306
sign_message_input_placeholder: 'Paste the message from the exchange',
303307
sign_message_sign_button: 'Sign Message',
@@ -306,6 +310,8 @@ const strings = {
306310
'Only sign messages from a service you trust. A signature proves you control this address but never reveals your private keys.',
307311
sign_message_no_address_error:
308312
'Unable to load a wallet address to sign with.',
313+
sign_message_address_not_owned_error:
314+
'This wallet does not control that address. Enter an address that belongs to this wallet.',
309315
fragment_wallets_pubkey_copied_title: 'XPub Address Copied',
310316
fragment_wallets_export_transactions: 'Export Transactions',
311317
fragment_wallets_rename_wallet: 'Rename Wallet',

src/locales/strings/enUS.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,13 +198,17 @@
198198
"fragment_wallets_sign_message": "Sign Message",
199199
"sign_message_title": "Sign Message",
200200
"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",
201+
"sign_message_address_label": "Signing Address",
202+
"sign_message_address_input_placeholder": "Enter or paste the wallet address",
203+
"sign_message_address_helper": "Defaults to your current receive address. To match a specific address you already gave the exchange, enter it here. This wallet must control the address.",
204+
"sign_message_use_default_address": "Use default address",
202205
"sign_message_input_label": "Message to Sign",
203206
"sign_message_input_placeholder": "Paste the message from the exchange",
204207
"sign_message_sign_button": "Sign Message",
205208
"sign_message_signature_label": "Signature",
206209
"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.",
207210
"sign_message_no_address_error": "Unable to load a wallet address to sign with.",
211+
"sign_message_address_not_owned_error": "This wallet does not control that address. Enter an address that belongs to this wallet.",
208212
"fragment_wallets_pubkey_copied_title": "XPub Address Copied",
209213
"fragment_wallets_export_transactions": "Export Transactions",
210214
"fragment_wallets_rename_wallet": "Rename Wallet",

0 commit comments

Comments
 (0)