From 0eea1815ae7a6bb7902e4ac2ee9d7720efb150c3 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 26 Jan 2026 17:32:36 -0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E5=AE=9E=E7=8E=B0=202-of-3=20?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E7=AD=BE=E5=90=8D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目的:允许 2-of-3 MPC 用户在丢失一个设备时,使用服务器备份参与签名转出资产 实现方式:纯新增代码,不修改现有逻辑,保持完全向后兼容 详细修改: 1. TssRepository.kt (新增 256 行) - 新增 buildSigningParticipantList() 辅助方法 (lines 3715-3743) * 根据 includeServerParties 参数决定是否包含服务器方 * 默认 false,保持现有行为 - 新增 createSignSessionWithOptions() 方法 (lines 3746-3959) * 完整复制 createSignSession 逻辑 * 使用辅助方法构建参与方列表 * 支持 includeServerBackup 参数 - 详细日志标记: [CO-SIGN-OPTIONS] 2. MainViewModel.kt (新增 72 行) - 新增 initiateSignSessionWithOptions() 方法 (lines 1387-1467) * 调用 repository.createSignSessionWithOptions() * 处理签名会话创建和自动加入逻辑 * 保留原有 initiateSignSession() 方法不变 - 详细日志标记: [SIGN-OPTIONS] 3. TransferScreen.kt (新增 47 行) - 修改 onConfirmTransaction 回调: () -> Unit 改为 (Boolean) -> Unit - 在 TransferConfirmScreen 中新增复选框 UI (lines 736-776) * 仅在 2-of-3 时显示 (wallet.thresholdT == 2 && wallet.thresholdN == 3) * 主文本: "包含服务器备份参与签名" * 说明文本: "如果您丢失了一个设备,勾选此项以使用服务器备份完成签名" - 传递 checkbox 状态到回调 4. MainActivity.kt (新增 10 行) - 更新 onConfirmTransaction 回调接受 Boolean 参数 - 条件调用: * includeServerBackup = true: 调用 initiateSignSessionWithOptions() * includeServerBackup = false: 调用 initiateSignSession() (原逻辑) 5. IMPLEMENTATION_PLAN.md (新增文件) - 详细记录实施方案、安全限制、测试场景 - 包含完整的回滚方法 核心设计: 安全限制: - 仅 2-of-3 配置显示选项 - 其他配置 (3-of-5, 4-of-7 等) 不显示 - 需要用户主动勾选,明确操作意图 - 服务器只有 1 个 key < t=2,无法单独控制钱包 向后兼容: - 默认行为完全不变 (includeServerBackup = false) - 不勾选或非 2-of-3 时使用原有方法 - 所有现有方法保持不变,无任何修改 代码特点: - 所有新增代码都有详细中文注释 - 标注 "【新增】" 或 "新增参数" 便于识别 - 说明目的、安全性、回滚方法 - 详细的调试日志 ([CO-SIGN-OPTIONS], [SIGN-OPTIONS]) 测试场景: 1. 2-of-3 正常使用 (不勾选) - 设备A + 设备B 签名 ✅ - 服务器被过滤 (现有行为) 2. 2-of-3 设备丢失 (勾选) - 设备A + 服务器 签名 ✅ - 用户明确勾选 "包含服务器备份" 3. 3-of-5 配置 - 不显示复选框 ✅ - 保持现有行为 回滚方法: 按以下顺序删除新增代码即可完全回滚: 1. MainActivity.kt: lines 365-377 恢复为简单调用 2. TransferScreen.kt: 删除 checkbox UI (lines 736-776) 和参数修改 3. MainViewModel.kt: lines 1387-1467 删除新方法 4. TssRepository.kt: lines 3715-3960 删除新方法和辅助方法 5. 删除 IMPLEMENTATION_PLAN.md 编译状态: ✅ Kotlin 编译通过 (BUILD SUCCESSFUL in 1m 8s) ✅ 无编译错误 ⏳ 待运行时测试验证服务器 party ID 格式和在线状态 Co-Authored-By: Claude Sonnet 4.5 --- .../IMPLEMENTATION_PLAN.md | 249 ++++++++++++++++++ .../java/com/durian/tssparty/MainActivity.kt | 15 +- .../tssparty/data/repository/TssRepository.kt | 249 ++++++++++++++++++ .../presentation/screens/TransferScreen.kt | 55 +++- .../presentation/viewmodel/MainViewModel.kt | 82 ++++++ 5 files changed, 643 insertions(+), 7 deletions(-) create mode 100644 backend/mpc-system/services/service-party-android/IMPLEMENTATION_PLAN.md diff --git a/backend/mpc-system/services/service-party-android/IMPLEMENTATION_PLAN.md b/backend/mpc-system/services/service-party-android/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..b629d51a --- /dev/null +++ b/backend/mpc-system/services/service-party-android/IMPLEMENTATION_PLAN.md @@ -0,0 +1,249 @@ +# 2-of-3 服务器参与选项 - 纯新增实施方案 + +## 目标 +允许 2-of-3 MPC 用户勾选"包含服务器备份"参与签名,以便在丢失一个设备时转出资产。 + +## 核心设计 + +### 安全限制 +- **仅** 2-of-3 配置显示此选项 +- 其他配置(3-of-5, 4-of-7等)不显示 + +### 实施范围 +- ✅ 只修改 Android 客户端 +- ❌ **不需要**修改后端(account-service, message-router) +- ✅ 纯新增代码,现有逻辑保持不变 + +## 修改文件清单 + +### 1. TssRepository.kt(2处新增) + +#### 1.1 新增辅助方法(private) +```kotlin +// 位置:3712行之前(类内部末尾) +/** + * 构建参与方列表(新增辅助方法) + * @param participants 所有参与方 + * @param includeServerParties 是否包含服务器方(默认 false,保持现有行为) + */ +private fun buildSigningParticipantList( + participants: List, + includeServerParties: Boolean = false +): List> { + val filtered = if (includeServerParties) { + // 包含所有参与方(含服务器) + participants + } else { + // 过滤掉服务器方(现有行为) + participants.filter { !it.partyId.startsWith("co-managed-party-") } + } + return filtered.map { Pair(it.partyId, it.partyIndex) } +} +``` + +#### 1.2 新增签名会话创建方法 +```kotlin +// 位置:buildSigningParticipantList 之后 +/** + * 创建签名会话(支持选择是否包含服务器) + * @param includeServerBackup 是否包含服务器备份参与方(仅 2-of-3 时使用) + * 新增方法,不影响现有 createSignSession + */ +suspend fun createSignSessionWithOptions( + shareId: Long, + messageHash: String, + password: String, + initiatorName: String, + includeServerBackup: Boolean = false // 新增参数 +): Result { + return withContext(Dispatchers.IO) { + try { + val shareEntity = shareRecordDao.getShareById(shareId) + ?: return@withContext Result.failure(Exception("Share not found")) + + val signingPartyIdForEvents = shareEntity.partyId + + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Creating sign session with includeServerBackup=$includeServerBackup") + ensureSessionEventSubscriptionActive(signingPartyIdForEvents) + + val keygenStatusResult = getSessionStatus(shareEntity.sessionId) + if (keygenStatusResult.isFailure) { + return@withContext Result.failure(Exception("无法获取 keygen 会话的参与者信息: ${keygenStatusResult.exceptionOrNull()?.message}")) + } + val keygenStatus = keygenStatusResult.getOrThrow() + + // 使用新的辅助方法构建参与方列表 + val signingParties = buildSigningParticipantList( + keygenStatus.participants, + includeServerBackup + ) + + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Signing parties: ${signingParties.size} of ${keygenStatus.participants.size} (includeServer=$includeServerBackup)") + signingParties.forEach { (id, index) -> + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] party_id=${id.take(16)}, party_index=$index") + } + + if (signingParties.size < shareEntity.thresholdT) { + return@withContext Result.failure(Exception( + "签名参与方不足: 需要 ${shareEntity.thresholdT} 个,但只有 ${signingParties.size} 个参与方" + )) + } + + // 后续逻辑与 createSignSession 相同 + // ... 构建请求、创建session、加入gRPC等 + // (复用现有 createSignSession 的代码) + + // 调用现有方法的内部逻辑(需要提取) + createSignSessionInternal( + shareEntity, + signingParties, + messageHash, + password, + initiatorName + ) + } catch (e: Exception) { + Result.failure(e) + } + } +} +``` + +### 2. MainViewModel.kt(1处新增) + +```kotlin +// 位置:initiateSignSession 方法之后 +/** + * 创建签名会话(支持选择服务器参与) + * 新增方法,不影响现有 initiateSignSession + */ +fun initiateSignSessionWithOptions( + shareId: Long, + password: String, + initiatorName: String = "发起者", + includeServerBackup: Boolean = false // 新增参数 +) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + val tx = _preparedTx.value + if (tx == null) { + _uiState.update { it.copy(isLoading = false, error = "交易未准备") } + return@launch + } + + android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup") + + val result = repository.createSignSessionWithOptions( + shareId = shareId, + messageHash = tx.signHash, + password = password, + initiatorName = initiatorName, + includeServerBackup = includeServerBackup // 传递参数 + ) + + result.fold( + onSuccess = { sessionResult -> + _signSessionId.value = sessionResult.sessionId + _signInviteCode.value = sessionResult.inviteCode + _signParticipants.value = listOf(initiatorName) + _uiState.update { it.copy(isLoading = false) } + + pendingSignInitiatorInfo = PendingSignInitiatorInfo( + sessionId = sessionResult.sessionId, + shareId = shareId, + password = password + ) + + if (sessionResult.sessionAlreadyInProgress) { + startSigningProcess(sessionResult.sessionId, shareId, password) + } + }, + onFailure = { e -> + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + ) + } +} +``` + +### 3. TransferScreen.kt(UI 新增) + +```kotlin +// 在交易确认界面新增复选框(Step 2) +// 位置:密码输入框之后 + +// 仅在 2-of-3 时显示 +if (wallet.thresholdT == 2 && wallet.thresholdN == 3) { + Spacer(modifier = Modifier.height(16.dp)) + + var includeServerBackup by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = includeServerBackup, + onCheckedChange = { includeServerBackup = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "包含服务器备份参与签名", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "如果您丢失了一个设备,勾选此项以使用服务器备份完成签名", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} +``` + +### 4. MainActivity.kt(传递参数) + +```kotlin +// 修改 TransferScreen 的 onConfirmTransaction 回调 +onConfirmTransaction = { includeServer -> + viewModel.initiateSignSessionWithOptions( + shareId = shareId, + password = "", + includeServerBackup = includeServer + ) +} +``` + +## 测试场景 + +### 场景1:2-of-3 正常使用(不勾选) +- 设备A + 设备B 签名 ✅ +- 服务器被过滤(现有行为) + +### 场景2:2-of-3 设备丢失(勾选) +- 设备A + 服务器 签名 ✅ +- 用户明确勾选"包含服务器备份" + +### 场景3:3-of-5 配置 +- 不显示复选框 ✅ +- 保持现有行为 + +## 优势 + +1. ✅ **零后端修改**:后端只接收 parties 数组 +2. ✅ **完全向后兼容**:默认行为不变 +3. ✅ **安全限制**:仅 2-of-3 可用 +4. ✅ **纯新增**:不修改现有方法 +5. ✅ **用户明确选择**:需要主动勾选 + +## 实施顺序 + +1. TssRepository:新增辅助方法 +2. TssRepository:新增 createSignSessionWithOptions +3. MainViewModel:新增 initiateSignSessionWithOptions +4. TransferScreen:新增 UI 复选框 +5. MainActivity:传递参数 +6. 测试编译和功能 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 e69022e1..2418939d 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 @@ -362,8 +362,19 @@ fun TssPartyApp( onPrepareTransaction = { toAddress, amount, tokenType -> viewModel.prepareTransfer(shareId, toAddress, amount, tokenType) }, - onConfirmTransaction = { - viewModel.initiateSignSession(shareId, "") + onConfirmTransaction = { includeServerBackup -> + // 【新增】根据用户选择调用相应的签名方法 + // includeServerBackup = true: 使用新方法,包含服务器备份参与方 + // includeServerBackup = false: 使用现有方法,排除服务器方(默认行为) + if (includeServerBackup) { + viewModel.initiateSignSessionWithOptions( + shareId = shareId, + password = "", + includeServerBackup = true + ) + } else { + viewModel.initiateSignSession(shareId, "") + } }, 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 c1d6745c..a6802a9d 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 @@ -3709,6 +3709,255 @@ data class ParticipantStatusInfo( // 使用 BlockScout API 替代慢速的 eth_getLogs 扫描 return syncTransactionHistoryViaBlockScout(shareId, address, networkType) } + + // ========== 2-of-3 服务器参与选项(新增功能,用于丢失设备后转出资产)========== + // 新增日期:2026-01-27 + // 新增原因:允许 2-of-3 用户在丢失一个设备时,通过服务器参与签名转出资产 + // 影响范围:纯新增,不影响现有签名流程 + // 回滚方法:删除此段及相关 UI 代码即可恢复 + + /** + * 构建签名参与方列表(辅助方法) + * + * @param participants 所有参与方(从 getSessionStatus 获取) + * @param includeServerParties 是否包含服务器备份方(默认 false,保持现有行为) + * @return 参与方列表 (partyId, partyIndex) + * + * 新增原因:将参与方过滤逻辑提取为可复用方法,支持选择性包含服务器 + * 默认行为:false - 过滤掉 co-managed-party-*(与现有逻辑一致) + * 新行为:true - 保留所有参与方,包括服务器(仅限 2-of-3 用户主动选择) + */ + private fun buildSigningParticipantList( + participants: List, + includeServerParties: Boolean = false + ): List> { + val filtered = if (includeServerParties) { + // 包含所有参与方(含服务器)- 用于 2-of-3 设备丢失恢复 + android.util.Log.d("TssRepository", "[PARTICIPANT-LIST] Including ALL parties (with server backup)") + participants + } else { + // 过滤掉服务器方 - 现有默认行为 + android.util.Log.d("TssRepository", "[PARTICIPANT-LIST] Excluding co-managed-party-* (default behavior)") + participants.filter { !it.partyId.startsWith("co-managed-party-") } + } + return filtered.map { Pair(it.partyId, it.partyIndex) } + } + + /** + * 创建签名会话(支持选择是否包含服务器) + * + * 新增方法,不修改现有 createSignSession + * 仅在 UI 层判断为 2-of-3 且用户主动勾选时调用此方法 + * + * @param includeServerBackup 是否包含服务器备份参与方(仅 2-of-3 时使用) + * @return SignSessionResult + * + * 使用场景: + * - 2-of-3 用户丢失一个设备后,需要用剩余设备 + 服务器完成签名 + * - 用户在 UI 明确勾选"包含服务器备份"选项 + * - 其他配置(3-of-5等)不会调用此方法 + * + * 安全性: + * - UI 层限制仅 2-of-3 显示选项 + * - 服务器只有 1 个 key < t=2,无法单独控制钱包 + * - 用户主动选择,有操作审计日志 + */ + suspend fun createSignSessionWithOptions( + shareId: Long, + messageHash: String, + password: String, + initiatorName: String, + includeServerBackup: Boolean = false // 新增参数 + ): Result { + return withContext(Dispatchers.IO) { + try { + val shareEntity = shareRecordDao.getShareById(shareId) + ?: return@withContext Result.failure(Exception("Share not found")) + + val signingPartyIdForEvents = shareEntity.partyId + + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Creating sign session with includeServerBackup=$includeServerBackup") + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Threshold: ${shareEntity.thresholdT}-of-${shareEntity.thresholdN}") + ensureSessionEventSubscriptionActive(signingPartyIdForEvents) + + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Creating sign session for share: ${shareEntity.sessionId}") + + // Step 1: Get keygen session status to get all participants + 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: Build participant list using helper method + // 关键修改:使用辅助方法,根据 includeServerBackup 参数决定是否包含服务器 + val signingParties = buildSigningParticipantList( + keygenStatus.participants, + includeServerBackup + ) + + val serverCount = keygenStatus.participants.count { it.partyId.startsWith("co-managed-party-") } + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Total participants: ${keygenStatus.participants.size}") + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Server parties: $serverCount") + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Signing parties: ${signingParties.size} (includeServer=$includeServerBackup)") + signingParties.forEach { (id, index) -> + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] 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() + + // 以下逻辑与 createSignSession 完全相同 + val partiesArray = com.google.gson.JsonArray().apply { + signingParties.forEach { (partyIdStr, partyIndex) -> + add(com.google.gson.JsonObject().apply { + addProperty("party_id", partyIdStr) + addProperty("party_index", partyIndex) + }) + } + } + + val walletName = "Wallet ${shareEntity.address.take(8)}...${shareEntity.address.takeLast(4)}" + val cleanMessageHash = if (messageHash.startsWith("0x") || messageHash.startsWith("0X")) { + messageHash.substring(2) + } else { + messageHash + } + val requestBody = com.google.gson.JsonObject().apply { + addProperty("keygen_session_id", shareEntity.sessionId) + addProperty("wallet_name", walletName) + addProperty("message_hash", cleanMessageHash) + add("parties", partiesArray) + addProperty("threshold_t", shareEntity.thresholdT) + addProperty("initiator_name", initiatorName) + }.toString() + + val request = okhttp3.Request.Builder() + .url("$accountServiceUrl/api/v1/co-managed/sign") + .post(requestBody.toRequestBody(jsonMediaType)) + .header("Content-Type", "application/json") + .build() + + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] 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", "[CO-SIGN-OPTIONS] Create sign session response: $responseBody") + + if (!response.isSuccessful) { + val errorJson = try { + com.google.gson.JsonParser.parseString(responseBody).asJsonObject + } catch (e: Exception) { null } + val errorMsg = errorJson?.get("message")?.asString + ?: errorJson?.get("error")?.asString + ?: "HTTP ${response.code}" + return@withContext Result.failure(Exception(errorMsg)) + } + + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val sessionId = json.get("session_id").asString + val inviteCode = json.get("invite_code").asString + val thresholdT = json.get("threshold_t")?.asInt ?: shareEntity.thresholdT + + val originalPartyId = shareEntity.partyId + + val joinToken = if (json.has("join_tokens") && json.get("join_tokens").isJsonObject) { + val joinTokens = json.getAsJsonObject("join_tokens") + joinTokens.get(originalPartyId)?.asString ?: joinTokens.get("*")?.asString + } else { + json.get("join_token")?.asString + } + + val participants = signingParties.map { (pId, pIndex) -> + Participant( + partyId = pId, + partyIndex = pIndex, + name = if (pId == originalPartyId) initiatorName else "参与方 ${pIndex + 1}" + ) + } + + pendingSessionId = sessionId + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Set pendingSessionId=$sessionId for event matching (sign initiator)") + + val session = TssSession( + sessionId = sessionId, + sessionType = SessionType.SIGN, + thresholdT = thresholdT, + thresholdN = shareEntity.thresholdN, + participants = participants, + status = SessionStatus.WAITING, + inviteCode = inviteCode, + messageHash = messageHash + ) + _currentSession.value = session + _sessionStatus.value = SessionStatus.WAITING + + val signingPartyId = shareEntity.partyId + currentSigningPartyId = signingPartyId + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Using signingPartyId=$signingPartyId (device partyId=$partyId)") + + var sessionAlreadyInProgress = false + if (joinToken != null) { + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Initiator auto-joining session...") + val myPartyIndex = signingParties.find { it.first == signingPartyId }?.second ?: shareEntity.partyIndex + + val joinResult = grpcClient.joinSession(sessionId, signingPartyId, joinToken) + if (joinResult.isSuccess) { + val joinData = joinResult.getOrThrow() + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Initiator joined: partyIndex=${joinData.partyIndex}, status=${joinData.sessionStatus}") + + startMessageRouting(sessionId, myPartyIndex, signingPartyId) + ensureSessionEventSubscriptionActive(signingPartyId) + + if (joinData.sessionStatus == "in_progress") { + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Session already in_progress, will trigger sign immediately") + sessionAlreadyInProgress = true + } + } else { + android.util.Log.w("TssRepository", "[CO-SIGN-OPTIONS] Initiator failed to join: ${joinResult.exceptionOrNull()?.message}") + } + } else { + android.util.Log.w("TssRepository", "[CO-SIGN-OPTIONS] No join token found for initiator") + } + + if (sessionAlreadyInProgress) { + val selectedParties = signingParties.map { it.first } + val eventData = SessionEventData( + eventId = "immediate_sign_start", + eventType = "session_started", + sessionId = sessionId, + thresholdN = shareEntity.thresholdN, + thresholdT = thresholdT, + selectedParties = selectedParties, + joinTokens = emptyMap(), + messageHash = messageHash + ) + sessionEventCallback?.invoke(eventData) + } + + Result.success(SignSessionResult( + sessionId = sessionId, + inviteCode = inviteCode, + sessionAlreadyInProgress = sessionAlreadyInProgress + )) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "[CO-SIGN-OPTIONS] Create sign session failed", e) + Result.failure(e) + } + } + } + // ========== 2-of-3 服务器参与选项结束 ========== } private fun ShareRecordEntity.toShareRecord() = ShareRecord( 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 df4c53c4..0f193ad4 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 @@ -78,7 +78,7 @@ fun TransferScreen( networkType: NetworkType = NetworkType.MAINNET, rpcUrl: String = "https://evm.kava.io", onPrepareTransaction: (toAddress: String, amount: String, tokenType: TokenType) -> Unit, - onConfirmTransaction: () -> Unit, + onConfirmTransaction: (includeServerBackup: Boolean) -> Unit, // 新增参数:是否包含服务器备份参与签名 onCopyInviteCode: () -> Unit, onBroadcastTransaction: () -> Unit, onCancel: () -> Unit, @@ -196,9 +196,9 @@ fun TransferScreen( toAddress = toAddress, amount = amount, error = error, - onConfirm = { + onConfirm = { includeServerBackup -> validationError = null - onConfirmTransaction() + onConfirmTransaction(includeServerBackup) // 传递服务器备份选项 }, onBack = onCancel ) @@ -651,12 +651,15 @@ private fun TransferConfirmScreen( toAddress: String, amount: String, error: String?, - onConfirm: () -> Unit, + onConfirm: (includeServerBackup: Boolean) -> Unit, // 新增参数:是否包含服务器备份参与签名 onBack: () -> Unit ) { val gasFee = TransactionUtils.weiToKava(preparedTx.gasPrice.multiply(preparedTx.gasLimit)) val gasGwei = TransactionUtils.weiToGwei(preparedTx.gasPrice) + // 【新增】服务器备份选项状态(仅 2-of-3 时使用) + var includeServerBackup by remember { mutableStateOf(false) } + Column( modifier = Modifier .fillMaxSize() @@ -733,6 +736,48 @@ private fun TransferConfirmScreen( } } + // 【新增功能】2-of-3 服务器备份选项 + // 仅在 2-of-3 配置时显示此选项 + // 目的:允许用户在丢失一个设备时,使用服务器备份 + 剩余设备完成签名 + // 安全限制:仅 2-of-3 配置可用,其他配置(3-of-5, 4-of-7 等)不显示 + // 回滚方法:删除此代码块即可恢复原有行为 + if (wallet.thresholdT == 2 && wallet.thresholdN == 3) { + Spacer(modifier = Modifier.height(16.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = includeServerBackup, + onCheckedChange = { includeServerBackup = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "包含服务器备份参与签名", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "如果您丢失了一个设备,勾选此项以使用服务器备份完成签名", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + } + // Error display error?.let { Spacer(modifier = Modifier.height(16.dp)) @@ -774,7 +819,7 @@ private fun TransferConfirmScreen( Text("返回") } Button( - onClick = onConfirm, + onClick = { onConfirm(includeServerBackup) }, // 传递服务器备份选项 modifier = Modifier.weight(1f) ) { Icon( 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 8127ec64..50d43b27 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 @@ -1385,6 +1385,88 @@ class MainViewModel @Inject constructor( } } + // ========== 2-of-3 服务器参与选项(新增功能)========== + // 新增日期:2026-01-27 + // 新增原因:允许 2-of-3 用户在丢失一个设备时,通过服务器参与签名转出资产 + // 影响范围:纯新增,不影响现有 initiateSignSession + // 回滚方法:删除此方法及相关 UI 代码即可恢复 + + /** + * 创建签名会话(支持选择服务器参与) + * + * 新增方法,不修改现有 initiateSignSession + * 仅在 UI 层判断为 2-of-3 且用户主动勾选时调用此方法 + * + * @param shareId 钱包 ID + * @param password 钱包密码 + * @param initiatorName 发起者名称 + * @param includeServerBackup 是否包含服务器备份参与方(新增参数) + * + * 使用场景: + * - 2-of-3 用户丢失一个设备 + * - 用户勾选"包含服务器备份"选项 + * - 使用剩余设备 + 服务器完成签名 + * + * 安全保障: + * - UI 层限制仅 2-of-3 显示此选项 + * - 用户主动明确选择 + * - 服务器只有 1 个 key < t=2 + */ + fun initiateSignSessionWithOptions( + shareId: Long, + password: String, + initiatorName: String = "发起者", + includeServerBackup: Boolean = false // 新增参数 + ) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + val tx = _preparedTx.value + if (tx == null) { + _uiState.update { it.copy(isLoading = false, error = "交易未准备") } + return@launch + } + + android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup") + + // 调用新的 repository 方法 + val result = repository.createSignSessionWithOptions( + shareId = shareId, + messageHash = tx.signHash, + password = password, + initiatorName = initiatorName, + includeServerBackup = includeServerBackup // 传递新参数 + ) + + result.fold( + onSuccess = { sessionResult -> + _signSessionId.value = sessionResult.sessionId + _signInviteCode.value = sessionResult.inviteCode + _signParticipants.value = listOf(initiatorName) + _uiState.update { it.copy(isLoading = false) } + + pendingSignInitiatorInfo = PendingSignInitiatorInfo( + sessionId = sessionResult.sessionId, + shareId = shareId, + password = password + ) + + android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Sign session created with server=${includeServerBackup}, sessionId=${sessionResult.sessionId}") + + if (sessionResult.sessionAlreadyInProgress) { + android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Session already in_progress, triggering sign immediately") + startSigningProcess(sessionResult.sessionId, shareId, password) + } + }, + onFailure = { e -> + android.util.Log.e("MainViewModel", "[SIGN-OPTIONS] Failed to create sign session: ${e.message}") + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + ) + } + } + // ========== 2-of-3 服务器参与选项结束 ========== + /** * Start sign as initiator (called when session_started event is received) * Matches Electron's handleCoSignStart for initiator