From 23dbb4e4bdd33d2f5820f7f9b395513968132969 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:38:15 +0200 Subject: [PATCH 1/3] feat: add message signing for Rootstock network - Add Sign Message button in Developer Options (only visible on Rootstock) - Create SignMessage screen with EVM-compatible message signing - Reuse existing signPersonalMessage from BackgroundExecutor - Include signature display and copy-to-clipboard functionality --- mobile/app/Settings.tsx | 11 ++ mobile/app/SignMessage.tsx | 268 +++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 mobile/app/SignMessage.tsx diff --git a/mobile/app/Settings.tsx b/mobile/app/Settings.tsx index e5f15c18..e37469fb 100644 --- a/mobile/app/Settings.tsx +++ b/mobile/app/Settings.tsx @@ -20,6 +20,7 @@ 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 { NETWORK_ROOTSTOCK } from '@shared/types/networks'; import { BackgroundExecutor } from '@/src/modules/background-executor'; const gitCommitHash = require('../git_commit_hash.json'); @@ -231,6 +232,16 @@ export default function SettingsScreen() { > ScanQr + + {network === NETWORK_ROOTSTOCK && ( + 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..4e9b608d --- /dev/null +++ b/mobile/app/SignMessage.tsx @@ -0,0 +1,268 @@ +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 { NETWORK_ROOTSTOCK } 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); + + // This screen should only be accessible from Rootstock network + // But we'll handle it gracefully if accessed from elsewhere + const isRootstock = network === NETWORK_ROOTSTOCK; + + const handleSign = async () => { + if (!message.trim()) { + Alert.alert('Error', 'Please enter a message to sign'); + return; + } + + if (!isRootstock) { + Alert.alert('Error', 'Message signing is only available on Rootstock network'); + return; + } + + setIsLoading(true); + try { + const password = await askPassword(); + + // Use EVM signing for Rootstock (it's an EVM-compatible chain) + const result = await BackgroundExecutor.signPersonalMessage( + message, + accountNumber, + password + ); + + if (result.success) { + setSignature(result.bytes); + 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 ( + + + + + + + {isRootstock ? ( + <> + + Sign a message with your Rootstock private key. This creates a cryptographic proof + that you control this wallet address on the Rootstock network. + + + + Message to Sign + + + + + + + {isLoading ? 'Signing...' : 'Sign Message'} + + + + {signature ? ( + + Rootstock Signature: + + + {signature} + + + + + Copy Signature + + + ) : null} + + + + + This signature proves you control the private key for account #{accountNumber} on the Rootstock network without revealing the key itself. + + + + ) : ( + + + + Message signing is only available when using the Rootstock network. + + + Please switch to Rootstock 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', + }, + 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 From a5b61c6c6730969e7f026cdbb14b3eb1da8533c4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 15 Sep 2025 20:51:09 +0200 Subject: [PATCH 2/3] Enable message signing for all EVM-compatible networks (#7) * Enable message signing for all EVM-compatible networks - Extend SignMessage module to support all EVM chains (Rootstock, Botanix, etc.) - Replace Rootstock-specific checks with generic EVM network detection - Update UI text to reflect multi-network support * Show Sign Message button for all EVM networks in Settings * Add signing address display to SignMessage screen - Display the EVM address that will be used for signing - Add copy functionality for the address - Show address field above the message input - Works for all EVM-compatible networks * Update comment to reflect multi-chain EVM support --- mobile/app/Settings.tsx | 4 +- mobile/app/SignMessage.tsx | 84 +++++++++++++++++++++++++++++++------- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/mobile/app/Settings.tsx b/mobile/app/Settings.tsx index e37469fb..dbc6995b 100644 --- a/mobile/app/Settings.tsx +++ b/mobile/app/Settings.tsx @@ -20,7 +20,7 @@ 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 { NETWORK_ROOTSTOCK } from '@shared/types/networks'; +import { getIsEVM } from '@shared/models/network-getters'; import { BackgroundExecutor } from '@/src/modules/background-executor'; const gitCommitHash = require('../git_commit_hash.json'); @@ -233,7 +233,7 @@ export default function SettingsScreen() { ScanQr - {network === NETWORK_ROOTSTOCK && ( + {network && getIsEVM(network) && ( router.push('/SignMessage')} diff --git a/mobile/app/SignMessage.tsx b/mobile/app/SignMessage.tsx index 4e9b608d..3b8f4485 100644 --- a/mobile/app/SignMessage.tsx +++ b/mobile/app/SignMessage.tsx @@ -10,21 +10,37 @@ 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 { NETWORK_ROOTSTOCK } from '@shared/types/networks'; +import { getIsEVM } from '@shared/models/network-getters'; 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 + // But we'll handle it gracefully if accessed from non-EVM networks + const isEVMNetwork = network ? getIsEVM(network) : false; - // This screen should only be accessible from Rootstock network - // But we'll handle it gracefully if accessed from elsewhere - const isRootstock = network === NETWORK_ROOTSTOCK; + // Fetch the EVM address when component mounts or network/account changes + React.useEffect(() => { + const fetchAddress = async () => { + if (isEVMNetwork && network) { + try { + const evmAddress = await BackgroundExecutor.getAddress(network, accountNumber); + setAddress(evmAddress); + } catch (error) { + console.error('Failed to fetch EVM address:', error); + } + } + }; + fetchAddress(); + }, [network, accountNumber, isEVMNetwork]); const handleSign = async () => { if (!message.trim()) { @@ -32,8 +48,8 @@ const SignMessage = () => { return; } - if (!isRootstock) { - Alert.alert('Error', 'Message signing is only available on Rootstock network'); + if (!isEVMNetwork) { + Alert.alert('Error', 'Message signing is only available on EVM-compatible networks'); return; } @@ -41,7 +57,7 @@ const SignMessage = () => { try { const password = await askPassword(); - // Use EVM signing for Rootstock (it's an EVM-compatible chain) + // Use EVM signing for all EVM-compatible chains const result = await BackgroundExecutor.signPersonalMessage( message, accountNumber, @@ -75,13 +91,31 @@ const SignMessage = () => { - {isRootstock ? ( + {isEVMNetwork ? ( <> - Sign a message with your Rootstock private key. This creates a cryptographic proof - that you control this wallet address on the Rootstock network. + Sign a message with your 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 { {signature ? ( - Rootstock Signature: + EVM Signature: {signature} @@ -124,7 +158,7 @@ const SignMessage = () => { - This signature proves you control the private key for account #{accountNumber} on the Rootstock network without revealing the key itself. + This signature proves you control the private key for account #{accountNumber} on the {network} network without revealing the key itself. @@ -132,10 +166,10 @@ const SignMessage = () => { - Message signing is only available when using the Rootstock network. + Message signing is only available on EVM-compatible networks. - Please switch to Rootstock from the home screen to use this feature. + Please switch to an EVM-compatible network (Rootstock, Botanix, etc.) from the home screen to use this feature. )} @@ -167,6 +201,26 @@ const styles = StyleSheet.create({ 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, From 3fe5934385d8d73a1662de778a2993b979a0d362 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:48:18 +0200 Subject: [PATCH 3/3] Add Spark network message signing support (#8) * Add Spark network message signing support - Implement signMessageWithIdentityKey in SparkWallet class - Add signSparkMessage to BackgroundExecutor - Update SignMessage UI to support both EVM and Spark networks - Skip password prompt for Spark (uses unencrypted submnemonics) - Add validation method for signature verification - Update Settings to show Sign Message button for Spark network * Revert .gitignore changes --- mobile/app/Settings.tsx | 3 +- mobile/app/SignMessage.tsx | 62 ++++++++++++++--------- mobile/src/modules/background-executor.ts | 38 ++++++++++++++ shared/class/wallets/spark-wallet.ts | 45 ++++++++++++++++ 4 files changed, 122 insertions(+), 26 deletions(-) diff --git a/mobile/app/Settings.tsx b/mobile/app/Settings.tsx index dbc6995b..88968bdf 100644 --- a/mobile/app/Settings.tsx +++ b/mobile/app/Settings.tsx @@ -21,6 +21,7 @@ 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'); @@ -233,7 +234,7 @@ export default function SettingsScreen() { ScanQr - {network && getIsEVM(network) && ( + {network && (getIsEVM(network) || network === NETWORK_SPARK) && ( router.push('/SignMessage')} diff --git a/mobile/app/SignMessage.tsx b/mobile/app/SignMessage.tsx index 3b8f4485..1870f56d 100644 --- a/mobile/app/SignMessage.tsx +++ b/mobile/app/SignMessage.tsx @@ -11,6 +11,7 @@ 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(); @@ -23,24 +24,26 @@ const SignMessage = () => { const [isLoading, setIsLoading] = useState(false); const [address, setAddress] = useState(''); - // This screen is available for all EVM-compatible networks - // But we'll handle it gracefully if accessed from non-EVM networks + // 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 EVM address when component mounts or network/account changes + // Fetch the address when component mounts or network/account changes React.useEffect(() => { const fetchAddress = async () => { - if (isEVMNetwork && network) { + if (isSupported && network) { try { - const evmAddress = await BackgroundExecutor.getAddress(network, accountNumber); - setAddress(evmAddress); + const walletAddress = await BackgroundExecutor.getAddress(network, accountNumber); + setAddress(walletAddress); } catch (error) { - console.error('Failed to fetch EVM address:', error); + console.error('Failed to fetch address:', error); } } }; fetchAddress(); - }, [network, accountNumber, isEVMNetwork]); + }, [network, accountNumber, isSupported]); const handleSign = async () => { if (!message.trim()) { @@ -48,24 +51,33 @@ const SignMessage = () => { return; } - if (!isEVMNetwork) { - Alert.alert('Error', 'Message signing is only available on EVM-compatible networks'); + if (!isSupported) { + Alert.alert('Error', 'Message signing is only available on EVM-compatible networks and Spark'); return; } setIsLoading(true); try { - const password = await askPassword(); - - // Use EVM signing for all EVM-compatible chains - const result = await BackgroundExecutor.signPersonalMessage( - message, - accountNumber, - password - ); + 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); + if (result?.success) { + setSignature(result.bytes || result.signature); Alert.alert('Success', 'Message signed successfully!'); } else { throw new Error(result.message || 'Failed to sign message'); @@ -91,10 +103,10 @@ const SignMessage = () => { - {isEVMNetwork ? ( + {isSupported ? ( <> - Sign a message with your EVM private key. This creates a cryptographic proof + 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. @@ -142,7 +154,7 @@ const SignMessage = () => { {signature ? ( - EVM Signature: + {isSparkNetwork ? 'Spark' : 'EVM'} Signature: {signature} @@ -166,10 +178,10 @@ const SignMessage = () => { - Message signing is only available on EVM-compatible networks. + Message signing is only available on EVM-compatible networks and Spark. - Please switch to an EVM-compatible network (Rootstock, Botanix, etc.) from the home screen to use this feature. + Please switch to a supported network (Rootstock, Botanix, Spark, etc.) from the home screen to use this feature. )} 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); + } }