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.NativeAsset
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.CardanoWalletManager
import io.element.android.libraries.architecture.Presenter
@ -32,6 +34,8 @@ class WalletPanelPresenter @Inject constructor(
private val walletManager: CardanoWalletManager,
private val cardanoClient: CardanoClient,
private val matrixClient: MatrixClient,
private val walletBackupService: WalletBackupService,
private val keyStorage: CardanoKeyStorage,
) : Presenter<WalletPanelState> {
@Composable
@ -50,6 +54,13 @@ class WalletPanelPresenter @Inject constructor(
var mnemonicWords by remember { mutableStateOf<List<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
LaunchedEffect(Unit) {
walletManager.initialize(matrixClient.sessionId)
@ -133,6 +144,95 @@ class WalletPanelPresenter @Inject constructor(
WalletPanelEvent.Close -> {
// 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 -> {
// Other events handled elsewhere
}
@ -153,6 +253,11 @@ class WalletPanelPresenter @Inject constructor(
showMnemonicDialog = showMnemonicDialog,
mnemonicWords = mnemonicWords,
mnemonicError = mnemonicError,
showBackupDialog = showBackupDialog,
backupMode = backupMode,
backupInProgress = backupInProgress,
backupError = backupError,
backupSuccess = backupSuccess,
eventSink = ::handleEvent,
)
}

View file

@ -28,6 +28,12 @@ data class WalletPanelState(
val showMnemonicDialog: Boolean,
val mnemonicWords: List<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,
) {
companion object {
@ -45,6 +51,11 @@ data class WalletPanelState(
showMnemonicDialog = false,
mnemonicWords = null,
mnemonicError = null,
showBackupDialog = false,
backupMode = BackupMode.BACKUP,
backupInProgress = false,
backupError = null,
backupSuccess = null,
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.
*/
@ -104,4 +123,23 @@ sealed interface WalletPanelEvent {
/** Close the panel. */
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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@ -33,7 +35,11 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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(
modifier = modifier,
topBar = {
@ -203,6 +225,7 @@ fun WalletPanelView(
isTestnet = state.isTestnet,
onCopyAddress = { state.eventSink(WalletPanelEvent.CopyAddress) },
onExportPhrase = { state.eventSink(WalletPanelEvent.ExportRecoveryPhrase) },
onBackupToMatrix = { state.eventSink(WalletPanelEvent.ShowBackupDialog) },
onDeleteWallet = { state.eventSink(WalletPanelEvent.DeleteWallet) },
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)
@Composable
private fun MnemonicDisplayDialog(
@ -366,6 +473,11 @@ internal fun WalletPanelViewPreview() = ElementPreview {
showMnemonicDialog = false,
mnemonicWords = null,
mnemonicError = null,
showBackupDialog = false,
backupMode = BackupMode.BACKUP,
backupInProgress = false,
backupError = null,
backupSuccess = null,
eventSink = {},
),
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.features.wallet.impl.R
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.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -230,6 +231,11 @@ internal fun OverviewTabViewPreview() = ElementPreview {
showMnemonicDialog = false,
mnemonicWords = null,
mnemonicError = null,
showBackupDialog = false,
backupMode = BackupMode.BACKUP,
backupInProgress = false,
backupError = null,
backupSuccess = null,
eventSink = {},
),
onSendClick = {},

View file

@ -38,6 +38,7 @@ fun SettingsTabView(
isTestnet: Boolean,
onCopyAddress: () -> Unit,
onExportPhrase: () -> Unit,
onBackupToMatrix: () -> Unit,
onDeleteWallet: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -176,6 +177,43 @@ fun SettingsTabView(
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(
modifier = Modifier
.fillMaxWidth()
@ -218,6 +256,7 @@ internal fun SettingsTabViewPreview() = ElementPreview {
isTestnet = true,
onCopyAddress = {},
onExportPhrase = {},
onBackupToMatrix = {},
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_button">Set Up Wallet</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>