From 2799eb5a3ad94c1ed81474ba267c0a41d9d2cf0e Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 26 Jan 2026 04:15:52 -0800 Subject: [PATCH] =?UTF-8?q?fix(tss-android):=20=E4=BF=AE=E5=A4=8D=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E6=81=A2=E5=A4=8D=E5=90=8E=E9=92=B1=E5=8C=85=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E7=AD=BE=E5=90=8D=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题根因 备份恢复后的钱包在签名时失败,根本原因是 gRPC 通信使用了**设备的 partyId**, 而不是 **share 的原始 partyId**(keygen 时生成的 partyId)。 这导致: 1. 消息订阅使用错误的 partyId,无法接收其他参与方发送的消息 2. 消息发送使用错误的 fromParty,其他参与方无法正确路由消息 3. Session 事件订阅使用错误的 partyId,无法接收 session_started 等事件 4. API 调用使用错误的 partyId,服务端无法正确识别参与方 ## 修改内容 ### 1. 添加新的成员变量用于跟踪正确的 partyId - `currentMessageRoutingPartyId`: 消息路由使用的 partyId - `currentSessionEventPartyId`: Session 事件订阅使用的 partyId ### 2. 修改 startMessageRouting 方法 - 添加 `routingPartyId` 可选参数 - 签名流程中使用 signingPartyId(share 原始 partyId) - 消息发送 (routeMessage fromParty) 使用正确的 partyId - 消息订阅 (subscribeMessages) 使用正确的 partyId ### 3. 修改 startSessionEventSubscription 方法 - 添加 `subscriptionPartyId` 可选参数 - 签名流程中使用 signingPartyId ### 4. 修改 ensureSessionEventSubscriptionActive 方法 - 添加 `signingPartyId` 可选参数 - 支持动态切换订阅的 partyId ### 5. 修复所有签名流程中的调用 #### joinSignSessionViaGrpc 流程: - grpcClient.joinSession 使用 signingPartyId - startMessageRouting 使用 signingPartyId - ensureSessionEventSubscriptionActive 使用 signingPartyId #### joinSignSessionViaApiAndExecute 流程: - joinSignSessionViaApi HTTP 请求使用 signingPartyId - grpcClient.joinSession 使用 signingPartyId - startMessageRouting 使用 signingPartyId #### createSignSession 流程: - ensureSessionEventSubscriptionActive 使用 signingPartyId - join_tokens 查找使用 originalPartyId - grpcClient.joinSession 使用 signingPartyId - startMessageRouting 使用 signingPartyId #### startSigning 流程: - startMessageRouting 使用 signingPartyId ### 6. 修复 joinSignSessionViaApi 函数 - 添加 signingPartyId 参数 - HTTP 请求体中的 party_id 和 device_id 使用 signingPartyId ### 7. 修复重连恢复逻辑 (restoreStreamsAfterReconnect) - startMessageRouting 使用保存的 currentMessageRoutingPartyId - startSessionEventSubscription 使用保存的 currentSessionEventPartyId ## 测试场景 修复后应支持以下场景: 1. 原设备 keygen → 原设备签名 ✓ 2. 原设备 keygen → 备份 → 新设备恢复 → 新设备发起签名 ✓ 3. 原设备 keygen → 备份 → 新设备恢复 → 新设备参与签名 ✓ Co-Authored-By: Claude Opus 4.5 --- .../tssparty/data/repository/TssRepository.kt | 158 +++++++++++++----- 1 file changed, 116 insertions(+), 42 deletions(-) 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 4ac5e70b..18bc9b2e 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 @@ -108,6 +108,11 @@ class TssRepository @Inject constructor( // Track current message routing params for reconnection recovery private var currentMessageRoutingSessionId: String? = null private var currentMessageRoutingPartyIndex: Int? = null + private var currentMessageRoutingPartyId: String? = null // The partyId used for message routing (may differ from device partyId for restored wallets) + + // Track current session event subscription partyId for reconnection recovery + // This may differ from device partyId when signing with restored wallets + private var currentSessionEventPartyId: String? = null // Account service URL (configurable via settings) private var accountServiceUrl: String = "https://rwaapi.szaiai.com" @@ -126,17 +131,19 @@ class TssRepository @Inject constructor( private fun restoreStreamsAfterReconnect() { val sessionId = currentMessageRoutingSessionId val partyIndex = currentMessageRoutingPartyIndex + val routingPartyId = currentMessageRoutingPartyId // Restore message routing if we had an active session if (sessionId != null && partyIndex != null) { - android.util.Log.d("TssRepository", "Restoring message routing for session: $sessionId") - startMessageRouting(sessionId, partyIndex) + android.util.Log.d("TssRepository", "Restoring message routing for session: $sessionId, routingPartyId: $routingPartyId") + startMessageRouting(sessionId, partyIndex, routingPartyId) } - // Restore session event subscription + // Restore session event subscription with the correct partyId if (grpcClient.wasEventStreamSubscribed()) { - android.util.Log.d("TssRepository", "Restoring session event subscription") - startSessionEventSubscription() + val eventPartyId = currentSessionEventPartyId + android.util.Log.d("TssRepository", "Restoring session event subscription with partyId: $eventPartyId") + startSessionEventSubscription(eventPartyId) } } @@ -232,12 +239,19 @@ class TssRepository @Inject constructor( /** * Start session event subscription (called after registration) + * + * @param subscriptionPartyId Optional partyId for subscription. If null, uses device partyId. + * CRITICAL: For signing with restored wallets, this should be the original partyId + * from keygen (shareEntity.partyId) so that session events are received correctly. */ - private fun startSessionEventSubscription() { + private fun startSessionEventSubscription(subscriptionPartyId: String? = null) { sessionEventJob?.cancel() - android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $partyId") + val effectivePartyId = subscriptionPartyId ?: partyId + // Save for reconnection recovery + currentSessionEventPartyId = effectivePartyId + android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $effectivePartyId (device partyId: $partyId)") sessionEventJob = repositoryScope.launch { - grpcClient.subscribeSessionEvents(partyId).collect { event -> + grpcClient.subscribeSessionEvents(effectivePartyId).collect { event -> android.util.Log.d("TssRepository", "=== Session event received ===") android.util.Log.d("TssRepository", " eventType: ${event.eventType}") android.util.Log.d("TssRepository", " sessionId: ${event.sessionId}") @@ -289,20 +303,32 @@ class TssRepository @Inject constructor( * Ensure session event subscription is active * Called before critical operations (like joining sign session) to ensure * the event stream hasn't silently disconnected + * + * @param signingPartyId Optional partyId for signing operations. If provided, + * the subscription will use this partyId instead of the device partyId. + * CRITICAL: For signing with restored wallets, this should be the original + * partyId from keygen (shareEntity.partyId). */ - private fun ensureSessionEventSubscriptionActive() { + private fun ensureSessionEventSubscriptionActive(signingPartyId: String? = null) { // Check if the session event job is still active val isActive = sessionEventJob?.isActive == true - android.util.Log.d("TssRepository", "Checking session event subscription: isActive=$isActive") + val effectivePartyId = signingPartyId ?: currentSessionEventPartyId ?: partyId + android.util.Log.d("TssRepository", "Checking session event subscription: isActive=$isActive, effectivePartyId=$effectivePartyId") if (!isActive) { android.util.Log.w("TssRepository", "Session event subscription is not active, restarting...") - startSessionEventSubscription() + startSessionEventSubscription(signingPartyId) } else { // Even if the job is "active", the gRPC stream may have silently disconnected // Force a restart to ensure we have a fresh connection - android.util.Log.d("TssRepository", "Refreshing session event subscription to ensure fresh connection") - startSessionEventSubscription() + // Also restart if we need to switch to a different partyId for signing + val needsRestart = signingPartyId != null && signingPartyId != currentSessionEventPartyId + if (needsRestart) { + android.util.Log.d("TssRepository", "Switching session event subscription to signingPartyId: $signingPartyId") + } else { + android.util.Log.d("TssRepository", "Refreshing session event subscription to ensure fresh connection") + } + startSessionEventSubscription(signingPartyId) } } @@ -1186,11 +1212,13 @@ class TssRepository @Inject constructor( _sessionStatus.value = SessionStatus.WAITING // Start message subscription (matching Electron's prepareForSign) - startMessageRouting(sessionId, myPartyIndex) + // CRITICAL: Use signingPartyId (original partyId from keygen) for message routing + startMessageRouting(sessionId, myPartyIndex, signingPartyId) - // CRITICAL: Ensure session event subscription is active + // CRITICAL: Ensure session event subscription is active with signingPartyId // The event stream may have silently disconnected, so refresh it - ensureSessionEventSubscriptionActive() + // Use signingPartyId so that session events for restored wallets are received + ensureSessionEventSubscriptionActive(signingPartyId) android.util.Log.d("TssRepository", "Sign session state set, waiting for session_started event or in_progress status") @@ -1512,8 +1540,15 @@ class TssRepository @Inject constructor( val shareEntity = shareRecordDao.getShareById(shareId) ?: return@coroutineScope Result.failure(Exception("Share not found")) + // CRITICAL: Define signingPartyId BEFORE calling API + // Use shareEntity.partyId (original partyId from keygen) for signing + val signingPartyId = shareEntity.partyId + currentSigningPartyId = signingPartyId // Save for later use in this flow + android.util.Log.d("TssRepository", "Using signingPartyId=$signingPartyId for API sign join (device partyId=$partyId)") + // Step 1: Call account-service API to join sign session and get party info - val joinApiResult = joinSignSessionViaApi(inviteCode, shareEntity.partyIndex) + // CRITICAL: Pass signingPartyId (original partyId from keygen) to the API + val joinApiResult = joinSignSessionViaApi(inviteCode, shareEntity.partyIndex, signingPartyId) if (joinApiResult.isFailure) { return@coroutineScope Result.failure(joinApiResult.exceptionOrNull()!!) } @@ -1522,7 +1557,8 @@ class TssRepository @Inject constructor( android.util.Log.d("TssRepository", "API sign join successful: sessionId=${apiJoinData.sessionId}, partyIndex=${apiJoinData.partyIndex}, messageHash=${apiJoinData.messageHash}") // Step 2: Join session via gRPC for message routing - val joinResult = grpcClient.joinSession(apiJoinData.sessionId, partyId, apiJoinData.joinToken) + // CRITICAL: Use signingPartyId (original partyId from keygen) for backup/restore support + val joinResult = grpcClient.joinSession(apiJoinData.sessionId, signingPartyId, apiJoinData.joinToken) if (joinResult.isFailure) { android.util.Log.e("TssRepository", "gRPC join failed", joinResult.exceptionOrNull()) return@coroutineScope Result.failure(joinResult.exceptionOrNull()!!) @@ -1547,10 +1583,7 @@ class TssRepository @Inject constructor( _currentSession.value = session _sessionStatus.value = SessionStatus.WAITING - // Add self to participants - // CRITICAL: Use shareEntity.partyId (original partyId from keygen) for signing - val signingPartyId = shareEntity.partyId - currentSigningPartyId = signingPartyId // Save for later use in this flow + // Add self to participants using signingPartyId val allParticipants = sessionData.participants + Participant(signingPartyId, myPartyIndex) // Start TSS sign @@ -1573,7 +1606,8 @@ class TssRepository @Inject constructor( _sessionStatus.value = SessionStatus.IN_PROGRESS // Start message routing - startMessageRouting(apiJoinData.sessionId, myPartyIndex) + // CRITICAL: Use signingPartyId (original partyId from keygen) for message routing + startMessageRouting(apiJoinData.sessionId, myPartyIndex, signingPartyId) // Mark ready - use signingPartyId (original partyId from keygen) grpcClient.markPartyReady(apiJoinData.sessionId, signingPartyId) @@ -1608,8 +1642,13 @@ class TssRepository @Inject constructor( /** * Join sign session via account-service HTTP API * Uses validateSignInviteCode to get session info, then joins + * + * @param inviteCode The invite code for the sign session + * @param partyIndex The party index for this participant + * @param signingPartyId The original partyId from keygen (shareEntity.partyId) + * CRITICAL: For backup/restore support, this must be the original partyId */ - private suspend fun joinSignSessionViaApi(inviteCode: String, partyIndex: Int): Result { + private suspend fun joinSignSessionViaApi(inviteCode: String, partyIndex: Int, signingPartyId: String): Result { return withContext(Dispatchers.IO) { try { // First, get sign session info by invite code @@ -1625,13 +1664,14 @@ class TssRepository @Inject constructor( android.util.Log.d("TssRepository", "Got sign session info: sessionId=$sessionId, messageHash=${signSessionInfo.messageHash}, joinToken=$joinToken") // Now call join API (same endpoint as keygen join, but for sign sessions) + // CRITICAL: Use signingPartyId (original partyId from keygen) for backup/restore support val jsonMediaType = "application/json; charset=utf-8".toMediaType() val requestBody = com.google.gson.JsonObject().apply { - addProperty("party_id", partyId) + addProperty("party_id", signingPartyId) // Use original partyId from keygen addProperty("join_token", joinToken) addProperty("party_index", partyIndex) addProperty("device_type", "mobile") - addProperty("device_id", partyId) + addProperty("device_id", signingPartyId) // Use original partyId from keygen }.toString() val request = okhttp3.Request.Builder() @@ -1679,21 +1719,35 @@ class TssRepository @Inject constructor( /** * Start message routing between TSS and gRPC + * + * @param sessionId The session ID + * @param partyIndex The party index for this participant + * @param routingPartyId The party ID to use for message routing. + * CRITICAL: For signing with restored wallets, this MUST be the original partyId + * from keygen (shareEntity.partyId), not the current device's partyId. + * For keygen, this should be the device's partyId. */ - private fun startMessageRouting(sessionId: String, partyIndex: Int) { + private fun startMessageRouting(sessionId: String, partyIndex: Int, routingPartyId: String? = null) { // Save params for reconnection recovery currentMessageRoutingSessionId = sessionId currentMessageRoutingPartyIndex = partyIndex + // Use provided routingPartyId, or fall back to device partyId for keygen + val effectivePartyId = routingPartyId ?: partyId + // Save for reconnection recovery + currentMessageRoutingPartyId = effectivePartyId + messageCollectionJob?.cancel() messageCollectionJob = repositoryScope.launch { + android.util.Log.d("TssRepository", "Starting message routing: sessionId=$sessionId, routingPartyId=$effectivePartyId") + // Collect outgoing messages from TSS and route via gRPC launch { tssNativeBridge.outgoingMessages.collect { message -> val payload = Base64.decode(message.payload, Base64.NO_WRAP) grpcClient.routeMessage( sessionId = sessionId, - fromParty = partyId, + fromParty = effectivePartyId, // Use the correct partyId for routing toParties = message.toParties ?: emptyList(), roundNumber = 0, messageType = if (message.isBroadcast) "broadcast" else "p2p", @@ -1704,7 +1758,7 @@ class TssRepository @Inject constructor( // Collect incoming messages from gRPC and send to TSS launch { - grpcClient.subscribeMessages(sessionId, partyId).collect { message -> + grpcClient.subscribeMessages(sessionId, effectivePartyId).collect { message -> // Find party index from party ID val session = _currentSession.value val fromPartyIndex = session?.participants?.find { it.partyId == message.fromParty }?.partyIndex @@ -2195,15 +2249,19 @@ class TssRepository @Inject constructor( ): Result { return withContext(Dispatchers.IO) { try { - // CRITICAL: Ensure session event subscription is active BEFORE creating sign session - // This prevents race condition where session_started event arrives before subscription is ready - android.util.Log.d("TssRepository", "[CO-SIGN] Ensuring session event subscription is active before creating sign session") - ensureSessionEventSubscriptionActive() - - // Get share record + // Get share record first to determine the signingPartyId val shareEntity = shareRecordDao.getShareById(shareId) ?: return@withContext Result.failure(Exception("Share not found")) + // CRITICAL: Use shareEntity.partyId (original partyId from keygen) for session events + val signingPartyIdForEvents = shareEntity.partyId + + // CRITICAL: Ensure session event subscription is active BEFORE creating sign session + // This prevents race condition where session_started event arrives before subscription is ready + // Use signingPartyId so that session events for restored wallets are received + android.util.Log.d("TssRepository", "[CO-SIGN] Ensuring session event subscription is active before creating sign session (signingPartyId=$signingPartyIdForEvents)") + ensureSessionEventSubscriptionActive(signingPartyIdForEvents) + 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 @@ -2296,20 +2354,26 @@ class TssRepository @Inject constructor( val inviteCode = json.get("invite_code").asString val thresholdT = json.get("threshold_t")?.asInt ?: shareEntity.thresholdT + // CRITICAL: Use shareEntity.partyId (original partyId from keygen) for join token lookup + // The server generates join_tokens keyed by original partyId, not device partyId + val originalPartyId = shareEntity.partyId + // 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 + // CRITICAL: Use originalPartyId (from keygen) to look up join token + joinTokens.get(originalPartyId)?.asString ?: joinTokens.get("*")?.asString } else { json.get("join_token")?.asString } // Build participants list from signingParties + // CRITICAL: Use originalPartyId for name comparison (not device partyId) val participants = signingParties.map { (pId, pIndex) -> Participant( partyId = pId, partyIndex = pIndex, - name = if (pId == partyId) initiatorName else "参与方 ${pIndex + 1}" + name = if (pId == originalPartyId) initiatorName else "参与方 ${pIndex + 1}" ) } @@ -2334,22 +2398,31 @@ class TssRepository @Inject constructor( _sessionStatus.value = SessionStatus.WAITING // Step 4: Initiator auto-join via gRPC (matching Electron behavior) + // CRITICAL: Use shareEntity.partyId (original partyId from keygen) for signing + // This is essential for backup/restore to work correctly + val signingPartyId = shareEntity.partyId + currentSigningPartyId = signingPartyId // Save for later use in this flow + android.util.Log.d("TssRepository", "[CO-SIGN] Using signingPartyId=$signingPartyId (device partyId=$partyId)") + 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 myPartyIndex = signingParties.find { it.first == signingPartyId }?.second ?: shareEntity.partyIndex - val joinResult = grpcClient.joinSession(sessionId, partyId, joinToken) + // CRITICAL: Use signingPartyId (original partyId from keygen) for joining + val joinResult = grpcClient.joinSession(sessionId, signingPartyId, 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) + // CRITICAL: Use signingPartyId for message routing + startMessageRouting(sessionId, myPartyIndex, signingPartyId) // CRITICAL: Ensure session event subscription is active for sign initiator // The event stream may have silently disconnected - ensureSessionEventSubscriptionActive() + // Use signingPartyId so that session events for restored wallets are received + ensureSessionEventSubscriptionActive(signingPartyId) // Step 6: Check if session already in_progress if (joinData.sessionStatus == "in_progress") { @@ -2456,7 +2529,8 @@ class TssRepository @Inject constructor( // 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) + // CRITICAL: Use signingPartyId for message routing + startMessageRouting(sessionId, shareEntity.partyIndex, signingPartyId) } // Mark ready - use signingPartyId (original partyId from keygen)