From 7b6d6de8019f2501c60142a305d68fc6e54389bd Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 31 Dec 2025 23:27:29 -0800 Subject: [PATCH] feat(android): add Android TSS Party app with full API implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../services/service-party-android/.gitignore | 96 ++ .../services/service-party-android/README.md | 101 ++ .../app/build.gradle.kts | 185 +++ .../app/libs/tsslib-sources.jar | Bin 0 -> 7343 bytes .../app/proguard-rules.pro | 21 + .../app/src/main/AndroidManifest.xml | 36 + .../java/com/durian/tssparty/MainActivity.kt | 396 ++++++ .../durian/tssparty/TssPartyApplication.kt | 7 + .../durian/tssparty/data/local/Database.kt | 79 ++ .../tssparty/data/local/TssNativeBridge.kt | 172 +++ .../durian/tssparty/data/remote/GrpcClient.kt | 334 +++++ .../tssparty/data/repository/TssRepository.kt | 1221 +++++++++++++++++ .../java/com/durian/tssparty/di/AppModule.kt | 66 + .../durian/tssparty/domain/model/AppState.kt | 58 + .../durian/tssparty/domain/model/Models.kt | 125 ++ .../components/BottomNavigation.kt | 85 ++ .../presentation/screens/CoSignJoinScreen.kt | 865 ++++++++++++ .../screens/CreateWalletScreen.kt | 989 +++++++++++++ .../presentation/screens/HomeScreen.kt | 211 +++ .../presentation/screens/JoinKeygenScreen.kt | 727 ++++++++++ .../presentation/screens/JoinScreen.kt | 229 ++++ .../presentation/screens/SettingsScreen.kt | 537 ++++++++ .../presentation/screens/SignScreen.kt | 199 +++ .../screens/StartupCheckScreen.kt | 273 ++++ .../presentation/screens/TransferScreen.kt | 1061 ++++++++++++++ .../presentation/screens/WalletsScreen.kt | 805 +++++++++++ .../presentation/viewmodel/MainViewModel.kt | 855 ++++++++++++ .../com/durian/tssparty/ui/theme/Theme.kt | 79 ++ .../java/com/durian/tssparty/ui/theme/Type.kt | 31 + .../com/durian/tssparty/util/AddressUtils.kt | 186 +++ .../durian/tssparty/util/TransactionUtils.kt | 500 +++++++ .../app/src/main/proto/message_router.proto | 215 +++ .../res/drawable/ic_launcher_foreground.xml | 17 + .../src/main/res/mipmap-hdpi/ic_launcher.xml | 5 + .../res/mipmap-hdpi/ic_launcher_round.xml | 5 + .../app/src/main/res/values/colors.xml | 8 + .../app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/values/themes.xml | 6 + .../app/src/main/res/xml/backup_rules.xml | 6 + .../main/res/xml/data_extraction_rules.xml | 13 + .../service-party-android/build.gradle.kts | 18 + .../service-party-android/gradle.properties | 11 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63721 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + .../services/service-party-android/gradlew | 184 +++ .../service-party-android/gradlew.bat | 92 ++ .../service-party-android/settings.gradle.kts | 18 + .../service-party-android/tsslib/build.bat | 23 + .../service-party-android/tsslib/build.sh | 24 + .../service-party-android/tsslib/go.mod | 36 + .../service-party-android/tsslib/go.sum | 264 ++++ .../service-party-android/tsslib/tsslib.go | 629 +++++++++ .../service-party-app/electron/main.ts | 4 +- .../src/components/Layout.tsx | 2 +- .../service-party-app/src/pages/Settings.tsx | 2 +- .../src/utils/transaction.ts | 2 +- 56 files changed, 12119 insertions(+), 5 deletions(-) create mode 100644 backend/mpc-system/services/service-party-android/.gitignore create mode 100644 backend/mpc-system/services/service-party-android/README.md create mode 100644 backend/mpc-system/services/service-party-android/app/build.gradle.kts create mode 100644 backend/mpc-system/services/service-party-android/app/libs/tsslib-sources.jar create mode 100644 backend/mpc-system/services/service-party-android/app/proguard-rules.pro create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/AndroidManifest.xml create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/TssPartyApplication.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/TssNativeBridge.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/AppState.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/components/BottomNavigation.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/HomeScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SettingsScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SignScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Theme.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Type.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/AddressUtils.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/TransactionUtils.kt create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/proto/message_router.proto create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/res/values/colors.xml create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/res/values/strings.xml create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/res/values/themes.xml create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/res/xml/backup_rules.xml create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 backend/mpc-system/services/service-party-android/build.gradle.kts create mode 100644 backend/mpc-system/services/service-party-android/gradle.properties create mode 100644 backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.properties create mode 100644 backend/mpc-system/services/service-party-android/gradlew create mode 100644 backend/mpc-system/services/service-party-android/gradlew.bat create mode 100644 backend/mpc-system/services/service-party-android/settings.gradle.kts create mode 100644 backend/mpc-system/services/service-party-android/tsslib/build.bat create mode 100644 backend/mpc-system/services/service-party-android/tsslib/build.sh create mode 100644 backend/mpc-system/services/service-party-android/tsslib/go.mod create mode 100644 backend/mpc-system/services/service-party-android/tsslib/go.sum create mode 100644 backend/mpc-system/services/service-party-android/tsslib/tsslib.go diff --git a/backend/mpc-system/services/service-party-android/.gitignore b/backend/mpc-system/services/service-party-android/.gitignore new file mode 100644 index 00000000..08067624 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/.gitignore @@ -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 diff --git a/backend/mpc-system/services/service-party-android/README.md b/backend/mpc-system/services/service-party-android/README.md new file mode 100644 index 00000000..6ec234cd --- /dev/null +++ b/backend/mpc-system/services/service-party-android/README.md @@ -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 diff --git a/backend/mpc-system/services/service-party-android/app/build.gradle.kts b/backend/mpc-system/services/service-party-android/app/build.gradle.kts new file mode 100644 index 00000000..1eabf5dd --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/build.gradle.kts @@ -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 +} diff --git a/backend/mpc-system/services/service-party-android/app/libs/tsslib-sources.jar b/backend/mpc-system/services/service-party-android/app/libs/tsslib-sources.jar new file mode 100644 index 0000000000000000000000000000000000000000..b8480d730e0eb1e789c0d3ea68be398e02bd23bf GIT binary patch literal 7343 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 + + + + + + + + + + + + + + + + + + + + diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt new file mode 100644 index 00000000..5b09ddb0 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/MainActivity.kt @@ -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(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) + } + ) + } + } + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/TssPartyApplication.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/TssPartyApplication.kt new file mode 100644 index 00000000..142582d2 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/TssPartyApplication.kt @@ -0,0 +1,7 @@ +package com.durian.tssparty + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class TssPartyApplication : Application() diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt new file mode 100644 index 00000000..45f0331a --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt @@ -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> + + @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 +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/TssNativeBridge.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/TssNativeBridge.kt new file mode 100644 index 00000000..b37ce1b2 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/TssNativeBridge.kt @@ -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(Channel.BUFFERED) + val outgoingMessages: Flow = _outgoingMessages.receiveAsFlow() + + private val _progress = Channel>(Channel.BUFFERED) + val progress: Flow> = _progress.receiveAsFlow() + + private val _errors = Channel(Channel.BUFFERED) + val errors: Flow = _errors.receiveAsFlow() + + private val _logs = Channel(Channel.BUFFERED) + val logs: Flow = _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, + password: String + ): Result = 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, + messageHash: String, + shareData: String, + password: String + ): Result = 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 = 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 = 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 = 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() + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt new file mode 100644 index 00000000..ec560879 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/remote/GrpcClient.kt @@ -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 = 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 = 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 = 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, + roundNumber: Int, + messageType: String, + payload: ByteArray + ): Result = 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 = callbackFlow { + val request = SubscribeMessagesRequest.newBuilder() + .setSessionId(sessionId) + .setPartyId(partyId) + .build() + + val observer = object : StreamObserver { + 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 = callbackFlow { + val request = SubscribeSessionEventsRequest.newBuilder() + .setPartyId(partyId) + .build() + + val observer = object : StreamObserver { + 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 = 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 = 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, + 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, + val joinTokens: Map, + val messageHash: String? +) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt new file mode 100644 index 00000000..67f41c41 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt @@ -0,0 +1,1221 @@ +package com.durian.tssparty.data.repository + +import android.util.Base64 +import com.durian.tssparty.data.local.ShareRecordDao +import com.durian.tssparty.data.local.ShareRecordEntity +import com.durian.tssparty.data.local.TssNativeBridge +import com.durian.tssparty.data.remote.GrpcClient +import com.durian.tssparty.data.remote.IncomingMessage +import com.durian.tssparty.data.remote.JoinSessionData +import com.durian.tssparty.data.remote.SessionEventData +import com.durian.tssparty.domain.model.* +import com.durian.tssparty.util.AddressUtils +import com.durian.tssparty.util.TransactionUtils +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository for TSS operations + */ +@Singleton +class TssRepository @Inject constructor( + private val grpcClient: GrpcClient, + private val tssNativeBridge: TssNativeBridge, + private val shareRecordDao: ShareRecordDao +) { + private val _currentSession = MutableStateFlow(null) + val currentSession: StateFlow = _currentSession.asStateFlow() + + private val _sessionStatus = MutableStateFlow(SessionStatus.WAITING) + val sessionStatus: StateFlow = _sessionStatus.asStateFlow() + + private var partyId: String = UUID.randomUUID().toString() + private var messageCollectionJob: Job? = null + + // Account service URL (configurable via settings) + private var accountServiceUrl: String = "https://rwaapi.szaiai.com" + + // HTTP client for API calls + private val httpClient = okhttp3.OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + + /** + * Update account service URL from settings + */ + fun setAccountServiceUrl(url: String) { + accountServiceUrl = url.trimEnd('/') + } + + /** + * Connect to the Message Router + */ + fun connect(host: String, port: Int) { + grpcClient.connect(host, port) + } + + /** + * Disconnect from the server + */ + fun disconnect() { + messageCollectionJob?.cancel() + grpcClient.disconnect() + } + + /** + * Register this party with the router + */ + suspend fun registerParty(): String { + grpcClient.registerParty(partyId, "temporary", "1.0.0") + return partyId + } + + /** + * Get share count for startup check + */ + suspend fun getShareCount(): Int { + return shareRecordDao.getShareCount() + } + + /** + * Check Kava blockchain health + */ + suspend fun checkKavaHealth(): Boolean { + // TODO: Implement actual Kava RPC health check + return true + } + + /** + * Create a new keygen session (as initiator) + * Calls account-service API: POST /api/v1/co-managed/sessions + */ + suspend fun createKeygenSession( + walletName: String, + thresholdT: Int, + thresholdN: Int, + participantName: String + ): Result { + return withContext(Dispatchers.IO) { + try { + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + // Build request body matching account-service API + val requestBody = com.google.gson.JsonObject().apply { + addProperty("wallet_name", walletName) + addProperty("threshold_t", thresholdT) + addProperty("threshold_n", thresholdN) + addProperty("initiator_party_id", partyId) + addProperty("initiator_name", participantName) + addProperty("persistent_count", 0) // All external participants + addProperty("external_count", thresholdN) + }.toString() + + val request = okhttp3.Request.Builder() + .url("$accountServiceUrl/api/v1/co-managed/sessions") + .post(requestBody.toRequestBody(jsonMediaType)) + .header("Content-Type", "application/json") + .build() + + android.util.Log.d("TssRepository", "Creating keygen session: $requestBody") + + val response = httpClient.newCall(request).execute() + val responseBody = response.body?.string() + ?: return@withContext Result.failure(Exception("空响应")) + + android.util.Log.d("TssRepository", "Create session response: $responseBody") + + if (!response.isSuccessful) { + val errorJson = try { + com.google.gson.JsonParser.parseString(responseBody).asJsonObject + } catch (e: Exception) { null } + val errorMsg = errorJson?.get("message")?.asString + ?: errorJson?.get("error")?.asString + ?: "HTTP ${response.code}" + return@withContext Result.failure(Exception(errorMsg)) + } + + // Parse response + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val inviteCode = json.get("invite_code").asString + val joinToken = json.get("join_token")?.asString + ?: json.get("join_tokens")?.asJsonObject?.entrySet()?.firstOrNull()?.value?.asString + ?: "" + + // Return invite code in format: inviteCode (the API returns a ready-to-use invite code) + // The invite code can be used directly - joinToken is for direct session joining + Result.success(inviteCode) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Create keygen session failed", e) + Result.failure(e) + } + } + } + + /** + * Get all stored shares + */ + fun getAllShares(): Flow> { + return shareRecordDao.getAllShares().map { entities -> + entities.map { it.toShareRecord() } + } + } + + /** + * Validate an invite code and get session info + * Calls account-service API: GET /api/v1/co-managed/sessions/by-invite-code/{inviteCode} + */ + suspend fun validateInviteCode(inviteCode: String): Result { + return withContext(Dispatchers.IO) { + try { + val request = okhttp3.Request.Builder() + .url("$accountServiceUrl/api/v1/co-managed/sessions/by-invite-code/$inviteCode") + .get() + .build() + + android.util.Log.d("TssRepository", "Validating invite code: $inviteCode") + + val response = httpClient.newCall(request).execute() + val responseBody = response.body?.string() + ?: return@withContext Result.failure(Exception("空响应")) + + android.util.Log.d("TssRepository", "Validate response: $responseBody") + + if (!response.isSuccessful) { + val errorJson = try { + com.google.gson.JsonParser.parseString(responseBody).asJsonObject + } catch (e: Exception) { null } + val errorMsg = errorJson?.get("message")?.asString + ?: errorJson?.get("error")?.asString + ?: "HTTP ${response.code}" + return@withContext Result.failure(Exception(errorMsg)) + } + + // Parse response + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val sessionId = json.get("session_id").asString + val walletName = json.get("wallet_name")?.asString ?: "" + val thresholdT = json.get("threshold_t")?.asInt ?: 2 + val thresholdN = json.get("threshold_n")?.asInt ?: 3 + val joinedParties = json.get("joined_parties")?.asInt ?: 0 + val joinToken = json.get("join_token")?.asString ?: "" + + val sessionInfo = SessionInfoResponse( + sessionId = sessionId, + walletName = walletName, + thresholdT = thresholdT, + thresholdN = thresholdN, + initiator = "发起者", // API may not return this + currentParticipants = joinedParties, + totalParticipants = thresholdN + ) + + Result.success(ValidateInviteCodeResult( + sessionInfo = sessionInfo, + joinToken = joinToken + )) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Validate invite code failed", e) + Result.failure(e) + } + } + } + + /** + * Validate a sign session invite code and get session info + * Calls account-service API: GET /api/v1/co-managed/sign/by-invite-code/{inviteCode} + */ + suspend fun validateSignInviteCode(inviteCode: String): Result { + return withContext(Dispatchers.IO) { + try { + val request = okhttp3.Request.Builder() + .url("$accountServiceUrl/api/v1/co-managed/sign/by-invite-code/$inviteCode") + .get() + .build() + + android.util.Log.d("TssRepository", "Validating sign invite code: $inviteCode") + + val response = httpClient.newCall(request).execute() + val responseBody = response.body?.string() + ?: return@withContext Result.failure(Exception("空响应")) + + android.util.Log.d("TssRepository", "Validate sign response: $responseBody") + + if (!response.isSuccessful) { + val errorJson = try { + com.google.gson.JsonParser.parseString(responseBody).asJsonObject + } catch (e: Exception) { null } + val errorMsg = errorJson?.get("message")?.asString + ?: errorJson?.get("error")?.asString + ?: "HTTP ${response.code}" + return@withContext Result.failure(Exception(errorMsg)) + } + + // Parse response + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val sessionId = json.get("session_id").asString + val keygenSessionId = json.get("keygen_session_id")?.asString ?: "" + val walletName = json.get("wallet_name")?.asString ?: "" + val messageHash = json.get("message_hash")?.asString ?: "" + val thresholdT = json.get("threshold_t")?.asInt ?: 2 + val thresholdN = json.get("threshold_n")?.asInt ?: 3 + val joinedCount = json.get("joined_count")?.asInt ?: 0 + val joinToken = json.get("join_token")?.asString ?: "" + + val signSessionInfo = SignSessionInfoResponse( + sessionId = sessionId, + keygenSessionId = keygenSessionId, + walletName = walletName, + messageHash = messageHash, + thresholdT = thresholdT, + thresholdN = thresholdN, + currentParticipants = joinedCount + ) + + Result.success(ValidateSignInviteCodeResult( + signSessionInfo = signSessionInfo, + joinToken = joinToken + )) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Validate sign invite code failed", e) + Result.failure(e) + } + } + } + + /** + * Join a keygen session and execute keygen protocol + * First calls account-service to get session info and join token, then joins via gRPC + */ + suspend fun joinKeygenSession( + inviteCode: String, + password: String + ): Result = coroutineScope { + try { + android.util.Log.d("TssRepository", "Joining keygen session with invite code: $inviteCode") + + // Step 1: Call account-service API to join session and get party_index + val joinApiResult = joinSessionViaApi(inviteCode) + if (joinApiResult.isFailure) { + return@coroutineScope Result.failure(joinApiResult.exceptionOrNull()!!) + } + val apiJoinData = joinApiResult.getOrThrow() + + android.util.Log.d("TssRepository", "API join successful: sessionId=${apiJoinData.sessionId}, partyIndex=${apiJoinData.partyIndex}") + + // Step 2: Join session via gRPC for message routing + // Use the join_token from API response + val joinResult = grpcClient.joinSession(apiJoinData.sessionId, partyId, apiJoinData.joinToken) + if (joinResult.isFailure) { + android.util.Log.e("TssRepository", "gRPC join failed", joinResult.exceptionOrNull()) + return@coroutineScope Result.failure(joinResult.exceptionOrNull()!!) + } + + val sessionData = joinResult.getOrThrow() + + // Use party_index from API response (more reliable than gRPC response) + val myPartyIndex = apiJoinData.partyIndex + + // Update session state + val session = TssSession( + sessionId = apiJoinData.sessionId, + sessionType = SessionType.KEYGEN, + thresholdT = apiJoinData.thresholdT, + thresholdN = apiJoinData.thresholdN, + participants = sessionData.participants, + status = SessionStatus.WAITING, + inviteCode = inviteCode + ) + _currentSession.value = session + _sessionStatus.value = SessionStatus.WAITING + + // Add self to participants + val allParticipants = sessionData.participants + Participant(partyId, myPartyIndex) + + // Start TSS keygen + val startResult = tssNativeBridge.startKeygen( + sessionId = apiJoinData.sessionId, + partyId = partyId, + partyIndex = myPartyIndex, + thresholdT = apiJoinData.thresholdT, + thresholdN = apiJoinData.thresholdN, + participants = allParticipants, + password = password + ) + + if (startResult.isFailure) { + return@coroutineScope Result.failure(startResult.exceptionOrNull()!!) + } + + _sessionStatus.value = SessionStatus.IN_PROGRESS + + // Start message routing + startMessageRouting(apiJoinData.sessionId, myPartyIndex) + + // Mark ready + grpcClient.markPartyReady(apiJoinData.sessionId, partyId) + + // Wait for keygen result + val keygenResult = tssNativeBridge.waitForKeygenResult(password) + if (keygenResult.isFailure) { + _sessionStatus.value = SessionStatus.FAILED + return@coroutineScope Result.failure(keygenResult.exceptionOrNull()!!) + } + + val result = keygenResult.getOrThrow() + + // Derive address from public key + val publicKeyBytes = Base64.decode(result.publicKey, Base64.NO_WRAP) + val address = AddressUtils.deriveKavaAddress(publicKeyBytes) + + // Save share record + val shareEntity = ShareRecordEntity( + sessionId = apiJoinData.sessionId, + publicKey = result.publicKey, + encryptedShare = result.encryptedShare, + thresholdT = apiJoinData.thresholdT, + thresholdN = apiJoinData.thresholdN, + partyIndex = myPartyIndex, + address = address + ) + val id = shareRecordDao.insertShare(shareEntity) + + // Report completion + grpcClient.reportCompletion(apiJoinData.sessionId, partyId, publicKeyBytes) + + _sessionStatus.value = SessionStatus.COMPLETED + messageCollectionJob?.cancel() + + Result.success(shareEntity.copy(id = id).toShareRecord()) + + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Join keygen session failed", e) + _sessionStatus.value = SessionStatus.FAILED + Result.failure(e) + } + } + + /** + * Join session via account-service HTTP API + * POST /api/v1/co-managed/sessions/{sessionId}/join + */ + private suspend fun joinSessionViaApi(inviteCode: String): Result { + return withContext(Dispatchers.IO) { + try { + // First, get session info by invite code to get session_id and join_token + val sessionInfoResult = validateInviteCode(inviteCode) + if (sessionInfoResult.isFailure) { + return@withContext Result.failure(sessionInfoResult.exceptionOrNull()!!) + } + val sessionInfo = sessionInfoResult.getOrThrow() + val sessionId = sessionInfo.sessionInfo.sessionId + val joinToken = sessionInfo.joinToken + + android.util.Log.d("TssRepository", "Got session info: sessionId=$sessionId, joinToken=$joinToken") + + // Now call join API + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = com.google.gson.JsonObject().apply { + addProperty("party_id", partyId) + addProperty("join_token", joinToken) + addProperty("device_type", "mobile") + addProperty("device_id", partyId) + }.toString() + + val request = okhttp3.Request.Builder() + .url("$accountServiceUrl/api/v1/co-managed/sessions/$sessionId/join") + .post(requestBody.toRequestBody(jsonMediaType)) + .header("Content-Type", "application/json") + .build() + + android.util.Log.d("TssRepository", "Joining session via API: $requestBody") + + val response = httpClient.newCall(request).execute() + val responseBody = response.body?.string() + ?: return@withContext Result.failure(Exception("空响应")) + + android.util.Log.d("TssRepository", "Join API response: $responseBody") + + if (!response.isSuccessful) { + val errorJson = try { + com.google.gson.JsonParser.parseString(responseBody).asJsonObject + } catch (e: Exception) { null } + val errorMsg = errorJson?.get("message")?.asString + ?: errorJson?.get("error")?.asString + ?: "HTTP ${response.code}" + return@withContext Result.failure(Exception(errorMsg)) + } + + // Parse response + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val partyIndex = json.get("party_index")?.asInt ?: 0 + val sessionInfoJson = json.get("session_info")?.asJsonObject + val thresholdT = sessionInfoJson?.get("threshold_t")?.asInt ?: sessionInfo.sessionInfo.thresholdT + val thresholdN = sessionInfoJson?.get("threshold_n")?.asInt ?: sessionInfo.sessionInfo.thresholdN + + Result.success(ApiJoinSessionData( + sessionId = sessionId, + partyIndex = partyIndex, + thresholdT = thresholdT, + thresholdN = thresholdN, + joinToken = joinToken + )) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Join session via API failed", e) + Result.failure(e) + } + } + } + + /** + * Join a sign session and execute sign protocol + * First calls account-service to get session info and join, then joins via gRPC + */ + suspend fun joinSignSession( + inviteCode: String, + shareId: Long, + password: String + ): Result = coroutineScope { + try { + android.util.Log.d("TssRepository", "Joining sign session with invite code: $inviteCode") + + // Get share record + val shareEntity = shareRecordDao.getShareById(shareId) + ?: return@coroutineScope Result.failure(Exception("Share not found")) + + // Step 1: Call account-service API to join sign session and get party info + val joinApiResult = joinSignSessionViaApi(inviteCode, shareEntity.partyIndex) + if (joinApiResult.isFailure) { + return@coroutineScope Result.failure(joinApiResult.exceptionOrNull()!!) + } + val apiJoinData = joinApiResult.getOrThrow() + + android.util.Log.d("TssRepository", "API sign join successful: sessionId=${apiJoinData.sessionId}, partyIndex=${apiJoinData.partyIndex}, messageHash=${apiJoinData.messageHash}") + + // Step 2: Join session via gRPC for message routing + val joinResult = grpcClient.joinSession(apiJoinData.sessionId, partyId, apiJoinData.joinToken) + if (joinResult.isFailure) { + android.util.Log.e("TssRepository", "gRPC join failed", joinResult.exceptionOrNull()) + return@coroutineScope Result.failure(joinResult.exceptionOrNull()!!) + } + + val sessionData = joinResult.getOrThrow() + + // Use party_index from API response (more reliable) + val myPartyIndex = apiJoinData.partyIndex + + // Update session state + val session = TssSession( + sessionId = apiJoinData.sessionId, + sessionType = SessionType.SIGN, + thresholdT = apiJoinData.thresholdT, + thresholdN = apiJoinData.thresholdN, + participants = sessionData.participants, + status = SessionStatus.WAITING, + inviteCode = inviteCode, + messageHash = apiJoinData.messageHash + ) + _currentSession.value = session + _sessionStatus.value = SessionStatus.WAITING + + // Add self to participants + val allParticipants = sessionData.participants + Participant(partyId, myPartyIndex) + + // Start TSS sign + val startResult = tssNativeBridge.startSign( + sessionId = apiJoinData.sessionId, + partyId = partyId, + partyIndex = myPartyIndex, + thresholdT = apiJoinData.thresholdT, + thresholdN = shareEntity.thresholdN, // Use original N from keygen + participants = allParticipants, + messageHash = apiJoinData.messageHash, + shareData = shareEntity.encryptedShare, + password = password + ) + + if (startResult.isFailure) { + return@coroutineScope Result.failure(startResult.exceptionOrNull()!!) + } + + _sessionStatus.value = SessionStatus.IN_PROGRESS + + // Start message routing + startMessageRouting(apiJoinData.sessionId, myPartyIndex) + + // Mark ready + grpcClient.markPartyReady(apiJoinData.sessionId, partyId) + + // Wait for sign result + val signResult = tssNativeBridge.waitForSignResult() + if (signResult.isFailure) { + _sessionStatus.value = SessionStatus.FAILED + return@coroutineScope Result.failure(signResult.exceptionOrNull()!!) + } + + val result = signResult.getOrThrow() + + // Report completion + val signatureBytes = Base64.decode(result.signature, Base64.NO_WRAP) + grpcClient.reportCompletion(apiJoinData.sessionId, partyId, signature = signatureBytes) + + _sessionStatus.value = SessionStatus.COMPLETED + messageCollectionJob?.cancel() + + Result.success(result) + + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Join sign session failed", e) + _sessionStatus.value = SessionStatus.FAILED + Result.failure(e) + } + } + + /** + * Join sign session via account-service HTTP API + * Uses validateSignInviteCode to get session info, then joins + */ + private suspend fun joinSignSessionViaApi(inviteCode: String, partyIndex: Int): Result { + return withContext(Dispatchers.IO) { + try { + // First, get sign session info by invite code + val sessionInfoResult = validateSignInviteCode(inviteCode) + if (sessionInfoResult.isFailure) { + return@withContext Result.failure(sessionInfoResult.exceptionOrNull()!!) + } + val sessionInfo = sessionInfoResult.getOrThrow() + val signSessionInfo = sessionInfo.signSessionInfo + val sessionId = signSessionInfo.sessionId + val joinToken = sessionInfo.joinToken + + android.util.Log.d("TssRepository", "Got sign session info: sessionId=$sessionId, messageHash=${signSessionInfo.messageHash}, joinToken=$joinToken") + + // Now call join API (same endpoint as keygen join, but for sign sessions) + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = com.google.gson.JsonObject().apply { + addProperty("party_id", partyId) + addProperty("join_token", joinToken) + addProperty("party_index", partyIndex) + addProperty("device_type", "mobile") + addProperty("device_id", partyId) + }.toString() + + val request = okhttp3.Request.Builder() + .url("$accountServiceUrl/api/v1/co-managed/sign/$sessionId/join") + .post(requestBody.toRequestBody(jsonMediaType)) + .header("Content-Type", "application/json") + .build() + + android.util.Log.d("TssRepository", "Joining sign session via API: $requestBody") + + val response = httpClient.newCall(request).execute() + val responseBody = response.body?.string() + ?: return@withContext Result.failure(Exception("空响应")) + + android.util.Log.d("TssRepository", "Join sign API response: $responseBody") + + if (!response.isSuccessful) { + val errorJson = try { + com.google.gson.JsonParser.parseString(responseBody).asJsonObject + } catch (e: Exception) { null } + val errorMsg = errorJson?.get("message")?.asString + ?: errorJson?.get("error")?.asString + ?: "HTTP ${response.code}" + return@withContext Result.failure(Exception(errorMsg)) + } + + // Parse response - extract party_index if provided, otherwise use the one from share + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val responsePartyIndex = json.get("party_index")?.asInt ?: partyIndex + + Result.success(ApiJoinSignSessionData( + sessionId = sessionId, + partyIndex = responsePartyIndex, + thresholdT = signSessionInfo.thresholdT, + thresholdN = signSessionInfo.thresholdN, + messageHash = signSessionInfo.messageHash, + joinToken = joinToken + )) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Join sign session via API failed", e) + Result.failure(e) + } + } + } + + /** + * Start message routing between TSS and gRPC + */ + private fun startMessageRouting(sessionId: String, partyIndex: Int) { + messageCollectionJob?.cancel() + messageCollectionJob = CoroutineScope(Dispatchers.IO).launch { + // Collect outgoing messages from TSS and route via gRPC + launch { + tssNativeBridge.outgoingMessages.collect { message -> + val payload = Base64.decode(message.payload, Base64.NO_WRAP) + grpcClient.routeMessage( + sessionId = sessionId, + fromParty = partyId, + toParties = message.toParties ?: emptyList(), + roundNumber = 0, + messageType = if (message.isBroadcast) "broadcast" else "p2p", + payload = payload + ) + } + } + + // Collect incoming messages from gRPC and send to TSS + launch { + grpcClient.subscribeMessages(sessionId, partyId).collect { message -> + // Find party index from party ID + val session = _currentSession.value + val fromPartyIndex = session?.participants?.find { it.partyId == message.fromParty }?.partyIndex + ?: return@collect + + tssNativeBridge.sendIncomingMessage( + fromPartyIndex = fromPartyIndex, + isBroadcast = message.isBroadcast, + payload = message.payload + ) + } + } + } + } + + /** + * Cancel current session + */ + fun cancelSession() { + tssNativeBridge.cancelSession() + messageCollectionJob?.cancel() + _currentSession.value = null + _sessionStatus.value = SessionStatus.WAITING + } + + /** + * Delete a share record + */ + suspend fun deleteShare(id: Long) { + shareRecordDao.deleteShareById(id) + } + + /** + * Get balance for an address using eth_getBalance RPC + */ + suspend fun getBalance(address: String, rpcUrl: String): Result { + return withContext(Dispatchers.IO) { + try { + val client = okhttp3.OkHttpClient() + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = """ + { + "jsonrpc": "2.0", + "method": "eth_getBalance", + "params": ["$address", "latest"], + "id": 1 + } + """.trimIndent() + + val request = okhttp3.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")) + + // Parse JSON 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 hexBalance = json.get("result").asString + // Convert hex to decimal (wei) then to KAVA (18 decimals) + val weiBalance = java.math.BigInteger(hexBalance.removePrefix("0x"), 16) + val kavaBalance = java.math.BigDecimal(weiBalance).divide(java.math.BigDecimal("1000000000000000000"), 6, java.math.RoundingMode.DOWN) + + Result.success(kavaBalance.toPlainString()) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + // ========== Transfer / Sign Session Methods ========== + + /** + * Prepare a transaction for signing + */ + suspend fun prepareTransaction( + from: String, + to: String, + amount: String, + rpcUrl: String, + chainId: Int = TransactionUtils.KAVA_TESTNET_CHAIN_ID + ): Result { + return TransactionUtils.prepareTransaction( + TransactionUtils.TransactionParams( + from = from, + to = to, + amount = amount, + rpcUrl = rpcUrl, + chainId = chainId + ) + ) + } + + /** + * Create a sign session for a transaction (as initiator) + * Calls account-service API: POST /api/v1/co-managed/sign + */ + suspend fun createSignSession( + shareId: Long, + messageHash: String, + password: String, + initiatorName: String + ): Result { + return withContext(Dispatchers.IO) { + try { + // Get share record + val shareEntity = shareRecordDao.getShareById(shareId) + ?: return@withContext Result.failure(Exception("Share not found")) + + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + // Build parties array - include initiator party info + val partiesArray = com.google.gson.JsonArray().apply { + add(com.google.gson.JsonObject().apply { + addProperty("party_id", partyId) + addProperty("party_index", shareEntity.partyIndex) + }) + } + + // Build request body matching account-service API + val requestBody = com.google.gson.JsonObject().apply { + addProperty("keygen_session_id", shareEntity.sessionId) + addProperty("wallet_name", "Wallet") // Use a default name or could be passed as parameter + addProperty("message_hash", messageHash) + add("parties", partiesArray) + addProperty("threshold_t", shareEntity.thresholdT) + addProperty("initiator_name", initiatorName) + }.toString() + + val request = okhttp3.Request.Builder() + .url("$accountServiceUrl/api/v1/co-managed/sign") + .post(requestBody.toRequestBody(jsonMediaType)) + .header("Content-Type", "application/json") + .build() + + android.util.Log.d("TssRepository", "Creating sign session: $requestBody") + + val response = httpClient.newCall(request).execute() + val responseBody = response.body?.string() + ?: return@withContext Result.failure(Exception("空响应")) + + android.util.Log.d("TssRepository", "Create sign session response: $responseBody") + + if (!response.isSuccessful) { + val errorJson = try { + com.google.gson.JsonParser.parseString(responseBody).asJsonObject + } catch (e: Exception) { null } + val errorMsg = errorJson?.get("message")?.asString + ?: errorJson?.get("error")?.asString + ?: "HTTP ${response.code}" + return@withContext Result.failure(Exception(errorMsg)) + } + + // Parse response + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val sessionId = json.get("session_id").asString + val inviteCode = json.get("invite_code").asString + val thresholdT = json.get("threshold_t")?.asInt ?: shareEntity.thresholdT + + // Update session state + val session = TssSession( + sessionId = sessionId, + sessionType = SessionType.SIGN, + thresholdT = thresholdT, + thresholdN = shareEntity.thresholdN, + participants = listOf(Participant(partyId, shareEntity.partyIndex, initiatorName)), + status = SessionStatus.WAITING, + inviteCode = inviteCode, + messageHash = messageHash + ) + _currentSession.value = session + _sessionStatus.value = SessionStatus.WAITING + + Result.success(SignSessionResult( + sessionId = sessionId, + inviteCode = inviteCode + )) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Create sign session failed", e) + Result.failure(e) + } + } + } + + /** + * Start the signing process after all parties have joined + */ + suspend fun startSigning( + sessionId: String, + shareId: Long, + password: String + ): Result { + return withContext(Dispatchers.IO) { + try { + val session = _currentSession.value + ?: return@withContext Result.failure(Exception("No active session")) + + val shareEntity = shareRecordDao.getShareById(shareId) + ?: return@withContext Result.failure(Exception("Share not found")) + + // Start TSS sign + val startResult = tssNativeBridge.startSign( + sessionId = sessionId, + partyId = partyId, + partyIndex = shareEntity.partyIndex, + thresholdT = session.thresholdT, + thresholdN = shareEntity.thresholdN, + participants = session.participants, + messageHash = session.messageHash ?: "", + shareData = shareEntity.encryptedShare, + password = password + ) + + if (startResult.isFailure) { + return@withContext Result.failure(startResult.exceptionOrNull()!!) + } + + _sessionStatus.value = SessionStatus.IN_PROGRESS + + // Start message routing + startMessageRouting(sessionId, shareEntity.partyIndex) + + // Mark ready + grpcClient.markPartyReady(sessionId, partyId) + + Result.success(Unit) + } catch (e: Exception) { + _sessionStatus.value = SessionStatus.FAILED + Result.failure(e) + } + } + } + + /** + * Wait for signing to complete and get signature + */ + suspend fun waitForSignature(): Result { + return withContext(Dispatchers.IO) { + try { + val signResult = tssNativeBridge.waitForSignResult() + if (signResult.isFailure) { + _sessionStatus.value = SessionStatus.FAILED + return@withContext Result.failure(signResult.exceptionOrNull()!!) + } + + val result = signResult.getOrThrow() + + // Report completion + val signatureBytes = Base64.decode(result.signature, Base64.NO_WRAP) + val session = _currentSession.value + if (session != null) { + grpcClient.reportCompletion(session.sessionId, partyId, signature = signatureBytes) + } + + _sessionStatus.value = SessionStatus.COMPLETED + messageCollectionJob?.cancel() + + Result.success(result) + } catch (e: Exception) { + _sessionStatus.value = SessionStatus.FAILED + Result.failure(e) + } + } + } + + /** + * Finalize and broadcast a signed transaction + */ + suspend fun broadcastTransaction( + preparedTx: TransactionUtils.PreparedTransaction, + signature: String, + rpcUrl: String + ): Result { + return withContext(Dispatchers.IO) { + try { + // Parse signature (format: 0x + r(64) + s(64) + v(2)) + val sigHex = signature.removePrefix("0x") + if (sigHex.length != 130) { + return@withContext Result.failure(Exception("Invalid signature length")) + } + + val rHex = sigHex.substring(0, 64) + val sHex = sigHex.substring(64, 128) + val vHex = sigHex.substring(128, 130) + + val r = rHex.hexToByteArray() + val s = sHex.hexToByteArray() + val v = vHex.toInt(16) + val recoveryId = if (v >= 27) v - 27 else v + + // Finalize transaction with signature + val signedTx = TransactionUtils.finalizeTransaction( + preparedTx = preparedTx, + r = r, + s = s, + recoveryId = recoveryId + ) + + // Broadcast to network + TransactionUtils.broadcastTransaction(signedTx, rpcUrl) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + /** + * Get transaction receipt + */ + suspend fun getTransactionReceipt( + txHash: String, + rpcUrl: String + ): Result { + return TransactionUtils.getTransactionReceipt(txHash, rpcUrl) + } + + // ========== Connection Test Methods ========== + + /** + * Test Message Router connection + * Returns success/failure with latency + */ + suspend fun testMessageRouter(serverUrl: String): Result { + return withContext(Dispatchers.IO) { + try { + val startTime = System.currentTimeMillis() + + val parts = serverUrl.split(":") + val host = parts[0] + val port = parts.getOrNull(1)?.toIntOrNull() ?: 443 + + // Try to connect + grpcClient.connect(host, port) + val testPartyId = "test-${UUID.randomUUID()}" + grpcClient.registerParty(testPartyId, "test", "1.0.0") + + val latency = System.currentTimeMillis() - startTime + Result.success(latency) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + /** + * Test Account Service connection + * Makes a health check or simple GET request + */ + suspend fun testAccountService(serviceUrl: String): Result { + return withContext(Dispatchers.IO) { + try { + val startTime = System.currentTimeMillis() + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + // Try health endpoint or root + val healthUrl = if (serviceUrl.endsWith("/")) { + "${serviceUrl}health" + } else { + "$serviceUrl/health" + } + + val request = okhttp3.Request.Builder() + .url(healthUrl) + .get() + .build() + + val response = client.newCall(request).execute() + val latency = System.currentTimeMillis() - startTime + + // Accept 200, 404 (endpoint may not exist but server is up) + if (response.isSuccessful || response.code == 404) { + Result.success(latency) + } else { + Result.failure(Exception("HTTP ${response.code}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + } + + /** + * Test Kava RPC connection + * Makes an eth_chainId RPC call + */ + suspend fun testKavaApi(rpcUrl: String): Result { + return withContext(Dispatchers.IO) { + try { + val startTime = System.currentTimeMillis() + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = """ + { + "jsonrpc": "2.0", + "method": "eth_chainId", + "params": [], + "id": 1 + } + """.trimIndent() + + val request = okhttp3.Request.Builder() + .url(rpcUrl) + .post(requestBody.toRequestBody(jsonMediaType)) + .build() + + val response = client.newCall(request).execute() + val latency = System.currentTimeMillis() - startTime + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val responseBody = response.body?.string() + ?: return@withContext Result.failure(Exception("空响应")) + + // Parse JSON to verify it's a valid JSON-RPC 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) + ) + } + + Result.success(latency) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + /** + * Get a share by ID + */ + suspend fun getShareById(shareId: Long): ShareRecord? { + return shareRecordDao.getShareById(shareId)?.toShareRecord() + } + + 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 + } +} + +/** + * Result of creating a sign session + */ +data class SignSessionResult( + val sessionId: String, + val inviteCode: String +) + +/** + * Result of validating an invite code + */ +data class ValidateInviteCodeResult( + val sessionInfo: SessionInfoResponse, + val joinToken: String +) + +/** + * Session info from validateInviteCode API + */ +data class SessionInfoResponse( + val sessionId: String, + val walletName: String, + val thresholdT: Int, + val thresholdN: Int, + val initiator: String, + val currentParticipants: Int, + val totalParticipants: Int +) + +/** + * Result of validating a sign session invite code + */ +data class ValidateSignInviteCodeResult( + val signSessionInfo: SignSessionInfoResponse, + val joinToken: String +) + +/** + * Sign session info from validateSignInviteCode API + */ +data class SignSessionInfoResponse( + val sessionId: String, + val keygenSessionId: String, + val walletName: String, + val messageHash: String, + val thresholdT: Int, + val thresholdN: Int, + val currentParticipants: Int +) + +/** + * Data returned from joinSessionViaApi + */ +data class ApiJoinSessionData( + val sessionId: String, + val partyIndex: Int, + val thresholdT: Int, + val thresholdN: Int, + val joinToken: String +) + +/** + * Data returned from joinSignSessionViaApi + */ +data class ApiJoinSignSessionData( + val sessionId: String, + val partyIndex: Int, + val thresholdT: Int, + val thresholdN: Int, + val messageHash: String, + val joinToken: String +) + +private fun ShareRecordEntity.toShareRecord() = ShareRecord( + id = id, + sessionId = sessionId, + publicKey = publicKey, + encryptedShare = encryptedShare, + thresholdT = thresholdT, + thresholdN = thresholdN, + partyIndex = partyIndex, + address = address, + createdAt = createdAt +) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt new file mode 100644 index 00000000..c3652dd4 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/di/AppModule.kt @@ -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) + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/AppState.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/AppState.kt new file mode 100644 index 00000000..2d19358d --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/AppState.kt @@ -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 +) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt new file mode 100644 index 00000000..19c89fd4 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/domain/model/Models.kt @@ -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, + 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?, + @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 +) + +/** + * 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 +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/components/BottomNavigation.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/components/BottomNavigation.kt new file mode 100644 index 00000000..0f1d2c32 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/components/BottomNavigation.kt @@ -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) } + ) + } + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt new file mode 100644 index 00000000..404db302 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CoSignJoinScreen.kt @@ -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, + sessionStatus: SessionStatus, + isLoading: Boolean, + error: String?, + signSessionInfo: SignSessionInfo? = null, + participants: List = 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(null) } + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var validationError by remember { mutableStateOf(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, + 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, + 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 + ) + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt new file mode 100644 index 00000000..965d4638 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/CreateWalletScreen.kt @@ -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 = 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(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, + 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("返回首页") + } + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/HomeScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/HomeScreen.kt new file mode 100644 index 00000000..29f3957b --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/HomeScreen.kt @@ -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, + 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") + } + } + ) + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt new file mode 100644 index 00000000..ecdaefa2 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinKeygenScreen.kt @@ -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 = 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(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, + 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 + ) + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinScreen.kt new file mode 100644 index 00000000..2da0c4b6 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/JoinScreen.kt @@ -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(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") + } + } + } + } + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SettingsScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SettingsScreen.kt new file mode 100644 index 00000000..b2440ea3 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SettingsScreen.kt @@ -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(null) } + var localAccountServiceResult by remember { mutableStateOf(null) } + var localKavaApiResult by remember { mutableStateOf(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 + ) + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SignScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SignScreen.kt new file mode 100644 index 00000000..bdf9669f --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/SignScreen.kt @@ -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") + } + } + } + } + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt new file mode 100644 index 00000000..9b08dd45 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/StartupCheckScreen.kt @@ -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 + ) + } + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt new file mode 100644 index 00000000..354b11a6 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt @@ -0,0 +1,1061 @@ +package com.durian.tssparty.presentation.screens + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +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 com.durian.tssparty.domain.model.NetworkType +import com.durian.tssparty.domain.model.SessionStatus +import com.durian.tssparty.domain.model.ShareRecord +import com.durian.tssparty.util.TransactionUtils +import java.math.BigInteger + +/** + * Transfer Screen - matches service-party-app Home.tsx transfer flow exactly + * + * Flow: + * 1. input - Enter recipient address, amount, password + * 2. preparing - Preparing transaction (getting nonce, gas, etc.) + * 3. confirm - Confirm transaction details with gas fees + * 4. signing - TSS multi-party signing in progress + * 5. broadcasting - Broadcasting signed transaction + * 6. completed - Show transaction hash and explorer link + * 7. error - Show error message + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransferScreen( + wallet: ShareRecord, + balance: String?, + sessionStatus: SessionStatus, + participants: List = emptyList(), + currentRound: Int = 0, + totalRounds: Int = 9, + preparedTx: TransactionUtils.PreparedTransaction? = null, + signSessionId: String? = null, + inviteCode: String? = null, + signature: String? = null, + txHash: String? = null, + isLoading: Boolean = false, + error: String? = null, + networkType: NetworkType = NetworkType.MAINNET, + onPrepareTransaction: (toAddress: String, amount: String) -> Unit, + onConfirmTransaction: (password: String) -> Unit, + onCopyInviteCode: () -> Unit, + onBroadcastTransaction: () -> Unit, + onCancel: () -> Unit, + onBackToWallets: () -> Unit +) { + var toAddress by remember { mutableStateOf("") } + var amount by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var validationError by remember { mutableStateOf(null) } + + // Determine current step + val step = when { + txHash != null -> "completed" + signature != null -> "broadcasting" + sessionStatus == SessionStatus.IN_PROGRESS -> "signing" + signSessionId != null -> "signing" + preparedTx != null && !isLoading -> "confirm" + isLoading && preparedTx == null -> "preparing" + error != null -> "error" + else -> "input" + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Top Bar + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onCancel) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + Text( + text = "转账", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(48.dp)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + when (step) { + "input" -> TransferInputScreen( + wallet = wallet, + balance = balance, + toAddress = toAddress, + onToAddressChange = { toAddress = it }, + amount = amount, + onAmountChange = { amount = it }, + error = validationError ?: error, + onSubmit = { + when { + toAddress.isBlank() -> validationError = "请输入收款地址" + !toAddress.startsWith("0x") || toAddress.length != 42 -> validationError = "地址格式不正确" + amount.isBlank() -> validationError = "请输入金额" + amount.toDoubleOrNull() == null || amount.toDouble() <= 0 -> validationError = "金额无效" + balance != null && amount.toDouble() > balance.toDouble() -> validationError = "余额不足" + else -> { + validationError = null + onPrepareTransaction(toAddress.trim(), amount.trim()) + } + } + }, + onCancel = onCancel + ) + + "preparing" -> PreparingScreen() + + "confirm" -> TransferConfirmScreen( + wallet = wallet, + preparedTx = preparedTx!!, + toAddress = toAddress, + amount = amount, + password = password, + onPasswordChange = { password = it }, + showPassword = showPassword, + onTogglePassword = { showPassword = !showPassword }, + error = error, + onConfirm = { + if (password.isBlank()) { + validationError = "请输入密码" + } else { + validationError = null + onConfirmTransaction(password) + } + }, + onBack = onCancel + ) + + "signing" -> SigningScreen( + wallet = wallet, + sessionId = signSessionId ?: "", + inviteCode = inviteCode, + sessionStatus = sessionStatus, + participants = participants, + currentRound = currentRound, + totalRounds = totalRounds, + onCopyInviteCode = onCopyInviteCode + ) + + "broadcasting" -> BroadcastingScreen( + isLoading = isLoading, + onBroadcast = onBroadcastTransaction + ) + + "completed" -> CompletedScreen( + txHash = txHash!!, + toAddress = toAddress, + amount = amount, + networkType = networkType, + onBackToWallets = onBackToWallets + ) + + "error" -> ErrorScreen( + error = error ?: "未知错误", + onRetry = onCancel, + onBack = onBackToWallets + ) + } + } +} + +@Composable +private fun TransferInputScreen( + wallet: ShareRecord, + balance: String?, + toAddress: String, + onToAddressChange: (String) -> Unit, + amount: String, + onAmountChange: (String) -> Unit, + error: String?, + onSubmit: () -> Unit, + onCancel: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // From wallet info + Card( + 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(4.dp)) + Text( + text = wallet.address, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "余额: ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (balance != null) "$balance KAVA" else "加载中...", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Recipient address + OutlinedTextField( + value = toAddress, + onValueChange = onToAddressChange, + 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 = onAmountChange, + label = { Text("金额 (KAVA)") }, + placeholder = { Text("0.0") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + leadingIcon = { + Icon(Icons.Default.AttachMoney, contentDescription = null) + }, + trailingIcon = { + if (balance != null) { + TextButton( + onClick = { onAmountChange(balance) } + ) { + Text("全部", style = MaterialTheme.typography.labelSmall) + } + } + } + ) + + 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 + ) + } + } + + // Error display + error?.let { + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + 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.weight(1f)) + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f) + ) { + Text("取消") + } + Button( + onClick = onSubmit, + modifier = Modifier.weight(1f) + ) { + Text("下一步") + } + } + } +} + +@Composable +private fun PreparingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 4.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "准备交易中...", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "正在获取 Gas 价格和 Nonce", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun TransferConfirmScreen( + wallet: ShareRecord, + preparedTx: TransactionUtils.PreparedTransaction, + toAddress: String, + amount: String, + password: String, + onPasswordChange: (String) -> Unit, + showPassword: Boolean, + onTogglePassword: () -> Unit, + error: String?, + onConfirm: () -> Unit, + onBack: () -> Unit +) { + val gasFee = TransactionUtils.weiToKava(preparedTx.gasPrice.multiply(preparedTx.gasLimit)) + val gasGwei = TransactionUtils.weiToGwei(preparedTx.gasPrice) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Text( + text = "确认交易", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Transaction summary card + Card { + Column(modifier = Modifier.padding(16.dp)) { + // Amount + Text( + text = "转账金额", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "$amount KAVA", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(16.dp)) + Divider() + Spacer(modifier = Modifier.height(16.dp)) + + // To address + InfoRow("收款地址", toAddress.take(10) + "..." + toAddress.takeLast(8)) + + Spacer(modifier = Modifier.height(8.dp)) + + // Gas info + InfoRow("Gas 价格", "$gasGwei Gwei") + Spacer(modifier = Modifier.height(4.dp)) + InfoRow("Gas 限制", preparedTx.gasLimit.toString()) + Spacer(modifier = Modifier.height(4.dp)) + InfoRow("预估手续费", "$gasFee KAVA") + Spacer(modifier = Modifier.height(4.dp)) + InfoRow("Nonce", preparedTx.nonce.toString()) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Threshold info + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Group, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "此交易需要 ${wallet.thresholdT} 个参与者共同签名", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Password input + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text("钱包密码") }, + placeholder = { Text("输入密码解锁密钥份额") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + trailingIcon = { + IconButton(onClick = onTogglePassword) { + Icon( + if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = null + ) + } + } + ) + + // Error display + error?.let { + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + 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.weight(1f)) + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onBack, + modifier = Modifier.weight(1f) + ) { + Text("返回") + } + Button( + onClick = onConfirm, + 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 SigningScreen( + wallet: ShareRecord, + sessionId: String, + inviteCode: String?, + sessionStatus: SessionStatus, + participants: List, + currentRound: Int, + totalRounds: Int, + onCopyInviteCode: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // Header with session ID + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "签名会话", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + // Status badge + Surface( + color = when (sessionStatus) { + SessionStatus.WAITING -> MaterialTheme.colorScheme.tertiaryContainer + SessionStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primaryContainer + SessionStatus.COMPLETED -> Color(0xFF4CAF50) + 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 + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "会话 ID: ${sessionId.take(16)}...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Invite code (if waiting) + if (sessionStatus == SessionStatus.WAITING && inviteCode != null) { + Card( + 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.bodyMedium, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + Button(onClick = onCopyInviteCode) { + Text("复制") + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "将此邀请码分享给其他签名参与者", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + // Progress bar (if in progress) + if (sessionStatus == SessionStatus.IN_PROGRESS) { + Card { + 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 { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "签名参与者 (${participants.size} / ${wallet.thresholdT})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(12.dp)) + + 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 + ) + } + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(20.dp) + ) + } + } + + // Show empty slots + for (i in participants.size until wallet.thresholdT) { + 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 + ) + } + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Info text + if (sessionStatus == SessionStatus.WAITING) { + Text( + text = "等待其他参与者加入后将自动开始签名...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } else if (sessionStatus == SessionStatus.IN_PROGRESS) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "正在进行多方签名计算...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun BroadcastingScreen( + isLoading: Boolean, + onBroadcast: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Success icon + Surface( + modifier = Modifier.size(80.dp), + shape = MaterialTheme.shapes.extraLarge, + color = Color(0xFF4CAF50) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "签名完成!", + style = MaterialTheme.typography.headlineSmall, + 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)) + + Button( + onClick = onBroadcast, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(0.8f) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("广播中...") + } else { + Icon( + Icons.Default.Send, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("广播交易") + } + } + } + } +} + +@Composable +private fun CompletedScreen( + txHash: String, + toAddress: String, + amount: String, + networkType: NetworkType, + onBackToWallets: () -> Unit +) { + val context = LocalContext.current + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Success icon + Surface( + modifier = Modifier.size(100.dp), + shape = MaterialTheme.shapes.extraLarge, + color = Color(0xFF4CAF50) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(60.dp), + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "交易已成功广播!", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "向 ${toAddress.take(10)}...${toAddress.takeLast(8)} 转账 $amount KAVA", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Transaction hash card + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "交易哈希", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = txHash, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // View on explorer button + OutlinedButton( + onClick = { + val baseUrl = if (networkType == NetworkType.TESTNET) { + "https://testnet.kavascan.com" + } else { + "https://kavascan.com" + } + val url = "$baseUrl/tx/$txHash" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("在 KavaScan 查看") + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onBackToWallets, + modifier = Modifier.fillMaxWidth() + ) { + Text("返回钱包列表") + } + } + } +} + +@Composable +private fun ErrorScreen( + error: String, + onRetry: () -> Unit, + onBack: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.size(80.dp), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.errorContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.error + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "交易失败", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton(onClick = onBack) { + Text("返回") + } + Button(onClick = onRetry) { + 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 + ) + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt new file mode 100644 index 00000000..4255531d --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/WalletsScreen.kt @@ -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, + isConnected: Boolean, + balances: Map = 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(null) } + var showTransferDialog by remember { mutableStateOf(false) } + var transferWallet by remember { mutableStateOf(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(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 + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt new file mode 100644 index 00000000..43e74c7e --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt @@ -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.asStateFlow() + + // UI State + private val _uiState = MutableStateFlow(MainUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Settings + private val _settings = MutableStateFlow(AppSettings()) + val settings: StateFlow = _settings.asStateFlow() + + // Share records + val shares: StateFlow> = repository.getAllShares() + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + // Session status from repository + val sessionStatus: StateFlow = repository.sessionStatus + + // Created session invite code + private val _createdInviteCode = MutableStateFlow(null) + val createdInviteCode: StateFlow = _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(null) + val currentSessionId: StateFlow = _currentSessionId.asStateFlow() + + private val _sessionParticipants = MutableStateFlow>(emptyList()) + val sessionParticipants: StateFlow> = _sessionParticipants.asStateFlow() + + private val _currentRound = MutableStateFlow(0) + val currentRound: StateFlow = _currentRound.asStateFlow() + + private val _publicKey = MutableStateFlow(null) + val publicKey: StateFlow = _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(null) + val joinSessionInfo: StateFlow = _joinSessionInfo.asStateFlow() + + private val _joinKeygenParticipants = MutableStateFlow>(emptyList()) + val joinKeygenParticipants: StateFlow> = _joinKeygenParticipants.asStateFlow() + + private val _joinKeygenRound = MutableStateFlow(0) + val joinKeygenRound: StateFlow = _joinKeygenRound.asStateFlow() + + private val _joinKeygenPublicKey = MutableStateFlow(null) + val joinKeygenPublicKey: StateFlow = _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(null) + val coSignSessionInfo: StateFlow = _coSignSessionInfo.asStateFlow() + + private val _coSignParticipants = MutableStateFlow>(emptyList()) + val coSignParticipants: StateFlow> = _coSignParticipants.asStateFlow() + + private val _coSignRound = MutableStateFlow(0) + val coSignRound: StateFlow = _coSignRound.asStateFlow() + + private val _coSignSignature = MutableStateFlow(null) + val coSignSignature: StateFlow = _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(null) + val messageRouterTestResult: StateFlow = _messageRouterTestResult.asStateFlow() + + private val _accountServiceTestResult = MutableStateFlow(null) + val accountServiceTestResult: StateFlow = _accountServiceTestResult.asStateFlow() + + private val _kavaApiTestResult = MutableStateFlow(null) + val kavaApiTestResult: StateFlow = _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>(emptyMap()) + val balances: StateFlow> = _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.asStateFlow() + + // Prepared transaction + private val _preparedTx = MutableStateFlow(null) + val preparedTx: StateFlow = _preparedTx.asStateFlow() + + // Sign session for transfer + private val _signSessionId = MutableStateFlow(null) + val signSessionId: StateFlow = _signSessionId.asStateFlow() + + private val _signInviteCode = MutableStateFlow(null) + val signInviteCode: StateFlow = _signInviteCode.asStateFlow() + + private val _signParticipants = MutableStateFlow>(emptyList()) + val signParticipants: StateFlow> = _signParticipants.asStateFlow() + + private val _signCurrentRound = MutableStateFlow(0) + val signCurrentRound: StateFlow = _signCurrentRound.asStateFlow() + + private val _signature = MutableStateFlow(null) + val signature: StateFlow = _signature.asStateFlow() + + private val _txHash = MutableStateFlow(null) + val txHash: StateFlow = _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 +) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Theme.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Theme.kt new file mode 100644 index 00000000..6ce979c3 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Type.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Type.kt new file mode 100644 index 00000000..ffa6513d --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/ui/theme/Type.kt @@ -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 + ) +) diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/AddressUtils.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/AddressUtils.kt new file mode 100644 index 00000000..a25ef8c6 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/AddressUtils.kt @@ -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() + 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 } + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/TransactionUtils.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/TransactionUtils.kt new file mode 100644 index 00000000..9fd56c31 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/TransactionUtils.kt @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 { + 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 + } +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/proto/message_router.proto b/backend/mpc-system/services/service-party-android/app/src/main/proto/message_router.proto new file mode 100644 index 00000000..7a68fcdb --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/proto/message_router.proto @@ -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 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; +} diff --git a/backend/mpc-system/services/service-party-android/app/src/main/res/drawable/ic_launcher_foreground.xml b/backend/mpc-system/services/service-party-android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..42ae92de --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/backend/mpc-system/services/service-party-android/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/backend/mpc-system/services/service-party-android/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 00000000..41f345b5 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/backend/mpc-system/services/service-party-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/backend/mpc-system/services/service-party-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml new file mode 100644 index 00000000..41f345b5 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/backend/mpc-system/services/service-party-android/app/src/main/res/values/colors.xml b/backend/mpc-system/services/service-party-android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..e4030af6 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #4CAF50 + #388E3C + #81C784 + #FFFFFF + #000000 + diff --git a/backend/mpc-system/services/service-party-android/app/src/main/res/values/strings.xml b/backend/mpc-system/services/service-party-android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..e015fdbd --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + TSS Party + diff --git a/backend/mpc-system/services/service-party-android/app/src/main/res/values/themes.xml b/backend/mpc-system/services/service-party-android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..3fe000b4 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/backend/mpc-system/services/service-party-android/app/src/main/res/xml/backup_rules.xml b/backend/mpc-system/services/service-party-android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..c3e8523c --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/backend/mpc-system/services/service-party-android/app/src/main/res/xml/data_extraction_rules.xml b/backend/mpc-system/services/service-party-android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..0738311f --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/backend/mpc-system/services/service-party-android/build.gradle.kts b/backend/mpc-system/services/service-party-android/build.gradle.kts new file mode 100644 index 00000000..7fd98868 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/build.gradle.kts @@ -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) +} diff --git a/backend/mpc-system/services/service-party-android/gradle.properties b/backend/mpc-system/services/service-party-android/gradle.properties new file mode 100644 index 00000000..4e1c9592 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/gradle.properties @@ -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 diff --git a/backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.jar b/backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7f93135c49b765f8051ef9d0a6055ff8e46073d8 GIT binary patch literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc literal 0 HcmV?d00001 diff --git a/backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.properties b/backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..3fa8f862 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/backend/mpc-system/services/service-party-android/gradlew b/backend/mpc-system/services/service-party-android/gradlew new file mode 100644 index 00000000..81523c9c --- /dev/null +++ b/backend/mpc-system/services/service-party-android/gradlew @@ -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" "$@" diff --git a/backend/mpc-system/services/service-party-android/gradlew.bat b/backend/mpc-system/services/service-party-android/gradlew.bat new file mode 100644 index 00000000..93e3f59f --- /dev/null +++ b/backend/mpc-system/services/service-party-android/gradlew.bat @@ -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 diff --git a/backend/mpc-system/services/service-party-android/settings.gradle.kts b/backend/mpc-system/services/service-party-android/settings.gradle.kts new file mode 100644 index 00000000..1b6f72b4 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/settings.gradle.kts @@ -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") diff --git a/backend/mpc-system/services/service-party-android/tsslib/build.bat b/backend/mpc-system/services/service-party-android/tsslib/build.bat new file mode 100644 index 00000000..442b75d2 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/tsslib/build.bat @@ -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 diff --git a/backend/mpc-system/services/service-party-android/tsslib/build.sh b/backend/mpc-system/services/service-party-android/tsslib/build.sh new file mode 100644 index 00000000..44a9640f --- /dev/null +++ b/backend/mpc-system/services/service-party-android/tsslib/build.sh @@ -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" diff --git a/backend/mpc-system/services/service-party-android/tsslib/go.mod b/backend/mpc-system/services/service-party-android/tsslib/go.mod new file mode 100644 index 00000000..8d74ee03 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/tsslib/go.mod @@ -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 diff --git a/backend/mpc-system/services/service-party-android/tsslib/go.sum b/backend/mpc-system/services/service-party-android/tsslib/go.sum new file mode 100644 index 00000000..dbdad7b5 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/tsslib/go.sum @@ -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= diff --git a/backend/mpc-system/services/service-party-android/tsslib/tsslib.go b/backend/mpc-system/services/service-party-android/tsslib/tsslib.go new file mode 100644 index 00000000..d2784439 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/tsslib/tsslib.go @@ -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 +} diff --git a/backend/mpc-system/services/service-party-app/electron/main.ts b/backend/mpc-system/services/service-party-app/electron/main.ts index e848566d..278a01a2 100644 --- a/backend/mpc-system/services/service-party-app/electron/main.ts +++ b/backend/mpc-system/services/service-party-app/electron/main.ts @@ -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}`); diff --git a/backend/mpc-system/services/service-party-app/src/components/Layout.tsx b/backend/mpc-system/services/service-party-app/src/components/Layout.tsx index c239d994..c4cd729a 100644 --- a/backend/mpc-system/services/service-party-app/src/components/Layout.tsx +++ b/backend/mpc-system/services/service-party-app/src/components/Layout.tsx @@ -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(); diff --git a/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx b/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx index 99ef60be..80d8307f 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx +++ b/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx @@ -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); diff --git a/backend/mpc-system/services/service-party-app/src/utils/transaction.ts b/backend/mpc-system/services/service-party-app/src/utils/transaction.ts index f6b301cb..4574291b 100644 --- a/backend/mpc-system/services/service-party-app/src/utils/transaction.ts +++ b/backend/mpc-system/services/service-party-app/src/utils/transaction.ts @@ -23,7 +23,7 @@ export function getCurrentNetwork(): 'mainnet' | 'testnet' { return stored; } } - return 'testnet'; // 默认测试网 + return 'mainnet'; // 默认主网 } export function getCurrentChainId(): number {