feat(android): 实现 2-of-3 钱包服务器备份参与签名功能

目的:允许 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-26 17:32:36 -08:00
parent 0b22928d9a
commit 0eea1815ae
5 changed files with 643 additions and 7 deletions

View File

@ -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.kt2处新增
#### 1.1 新增辅助方法private
```kotlin
// 位置3712行之前类内部末尾
/**
* 构建参与方列表(新增辅助方法)
* @param participants 所有参与方
* @param includeServerParties 是否包含服务器方(默认 false保持现有行为
*/
private fun buildSigningParticipantList(
participants: List<ParticipantStatusInfo>,
includeServerParties: Boolean = false
): List<Pair<String, Int>> {
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<SignSessionResult> {
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.kt1处新增
```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.ktUI 新增)
```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
)
}
```
## 测试场景
### 场景12-of-3 正常使用(不勾选)
- 设备A + 设备B 签名 ✅
- 服务器被过滤(现有行为)
### 场景22-of-3 设备丢失(勾选)
- 设备A + 服务器 签名 ✅
- 用户明确勾选"包含服务器备份"
### 场景33-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. 测试编译和功能

View File

@ -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) }

View File

@ -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<ParticipantStatusInfo>,
includeServerParties: Boolean = false
): List<Pair<String, Int>> {
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<SignSessionResult> {
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(

View File

@ -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(

View File

@ -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