From a47b935bce5dbf9bb2037d049831246165ecfd7d Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 19 Jan 2026 18:56:27 -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=E6=97=A0=E6=B3=95=E7=AD=BE?= =?UTF-8?q?=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 问题原因: 备份数据中缺少 partyId 字段。恢复到新手机后,签名时使用的是新设备 生成的 partyId,而不是 keygen 时编码到 LocalPartySaveData 中的 原始 partyId,导致 TSS 签名协议无法正确匹配密钥数据而失败。 修复内容: 1. Models.kt: - ShareRecord 添加 partyId 字段 - ShareBackup 添加 partyId 字段,备份格式版本升级到 v2 - 更新 fromShareRecord() 和 toShareRecord() 方法 2. Database.kt: - ShareRecordEntity 添加 party_id 列 - 数据库版本升级到 3 3. AppModule.kt: - 添加 MIGRATION_2_3 数据库迁移脚本 4. TssRepository.kt: - 添加 currentSigningPartyId 成员变量跟踪当前签名使用的 partyId - keygen 保存时包含 partyId (3处) - 备份导入时保存原始 partyId - 签名流程使用 shareEntity.partyId 替代设备 partyId (3处) - gRPC 调用 (markPartyReady, reportCompletion) 使用原始 partyId 关键点: 签名时必须使用 keygen 时的原始 partyId,因为该 ID 被编码 到了 TSS 密钥数据结构中。现在备份会保存此关键字段,恢复后签名 将使用正确的 partyId。 Co-Authored-By: Claude Opus 4.5 --- .../durian/tssparty/data/local/Database.kt | 5 +- .../tssparty/data/repository/TssRepository.kt | 72 +++++++++++++------ .../java/com/durian/tssparty/di/AppModule.kt | 13 +++- .../durian/tssparty/domain/model/Models.kt | 8 ++- 4 files changed, 75 insertions(+), 23 deletions(-) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt index d6b5f6c0..2c537ab4 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt @@ -29,6 +29,9 @@ data class ShareRecordEntity( @ColumnInfo(name = "party_index") val partyIndex: Int, + @ColumnInfo(name = "party_id") + val partyId: String, // The original partyId used during keygen - required for signing + @ColumnInfo(name = "address") val address: String, @@ -95,7 +98,7 @@ interface AppSettingDao { */ @Database( entities = [ShareRecordEntity::class, AppSettingEntity::class], - version = 2, + version = 3, // Version 3: added party_id column to share_records exportSchema = false ) abstract class TssDatabase : RoomDatabase() { 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 cd3b9fe6..cc8372d8 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 @@ -48,6 +48,12 @@ class TssRepository @Inject constructor( // partyId is loaded once from database in registerParty() and cached here // This matches Electron's getOrCreatePartyId() pattern private lateinit var partyId: String + + // currentSigningPartyId: The partyId to use for the current signing session + // This may differ from partyId when signing with a restored wallet backup + // CRITICAL: For backup/restore to work, signing must use the original partyId from keygen + private var currentSigningPartyId: String? = null + private var messageCollectionJob: Job? = null private var sessionEventJob: Job? = null @@ -1051,6 +1057,7 @@ class TssRepository @Inject constructor( val address = AddressUtils.deriveEvmAddress(publicKeyBytes) // Save share record (use actual thresholds and party index from backend) + // CRITICAL: Save partyId - this is required for signing after backup/restore val shareEntity = ShareRecordEntity( sessionId = sessionId, publicKey = result.publicKey, @@ -1058,6 +1065,7 @@ class TssRepository @Inject constructor( thresholdT = actualThresholdT, thresholdN = actualThresholdN, partyIndex = actualPartyIndex, + partyId = partyId, address = address ) val id = shareRecordDao.insertShare(shareEntity) @@ -1222,10 +1230,14 @@ class TssRepository @Inject constructor( } else { messageHash } - android.util.Log.d("TssRepository", "Starting TSS sign with cleanMessageHash=${cleanMessageHash.take(20)}...") + // CRITICAL: Use shareEntity.partyId (original partyId from keygen) for signing + // This is required for backup/restore to work - the partyId must match what was used during keygen + val signingPartyId = shareEntity.partyId + currentSigningPartyId = signingPartyId // Save for later use in this flow + android.util.Log.d("TssRepository", "Starting TSS sign with cleanMessageHash=${cleanMessageHash.take(20)}..., signingPartyId=$signingPartyId") val startResult = tssNativeBridge.startSign( sessionId = sessionId, - partyId = partyId, + partyId = signingPartyId, partyIndex = partyIndex, thresholdT = thresholdT, thresholdN = shareEntity.thresholdN, // Use original N from keygen @@ -1243,8 +1255,8 @@ class TssRepository @Inject constructor( // Start collecting progress from native bridge startProgressCollection() - // Mark ready - grpcClient.markPartyReady(sessionId, partyId) + // Mark ready - use signingPartyId (original partyId from keygen) + grpcClient.markPartyReady(sessionId, signingPartyId) // Wait for sign result val signResult = tssNativeBridge.waitForSignResult() @@ -1256,14 +1268,15 @@ class TssRepository @Inject constructor( val result = signResult.getOrThrow() - // Report completion + // Report completion - use signingPartyId (original partyId from keygen) val signatureBytes = android.util.Base64.decode(result.signature, android.util.Base64.NO_WRAP) - grpcClient.reportCompletion(sessionId, partyId, signature = signatureBytes) + grpcClient.reportCompletion(sessionId, signingPartyId, signature = signatureBytes) stopProgressCollection() _sessionStatus.value = SessionStatus.COMPLETED pendingSessionId = null // Clear pending session ID on completion messageCollectionJob?.cancel() + currentSigningPartyId = null // Clear after signing completes android.util.Log.d("TssRepository", "Sign as joiner completed: signature=${result.signature.take(20)}...") @@ -1274,6 +1287,7 @@ class TssRepository @Inject constructor( stopProgressCollection() _sessionStatus.value = SessionStatus.FAILED pendingSessionId = null // Clear pending session ID on failure + currentSigningPartyId = null // Clear on failure too Result.failure(e) } } @@ -1366,6 +1380,7 @@ class TssRepository @Inject constructor( val address = AddressUtils.deriveEvmAddress(publicKeyBytes) // Save share record + // CRITICAL: Save partyId - this is required for signing after backup/restore val shareEntity = ShareRecordEntity( sessionId = apiJoinData.sessionId, publicKey = result.publicKey, @@ -1373,6 +1388,7 @@ class TssRepository @Inject constructor( thresholdT = apiJoinData.thresholdT, thresholdN = apiJoinData.thresholdN, partyIndex = myPartyIndex, + partyId = partyId, address = address ) val id = shareRecordDao.insertShare(shareEntity) @@ -1516,12 +1532,15 @@ class TssRepository @Inject constructor( _sessionStatus.value = SessionStatus.WAITING // Add self to participants - val allParticipants = sessionData.participants + Participant(partyId, myPartyIndex) + // CRITICAL: Use shareEntity.partyId (original partyId from keygen) for signing + val signingPartyId = shareEntity.partyId + currentSigningPartyId = signingPartyId // Save for later use in this flow + val allParticipants = sessionData.participants + Participant(signingPartyId, myPartyIndex) // Start TSS sign val startResult = tssNativeBridge.startSign( sessionId = apiJoinData.sessionId, - partyId = partyId, + partyId = signingPartyId, partyIndex = myPartyIndex, thresholdT = apiJoinData.thresholdT, thresholdN = shareEntity.thresholdN, // Use original N from keygen @@ -1540,8 +1559,8 @@ class TssRepository @Inject constructor( // Start message routing startMessageRouting(apiJoinData.sessionId, myPartyIndex) - // Mark ready - grpcClient.markPartyReady(apiJoinData.sessionId, partyId) + // Mark ready - use signingPartyId (original partyId from keygen) + grpcClient.markPartyReady(apiJoinData.sessionId, signingPartyId) // Wait for sign result val signResult = tssNativeBridge.waitForSignResult() @@ -1552,18 +1571,20 @@ class TssRepository @Inject constructor( val result = signResult.getOrThrow() - // Report completion + // Report completion - use signingPartyId (original partyId from keygen) val signatureBytes = Base64.decode(result.signature, Base64.NO_WRAP) - grpcClient.reportCompletion(apiJoinData.sessionId, partyId, signature = signatureBytes) + grpcClient.reportCompletion(apiJoinData.sessionId, signingPartyId, signature = signatureBytes) _sessionStatus.value = SessionStatus.COMPLETED messageCollectionJob?.cancel() + currentSigningPartyId = null // Clear after signing completes Result.success(result) } catch (e: Exception) { android.util.Log.e("TssRepository", "Join sign session failed", e) _sessionStatus.value = SessionStatus.FAILED + currentSigningPartyId = null // Clear on failure too Result.failure(e) } } @@ -1785,6 +1806,7 @@ class TssRepository @Inject constructor( val address = AddressUtils.deriveEvmAddress(publicKeyBytes) // Save share record (use actual thresholds from backend) + // CRITICAL: Save partyId - this is required for signing after backup/restore val shareEntity = ShareRecordEntity( sessionId = sessionId, publicKey = result.publicKey, @@ -1792,6 +1814,7 @@ class TssRepository @Inject constructor( thresholdT = actualThresholdT, thresholdN = actualThresholdN, partyIndex = myPartyIndex, + partyId = partyId, address = address ) val id = shareRecordDao.insertShare(shareEntity) @@ -1900,6 +1923,7 @@ class TssRepository @Inject constructor( } // Convert to entity and save + // CRITICAL: Preserve the original partyId from backup - this is required for signing val shareRecord = backup.toShareRecord() val entity = ShareRecordEntity( sessionId = shareRecord.sessionId, @@ -1908,6 +1932,7 @@ class TssRepository @Inject constructor( thresholdT = shareRecord.thresholdT, thresholdN = shareRecord.thresholdN, partyIndex = shareRecord.partyIndex, + partyId = shareRecord.partyId, address = shareRecord.address, createdAt = shareRecord.createdAt ) @@ -1915,7 +1940,7 @@ class TssRepository @Inject constructor( val newId = shareRecordDao.insertShare(entity) val savedShare = shareRecord.copy(id = newId) - android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}") + android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}, partyId: ${backup.partyId}") Result.success(savedShare) } catch (e: com.google.gson.JsonSyntaxException) { android.util.Log.e("TssRepository", "Invalid JSON format in backup", e) @@ -2312,8 +2337,12 @@ class TssRepository @Inject constructor( val shareEntity = shareRecordDao.getShareById(shareId) ?: return@withContext Result.failure(Exception("Share not found")) + // CRITICAL: Use shareEntity.partyId (original partyId from keygen) for signing + // This is required for backup/restore to work - the partyId must match what was used during keygen + val signingPartyId = shareEntity.partyId + currentSigningPartyId = signingPartyId // Save for waitForSignature android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: participants=${session.participants.size}") - android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: sessionId=$sessionId, partyId=$partyId, partyIndex=${shareEntity.partyIndex}") + android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: sessionId=$sessionId, signingPartyId=$signingPartyId, partyIndex=${shareEntity.partyIndex}") android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: thresholdT=${session.thresholdT}, thresholdN=${shareEntity.thresholdN}") android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: messageHash=${session.messageHash?.take(20)}...") session.participants.forEachIndexed { idx, p -> @@ -2328,10 +2357,10 @@ class TssRepository @Inject constructor( } else { rawMessageHash } - android.util.Log.d("TssRepository", "[CO-SIGN] Calling tssNativeBridge.startSign with cleanMessageHash=${cleanMessageHash.take(20)}...") + android.util.Log.d("TssRepository", "[CO-SIGN] Calling tssNativeBridge.startSign with cleanMessageHash=${cleanMessageHash.take(20)}..., signingPartyId=$signingPartyId") val startResult = tssNativeBridge.startSign( sessionId = sessionId, - partyId = partyId, + partyId = signingPartyId, partyIndex = shareEntity.partyIndex, thresholdT = session.thresholdT, thresholdN = shareEntity.thresholdN, @@ -2359,8 +2388,8 @@ class TssRepository @Inject constructor( startMessageRouting(sessionId, shareEntity.partyIndex) } - // Mark ready - grpcClient.markPartyReady(sessionId, partyId) + // Mark ready - use signingPartyId (original partyId from keygen) + grpcClient.markPartyReady(sessionId, signingPartyId) Result.success(Unit) } catch (e: Exception) { @@ -2386,16 +2415,18 @@ class TssRepository @Inject constructor( val result = signResult.getOrThrow() - // Report completion + // Report completion - use currentSigningPartyId (original partyId from keygen) val signatureBytes = Base64.decode(result.signature, Base64.NO_WRAP) val session = _currentSession.value + val signingPartyId = currentSigningPartyId ?: partyId if (session != null) { - grpcClient.reportCompletion(session.sessionId, partyId, signature = signatureBytes) + grpcClient.reportCompletion(session.sessionId, signingPartyId, signature = signatureBytes) } stopProgressCollection() _sessionStatus.value = SessionStatus.COMPLETED messageCollectionJob?.cancel() + currentSigningPartyId = null // Clear after signing completes Result.success(result) } catch (e: Exception) { @@ -2759,6 +2790,7 @@ private fun ShareRecordEntity.toShareRecord() = ShareRecord( thresholdT = thresholdT, thresholdN = thresholdN, partyIndex = partyIndex, + partyId = partyId, address = address, createdAt = createdAt ) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt index b62a4262..b8b1d244 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt @@ -34,6 +34,17 @@ object AppModule { } } + // Migration from version 2 to 3: add party_id column to share_records + // This is critical for backup/restore - the partyId must be preserved for signing to work + private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add party_id column with empty default (existing records will need to be re-exported) + database.execSQL( + "ALTER TABLE `share_records` ADD COLUMN `party_id` TEXT NOT NULL DEFAULT ''" + ) + } + } + @Provides @Singleton fun provideGson(): Gson { @@ -48,7 +59,7 @@ object AppModule { TssDatabase::class.java, "tss_party.db" ) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt index 4b2bfc85..6a2573f4 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt @@ -86,6 +86,7 @@ data class ShareRecord( val thresholdT: Int, val thresholdN: Int, val partyIndex: Int, + val partyId: String, // The original partyId used during keygen - required for signing val address: String, val createdAt: Long = System.currentTimeMillis() ) @@ -165,7 +166,7 @@ data class WalletBalance( */ data class ShareBackup( @SerializedName("version") - val version: Int = 1, // Backup format version for future compatibility + val version: Int = 2, // Version 2: added partyId field for proper backup/restore @SerializedName("sessionId") val sessionId: String, @@ -185,6 +186,9 @@ data class ShareBackup( @SerializedName("partyIndex") val partyIndex: Int, + @SerializedName("partyId") + val partyId: String, // The original partyId used during keygen - CRITICAL for signing after restore + @SerializedName("address") val address: String, @@ -209,6 +213,7 @@ data class ShareBackup( thresholdT = share.thresholdT, thresholdN = share.thresholdN, partyIndex = share.partyIndex, + partyId = share.partyId, address = share.address, createdAt = share.createdAt ) @@ -227,6 +232,7 @@ data class ShareBackup( thresholdT = thresholdT, thresholdN = thresholdN, partyIndex = partyIndex, + partyId = partyId, address = address, createdAt = createdAt )