feat(wallet): Add SSSS backup functionality

- Add "Backup to Matrix" button to wallet Settings tab
- Implement BackupRecoveryKeyDialog for entering recovery key
- Wire up WalletBackupService for SSSS encryption
- Add backup state to WalletPanelState and WalletPanelEvent
- Add localized strings for backup UI

Backup flow:
1. User taps "Backup to Matrix" in wallet settings
2. Dialog prompts for Matrix recovery key
3. Wallet mnemonic is encrypted with SSSS
4. Stored in Matrix account data as com.sulkta.cardano.wallet_seed

Tested: Successfully backed up wallet to SSSS on testnet.
This commit is contained in:
Kayos 2026-03-29 05:02:25 -07:00
parent 1308a8299a
commit 75edbd5499
6 changed files with 316 additions and 0 deletions

View file

@ -18,6 +18,8 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.CardanoClient import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.NativeAsset import io.element.android.features.wallet.api.NativeAsset
import io.element.android.features.wallet.api.TxSummary import io.element.android.features.wallet.api.TxSummary
import io.element.android.features.wallet.api.backup.WalletBackupService
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -32,6 +34,8 @@ class WalletPanelPresenter @Inject constructor(
private val walletManager: CardanoWalletManager, private val walletManager: CardanoWalletManager,
private val cardanoClient: CardanoClient, private val cardanoClient: CardanoClient,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val walletBackupService: WalletBackupService,
private val keyStorage: CardanoKeyStorage,
) : Presenter<WalletPanelState> { ) : Presenter<WalletPanelState> {
@Composable @Composable
@ -50,6 +54,13 @@ class WalletPanelPresenter @Inject constructor(
var mnemonicWords by remember { mutableStateOf<List<String>?>(null) } var mnemonicWords by remember { mutableStateOf<List<String>?>(null) }
var mnemonicError by remember { mutableStateOf<String?>(null) } var mnemonicError by remember { mutableStateOf<String?>(null) }
// SSSS Backup state
var showBackupDialog by remember { mutableStateOf(false) }
var backupMode by remember { mutableStateOf(BackupMode.BACKUP) }
var backupInProgress by remember { mutableStateOf(false) }
var backupError by remember { mutableStateOf<String?>(null) }
var backupSuccess by remember { mutableStateOf<String?>(null) }
// Initialize wallet on first composition // Initialize wallet on first composition
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
walletManager.initialize(matrixClient.sessionId) walletManager.initialize(matrixClient.sessionId)
@ -133,6 +144,95 @@ class WalletPanelPresenter @Inject constructor(
WalletPanelEvent.Close -> { WalletPanelEvent.Close -> {
// Navigation handled by node callback // Navigation handled by node callback
} }
// SSSS Backup events
WalletPanelEvent.ShowBackupDialog -> {
backupMode = BackupMode.BACKUP
backupError = null
backupSuccess = null
showBackupDialog = true
}
WalletPanelEvent.ShowRestoreDialog -> {
backupMode = BackupMode.RESTORE
backupError = null
backupSuccess = null
showBackupDialog = true
}
WalletPanelEvent.DismissBackupDialog -> {
showBackupDialog = false
backupError = null
}
is WalletPanelEvent.ConfirmBackup -> {
scope.launch {
backupInProgress = true
backupError = null
// Normalize recovery key: remove spaces and convert to lowercase
val normalizedKey = event.recoveryKey.replace("\\s+".toRegex(), "").lowercase()
walletManager.getMnemonic(matrixClient.sessionId)
.onSuccess { mnemonic ->
walletBackupService.backupSeed(normalizedKey, mnemonic)
.onSuccess {
Timber.i("Wallet backed up to SSSS successfully")
backupSuccess = "Wallet backed up successfully"
showBackupDialog = false
}
.onFailure { e ->
Timber.e(e, "Failed to backup wallet to SSSS")
backupError = e.message ?: "Failed to backup wallet"
}
}
.onFailure { e ->
Timber.e(e, "Failed to get mnemonic for backup")
backupError = e.message ?: "Failed to retrieve wallet data"
}
backupInProgress = false
}
}
is WalletPanelEvent.ConfirmRestore -> {
scope.launch {
backupInProgress = true
backupError = null
// Normalize recovery key: remove spaces and convert to lowercase
val normalizedKey = event.recoveryKey.replace("\\s+".toRegex(), "").lowercase()
walletBackupService.restoreSeed(normalizedKey)
.onSuccess { mnemonic ->
if (mnemonic != null) {
// First delete existing wallet if any
keyStorage.deleteWallet(matrixClient.sessionId)
// Import the restored mnemonic
keyStorage.importWallet(matrixClient.sessionId, mnemonic)
.onSuccess {
Timber.i("Wallet restored from SSSS successfully")
backupSuccess = "Wallet restored successfully"
showBackupDialog = false
// Reinitialize wallet state
walletManager.initialize(matrixClient.sessionId)
}
.onFailure { e ->
Timber.e(e, "Failed to import restored wallet")
backupError = e.message ?: "Failed to import wallet"
}
} else {
backupError = "No wallet backup found in Matrix"
}
}
.onFailure { e ->
Timber.e(e, "Failed to restore wallet from SSSS")
backupError = e.message ?: "Failed to restore wallet"
}
backupInProgress = false
}
}
WalletPanelEvent.ClearBackupMessage -> {
backupError = null
backupSuccess = null
}
else -> { else -> {
// Other events handled elsewhere // Other events handled elsewhere
} }
@ -153,6 +253,11 @@ class WalletPanelPresenter @Inject constructor(
showMnemonicDialog = showMnemonicDialog, showMnemonicDialog = showMnemonicDialog,
mnemonicWords = mnemonicWords, mnemonicWords = mnemonicWords,
mnemonicError = mnemonicError, mnemonicError = mnemonicError,
showBackupDialog = showBackupDialog,
backupMode = backupMode,
backupInProgress = backupInProgress,
backupError = backupError,
backupSuccess = backupSuccess,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -28,6 +28,12 @@ data class WalletPanelState(
val showMnemonicDialog: Boolean, val showMnemonicDialog: Boolean,
val mnemonicWords: List<String>?, val mnemonicWords: List<String>?,
val mnemonicError: String?, val mnemonicError: String?,
// SSSS Backup state
val showBackupDialog: Boolean,
val backupMode: BackupMode,
val backupInProgress: Boolean,
val backupError: String?,
val backupSuccess: String?,
val eventSink: (WalletPanelEvent) -> Unit, val eventSink: (WalletPanelEvent) -> Unit,
) { ) {
companion object { companion object {
@ -45,6 +51,11 @@ data class WalletPanelState(
showMnemonicDialog = false, showMnemonicDialog = false,
mnemonicWords = null, mnemonicWords = null,
mnemonicError = null, mnemonicError = null,
showBackupDialog = false,
backupMode = BackupMode.BACKUP,
backupInProgress = false,
backupError = null,
backupSuccess = null,
eventSink = {}, eventSink = {},
) )
} }
@ -62,6 +73,14 @@ data class WalletPanelState(
} }
} }
/**
* Backup operation mode.
*/
enum class BackupMode {
BACKUP,
RESTORE
}
/** /**
* Events that can be triggered from the wallet panel UI. * Events that can be triggered from the wallet panel UI.
*/ */
@ -104,4 +123,23 @@ sealed interface WalletPanelEvent {
/** Close the panel. */ /** Close the panel. */
data object Close : WalletPanelEvent data object Close : WalletPanelEvent
// SSSS Backup events
/** Show backup dialog to enter recovery key. */
data object ShowBackupDialog : WalletPanelEvent
/** Show restore dialog to enter recovery key. */
data object ShowRestoreDialog : WalletPanelEvent
/** Dismiss the backup/restore dialog. */
data object DismissBackupDialog : WalletPanelEvent
/** Confirm backup with the provided recovery key. */
data class ConfirmBackup(val recoveryKey: String) : WalletPanelEvent
/** Confirm restore with the provided recovery key. */
data class ConfirmRestore(val recoveryKey: String) : WalletPanelEvent
/** Clear backup success/error message. */
data object ClearBackupMessage : WalletPanelEvent
} }

View file

@ -24,8 +24,10 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -33,7 +35,11 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -133,6 +139,22 @@ fun WalletPanelView(
) )
} }
// Show backup/restore dialog
if (state.showBackupDialog) {
BackupRecoveryKeyDialog(
mode = state.backupMode,
isLoading = state.backupInProgress,
error = state.backupError,
onConfirm = { recoveryKey ->
when (state.backupMode) {
BackupMode.BACKUP -> state.eventSink(WalletPanelEvent.ConfirmBackup(recoveryKey))
BackupMode.RESTORE -> state.eventSink(WalletPanelEvent.ConfirmRestore(recoveryKey))
}
},
onDismiss = { state.eventSink(WalletPanelEvent.DismissBackupDialog) }
)
}
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@ -203,6 +225,7 @@ fun WalletPanelView(
isTestnet = state.isTestnet, isTestnet = state.isTestnet,
onCopyAddress = { state.eventSink(WalletPanelEvent.CopyAddress) }, onCopyAddress = { state.eventSink(WalletPanelEvent.CopyAddress) },
onExportPhrase = { state.eventSink(WalletPanelEvent.ExportRecoveryPhrase) }, onExportPhrase = { state.eventSink(WalletPanelEvent.ExportRecoveryPhrase) },
onBackupToMatrix = { state.eventSink(WalletPanelEvent.ShowBackupDialog) },
onDeleteWallet = { state.eventSink(WalletPanelEvent.DeleteWallet) }, onDeleteWallet = { state.eventSink(WalletPanelEvent.DeleteWallet) },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@ -213,6 +236,90 @@ fun WalletPanelView(
} }
} }
@Composable
private fun BackupRecoveryKeyDialog(
mode: BackupMode,
isLoading: Boolean,
error: String?,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var recoveryKey by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { if (!isLoading) onDismiss() },
properties = DialogProperties(
dismissOnBackPress = !isLoading,
dismissOnClickOutside = !isLoading,
),
title = {
Text(
text = stringResource(R.string.wallet_backup_dialog_title),
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
Column {
Text(
text = stringResource(R.string.wallet_backup_dialog_message),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp),
)
OutlinedTextField(
value = recoveryKey,
onValueChange = { recoveryKey = it },
label = { Text(stringResource(R.string.wallet_backup_dialog_hint)) },
enabled = !isLoading,
singleLine = false,
minLines = 2,
maxLines = 4,
modifier = Modifier.fillMaxWidth(),
isError = error != null,
)
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 16.dp)
.align(Alignment.CenterHorizontally),
)
}
}
},
confirmButton = {
Button(
onClick = { onConfirm(recoveryKey) },
enabled = !isLoading && recoveryKey.isNotBlank(),
) {
Text(
text = when (mode) {
BackupMode.BACKUP -> stringResource(R.string.wallet_backup_dialog_backup)
BackupMode.RESTORE -> stringResource(R.string.wallet_backup_dialog_restore)
}
)
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isLoading,
) {
Text(stringResource(R.string.wallet_backup_dialog_cancel))
}
},
)
}
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun MnemonicDisplayDialog( private fun MnemonicDisplayDialog(
@ -366,6 +473,11 @@ internal fun WalletPanelViewPreview() = ElementPreview {
showMnemonicDialog = false, showMnemonicDialog = false,
mnemonicWords = null, mnemonicWords = null,
mnemonicError = null, mnemonicError = null,
showBackupDialog = false,
backupMode = BackupMode.BACKUP,
backupInProgress = false,
backupError = null,
backupSuccess = null,
eventSink = {}, eventSink = {},
), ),
onBackClick = {}, onBackClick = {},

View file

@ -47,6 +47,7 @@ import com.google.zxing.qrcode.QRCodeWriter
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.wallet.impl.R import io.element.android.features.wallet.impl.R
import io.element.android.features.wallet.impl.panel.WalletPanelEvent import io.element.android.features.wallet.impl.panel.WalletPanelEvent
import io.element.android.features.wallet.impl.panel.BackupMode
import io.element.android.features.wallet.impl.panel.WalletPanelState import io.element.android.features.wallet.impl.panel.WalletPanelState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -230,6 +231,11 @@ internal fun OverviewTabViewPreview() = ElementPreview {
showMnemonicDialog = false, showMnemonicDialog = false,
mnemonicWords = null, mnemonicWords = null,
mnemonicError = null, mnemonicError = null,
showBackupDialog = false,
backupMode = BackupMode.BACKUP,
backupInProgress = false,
backupError = null,
backupSuccess = null,
eventSink = {}, eventSink = {},
), ),
onSendClick = {}, onSendClick = {},

View file

@ -38,6 +38,7 @@ fun SettingsTabView(
isTestnet: Boolean, isTestnet: Boolean,
onCopyAddress: () -> Unit, onCopyAddress: () -> Unit,
onExportPhrase: () -> Unit, onExportPhrase: () -> Unit,
onBackupToMatrix: () -> Unit,
onDeleteWallet: () -> Unit, onDeleteWallet: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -176,6 +177,43 @@ fun SettingsTabView(
HorizontalDivider() HorizontalDivider()
// Backup to Matrix
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onBackupToMatrix)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.Cloud(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
) {
Text(
text = stringResource(R.string.wallet_settings_backup_matrix),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.wallet_settings_backup_matrix_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = CompoundIcons.ChevronRight(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -218,6 +256,7 @@ internal fun SettingsTabViewPreview() = ElementPreview {
isTestnet = true, isTestnet = true,
onCopyAddress = {}, onCopyAddress = {},
onExportPhrase = {}, onExportPhrase = {},
onBackupToMatrix = {},
onDeleteWallet = {}, onDeleteWallet = {},
) )
} }

View file

@ -47,4 +47,20 @@
<string name="wallet_payment_no_wallet_message">Set up your wallet to send ADA</string> <string name="wallet_payment_no_wallet_message">Set up your wallet to send ADA</string>
<string name="wallet_payment_no_wallet_button">Set Up Wallet</string> <string name="wallet_payment_no_wallet_button">Set Up Wallet</string>
<string name="wallet_payment_insufficient_balance">Insufficient balance (%s ADA available)</string> <string name="wallet_payment_insufficient_balance">Insufficient balance (%s ADA available)</string>
<!-- SSSS Backup -->
<string name="wallet_settings_backup_matrix">Backup to Matrix</string>
<string name="wallet_settings_backup_matrix_description">Encrypt and store your wallet in Matrix account data</string>
<string name="wallet_settings_restore_matrix">Restore from Matrix</string>
<string name="wallet_settings_restore_matrix_description">Restore wallet from Matrix backup</string>
<string name="wallet_backup_dialog_title">Enter Recovery Key</string>
<string name="wallet_backup_dialog_message">Enter your Matrix recovery key to encrypt your wallet backup. This is the same key used to unlock your encrypted messages.</string>
<string name="wallet_backup_dialog_hint">Recovery key</string>
<string name="wallet_backup_dialog_backup">Backup</string>
<string name="wallet_backup_dialog_restore">Restore</string>
<string name="wallet_backup_dialog_cancel">Cancel</string>
<string name="wallet_backup_success">Wallet backed up successfully</string>
<string name="wallet_restore_success">Wallet restored successfully</string>
<string name="wallet_backup_error">Backup failed: %s</string>
<string name="wallet_restore_error">Restore failed: %s</string>
</resources> </resources>