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:
parent
0b22928d9a
commit
0eea1815ae
|
|
@ -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<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.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. 测试编译和功能
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue