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);
+ }
}