fix(tss-android): 修复备份恢复后钱包无法签名的问题

## 问题根因

备份恢复后的钱包在签名时失败,根本原因是 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-26 04:15:52 -08:00
parent 37d3300b17
commit 2799eb5a3a
1 changed files with 116 additions and 42 deletions

View File

@ -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<ApiJoinSignSessionData> {
private suspend fun joinSignSessionViaApi(inviteCode: String, partyIndex: Int, signingPartyId: String): Result<ApiJoinSignSessionData> {
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<SignSessionResult> {
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)