diff --git a/mobile/app/Settings.tsx b/mobile/app/Settings.tsx index e5f15c18..88968bdf 100644 --- a/mobile/app/Settings.tsx +++ b/mobile/app/Settings.tsx @@ -20,6 +20,8 @@ import { SETTINGS_CONFIG } from '@shared/hooks/SettingsContext'; import { useSettings } from '@shared/hooks/useSettings'; import { capitalizeFirstLetter } from '@shared/modules/string-utils'; import { STORAGE_KEY_BTC_XPUB, STORAGE_KEY_MNEMONIC } from '@shared/types/IStorage'; +import { getIsEVM } from '@shared/models/network-getters'; +import { NETWORK_SPARK } from '@shared/types/networks'; import { BackgroundExecutor } from '@/src/modules/background-executor'; const gitCommitHash = require('../git_commit_hash.json'); @@ -231,6 +233,16 @@ export default function SettingsScreen() { > ScanQr + + {network && (getIsEVM(network) || network === NETWORK_SPARK) && ( + router.push('/SignMessage')} + testID="SignMessageButton" + > + Sign Message + + )} diff --git a/mobile/app/SignMessage.tsx b/mobile/app/SignMessage.tsx new file mode 100644 index 00000000..1870f56d --- /dev/null +++ b/mobile/app/SignMessage.tsx @@ -0,0 +1,334 @@ +import { Ionicons } from '@expo/vector-icons'; +import { Stack, useRouter } from 'expo-router'; +import React, { useContext, useState } from 'react'; +import { View, ScrollView, StyleSheet, TextInput, TouchableOpacity, Alert, Text, Clipboard } from 'react-native'; + +import GradientScreen from '@/components/GradientScreen'; +import ScreenHeader from '@/components/navigation/ScreenHeader'; +import { ThemedText } from '@/components/ThemedText'; +import { AskPasswordContext } from '@/src/hooks/AskPasswordContext'; +import { BackgroundExecutor } from '@/src/modules/background-executor'; +import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { getIsEVM } from '@shared/models/network-getters'; +import { NETWORK_SPARK } from '@shared/types/networks'; + +const SignMessage = () => { + const router = useRouter(); + const { network } = useContext(NetworkContext); + const { accountNumber } = useContext(AccountNumberContext); + const { askPassword } = useContext(AskPasswordContext); + + const [message, setMessage] = useState(''); + const [signature, setSignature] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [address, setAddress] = useState(''); + + // This screen is available for all EVM-compatible networks and Spark + // But we'll handle it gracefully if accessed from unsupported networks + const isEVMNetwork = network ? getIsEVM(network) : false; + const isSparkNetwork = network === NETWORK_SPARK; + const isSupported = isEVMNetwork || isSparkNetwork; + + // Fetch the address when component mounts or network/account changes + React.useEffect(() => { + const fetchAddress = async () => { + if (isSupported && network) { + try { + const walletAddress = await BackgroundExecutor.getAddress(network, accountNumber); + setAddress(walletAddress); + } catch (error) { + console.error('Failed to fetch address:', error); + } + } + }; + fetchAddress(); + }, [network, accountNumber, isSupported]); + + const handleSign = async () => { + if (!message.trim()) { + Alert.alert('Error', 'Please enter a message to sign'); + return; + } + + if (!isSupported) { + Alert.alert('Error', 'Message signing is only available on EVM-compatible networks and Spark'); + return; + } + + setIsLoading(true); + try { + let result; + if (isSparkNetwork) { + // Spark doesn't need password as it uses unencrypted submnemonics + result = await BackgroundExecutor.signSparkMessage( + message, + accountNumber, + null // No password needed for Spark + ); + } else { + // EVM chains need password to decrypt the mnemonic + const password = await askPassword(); + result = await BackgroundExecutor.signPersonalMessage( + message, + accountNumber, + password + ); + } + + if (result?.success) { + setSignature(result.bytes || result.signature); + Alert.alert('Success', 'Message signed successfully!'); + } else { + throw new Error(result.message || 'Failed to sign message'); + } + } catch (error: any) { + Alert.alert('Error', error.message || 'Failed to sign message'); + } finally { + setIsLoading(false); + } + }; + + const copyToClipboard = () => { + if (signature) { + Clipboard.setString(signature); + Alert.alert('Copied', 'Signature copied to clipboard'); + } + }; + + return ( + + + + + + + {isSupported ? ( + <> + + Sign a message with your {isSparkNetwork ? 'Spark identity' : 'EVM private'} key. This creates a cryptographic proof + that you control this wallet address on the {network} network. + + + + Signing Address + + {address || 'Loading...'} + {address && ( + { + Clipboard.setString(address); + Alert.alert('Copied', 'Address copied to clipboard'); + }} + style={styles.copyIcon} + > + + + )} + + + + + Message to Sign + + + + + + + {isLoading ? 'Signing...' : 'Sign Message'} + + + + {signature ? ( + + {isSparkNetwork ? 'Spark' : 'EVM'} Signature: + + + {signature} + + + + + Copy Signature + + + ) : null} + + + + + This signature proves you control the private key for account #{accountNumber} on the {network} network without revealing the key itself. + + + + ) : ( + + + + Message signing is only available on EVM-compatible networks and Spark. + + + Please switch to a supported network (Rootstock, Botanix, Spark, etc.) from the home screen to use this feature. + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + scrollContent: { + flexGrow: 1, + paddingHorizontal: 16, + }, + contentContainer: { + flex: 1, + paddingVertical: 20, + }, + description: { + color: 'rgba(255, 255, 255, 0.8)', + marginBottom: 30, + lineHeight: 20, + }, + inputSection: { + marginBottom: 20, + }, + inputLabel: { + color: 'rgba(255, 255, 255, 0.9)', + marginBottom: 10, + fontWeight: '500', + }, + addressContainer: { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 12, + padding: 15, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + addressText: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 13, + fontFamily: 'monospace', + flex: 1, + }, + copyIcon: { + marginLeft: 10, + padding: 4, + }, + messageInput: { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 12, + padding: 15, + color: 'rgba(255, 255, 255, 0.9)', + minHeight: 120, + fontSize: 14, + }, + signButton: { + backgroundColor: '#000000', + borderRadius: 12, + paddingVertical: 15, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + marginBottom: 30, + }, + buttonDisabled: { + opacity: 0.6, + }, + signButtonText: { + color: 'rgba(255, 255, 255, 0.9)', + fontWeight: '600', + }, + resultSection: { + backgroundColor: 'rgba(0, 255, 0, 0.05)', + borderWidth: 1, + borderColor: 'rgba(0, 255, 0, 0.2)', + borderRadius: 12, + padding: 15, + marginBottom: 30, + }, + resultLabel: { + color: 'rgba(255, 255, 255, 0.7)', + marginBottom: 10, + fontSize: 12, + }, + signatureContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.3)', + borderRadius: 8, + padding: 10, + marginBottom: 10, + }, + signatureText: { + color: 'rgba(0, 255, 0, 0.8)', + fontFamily: 'monospace', + fontSize: 12, + }, + copyButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingVertical: 8, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderRadius: 8, + }, + copyButtonText: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + }, + infoSection: { + flexDirection: 'row', + gap: 10, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + padding: 15, + borderRadius: 12, + }, + infoText: { + flex: 1, + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 12, + lineHeight: 18, + }, + unsupportedSection: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 60, + }, + unsupportedText: { + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 16, + textAlign: 'center', + marginTop: 20, + marginBottom: 10, + }, + unsupportedSubtext: { + color: 'rgba(255, 255, 255, 0.4)', + fontSize: 14, + textAlign: 'center', + paddingHorizontal: 20, + }, +}); + +export default SignMessage; \ No newline at end of file diff --git a/mobile/src/modules/background-executor.ts b/mobile/src/modules/background-executor.ts index 437ed0bc..11a1dd9d 100644 --- a/mobile/src/modules/background-executor.ts +++ b/mobile/src/modules/background-executor.ts @@ -230,6 +230,44 @@ export const BackgroundExecutor: IBackgroundCaller = { } }, + async signSparkMessage(message, accountNumber, password) { + try { + // Get the submnemonic for the account + const submnemonic = await SecureStorage.getItem(STORAGE_KEY_SUB_MNEMONIC + accountNumber); + + if (!submnemonic) { + return { + success: false, + signature: '', + message: 'No submnemonic found for this account. Please reinstall the app.', + }; + } + + // Get or initialize the Spark wallet + const wallet = await BackgroundExecutor.lazyInitWallet(NETWORK_SPARK, accountNumber); + + if (!(wallet instanceof SparkWallet)) { + return { + success: false, + signature: '', + message: 'Failed to initialize Spark wallet', + }; + } + + // Sign the message with the identity key + const signature = await wallet.signMessageWithIdentityKey(message); + + return { success: true, signature }; + } catch (error: any) { + console.error('Error signing Spark message:', error); + return { + success: false, + signature: '', + message: error.message || 'Failed to sign message with Spark wallet', + }; + } + }, + async openPopup(...params: OpenPopupRequest) { const bridge = BrowserBridge.getInstance(); if (bridge) { diff --git a/shared/class/wallets/spark-wallet.ts b/shared/class/wallets/spark-wallet.ts index 91fea942..511f46d0 100644 --- a/shared/class/wallets/spark-wallet.ts +++ b/shared/class/wallets/spark-wallet.ts @@ -186,4 +186,49 @@ export class SparkWallet extends ArkWallet implements InterfaceLightningWallet { return await this._sdkWallet.transferTokens({ receiverSparkAddress, tokenAmount, tokenIdentifier: tokenIdentifier as Bech32mTokenIdentifier }); } + + /** + * Sign a message with the wallet's identity key + * @param message - The message to sign (string or Uint8Array) + * @param compact - Whether to use compact signature format (default: true) + * @returns The signature as a hex string + */ + async signMessageWithIdentityKey(message: string | Uint8Array, compact: boolean = true): Promise { + if (!this._sdkWallet) throw new Error('Spark wallet not initialized'); + + // Convert string to Uint8Array if needed + const messageBytes = typeof message === 'string' + ? new TextEncoder().encode(message) + : message; + + // Sign the message using the SDK's signMessageWithIdentityKey method + const signature = await this._sdkWallet.signMessageWithIdentityKey(messageBytes, compact); + + // Convert signature to hex string if it's a Uint8Array + if (signature instanceof Uint8Array) { + return Buffer.from(signature).toString('hex'); + } + + return signature; + } + + /** + * Validate a message signature against the identity key + * @param message - The original message + * @param signature - The signature to validate + * @returns True if the signature is valid + */ + async validateMessageWithIdentityKey(message: string | Uint8Array, signature: string | Uint8Array): Promise { + if (!this._sdkWallet) throw new Error('Spark wallet not initialized'); + + const messageBytes = typeof message === 'string' + ? new TextEncoder().encode(message) + : message; + + const signatureBytes = typeof signature === 'string' + ? Buffer.from(signature, 'hex') + : signature; + + return await this._sdkWallet.validateMessageWithIdentityKey(messageBytes, signatureBytes); + } }