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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 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).
- 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.
- changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings".
- 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.
Expand Down
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
223 changes: 223 additions & 0 deletions src/components/scenes/SignMessageScene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { useQuery } from '@tanstack/react-query'
import type { EdgeCurrencyWallet } from 'edge-core-js'
import * as React from 'react'
import { View } from 'react-native'

import { useHandler } from '../../hooks/useHandler'
import { lstrings } from '../../locales/strings'
import type { EdgeAppSceneProps } from '../../types/routerTypes'
import { SceneButtons } from '../buttons/SceneButtons'
import { EdgeCard } from '../cards/EdgeCard'
import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity'
import { SceneWrapper } from '../common/SceneWrapper'
import { withWallet } from '../hoc/withWallet'
import { EdgeRow } from '../rows/EdgeRow'
import { showError } from '../services/AirshipInstance'
import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
import { EdgeText, Paragraph, SmallText } from '../themed/EdgeText'
import { FilledTextInput } from '../themed/FilledTextInput'

export interface SignMessageParams {
walletId: string
}

interface Props extends EdgeAppSceneProps<'signMessage'> {
wallet: EdgeCurrencyWallet
}

/**
* Lets a user sign an arbitrary message with an address they control, to prove
* self-hosted wallet ownership when withdrawing from a CEX/CASP (EU Travel
* Rule). BTC-first: the menu entry only appears for UTXO wallets whose plugin
* implements message signing.
*
* The signing address defaults to the wallet's current receive address, but is
* editable: an exchange usually asks the 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.
*/
const SignMessageSceneComponent: React.FC<Props> = props => {
const { wallet } = props

const theme = useTheme()
const styles = getStyles(theme)

const [address, setAddress] = React.useState('')
const [addressTouched, setAddressTouched] = React.useState(false)
const [message, setMessage] = React.useState('')
const [signature, setSignature] = React.useState('')
const [isSigning, setIsSigning] = React.useState(false)

// Default to the wallet's own receive address. Prefer the native segwit
// address (the canonical receive address the user hands the exchange),
// matching `segwitAddress ?? publicAddress` used elsewhere; the
// `publicAddress` type is the wrapped/legacy variant.
const { data: defaultAddress, error: addressError } = useQuery({
queryKey: ['signMessageAddress', wallet.id],
queryFn: async () => {
const addresses = await wallet.getAddresses({ tokenId: null })
const receiveAddress =
addresses.find(address => address.addressType === 'segwitAddress') ??
addresses.find(address => address.addressType === 'publicAddress') ??
addresses[0]
if (receiveAddress == null) {
throw new Error(lstrings.sign_message_no_address_error)
}
return receiveAddress.publicAddress
Comment thread
cursor[bot] marked this conversation as resolved.
}
})

React.useEffect(() => {
if (addressError != null) showError(addressError)
}, [addressError])

// Seed the editable address with the default once it loads, unless the user
// has already typed their own address.
React.useEffect(() => {
if (!addressTouched && defaultAddress != null) setAddress(defaultAddress)
}, [addressTouched, defaultAddress])

// A signature is bound to both the message and the address, so clear it
// whenever either changes to prevent copying a stale signature.
const handleChangeAddress = useHandler((text: string) => {
setAddressTouched(true)
setAddress(text.trim())
setSignature('')
})

const handleUseDefaultAddress = useHandler(() => {
setAddressTouched(false)
if (defaultAddress != null) setAddress(defaultAddress)
setSignature('')
})

const handleChangeMessage = useHandler((text: string) => {
setMessage(text)
setSignature('')
})

const handleSign = useHandler(async () => {
if (address === '') {
showError(lstrings.sign_message_no_address_error)
return
}
setIsSigning(true)
try {
// `signMessage` signs the literal UTF-8 message, which is what exchanges
// verify. `signBytes` would base64-re-encode the bytes before signing and
// produce a signature over the wrong data, so it is not usable here.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const signedMessage = await wallet.signMessage(message, {
otherParams: { publicAddress: address }
})
setSignature(signedMessage)
Comment thread
j0ntz marked this conversation as resolved.
} catch (error: unknown) {
// The plugin throws when the wallet does not own the address (or it is
// malformed). Surface a clear, actionable message for that common case.
if (
error instanceof Error &&
/data-layer address|scriptPubkey|invalid/i.test(error.message)
) {
showError(lstrings.sign_message_address_not_owned_error)
} else {
showError(error)
}
} finally {
setIsSigning(false)
}
})

const showUseDefault =
defaultAddress != null && address !== defaultAddress && !isSigning

return (
<SceneWrapper scroll>
<View style={styles.container}>
<Paragraph>{lstrings.sign_message_instructions}</Paragraph>

<FilledTextInput
aroundRem={0.5}
autoCapitalize="none"
autoCorrect={false}
// Lock the field while signing so the address cannot change mid-flight
// and leave the produced signature bound to a different address.
disabled={isSigning}
placeholder={lstrings.sign_message_address_input_placeholder}
testID="signMessageAddressInput"
value={address}
onChangeText={handleChangeAddress}
/>
<Paragraph>
<SmallText>{lstrings.sign_message_address_helper}</SmallText>
</Paragraph>
{showUseDefault ? (
<EdgeTouchableOpacity
style={styles.useDefault}
onPress={handleUseDefaultAddress}
>
<EdgeText style={styles.useDefaultText}>
{lstrings.sign_message_use_default_address}
</EdgeText>
</EdgeTouchableOpacity>
) : null}

<FilledTextInput
aroundRem={0.5}
autoCorrect={false}
// Lock the field while signing so the message cannot change mid-flight
// and leave the produced signature bound to a different message.
disabled={isSigning}
multiline
numberOfLines={4}
placeholder={lstrings.sign_message_input_placeholder}
testID="signMessageInput"
value={message}
onChangeText={handleChangeMessage}
/>

{signature !== '' && (
<EdgeCard>
<EdgeRow
rightButtonType="copy"
title={lstrings.sign_message_signature_label}
body={signature}
/>
</EdgeCard>
)}
Comment thread
cursor[bot] marked this conversation as resolved.

<Paragraph>
<SmallText>{lstrings.sign_message_safety_note}</SmallText>
</Paragraph>

<SceneButtons
primary={{
label: lstrings.sign_message_sign_button,
onPress: handleSign,
disabled: message === '' || address === '',
spinner: isSigning,
testID: 'signMessageButton'
}}
/>
</View>
</SceneWrapper>
)
}

const getStyles = cacheStyles((theme: Theme) => ({
container: {
padding: theme.rem(0.5)
},
useDefault: {
alignSelf: 'flex-start',
paddingHorizontal: theme.rem(0.5),
paddingBottom: theme.rem(0.5)
},
useDefaultText: {
color: theme.iconTappable,
fontSize: theme.rem(0.75)
}
}))

export const SignMessageScene = withWallet(SignMessageSceneComponent)
19 changes: 19 additions & 0 deletions src/locales/en_US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,25 @@ const strings = {
fragment_wallets_view_private_view_key: 'Private View Key',
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.`,
fragment_wallets_view_xpub: 'View XPub Address',
fragment_wallets_sign_message: 'Sign Message',
sign_message_title: 'Sign Message',
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.',
sign_message_address_label: 'Signing Address',
sign_message_address_input_placeholder: 'Enter or paste the wallet address',
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.',
sign_message_use_default_address: 'Use default address',
sign_message_input_label: 'Message to Sign',
sign_message_input_placeholder: 'Paste the message from the exchange',
sign_message_sign_button: 'Sign Message',
sign_message_signature_label: 'Signature',
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.',
sign_message_no_address_error:
'Unable to load a wallet address to sign with.',
sign_message_address_not_owned_error:
'This wallet does not control that address. Enter an address that belongs to this wallet.',
fragment_wallets_pubkey_copied_title: 'XPub Address Copied',
fragment_wallets_export_transactions: 'Export Transactions',
fragment_wallets_rename_wallet: 'Rename Wallet',
Expand Down
14 changes: 14 additions & 0 deletions src/locales/strings/enUS.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,20 @@
"fragment_wallets_view_private_view_key": "Private View Key",
"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.",
"fragment_wallets_view_xpub": "View XPub Address",
"fragment_wallets_sign_message": "Sign Message",
"sign_message_title": "Sign Message",
"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.",
"sign_message_address_label": "Signing Address",
"sign_message_address_input_placeholder": "Enter or paste the wallet address",
"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.",
"sign_message_use_default_address": "Use default address",
"sign_message_input_label": "Message to Sign",
"sign_message_input_placeholder": "Paste the message from the exchange",
"sign_message_sign_button": "Sign Message",
"sign_message_signature_label": "Signature",
"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.",
"sign_message_no_address_error": "Unable to load a wallet address to sign with.",
"sign_message_address_not_owned_error": "This wallet does not control that address. Enter an address that belongs to this wallet.",
"fragment_wallets_pubkey_copied_title": "XPub Address Copied",
"fragment_wallets_export_transactions": "Export Transactions",
"fragment_wallets_rename_wallet": "Rename Wallet",
Expand Down
2 changes: 2 additions & 0 deletions src/types/routerTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import type { RampPendingParams } from '../components/scenes/RampPendingScene'
import type { RampSelectOptionParams } from '../components/scenes/RampSelectOptionScene'
import type { RequestParams } from '../components/scenes/RequestScene'
import type { SendScene2Params } from '../components/scenes/SendScene2'
import type { SignMessageParams } from '../components/scenes/SignMessageScene'
import type { EarnSceneParams } from '../components/scenes/Staking/EarnScene'
import type { StakeModifyParams } from '../components/scenes/Staking/StakeModifyScene'
import type { StakeOptionsParams } from '../components/scenes/Staking/StakeOptionsScene'
Expand Down Expand Up @@ -236,6 +237,7 @@ export type EdgeAppStackParamList = {} & {
send2: SendScene2Params
settingsOverview: undefined
settingsOverviewTab: undefined
signMessage: SignMessageParams
spendingLimits: undefined
stakeModify: StakeModifyParams
stakeOptions: StakeOptionsParams
Expand Down
Loading