feat(android): add Android TSS Party app with full API implementation

Major changes:
- Add complete Android app (service-party-android) with Jetpack Compose UI
- Implement real account-service API calls for keygen and sign sessions:
  - POST /api/v1/co-managed/sessions (create keygen session)
  - GET /api/v1/co-managed/sessions/by-invite-code/{code} (validate invite)
  - POST /api/v1/co-managed/sessions/{id}/join (join keygen session)
  - POST /api/v1/co-managed/sign (create sign session)
  - GET /api/v1/co-managed/sign/by-invite-code/{code} (validate sign invite)
  - POST /api/v1/co-managed/sign/{id}/join (join sign session)
- Add QR code generation and scanning for session invites
- Remove password requirement (use empty string)
- Add floating action button for wallet creation
- Add network type aware explorer links (mainnet/testnet)

Network configuration:
- Change default network to Kava mainnet for both Electron and Android apps
- Electron: main.ts, transaction.ts, Settings.tsx, Layout.tsx
- Android: Models.kt (NetworkType.MAINNET default)

Features:
- Full TSS keygen and sign protocol via gomobile bindings
- gRPC message routing for multi-party communication
- Cross-platform compatibility with service-party-app (Electron)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-31 23:27:29 -08:00
parent ff995a827b
commit 7b6d6de801
56 changed files with 12119 additions and 5 deletions

View File

@ -0,0 +1,96 @@
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
app/build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
.idea/caches
.idea/modules.xml
.idea/misc.xml
.idea/vcs.xml
# Keystore files (DO NOT COMMIT production keystores)
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# Kotlin
.kotlin/
# OS-specific files
.DS_Store
Thumbs.db
*.swp
*~
# Signing configs - don't commit
signing.properties
keystore.properties

View File

@ -0,0 +1,101 @@
# TSS Party Android
Android 版本的 TSS (Threshold Signature Scheme) Party 应用,用于多方共管钱包的密钥生成和签名。
## 项目结构
```
service-party-android/
├── app/ # Android 应用模块
│ ├── src/main/
│ │ ├── java/com/durian/tssparty/
│ │ │ ├── data/ # 数据层
│ │ │ │ ├── local/ # 本地存储 (Room, TSS Bridge)
│ │ │ │ ├── remote/ # 远程通信 (gRPC)
│ │ │ │ └── repository/ # 数据仓库
│ │ │ ├── domain/model/ # 领域模型
│ │ │ ├── presentation/ # UI 层
│ │ │ │ ├── screens/ # Compose 屏幕
│ │ │ │ └── viewmodel/ # ViewModels
│ │ │ ├── di/ # Hilt 依赖注入
│ │ │ ├── ui/theme/ # Material Theme
│ │ │ └── util/ # 工具类
│ │ ├── proto/ # gRPC Proto 文件
│ │ └── res/ # Android 资源
│ └── libs/ # TSS 原生库 (.aar)
├── tsslib/ # Go TSS 库源码
│ ├── tsslib.go # gomobile 绑定
│ ├── go.mod
│ ├── build.sh # Linux/macOS 构建脚本
│ └── build.bat # Windows 构建脚本
└── gradle/ # Gradle Wrapper
```
## 技术栈
- **UI**: Jetpack Compose + Material 3
- **架构**: MVVM + Repository Pattern
- **依赖注入**: Hilt
- **数据库**: Room
- **网络**: gRPC (protobuf-lite)
- **TSS 核心**: Go + gomobile (BnB Chain tss-lib v2)
## 构建步骤
### 1. 构建 TSS 原生库 (可选,需要 Go 环境)
```bash
# 安装 gomobile
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
# 构建 Android AAR
cd tsslib
./build.sh # Linux/macOS
# 或
build.bat # Windows
```
这将在 `app/libs/` 生成 `tsslib.aar`
> **注意**: 当前版本使用 Kotlin stub 实现,无需编译 Go 库即可构建 APK。
> 实际运行需要真正的 `tsslib.aar`
### 2. 构建 APK
```bash
# Debug 版本
./gradlew assembleDebug
# Release 版本 (需要签名配置)
./gradlew assembleRelease
```
APK 输出路径: `app/build/outputs/apk/debug/app-debug.apk`
## 功能
1. **加入 Keygen 会话** - 扫描/输入邀请码,参与多方密钥生成
2. **查看钱包** - 显示已创建的共管钱包列表
3. **签名交易** - 使用密钥份额参与多方签名
4. **设置** - 配置 Message Router 服务器地址
## 配置
默认服务器配置:
- Message Router: `localhost:50051`
- Kava RPC: `https://evm.kava.io`
## 与 Electron 版本的对应关系
| Electron 版本 | Android 版本 |
|---------------|--------------|
| `electron/main.ts` | `TssNativeBridge.kt` + `GrpcClient.kt` |
| `electron/preload.ts` | `TssRepository.kt` |
| `src/pages/*.tsx` | `presentation/screens/*.kt` |
| `tss-party/` (Go 子进程) | `tsslib/` (gomobile .aar) |
| sql.js | Room Database |
## 许可证
MIT

View File

@ -0,0 +1,185 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.protobuf")
kotlin("kapt")
}
android {
namespace = "com.durian.tssparty"
compileSdk = 34
defaultConfig {
applicationId = "com.durian.tssparty"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
// NDK configuration for TSS native library
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
}
signingConfigs {
create("release") {
// Use debug keystore for now - replace with production keystore for real release
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
release {
isMinifyEnabled = false // Disable minification for easier debugging
isShrinkResources = false
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.6"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
sourceSets {
getByName("main") {
// Include the compiled TSS .aar library
jniLibs.srcDirs("libs")
}
}
}
// Protobuf configuration for gRPC
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.1"
}
plugins {
create("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:1.60.0"
}
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
task.plugins {
create("grpc") {
option("lite")
}
}
}
}
}
dependencies {
// TSS Library (gomobile generated)
implementation(files("libs/tsslib.aar"))
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.7.6")
// Hilt DI
implementation("com.google.dagger:hilt-android:2.48.1")
kapt("com.google.dagger:hilt-android-compiler:2.48.1")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Room Database
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// gRPC
implementation("io.grpc:grpc-okhttp:1.60.0")
implementation("io.grpc:grpc-protobuf-lite:1.60.0")
implementation("io.grpc:grpc-stub:1.60.0")
implementation("io.grpc:grpc-kotlin-stub:1.4.1")
implementation("com.google.protobuf:protobuf-kotlin-lite:3.25.1")
// Networking
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// JSON
implementation("com.google.code.gson:gson:2.10.1")
// QR Code
implementation("com.google.zxing:core:3.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
// Crypto
implementation("org.bouncycastle:bcprov-jdk18on:1.77")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
kapt {
correctErrorTypes = true
}

View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# Keep gRPC classes
-keep class io.grpc.** { *; }
-keep class com.google.protobuf.** { *; }
-keep class com.durian.tssparty.grpc.** { *; }
# Keep tsslib (gomobile generated)
-keep class tsslib.** { *; }
# Keep Hilt generated classes
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
# Keep Room entities
-keep class com.durian.tssparty.data.local.** { *; }
# Gson
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.durian.tssparty.domain.model.** { *; }

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Camera permission for QR code scanning (optional) -->
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".TssPartyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TssParty"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.TssParty">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,396 @@
package com.durian.tssparty
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.durian.tssparty.domain.model.AppReadyState
import com.durian.tssparty.presentation.components.BottomNavItem
import com.durian.tssparty.presentation.components.TssBottomNavigation
import com.durian.tssparty.presentation.screens.*
import com.durian.tssparty.presentation.viewmodel.MainViewModel
import com.durian.tssparty.presentation.viewmodel.ConnectionTestResult as ViewModelConnectionTestResult
import com.durian.tssparty.ui.theme.TssPartyTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TssPartyTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
TssPartyApp(
onCopyToClipboard = { text ->
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("邀请码", text)
clipboard.setPrimaryClip(clip)
Toast.makeText(this, "邀请码已复制", Toast.LENGTH_SHORT).show()
}
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TssPartyApp(
viewModel: MainViewModel = hiltViewModel(),
onCopyToClipboard: (String) -> Unit = {}
) {
val navController = rememberNavController()
val appState by viewModel.appState.collectAsState()
val uiState by viewModel.uiState.collectAsState()
val shares by viewModel.shares.collectAsState()
val sessionStatus by viewModel.sessionStatus.collectAsState()
val settings by viewModel.settings.collectAsState()
val createdInviteCode by viewModel.createdInviteCode.collectAsState()
val balances by viewModel.balances.collectAsState()
val currentSessionId by viewModel.currentSessionId.collectAsState()
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
val currentRound by viewModel.currentRound.collectAsState()
val publicKey by viewModel.publicKey.collectAsState()
// Transfer state
val preparedTx by viewModel.preparedTx.collectAsState()
val signSessionId by viewModel.signSessionId.collectAsState()
val signInviteCode by viewModel.signInviteCode.collectAsState()
val signParticipants by viewModel.signParticipants.collectAsState()
val signCurrentRound by viewModel.signCurrentRound.collectAsState()
val signature by viewModel.signature.collectAsState()
val txHash by viewModel.txHash.collectAsState()
// Join keygen state
val joinSessionInfo by viewModel.joinSessionInfo.collectAsState()
val joinKeygenParticipants by viewModel.joinKeygenParticipants.collectAsState()
val joinKeygenRound by viewModel.joinKeygenRound.collectAsState()
val joinKeygenPublicKey by viewModel.joinKeygenPublicKey.collectAsState()
// CoSign state
val coSignSessionInfo by viewModel.coSignSessionInfo.collectAsState()
val coSignParticipants by viewModel.coSignParticipants.collectAsState()
val coSignRound by viewModel.coSignRound.collectAsState()
val coSignSignature by viewModel.coSignSignature.collectAsState()
// Settings test connection results
val messageRouterTestResult by viewModel.messageRouterTestResult.collectAsState()
val accountServiceTestResult by viewModel.accountServiceTestResult.collectAsState()
val kavaApiTestResult by viewModel.kavaApiTestResult.collectAsState()
// Current transfer wallet
var transferWalletId by remember { mutableStateOf<Long?>(null) }
// Track if startup is complete
var startupComplete by remember { mutableStateOf(false) }
// Handle success messages
LaunchedEffect(uiState.successMessage) {
if (uiState.successMessage != null) {
// Navigate back to wallets on success
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
viewModel.clearSuccess()
viewModel.clearCreatedInviteCode()
}
}
// Show startup check screen if not complete
if (!startupComplete) {
StartupCheckScreen(
appState = appState,
onEnterApp = { startupComplete = true },
onRetry = { viewModel.checkAllServices() }
)
return
}
// Main app with bottom navigation
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: BottomNavItem.Wallets.route
Scaffold(
bottomBar = {
TssBottomNavigation(
currentRoute = currentRoute,
onNavigate = { item ->
navController.navigate(item.route) {
// Pop up to the start destination to avoid building up a large stack
popUpTo(BottomNavItem.Wallets.route) {
saveState = true
}
// Avoid multiple copies of the same destination
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = BottomNavItem.Wallets.route,
modifier = Modifier.padding(paddingValues)
) {
// Tab 1: My Wallets (我的钱包)
composable(BottomNavItem.Wallets.route) {
// Fetch balances when entering wallets screen
LaunchedEffect(shares) {
viewModel.fetchAllBalances()
}
WalletsScreen(
shares = shares,
isConnected = uiState.isConnected,
balances = balances,
onDeleteShare = { viewModel.deleteShare(it) },
onRefreshBalance = { address -> viewModel.fetchBalance(address) },
onTransfer = { shareId, toAddress, amount, password ->
transferWalletId = shareId
viewModel.prepareTransfer(shareId, toAddress, amount)
navController.navigate("transfer/$shareId")
},
onCreateWallet = {
navController.navigate(BottomNavItem.Create.route)
}
)
}
// Transfer Screen
composable("transfer/{shareId}") { backStackEntry ->
val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull()
val wallet = shareId?.let { viewModel.getWalletById(it) }
if (wallet != null) {
TransferScreen(
wallet = wallet,
balance = balances[wallet.address],
sessionStatus = sessionStatus,
participants = signParticipants,
currentRound = signCurrentRound,
totalRounds = 9,
preparedTx = preparedTx,
signSessionId = signSessionId,
inviteCode = signInviteCode,
signature = signature,
txHash = txHash,
isLoading = uiState.isLoading,
error = uiState.error,
networkType = settings.networkType,
onPrepareTransaction = { toAddress, amount ->
viewModel.prepareTransfer(shareId, toAddress, amount)
},
onConfirmTransaction = { password ->
viewModel.initiateSignSession(shareId, password)
},
onCopyInviteCode = {
signInviteCode?.let { onCopyToClipboard(it) }
},
onBroadcastTransaction = {
viewModel.broadcastTransaction()
},
onCancel = {
viewModel.resetTransferState()
viewModel.clearError()
navController.popBackStack()
},
onBackToWallets = {
viewModel.resetTransferState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
}
)
}
}
// Tab 2: Create Wallet (创建钱包)
composable(BottomNavItem.Create.route) {
CreateWalletScreen(
isLoading = uiState.isLoading,
error = uiState.error,
inviteCode = createdInviteCode,
sessionId = currentSessionId,
sessionStatus = sessionStatus,
participants = sessionParticipants,
currentRound = currentRound,
totalRounds = 9,
publicKey = publicKey,
onCreateSession = { name, t, n, participantName ->
viewModel.createKeygenSession(name, t, n, participantName)
},
onCopyInviteCode = {
createdInviteCode?.let { onCopyToClipboard(it) }
},
onEnterSession = {
viewModel.enterSession()
},
onCancel = {
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetSessionState()
},
onBackToHome = {
viewModel.resetSessionState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
}
)
}
// Tab 3: Join Keygen (加入创建)
composable(BottomNavItem.JoinKeygen.route) {
// Convert JoinKeygenSessionInfo to JoinSessionInfo for the screen
val screenSessionInfo = joinSessionInfo?.let {
JoinSessionInfo(
sessionId = it.sessionId,
walletName = it.walletName,
thresholdT = it.thresholdT,
thresholdN = it.thresholdN,
initiator = it.initiator,
currentParticipants = it.currentParticipants,
totalParticipants = it.totalParticipants
)
}
JoinKeygenScreen(
sessionStatus = sessionStatus,
isLoading = uiState.isLoading,
error = uiState.error,
sessionInfo = screenSessionInfo,
participants = joinKeygenParticipants,
currentRound = joinKeygenRound,
totalRounds = 9,
publicKey = joinKeygenPublicKey,
onValidateInviteCode = { inviteCode ->
viewModel.validateInviteCode(inviteCode)
},
onJoinKeygen = { inviteCode, password ->
viewModel.joinKeygen(inviteCode, password)
},
onCancel = {
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetJoinKeygenState()
},
onBackToHome = {
viewModel.resetJoinKeygenState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
}
)
}
// Tab 4: Co-Sign (参与签名)
composable(BottomNavItem.CoSign.route) {
// Convert CoSignSessionInfo to SignSessionInfo for the screen
val screenSignSessionInfo = coSignSessionInfo?.let {
SignSessionInfo(
sessionId = it.sessionId,
keygenSessionId = it.keygenSessionId,
walletName = it.walletName,
messageHash = it.messageHash,
thresholdT = it.thresholdT,
thresholdN = it.thresholdN,
currentParticipants = it.currentParticipants
)
}
CoSignJoinScreen(
shares = shares,
sessionStatus = sessionStatus,
isLoading = uiState.isLoading,
error = uiState.error,
signSessionInfo = screenSignSessionInfo,
participants = coSignParticipants,
currentRound = coSignRound,
totalRounds = 9,
signature = coSignSignature,
onValidateInviteCode = { inviteCode ->
viewModel.validateSignInviteCode(inviteCode)
},
onJoinSign = { inviteCode, shareId, password ->
viewModel.joinSign(inviteCode, shareId, password)
},
onCancel = {
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetCoSignState()
},
onBackToHome = {
viewModel.resetCoSignState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
}
)
}
// Tab 5: Settings (设置)
composable(BottomNavItem.Settings.route) {
// Convert ViewModel ConnectionTestResult to Screen ConnectionTestResult
val screenMessageRouterStatus: ConnectionTestResult? = messageRouterTestResult?.let {
ConnectionTestResult(
success = it.success,
message = it.message,
latency = it.latency
)
}
val screenAccountServiceStatus: ConnectionTestResult? = accountServiceTestResult?.let {
ConnectionTestResult(
success = it.success,
message = it.message,
latency = it.latency
)
}
val screenKavaApiStatus: ConnectionTestResult? = kavaApiTestResult?.let {
ConnectionTestResult(
success = it.success,
message = it.message,
latency = it.latency
)
}
SettingsScreen(
settings = settings,
isConnected = uiState.isConnected,
messageRouterStatus = screenMessageRouterStatus,
accountServiceStatus = screenAccountServiceStatus,
kavaApiStatus = screenKavaApiStatus,
onSaveSettings = { newSettings ->
viewModel.updateSettings(newSettings)
},
onTestMessageRouter = { url ->
viewModel.testMessageRouter(url)
},
onTestAccountService = { url ->
viewModel.testAccountService(url)
},
onTestKavaApi = { url ->
viewModel.testKavaApi(url)
}
)
}
}
}
}

View File

@ -0,0 +1,7 @@
package com.durian.tssparty
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TssPartyApplication : Application()

View File

@ -0,0 +1,79 @@
package com.durian.tssparty.data.local
import androidx.room.*
import kotlinx.coroutines.flow.Flow
/**
* Entity for storing TSS share records
*/
@Entity(tableName = "share_records")
data class ShareRecordEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "session_id")
val sessionId: String,
@ColumnInfo(name = "public_key")
val publicKey: String,
@ColumnInfo(name = "encrypted_share")
val encryptedShare: String,
@ColumnInfo(name = "threshold_t")
val thresholdT: Int,
@ColumnInfo(name = "threshold_n")
val thresholdN: Int,
@ColumnInfo(name = "party_index")
val partyIndex: Int,
@ColumnInfo(name = "address")
val address: String,
@ColumnInfo(name = "created_at")
val createdAt: Long = System.currentTimeMillis()
)
/**
* DAO for share records
*/
@Dao
interface ShareRecordDao {
@Query("SELECT * FROM share_records ORDER BY created_at DESC")
fun getAllShares(): Flow<List<ShareRecordEntity>>
@Query("SELECT * FROM share_records WHERE id = :id")
suspend fun getShareById(id: Long): ShareRecordEntity?
@Query("SELECT * FROM share_records WHERE session_id = :sessionId")
suspend fun getShareBySessionId(sessionId: String): ShareRecordEntity?
@Query("SELECT * FROM share_records WHERE address = :address")
suspend fun getShareByAddress(address: String): ShareRecordEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertShare(share: ShareRecordEntity): Long
@Delete
suspend fun deleteShare(share: ShareRecordEntity)
@Query("DELETE FROM share_records WHERE id = :id")
suspend fun deleteShareById(id: Long)
@Query("SELECT COUNT(*) FROM share_records")
suspend fun getShareCount(): Int
}
/**
* Room database
*/
@Database(
entities = [ShareRecordEntity::class],
version = 1,
exportSchema = false
)
abstract class TssDatabase : RoomDatabase() {
abstract fun shareRecordDao(): ShareRecordDao
}

View File

@ -0,0 +1,172 @@
package com.durian.tssparty.data.local
import com.durian.tssparty.domain.model.KeygenResult
import com.durian.tssparty.domain.model.Participant
import com.durian.tssparty.domain.model.SignResult
import com.durian.tssparty.domain.model.TssOutgoingMessage
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.withContext
import tsslib.MessageCallback
import tsslib.Tsslib
import javax.inject.Inject
import javax.inject.Singleton
/**
* Bridge between Kotlin and Go TSS library via gomobile bindings
*/
@Singleton
class TssNativeBridge @Inject constructor(
private val gson: Gson
) {
private val _outgoingMessages = Channel<TssOutgoingMessage>(Channel.BUFFERED)
val outgoingMessages: Flow<TssOutgoingMessage> = _outgoingMessages.receiveAsFlow()
private val _progress = Channel<Pair<Int, Int>>(Channel.BUFFERED)
val progress: Flow<Pair<Int, Int>> = _progress.receiveAsFlow()
private val _errors = Channel<String>(Channel.BUFFERED)
val errors: Flow<String> = _errors.receiveAsFlow()
private val _logs = Channel<String>(Channel.BUFFERED)
val logs: Flow<String> = _logs.receiveAsFlow()
private val callback = object : MessageCallback {
override fun onOutgoingMessage(messageJSON: String) {
try {
val message = gson.fromJson(messageJSON, TssOutgoingMessage::class.java)
_outgoingMessages.trySend(message)
} catch (e: Exception) {
_errors.trySend("Failed to parse outgoing message: ${e.message}")
}
}
override fun onProgress(round: Long, totalRounds: Long) {
_progress.trySend(Pair(round.toInt(), totalRounds.toInt()))
}
override fun onError(errorMessage: String) {
_errors.trySend(errorMessage)
}
override fun onLog(message: String) {
_logs.trySend(message)
}
}
/**
* Start a keygen session
*/
suspend fun startKeygen(
sessionId: String,
partyId: String,
partyIndex: Int,
thresholdT: Int,
thresholdN: Int,
participants: List<Participant>,
password: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val participantsJson = gson.toJson(participants)
Tsslib.startKeygen(
sessionId,
partyId,
partyIndex.toLong(),
thresholdT.toLong(),
thresholdN.toLong(),
participantsJson,
password,
callback
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Start a sign session
*/
suspend fun startSign(
sessionId: String,
partyId: String,
partyIndex: Int,
thresholdT: Int,
thresholdN: Int,
participants: List<Participant>,
messageHash: String,
shareData: String,
password: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val participantsJson = gson.toJson(participants)
Tsslib.startSign(
sessionId,
partyId,
partyIndex.toLong(),
thresholdT.toLong(),
thresholdN.toLong(),
participantsJson,
messageHash,
shareData,
password,
callback
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Send incoming message from another party
*/
suspend fun sendIncomingMessage(
fromPartyIndex: Int,
isBroadcast: Boolean,
payload: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
Tsslib.sendIncomingMessage(fromPartyIndex.toLong(), isBroadcast, payload)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Wait for keygen result
*/
suspend fun waitForKeygenResult(password: String): Result<KeygenResult> = withContext(Dispatchers.IO) {
try {
val resultJson = Tsslib.waitForKeygenResult(password)
val result = gson.fromJson(resultJson, KeygenResult::class.java)
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Wait for sign result
*/
suspend fun waitForSignResult(): Result<SignResult> = withContext(Dispatchers.IO) {
try {
val resultJson = Tsslib.waitForSignResult()
val result = gson.fromJson(resultJson, SignResult::class.java)
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Cancel current session
*/
fun cancelSession() {
Tsslib.cancelSession()
}
}

View File

@ -0,0 +1,334 @@
package com.durian.tssparty.data.remote
import android.util.Base64
import com.durian.tssparty.domain.model.Participant
import com.durian.tssparty.grpc.*
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import io.grpc.stub.StreamObserver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* gRPC client for Message Router service
*/
@Singleton
class GrpcClient @Inject constructor() {
private var channel: ManagedChannel? = null
private var stub: MessageRouterGrpc.MessageRouterBlockingStub? = null
private var asyncStub: MessageRouterGrpc.MessageRouterStub? = null
/**
* Connect to the Message Router server
*/
fun connect(host: String, port: Int) {
disconnect()
channel = ManagedChannelBuilder
.forAddress(host, port)
.usePlaintext() // TODO: Use TLS in production
.keepAliveTime(30, TimeUnit.SECONDS)
.keepAliveTimeout(10, TimeUnit.SECONDS)
.build()
stub = MessageRouterGrpc.newBlockingStub(channel)
asyncStub = MessageRouterGrpc.newStub(channel)
}
/**
* Disconnect from the server
*/
fun disconnect() {
channel?.shutdown()
channel = null
stub = null
asyncStub = null
}
/**
* Register party with the router
*/
suspend fun registerParty(
partyId: String,
partyRole: String = "temporary",
version: String = "1.0.0"
): Result<Boolean> = withContext(Dispatchers.IO) {
try {
val request = RegisterPartyRequest.newBuilder()
.setPartyId(partyId)
.setPartyRole(partyRole)
.setVersion(version)
.build()
val response = stub?.registerParty(request)
Result.success(response?.success ?: false)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Join a session
*/
suspend fun joinSession(
sessionId: String,
partyId: String,
joinToken: String
): Result<JoinSessionData> = withContext(Dispatchers.IO) {
try {
val deviceInfo = DeviceInfo.newBuilder()
.setDeviceType("mobile")
.setDeviceId(partyId)
.setPlatform("android")
.setAppVersion("1.0.0")
.build()
val request = JoinSessionRequest.newBuilder()
.setSessionId(sessionId)
.setPartyId(partyId)
.setJoinToken(joinToken)
.setDeviceInfo(deviceInfo)
.build()
val response = stub?.joinSession(request)
if (response?.success == true) {
val sessionInfo = response.sessionInfo
val participants = response.otherPartiesList.map { party ->
Participant(party.partyId, party.partyIndex)
}
Result.success(
JoinSessionData(
sessionId = sessionInfo.sessionId,
sessionType = sessionInfo.sessionType,
thresholdN = sessionInfo.thresholdN,
thresholdT = sessionInfo.thresholdT,
partyIndex = response.partyIndex,
participants = participants,
messageHash = if (sessionInfo.messageHash.isEmpty) null
else Base64.encodeToString(sessionInfo.messageHash.toByteArray(), Base64.NO_WRAP)
)
)
} else {
Result.failure(Exception("Failed to join session"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Mark party as ready
*/
suspend fun markPartyReady(
sessionId: String,
partyId: String
): Result<Boolean> = withContext(Dispatchers.IO) {
try {
val request = MarkPartyReadyRequest.newBuilder()
.setSessionId(sessionId)
.setPartyId(partyId)
.build()
val response = stub?.markPartyReady(request)
Result.success(response?.allReady ?: false)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Route a message to other parties
*/
suspend fun routeMessage(
sessionId: String,
fromParty: String,
toParties: List<String>,
roundNumber: Int,
messageType: String,
payload: ByteArray
): Result<String> = withContext(Dispatchers.IO) {
try {
val request = RouteMessageRequest.newBuilder()
.setSessionId(sessionId)
.setFromParty(fromParty)
.addAllToParties(toParties)
.setRoundNumber(roundNumber)
.setMessageType(messageType)
.setPayload(com.google.protobuf.ByteString.copyFrom(payload))
.build()
val response = stub?.routeMessage(request)
if (response?.success == true) {
Result.success(response.messageId)
} else {
Result.failure(Exception("Failed to route message"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Subscribe to messages for a party
*/
fun subscribeMessages(sessionId: String, partyId: String): Flow<IncomingMessage> = callbackFlow {
val request = SubscribeMessagesRequest.newBuilder()
.setSessionId(sessionId)
.setPartyId(partyId)
.build()
val observer = object : StreamObserver<MPCMessage> {
override fun onNext(message: MPCMessage) {
val incoming = IncomingMessage(
messageId = message.messageId,
fromParty = message.fromParty,
isBroadcast = message.isBroadcast,
roundNumber = message.roundNumber,
payload = Base64.encodeToString(message.payload.toByteArray(), Base64.NO_WRAP)
)
trySend(incoming)
}
override fun onError(t: Throwable) {
close(t)
}
override fun onCompleted() {
close()
}
}
asyncStub?.subscribeMessages(request, observer)
awaitClose { }
}
/**
* Subscribe to session events
*/
fun subscribeSessionEvents(partyId: String): Flow<SessionEventData> = callbackFlow {
val request = SubscribeSessionEventsRequest.newBuilder()
.setPartyId(partyId)
.build()
val observer = object : StreamObserver<SessionEvent> {
override fun onNext(event: SessionEvent) {
val eventData = SessionEventData(
eventId = event.eventId,
eventType = event.eventType,
sessionId = event.sessionId,
thresholdN = event.thresholdN,
thresholdT = event.thresholdT,
selectedParties = event.selectedPartiesList,
joinTokens = event.joinTokensMap,
messageHash = if (event.messageHash.isEmpty) null
else Base64.encodeToString(event.messageHash.toByteArray(), Base64.NO_WRAP)
)
trySend(eventData)
}
override fun onError(t: Throwable) {
close(t)
}
override fun onCompleted() {
close()
}
}
asyncStub?.subscribeSessionEvents(request, observer)
awaitClose { }
}
/**
* Report completion
*/
suspend fun reportCompletion(
sessionId: String,
partyId: String,
publicKey: ByteArray? = null,
signature: ByteArray? = null
): Result<Boolean> = withContext(Dispatchers.IO) {
try {
val builder = ReportCompletionRequest.newBuilder()
.setSessionId(sessionId)
.setPartyId(partyId)
publicKey?.let {
builder.setPublicKey(com.google.protobuf.ByteString.copyFrom(it))
}
signature?.let {
builder.setSignature(com.google.protobuf.ByteString.copyFrom(it))
}
val response = stub?.reportCompletion(builder.build())
Result.success(response?.allCompleted ?: false)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Send heartbeat
*/
suspend fun heartbeat(partyId: String): Result<Int> = withContext(Dispatchers.IO) {
try {
val request = HeartbeatRequest.newBuilder()
.setPartyId(partyId)
.setTimestamp(System.currentTimeMillis())
.build()
val response = stub?.heartbeat(request)
Result.success(response?.pendingMessages ?: 0)
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* Data class for join session response
*/
data class JoinSessionData(
val sessionId: String,
val sessionType: String,
val thresholdN: Int,
val thresholdT: Int,
val partyIndex: Int,
val participants: List<Participant>,
val messageHash: String?
)
/**
* Data class for incoming MPC message
*/
data class IncomingMessage(
val messageId: String,
val fromParty: String,
val isBroadcast: Boolean,
val roundNumber: Int,
val payload: String // base64 encoded
)
/**
* Data class for session event
*/
data class SessionEventData(
val eventId: String,
val eventType: String,
val sessionId: String,
val thresholdN: Int,
val thresholdT: Int,
val selectedParties: List<String>,
val joinTokens: Map<String, String>,
val messageHash: String?
)

View File

@ -0,0 +1,66 @@
package com.durian.tssparty.di
import android.content.Context
import androidx.room.Room
import com.durian.tssparty.data.local.ShareRecordDao
import com.durian.tssparty.data.local.TssDatabase
import com.durian.tssparty.data.local.TssNativeBridge
import com.durian.tssparty.data.remote.GrpcClient
import com.durian.tssparty.data.repository.TssRepository
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideGson(): Gson {
return GsonBuilder().create()
}
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): TssDatabase {
return Room.databaseBuilder(
context,
TssDatabase::class.java,
"tss_party.db"
).build()
}
@Provides
@Singleton
fun provideShareRecordDao(database: TssDatabase): ShareRecordDao {
return database.shareRecordDao()
}
@Provides
@Singleton
fun provideGrpcClient(): GrpcClient {
return GrpcClient()
}
@Provides
@Singleton
fun provideTssNativeBridge(gson: Gson): TssNativeBridge {
return TssNativeBridge(gson)
}
@Provides
@Singleton
fun provideTssRepository(
grpcClient: GrpcClient,
tssNativeBridge: TssNativeBridge,
shareRecordDao: ShareRecordDao
): TssRepository {
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao)
}
}

View File

@ -0,0 +1,58 @@
package com.durian.tssparty.domain.model
/**
* Application ready state
*/
enum class AppReadyState {
INITIALIZING,
READY,
ERROR
}
/**
* Service check status
*/
data class ServiceStatus(
val isOnline: Boolean = false,
val message: String = "",
val latency: Long? = null
)
/**
* Environment state - tracks all service statuses
*/
data class EnvironmentState(
val database: ServiceStatus = ServiceStatus(),
val messageRouter: ServiceStatus = ServiceStatus(),
val kavaApi: ServiceStatus = ServiceStatus()
)
/**
* Operation progress for keygen/sign
*/
data class OperationProgress(
val isActive: Boolean = false,
val type: OperationType = OperationType.NONE,
val sessionId: String? = null,
val currentRound: Int = 0,
val totalRounds: Int = 0,
val status: String = ""
)
enum class OperationType {
NONE,
KEYGEN,
SIGN
}
/**
* Global app state (similar to Zustand store in Electron version)
*/
data class AppState(
val appReady: AppReadyState = AppReadyState.INITIALIZING,
val appError: String? = null,
val environment: EnvironmentState = EnvironmentState(),
val operation: OperationProgress = OperationProgress(),
val partyId: String? = null,
val walletCount: Int = 0
)

View File

@ -0,0 +1,125 @@
package com.durian.tssparty.domain.model
import com.google.gson.annotations.SerializedName
/**
* Participant in a TSS session
*/
data class Participant(
@SerializedName("partyId")
val partyId: String,
@SerializedName("partyIndex")
val partyIndex: Int,
@SerializedName("name")
val name: String = ""
)
/**
* TSS Session information
*/
data class TssSession(
val sessionId: String,
val sessionType: SessionType,
val thresholdT: Int,
val thresholdN: Int,
val participants: List<Participant>,
val status: SessionStatus,
val inviteCode: String? = null,
val messageHash: String? = null,
val createdAt: Long = System.currentTimeMillis()
)
enum class SessionType {
KEYGEN,
SIGN
}
enum class SessionStatus {
WAITING,
IN_PROGRESS,
COMPLETED,
FAILED
}
/**
* Result of key generation
*/
data class KeygenResult(
@SerializedName("publicKey")
val publicKey: String, // base64 encoded
@SerializedName("encryptedShare")
val encryptedShare: String // base64 encoded
)
/**
* Result of signing
*/
data class SignResult(
@SerializedName("signature")
val signature: String, // base64 encoded (r || s || v, 65 bytes)
@SerializedName("recoveryId")
val recoveryId: Int
)
/**
* Outgoing TSS message
*/
data class TssOutgoingMessage(
@SerializedName("type")
val type: String,
@SerializedName("isBroadcast")
val isBroadcast: Boolean,
@SerializedName("toParties")
val toParties: List<String>?,
@SerializedName("payload")
val payload: String // base64 encoded
)
/**
* Share record stored in local database
*/
data class ShareRecord(
val id: Long = 0,
val sessionId: String,
val publicKey: String,
val encryptedShare: String,
val thresholdT: Int,
val thresholdN: Int,
val partyIndex: Int,
val address: String,
val createdAt: Long = System.currentTimeMillis()
)
/**
* Account balance information
*/
data class AccountBalance(
val address: String,
val balance: String,
val denom: String = "ukava"
)
/**
* Sign session request
*/
data class SignSessionRequest(
val sessionId: String,
val messageHash: String, // hex encoded
val participants: List<Participant>
)
/**
* Settings
* Matches service-party-app settings structure
*/
data class AppSettings(
val messageRouterUrl: String = "mpc-grpc.szaiai.com:443",
val accountServiceUrl: String = "https://rwaapi.szaiai.com",
val kavaRpcUrl: String = "https://evm.kava.io",
val networkType: NetworkType = NetworkType.MAINNET
)
enum class NetworkType {
MAINNET,
TESTNET
}

View File

@ -0,0 +1,85 @@
package com.durian.tssparty.presentation.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Navigation destinations for bottom tabs
*/
sealed class BottomNavItem(
val route: String,
val title: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector
) {
data object Wallets : BottomNavItem(
route = "wallets",
title = "我的钱包",
selectedIcon = Icons.Filled.Lock,
unselectedIcon = Icons.Outlined.Lock
)
data object Create : BottomNavItem(
route = "create",
title = "创建钱包",
selectedIcon = Icons.Filled.Add,
unselectedIcon = Icons.Outlined.Add
)
data object JoinKeygen : BottomNavItem(
route = "join_keygen",
title = "加入创建",
selectedIcon = Icons.Filled.Handshake,
unselectedIcon = Icons.Outlined.Handshake
)
data object CoSign : BottomNavItem(
route = "cosign",
title = "参与签名",
selectedIcon = Icons.Filled.Create,
unselectedIcon = Icons.Outlined.Create
)
data object Settings : BottomNavItem(
route = "settings",
title = "设置",
selectedIcon = Icons.Filled.Settings,
unselectedIcon = Icons.Outlined.Settings
)
}
val bottomNavItems = listOf(
BottomNavItem.Wallets,
BottomNavItem.JoinKeygen,
BottomNavItem.CoSign,
BottomNavItem.Settings
)
@Composable
fun TssBottomNavigation(
currentRoute: String,
onNavigate: (BottomNavItem) -> Unit
) {
NavigationBar {
bottomNavItems.forEach { item ->
val selected = currentRoute == item.route ||
(item == BottomNavItem.Wallets && currentRoute.startsWith("wallet_detail"))
NavigationBarItem(
icon = {
Icon(
imageVector = if (selected) item.selectedIcon else item.unselectedIcon,
contentDescription = item.title
)
},
label = { Text(item.title) },
selected = selected,
onClick = { onNavigate(item) }
)
}
}
}

View File

@ -0,0 +1,865 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
import com.durian.tssparty.domain.model.ShareRecord
/**
* Sign session info returned from validateSignInviteCode API
* Matches service-party-app SignSessionInfo type
*/
data class SignSessionInfo(
val sessionId: String,
val keygenSessionId: String,
val walletName: String,
val messageHash: String,
val thresholdT: Int,
val thresholdN: Int,
val currentParticipants: Int
)
/**
* CoSign Join screen matching service-party-app/src/renderer/src/pages/CoSignJoin.tsx
* 2-step flow: input select_share (auto-join) signing completed
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CoSignJoinScreen(
shares: List<ShareRecord>,
sessionStatus: SessionStatus,
isLoading: Boolean,
error: String?,
signSessionInfo: SignSessionInfo? = null,
participants: List<String> = emptyList(),
currentRound: Int = 0,
totalRounds: Int = 9,
signature: String? = null,
onValidateInviteCode: (inviteCode: String) -> Unit,
onJoinSign: (inviteCode: String, shareId: Long, password: String) -> Unit,
onCancel: () -> Unit,
onBackToHome: () -> Unit = {}
) {
var inviteCode by remember { mutableStateOf("") }
var selectedShareId by remember { mutableStateOf<Long?>(null) }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var validationError by remember { mutableStateOf<String?>(null) }
// 2-step flow: input → select_share → joining → signing → completed
var step by remember { mutableStateOf("input") }
var autoJoinAttempted by remember { mutableStateOf(false) }
// Handle session info received (validation success)
LaunchedEffect(signSessionInfo) {
if (signSessionInfo != null && step == "input") {
step = "select_share"
// Auto-select matching share
val matchingShare = shares.find { it.sessionId == signSessionInfo.keygenSessionId }
if (matchingShare != null) {
selectedShareId = matchingShare.id
} else if (shares.size == 1) {
// Auto-select if only one share
selectedShareId = shares.first().id
}
}
}
// Auto-join when we have session info and selected share
LaunchedEffect(step, signSessionInfo, selectedShareId, autoJoinAttempted, isLoading) {
if (step == "select_share" && signSessionInfo != null &&
selectedShareId != null && !autoJoinAttempted && !isLoading && error == null) {
// Check if we should auto-join (matching share found)
val matchingShare = shares.find { it.sessionId == signSessionInfo.keygenSessionId }
if (matchingShare != null && selectedShareId == matchingShare.id) {
autoJoinAttempted = true
step = "joining"
onJoinSign(inviteCode, selectedShareId!!, password)
}
}
}
// Handle session status changes
LaunchedEffect(sessionStatus) {
when (sessionStatus) {
SessionStatus.IN_PROGRESS -> {
step = "signing"
}
SessionStatus.COMPLETED -> {
step = "completed"
}
SessionStatus.FAILED -> {
if (step == "joining" || step == "signing") {
step = "select_share"
autoJoinAttempted = false
}
}
else -> {}
}
}
// Reset auto-join on error
LaunchedEffect(error) {
if (error != null && (step == "joining" || step == "signing")) {
step = "select_share"
autoJoinAttempted = false
}
}
when (step) {
"input" -> InputScreen(
inviteCode = inviteCode,
isLoading = isLoading,
error = error,
validationError = validationError,
onInviteCodeChange = { inviteCode = it.uppercase() },
onValidateCode = {
when {
inviteCode.isBlank() -> validationError = "请输入邀请码"
else -> {
validationError = null
onValidateInviteCode(inviteCode)
}
}
},
onCancel = onCancel
)
"select_share" -> SelectShareScreen(
shares = shares,
signSessionInfo = signSessionInfo,
selectedShareId = selectedShareId,
password = password,
showPassword = showPassword,
isLoading = isLoading,
error = error,
validationError = validationError,
onShareSelected = { selectedShareId = it },
onPasswordChange = { password = it },
onTogglePassword = { showPassword = !showPassword },
onBack = {
step = "input"
autoJoinAttempted = false
validationError = null
},
onJoinSign = {
when {
selectedShareId == null -> validationError = "请选择一个钱包"
else -> {
validationError = null
autoJoinAttempted = true
step = "joining"
onJoinSign(inviteCode, selectedShareId!!, password)
}
}
}
)
"joining" -> JoiningScreen()
"signing" -> SigningProgressScreen(
sessionStatus = sessionStatus,
participants = participants,
currentRound = currentRound,
totalRounds = totalRounds,
onCancel = onCancel
)
"completed" -> SigningCompletedScreen(
signature = signature,
onBackToHome = onBackToHome
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InputScreen(
inviteCode: String,
isLoading: Boolean,
error: String?,
validationError: String?,
onInviteCodeChange: (String) -> Unit,
onValidateCode: () -> Unit,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header
Text(
text = "加入多方签名",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "输入邀请码加入签名会话",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Invite Code Input
OutlinedTextField(
value = inviteCode,
onValueChange = onInviteCodeChange,
label = { Text("邀请码") },
placeholder = { Text("粘贴邀请码或邀请链接") },
leadingIcon = {
Icon(Icons.Default.QrCode, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
// Error display
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
validationError?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.weight(1f))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text("取消")
}
Button(
onClick = onValidateCode,
modifier = Modifier.weight(1f),
enabled = !isLoading && inviteCode.isNotBlank()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("验证中...")
} else {
Text("下一步")
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectShareScreen(
shares: List<ShareRecord>,
signSessionInfo: SignSessionInfo?,
selectedShareId: Long?,
password: String,
showPassword: Boolean,
isLoading: Boolean,
error: String?,
validationError: String?,
onShareSelected: (Long) -> Unit,
onPasswordChange: (String) -> Unit,
onTogglePassword: () -> Unit,
onBack: () -> Unit,
onJoinSign: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header
Text(
text = "加入多方签名",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(24.dp))
// Session info card
if (signSessionInfo != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "签名会话信息",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
InfoRow("钱包名称", signSessionInfo.walletName)
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("签名阈值", "${signSessionInfo.thresholdT}-of-${signSessionInfo.thresholdN}")
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("消息哈希", "${signSessionInfo.messageHash.take(16)}...")
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("当前参与者", "${signSessionInfo.currentParticipants} / ${signSessionInfo.thresholdT}")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Select Wallet
Text(
text = "选择本地钱包",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
if (shares.isEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "暂无可用钱包",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline
)
Text(
text = "请先创建或加入一个密钥生成会话",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
// Wallet selection cards
shares.forEach { share ->
val isSelected = selectedShareId == share.id
val isMatching = signSessionInfo?.keygenSessionId == share.sessionId
Card(
onClick = { onShareSelected(share.id) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface
),
border = if (isSelected)
CardDefaults.outlinedCardBorder()
else
null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = { onShareSelected(share.id) }
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = share.address.take(10) + "..." + share.address.takeLast(8),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
)
if (isMatching) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.primary,
shape = MaterialTheme.shapes.small
) {
Text(
text = "匹配",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${share.thresholdT}-of-${share.thresholdN} | Party #${share.partyIndex}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isSelected) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Password Input (optional)
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text("钱包密码 (可选)") },
placeholder = { Text("如果设置了密码,请输入") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = onTogglePassword) {
Icon(
imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
// Error display
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
validationError?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.weight(1f))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onBack,
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text("返回")
}
Button(
onClick = onJoinSign,
modifier = Modifier.weight(1f),
enabled = !isLoading && shares.isNotEmpty() && selectedShareId != null
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("加入中...")
} else {
Icon(Icons.Default.Create, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("加入签名")
}
}
}
}
}
@Composable
private fun JoiningScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(80.dp),
strokeWidth = 6.dp
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "正在加入签名会话...",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "请稍候,正在连接到其他签名参与者",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SigningProgressScreen(
sessionStatus: SessionStatus,
participants: List<String>,
currentRound: Int,
totalRounds: Int,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header
Text(
text = "签名进行中",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "请保持应用在前台,直到签名完成",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Progress indicator
CircularProgressIndicator(
modifier = Modifier.size(80.dp),
strokeWidth = 6.dp
)
Spacer(modifier = Modifier.height(24.dp))
// Progress card
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "签名进度",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Text(
text = "轮次 $currentRound / $totalRounds",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = if (totalRounds > 0) currentRound.toFloat() / totalRounds else 0f,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Participants card
if (participants.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "签名参与方 (${participants.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
participants.forEach { participant ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = participant,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.weight(1f))
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Cancel button
OutlinedButton(onClick = onCancel) {
Icon(Icons.Default.Cancel, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("取消")
}
}
}
@Composable
private fun SigningCompletedScreen(
signature: String?,
onBackToHome: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Success icon
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "签名完成!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "多方签名已成功完成",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Signature info
if (signature != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "签名结果",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${signature.take(32)}...${signature.takeLast(32)}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onBackToHome,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Home, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("返回首页")
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}

View File

@ -0,0 +1,989 @@
package com.durian.tssparty.presentation.screens
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
/**
* Create Wallet Screen - matches service-party-app Create.tsx exactly
*
* Flow:
* 1. config - Configuration form (wallet name, threshold, participant name)
* 2. creating - Loading state
* 3. created - Show invite code
* 4. session - Navigate to session page (handled by navigation)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateWalletScreen(
isLoading: Boolean,
error: String?,
inviteCode: String?,
sessionId: String?,
sessionStatus: SessionStatus,
participants: List<String> = emptyList(),
currentRound: Int = 0,
totalRounds: Int = 9,
publicKey: String? = null,
onCreateSession: (walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) -> Unit,
onCopyInviteCode: () -> Unit,
onEnterSession: () -> Unit,
onCancel: () -> Unit,
onBackToHome: () -> Unit
) {
var walletName by remember { mutableStateOf("") }
var thresholdT by remember { mutableIntStateOf(3) }
var thresholdN by remember { mutableIntStateOf(5) }
var participantName by remember { mutableStateOf("") }
var validationError by remember { mutableStateOf<String?>(null) }
// Determine current step based on state
val step = when {
sessionStatus == SessionStatus.IN_PROGRESS || sessionStatus == SessionStatus.COMPLETED || sessionStatus == SessionStatus.FAILED -> "session"
inviteCode != null -> "created"
isLoading -> "creating"
else -> "config"
}
when (step) {
"config" -> ConfigScreen(
walletName = walletName,
onWalletNameChange = { walletName = it },
thresholdT = thresholdT,
onThresholdTChange = { thresholdT = it },
thresholdN = thresholdN,
onThresholdNChange = { thresholdN = it },
participantName = participantName,
onParticipantNameChange = { participantName = it },
error = error ?: validationError,
onCreateSession = {
// Validate inputs
when {
walletName.isBlank() -> validationError = "请输入钱包名称"
participantName.isBlank() -> validationError = "请输入您的名称"
thresholdT < 1 -> validationError = "签名阈值至少为 1"
thresholdN < 2 -> validationError = "参与方总数至少为 2"
thresholdT > thresholdN -> validationError = "签名阈值不能大于参与方总数"
else -> {
validationError = null
onCreateSession(walletName.trim(), thresholdT, thresholdN, participantName.trim())
}
}
},
onCancel = onCancel
)
"creating" -> CreatingScreen()
"created" -> CreatedScreen(
inviteCode = inviteCode!!,
onCopyInviteCode = onCopyInviteCode,
onEnterSession = onEnterSession
)
"session" -> SessionScreen(
walletName = walletName,
sessionId = sessionId ?: "",
sessionStatus = sessionStatus,
participants = participants,
thresholdT = thresholdT,
thresholdN = thresholdN,
currentRound = currentRound,
totalRounds = totalRounds,
publicKey = publicKey,
inviteCode = inviteCode,
onCopyInviteCode = onCopyInviteCode,
onBackToHome = onBackToHome
)
}
}
@Composable
private fun ConfigScreen(
walletName: String,
onWalletNameChange: (String) -> Unit,
thresholdT: Int,
onThresholdTChange: (Int) -> Unit,
thresholdN: Int,
onThresholdNChange: (Int) -> Unit,
participantName: String,
onParticipantNameChange: (String) -> Unit,
error: String?,
onCreateSession: () -> Unit,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header
Text(
text = "创建共管钱包",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "3-of-5 混合托管模式 - 设置钱包参数并邀请参与方",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
// Info Box - Hybrid Custody Mode Explanation
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "混合托管模式说明",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "• 5 个密钥份额2 个平台备份 + 3 个用户持有\n" +
"• 日常签名:仅需 3 个用户参与\n" +
"• 密钥恢复2 个用户可丢失密钥,使用平台备份轮换\n" +
"• 安全保障:平台备份仅用于紧急恢复,不参与日常签名",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Wallet Name Input
OutlinedTextField(
value = walletName,
onValueChange = onWalletNameChange,
label = { Text("钱包名称") },
placeholder = { Text("为您的共管钱包命名") },
leadingIcon = {
Icon(Icons.Default.AccountBalanceWallet, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
// Threshold Configuration
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "阈值设置 (T-of-N)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// Threshold T
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "签名阈值 (T)",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { if (thresholdT > 1) onThresholdTChange(thresholdT - 1) }
) {
Icon(Icons.Default.Remove, contentDescription = "减少")
}
Text(
text = thresholdT.toString(),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
IconButton(
onClick = { if (thresholdT < thresholdN) onThresholdTChange(thresholdT + 1) }
) {
Icon(Icons.Default.Add, contentDescription = "增加")
}
}
}
Text(
text = "of",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Threshold N
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "参与方总数 (N)",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
if (thresholdN > 2) {
onThresholdNChange(thresholdN - 1)
// Cap T if it exceeds new N
if (thresholdT > thresholdN - 1) {
onThresholdTChange(thresholdN - 1)
}
}
}
) {
Icon(Icons.Default.Remove, contentDescription = "减少")
}
Text(
text = thresholdN.toString(),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
IconButton(
onClick = { if (thresholdN < 10) onThresholdNChange(thresholdN + 1) }
) {
Icon(Icons.Default.Add, contentDescription = "增加")
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "需要 $thresholdT 个参与方共同签名才能执行交易 (其中 2 个由平台托管用于备份)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Participant Name Input
OutlinedTextField(
value = participantName,
onValueChange = onParticipantNameChange,
label = { Text("您的名称") },
placeholder = { Text("输入您的名称(其他参与者可见)") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
// Error display
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.weight(1f))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f)
) {
Text("取消")
}
Button(
onClick = onCreateSession,
modifier = Modifier.weight(1f)
) {
Text("创建会话")
}
}
}
}
@Composable
private fun CreatingScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp),
strokeWidth = 3.dp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "正在创建会话...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun CreatedScreen(
inviteCode: String,
onCopyInviteCode: () -> Unit,
onEnterSession: () -> Unit
) {
val clipboardManager = LocalClipboardManager.current
// Generate invite link for QR code
val inviteLink = "tssparty://join/$inviteCode"
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Success Icon
Surface(
modifier = Modifier.size(64.dp),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "会话创建成功",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "分享二维码或邀请码给其他参与方",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// QR Code
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "扫码加入",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
// Generate QR Code
val qrBitmap = remember(inviteCode) {
generateQRCodeBitmap(inviteCode, 240)
}
qrBitmap?.let { bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "邀请二维码",
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.padding(8.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "其他参与方可扫描此二维码加入",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Invite Code Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "邀请码",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// Invite code display
Surface(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = inviteCode,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(12.dp),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(12.dp))
// Copy buttons row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onCopyInviteCode,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("复制邀请码")
}
OutlinedButton(
onClick = {
clipboardManager.setText(AnnotatedString(inviteLink))
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Link,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("复制链接")
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onEnterSession,
modifier = Modifier.fillMaxWidth()
) {
Text("进入会话")
}
}
}
/**
* Generate QR code bitmap
*/
private fun generateQRCodeBitmap(content: String, size: Int): Bitmap? {
return try {
val writer = QRCodeWriter()
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
val width = bitMatrix.width
val height = bitMatrix.height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap.setPixel(x, y, if (bitMatrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
}
}
bitmap
} catch (e: Exception) {
null
}
}
@Composable
private fun SessionScreen(
walletName: String,
sessionId: String,
sessionStatus: SessionStatus,
participants: List<String>,
thresholdT: Int,
thresholdN: Int,
currentRound: Int,
totalRounds: Int,
publicKey: String?,
inviteCode: String?,
onCopyInviteCode: () -> Unit,
onBackToHome: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = walletName.ifEmpty { "共管钱包" },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = sessionId.take(16) + "...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Status Badge
Surface(
color = when (sessionStatus) {
SessionStatus.WAITING -> MaterialTheme.colorScheme.tertiaryContainer
SessionStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primaryContainer
SessionStatus.COMPLETED -> MaterialTheme.colorScheme.primaryContainer
SessionStatus.FAILED -> MaterialTheme.colorScheme.errorContainer
},
shape = MaterialTheme.shapes.small
) {
Text(
text = when (sessionStatus) {
SessionStatus.WAITING -> "等待参与方"
SessionStatus.IN_PROGRESS -> "密钥生成中"
SessionStatus.COMPLETED -> "已完成"
SessionStatus.FAILED -> "失败"
},
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = when (sessionStatus) {
SessionStatus.WAITING -> MaterialTheme.colorScheme.onTertiaryContainer
SessionStatus.IN_PROGRESS -> MaterialTheme.colorScheme.onPrimaryContainer
SessionStatus.COMPLETED -> MaterialTheme.colorScheme.onPrimaryContainer
SessionStatus.FAILED -> MaterialTheme.colorScheme.onErrorContainer
}
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Invite Code with QR (if waiting)
if (sessionStatus == SessionStatus.WAITING && inviteCode != null) {
// QR Code Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "扫码加入",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
// Generate QR Code
val qrBitmap = remember(inviteCode) {
generateQRCodeBitmap(inviteCode, 200)
}
qrBitmap?.let { bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "邀请二维码",
modifier = Modifier
.size(160.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.padding(8.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Invite Code Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "邀请码",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = inviteCode,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
OutlinedButton(onClick = onCopyInviteCode) {
Icon(
Icons.Default.ContentCopy,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("复制")
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
// Progress Bar (if in progress)
if (sessionStatus == SessionStatus.IN_PROGRESS) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "密钥生成进度",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Text(
text = "$currentRound / $totalRounds",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = currentRound.toFloat() / totalRounds,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
// Participants List
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "参与方 (${participants.size} / $thresholdN)",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
// Show joined participants
participants.forEachIndexed { index, name ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "#${index + 1}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = name,
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = if (sessionStatus == SessionStatus.COMPLETED) "" else "",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Show empty slots
for (i in participants.size until thresholdN) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "#${i + 1}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "等待加入...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Threshold Info
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$thresholdT-of-$thresholdN",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "需要 $thresholdT 个参与方共同签名",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Completion Result
if (sessionStatus == SessionStatus.COMPLETED && publicKey != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "钱包公钥",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = publicKey,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "密钥份额已安全保存到本地",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
// Failure Message
if (sessionStatus == SessionStatus.FAILED) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "密钥生成失败,请重试",
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.weight(1f))
// Footer Button
Button(
onClick = onBackToHome,
modifier = Modifier.fillMaxWidth(),
colors = if (sessionStatus == SessionStatus.COMPLETED || sessionStatus == SessionStatus.FAILED) {
ButtonDefaults.buttonColors()
} else {
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
) {
Text("返回首页")
}
}
}

View File

@ -0,0 +1,211 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.ShareRecord
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
shares: List<ShareRecord>,
isConnected: Boolean,
onNavigateToJoin: () -> Unit,
onNavigateToSign: (Long) -> Unit,
onNavigateToSettings: () -> Unit,
onDeleteShare: (Long) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("TSS Party") },
actions = {
// Connection status indicator
Icon(
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = if (isConnected) "Connected" else "Disconnected",
tint = if (isConnected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToJoin) {
Icon(Icons.Default.Add, contentDescription = "Join Session")
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
Text(
text = "My Wallets",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(16.dp))
if (shares.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No wallets yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to join a keygen session",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(shares) { share ->
WalletCard(
share = share,
onSign = { onNavigateToSign(share.id) },
onDelete = { onDeleteShare(share.id) }
)
}
}
}
}
}
}
@Composable
fun WalletCard(
share: ShareRecord,
onSign: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Address",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = share.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${share.thresholdT}-of-${share.thresholdN}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = "Party #${share.partyIndex}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { showDeleteDialog = true }) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Delete")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onSign) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Sign")
}
}
}
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Wallet") },
text = { Text("Are you sure you want to delete this wallet? This action cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
onDelete()
showDeleteDialog = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
}

View File

@ -0,0 +1,727 @@
package com.durian.tssparty.presentation.screens
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
/**
* Session info returned from validateInviteCode API
* Matches service-party-app SessionInfo type
*/
data class JoinSessionInfo(
val sessionId: String,
val walletName: String,
val thresholdT: Int,
val thresholdN: Int,
val initiator: String,
val currentParticipants: Int,
val totalParticipants: Int
)
/**
* JoinKeygen screen matching service-party-app/src/renderer/src/pages/Join.tsx
* Simplified flow without password: input confirm joining
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JoinKeygenScreen(
sessionStatus: SessionStatus,
isLoading: Boolean,
error: String?,
sessionInfo: JoinSessionInfo? = null,
participants: List<String> = emptyList(),
currentRound: Int = 0,
totalRounds: Int = 9,
publicKey: String? = null,
onValidateInviteCode: (inviteCode: String) -> Unit,
onJoinKeygen: (inviteCode: String, password: String) -> Unit,
onCancel: () -> Unit,
onBackToHome: () -> Unit = {}
) {
var inviteCode by remember { mutableStateOf("") }
var validationError by remember { mutableStateOf<String?>(null) }
// 3-step flow: input → confirm → joining
var step by remember { mutableStateOf("input") }
var autoJoinAttempted by remember { mutableStateOf(false) }
// Handle session info received (validation success)
LaunchedEffect(sessionInfo) {
if (sessionInfo != null && step == "input") {
step = "confirm"
}
}
// Auto-join when we have session info (password is empty string)
LaunchedEffect(step, sessionInfo, autoJoinAttempted, isLoading) {
if (step == "confirm" && sessionInfo != null && !autoJoinAttempted && !isLoading && error == null) {
autoJoinAttempted = true
step = "joining"
onJoinKeygen(inviteCode, "") // Empty password
}
}
// Handle session status changes
LaunchedEffect(sessionStatus) {
when (sessionStatus) {
SessionStatus.IN_PROGRESS -> {
step = "progress"
}
SessionStatus.COMPLETED -> {
step = "completed"
}
SessionStatus.FAILED -> {
if (step == "joining") {
step = "confirm"
}
}
else -> {}
}
}
// Reset auto-join on error
LaunchedEffect(error) {
if (error != null && step == "joining") {
step = "confirm"
autoJoinAttempted = false
}
}
when (step) {
"input" -> InputScreen(
inviteCode = inviteCode,
isLoading = isLoading,
error = error,
validationError = validationError,
onInviteCodeChange = { inviteCode = it },
onValidateCode = {
when {
inviteCode.isBlank() -> validationError = "请输入邀请码"
else -> {
validationError = null
onValidateInviteCode(inviteCode)
}
}
},
onCancel = onCancel
)
"confirm" -> ConfirmScreen(
sessionInfo = sessionInfo,
isLoading = isLoading,
error = error,
onBack = {
step = "input"
autoJoinAttempted = false
},
onRetry = {
autoJoinAttempted = false
}
)
"joining" -> JoiningScreen()
"progress" -> KeygenProgressScreen(
sessionStatus = sessionStatus,
participants = participants,
currentRound = currentRound,
totalRounds = totalRounds,
onCancel = onCancel
)
"completed" -> KeygenCompletedScreen(
publicKey = publicKey,
onBackToHome = onBackToHome
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InputScreen(
inviteCode: String,
isLoading: Boolean,
error: String?,
validationError: String?,
onInviteCodeChange: (String) -> Unit,
onValidateCode: () -> Unit,
onCancel: () -> Unit
) {
val context = LocalContext.current
// QR Scanner launcher
val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract()
) { result ->
if (result.contents != null) {
// Parse the scanned content (could be invite code or deep link)
val scannedContent = result.contents
val extractedCode = if (scannedContent.startsWith("tssparty://join/")) {
scannedContent.removePrefix("tssparty://join/")
} else {
scannedContent
}
onInviteCodeChange(extractedCode)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header
Text(
text = "加入共管钱包",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "扫描二维码或输入邀请码加入多方钱包创建会话",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Scan QR Button
Card(
onClick = {
val options = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt("扫描邀请二维码")
setCameraId(0)
setBeepEnabled(true)
setBarcodeImageEnabled(false)
setOrientationLocked(true)
}
scanLauncher.launch(options)
},
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "扫描二维码",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Divider with "或"
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Divider(modifier = Modifier.weight(1f))
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Divider(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(24.dp))
// Invite Code Input
OutlinedTextField(
value = inviteCode,
onValueChange = onInviteCodeChange,
label = { Text("邀请码") },
placeholder = { Text("粘贴邀请码") },
leadingIcon = {
Icon(Icons.Default.Key, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
// Info card
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "请向会话发起者获取邀请二维码或邀请码",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Error display
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
validationError?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.weight(1f))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text("取消")
}
Button(
onClick = onValidateCode,
modifier = Modifier.weight(1f),
enabled = !isLoading && inviteCode.isNotBlank()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("验证中...")
} else {
Text("加入会话")
}
}
}
}
}
@Composable
private fun ConfirmScreen(
sessionInfo: JoinSessionInfo?,
isLoading: Boolean,
error: String?,
onBack: () -> Unit,
onRetry: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header
Text(
text = "确认会话信息",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(24.dp))
// Session info card
if (sessionInfo != null) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
InfoRow("钱包名称", sessionInfo.walletName)
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("阈值设置", "${sessionInfo.thresholdT}-of-${sessionInfo.thresholdN}")
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("发起者", sessionInfo.initiator)
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("当前参与者", "${sessionInfo.currentParticipants} / ${sessionInfo.totalParticipants}")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Error or auto-joining state
if (error != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onBack,
modifier = Modifier.weight(1f)
) {
Text("返回")
}
Button(
onClick = onRetry,
modifier = Modifier.weight(1f)
) {
Text("重试")
}
}
} else {
// Auto-joining state
CircularProgressIndicator(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "正在自动加入会话...",
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@Composable
private fun JoiningScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(80.dp),
strokeWidth = 6.dp
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "正在加入会话...",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "请稍候,正在连接到其他参与者",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun KeygenProgressScreen(
sessionStatus: SessionStatus,
participants: List<String>,
currentRound: Int,
totalRounds: Int,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header
Text(
text = "密钥生成中",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "请保持应用在前台,直到密钥生成完成",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Progress indicator
CircularProgressIndicator(
modifier = Modifier.size(80.dp),
strokeWidth = 6.dp
)
Spacer(modifier = Modifier.height(24.dp))
// Progress card
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "协议进度",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Text(
text = "$currentRound / $totalRounds",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = if (totalRounds > 0) currentRound.toFloat() / totalRounds else 0f,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Participants card
if (participants.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "参与方 (${participants.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
participants.forEach { participant ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = participant,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Cancel button
OutlinedButton(onClick = onCancel) {
Icon(Icons.Default.Cancel, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("取消")
}
}
}
@Composable
private fun KeygenCompletedScreen(
publicKey: String?,
onBackToHome: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Success icon
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "密钥生成成功!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "您的钱包已创建成功,可以在「我的钱包」中查看",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Public key info
if (publicKey != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "公钥",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${publicKey.take(20)}...${publicKey.takeLast(20)}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onBackToHome,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Home, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("返回首页")
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}

View File

@ -0,0 +1,229 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JoinScreen(
sessionStatus: SessionStatus,
isLoading: Boolean,
error: String?,
onJoinKeygen: (inviteCode: String, password: String) -> Unit,
onCancel: () -> Unit,
onBack: () -> Unit
) {
var inviteCode by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Join Keygen") },
navigationIcon = {
IconButton(onClick = onBack, enabled = !isLoading) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Instructions
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Enter the invite code shared by the session creator and set a password to protect your key share.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
// Invite code input
OutlinedTextField(
value = inviteCode,
onValueChange = { inviteCode = it },
label = { Text("Invite Code") },
placeholder = { Text("session-id:join-token") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
leadingIcon = {
Icon(Icons.Default.QrCode, contentDescription = null)
}
)
// Password input
OutlinedTextField(
value = password,
onValueChange = {
password = it
passwordError = null
},
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = if (showPassword) "Hide password" else "Show password"
)
}
}
)
// Confirm password
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
passwordError = null
},
label = { Text("Confirm Password") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
isError = passwordError != null,
supportingText = passwordError?.let { { Text(it) } }
)
// Error message
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
// Progress indicator
if (isLoading) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = when (sessionStatus) {
SessionStatus.WAITING -> "Waiting for other parties..."
SessionStatus.IN_PROGRESS -> "Generating keys..."
SessionStatus.COMPLETED -> "Completed!"
SessionStatus.FAILED -> "Failed"
},
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (isLoading) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
} else {
Button(
onClick = {
if (password != confirmPassword) {
passwordError = "Passwords do not match"
return@Button
}
if (password.length < 4) {
passwordError = "Password must be at least 4 characters"
return@Button
}
if (inviteCode.isBlank()) {
return@Button
}
onJoinKeygen(inviteCode.trim(), password)
},
modifier = Modifier.fillMaxWidth(),
enabled = inviteCode.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Join Keygen")
}
}
}
}
}
}

View File

@ -0,0 +1,537 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.AppSettings
import com.durian.tssparty.domain.model.NetworkType
/**
* Connection test result
*/
data class ConnectionTestResult(
val success: Boolean,
val message: String,
val latency: Long? = null
)
/**
* Settings screen matching service-party-app/src/pages/Settings.tsx
* Full implementation with test connection buttons and Account Service URL
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
settings: AppSettings,
isConnected: Boolean,
messageRouterStatus: ConnectionTestResult? = null,
accountServiceStatus: ConnectionTestResult? = null,
kavaApiStatus: ConnectionTestResult? = null,
onSaveSettings: (AppSettings) -> Unit,
onTestMessageRouter: (String) -> Unit = {},
onTestAccountService: (String) -> Unit = {},
onTestKavaApi: (String) -> Unit = {}
) {
var messageRouterUrl by remember { mutableStateOf(settings.messageRouterUrl) }
var accountServiceUrl by remember { mutableStateOf(settings.accountServiceUrl) }
var kavaRpcUrl by remember { mutableStateOf(settings.kavaRpcUrl) }
var networkType by remember { mutableStateOf(settings.networkType) }
var hasChanges by remember { mutableStateOf(false) }
// Test connection states
var isTestingMessageRouter by remember { mutableStateOf(false) }
var isTestingAccountService by remember { mutableStateOf(false) }
var isTestingKavaApi by remember { mutableStateOf(false) }
// Local test results (for display)
var localMessageRouterResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
var localAccountServiceResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
var localKavaApiResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
// Update local results when props change
LaunchedEffect(messageRouterStatus) {
if (messageRouterStatus != null) {
localMessageRouterResult = messageRouterStatus
isTestingMessageRouter = false
}
}
LaunchedEffect(accountServiceStatus) {
if (accountServiceStatus != null) {
localAccountServiceResult = accountServiceStatus
isTestingAccountService = false
}
}
LaunchedEffect(kavaApiStatus) {
if (kavaApiStatus != null) {
localKavaApiResult = kavaApiStatus
isTestingKavaApi = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header
Text(
text = "设置",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "配置应用程序连接和网络设置",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Connection status overview
Card(
colors = CardDefaults.cardColors(
containerColor = if (isConnected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = null,
tint = if (isConnected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = if (isConnected) "应用就绪" else "连接异常",
fontWeight = FontWeight.Medium,
color = if (isConnected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = if (isConnected) "所有服务正常运行" else "请检查网络设置",
style = MaterialTheme.typography.bodySmall,
color = if (isConnected)
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
else
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Section: Connection Settings
Text(
text = "连接设置",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
// Message Router URL
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "消息路由服务",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "TSS 多方计算消息中继服务器",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = messageRouterUrl,
onValueChange = {
messageRouterUrl = it
hasChanges = true
localMessageRouterResult = null
},
label = { Text("服务地址") },
placeholder = { Text("mpc-grpc.szaiai.com:443") },
modifier = Modifier.weight(1f),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Cloud, contentDescription = null)
}
)
Button(
onClick = {
isTestingMessageRouter = true
localMessageRouterResult = null
onTestMessageRouter(messageRouterUrl)
},
enabled = !isTestingMessageRouter && messageRouterUrl.isNotBlank()
) {
if (isTestingMessageRouter) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("测试")
}
}
}
// Test result
localMessageRouterResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Account Service URL
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "账户服务",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "会话管理和账户 API 服务",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = accountServiceUrl,
onValueChange = {
accountServiceUrl = it
hasChanges = true
localAccountServiceResult = null
},
label = { Text("API 地址") },
placeholder = { Text("https://rwaapi.szaiai.com") },
modifier = Modifier.weight(1f),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Api, contentDescription = null)
}
)
Button(
onClick = {
isTestingAccountService = true
localAccountServiceResult = null
onTestAccountService(accountServiceUrl)
},
enabled = !isTestingAccountService && accountServiceUrl.isNotBlank()
) {
if (isTestingAccountService) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("测试")
}
}
}
// Test result
localAccountServiceResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Section: Blockchain Network
Text(
text = "区块链网络",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "选择要连接的 Kava 区块链网络",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilterChip(
selected = networkType == NetworkType.MAINNET,
onClick = {
networkType = NetworkType.MAINNET
kavaRpcUrl = "https://evm.kava.io"
hasChanges = true
localKavaApiResult = null
},
label = { Text("主网 (Kava)") },
leadingIcon = if (networkType == NetworkType.MAINNET) {
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
} else null
)
FilterChip(
selected = networkType == NetworkType.TESTNET,
onClick = {
networkType = NetworkType.TESTNET
kavaRpcUrl = "https://evm.testnet.kava.io"
hasChanges = true
localKavaApiResult = null
},
label = { Text("测试网") },
leadingIcon = if (networkType == NetworkType.TESTNET) {
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
} else null
)
}
Spacer(modifier = Modifier.height(16.dp))
// Kava RPC URL
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Kava RPC 节点",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "区块链交易和查询 API",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = kavaRpcUrl,
onValueChange = {
kavaRpcUrl = it
hasChanges = true
localKavaApiResult = null
},
label = { Text("RPC 地址") },
placeholder = { Text("https://evm.kava.io") },
modifier = Modifier.weight(1f),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Link, contentDescription = null)
}
)
Button(
onClick = {
isTestingKavaApi = true
localKavaApiResult = null
onTestKavaApi(kavaRpcUrl)
},
enabled = !isTestingKavaApi && kavaRpcUrl.isNotBlank()
) {
if (isTestingKavaApi) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("测试")
}
}
}
// Test result
localKavaApiResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Section: About
Text(
text = "关于",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(12.dp))
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
AboutRow("应用名称", "TSS Party")
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("版本", "1.0.0")
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("TSS 协议", "GG20")
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("区块链", "Kava EVM")
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("项目", "RWADurian MPC System")
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.weight(1f))
// Save button
Button(
onClick = {
onSaveSettings(
AppSettings(
messageRouterUrl = messageRouterUrl,
accountServiceUrl = accountServiceUrl,
kavaRpcUrl = kavaRpcUrl,
networkType = networkType
)
)
hasChanges = false
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = hasChanges
) {
Icon(Icons.Default.Save, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("保存设置")
}
}
}
@Composable
private fun AboutRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}

View File

@ -0,0 +1,199 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
import com.durian.tssparty.domain.model.ShareRecord
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SignScreen(
share: ShareRecord?,
sessionStatus: SessionStatus,
isLoading: Boolean,
error: String?,
onJoinSign: (inviteCode: String, shareId: Long, password: String) -> Unit,
onCancel: () -> Unit,
onBack: () -> Unit
) {
var inviteCode by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Sign Transaction") },
navigationIcon = {
IconButton(onClick = onBack, enabled = !isLoading) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Wallet info
share?.let { s ->
Card {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Signing with wallet",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = s.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${s.thresholdT}-of-${s.thresholdN} • Party #${s.partyIndex}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
// Invite code input
OutlinedTextField(
value = inviteCode,
onValueChange = { inviteCode = it },
label = { Text("Sign Session Code") },
placeholder = { Text("session-id:join-token") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
leadingIcon = {
Icon(Icons.Default.QrCode, contentDescription = null)
}
)
// Password input
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = if (showPassword) "Hide password" else "Show password"
)
}
}
)
// Error message
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
// Progress indicator
if (isLoading) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = when (sessionStatus) {
SessionStatus.WAITING -> "Waiting for other parties..."
SessionStatus.IN_PROGRESS -> "Signing in progress..."
SessionStatus.COMPLETED -> "Signed successfully!"
SessionStatus.FAILED -> "Signing failed"
},
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (isLoading) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
} else {
Button(
onClick = {
share?.let {
onJoinSign(inviteCode.trim(), it.id, password)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = share != null && inviteCode.isNotBlank() && password.isNotBlank()
) {
Icon(Icons.Default.Edit, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Sign")
}
}
}
}
}
}

View File

@ -0,0 +1,273 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.durian.tssparty.domain.model.AppReadyState
import com.durian.tssparty.domain.model.AppState
import com.durian.tssparty.domain.model.ServiceStatus
@Composable
fun StartupCheckScreen(
appState: AppState,
onEnterApp: () -> Unit,
onRetry: () -> Unit
) {
val canEnter = appState.appReady == AppReadyState.READY || appState.appReady == AppReadyState.ERROR
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// App Logo/Icon
Box(
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(24.dp))
// App Title
Text(
text = "TSS Party",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "多方安全计算钱包",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
// Service Check Cards
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "服务状态",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
// Database Status
ServiceCheckItem(
icon = Icons.Default.Storage,
title = "本地数据库",
status = appState.environment.database,
extraInfo = if (appState.walletCount > 0) "${appState.walletCount} 个钱包" else null
)
Divider(modifier = Modifier.padding(vertical = 12.dp))
// Message Router Status
ServiceCheckItem(
icon = Icons.Default.Cloud,
title = "消息路由服务",
status = appState.environment.messageRouter,
extraInfo = appState.partyId?.take(8)?.let { "Party: $it..." }
)
Divider(modifier = Modifier.padding(vertical = 12.dp))
// Kava API Status
ServiceCheckItem(
icon = Icons.Default.Language,
title = "Kava 区块链",
status = appState.environment.kavaApi,
extraInfo = appState.environment.kavaApi.latency?.let { "${it}ms" }
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Status Message
when (appState.appReady) {
AppReadyState.INITIALIZING -> {
Row(
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "正在检查服务...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
AppReadyState.READY -> {
Text(
text = "所有服务就绪",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
AppReadyState.ERROR -> {
Text(
text = appState.appError ?: "部分服务不可用",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Action Buttons
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (appState.appReady == AppReadyState.ERROR) {
OutlinedButton(
onClick = onRetry,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("重试")
}
}
Button(
onClick = onEnterApp,
enabled = canEnter,
modifier = Modifier.weight(1f)
) {
Text(
text = when (appState.appReady) {
AppReadyState.READY -> "进入应用"
AppReadyState.ERROR -> "继续使用"
else -> "加载中..."
}
)
if (canEnter) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
Icons.Default.ArrowForward,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
}
@Composable
private fun ServiceCheckItem(
icon: ImageVector,
title: String,
status: ServiceStatus,
extraInfo: String? = null
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Icon with status indicator
Box {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
// Status dot
Box(
modifier = Modifier
.size(12.dp)
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(
when {
status.message.isEmpty() -> Color.Gray
status.isOnline -> Color(0xFF4CAF50)
else -> Color(0xFFFF5722)
}
)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (status.message.isNotEmpty()) {
Text(
text = status.message,
style = MaterialTheme.typography.bodySmall,
color = if (status.isOnline)
MaterialTheme.colorScheme.onSurfaceVariant
else
MaterialTheme.colorScheme.error
)
}
}
// Extra info (wallet count, latency, etc.)
extraInfo?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}

View File

@ -0,0 +1,805 @@
package com.durian.tssparty.presentation.screens
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.durian.tssparty.domain.model.ShareRecord
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletsScreen(
shares: List<ShareRecord>,
isConnected: Boolean,
balances: Map<String, String> = emptyMap(),
onDeleteShare: (Long) -> Unit,
onRefreshBalance: ((String) -> Unit)? = null,
onTransfer: ((shareId: Long, toAddress: String, amount: String, password: String) -> Unit)? = null,
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
onCreateWallet: (() -> Unit)? = null
) {
var selectedWallet by remember { mutableStateOf<ShareRecord?>(null) }
var showTransferDialog by remember { mutableStateOf(false) }
var transferWallet by remember { mutableStateOf<ShareRecord?>(null) }
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Header with connection status
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "我的钱包",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
// Connection status
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = if (isConnected) "已连接" else "未连接",
tint = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (isConnected) "已连接" else "离线",
style = MaterialTheme.typography.bodySmall,
color = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${shares.size} 个钱包",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
if (shares.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "暂无钱包",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "使用「创建钱包」发起新钱包",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
Text(
text = "或使用「加入创建」参与他人的会话",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
// Wallet list
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
) {
items(shares) { share ->
WalletItemCard(
share = share,
balance = balances[share.address],
onViewDetails = { selectedWallet = share },
onTransfer = {
transferWallet = share
showTransferDialog = true
},
onDelete = { onDeleteShare(share.id) }
)
}
}
}
}
// Floating Action Button for creating wallet
if (onCreateWallet != null) {
FloatingActionButton(
onClick = onCreateWallet,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "创建钱包",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
// Wallet detail dialog
selectedWallet?.let { wallet ->
WalletDetailDialog(
wallet = wallet,
onDismiss = { selectedWallet = null },
onTransfer = {
selectedWallet = null
transferWallet = wallet
showTransferDialog = true
},
onExport = onExportBackup?.let { export ->
{ password -> export(wallet.id, password) }
}
)
}
// Transfer dialog
if (showTransferDialog && transferWallet != null) {
TransferDialog(
wallet = transferWallet!!,
onDismiss = {
showTransferDialog = false
transferWallet = null
},
onConfirm = { toAddress, amount, password ->
onTransfer?.invoke(transferWallet!!.id, toAddress, amount, password)
showTransferDialog = false
transferWallet = null
}
)
}
}
@Composable
private fun WalletItemCard(
share: ShareRecord,
balance: String? = null,
onViewDetails: () -> Unit,
onTransfer: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onViewDetails() }
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Header with threshold badge
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Threshold badge
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${share.thresholdT}-of-${share.thresholdN}",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
)
}
// Party index
Text(
text = "参与者 #${share.partyIndex}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.height(12.dp))
// Address
Text(
text = "地址",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = share.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(12.dp))
// Balance display
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.AccountBalance,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
if (balance != null) {
Text(
text = "$balance KAVA",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
} else {
// Loading state
Text(
text = "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Divider()
Spacer(modifier = Modifier.height(8.dp))
// Actions
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = onViewDetails) {
Icon(
Icons.Default.QrCode,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("详情")
}
TextButton(onClick = onTransfer) {
Icon(
Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("转账")
}
TextButton(
onClick = { showDeleteDialog = true },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("删除")
}
}
}
}
// Delete confirmation dialog
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
icon = {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
title = { Text("删除钱包") },
text = {
Text("确定要删除这个钱包吗?此操作无法撤销,删除后您将无法使用此密钥份额参与签名。")
},
confirmButton = {
TextButton(
onClick = {
onDelete()
showDeleteDialog = false
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("删除")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("取消")
}
}
)
}
}
@Composable
private fun WalletDetailDialog(
wallet: ShareRecord,
onDismiss: () -> Unit,
onTransfer: () -> Unit,
onExport: ((String) -> Unit)?
) {
val clipboardManager = LocalClipboardManager.current
var showExportDialog by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// QR Code
val qrBitmap = remember(wallet.address) {
generateQRCode(wallet.address, 240)
}
qrBitmap?.let { bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.padding(8.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
// Threshold badge
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "${wallet.thresholdT}-of-${wallet.thresholdN} 多签钱包",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(16.dp))
// Address
Text(
text = "Kava EVM 地址",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = wallet.address,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Copy button
OutlinedButton(
onClick = {
clipboardManager.setText(AnnotatedString(wallet.address))
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("复制地址")
}
Spacer(modifier = Modifier.height(16.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// Info rows
InfoRow("门限设置", "${wallet.thresholdT}-of-${wallet.thresholdN}")
InfoRow("您的序号", "#${wallet.partyIndex}")
InfoRow("会话ID", wallet.sessionId.take(16) + "...")
Spacer(modifier = Modifier.height(24.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onTransfer,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("转账")
}
if (onExport != null) {
OutlinedButton(
onClick = { showExportDialog = true },
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("导出")
}
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onDismiss) {
Text("关闭")
}
}
}
}
// Export dialog
if (showExportDialog && onExport != null) {
ExportBackupDialog(
onDismiss = { showExportDialog = false },
onConfirm = { password ->
onExport(password)
showExportDialog = false
}
)
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun TransferDialog(
wallet: ShareRecord,
onDismiss: () -> Unit,
onConfirm: (toAddress: String, amount: String, password: String) -> Unit
) {
var toAddress by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
Text(
text = "转账",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${wallet.address.take(10)}...${wallet.address.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(24.dp))
// Recipient address
OutlinedTextField(
value = toAddress,
onValueChange = { toAddress = it },
label = { Text("收款地址") },
placeholder = { Text("0x...") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
}
)
Spacer(modifier = Modifier.height(16.dp))
// Amount
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = { Text("金额 (KAVA)") },
placeholder = { Text("0.0") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
leadingIcon = {
Icon(Icons.Default.AttachMoney, contentDescription = null)
}
)
Spacer(modifier = Modifier.height(16.dp))
// Password
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("钱包密码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null
)
}
}
)
error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(16.dp))
// Info card
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "转账需要 ${wallet.thresholdT} 个参与者共同签名。确认后将创建签名会话。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("取消")
}
Button(
onClick = {
when {
toAddress.isBlank() -> error = "请输入收款地址"
!toAddress.startsWith("0x") || toAddress.length != 42 -> error = "地址格式不正确"
amount.isBlank() -> error = "请输入金额"
amount.toDoubleOrNull() == null || amount.toDouble() <= 0 -> error = "金额无效"
password.isBlank() -> error = "请输入密码"
else -> {
error = null
onConfirm(toAddress, amount, password)
}
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("确认转账")
}
}
}
}
}
}
@Composable
private fun ExportBackupDialog(
onDismiss: () -> Unit,
onConfirm: (password: String) -> Unit
) {
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(Icons.Default.Download, contentDescription = null)
},
title = { Text("导出备份") },
text = {
Column {
Text("导出加密备份文件,请妥善保管。")
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null
)
}
},
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(password) },
enabled = password.isNotBlank()
) {
Text("导出")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}
/**
* Generate QR code bitmap
*/
private fun generateQRCode(content: String, size: Int): Bitmap? {
return try {
val writer = QRCodeWriter()
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
val width = bitMatrix.width
val height = bitMatrix.height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap.setPixel(x, y, if (bitMatrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
}
}
bitmap
} catch (e: Exception) {
null
}
}

View File

@ -0,0 +1,855 @@
package com.durian.tssparty.presentation.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.durian.tssparty.data.repository.TssRepository
import com.durian.tssparty.domain.model.*
import com.durian.tssparty.util.TransactionUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
private val repository: TssRepository
) : ViewModel() {
// App State (similar to Zustand store)
private val _appState = MutableStateFlow(AppState())
val appState: StateFlow<AppState> = _appState.asStateFlow()
// UI State
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
// Settings
private val _settings = MutableStateFlow(AppSettings())
val settings: StateFlow<AppSettings> = _settings.asStateFlow()
// Share records
val shares: StateFlow<List<ShareRecord>> = repository.getAllShares()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
// Session status from repository
val sessionStatus: StateFlow<SessionStatus> = repository.sessionStatus
// Created session invite code
private val _createdInviteCode = MutableStateFlow<String?>(null)
val createdInviteCode: StateFlow<String?> = _createdInviteCode.asStateFlow()
init {
// Start initialization on app launch
checkAllServices()
}
/**
* Check all services for startup
*/
fun checkAllServices() {
viewModelScope.launch {
_appState.update { it.copy(appReady = AppReadyState.INITIALIZING) }
var hasError = false
var errorMessage: String? = null
// 1. Check Database
try {
val walletCount = repository.getShareCount()
_appState.update {
it.copy(
walletCount = walletCount,
environment = it.environment.copy(
database = ServiceStatus(
isOnline = true,
message = "正常"
)
)
)
}
} catch (e: Exception) {
hasError = true
errorMessage = "数据库错误: ${e.message}"
_appState.update {
it.copy(
environment = it.environment.copy(
database = ServiceStatus(
isOnline = false,
message = e.message ?: "错误"
)
)
)
}
}
// 2. Check Message Router
try {
val serverUrl = _settings.value.messageRouterUrl
val parts = serverUrl.split(":")
val host = parts[0]
val port = parts.getOrNull(1)?.toIntOrNull() ?: 50051
repository.connect(host, port)
val partyId = repository.registerParty()
_appState.update {
it.copy(
partyId = partyId,
environment = it.environment.copy(
messageRouter = ServiceStatus(
isOnline = true,
message = "已连接"
)
)
)
}
_uiState.update { it.copy(isConnected = true) }
} catch (e: Exception) {
hasError = true
if (errorMessage == null) errorMessage = "消息路由: ${e.message}"
_appState.update {
it.copy(
environment = it.environment.copy(
messageRouter = ServiceStatus(
isOnline = false,
message = e.message ?: "连接失败"
)
)
)
}
_uiState.update { it.copy(isConnected = false) }
}
// 3. Check Kava API
try {
val startTime = System.currentTimeMillis()
val isHealthy = repository.checkKavaHealth()
val latency = System.currentTimeMillis() - startTime
_appState.update {
it.copy(
environment = it.environment.copy(
kavaApi = ServiceStatus(
isOnline = isHealthy,
message = if (isHealthy) "正常" else "不可用",
latency = latency
)
)
)
}
} catch (e: Exception) {
// Kava API is not critical
_appState.update {
it.copy(
environment = it.environment.copy(
kavaApi = ServiceStatus(
isOnline = false,
message = e.message ?: "错误"
)
)
)
}
}
// Small delay for visual feedback
delay(1000)
// Update final state
_appState.update {
it.copy(
appReady = if (hasError) AppReadyState.ERROR else AppReadyState.READY,
appError = errorMessage
)
}
}
}
/**
* Connect to Message Router server
*/
fun connectToServer(serverUrl: String) {
viewModelScope.launch {
try {
val parts = serverUrl.split(":")
val host = parts[0]
val port = parts.getOrNull(1)?.toIntOrNull() ?: 50051
repository.connect(host, port)
repository.registerParty()
_uiState.update { it.copy(isConnected = true, error = null) }
_appState.update {
it.copy(
environment = it.environment.copy(
messageRouter = ServiceStatus(isOnline = true, message = "已连接")
)
)
}
} catch (e: Exception) {
_uiState.update { it.copy(isConnected = false, error = e.message) }
_appState.update {
it.copy(
environment = it.environment.copy(
messageRouter = ServiceStatus(isOnline = false, message = e.message ?: "失败")
)
)
}
}
}
}
// Current session data for CreateWalletScreen
private val _currentSessionId = MutableStateFlow<String?>(null)
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
private val _sessionParticipants = MutableStateFlow<List<String>>(emptyList())
val sessionParticipants: StateFlow<List<String>> = _sessionParticipants.asStateFlow()
private val _currentRound = MutableStateFlow(0)
val currentRound: StateFlow<Int> = _currentRound.asStateFlow()
private val _publicKey = MutableStateFlow<String?>(null)
val publicKey: StateFlow<String?> = _publicKey.asStateFlow()
/**
* Create a new keygen session (initiator)
* Note: password is not needed for creating session in service-party-app
*/
fun createKeygenSession(walletName: String, thresholdT: Int, thresholdN: Int, participantName: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val result = repository.createKeygenSession(walletName, thresholdT, thresholdN, participantName)
result.fold(
onSuccess = { inviteCode ->
_createdInviteCode.value = inviteCode
// Parse sessionId from invite code (format: sessionId:joinToken)
val sessionId = inviteCode.split(":").firstOrNull()
_currentSessionId.value = sessionId
// Add self as first participant
_sessionParticipants.value = listOf(participantName)
_uiState.update { it.copy(isLoading = false) }
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
/**
* Enter session (transition from created to session screen)
*/
fun enterSession() {
// This triggers the session screen to show
// The session status will change to IN_PROGRESS once keygen starts
}
/**
* Reset session state (back to home)
*/
fun resetSessionState() {
_currentSessionId.value = null
_sessionParticipants.value = emptyList()
_currentRound.value = 0
_publicKey.value = null
_createdInviteCode.value = null
}
// ========== Join Keygen State ==========
private val _joinSessionInfo = MutableStateFlow<JoinKeygenSessionInfo?>(null)
val joinSessionInfo: StateFlow<JoinKeygenSessionInfo?> = _joinSessionInfo.asStateFlow()
private val _joinKeygenParticipants = MutableStateFlow<List<String>>(emptyList())
val joinKeygenParticipants: StateFlow<List<String>> = _joinKeygenParticipants.asStateFlow()
private val _joinKeygenRound = MutableStateFlow(0)
val joinKeygenRound: StateFlow<Int> = _joinKeygenRound.asStateFlow()
private val _joinKeygenPublicKey = MutableStateFlow<String?>(null)
val joinKeygenPublicKey: StateFlow<String?> = _joinKeygenPublicKey.asStateFlow()
// Store invite code and password for auto-join
private var pendingInviteCode: String = ""
private var pendingPassword: String = ""
/**
* Validate invite code and get session info
*/
fun validateInviteCode(inviteCode: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
pendingInviteCode = inviteCode
val result = repository.validateInviteCode(inviteCode)
result.fold(
onSuccess = { validateResult ->
val info = validateResult.sessionInfo
_joinSessionInfo.value = JoinKeygenSessionInfo(
sessionId = info.sessionId,
walletName = info.walletName,
thresholdT = info.thresholdT,
thresholdN = info.thresholdN,
initiator = info.initiator,
currentParticipants = info.currentParticipants,
totalParticipants = info.totalParticipants
)
_uiState.update { it.copy(isLoading = false) }
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
/**
* Join a keygen session (called after validateInviteCode)
*/
fun joinKeygen(inviteCode: String, password: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
pendingPassword = password
val result = repository.joinKeygenSession(inviteCode, password)
result.fold(
onSuccess = { share ->
_joinKeygenPublicKey.value = share.publicKey
_uiState.update {
it.copy(
isLoading = false,
lastCreatedAddress = share.address,
successMessage = "钱包创建成功!"
)
}
// Update wallet count
_appState.update { state ->
state.copy(walletCount = state.walletCount + 1)
}
},
onFailure = { e ->
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
}
)
}
}
/**
* Reset join keygen state
*/
fun resetJoinKeygenState() {
_joinSessionInfo.value = null
_joinKeygenParticipants.value = emptyList()
_joinKeygenRound.value = 0
_joinKeygenPublicKey.value = null
pendingInviteCode = ""
pendingPassword = ""
}
// ========== CoSign (Join Sign) State ==========
private val _coSignSessionInfo = MutableStateFlow<CoSignSessionInfo?>(null)
val coSignSessionInfo: StateFlow<CoSignSessionInfo?> = _coSignSessionInfo.asStateFlow()
private val _coSignParticipants = MutableStateFlow<List<String>>(emptyList())
val coSignParticipants: StateFlow<List<String>> = _coSignParticipants.asStateFlow()
private val _coSignRound = MutableStateFlow(0)
val coSignRound: StateFlow<Int> = _coSignRound.asStateFlow()
private val _coSignSignature = MutableStateFlow<String?>(null)
val coSignSignature: StateFlow<String?> = _coSignSignature.asStateFlow()
// Store pending CoSign data
private var pendingCoSignInviteCode: String = ""
/**
* Validate sign session invite code
*/
fun validateSignInviteCode(inviteCode: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
pendingCoSignInviteCode = inviteCode
val result = repository.validateSignInviteCode(inviteCode)
result.fold(
onSuccess = { validateResult ->
val info = validateResult.signSessionInfo
_coSignSessionInfo.value = CoSignSessionInfo(
sessionId = info.sessionId,
keygenSessionId = info.keygenSessionId,
walletName = info.walletName,
messageHash = info.messageHash,
thresholdT = info.thresholdT,
thresholdN = info.thresholdN,
currentParticipants = info.currentParticipants
)
_uiState.update { it.copy(isLoading = false) }
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
/**
* Join a sign session
*/
fun joinSign(inviteCode: String, shareId: Long, password: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val result = repository.joinSignSession(inviteCode, shareId, password)
result.fold(
onSuccess = { signResult ->
_coSignSignature.value = signResult.signature
_uiState.update {
it.copy(
isLoading = false,
lastSignature = signResult.signature,
successMessage = "签名完成!"
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
}
)
}
}
/**
* Reset CoSign state
*/
fun resetCoSignState() {
_coSignSessionInfo.value = null
_coSignParticipants.value = emptyList()
_coSignRound.value = 0
_coSignSignature.value = null
pendingCoSignInviteCode = ""
}
/**
* Cancel current session
*/
fun cancelSession() {
repository.cancelSession()
_createdInviteCode.value = null
_uiState.update { it.copy(isLoading = false) }
}
/**
* Delete a share
*/
fun deleteShare(id: Long) {
viewModelScope.launch {
repository.deleteShare(id)
// Update wallet count
_appState.update { state ->
state.copy(walletCount = (state.walletCount - 1).coerceAtLeast(0))
}
}
}
/**
* Update settings
*/
fun updateSettings(newSettings: AppSettings) {
_settings.value = newSettings
connectToServer(newSettings.messageRouterUrl)
// Update account service URL in repository
repository.setAccountServiceUrl(newSettings.accountServiceUrl)
}
// ========== Connection Test State ==========
private val _messageRouterTestResult = MutableStateFlow<ConnectionTestResult?>(null)
val messageRouterTestResult: StateFlow<ConnectionTestResult?> = _messageRouterTestResult.asStateFlow()
private val _accountServiceTestResult = MutableStateFlow<ConnectionTestResult?>(null)
val accountServiceTestResult: StateFlow<ConnectionTestResult?> = _accountServiceTestResult.asStateFlow()
private val _kavaApiTestResult = MutableStateFlow<ConnectionTestResult?>(null)
val kavaApiTestResult: StateFlow<ConnectionTestResult?> = _kavaApiTestResult.asStateFlow()
/**
* Test Message Router connection
*/
fun testMessageRouter(serverUrl: String) {
viewModelScope.launch {
_messageRouterTestResult.value = null
val result = repository.testMessageRouter(serverUrl)
result.fold(
onSuccess = { latency ->
_messageRouterTestResult.value = ConnectionTestResult(
success = true,
message = "连接成功",
latency = latency
)
},
onFailure = { e ->
_messageRouterTestResult.value = ConnectionTestResult(
success = false,
message = e.message ?: "连接失败"
)
}
)
}
}
/**
* Test Account Service connection
*/
fun testAccountService(serviceUrl: String) {
viewModelScope.launch {
_accountServiceTestResult.value = null
val result = repository.testAccountService(serviceUrl)
result.fold(
onSuccess = { latency ->
_accountServiceTestResult.value = ConnectionTestResult(
success = true,
message = "连接成功",
latency = latency
)
},
onFailure = { e ->
_accountServiceTestResult.value = ConnectionTestResult(
success = false,
message = e.message ?: "连接失败"
)
}
)
}
}
/**
* Test Kava API connection
*/
fun testKavaApi(rpcUrl: String) {
viewModelScope.launch {
_kavaApiTestResult.value = null
val result = repository.testKavaApi(rpcUrl)
result.fold(
onSuccess = { latency ->
_kavaApiTestResult.value = ConnectionTestResult(
success = true,
message = "连接成功",
latency = latency
)
},
onFailure = { e ->
_kavaApiTestResult.value = ConnectionTestResult(
success = false,
message = e.message ?: "连接失败"
)
}
)
}
}
/**
* Clear error message
*/
fun clearError() {
_uiState.update { it.copy(error = null) }
}
/**
* Clear success message
*/
fun clearSuccess() {
_uiState.update { it.copy(successMessage = null) }
}
/**
* Clear created invite code
*/
fun clearCreatedInviteCode() {
_createdInviteCode.value = null
}
// Wallet balances cache
private val _balances = MutableStateFlow<Map<String, String>>(emptyMap())
val balances: StateFlow<Map<String, String>> = _balances.asStateFlow()
/**
* Fetch balance for a wallet address
*/
fun fetchBalance(address: String) {
viewModelScope.launch {
val rpcUrl = _settings.value.kavaRpcUrl
val result = repository.getBalance(address, rpcUrl)
result.onSuccess { balance ->
_balances.update { it + (address to balance) }
}
}
}
/**
* Fetch balances for all wallets
*/
fun fetchAllBalances() {
viewModelScope.launch {
shares.value.forEach { share ->
fetchBalance(share.address)
}
}
}
// ========== Transfer / Sign Session State ==========
// Transfer state
private val _transferState = MutableStateFlow(TransferState())
val transferState: StateFlow<TransferState> = _transferState.asStateFlow()
// Prepared transaction
private val _preparedTx = MutableStateFlow<TransactionUtils.PreparedTransaction?>(null)
val preparedTx: StateFlow<TransactionUtils.PreparedTransaction?> = _preparedTx.asStateFlow()
// Sign session for transfer
private val _signSessionId = MutableStateFlow<String?>(null)
val signSessionId: StateFlow<String?> = _signSessionId.asStateFlow()
private val _signInviteCode = MutableStateFlow<String?>(null)
val signInviteCode: StateFlow<String?> = _signInviteCode.asStateFlow()
private val _signParticipants = MutableStateFlow<List<String>>(emptyList())
val signParticipants: StateFlow<List<String>> = _signParticipants.asStateFlow()
private val _signCurrentRound = MutableStateFlow(0)
val signCurrentRound: StateFlow<Int> = _signCurrentRound.asStateFlow()
private val _signature = MutableStateFlow<String?>(null)
val signature: StateFlow<String?> = _signature.asStateFlow()
private val _txHash = MutableStateFlow<String?>(null)
val txHash: StateFlow<String?> = _txHash.asStateFlow()
/**
* Prepare a transfer transaction
*/
fun prepareTransfer(shareId: Long, toAddress: String, amount: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
_transferState.update { it.copy(shareId = shareId, toAddress = toAddress, amount = amount) }
val share = repository.getShareById(shareId)
if (share == null) {
_uiState.update { it.copy(isLoading = false, error = "钱包不存在") }
return@launch
}
val rpcUrl = _settings.value.kavaRpcUrl
val chainId = if (_settings.value.networkType == NetworkType.TESTNET) {
TransactionUtils.KAVA_TESTNET_CHAIN_ID
} else {
TransactionUtils.KAVA_MAINNET_CHAIN_ID
}
val result = repository.prepareTransaction(
from = share.address,
to = toAddress,
amount = amount,
rpcUrl = rpcUrl,
chainId = chainId
)
result.fold(
onSuccess = { tx ->
_preparedTx.value = tx
_uiState.update { it.copy(isLoading = false) }
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
/**
* Create sign session and start signing
*/
fun initiateSignSession(shareId: Long, password: String, initiatorName: String = "发起者") {
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
}
val result = repository.createSignSession(
shareId = shareId,
messageHash = tx.signHash,
password = password,
initiatorName = initiatorName
)
result.fold(
onSuccess = { sessionResult ->
_signSessionId.value = sessionResult.sessionId
_signInviteCode.value = sessionResult.inviteCode
_signParticipants.value = listOf(initiatorName)
_uiState.update { it.copy(isLoading = false) }
// Start signing process
startSigningProcess(sessionResult.sessionId, shareId, password)
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
/**
* Start the TSS signing process
*/
private fun startSigningProcess(sessionId: String, shareId: Long, password: String) {
viewModelScope.launch {
val startResult = repository.startSigning(sessionId, shareId, password)
if (startResult.isFailure) {
_uiState.update { it.copy(error = startResult.exceptionOrNull()?.message) }
return@launch
}
// Wait for signature
val signResult = repository.waitForSignature()
signResult.fold(
onSuccess = { result ->
_signature.value = result.signature
},
onFailure = { e ->
_uiState.update { it.copy(error = e.message) }
}
)
}
}
/**
* Broadcast the signed transaction
*/
fun broadcastTransaction() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val tx = _preparedTx.value
val sig = _signature.value
if (tx == null || sig == null) {
_uiState.update { it.copy(isLoading = false, error = "交易或签名缺失") }
return@launch
}
val rpcUrl = _settings.value.kavaRpcUrl
val result = repository.broadcastTransaction(tx, sig, rpcUrl)
result.fold(
onSuccess = { hash ->
_txHash.value = hash
_uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") }
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
/**
* Reset transfer state
*/
fun resetTransferState() {
_transferState.value = TransferState()
_preparedTx.value = null
_signSessionId.value = null
_signInviteCode.value = null
_signParticipants.value = emptyList()
_signCurrentRound.value = 0
_signature.value = null
_txHash.value = null
}
/**
* Get wallet by ID
*/
fun getWalletById(shareId: Long): ShareRecord? {
return shares.value.find { it.id == shareId }
}
override fun onCleared() {
super.onCleared()
repository.disconnect()
}
}
/**
* UI State
*/
data class MainUiState(
val isConnected: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
val successMessage: String? = null,
val lastCreatedAddress: String? = null,
val lastSignature: String? = null
)
/**
* Transfer state
*/
data class TransferState(
val shareId: Long = 0,
val toAddress: String = "",
val amount: String = ""
)
/**
* Join keygen session info (from validateInviteCode)
*/
data class JoinKeygenSessionInfo(
val sessionId: String,
val walletName: String,
val thresholdT: Int,
val thresholdN: Int,
val initiator: String,
val currentParticipants: Int,
val totalParticipants: Int
)
/**
* CoSign session info (from validateSignInviteCode)
*/
data class CoSignSessionInfo(
val sessionId: String,
val keygenSessionId: String,
val walletName: String,
val messageHash: String,
val thresholdT: Int,
val thresholdN: Int,
val currentParticipants: Int
)
/**
* Connection test result for Settings screen
*/
data class ConnectionTestResult(
val success: Boolean,
val message: String,
val latency: Long? = null
)

View File

@ -0,0 +1,79 @@
package com.durian.tssparty.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// Durian Green Theme Colors
private val DurianGreen = Color(0xFF4CAF50)
private val DurianGreenDark = Color(0xFF388E3C)
private val DurianGreenLight = Color(0xFF81C784)
private val DarkColorScheme = darkColorScheme(
primary = DurianGreenLight,
onPrimary = Color.Black,
primaryContainer = DurianGreenDark,
onPrimaryContainer = Color.White,
secondary = Color(0xFFB2DFDB),
onSecondary = Color.Black,
background = Color(0xFF121212),
onBackground = Color.White,
surface = Color(0xFF1E1E1E),
onSurface = Color.White,
error = Color(0xFFCF6679),
onError = Color.Black
)
private val LightColorScheme = lightColorScheme(
primary = DurianGreen,
onPrimary = Color.White,
primaryContainer = DurianGreenLight,
onPrimaryContainer = Color.Black,
secondary = Color(0xFF00796B),
onSecondary = Color.White,
background = Color(0xFFFAFAFA),
onBackground = Color.Black,
surface = Color.White,
onSurface = Color.Black,
error = Color(0xFFB00020),
onError = Color.White
)
@Composable
fun TssPartyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,31 @@
package com.durian.tssparty.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@ -0,0 +1,186 @@
package com.durian.tssparty.util
import org.bouncycastle.jcajce.provider.digest.Keccak
import org.bouncycastle.jcajce.provider.digest.RIPEMD160
import org.bouncycastle.jcajce.provider.digest.SHA256
import java.security.MessageDigest
/**
* Utility functions for address derivation
*/
object AddressUtils {
/**
* Derive Kava address from compressed public key
* Kava uses Bech32 with "kava" prefix
*/
fun deriveKavaAddress(compressedPubKey: ByteArray): String {
// 1. Decompress public key if compressed (33 bytes -> 65 bytes)
val uncompressedPubKey = if (compressedPubKey.size == 33) {
decompressPublicKey(compressedPubKey)
} else {
compressedPubKey
}
// 2. For Cosmos/Kava: SHA256 -> RIPEMD160
val sha256 = SHA256.Digest().digest(compressedPubKey)
val ripemd160 = RIPEMD160.Digest().digest(sha256)
// 3. Bech32 encode with "kava" prefix
return Bech32.encode("kava", convertBits(ripemd160, 8, 5, true))
}
/**
* Derive EVM address from public key (for Kava EVM compatibility)
*/
fun deriveEvmAddress(compressedPubKey: ByteArray): String {
// 1. Decompress if needed
val uncompressedPubKey = if (compressedPubKey.size == 33) {
decompressPublicKey(compressedPubKey)
} else {
compressedPubKey
}
// 2. Take last 64 bytes (remove 0x04 prefix)
val pubKeyNoPrefix = if (uncompressedPubKey.size == 65) {
uncompressedPubKey.sliceArray(1..64)
} else {
uncompressedPubKey
}
// 3. Keccak256 hash
val keccak = Keccak.Digest256().digest(pubKeyNoPrefix)
// 4. Take last 20 bytes
val addressBytes = keccak.sliceArray(12..31)
// 5. Hex encode with 0x prefix
return "0x" + addressBytes.toHexString()
}
/**
* Decompress a compressed secp256k1 public key
*/
private fun decompressPublicKey(compressed: ByteArray): ByteArray {
require(compressed.size == 33) { "Invalid compressed public key size" }
val prefix = compressed[0].toInt() and 0xFF
require(prefix == 0x02 || prefix == 0x03) { "Invalid compression prefix" }
val x = compressed.sliceArray(1..32)
// secp256k1 curve parameters
val p = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F".toBigInteger(16)
val xBigInt = x.toBigInteger()
// y² = x³ + 7 (mod p)
val ySquared = (xBigInt.pow(3) + 7.toBigInteger()).mod(p)
// Calculate y using modular square root
var y = ySquared.modPow((p + 1.toBigInteger()) / 4.toBigInteger(), p)
// Check parity
val isOdd = prefix == 0x03
if (y.testBit(0) != isOdd) {
y = p - y
}
// Build uncompressed key: 0x04 || x || y
val result = ByteArray(65)
result[0] = 0x04
val xBytes = x
val yBytes = y.toByteArray32()
System.arraycopy(xBytes, 0, result, 1, 32)
System.arraycopy(yBytes, 0, result, 33, 32)
return result
}
/**
* Convert between bit groups for Bech32
*/
private fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): ByteArray {
var acc = 0
var bits = 0
val result = mutableListOf<Byte>()
val maxv = (1 shl toBits) - 1
for (value in data) {
val v = value.toInt() and 0xFF
acc = (acc shl fromBits) or v
bits += fromBits
while (bits >= toBits) {
bits -= toBits
result.add(((acc shr bits) and maxv).toByte())
}
}
if (pad && bits > 0) {
result.add(((acc shl (toBits - bits)) and maxv).toByte())
}
return result.toByteArray()
}
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
private fun ByteArray.toBigInteger(): java.math.BigInteger {
return java.math.BigInteger(1, this)
}
private fun java.math.BigInteger.toByteArray32(): ByteArray {
val bytes = this.toByteArray()
return when {
bytes.size == 32 -> bytes
bytes.size > 32 -> bytes.sliceArray((bytes.size - 32) until bytes.size)
else -> ByteArray(32 - bytes.size) + bytes
}
}
}
/**
* Bech32 encoding utilities
*/
object Bech32 {
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
fun encode(hrp: String, data: ByteArray): String {
val combined = data.map { it.toInt() and 0xFF }.toIntArray()
val checksum = createChecksum(hrp, combined)
val result = StringBuilder(hrp).append("1")
for (d in combined) result.append(CHARSET[d])
for (d in checksum) result.append(CHARSET[d])
return result.toString()
}
private fun polymod(values: IntArray): Int {
var chk = 1
for (v in values) {
val top = chk shr 25
chk = ((chk and 0x1ffffff) shl 5) xor v
for (i in 0..4) {
if ((top shr i) and 1 == 1) {
chk = chk xor GENERATOR[i]
}
}
}
return chk
}
private fun hrpExpand(hrp: String): IntArray {
val result = IntArray(hrp.length * 2 + 1)
for (i in hrp.indices) {
result[i] = hrp[i].code shr 5
result[i + hrp.length + 1] = hrp[i].code and 31
}
result[hrp.length] = 0
return result
}
private fun createChecksum(hrp: String, data: IntArray): IntArray {
val values = hrpExpand(hrp) + data + intArrayOf(0, 0, 0, 0, 0, 0)
val polymod = polymod(values) xor 1
return IntArray(6) { (polymod shr (5 * (5 - it))) and 31 }
}
}

View File

@ -0,0 +1,500 @@
package com.durian.tssparty.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.bouncycastle.jcajce.provider.digest.Keccak
import java.math.BigDecimal
import java.math.BigInteger
import java.util.concurrent.TimeUnit
/**
* Transaction utilities for Kava EVM
* Matches service-party-app/src/utils/transaction.ts
*/
object TransactionUtils {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
// Chain IDs
const val KAVA_TESTNET_CHAIN_ID = 2221
const val KAVA_MAINNET_CHAIN_ID = 2222
/**
* Prepared transaction ready for signing
*/
data class PreparedTransaction(
val nonce: BigInteger,
val gasPrice: BigInteger,
val gasLimit: BigInteger,
val to: String,
val from: String,
val value: BigInteger,
val data: ByteArray = ByteArray(0),
val chainId: Int,
val signHash: String, // Hash to be signed (hex with 0x prefix)
val rawTxForSigning: ByteArray // RLP encoded tx for signing
)
/**
* Transaction parameters for preparation
*/
data class TransactionParams(
val from: String,
val to: String,
val amount: String, // In KAVA (not wei)
val rpcUrl: String,
val chainId: Int = KAVA_TESTNET_CHAIN_ID
)
/**
* Prepare a transaction for signing
* Gets nonce, gas price, estimates gas, and calculates sign hash
*/
suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
try {
// 1. Get nonce
val nonce = getNonce(params.from, params.rpcUrl).getOrThrow()
// 2. Get gas price
val gasPrice = getGasPrice(params.rpcUrl).getOrThrow()
// 3. Convert amount to wei (1 KAVA = 10^18 wei)
val valueWei = kavaToWei(params.amount)
// 4. Estimate gas
val gasLimit = estimateGas(
from = params.from,
to = params.to,
value = valueWei,
rpcUrl = params.rpcUrl
).getOrElse { BigInteger.valueOf(21000) } // Default for simple transfer
// 5. RLP encode for signing (Legacy Type 0 format)
// Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
val rawTxForSigning = rlpEncodeForSigning(
nonce = nonce,
gasPrice = gasPrice,
gasLimit = gasLimit,
to = params.to,
value = valueWei,
data = ByteArray(0),
chainId = params.chainId
)
// 6. Calculate Keccak-256 hash
val signHash = keccak256(rawTxForSigning)
Result.success(PreparedTransaction(
nonce = nonce,
gasPrice = gasPrice,
gasLimit = gasLimit,
to = params.to,
from = params.from,
value = valueWei,
chainId = params.chainId,
signHash = "0x" + signHash.toHexString(),
rawTxForSigning = rawTxForSigning
))
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Finalize transaction with signature
* Returns the signed raw transaction hex string ready for broadcast
*/
fun finalizeTransaction(
preparedTx: PreparedTransaction,
r: ByteArray,
s: ByteArray,
recoveryId: Int
): String {
// Calculate EIP-155 v value
// v = chainId * 2 + 35 + recovery_id
val v = preparedTx.chainId * 2 + 35 + recoveryId
// RLP encode signed transaction
// Format: [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
val signedTx = rlpEncodeSigned(
nonce = preparedTx.nonce,
gasPrice = preparedTx.gasPrice,
gasLimit = preparedTx.gasLimit,
to = preparedTx.to,
value = preparedTx.value,
data = preparedTx.data,
v = BigInteger.valueOf(v.toLong()),
r = BigInteger(1, r),
s = BigInteger(1, s)
)
return "0x" + signedTx.toHexString()
}
/**
* Broadcast signed transaction to the network
*/
suspend fun broadcastTransaction(signedTx: String, rpcUrl: String): Result<String> = withContext(Dispatchers.IO) {
try {
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"params": ["$signedTx"],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
val errorMsg = json.get("error").asJsonObject.get("message").asString
return@withContext Result.failure(Exception(errorMsg))
}
val txHash = json.get("result").asString
Result.success(txHash)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Get transaction receipt (for confirmation)
*/
suspend fun getTransactionReceipt(txHash: String, rpcUrl: String): Result<TransactionReceipt?> = withContext(Dispatchers.IO) {
try {
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_getTransactionReceipt",
"params": ["$txHash"],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
val errorMsg = json.get("error").asJsonObject.get("message").asString
return@withContext Result.failure(Exception(errorMsg))
}
val result = json.get("result")
if (result.isJsonNull) {
// Transaction not yet mined
return@withContext Result.success(null)
}
val receipt = result.asJsonObject
Result.success(TransactionReceipt(
transactionHash = receipt.get("transactionHash").asString,
blockNumber = receipt.get("blockNumber").asString,
status = receipt.get("status").asString == "0x1",
gasUsed = BigInteger(receipt.get("gasUsed").asString.removePrefix("0x"), 16)
))
} catch (e: Exception) {
Result.failure(e)
}
}
data class TransactionReceipt(
val transactionHash: String,
val blockNumber: String,
val status: Boolean,
val gasUsed: BigInteger
)
// ========== RPC Methods ==========
private suspend fun getNonce(address: String, rpcUrl: String): Result<BigInteger> = withContext(Dispatchers.IO) {
try {
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_getTransactionCount",
"params": ["$address", "pending"],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
}
val hexNonce = json.get("result").asString
Result.success(BigInteger(hexNonce.removePrefix("0x"), 16))
} catch (e: Exception) {
Result.failure(e)
}
}
private suspend fun getGasPrice(rpcUrl: String): Result<BigInteger> = withContext(Dispatchers.IO) {
try {
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_gasPrice",
"params": [],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
}
val hexGasPrice = json.get("result").asString
Result.success(BigInteger(hexGasPrice.removePrefix("0x"), 16))
} catch (e: Exception) {
Result.failure(e)
}
}
private suspend fun estimateGas(
from: String,
to: String,
value: BigInteger,
rpcUrl: String
): Result<BigInteger> = withContext(Dispatchers.IO) {
try {
val valueHex = "0x" + value.toString(16)
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_estimateGas",
"params": [{
"from": "$from",
"to": "$to",
"value": "$valueHex"
}],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
}
val hexGas = json.get("result").asString
// Add 10% buffer
val gas = BigInteger(hexGas.removePrefix("0x"), 16)
val gasWithBuffer = gas.multiply(BigInteger.valueOf(110)).divide(BigInteger.valueOf(100))
Result.success(gasWithBuffer)
} catch (e: Exception) {
Result.failure(e)
}
}
// ========== Utility Methods ==========
fun kavaToWei(kava: String): BigInteger {
val decimal = BigDecimal(kava)
val weiDecimal = decimal.multiply(BigDecimal("1000000000000000000"))
return weiDecimal.toBigInteger()
}
fun weiToKava(wei: BigInteger): String {
val weiDecimal = BigDecimal(wei)
val kavaDecimal = weiDecimal.divide(BigDecimal("1000000000000000000"), 6, java.math.RoundingMode.DOWN)
return kavaDecimal.toPlainString()
}
fun weiToGwei(wei: BigInteger): String {
val weiDecimal = BigDecimal(wei)
val gweiDecimal = weiDecimal.divide(BigDecimal("1000000000"), 2, java.math.RoundingMode.DOWN)
return gweiDecimal.toPlainString()
}
private fun keccak256(data: ByteArray): ByteArray {
val keccak = Keccak.Digest256()
return keccak.digest(data)
}
// ========== RLP Encoding ==========
/**
* RLP encode transaction for signing (EIP-155)
* Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
*/
private fun rlpEncodeForSigning(
nonce: BigInteger,
gasPrice: BigInteger,
gasLimit: BigInteger,
to: String,
value: BigInteger,
data: ByteArray,
chainId: Int
): ByteArray {
val items = listOf(
rlpEncodeInteger(nonce),
rlpEncodeInteger(gasPrice),
rlpEncodeInteger(gasLimit),
rlpEncodeAddress(to),
rlpEncodeInteger(value),
rlpEncodeBytes(data),
rlpEncodeInteger(BigInteger.valueOf(chainId.toLong())),
rlpEncodeInteger(BigInteger.ZERO),
rlpEncodeInteger(BigInteger.ZERO)
)
return rlpEncodeList(items)
}
/**
* RLP encode signed transaction
* Format: [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
*/
private fun rlpEncodeSigned(
nonce: BigInteger,
gasPrice: BigInteger,
gasLimit: BigInteger,
to: String,
value: BigInteger,
data: ByteArray,
v: BigInteger,
r: BigInteger,
s: BigInteger
): ByteArray {
val items = listOf(
rlpEncodeInteger(nonce),
rlpEncodeInteger(gasPrice),
rlpEncodeInteger(gasLimit),
rlpEncodeAddress(to),
rlpEncodeInteger(value),
rlpEncodeBytes(data),
rlpEncodeInteger(v),
rlpEncodeInteger(r),
rlpEncodeInteger(s)
)
return rlpEncodeList(items)
}
private fun rlpEncodeInteger(value: BigInteger): ByteArray {
if (value == BigInteger.ZERO) {
return byteArrayOf(0x80.toByte())
}
val bytes = value.toByteArray()
// Remove leading zero if present
val trimmed = if (bytes[0] == 0.toByte() && bytes.size > 1) {
bytes.copyOfRange(1, bytes.size)
} else {
bytes
}
return rlpEncodeBytes(trimmed)
}
private fun rlpEncodeAddress(address: String): ByteArray {
val cleanAddress = address.removePrefix("0x")
val bytes = cleanAddress.hexToByteArray()
return rlpEncodeBytes(bytes)
}
private fun rlpEncodeBytes(bytes: ByteArray): ByteArray {
return when {
bytes.size == 1 && bytes[0].toInt() and 0xFF < 0x80 -> bytes
bytes.size <= 55 -> {
val result = ByteArray(1 + bytes.size)
result[0] = (0x80 + bytes.size).toByte()
System.arraycopy(bytes, 0, result, 1, bytes.size)
result
}
else -> {
val lengthBytes = bytes.size.toBigInteger().toByteArray().let { arr ->
if (arr[0] == 0.toByte() && arr.size > 1) arr.copyOfRange(1, arr.size) else arr
}
val result = ByteArray(1 + lengthBytes.size + bytes.size)
result[0] = (0xB7 + lengthBytes.size).toByte()
System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.size)
System.arraycopy(bytes, 0, result, 1 + lengthBytes.size, bytes.size)
result
}
}
}
private fun rlpEncodeList(items: List<ByteArray>): ByteArray {
val concatenated = items.fold(ByteArray(0)) { acc, item -> acc + item }
return when {
concatenated.size <= 55 -> {
val result = ByteArray(1 + concatenated.size)
result[0] = (0xC0 + concatenated.size).toByte()
System.arraycopy(concatenated, 0, result, 1, concatenated.size)
result
}
else -> {
val lengthBytes = concatenated.size.toBigInteger().toByteArray().let { arr ->
if (arr[0] == 0.toByte() && arr.size > 1) arr.copyOfRange(1, arr.size) else arr
}
val result = ByteArray(1 + lengthBytes.size + concatenated.size)
result[0] = (0xF7 + lengthBytes.size).toByte()
System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.size)
System.arraycopy(concatenated, 0, result, 1 + lengthBytes.size, concatenated.size)
result
}
}
}
// ========== Extension Functions ==========
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
private fun String.hexToByteArray(): ByteArray {
val len = this.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte()
i += 2
}
return data
}
}

View File

@ -0,0 +1,215 @@
syntax = "proto3";
package mpc.router.v1;
option java_package = "com.durian.tssparty.grpc";
option java_outer_classname = "MessageRouterProto";
option java_multiple_files = true;
// MessageRouter service handles MPC message routing
service MessageRouter {
// RouteMessage routes a message from one party to others
rpc RouteMessage(RouteMessageRequest) returns (RouteMessageResponse);
// SubscribeMessages subscribes to messages for a party (streaming)
rpc SubscribeMessages(SubscribeMessagesRequest) returns (stream MPCMessage);
// GetPendingMessages retrieves pending messages (polling alternative)
rpc GetPendingMessages(GetPendingMessagesRequest) returns (GetPendingMessagesResponse);
// AcknowledgeMessage acknowledges receipt of a message
rpc AcknowledgeMessage(AcknowledgeMessageRequest) returns (AcknowledgeMessageResponse);
// RegisterParty registers a party with the message router
rpc RegisterParty(RegisterPartyRequest) returns (RegisterPartyResponse);
// Heartbeat sends a heartbeat to keep the party alive
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
// SubscribeSessionEvents subscribes to session lifecycle events
rpc SubscribeSessionEvents(SubscribeSessionEventsRequest) returns (stream SessionEvent);
// JoinSession joins a session (proxied to Session Coordinator)
rpc JoinSession(JoinSessionRequest) returns (JoinSessionResponse);
// MarkPartyReady marks a party as ready
rpc MarkPartyReady(MarkPartyReadyRequest) returns (MarkPartyReadyResponse);
// ReportCompletion reports protocol completion
rpc ReportCompletion(ReportCompletionRequest) returns (ReportCompletionResponse);
// GetSessionStatus gets session status
rpc GetSessionStatus(GetSessionStatusRequest) returns (GetSessionStatusResponse);
}
message RouteMessageRequest {
string session_id = 1;
string from_party = 2;
repeated string to_parties = 3;
int32 round_number = 4;
string message_type = 5;
bytes payload = 6;
}
message RouteMessageResponse {
bool success = 1;
string message_id = 2;
}
message SubscribeMessagesRequest {
string session_id = 1;
string party_id = 2;
}
message MPCMessage {
string message_id = 1;
string session_id = 2;
string from_party = 3;
bool is_broadcast = 4;
int32 round_number = 5;
string message_type = 6;
bytes payload = 7;
int64 created_at = 8;
}
message GetPendingMessagesRequest {
string session_id = 1;
string party_id = 2;
int64 after_timestamp = 3;
}
message GetPendingMessagesResponse {
repeated MPCMessage messages = 1;
}
message NotificationChannel {
string email = 1;
string phone = 2;
string push_token = 3;
}
message RegisterPartyRequest {
string party_id = 1;
string party_role = 2;
string version = 3;
NotificationChannel notification = 4;
}
message RegisterPartyResponse {
bool success = 1;
string message = 2;
int64 registered_at = 3;
}
message SubscribeSessionEventsRequest {
string party_id = 1;
repeated string event_types = 2;
}
message SessionEvent {
string event_id = 1;
string event_type = 2;
string session_id = 3;
int32 threshold_n = 4;
int32 threshold_t = 5;
repeated string selected_parties = 6;
map<string, string> join_tokens = 7;
bytes message_hash = 8;
int64 created_at = 9;
int64 expires_at = 10;
}
message AcknowledgeMessageRequest {
string message_id = 1;
string party_id = 2;
string session_id = 3;
bool success = 4;
string error_message = 5;
}
message AcknowledgeMessageResponse {
bool success = 1;
string message = 2;
}
message HeartbeatRequest {
string party_id = 1;
int64 timestamp = 2;
}
message HeartbeatResponse {
bool success = 1;
int64 server_timestamp = 2;
int32 pending_messages = 3;
}
message DeviceInfo {
string device_type = 1;
string device_id = 2;
string platform = 3;
string app_version = 4;
}
message PartyInfo {
string party_id = 1;
int32 party_index = 2;
DeviceInfo device_info = 3;
}
message SessionInfo {
string session_id = 1;
string session_type = 2;
int32 threshold_n = 3;
int32 threshold_t = 4;
bytes message_hash = 5;
string status = 6;
string keygen_session_id = 7;
}
message JoinSessionRequest {
string session_id = 1;
string party_id = 2;
string join_token = 3;
DeviceInfo device_info = 4;
}
message JoinSessionResponse {
bool success = 1;
SessionInfo session_info = 2;
repeated PartyInfo other_parties = 3;
int32 party_index = 4;
}
message MarkPartyReadyRequest {
string session_id = 1;
string party_id = 2;
}
message MarkPartyReadyResponse {
bool success = 1;
bool all_ready = 2;
}
message ReportCompletionRequest {
string session_id = 1;
string party_id = 2;
bytes public_key = 3;
bytes signature = 4;
}
message ReportCompletionResponse {
bool success = 1;
bool all_completed = 2;
}
message GetSessionStatusRequest {
string session_id = 1;
}
message GetSessionStatusResponse {
string session_id = 1;
string status = 2;
int32 threshold_n = 3;
int32 threshold_t = 4;
repeated PartyInfo participants = 5;
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple wallet icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 L78,30 C80.2,30 82,31.8 82,34 L82,74 C82,76.2 80.2,78 78,78 L30,78 C27.8,78 26,76.2 26,74 L26,34 C26,31.8 27.8,30 30,30 L54,30 Z M54,26 L30,26 C25.6,26 22,29.6 22,34 L22,74 C22,78.4 25.6,82 30,82 L78,82 C82.4,82 86,78.4 86,74 L86,34 C86,29.6 82.4,26 78,26 L54,26 Z"/>
<!-- Key symbol -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,44 C58.4,44 62,47.6 62,52 C62,54.8 60.6,57.2 58.4,58.6 L58.4,66 L50,66 L50,58.6 C47.4,57.2 46,54.8 46,52 C46,47.6 49.6,44 54,44 Z M54,48 C51.8,48 50,49.8 50,52 C50,53.4 50.8,54.6 52,55.2 L52,62 L56,62 L56,55.2 C57.2,54.6 58,53.4 58,52 C58,49.8 56.2,48 54,48 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/green_primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/green_primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="green_primary">#4CAF50</color>
<color name="green_dark">#388E3C</color>
<color name="green_light">#81C784</color>
<color name="white">#FFFFFF</color>
<color name="black">#000000</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TSS Party</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TssParty" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/green_primary</item>
</style>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
<exclude domain="database" path="tss_party.db"/>
</full-backup-content>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
<exclude domain="database" path="tss_party.db"/>
</cloud-backup>
<device-transfer>
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
<exclude domain="database" path="tss_party.db"/>
</device-transfer>
</data-extraction-rules>

View File

@ -0,0 +1,18 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.21" apply false
id("com.google.dagger.hilt.android") version "2.48.1" apply false
id("com.google.protobuf") version "0.9.4" apply false
}
buildscript {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean", Delete::class) {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,11 @@
# Project-wide Gradle settings
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
# Android settings
android.useAndroidX=true
android.nonTransitiveRClass=true
# Kotlin settings
kotlin.code.style=official

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -0,0 +1,184 @@
#!/bin/sh
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html
#
# (2) You need a Java Runtime Environment (JRE) to run Gradle.
#
##############################################################################
#
# Gradle start up script for POSIX
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MSYS* | MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kass://www.gradle.org/
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Annoying
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Annoying
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell://www.gnu.org/software/bash/manual/html_node/Quoting.html
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

View File

@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TSSPartyAndroid"
include(":app")

View File

@ -0,0 +1,23 @@
@echo off
REM Build TSS library for Android using gomobile
echo === Building TSS Library for Android ===
REM Check if gomobile is available
where gomobile >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo Installing gomobile...
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
)
REM Download dependencies
echo Downloading Go dependencies...
go mod tidy
REM Build for Android
echo Building Android AAR...
gomobile bind -target=android -androidapi=26 -o ..\app\libs\tsslib.aar .
echo === Build complete! ===
echo Output: ..\app\libs\tsslib.aar

View File

@ -0,0 +1,24 @@
#!/bin/bash
# Build TSS library for Android using gomobile
set -e
echo "=== Building TSS Library for Android ==="
# Check if gomobile is installed
if ! command -v gomobile &> /dev/null; then
echo "Installing gomobile..."
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
fi
# Download dependencies
echo "Downloading Go dependencies..."
go mod tidy
# Build for Android
echo "Building Android AAR..."
gomobile bind -target=android -androidapi=26 -o ../app/libs/tsslib.aar .
echo "=== Build complete! ==="
echo "Output: ../app/libs/tsslib.aar"

View File

@ -0,0 +1,36 @@
module github.com/rwadurian/tsslib
go 1.24.0
require github.com/bnb-chain/tss-lib/v2 v2.0.2
require (
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
github.com/btcsuite/btcd v0.23.4 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
github.com/btcsuite/btcutil v1.0.2 // indirect
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/ipfs/go-log v1.0.5 // indirect
github.com/ipfs/go-log/v2 v2.5.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)
// Replace to fix tss-lib dependency issue with ed25519
replace github.com/agl/ed25519 => github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412

View File

@ -0,0 +1,264 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI=
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bnb-chain/tss-lib/v2 v2.0.2 h1:dL2GJFCSYsYQ0bHkGll+hNM2JWsC1rxDmJJJQEmUy9g=
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik=
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/jsonindent v0.0.0-20171116142732-447bf004320b/go.mod h1:SXIpH2WO0dyF5YBc6Iq8jc8TEJYe1Fk2Rc1EVYUdIgY=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.2 h1:VYWnrP5fXmz1MXvjuUvcBrXSjGE6xjON+axB/UrpO3E=
github.com/otiai10/mint v1.3.2/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 h1:7x5D/2dkkr27Tgh4WFuX+iCS6OzuE5YJoqJzeqM+5mc=
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11/go.mod h1:1DmRMnU78i/OVkMnHzvhXSi4p8IhYUmtLJWhyOavJc0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=

View File

@ -0,0 +1,629 @@
// Package tsslib provides TSS (Threshold Signature Scheme) functionality for Android
// This package is designed to be compiled with gomobile for Android integration via JNI
//
// Based on the verified tss-party implementation from service-party-app (Electron version)
package tsslib
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"sync"
"time"
"github.com/bnb-chain/tss-lib/v2/common"
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
"github.com/bnb-chain/tss-lib/v2/ecdsa/signing"
"github.com/bnb-chain/tss-lib/v2/tss"
)
// MessageCallback is the interface for receiving TSS protocol messages
// Android side implements this interface to handle message routing
type MessageCallback interface {
// OnOutgoingMessage is called when TSS needs to send a message to other parties
// messageJSON contains: type, isBroadcast, toParties, payload (base64)
OnOutgoingMessage(messageJSON string)
// OnProgress is called to report protocol progress
OnProgress(round, totalRounds int)
// OnError is called when an error occurs
OnError(errorMessage string)
// OnLog is called for debug logging
OnLog(message string)
}
// Participant represents a party in the TSS protocol
type Participant struct {
PartyID string `json:"partyId"`
PartyIndex int `json:"partyIndex"`
}
// tssSession manages a TSS keygen or signing session
type tssSession struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
callback MessageCallback
localParty tss.Party
partyIndexMap map[int]*tss.PartyID
errCh chan error
keygenResultCh chan *keygen.LocalPartySaveData
signResultCh chan *common.SignatureData
isKeygen bool
}
var (
currentSession *tssSession
sessionMu sync.Mutex
)
// StartKeygen initiates a new key generation session
// This is the entry point called from Android via JNI
func StartKeygen(
sessionID, partyID string,
partyIndex, thresholdT, thresholdN int,
participantsJSON, password string,
callback MessageCallback,
) error {
sessionMu.Lock()
defer sessionMu.Unlock()
if currentSession != nil {
return fmt.Errorf("a session is already in progress")
}
// Parse participants
var participants []Participant
if err := json.Unmarshal([]byte(participantsJSON), &participants); err != nil {
return fmt.Errorf("failed to parse participants: %w", err)
}
if len(participants) != thresholdN {
return fmt.Errorf("participant count mismatch: got %d, expected %d", len(participants), thresholdN)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
session := &tssSession{
ctx: ctx,
cancel: cancel,
callback: callback,
partyIndexMap: make(map[int]*tss.PartyID),
errCh: make(chan error, 1),
keygenResultCh: make(chan *keygen.LocalPartySaveData, 1),
isKeygen: true,
}
// Create TSS party IDs - same as verified Electron version
tssPartyIDs := make([]*tss.PartyID, len(participants))
var selfTSSID *tss.PartyID
for i, p := range participants {
partyKey := tss.NewPartyID(
p.PartyID,
fmt.Sprintf("party-%d", p.PartyIndex),
big.NewInt(int64(p.PartyIndex+1)),
)
tssPartyIDs[i] = partyKey
if p.PartyID == partyID {
selfTSSID = partyKey
}
}
if selfTSSID == nil {
cancel()
return fmt.Errorf("self party not found in participants")
}
// Sort party IDs
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Build party index map for incoming messages
for _, p := range sortedPartyIDs {
for _, orig := range participants {
if orig.PartyID == p.Id {
session.partyIndexMap[orig.PartyIndex] = p
break
}
}
}
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
// For 2-of-3: thresholdT=2, tss-lib threshold=1, signers_needed=1+1=2 ✓
peerCtx := tss.NewPeerContext(sortedPartyIDs)
tssThreshold := thresholdT - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
callback.OnLog(fmt.Sprintf("[TSS-KEYGEN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)",
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT))
// Create channels
outCh := make(chan tss.Message, thresholdN*10)
endCh := make(chan *keygen.LocalPartySaveData, 1)
// Create local party
localParty := keygen.NewLocalParty(params, outCh, endCh)
session.localParty = localParty
// Start the local party
go func() {
if err := localParty.Start(); err != nil {
session.errCh <- err
}
}()
// Handle outgoing messages
go func() {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-outCh:
if !ok {
return
}
session.handleOutgoingMessage(msg)
}
}
}()
// Handle completion
go func() {
select {
case <-ctx.Done():
callback.OnError("session timeout or cancelled")
case err := <-session.errCh:
callback.OnError(fmt.Sprintf("keygen error: %v", err))
case saveData := <-endCh:
session.keygenResultCh <- saveData
}
}()
currentSession = session
return nil
}
// StartSign initiates a new signing session
// Based on verified executeSign from Electron version
func StartSign(
sessionID, partyID string,
partyIndex, thresholdT, thresholdN int,
participantsJSON, messageHashHex, shareDataBase64, password string,
callback MessageCallback,
) error {
sessionMu.Lock()
defer sessionMu.Unlock()
if currentSession != nil {
return fmt.Errorf("a session is already in progress")
}
// Parse participants
var participants []Participant
if err := json.Unmarshal([]byte(participantsJSON), &participants); err != nil {
return fmt.Errorf("failed to parse participants: %w", err)
}
// Note: For signing, participant count equals threshold T (not N)
// because only T parties participate in signing
if len(participants) != thresholdT {
return fmt.Errorf("participant count mismatch: got %d, expected %d (threshold T)", len(participants), thresholdT)
}
// Decode and decrypt share data
encryptedShare, err := base64.StdEncoding.DecodeString(shareDataBase64)
if err != nil {
return fmt.Errorf("failed to decode share data: %w", err)
}
shareBytes, err := decryptShare(encryptedShare, password)
if err != nil {
return fmt.Errorf("failed to decrypt share: %w", err)
}
// Parse keygen save data
var keygenData keygen.LocalPartySaveData
if err := json.Unmarshal(shareBytes, &keygenData); err != nil {
return fmt.Errorf("failed to parse keygen data: %w", err)
}
// Decode message hash
messageHash, err := hex.DecodeString(messageHashHex)
if err != nil {
return fmt.Errorf("failed to decode message hash: %w", err)
}
if len(messageHash) != 32 {
return fmt.Errorf("message hash must be 32 bytes, got %d", len(messageHash))
}
msgBigInt := new(big.Int).SetBytes(messageHash)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
session := &tssSession{
ctx: ctx,
cancel: cancel,
callback: callback,
partyIndexMap: make(map[int]*tss.PartyID),
errCh: make(chan error, 1),
signResultCh: make(chan *common.SignatureData, 1),
isKeygen: false,
}
// Create TSS party IDs for signing participants
// IMPORTANT: For tss-lib signing, we must reconstruct the party IDs in the same way
// as during keygen. The signing subset (T parties) must use their original keys from keygen.
tssPartyIDs := make([]*tss.PartyID, 0, len(participants))
var selfTSSID *tss.PartyID
for _, p := range participants {
partyKey := tss.NewPartyID(
p.PartyID,
fmt.Sprintf("party-%d", p.PartyIndex),
big.NewInt(int64(p.PartyIndex+1)),
)
tssPartyIDs = append(tssPartyIDs, partyKey)
if p.PartyID == partyID {
selfTSSID = partyKey
}
}
if selfTSSID == nil {
cancel()
return fmt.Errorf("self party not found in participants")
}
// Sort party IDs (important for tss-lib)
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Build party index map for incoming messages
for _, p := range sortedPartyIDs {
for _, orig := range participants {
if orig.PartyID == p.Id {
session.partyIndexMap[orig.PartyIndex] = p
break
}
}
}
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// This MUST match keygen exactly!
peerCtx := tss.NewPeerContext(sortedPartyIDs)
tssThreshold := thresholdT - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
callback.OnLog(fmt.Sprintf("[TSS-SIGN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)",
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT))
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Original keygenData has %d parties (Ks length)", len(keygenData.Ks)))
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Building subset for %d signing parties", len(sortedPartyIDs)))
// CRITICAL: Build a subset of the keygen save data for the current signing parties
// This is required when signing with a subset of the original keygen participants.
subsetKeygenData := keygen.BuildLocalSaveDataSubset(keygenData, sortedPartyIDs)
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Subset keygenData has %d parties (Ks length)", len(subsetKeygenData.Ks)))
// Create channels
outCh := make(chan tss.Message, thresholdT*10)
endCh := make(chan *common.SignatureData, 1)
// Create local party for signing with the SUBSET keygen data
localParty := signing.NewLocalParty(msgBigInt, params, subsetKeygenData, outCh, endCh)
session.localParty = localParty
// Start the local party
go func() {
if err := localParty.Start(); err != nil {
session.errCh <- err
}
}()
// Handle outgoing messages
go func() {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-outCh:
if !ok {
return
}
session.handleOutgoingMessage(msg)
}
}
}()
// Handle completion
go func() {
select {
case <-ctx.Done():
callback.OnError("session timeout or cancelled")
case err := <-session.errCh:
callback.OnError(fmt.Sprintf("sign error: %v", err))
case sigData := <-endCh:
session.signResultCh <- sigData
}
}()
currentSession = session
return nil
}
// SendIncomingMessage delivers a message from another party to the current session
func SendIncomingMessage(fromPartyIndex int, isBroadcast bool, payloadBase64 string) error {
sessionMu.Lock()
session := currentSession
sessionMu.Unlock()
if session == nil {
return fmt.Errorf("no active session")
}
session.mu.Lock()
defer session.mu.Unlock()
fromParty, ok := session.partyIndexMap[fromPartyIndex]
if !ok {
return fmt.Errorf("unknown party index: %d", fromPartyIndex)
}
payload, err := base64.StdEncoding.DecodeString(payloadBase64)
if err != nil {
return fmt.Errorf("failed to decode payload: %w", err)
}
parsedMsg, err := tss.ParseWireMessage(payload, fromParty, isBroadcast)
if err != nil {
return fmt.Errorf("failed to parse message: %w", err)
}
go func() {
_, err := session.localParty.Update(parsedMsg)
if err != nil {
// Only send fatal errors
if !isDuplicateError(err) {
session.errCh <- err
}
}
}()
return nil
}
// WaitForKeygenResult blocks until keygen completes and returns the result as JSON
func WaitForKeygenResult(password string) (string, error) {
sessionMu.Lock()
session := currentSession
sessionMu.Unlock()
if session == nil {
return "", fmt.Errorf("no active session")
}
if !session.isKeygen {
return "", fmt.Errorf("current session is not a keygen session")
}
// Track progress - GG20 keygen has 4 rounds
totalRounds := 4
select {
case <-session.ctx.Done():
return "", session.ctx.Err()
case saveData := <-session.keygenResultCh:
// Keygen completed successfully
session.callback.OnProgress(totalRounds, totalRounds)
// Get public key - same as Electron version
pubKey := saveData.ECDSAPub.ToECDSAPubKey()
pubKeyBytes := make([]byte, 33)
pubKeyBytes[0] = 0x02 + byte(pubKey.Y.Bit(0))
xBytes := pubKey.X.Bytes()
copy(pubKeyBytes[33-len(xBytes):], xBytes)
// Serialize and encrypt save data
saveDataBytes, err := json.Marshal(saveData)
if err != nil {
return "", fmt.Errorf("failed to serialize save data: %w", err)
}
// Encrypt with password (same as Electron version)
encryptedShare := encryptShare(saveDataBytes, password)
result := struct {
PublicKey string `json:"publicKey"`
EncryptedShare string `json:"encryptedShare"`
}{
PublicKey: base64.StdEncoding.EncodeToString(pubKeyBytes),
EncryptedShare: base64.StdEncoding.EncodeToString(encryptedShare),
}
resultJSON, _ := json.Marshal(result)
// Clean up session
session.cancel()
sessionMu.Lock()
currentSession = nil
sessionMu.Unlock()
return string(resultJSON), nil
}
}
// WaitForSignResult blocks until signing completes and returns the result as JSON
func WaitForSignResult() (string, error) {
sessionMu.Lock()
session := currentSession
sessionMu.Unlock()
if session == nil {
return "", fmt.Errorf("no active session")
}
if session.isKeygen {
return "", fmt.Errorf("current session is not a sign session")
}
// Track progress - GG20 signing has 9 rounds
totalRounds := 9
select {
case <-session.ctx.Done():
return "", session.ctx.Err()
case sigData := <-session.signResultCh:
// Signing completed successfully
session.callback.OnProgress(totalRounds, totalRounds)
// Construct signature: R (32 bytes) || S (32 bytes)
rBytes := sigData.R
sBytes := sigData.S
signature := make([]byte, 64)
copy(signature[32-len(rBytes):32], rBytes)
copy(signature[64-len(sBytes):64], sBytes)
// Recovery ID for Ethereum-style signatures
recoveryID := int(sigData.SignatureRecovery[0])
// Append recovery ID to signature (r + s + v = 64 + 1 = 65 bytes)
// This is needed for EVM transaction signing
signatureWithV := make([]byte, 65)
copy(signatureWithV, signature)
signatureWithV[64] = byte(recoveryID)
result := struct {
Signature string `json:"signature"`
RecoveryID int `json:"recoveryId"`
}{
Signature: base64.StdEncoding.EncodeToString(signatureWithV),
RecoveryID: recoveryID,
}
resultJSON, _ := json.Marshal(result)
// Clean up session
session.cancel()
sessionMu.Lock()
currentSession = nil
sessionMu.Unlock()
return string(resultJSON), nil
}
}
// CancelSession cancels the current session
func CancelSession() {
sessionMu.Lock()
defer sessionMu.Unlock()
if currentSession != nil {
currentSession.cancel()
currentSession = nil
}
}
func (s *tssSession) handleOutgoingMessage(msg tss.Message) {
msgBytes, _, err := msg.WireBytes()
if err != nil {
return
}
var toParties []string
if !msg.IsBroadcast() {
for _, to := range msg.GetTo() {
toParties = append(toParties, to.Id)
}
}
outMsg := struct {
Type string `json:"type"`
IsBroadcast bool `json:"isBroadcast"`
ToParties []string `json:"toParties,omitempty"`
Payload string `json:"payload"`
}{
Type: "outgoing",
IsBroadcast: msg.IsBroadcast(),
ToParties: toParties,
Payload: base64.StdEncoding.EncodeToString(msgBytes),
}
data, _ := json.Marshal(outMsg)
s.callback.OnOutgoingMessage(string(data))
}
func isDuplicateError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "duplicate") || contains(errStr, "already received")
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// encryptShare encrypts the share data with password
// Same implementation as Electron version for compatibility
func encryptShare(data []byte, password string) []byte {
// TODO: Use proper AES-256-GCM encryption
// For now, just prepend a marker and the password hash
// This is NOT secure - just a placeholder (same as Electron version)
result := make([]byte, len(data)+32)
copy(result[:32], hashPassword(password))
copy(result[32:], data)
return result
}
// decryptShare decrypts the share data with password
// Same implementation as Electron version for compatibility
func decryptShare(encryptedData []byte, password string) ([]byte, error) {
// Match the encryption format: first 32 bytes are password hash, rest is data
if len(encryptedData) < 32 {
return nil, fmt.Errorf("encrypted data too short")
}
// Verify password (simple check - matches encryptShare)
expectedHash := hashPassword(password)
actualHash := encryptedData[:32]
// Simple comparison
match := true
for i := 0; i < 32; i++ {
if expectedHash[i] != actualHash[i] {
match = false
break
}
}
if !match {
return nil, fmt.Errorf("incorrect password")
}
return encryptedData[32:], nil
}
// hashPassword creates a simple hash of the password
// Same implementation as Electron version for compatibility
func hashPassword(password string) []byte {
// Simple hash - should use PBKDF2 or Argon2 in production
hash := make([]byte, 32)
for i := 0; i < len(password) && i < 32; i++ {
hash[i] = password[i]
}
return hash
}

View File

@ -414,8 +414,8 @@ async function initServices() {
}
});
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认测试网)
const kavaNetwork = database.getSetting('kava_network') || 'testnet';
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认网)
const kavaNetwork = database.getSetting('kava_network') || 'mainnet';
const kavaConfig = kavaNetwork === 'mainnet' ? KAVA_MAINNET_TX_CONFIG : KAVA_TESTNET_TX_CONFIG;
kavaTxService = new KavaTxService(kavaConfig);
debugLog.info('kava', `Kava network: ${kavaNetwork}`);

View File

@ -18,7 +18,7 @@ const navItems = [
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const [isRefreshing, setIsRefreshing] = useState(false);
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
const { environment, operation, checkAllServices, appReady } = useAppStore();

View File

@ -18,7 +18,7 @@ export default function Settings() {
autoBackup: false,
backupPath: '',
});
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);

View File

@ -23,7 +23,7 @@ export function getCurrentNetwork(): 'mainnet' | 'testnet' {
return stored;
}
}
return 'testnet'; // 默认测试
return 'mainnet'; // 默认主
}
export function getCurrentChainId(): number {