From ed55be2b8609abdbfb2f10901e7f6170faec0ce0 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 1 Jan 2026 18:18:29 -0800 Subject: [PATCH] fix(android): transfer flow improvements and message_hash format fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transfer Screen improvements: - Add QR code scanning for recipient address (using zxing library) - Support EIP-681 URI format (ethereum:0x...) and plain address - Remove password requirement - TSS wallets don't need passwords - Remove unused onScanQrCode callback parameter WalletsScreen changes: - Simplify onTransfer callback to only pass shareId - Remove TransferDialog - now navigates directly to TransferScreen - Remove unused state variables (showTransferDialog, transferWallet) Bug fix: - Remove 0x prefix from message_hash before sending to API - Backend expects pure hex, not 0x-prefixed hex string 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../java/com/durian/tssparty/MainActivity.kt | 6 +- .../tssparty/data/repository/TssRepository.kt | 8 +- .../presentation/screens/TransferScreen.kt | 41 +++- .../presentation/screens/WalletsScreen.kt | 199 +----------------- 4 files changed, 49 insertions(+), 205 deletions(-) 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 index db73038a..2f479af5 100644 --- 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 @@ -170,9 +170,8 @@ fun TssPartyApp( networkType = settings.networkType, onDeleteShare = { viewModel.deleteShare(it) }, onRefreshBalance = { address -> viewModel.fetchBalance(address) }, - onTransfer = { shareId, toAddress, amount, password -> + onTransfer = { shareId -> transferWalletId = shareId - viewModel.prepareTransfer(shareId, toAddress, amount) navController.navigate("transfer/$shareId") }, onCreateWallet = { @@ -209,9 +208,6 @@ fun TssPartyApp( onConfirmTransaction = { viewModel.initiateSignSession(shareId, "") }, - onScanQrCode = { - // TODO: Launch QR scanner for recipient address - }, onCopyInviteCode = { signInviteCode?.let { onCopyToClipboard(it) } }, 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 index 1b970e0a..efa1b742 100644 --- 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 @@ -1697,10 +1697,16 @@ class TssRepository @Inject constructor( // Build request body matching account-service API // Note: Android ShareRecordEntity doesn't store wallet_name, use address-based name val walletName = "Wallet ${shareEntity.address.take(8)}...${shareEntity.address.takeLast(4)}" + // Remove 0x prefix from messageHash - backend expects pure hex + val cleanMessageHash = if (messageHash.startsWith("0x") || messageHash.startsWith("0X")) { + messageHash.substring(2) + } else { + messageHash + } val requestBody = com.google.gson.JsonObject().apply { addProperty("keygen_session_id", shareEntity.sessionId) addProperty("wallet_name", walletName) - addProperty("message_hash", messageHash) + addProperty("message_hash", cleanMessageHash) add("parties", partiesArray) addProperty("threshold_t", shareEntity.thresholdT) addProperty("initiator_name", initiatorName) 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 index 00b02496..cbb9020f 100644 --- 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 @@ -27,6 +27,8 @@ 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 com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions import kotlinx.coroutines.launch import java.math.BigInteger @@ -34,13 +36,15 @@ import java.math.BigInteger * Transfer Screen - matches service-party-app Home.tsx transfer flow exactly * * Flow: - * 1. input - Enter recipient address, amount, password + * 1. input - Enter recipient address and amount (QR scan or manual input) * 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 + * + * Note: No password required - TSS wallets don't use passwords */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -65,13 +69,31 @@ fun TransferScreen( onCopyInviteCode: () -> Unit, onBroadcastTransaction: () -> Unit, onCancel: () -> Unit, - onBackToWallets: () -> Unit, - onScanQrCode: () -> Unit = {} + onBackToWallets: () -> Unit ) { var toAddress by remember { mutableStateOf("") } var amount by remember { mutableStateOf("") } var validationError by remember { mutableStateOf(null) } + // QR Scanner launcher for recipient address + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + result.contents?.let { scannedContent -> + // Handle scanned address - may be plain address or EIP-681 URI + val address = if (scannedContent.startsWith("ethereum:")) { + // Parse EIP-681 URI: ethereum:0x...[@chainId][/function]?[params] + scannedContent.removePrefix("ethereum:").split("@", "/", "?").firstOrNull() ?: scannedContent + } else { + scannedContent + } + // Only set if it looks like a valid address + if (address.startsWith("0x") && address.length == 42) { + toAddress = address + } else { + validationError = "扫描的不是有效的钱包地址" + } + } + } + // Determine current step val step = when { txHash != null -> "completed" @@ -132,7 +154,18 @@ fun TransferScreen( } }, onCancel = onCancel, - onScanQrCode = onScanQrCode + onScanQrCode = { + val options = ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("扫描收款地址二维码") + setCameraId(0) + setBeepEnabled(true) + setBarcodeImageEnabled(false) + setOrientationLocked(true) + setCaptureActivity(PortraitCaptureActivity::class.java) + } + scanLauncher.launch(options) + } ) "preparing" -> PreparingScreen() 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 index 3d8485e0..b41b690d 100644 --- 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 @@ -27,6 +27,7 @@ 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 +// Note: Some unused imports kept for ExportBackupDialog which still uses password import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -50,13 +51,11 @@ fun WalletsScreen( networkType: NetworkType = NetworkType.MAINNET, onDeleteShare: (Long) -> Unit, onRefreshBalance: ((String) -> Unit)? = null, - onTransfer: ((shareId: Long, toAddress: String, amount: String, password: String) -> Unit)? = null, + onTransfer: ((shareId: Long) -> 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( @@ -149,8 +148,7 @@ fun WalletsScreen( balance = balances[share.address], onViewDetails = { selectedWallet = share }, onTransfer = { - transferWallet = share - showTransferDialog = true + onTransfer?.invoke(share.id) }, onDelete = { onDeleteShare(share.id) } ) @@ -185,30 +183,13 @@ fun WalletsScreen( onDismiss = { selectedWallet = null }, onTransfer = { selectedWallet = null - transferWallet = wallet - showTransferDialog = true + onTransfer?.invoke(wallet.id) }, 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 @@ -604,178 +585,6 @@ private fun InfoRow(label: String, value: String) { } } -@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,