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:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user