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 00000000..b8480d73 Binary files /dev/null and b/backend/mpc-system/services/service-party-android/app/libs/tsslib-sources.jar differ diff --git a/backend/mpc-system/services/service-party-android/app/proguard-rules.pro b/backend/mpc-system/services/service-party-android/app/proguard-rules.pro new file mode 100644 index 00000000..8f8b2f00 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. + +# Keep gRPC classes +-keep class io.grpc.** { *; } +-keep class com.google.protobuf.** { *; } +-keep class com.durian.tssparty.grpc.** { *; } + +# Keep tsslib (gomobile generated) +-keep class tsslib.** { *; } + +# Keep Hilt generated classes +-keep class dagger.hilt.** { *; } +-keep class javax.inject.** { *; } + +# Keep Room entities +-keep class com.durian.tssparty.data.local.** { *; } + +# Gson +-keepattributes Signature +-keepattributes *Annotation* +-keep class com.durian.tssparty.domain.model.** { *; } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/AndroidManifest.xml b/backend/mpc-system/services/service-party-android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a441ceec --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + 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 00000000..7f93135c Binary files /dev/null and b/backend/mpc-system/services/service-party-android/gradle/wrapper/gradle-wrapper.jar differ 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 {