feat(wallet): derive default Spark wallet from user's nsec

New users get a Spark wallet auto-created on first wallet-tab entry, deterministically derived from their nsec via HKDF-SHA256. The nsec is the only backup needed — signing in on another device re-derives the same wallet. The seed-phrase backup gate and relay-backup warnings are skipped for default wallets; the recovery phrase remains viewable in settings for cross-app export. Disconnecting lands the user on a setup screen with three explicit options: use the default wallet, restore from recovery phrase, or restore from relays. Existing custom-wallet users are unaffected.
This commit is contained in:
Barry Deen
2026-05-15 11:22:15 -04:00
parent 296b5f9702
commit f2eda5f7aa
8 changed files with 329 additions and 96 deletions
@@ -0,0 +1,36 @@
package com.wisp.app.nostr
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* HKDF (RFC 5869) and HMAC-SHA256 primitives shared by NIP-44 encryption
* and deterministic key derivation (Spark wallet entropy from nsec).
*/
object Hkdf {
private val hmacLocal = ThreadLocal.withInitial { Mac.getInstance("HmacSHA256") }
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
val mac = hmacLocal.get()!!
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
fun extract(salt: ByteArray, ikm: ByteArray): ByteArray = hmacSha256(salt, ikm)
fun expand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
require(length <= 255 * 32)
val n = (length + 31) / 32
var t = ByteArray(0)
val okm = ByteArray(length)
var offset = 0
for (i in 1..n) {
val input = t + info + byteArrayOf(i.toByte())
t = hmacSha256(prk, input)
val copyLen = minOf(32, length - offset)
System.arraycopy(t, 0, okm, offset, copyLen)
offset += copyLen
}
return okm
}
}
@@ -68,6 +68,22 @@ object Keys {
val sharedPoint = secp256k1.pubKeyTweakMul(pubkeyCompressed, privkey)
return sharedPoint.copyOfRange(1, 33)
}
/**
* Derive 16 bytes of entropy from a Nostr private key, suitable for seeding
* a BIP39 12-word mnemonic. Used to produce a default Spark wallet that is
* deterministically tied to the user's nsec — the nsec is the only backup
* needed.
*
* HKDF-SHA256(salt="wisp-spark-wallet-v1", ikm=privkey, info="entropy", L=16).
* The versioned salt locks this derivation to v1; any future change must
* bump the salt so existing wallets remain reachable from the same nsec.
*/
fun deriveSparkEntropy(privkey: ByteArray): ByteArray {
require(privkey.size == 32) { "Private key must be 32 bytes" }
val prk = Hkdf.extract("wisp-spark-wallet-v1".toByteArray(Charsets.UTF_8), privkey)
return Hkdf.expand(prk, "entropy".toByteArray(Charsets.UTF_8), 16)
}
}
/** Zero out sensitive byte arrays to minimize key exposure in memory. */
@@ -5,13 +5,10 @@ import org.bouncycastle.crypto.engines.ChaCha7539Engine
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.params.ParametersWithIV
import java.security.SecureRandom
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object Nip44 {
private const val VERSION: Byte = 0x02
private val random = SecureRandom()
private val hmacLocal = ThreadLocal.withInitial { Mac.getInstance("HmacSHA256") }
private val chachaLocal = ThreadLocal.withInitial { ChaCha7539Engine() }
/**
@@ -22,7 +19,7 @@ object Nip44 {
val compressed = Keys.pubkeyToCompressed(pubkey)
val sharedSecret = Keys.ecdh(privkey, compressed)
// HKDF-Extract(salt="nip44-v2", ikm=sharedSecret) -> conversation key
return hkdfExtract("nip44-v2".toByteArray(Charsets.UTF_8), sharedSecret)
return Hkdf.extract("nip44-v2".toByteArray(Charsets.UTF_8), sharedSecret)
}
/**
@@ -51,7 +48,7 @@ object Nip44 {
// HMAC-SHA256 over nonce || ciphertext
val hmacInput = nonce + ciphertext
val mac = hmacSha256(hmacKey, hmacInput)
val mac = Hkdf.hmacSha256(hmacKey, hmacInput)
// Assemble payload: version || nonce || ciphertext || hmac
val payload = byteArrayOf(VERSION) + nonce + ciphertext + mac
@@ -80,7 +77,7 @@ object Nip44 {
val hmacKey = messageKeys.copyOfRange(44, 76)
// Verify HMAC before decryption (encrypt-then-MAC)
val expectedMac = hmacSha256(hmacKey, nonce + ciphertext)
val expectedMac = Hkdf.hmacSha256(hmacKey, nonce + ciphertext)
require(constantTimeEquals(mac, expectedMac)) { "HMAC verification failed" }
// Decrypt
@@ -122,41 +119,13 @@ object Nip44 {
return ((unpaddedLen + chunk - 1) / chunk) * chunk
}
// --- HKDF ---
private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray {
return hmacSha256(salt, ikm)
}
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
require(length <= 255 * 32)
val n = (length + 31) / 32
var t = ByteArray(0)
val okm = ByteArray(length)
var offset = 0
for (i in 1..n) {
val input = t + info + byteArrayOf(i.toByte())
t = hmacSha256(prk, input)
val copyLen = minOf(32, length - offset)
System.arraycopy(t, 0, okm, offset, copyLen)
offset += copyLen
}
return okm
}
private fun deriveMessageKeys(conversationKey: ByteArray, nonce: ByteArray): ByteArray {
// HKDF-Expand(prk=conversationKey, info=nonce, len=76)
return hkdfExpand(conversationKey, nonce, 76)
return Hkdf.expand(conversationKey, nonce, 76)
}
// --- Crypto Primitives ---
private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
val mac = hmacLocal.get()
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
private fun chacha20Encrypt(key: ByteArray, nonce: ByteArray, input: ByteArray): ByteArray {
val engine = chachaLocal.get()
engine.init(true, ParametersWithIV(KeyParameter(key), nonce))
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.wisp.app.BuildConfig
import com.wisp.app.nostr.Keys
import java.io.File
import java.security.SecureRandom
@@ -112,18 +113,40 @@ class SparkRepository(
override fun hasConnection(): Boolean = hasMnemonic()
fun newMnemonic(): String {
val wordlist = BIP39_WORDS
if (wordlist.size < 2048) {
// Fallback: generate 16 random bytes and hex-encode as a placeholder.
// The user should provide a proper BIP39 mnemonic via restore.
error("BIP39 wordlist not available. Bundle bip39-english.txt in resources.")
}
val wordlist = requireWordlist()
val random = SecureRandom()
val entropy = ByteArray(16) // 128 bits → 12 words
random.nextBytes(entropy)
return entropyToMnemonic(entropy, wordlist)
}
/**
* Generate a BIP39 mnemonic deterministically from a Nostr private key.
* The same privkey always produces the same mnemonic, so a user's default
* Spark wallet is recoverable on any device by signing in with their nsec.
*
* Saves the mnemonic and marks it as the default wallet (skips backup nags).
*/
fun generateDefaultFromPrivkey(privkey: ByteArray): String {
val wordlist = requireWordlist()
val entropy = Keys.deriveSparkEntropy(privkey)
val mnemonic = entropyToMnemonic(entropy, wordlist)
encPrefs.edit()
.putString("spark_mnemonic", mnemonic)
.putBoolean("spark_is_default", true)
.putBoolean("seed_backup_acked", true)
.apply()
return mnemonic
}
private fun requireWordlist(): List<String> {
val wordlist = BIP39_WORDS
if (wordlist.size < 2048) {
error("BIP39 wordlist not available. Bundle bip39-english.txt in resources.")
}
return wordlist
}
private fun entropyToMnemonic(entropy: ByteArray, wordlist: List<String>): String {
// SHA-256 hash of entropy for checksum
val hash = java.security.MessageDigest.getInstance("SHA-256").digest(entropy)
@@ -191,11 +214,16 @@ class SparkRepository(
encPrefs.edit()
.remove("spark_mnemonic")
.remove("seed_backup_acked")
.remove("spark_is_default")
.apply()
_balance.value = null
_isConnected.value = false
}
/** True when the current wallet was derived from the user's nsec. */
fun isDefaultWallet(): Boolean =
encPrefs.getBoolean("spark_is_default", false)
fun isSeedBackupAcknowledged(): Boolean =
encPrefs.getBoolean("seed_backup_acked", false)
@@ -35,6 +35,17 @@ class WalletModeRepository(private val context: Context, pubkeyHex: String? = nu
prefs.edit().putString("balance_unit", unit.name).apply()
}
/**
* True once the user has explicitly disconnected a wallet on this account.
* Disables the first-entry auto-creation of the default Spark wallet so the
* user isn't surprised by it reappearing after they deliberately removed it.
*/
fun isAutoCreateSkipped(): Boolean = prefs.getBoolean("auto_create_skipped", false)
fun setAutoCreateSkipped(skipped: Boolean) {
prefs.edit().putBoolean("auto_create_skipped", skipped).apply()
}
companion object {
private fun prefsName(pubkeyHex: String?) =
if (pubkeyHex != null) "wisp_wallet_mode_$pubkeyHex" else "wisp_wallet_mode"
@@ -72,6 +72,7 @@ import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.Receipt
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
@@ -221,6 +222,8 @@ fun WalletScreen(
error = viewModel.sendError.collectAsState().value,
autoCheckState = viewModel.autoCheckState.collectAsState().value,
onCreateWallet = { viewModel.generateSparkWallet() },
onUseDefaultWallet = { viewModel.useDefaultWallet() },
canUseDefaultWallet = viewModel.keyRepo.hasKeypair(),
onRestoreMnemonicChange = { viewModel.updateRestoreMnemonic(it) },
onRestoreWallet = { viewModel.restoreSparkWallet() },
onRestoreFromRelay = {
@@ -237,7 +240,8 @@ fun WalletScreen(
val page = currentPage as WalletPage.SparkBackup
SparkBackupContent(
mnemonic = page.mnemonic,
onConfirm = { viewModel.confirmSparkBackup() }
onConfirm = { viewModel.confirmSparkBackup() },
isDefaultWallet = viewModel.isDefaultWallet.collectAsState().value
)
}
else -> WalletModeSelectionContent(
@@ -263,7 +267,8 @@ fun WalletScreen(
) {
SparkBackupContent(
mnemonic = page.mnemonic,
onConfirm = { viewModel.acknowledgeSeedBackup() }
onConfirm = { viewModel.acknowledgeSeedBackup() },
isDefaultWallet = viewModel.isDefaultWallet.collectAsState().value
)
}
}
@@ -272,9 +277,11 @@ fun WalletScreen(
walletMode = viewModel.walletMode.collectAsState().value,
balanceUnit = viewModel.balanceUnit.collectAsState().value,
showSettingsAlert = viewModel.walletMode.collectAsState().value == WalletMode.SPARK
&& !viewModel.seedBackupAcked.collectAsState().value,
&& !viewModel.seedBackupAcked.collectAsState().value
&& !viewModel.isDefaultWallet.collectAsState().value,
seedBackupAcked = viewModel.seedBackupAcked.collectAsState().value,
backupMissing = viewModel.backupMissing.collectAsState().value,
isDefaultWallet = viewModel.isDefaultWallet.collectAsState().value,
onSend = { viewModel.navigateTo(WalletPage.SendInput) },
onReceive = {
viewModel.navigateTo(WalletPage.ReceiveAmount)
@@ -424,6 +431,7 @@ fun WalletScreen(
relayBackupCheckLoading = viewModel.relayBackupCheckLoading.collectAsState().value,
deleteBackupStatus = viewModel.deleteBackupStatus.collectAsState().value,
isLoggedIn = viewModel.keyRepo.isLoggedIn(),
isDefaultWallet = viewModel.isDefaultWallet.collectAsState().value,
onCheckRelayBackups = { viewModel.checkRelayBackupStatuses() },
onDeleteRelayBackup = { viewModel.deleteRelayBackup() },
modifier = Modifier.padding(padding)
@@ -450,6 +458,7 @@ fun WalletScreen(
onDelete = { viewModel.deleteWallet() },
onCancel = { viewModel.navigateBack() },
walletMode = viewModel.walletMode.collectAsState().value,
isDefaultWallet = viewModel.isDefaultWallet.collectAsState().value,
modifier = Modifier.padding(padding)
)
is WalletPage.BackupToRelay -> BackupToRelayContent(
@@ -479,9 +488,11 @@ fun WalletScreen(
walletMode = viewModel.walletMode.collectAsState().value,
balanceUnit = viewModel.balanceUnit.collectAsState().value,
showSettingsAlert = viewModel.walletMode.collectAsState().value == WalletMode.SPARK
&& !viewModel.seedBackupAcked.collectAsState().value,
&& !viewModel.seedBackupAcked.collectAsState().value
&& !viewModel.isDefaultWallet.collectAsState().value,
seedBackupAcked = viewModel.seedBackupAcked.collectAsState().value,
backupMissing = viewModel.backupMissing.collectAsState().value,
isDefaultWallet = viewModel.isDefaultWallet.collectAsState().value,
onSend = { viewModel.navigateTo(WalletPage.SendInput) },
onReceive = { viewModel.navigateTo(WalletPage.ReceiveAmount) },
onTransactions = {
@@ -666,6 +677,7 @@ private fun WalletHomeContent(
showSettingsAlert: Boolean = false,
seedBackupAcked: Boolean = true,
backupMissing: Boolean = false,
isDefaultWallet: Boolean = false,
onSend: () -> Unit,
onReceive: () -> Unit,
onTransactions: () -> Unit,
@@ -722,8 +734,9 @@ private fun WalletHomeContent(
}
}
// Backup missing warning (big nasty warning)
if (walletMode == WalletMode.SPARK && backupMissing) {
// Backup missing warning (big nasty warning).
// Skipped for the nsec-derived default wallet — its backup is the user's nsec.
if (walletMode == WalletMode.SPARK && backupMissing && !isDefaultWallet) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@@ -758,8 +771,8 @@ private fun WalletHomeContent(
Spacer(Modifier.height(8.dp))
}
// Seed not yet viewed nudge
if (walletMode == WalletMode.SPARK && !seedBackupAcked && !backupMissing) {
// Seed not yet viewed nudge — also skipped for default wallets.
if (walletMode == WalletMode.SPARK && !seedBackupAcked && !backupMissing && !isDefaultWallet) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@@ -2228,6 +2241,8 @@ private fun SparkSetupContent(
error: String?,
autoCheckState: AutoCheckState = AutoCheckState.Idle,
onCreateWallet: () -> Unit,
onUseDefaultWallet: () -> Unit = {},
canUseDefaultWallet: Boolean = false,
onRestoreMnemonicChange: (String) -> Unit,
onRestoreWallet: () -> Unit,
onRestoreFromRelay: () -> Unit = {},
@@ -2392,11 +2407,26 @@ private fun SparkSetupContent(
}
if (!isConnecting && autoCheckState !is AutoCheckState.Found && autoCheckState !is AutoCheckState.MultipleFound) {
Button(
onClick = onCreateWallet,
modifier = Modifier.fillMaxWidth()
) {
Text("Create New Wallet")
if (canUseDefaultWallet) {
Button(
onClick = onUseDefaultWallet,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.wallet_use_default))
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.wallet_default_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Button(
onClick = onCreateWallet,
modifier = Modifier.fillMaxWidth()
) {
Text("Create New Wallet")
}
}
Spacer(Modifier.height(24.dp))
@@ -2501,7 +2531,8 @@ private fun SparkSetupContent(
@Composable
private fun SparkBackupContent(
mnemonic: String,
onConfirm: () -> Unit
onConfirm: () -> Unit,
isDefaultWallet: Boolean = false
) {
val words = mnemonic.split(" ")
val clipboardManager = LocalClipboardManager.current
@@ -2516,11 +2547,19 @@ private fun SparkBackupContent(
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(8.dp))
Text(
"Write down these words in order and store them safely. This is the only way to recover your wallet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
if (isDefaultWallet) {
Text(
stringResource(R.string.wallet_default_seed_info),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
"Write down these words in order and store them safely. This is the only way to recover your wallet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(24.dp))
@@ -2622,7 +2661,7 @@ private fun SparkBackupContent(
onClick = onConfirm,
modifier = Modifier.fillMaxWidth()
) {
Text("I've Backed This Up")
Text(if (isDefaultWallet) "Done" else "I've Backed This Up")
}
Spacer(Modifier.height(32.dp))
@@ -2647,6 +2686,7 @@ private fun WalletSettingsContent(
relayBackupCheckLoading: Boolean = false,
deleteBackupStatus: DeleteBackupStatus = DeleteBackupStatus.Idle,
isLoggedIn: Boolean = false,
isDefaultWallet: Boolean = false,
onCheckRelayBackups: () -> Unit = {},
onDeleteRelayBackup: () -> Unit = {},
modifier: Modifier = Modifier
@@ -2850,22 +2890,25 @@ private fun WalletSettingsContent(
onClick = onBackupMnemonic,
modifier = Modifier.fillMaxWidth()
) {
Text("Backup Recovery Phrase")
Text(if (isDefaultWallet) "View Recovery Phrase" else "Backup Recovery Phrase")
}
Spacer(Modifier.height(8.dp))
if (!isDefaultWallet) {
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onBackupToRelay,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Backup to Nostr Relays")
OutlinedButton(
onClick = onBackupToRelay,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Backup to Nostr Relays")
}
}
// Relay backup status section (when logged in)
if (isLoggedIn) {
// Relay backup status section (when logged in). Skipped for default
// wallets — the nsec already serves as their backup.
if (isLoggedIn && !isDefaultWallet) {
Spacer(Modifier.height(16.dp))
Row(
@@ -3018,12 +3061,19 @@ private fun WalletSettingsContent(
Button(
onClick = onDeleteWallet,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFD32F2F),
contentColor = Color.White
)
colors = if (isDefaultWallet) ButtonDefaults.buttonColors()
else ButtonDefaults.buttonColors(
containerColor = Color(0xFFD32F2F),
contentColor = Color.White
)
) {
Text(if (walletMode == WalletMode.NWC) "Disconnect" else "Delete Wallet")
Text(
when {
walletMode == WalletMode.NWC -> "Disconnect"
isDefaultWallet -> stringResource(R.string.wallet_switch_wallet)
else -> "Delete Wallet"
}
)
}
// Footer
@@ -3355,9 +3405,11 @@ private fun DeleteWalletConfirmContent(
onDelete: () -> Unit,
onCancel: () -> Unit,
walletMode: WalletMode = WalletMode.SPARK,
isDefaultWallet: Boolean = false,
modifier: Modifier = Modifier
) {
val isNwc = walletMode == WalletMode.NWC
val isDefault = !isNwc && isDefaultWallet
Column(
modifier = modifier
@@ -3369,34 +3421,45 @@ private fun DeleteWalletConfirmContent(
Spacer(Modifier.height(32.dp))
Icon(
Icons.Default.Close,
if (isDefault) Icons.Default.SwapHoriz else Icons.Default.Close,
contentDescription = null,
modifier = Modifier
.size(64.dp)
.background(Color(0xFFD32F2F).copy(alpha = 0.1f), CircleShape)
.background(
(if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F))
.copy(alpha = 0.1f),
CircleShape
)
.padding(16.dp),
tint = Color(0xFFD32F2F)
tint = if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F)
)
Spacer(Modifier.height(24.dp))
Text(
if (isNwc) "Disconnect NWC" else "Delete Wallet",
when {
isDefault -> stringResource(R.string.wallet_switch_wallet)
isNwc -> "Disconnect NWC"
else -> "Delete Wallet"
},
style = MaterialTheme.typography.headlineMedium,
color = Color(0xFFD32F2F)
color = if (isDefault) MaterialTheme.colorScheme.onSurface else Color(0xFFD32F2F)
)
Spacer(Modifier.height(16.dp))
Text(
if (isNwc) "This will remove the NWC connection string from this device. You can reconnect anytime with a new connection string."
else "This will permanently delete your wallet from this device. Your funds cannot be recovered without your recovery phrase.",
when {
isDefault -> stringResource(R.string.wallet_switch_wallet_body)
isNwc -> "This will remove the NWC connection string from this device. You can reconnect anytime with a new connection string."
else -> "This will permanently delete your wallet from this device. Your funds cannot be recovered without your recovery phrase."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (!isNwc) {
if (!isNwc && !isDefault) {
Spacer(Modifier.height(8.dp))
Text(
@@ -3422,13 +3485,20 @@ private fun DeleteWalletConfirmContent(
Button(
onClick = onDelete,
modifier = Modifier.fillMaxWidth(),
enabled = isNwc || confirmText == "DELETE",
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFD32F2F),
contentColor = Color.White
)
enabled = isNwc || isDefault || confirmText == "DELETE",
colors = if (isDefault) ButtonDefaults.buttonColors()
else ButtonDefaults.buttonColors(
containerColor = Color(0xFFD32F2F),
contentColor = Color.White
)
) {
Text(if (isNwc) "Disconnect" else "Delete Wallet")
Text(
when {
isDefault -> stringResource(R.string.wallet_switch_wallet)
isNwc -> "Disconnect"
else -> "Delete Wallet"
}
)
}
Spacer(Modifier.height(12.dp))
@@ -258,6 +258,18 @@ class WalletViewModel(
private val _backupMissing = MutableStateFlow(false)
val backupMissing: StateFlow<Boolean> = _backupMissing
/** True for the nsec-derived default wallet — drives copy/visibility tweaks. */
private val _isDefaultWallet = MutableStateFlow(sparkRepo.isDefaultWallet())
val isDefaultWallet: StateFlow<Boolean> = _isDefaultWallet
/**
* Set after [deleteWallet] so a subsequent [navigateHome] doesn't
* immediately re-derive the default wallet — the user explicitly
* disconnected to pick a different one. Persisted in
* [WalletModeRepository] so the choice survives app restarts.
*/
private var skipAutoCreate: Boolean = walletModeRepo.isAutoCreateSkipped()
private val _registeredAddress = MutableStateFlow<String?>(null)
private var connectJob: Job? = null
@@ -332,8 +344,13 @@ class WalletViewModel(
fun navigateTo(page: WalletPage) {
pageStack.add(page)
_currentPage.value = page
// Auto-check relay backup statuses when entering Settings
if (page is WalletPage.Settings && _walletMode.value == WalletMode.SPARK && keyRepo.isLoggedIn()) {
// Auto-check relay backup statuses when entering Settings.
// Default wallets re-derive from the nsec so they don't use relay backup.
if (page is WalletPage.Settings
&& _walletMode.value == WalletMode.SPARK
&& keyRepo.isLoggedIn()
&& !sparkRepo.isDefaultWallet()
) {
checkRelayBackupStatuses()
}
}
@@ -359,6 +376,52 @@ class WalletViewModel(
_deleteConfirmText.value = ""
_lightningAddressError.value = null
_addressAvailable.value = null
maybeAutoCreateDefaultWallet()
}
/**
* Auto-create the nsec-derived default wallet on first wallet-tab entry.
* No-ops unless the user is logged in with a local privkey and has no
* wallet configured. The user lands directly on the wallet Home screen
* once connection completes — they never see ModeSelection or the
* seed-phrase backup gate.
*/
private fun maybeAutoCreateDefaultWallet() {
if (skipAutoCreate) return
if (_walletMode.value != WalletMode.NONE) return
if (sparkRepo.hasMnemonic()) return
if (!keyRepo.hasKeypair()) return
startDefaultWallet()
}
/**
* Public action for the SparkSetup screen's "Use my default wallet" CTA.
* Re-derives the nsec-tied wallet after the user previously disconnected.
*/
fun useDefaultWallet() {
if (!keyRepo.hasKeypair()) return
// Replace any existing wallet (the user explicitly chose to switch)
if (sparkRepo.hasMnemonic()) {
sparkRepo.disconnect()
sparkRepo.clearMnemonic()
}
startDefaultWallet()
}
private fun startDefaultWallet() {
val keypair = keyRepo.getKeypair() ?: return
sparkRepo.generateDefaultFromPrivkey(keypair.privkey)
_isDefaultWallet.value = true
_seedBackupAcked.value = true
skipAutoCreate = false
walletModeRepo.setAutoCreateSkipped(false)
// Show SparkSetup's Connecting state instead of a brief ModeSelection flicker.
if (_currentPage.value !is WalletPage.SparkSetup) {
pageStack.add(WalletPage.SparkSetup)
_currentPage.value = WalletPage.SparkSetup
}
connectSparkWallet()
}
val isOnHome: Boolean get() = pageStack.size <= 1
@@ -532,6 +595,8 @@ class WalletViewModel(
walletModeRepo.setMode(WalletMode.NWC)
_walletMode.value = WalletMode.NWC
skipAutoCreate = false
walletModeRepo.setAutoCreateSkipped(false)
_statusLines.value = emptyList()
if (!silent) _walletState.value = WalletState.Connecting
@@ -553,6 +618,9 @@ class WalletViewModel(
fun generateSparkWallet() {
val mnemonic = sparkRepo.newMnemonic()
sparkRepo.saveMnemonic(mnemonic)
_isDefaultWallet.value = false
skipAutoCreate = false
walletModeRepo.setAutoCreateSkipped(false)
connectSparkWallet()
}
@@ -571,6 +639,9 @@ class WalletViewModel(
}
Log.d("WalletBackup", "restoreSparkWallet: saving and connecting")
sparkRepo.saveMnemonic(trimmed)
_isDefaultWallet.value = false
skipAutoCreate = false
walletModeRepo.setAutoCreateSkipped(false)
connectSparkWallet()
}
@@ -593,7 +664,7 @@ class WalletViewModel(
viewModelScope.launch {
sparkRepo.isConnected.first { it }
fetchLightningAddress()
if (keyRepo.isLoggedIn()) {
if (keyRepo.isLoggedIn() && !sparkRepo.isDefaultWallet()) {
checkRelayBackupStatuses()
}
}
@@ -667,12 +738,20 @@ class WalletViewModel(
}
fun deleteWallet() {
if (_walletMode.value == WalletMode.SPARK && _deleteConfirmText.value != "DELETE") return
// Custom (non-default) Spark wallets are irreversible without their
// seed phrase, so we require the typed DELETE confirmation. Default
// wallets re-derive from the nsec on demand, so a tap is enough.
if (_walletMode.value == WalletMode.SPARK
&& !sparkRepo.isDefaultWallet()
&& _deleteConfirmText.value != "DELETE"
) return
connectJob?.cancel()
statusCollectJob?.cancel()
connectionMonitorJob?.cancel()
val wasSpark = _walletMode.value == WalletMode.SPARK
when (_walletMode.value) {
WalletMode.NWC -> {
nwcRepo.disconnect()
@@ -692,7 +771,23 @@ class WalletViewModel(
_statusLines.value = emptyList()
_lightningAddress.value = null
_deleteConfirmText.value = ""
navigateHome()
_isDefaultWallet.value = false
// Suppress the auto-create on the next navigateHome — the user
// explicitly disconnected to choose a different wallet. They'll land
// on the SparkSetup screen with the three options. Persist so the
// choice survives app restarts.
skipAutoCreate = true
walletModeRepo.setAutoCreateSkipped(true)
pageStack.clear()
pageStack.add(WalletPage.Home)
if (wasSpark && keyRepo.hasKeypair()) {
pageStack.add(WalletPage.SparkSetup)
_currentPage.value = WalletPage.SparkSetup
} else {
_currentPage.value = WalletPage.Home
}
}
fun disconnectWallet() {
@@ -750,6 +845,9 @@ class WalletViewModel(
fun refreshState() {
_walletMode.value = walletModeRepo.getMode()
_isDefaultWallet.value = sparkRepo.isDefaultWallet()
_seedBackupAcked.value = sparkRepo.isSeedBackupAcknowledged()
skipAutoCreate = walletModeRepo.isAutoCreateSkipped()
val mode = _walletMode.value
val provider = activeProvider
+5
View File
@@ -862,4 +862,9 @@
<string name="wallet_fee_money">fee: %1$s</string>
<string name="wallet_seed_spark_only">This recovery phrase is specific to Spark wallets. It will not work with other wallet apps.</string>
<string name="wallet_generate_invoice">Generate invoice</string>
<string name="wallet_use_default">Use my default wallet</string>
<string name="wallet_default_subtitle">Derived from your Nostr key — no extra backup needed.</string>
<string name="wallet_switch_wallet">Switch Wallet</string>
<string name="wallet_switch_wallet_body">Disconnect this wallet so you can use your default wallet or restore a different one. Your funds stay safe — you can reconnect this wallet anytime by entering its recovery phrase.</string>
<string name="wallet_default_seed_info">This wallet is derived from your Nostr key, so the phrase below is just for export to other wallet apps. You don\'t need to back it up — your nsec is the backup.</string>
</resources>