fix(android): transfer flow improvements and message_hash format fix

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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-01 18:18:29 -08:00
parent 480251b85f
commit ed55be2b86
4 changed files with 49 additions and 205 deletions

View File

@ -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) }
},

View File

@ -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)

View File

@ -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<String?>(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()

View File

@ -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<ShareRecord?>(null) }
var showTransferDialog by remember { mutableStateOf(false) }
var transferWallet by remember { mutableStateOf<ShareRecord?>(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<String?>(null) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
Text(
text = "转账",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${wallet.address.take(10)}...${wallet.address.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(24.dp))
// Recipient address
OutlinedTextField(
value = toAddress,
onValueChange = { toAddress = it },
label = { Text("收款地址") },
placeholder = { Text("0x...") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
}
)
Spacer(modifier = Modifier.height(16.dp))
// Amount
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = { Text("金额 (KAVA)") },
placeholder = { Text("0.0") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
leadingIcon = {
Icon(Icons.Default.AttachMoney, contentDescription = null)
}
)
Spacer(modifier = Modifier.height(16.dp))
// Password
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("钱包密码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null
)
}
}
)
error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(16.dp))
// Info card
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "转账需要 ${wallet.thresholdT} 个参与者共同签名。确认后将创建签名会话。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("取消")
}
Button(
onClick = {
when {
toAddress.isBlank() -> error = "请输入收款地址"
!toAddress.startsWith("0x") || toAddress.length != 42 -> error = "地址格式不正确"
amount.isBlank() -> error = "请输入金额"
amount.toDoubleOrNull() == null || amount.toDouble() <= 0 -> error = "金额无效"
password.isBlank() -> error = "请输入密码"
else -> {
error = null
onConfirm(toAddress, amount, password)
}
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("确认转账")
}
}
}
}
}
}
@Composable
private fun ExportBackupDialog(
onDismiss: () -> Unit,