diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt index b41f067a..db73038a 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt @@ -108,6 +108,11 @@ fun TssPartyApp( navController.navigate(BottomNavItem.Wallets.route) { popUpTo(BottomNavItem.Wallets.route) { inclusive = true } } + // Reset all session states so next time user enters a fresh state + viewModel.resetSessionState() + viewModel.resetJoinKeygenState() + viewModel.resetCoSignState() + viewModel.resetTransferState() viewModel.clearSuccess() viewModel.clearCreatedInviteCode() } @@ -201,8 +206,11 @@ fun TssPartyApp( onPrepareTransaction = { toAddress, amount -> viewModel.prepareTransfer(shareId, toAddress, amount) }, - onConfirmTransaction = { password -> - viewModel.initiateSignSession(shareId, password) + onConfirmTransaction = { + viewModel.initiateSignSession(shareId, "") + }, + onScanQrCode = { + // TODO: Launch QR scanner for recipient address }, onCopyInviteCode = { signInviteCode?.let { onCopyToClipboard(it) } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt index c1516ab7..a585a8da 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt @@ -589,6 +589,20 @@ class TssRepository @Inject constructor( val joinedCount = json.get("joined_count")?.asInt ?: 0 val joinToken = json.get("join_token")?.asString ?: "" + // Parse parties array (matching Electron's sessionInfo.parties) + val partiesArray = json.get("parties")?.asJsonArray + val parties = mutableListOf() + partiesArray?.forEach { elem -> + val p = elem.asJsonObject + parties.add(Participant( + partyId = p.get("party_id")?.asString ?: "", + partyIndex = p.get("party_index")?.asInt ?: 0, + name = "参与方 ${(p.get("party_index")?.asInt ?: 0) + 1}" + )) + } + + android.util.Log.d("TssRepository", "[CO-SIGN] validateSignInviteCode: found ${parties.size} parties") + val signSessionInfo = SignSessionInfoResponse( sessionId = sessionId, keygenSessionId = keygenSessionId, @@ -596,7 +610,8 @@ class TssRepository @Inject constructor( messageHash = messageHash, thresholdT = thresholdT, thresholdN = thresholdN, - currentParticipants = joinedCount + currentParticipants = joinedCount, + parties = parties ) Result.success(ValidateSignInviteCodeResult( @@ -851,9 +866,11 @@ class TssRepository @Inject constructor( } val sessionData = joinResult.getOrThrow() - val myPartyIndex = shareEntity.partyIndex // Use party index from our share + // Use party_index from gRPC response (matching Electron's result.party_index) + // This is more authoritative than local share's partyIndex + val myPartyIndex = sessionData.partyIndex - android.util.Log.d("TssRepository", "gRPC sign join successful: partyIndex=$myPartyIndex, sessionStatus=${sessionData.sessionStatus}") + android.util.Log.d("TssRepository", "gRPC sign join successful: partyIndex=$myPartyIndex (from gRPC), localPartyIndex=${shareEntity.partyIndex}, sessionStatus=${sessionData.sessionStatus}") // Build participants list (matching Electron's logic) // Prefer using parties from validateInviteCode (complete list) @@ -1530,6 +1547,14 @@ class TssRepository @Inject constructor( currentMessageRoutingPartyIndex = null } + /** + * Reset session status to WAITING + * Called when navigating away from session screens to ensure fresh state + */ + fun resetSessionStatus() { + _sessionStatus.value = SessionStatus.WAITING + } + /** * Delete a share record */ @@ -1605,7 +1630,13 @@ class TssRepository @Inject constructor( /** * Create a sign session for a transaction (as initiator) - * Calls account-service API: POST /api/v1/co-managed/sign + * Matches Electron's cosign:createSession flow exactly: + * 1. Get keygen session status to get all participants with party_index + * 2. Filter out co-managed-party-* (server backup parties) + * 3. Create sign session with filtered signing parties + * 4. Auto-join the session via gRPC + * 5. Subscribe to message stream (prepareForSign) + * 6. Check if session already in_progress and trigger sign immediately */ suspend fun createSignSession( shareId: Long, @@ -1619,20 +1650,54 @@ class TssRepository @Inject constructor( val shareEntity = shareRecordDao.getShareById(shareId) ?: return@withContext Result.failure(Exception("Share not found")) + android.util.Log.d("TssRepository", "[CO-SIGN] Creating sign session for share: ${shareEntity.sessionId}") + + // Step 1: Get keygen session status to get all participants with party_index + // This matches Electron's: accountClient.getSessionStatus(share.session_id) + val keygenStatusResult = getSessionStatus(shareEntity.sessionId) + if (keygenStatusResult.isFailure) { + return@withContext Result.failure(Exception("无法获取 keygen 会话的参与者信息: ${keygenStatusResult.exceptionOrNull()?.message}")) + } + val keygenStatus = keygenStatusResult.getOrThrow() + + if (keygenStatus.participants.isEmpty()) { + return@withContext Result.failure(Exception("无法获取 keygen 会话的参与者信息")) + } + + // Step 2: Filter out co-managed-party-* (server backup parties) + // Only user-held signing parties should participate in signing + // This matches Electron's: filter(p => !p.party_id.startsWith('co-managed-party-')) + val signingParties = keygenStatus.participants + .filter { !it.partyId.startsWith("co-managed-party-") } + .map { Pair(it.partyId, it.partyIndex) } + + android.util.Log.d("TssRepository", "[CO-SIGN] Signing parties (excluding co-managed-party): ${signingParties.size} of ${keygenStatus.participants.size}") + signingParties.forEach { (id, index) -> + android.util.Log.d("TssRepository", "[CO-SIGN] party_id=${id.take(16)}, party_index=$index") + } + + if (signingParties.size < shareEntity.thresholdT) { + return@withContext Result.failure(Exception( + "签名参与方不足: 需要 ${shareEntity.thresholdT} 个,但只有 ${signingParties.size} 个用户方" + )) + } + val jsonMediaType = "application/json; charset=utf-8".toMediaType() - // Build parties array - include initiator party info + // Build parties array with all signing parties (not just initiator) val partiesArray = com.google.gson.JsonArray().apply { - add(com.google.gson.JsonObject().apply { - addProperty("party_id", partyId) - addProperty("party_index", shareEntity.partyIndex) - }) + signingParties.forEach { (partyIdStr, partyIndex) -> + add(com.google.gson.JsonObject().apply { + addProperty("party_id", partyIdStr) + addProperty("party_index", partyIndex) + }) + } } // Build request body matching account-service API val requestBody = com.google.gson.JsonObject().apply { addProperty("keygen_session_id", shareEntity.sessionId) - addProperty("wallet_name", "Wallet") // Use a default name or could be passed as parameter + addProperty("wallet_name", shareEntity.walletName) addProperty("message_hash", messageHash) add("parties", partiesArray) addProperty("threshold_t", shareEntity.thresholdT) @@ -1645,13 +1710,13 @@ class TssRepository @Inject constructor( .header("Content-Type", "application/json") .build() - android.util.Log.d("TssRepository", "Creating sign session: $requestBody") + android.util.Log.d("TssRepository", "[CO-SIGN] Creating sign session: $requestBody") val response = httpClient.newCall(request).execute() val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("空响应")) - android.util.Log.d("TssRepository", "Create sign session response: $responseBody") + android.util.Log.d("TssRepository", "[CO-SIGN] Create sign session response: $responseBody") if (!response.isSuccessful) { val errorJson = try { @@ -1669,13 +1734,31 @@ class TssRepository @Inject constructor( val inviteCode = json.get("invite_code").asString val thresholdT = json.get("threshold_t")?.asInt ?: shareEntity.thresholdT - // Update session state + // Get join token - support both new format (join_tokens map) and old format (join_token string) + val joinToken = if (json.has("join_tokens") && json.get("join_tokens").isJsonObject) { + val joinTokens = json.getAsJsonObject("join_tokens") + joinTokens.get(partyId)?.asString ?: joinTokens.get("*")?.asString + } else { + json.get("join_token")?.asString + } + + // Build participants list from signingParties + val participants = signingParties.map { (pId, pIndex) -> + Participant( + partyId = pId, + partyIndex = pIndex, + name = if (pId == partyId) initiatorName else "参与方 ${pIndex + 1}" + ) + } + + // Update session state with complete participant list + // Use original keygen thresholdN (matching Electron's share.threshold_n) val session = TssSession( sessionId = sessionId, sessionType = SessionType.SIGN, thresholdT = thresholdT, - thresholdN = shareEntity.thresholdN, - participants = listOf(Participant(partyId, shareEntity.partyIndex, initiatorName)), + thresholdN = shareEntity.thresholdN, // Use original keygen N (matching Electron) + participants = participants, status = SessionStatus.WAITING, inviteCode = inviteCode, messageHash = messageHash @@ -1683,12 +1766,54 @@ class TssRepository @Inject constructor( _currentSession.value = session _sessionStatus.value = SessionStatus.WAITING + // Step 4: Initiator auto-join via gRPC (matching Electron behavior) + var sessionAlreadyInProgress = false + if (joinToken != null) { + android.util.Log.d("TssRepository", "[CO-SIGN] Initiator auto-joining session...") + val myPartyIndex = signingParties.find { it.first == partyId }?.second ?: shareEntity.partyIndex + + val joinResult = grpcClient.joinSession(sessionId, partyId, joinToken) + if (joinResult.isSuccess) { + val joinData = joinResult.getOrThrow() + android.util.Log.d("TssRepository", "[CO-SIGN] Initiator joined: partyIndex=${joinData.partyIndex}, status=${joinData.sessionStatus}") + + // Step 5: Start message routing (prepareForSign) BEFORE sign starts + startMessageRouting(sessionId, myPartyIndex) + + // Step 6: Check if session already in_progress + if (joinData.sessionStatus == "in_progress") { + android.util.Log.d("TssRepository", "[CO-SIGN] Session already in_progress, will trigger sign immediately") + sessionAlreadyInProgress = true + } + } else { + android.util.Log.w("TssRepository", "[CO-SIGN] Initiator failed to join: ${joinResult.exceptionOrNull()?.message}") + } + } else { + android.util.Log.w("TssRepository", "[CO-SIGN] No join token found for initiator") + } + + // If session is already in_progress, notify callback immediately + if (sessionAlreadyInProgress) { + val selectedParties = signingParties.map { it.first } + val eventData = SessionEventData( + eventId = "immediate_sign_start", + eventType = "session_started", + sessionId = sessionId, + thresholdN = shareEntity.thresholdN, // Use original keygen N (matching Electron) + thresholdT = thresholdT, + selectedParties = selectedParties, + joinTokens = emptyMap(), + messageHash = messageHash + ) + sessionEventCallback?.invoke(eventData) + } + Result.success(SignSessionResult( sessionId = sessionId, inviteCode = inviteCode )) } catch (e: Exception) { - android.util.Log.e("TssRepository", "Create sign session failed", e) + android.util.Log.e("TssRepository", "[CO-SIGN] Create sign session failed", e) Result.failure(e) } } @@ -1696,6 +1821,7 @@ class TssRepository @Inject constructor( /** * Start the signing process after all parties have joined + * Note: Message routing should already be started in createSignSession (after auto-join) */ suspend fun startSigning( sessionId: String, @@ -1710,6 +1836,8 @@ class TssRepository @Inject constructor( val shareEntity = shareRecordDao.getShareById(shareId) ?: return@withContext Result.failure(Exception("Share not found")) + android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: participants=${session.participants.size}") + // Start TSS sign val startResult = tssNativeBridge.startSign( sessionId = sessionId, @@ -1729,8 +1857,11 @@ class TssRepository @Inject constructor( _sessionStatus.value = SessionStatus.IN_PROGRESS - // Start message routing - startMessageRouting(sessionId, shareEntity.partyIndex) + // Note: Message routing is already started in createSignSession after auto-join + // Only start if not already running (for backward compatibility with old flow) + if (messageCollectionJob == null || messageCollectionJob?.isActive != true) { + startMessageRouting(sessionId, shareEntity.partyIndex) + } // Mark ready grpcClient.markPartyReady(sessionId, partyId) @@ -2027,7 +2158,8 @@ data class SignSessionInfoResponse( val messageHash: String, val thresholdT: Int, val thresholdN: Int, - val currentParticipants: Int + val currentParticipants: Int, + val parties: List = emptyList() // Complete parties list from API ) /** diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt index 88ec22d9..00b02496 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt @@ -1,7 +1,10 @@ package com.durian.tssparty.presentation.screens +import android.app.Activity import android.content.Intent import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -17,8 +20,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -60,16 +61,15 @@ fun TransferScreen( networkType: NetworkType = NetworkType.MAINNET, rpcUrl: String = "https://evm.kava.io", onPrepareTransaction: (toAddress: String, amount: String) -> Unit, - onConfirmTransaction: (password: String) -> Unit, + onConfirmTransaction: () -> Unit, onCopyInviteCode: () -> Unit, onBroadcastTransaction: () -> Unit, onCancel: () -> Unit, - onBackToWallets: () -> Unit + onBackToWallets: () -> Unit, + onScanQrCode: () -> Unit = {} ) { var toAddress by remember { mutableStateOf("") } var amount by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var showPassword by remember { mutableStateOf(false) } var validationError by remember { mutableStateOf(null) } // Determine current step @@ -131,7 +131,8 @@ fun TransferScreen( } } }, - onCancel = onCancel + onCancel = onCancel, + onScanQrCode = onScanQrCode ) "preparing" -> PreparingScreen() @@ -141,18 +142,10 @@ fun TransferScreen( preparedTx = preparedTx!!, toAddress = toAddress, amount = amount, - password = password, - onPasswordChange = { password = it }, - showPassword = showPassword, - onTogglePassword = { showPassword = !showPassword }, error = error, onConfirm = { - if (password.isBlank()) { - validationError = "请输入密码" - } else { - validationError = null - onConfirmTransaction(password) - } + validationError = null + onConfirmTransaction() }, onBack = onCancel ) @@ -201,7 +194,8 @@ private fun TransferInputScreen( error: String?, rpcUrl: String, onSubmit: () -> Unit, - onCancel: () -> Unit + onCancel: () -> Unit, + onScanQrCode: () -> Unit ) { val scope = rememberCoroutineScope() var isCalculatingMax by remember { mutableStateOf(false) } @@ -251,16 +245,25 @@ private fun TransferInputScreen( Spacer(modifier = Modifier.height(24.dp)) - // Recipient address + // Recipient address with QR scan button OutlinedTextField( value = toAddress, onValueChange = onToAddressChange, label = { Text("收款地址") }, - placeholder = { Text("0x...") }, + placeholder = { Text("0x... 或点击扫码") }, modifier = Modifier.fillMaxWidth(), singleLine = true, leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) + }, + trailingIcon = { + IconButton(onClick = onScanQrCode) { + Icon( + Icons.Default.QrCodeScanner, + contentDescription = "扫描二维码", + tint = MaterialTheme.colorScheme.primary + ) + } } ) @@ -414,10 +417,6 @@ private fun TransferConfirmScreen( preparedTx: TransactionUtils.PreparedTransaction, toAddress: String, amount: String, - password: String, - onPasswordChange: (String) -> Unit, - showPassword: Boolean, - onTogglePassword: () -> Unit, error: String?, onConfirm: () -> Unit, onBack: () -> Unit @@ -501,30 +500,6 @@ private fun TransferConfirmScreen( } } - Spacer(modifier = Modifier.height(24.dp)) - - // Password input - OutlinedTextField( - value = password, - onValueChange = onPasswordChange, - label = { Text("钱包密码") }, - placeholder = { Text("输入密码解锁密钥份额") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - leadingIcon = { - Icon(Icons.Default.Lock, contentDescription = null) - }, - trailingIcon = { - IconButton(onClick = onTogglePassword) { - Icon( - if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = null - ) - } - } - ) - // Error display error?.let { Spacer(modifier = Modifier.height(16.dp)) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt index 189cce95..16c28f1c 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt @@ -426,6 +426,8 @@ class MainViewModel @Inject constructor( _publicKey.value = null _createdInviteCode.value = null _hasEnteredSession.value = false + // Reset session status to WAITING for fresh start + repository.resetSessionStatus() } // ========== Join Keygen State ========== @@ -612,6 +614,8 @@ class MainViewModel @Inject constructor( pendingJoinToken = "" pendingPassword = "" pendingJoinKeygenInfo = null + // Reset session status to WAITING for fresh start + repository.resetSessionStatus() } // ========== CoSign (Join Sign) State ========== @@ -635,7 +639,7 @@ class MainViewModel @Inject constructor( /** * Validate sign session invite code - * Matches Electron's cosign:validateInviteCode - returns sessionInfo + joinToken + * Matches Electron's cosign:validateInviteCode - returns sessionInfo + joinToken + parties */ fun validateSignInviteCode(inviteCode: String) { viewModelScope.launch { @@ -657,11 +661,12 @@ class MainViewModel @Inject constructor( messageHash = info.messageHash, thresholdT = info.thresholdT, thresholdN = info.thresholdN, - currentParticipants = info.currentParticipants + currentParticipants = info.currentParticipants, + parties = info.parties // Include complete parties list ) _uiState.update { it.copy(isLoading = false) } - android.util.Log.d("MainViewModel", "Validate sign success: sessionId=${info.sessionId}, joinToken length=${pendingCoSignJoinToken.length}") + android.util.Log.d("MainViewModel", "Validate sign success: sessionId=${info.sessionId}, parties=${info.parties.size}, joinToken length=${pendingCoSignJoinToken.length}") }, onFailure = { e -> _uiState.update { it.copy(isLoading = false, error = e.message) } @@ -693,9 +698,10 @@ class MainViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true, error = null) } - android.util.Log.d("MainViewModel", "Joining sign session: sessionId=${sessionInfo.sessionId}, joinToken length=${pendingCoSignJoinToken.length}") + android.util.Log.d("MainViewModel", "Joining sign session: sessionId=${sessionInfo.sessionId}, parties=${sessionInfo.parties.size}, joinToken length=${pendingCoSignJoinToken.length}") // Join session via gRPC (matching Electron's cosign:joinSession) + // Use complete parties list from validateInviteCode (matching Electron's behavior) val result = repository.joinSignSessionViaGrpc( sessionId = sessionInfo.sessionId, joinToken = pendingCoSignJoinToken, @@ -705,7 +711,7 @@ class MainViewModel @Inject constructor( messageHash = sessionInfo.messageHash, thresholdT = sessionInfo.thresholdT, thresholdN = sessionInfo.thresholdN, - parties = emptyList() // Will use other_parties from gRPC response + parties = sessionInfo.parties // Use complete parties list from validateInviteCode ) result.fold( @@ -793,6 +799,8 @@ class MainViewModel @Inject constructor( pendingCoSignInviteCode = "" pendingCoSignJoinToken = "" pendingJoinSignInfo = null + // Reset session status to WAITING for fresh start + repository.resetSessionStatus() } /** @@ -1212,7 +1220,8 @@ data class CoSignSessionInfo( val messageHash: String, val thresholdT: Int, val thresholdN: Int, - val currentParticipants: Int + val currentParticipants: Int, + val parties: List = emptyList() // Complete parties list from API ) /**