Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ internal fun WordInputGrid(
tabIndex: Int,
onWordsChanged: (List<List<String>>) -> Unit,
onFocusChanged: (Int) -> Unit,
onPasteMnemonic: ((String) -> Unit)? = null,
) {
val wordCount =
when (numberOfWords) {
Expand Down Expand Up @@ -116,6 +117,7 @@ internal fun WordInputGrid(
onFocusChanged(index + 1)
}
},
onPasteMnemonic = onPasteMnemonic,
)
}
}
Expand Down Expand Up @@ -149,6 +151,7 @@ internal fun WordInputGrid(
onFocusChanged(index + 1)
}
},
onPasteMnemonic = onPasteMnemonic,
)
}
}
Expand All @@ -167,6 +170,7 @@ private fun WordInputField(
onWordChanged: (String) -> Unit,
onFocusChanged: (Boolean) -> Unit,
onNext: () -> Unit,
onPasteMnemonic: ((String) -> Unit)? = null,
) {
val focusManager = LocalFocusManager.current
val autocomplete =
Expand Down Expand Up @@ -255,6 +259,13 @@ private fun WordInputField(
BasicTextField(
value = word,
onValueChange = { newValue ->
// detect paste of full mnemonic (12+ words)
val pastedWords = newValue.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (pastedWords.size >= 12 && onPasteMnemonic != null) {
onPasteMnemonic(newValue)
return@BasicTextField
}

val trimmed = newValue.trim().lowercase()
val oldWord = previousWord
previousWord = trimmed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import org.bitcoinppl.cove_core.ImportType
import org.bitcoinppl.cove_core.ImportWalletException
import org.bitcoinppl.cove_core.NumberOfBip39Words
import org.bitcoinppl.cove_core.Route
import org.bitcoinppl.cove_core.groupedPlainWordsOf
import org.bitcoinppl.cove_core.types.WalletId

private enum class AlertState {
Expand Down Expand Up @@ -100,27 +101,30 @@ fun HotWalletImportScreen(
importType: ImportType,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
// use local state so we can update when paste changes word count
var currentNumberOfWords by remember { mutableStateOf(numberOfWords) }

val wordCount =
when (numberOfWords) {
when (currentNumberOfWords) {
NumberOfBip39Words.TWELVE -> 12
NumberOfBip39Words.TWENTY_FOUR -> 24
}

val numberOfGroups = wordCount / GROUPS_OF
var enteredWords by remember(numberOfWords) {
var enteredWords by remember(currentNumberOfWords) {
mutableStateOf(List(numberOfGroups) { List(GROUPS_OF) { "" } })
}

var alertState by remember { mutableStateOf(AlertState.None) }
var duplicateWalletId by remember { mutableStateOf<WalletId?>(null) }
var genericErrorMessage by remember { mutableStateOf("") }
var focusedField by remember(numberOfWords) { mutableIntStateOf(0) }
var tabIndex by remember(numberOfWords) { mutableIntStateOf(0) }
var focusedField by remember(currentNumberOfWords) { mutableIntStateOf(0) }
var tabIndex by remember(currentNumberOfWords) { mutableIntStateOf(0) }
Comment on lines +104 to +122
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential bug: keyed remember may discard pasted words when word count changes.

When currentNumberOfWords changes (e.g., pasting 24 words while in 12-word mode), the remember(currentNumberOfWords) recreates enteredWords with empty lists on recomposition. The update in setWords happens on the old state instance before recomposition, so the pasted words may be lost.

Consider removing the key from remember and handling initialization explicitly, or ensuring state updates occur after recomposition settles:

Suggested approach
-    var enteredWords by remember(currentNumberOfWords) {
+    var enteredWords by remember {
         mutableStateOf(List(numberOfGroups) { List(GROUPS_OF) { "" } })
     }
+
+    // Reset enteredWords when word count changes (except during paste)
+    var isHandlingPaste by remember { mutableStateOf(false) }
+    LaunchedEffect(currentNumberOfWords) {
+        if (!isHandlingPaste) {
+            val groups = if (currentNumberOfWords == NumberOfBip39Words.TWELVE) 1 else 2
+            enteredWords = List(groups) { List(GROUPS_OF) { "" } }
+        }
+        isHandlingPaste = false
+    }

Then set isHandlingPaste = true at the start of handlePasteMnemonic.

🤖 Prompt for AI Agents
In
`@android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt`
around lines 104 - 122, The keyed remember for enteredWords using
remember(currentNumberOfWords) causes the state to be recreated (losing pasted
words) when currentNumberOfWords changes; remove the key so enteredWords is
remembered across the transient mode change and explicitly reinitialize its
contents when you truly want to switch sizes (e.g., provide an
initEnteredWords(numberOfGroups) helper and call it only when confirming a size
change), and modify handlePasteMnemonic to set and clear an isHandlingPaste flag
(or otherwise defer size-based reinitialization until after paste handling
finishes) so pasted words are applied to the stable enteredWords state; update
references to enteredWords, currentNumberOfWords, handlePasteMnemonic and
isHandlingPaste accordingly.


// auto-switch page when focus changes to a word on a different page
LaunchedEffect(focusedField) {
LaunchedEffect(focusedField, enteredWords) {
val newTab = focusedField / GROUPS_OF
if (newTab != tabIndex && newTab < numberOfGroups) {
if (newTab != tabIndex && newTab < enteredWords.size) {
tabIndex = newTab
}
}
Expand Down Expand Up @@ -151,13 +155,18 @@ fun HotWalletImportScreen(
val flatWords = words.flatten()
val totalWords = flatWords.size

// validate word count matches expected from route
if (totalWords != wordCount) {
Log.w("HotWalletImport", "Word count mismatch: got $totalWords, expected $wordCount")
genericErrorMessage = "Invalid number of words. Expected $wordCount words, got $totalWords"
alertState = AlertState.GenericError
return
}
// update word count based on actual pasted words (matching iOS behavior)
currentNumberOfWords =
when (totalWords) {
12 -> NumberOfBip39Words.TWELVE
24 -> NumberOfBip39Words.TWENTY_FOUR
else -> {
Log.w("HotWalletImport", "Invalid word count: $totalWords")
genericErrorMessage = "Invalid number of words: $totalWords. We only support 12 or 24 words."
alertState = AlertState.GenericError
return
}
}

// reset scanners
showQrScanner = false
Expand All @@ -166,7 +175,8 @@ fun HotWalletImportScreen(
// update words
enteredWords = words

// move to last field
// move to last page and last field
tabIndex = words.size - 1
focusedField = totalWords - 1
}

Expand All @@ -178,10 +188,37 @@ fun HotWalletImportScreen(
word.isNotEmpty() &&
Bip39WordSpecificAutocomplete(
wordNumber = (idx + 1).toUShort(),
numberOfWords = numberOfWords,
numberOfWords = currentNumberOfWords,
).use { it.isValidWord(word, enteredWords) }
}

fun handlePasteMnemonic(mnemonicString: String) {
// extract word-like tokens, stripping numbers and punctuation
val words =
mnemonicString
.split(Regex("\\s+"))
.map { it.lowercase() }
.filter { word -> word.all { it.isLetter() } }

// need 12 or 24 words
if (words.size != 12 && words.size != 24) {
alertState = AlertState.InvalidWords
return
}

// group words into chunks of GROUPS_OF (12)
val grouped = words.chunked(GROUPS_OF)
setWords(grouped)

// validate - show alert if invalid
try {
groupedPlainWordsOf(words.joinToString(" "), GROUPS_OF.toUByte())
} catch (e: Exception) {
Log.d("HotWalletImport", "Invalid pasted mnemonic: ${e.message}")
alertState = AlertState.InvalidWords
}
}

val focusManager = LocalFocusManager.current

// dismiss keyboard when all words become valid
Expand Down Expand Up @@ -299,21 +336,22 @@ fun HotWalletImportScreen(

WordInputGrid(
enteredWords = enteredWords,
numberOfWords = numberOfWords,
numberOfWords = currentNumberOfWords,
focusedField = focusedField,
tabIndex = tabIndex,
onWordsChanged = { newWords -> enteredWords = newWords },
onFocusChanged = { field -> focusedField = field },
onPasteMnemonic = ::handlePasteMnemonic,
)

// page indicator dots for 24-word import
if (numberOfWords == NumberOfBip39Words.TWENTY_FOUR) {
// page indicator dots for multi-page import
if (enteredWords.size > 1) {
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
repeat(numberOfGroups) { i ->
repeat(enteredWords.size) { i ->
val isSelected = i == tabIndex
Box(
modifier =
Expand Down Expand Up @@ -462,7 +500,7 @@ fun HotWalletImportScreen(
// NFC Scanner Bottom Sheet
if (showNfcScanner) {
NfcScannerSheet(
numberOfWords = numberOfWords,
numberOfWords = currentNumberOfWords,
onDismiss = {
showNfcScanner = false
},
Expand Down
21 changes: 18 additions & 3 deletions ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ private let rowHeight = 30.0
/// Handles return key press without dismissing keyboard, forwarding other calls to original delegate
private final class TextFieldReturnHandler: NSObject, UITextFieldDelegate {
var onReturn: () -> Void
var onPasteMnemonic: ((String) -> Void)?
weak var originalDelegate: UITextFieldDelegate?

init(onReturn: @escaping () -> Void, originalDelegate: UITextFieldDelegate?) {
init(onReturn: @escaping () -> Void, onPasteMnemonic: ((String) -> Void)?, originalDelegate: UITextFieldDelegate?) {
self.onReturn = onReturn
self.onPasteMnemonic = onPasteMnemonic
self.originalDelegate = originalDelegate
}

Expand All @@ -39,7 +41,14 @@ private final class TextFieldReturnHandler: NSObject, UITextFieldDelegate {
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
originalDelegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) ?? true
// detect paste of full mnemonic (12+ words)
let words = string.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
if words.count >= 12, let onPasteMnemonic {
onPasteMnemonic(string)
return false
}

return originalDelegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) ?? true
}

func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
Expand All @@ -62,6 +71,7 @@ private let groupsOf = HotWalletImportScreen.GROUPS_OF

struct HotWalletImportCard: View {
var numberOfWords: NumberOfBip39Words
var onPasteMnemonic: ((String) -> Void)?

@Binding var tabIndex: Int
@Binding var enteredWords: [[String]]
Expand All @@ -81,7 +91,8 @@ struct HotWalletImportCard: View {
filteredSuggestions: $filteredSuggestions,
focusField: $focusField,
allEnteredWords: enteredWords,
numberOfWords: numberOfWords
numberOfWords: numberOfWords,
onPasteMnemonic: onPasteMnemonic
)
.tag(index)
}
Expand Down Expand Up @@ -111,6 +122,7 @@ private struct CardTab: View {

let allEnteredWords: [[String]]
let numberOfWords: NumberOfBip39Words
var onPasteMnemonic: ((String) -> Void)?

let cardSpacing: CGFloat = 20

Expand All @@ -131,6 +143,7 @@ private struct CardTab: View {
),
allEnteredWords: allEnteredWords,
numberOfWords: numberOfWords,
onPasteMnemonic: onPasteMnemonic,
tabIndex: $tabIndex,
text: $fields[index],
filteredSuggestions: $filteredSuggestions,
Expand All @@ -151,6 +164,7 @@ private struct AutocompleteField: View {
let autocomplete: Bip39WordSpecificAutocomplete
let allEnteredWords: [[String]]
let numberOfWords: NumberOfBip39Words
var onPasteMnemonic: ((String) -> Void)?

@Binding var tabIndex: Int
@Binding var text: String
Expand Down Expand Up @@ -321,6 +335,7 @@ private struct AutocompleteField: View {
if returnHandler == nil {
let handler = TextFieldReturnHandler(
onReturn: { [self] in submitFocusField() },
onPasteMnemonic: onPasteMnemonic,
originalDelegate: textField.delegate
)
returnHandler = handler
Expand Down
47 changes: 40 additions & 7 deletions ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@ struct HotWalletImportScreen: View {
var lastIndex: Int {
switch numberOfWords {
case .twelve:
1
0
case .twentyFour:
3
1
}
}

Expand Down Expand Up @@ -369,6 +369,7 @@ struct HotWalletImportScreen: View {
var Card: some View {
HotWalletImportCard(
numberOfWords: numberOfWords,
onPasteMnemonic: handlePasteMnemonic,
tabIndex: $tabIndex,
enteredWords: $enteredWords,
filteredSuggestions: $filteredSuggestions,
Expand Down Expand Up @@ -527,12 +528,13 @@ struct HotWalletImportScreen: View {

// MARK: OnChange Functions

func onChangeFocusField(_ old: ImportFieldNumber?, _ new: ImportFieldNumber?) {
// clear suggestions when focus changes
func onChangeFocusField(_: ImportFieldNumber?, _ new: ImportFieldNumber?) {
filteredSuggestions = []

// check if we should move to next page
let focusFieldNumber = new?.fieldNumber ?? old?.fieldNumber ?? 1
// don't change tab when focus is cleared (e.g., after paste)
guard let new else { return }

let focusFieldNumber = new.fieldNumber
if (focusFieldNumber % groupsOf) == 1 {
withAnimation {
tabIndex = Int(focusFieldNumber / groupsOf)
Expand Down Expand Up @@ -592,7 +594,38 @@ struct HotWalletImportScreen: View {

enteredWords = words
sheetState = .none
tabIndex = lastIndex
tabIndex = words.count - 1
}

func handlePasteMnemonic(_ mnemonicString: String) {
// extract word-like tokens, stripping numbers and punctuation
let words = mnemonicString
.split(whereSeparator: { $0.isWhitespace })
.map { String($0).lowercased() }
.filter { word in
// keep only alphabetic strings (filters out "1.", "2)", etc.)
word.allSatisfy(\.isLetter)
}

// need 12 or 24 words
guard words.count == 12 || words.count == 24 else {
alertState = .init(.invalidWords)
return
}

// group words into chunks of groupsOf (12)
let grouped = stride(from: 0, to: words.count, by: groupsOf).map {
Array(words[$0 ..< min($0 + groupsOf, words.count)])
}
setWords(grouped)

// validate - show alert if invalid
do {
_ = try groupedPlainWordsOf(mnemonic: words.joined(separator: " "), groups: UInt8(groupsOf))
} catch {
Log.debug("Invalid pasted mnemonic: \(error)")
alertState = .init(.invalidWords)
}
}
Comment on lines +600 to 629
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validation should occur before populating the UI.

The current flow calls setWords(grouped) before validating with groupedPlainWordsOf. If the pasted words aren't valid BIP39 words, they'll be populated into the input fields before the error alert appears, leaving the UI in an invalid state.

Consider validating first and only setting words on success:

Proposed fix
 func handlePasteMnemonic(_ mnemonicString: String) {
     // extract word-like tokens, stripping numbers and punctuation
     let words = mnemonicString
         .split(whereSeparator: { $0.isWhitespace })
         .map { String($0).lowercased() }
         .filter { word in
             // keep only alphabetic strings (filters out "1.", "2)", etc.)
             word.allSatisfy(\.isLetter)
         }

     // need 12 or 24 words
     guard words.count == 12 || words.count == 24 else {
         alertState = .init(.invalidWords)
         return
     }

+    // validate words are valid BIP39 before populating UI
+    do {
+        _ = try groupedPlainWordsOf(mnemonic: words.joined(separator: " "), groups: UInt8(groupsOf))
+    } catch {
+        Log.debug("Invalid pasted mnemonic: \(error)")
+        alertState = .init(.invalidWords)
+        return
+    }
+
     // group words into chunks of groupsOf (12)
     let grouped = stride(from: 0, to: words.count, by: groupsOf).map {
         Array(words[$0 ..< min($0 + groupsOf, words.count)])
     }
     setWords(grouped)
-
-    // validate - show alert if invalid
-    do {
-        _ = try groupedPlainWordsOf(mnemonic: words.joined(separator: " "), groups: UInt8(groupsOf))
-    } catch {
-        Log.debug("Invalid pasted mnemonic: \(error)")
-        alertState = .init(.invalidWords)
-    }
 }
🤖 Prompt for AI Agents
In `@ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift` around
lines 600 - 629, The handler handlePasteMnemonic currently calls
setWords(grouped) before validating the pasted mnemonic; validate first using
groupedPlainWordsOf(mnemonic:groups:) (with the same groupsOf value) and only
call setWords(grouped) after the validation succeeds, otherwise set alertState =
.init(.invalidWords) on error and do not update the UI; keep the existing
try/catch around groupedPlainWordsOf and move it above the setWords call so the
UI is populated only on successful validation.

}

Expand Down