From 3727b0e817f639f73fcad86a5098d1a011526eaf Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 26 Jan 2026 06:55:25 -0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E5=AE=9E=E7=8E=B0=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TssRepository 添加交易记录管理方法 (saveTransactionRecord, updateTransactionStatus, confirmTransaction, getTransactionRecords) - 添加历史交易同步功能 (syncERC20TransactionHistory, syncNativeTransactionHistory, syncAllTransactionHistory) - MainViewModel 添加交易记录状态和后台确认轮询 - 新建 TransactionHistoryScreen 交易记录列表界面 - WalletsScreen 添加"记录"按钮入口 - 转账成功后自动保存记录并后台确认状态 - 首次导入钱包时自动同步链上历史交易 Co-Authored-By: Claude Opus 4.5 --- .../java/com/durian/tssparty/MainActivity.kt | 28 ++ .../tssparty/data/repository/TssRepository.kt | 473 +++++++++++++++++- .../screens/TransactionHistoryScreen.kt | 386 ++++++++++++++ .../presentation/screens/WalletsScreen.kt | 15 + .../presentation/viewmodel/MainViewModel.kt | 114 ++++- 5 files changed, 1014 insertions(+), 2 deletions(-) create mode 100644 backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransactionHistoryScreen.kt 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 c7831253..e5774592 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 @@ -110,6 +110,10 @@ fun TssPartyApp( val exportResult by viewModel.exportResult.collectAsState() val importResult by viewModel.importResult.collectAsState() + // Transaction history state + val transactionRecords by viewModel.transactionRecords.collectAsState() + val isSyncingHistory by viewModel.isSyncingHistory.collectAsState() + // Current transfer wallet var transferWalletId by remember { mutableStateOf(null) } @@ -296,6 +300,9 @@ fun TssPartyApp( transferWalletId = shareId navController.navigate("transfer/$shareId") }, + onHistory = { shareId, address -> + navController.navigate("history/$shareId/$address") + }, onExportBackup = { shareId, _ -> android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] ========== onExportBackup called ==========") android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] shareId: $shareId") @@ -378,6 +385,27 @@ fun TssPartyApp( } } + // Transaction History Screen + composable("history/{shareId}/{address}") { backStackEntry -> + val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull() ?: 0L + val address = backStackEntry.arguments?.getString("address") ?: "" + + // Load records when entering screen + LaunchedEffect(shareId) { + viewModel.loadTransactionRecords(shareId) + } + + TransactionHistoryScreen( + shareId = shareId, + walletAddress = address, + transactions = transactionRecords, + networkType = settings.networkType, + isSyncing = isSyncingHistory, + onBack = { navController.popBackStack() }, + onRefresh = { viewModel.syncTransactionHistory(shareId, address) } + ) + } + // Tab 2: Create Wallet (创建钱包) composable(BottomNavItem.Create.route) { CreateWalletScreen( 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 23485d4d..1e297665 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 @@ -2844,7 +2844,6 @@ class TssRepository @Inject constructor( } return data } -} /** * Result of creating a keygen session @@ -2976,6 +2975,478 @@ data class ParticipantStatusInfo( val status: String ) +// ========== Transaction Record Methods ========== + + /** + * 保存交易记录 + * 转账发起后立即调用,初始状态为 PENDING + */ + suspend fun saveTransactionRecord( + shareId: Long, + fromAddress: String, + toAddress: String, + amount: String, + tokenType: TokenType, + txHash: String, + gasPrice: String, + direction: String = "SENT" + ): Long { + return withContext(Dispatchers.IO) { + android.util.Log.d("TssRepository", "[TX-RECORD] Saving transaction record: txHash=${txHash.take(20)}...") + val entity = TransactionRecordEntity( + shareId = shareId, + fromAddress = fromAddress, + toAddress = toAddress, + amount = amount, + tokenType = tokenType.name, + txHash = txHash, + gasPrice = gasPrice, + status = "PENDING", + direction = direction, + createdAt = System.currentTimeMillis() + ) + val id = transactionRecordDao.insertRecord(entity) + android.util.Log.d("TssRepository", "[TX-RECORD] Saved with id: $id") + id + } + } + + /** + * 更新交易状态 + * 确认交易后调用,更新状态为 CONFIRMED 或 FAILED + */ + suspend fun updateTransactionStatus( + txHash: String, + status: String, + gasUsed: String, + txFee: String, + blockNumber: Long?, + confirmedAt: Long? + ) { + withContext(Dispatchers.IO) { + android.util.Log.d("TssRepository", "[TX-RECORD] Updating status for txHash=${txHash.take(20)}... to $status") + val record = transactionRecordDao.getRecordByTxHash(txHash) + if (record != null) { + transactionRecordDao.updateStatus( + id = record.id, + status = status, + confirmedAt = confirmedAt, + blockNumber = blockNumber, + gasUsed = gasUsed, + txFee = txFee + ) + android.util.Log.d("TssRepository", "[TX-RECORD] Status updated successfully") + } else { + android.util.Log.w("TssRepository", "[TX-RECORD] Record not found for txHash=${txHash.take(20)}...") + } + } + } + + /** + * 获取钱包的交易记录列表 + */ + fun getTransactionRecords(shareId: Long): Flow> { + return transactionRecordDao.getRecordsForShare(shareId) + } + + /** + * 获取所有待确认的交易 + */ + suspend fun getPendingTransactions(): List { + return withContext(Dispatchers.IO) { + transactionRecordDao.getPendingRecords() + } + } + + /** + * 确认交易状态 + * 查询链上交易收据并更新本地状态 + */ + suspend fun confirmTransaction(txHash: String, rpcUrl: String): Result { + return withContext(Dispatchers.IO) { + try { + android.util.Log.d("TssRepository", "[TX-CONFIRM] Checking receipt for txHash=${txHash.take(20)}...") + val receiptResult = TransactionUtils.getTransactionReceipt(txHash, rpcUrl) + + receiptResult.fold( + onSuccess = { receipt -> + if (receipt != null) { + val status = if (receipt.status) "CONFIRMED" else "FAILED" + val gasUsed = receipt.gasUsed.toString() + val blockNumber = try { + java.math.BigInteger(receipt.blockNumber.removePrefix("0x"), 16).toLong() + } catch (e: Exception) { null } + + // 计算交易费用 (gasUsed * gasPrice) + val record = transactionRecordDao.getRecordByTxHash(txHash) + val txFee = if (record != null && gasUsed.isNotEmpty()) { + val gasUsedBig = java.math.BigInteger(gasUsed) + val gasPriceBig = java.math.BigInteger(record.gasPrice) + val feeWei = gasUsedBig.multiply(gasPriceBig) + // 转换为 KAVA (18 decimals) + val feeKava = feeWei.toBigDecimal().divide(java.math.BigDecimal("1000000000000000000")) + feeKava.stripTrailingZeros().toPlainString() + } else "" + + updateTransactionStatus( + txHash = txHash, + status = status, + gasUsed = gasUsed, + txFee = txFee, + blockNumber = blockNumber, + confirmedAt = System.currentTimeMillis() + ) + android.util.Log.d("TssRepository", "[TX-CONFIRM] Transaction $status: blockNumber=$blockNumber") + Result.success(true) + } else { + android.util.Log.d("TssRepository", "[TX-CONFIRM] Receipt not yet available") + Result.success(false) // 交易还未确认 + } + }, + onFailure = { e -> + android.util.Log.e("TssRepository", "[TX-CONFIRM] Error: ${e.message}") + Result.failure(e) + } + ) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "[TX-CONFIRM] Exception: ${e.message}", e) + Result.failure(e) + } + } + } + + // ========== Transaction History Sync Methods ========== + + /** + * 同步 ERC-20 代币的历史交易 + * 使用 eth_getLogs 查询 Transfer 事件 + */ + suspend fun syncERC20TransactionHistory( + shareId: Long, + address: String, + contractAddress: String, + tokenType: TokenType, + rpcUrl: String + ): Result { + return withContext(Dispatchers.IO) { + try { + android.util.Log.d("TssRepository", "[SYNC-ERC20] Syncing ${tokenType.name} history for $address") + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + // Transfer event signature: keccak256("Transfer(address,address,uint256)") + val transferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + + // 地址需要补齐到 32 字节(左侧补零) + val paddedAddress = "0x" + address.removePrefix("0x").lowercase().padStart(64, '0') + + var totalSynced = 0 + + // 查询发送的交易 (from = address) + val sentRequest = """ + { + "jsonrpc": "2.0", + "method": "eth_getLogs", + "params": [{ + "address": "$contractAddress", + "topics": ["$transferTopic", "$paddedAddress", null], + "fromBlock": "earliest", + "toBlock": "latest" + }], + "id": 1 + } + """.trimIndent() + + val sentResponse = client.newCall( + okhttp3.Request.Builder() + .url(rpcUrl) + .post(sentRequest.toRequestBody(jsonMediaType)) + .build() + ).execute() + + val sentBody = sentResponse.body?.string() + if (sentBody != null) { + totalSynced += parseAndSaveTransferLogs(sentBody, shareId, address, tokenType, "SENT") + } + + // 查询接收的交易 (to = address) + val receivedRequest = """ + { + "jsonrpc": "2.0", + "method": "eth_getLogs", + "params": [{ + "address": "$contractAddress", + "topics": ["$transferTopic", null, "$paddedAddress"], + "fromBlock": "earliest", + "toBlock": "latest" + }], + "id": 2 + } + """.trimIndent() + + val receivedResponse = client.newCall( + okhttp3.Request.Builder() + .url(rpcUrl) + .post(receivedRequest.toRequestBody(jsonMediaType)) + .build() + ).execute() + + val receivedBody = receivedResponse.body?.string() + if (receivedBody != null) { + totalSynced += parseAndSaveTransferLogs(receivedBody, shareId, address, tokenType, "RECEIVED") + } + + android.util.Log.d("TssRepository", "[SYNC-ERC20] Synced $totalSynced ${tokenType.name} transactions") + Result.success(totalSynced) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "[SYNC-ERC20] Error: ${e.message}", e) + Result.failure(e) + } + } + } + + /** + * 解析 Transfer 日志并保存到数据库 + */ + private suspend fun parseAndSaveTransferLogs( + responseBody: String, + shareId: Long, + address: String, + tokenType: TokenType, + direction: String + ): Int { + var count = 0 + try { + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val result = json.getAsJsonArray("result") ?: return 0 + + for (log in result) { + try { + val logObj = log.asJsonObject + val txHash = logObj.get("transactionHash")?.asString ?: continue + val topics = logObj.getAsJsonArray("topics") + val data = logObj.get("data")?.asString ?: "0x0" + val blockNumber = logObj.get("blockNumber")?.asString + + // 检查是否已存在 + val existing = transactionRecordDao.getRecordByTxHash(txHash) + if (existing != null) continue + + // 解析 from 和 to 地址 (topics[1] 和 topics[2]) + val fromAddress = if (topics.size() > 1) { + "0x" + topics[1].asString.removePrefix("0x").takeLast(40) + } else continue + val toAddress = if (topics.size() > 2) { + "0x" + topics[2].asString.removePrefix("0x").takeLast(40) + } else continue + + // 解析金额 (data 字段) + val valueHex = data.removePrefix("0x") + val valueBig = if (valueHex.isNotEmpty()) { + java.math.BigInteger(valueHex, 16) + } else java.math.BigInteger.ZERO + + // 转换为人类可读格式 (根据代币精度) + val decimals = TokenConfig.getDecimals(tokenType) + val divisor = java.math.BigDecimal.TEN.pow(decimals) + val humanReadable = valueBig.toBigDecimal().divide(divisor).stripTrailingZeros().toPlainString() + + // 解析区块号 + val blockNum = blockNumber?.let { + java.math.BigInteger(it.removePrefix("0x"), 16).toLong() + } + + // 保存记录 + val entity = TransactionRecordEntity( + shareId = shareId, + fromAddress = fromAddress, + toAddress = toAddress, + amount = humanReadable, + tokenType = tokenType.name, + txHash = txHash, + gasPrice = "0", // 历史交易无法获取 gasPrice + status = "CONFIRMED", + direction = direction, + blockNumber = blockNum, + confirmedAt = System.currentTimeMillis() + ) + transactionRecordDao.insertRecord(entity) + count++ + } catch (e: Exception) { + android.util.Log.w("TssRepository", "[SYNC-ERC20] Error parsing log: ${e.message}") + } + } + } catch (e: Exception) { + android.util.Log.e("TssRepository", "[SYNC-ERC20] Error parsing response: ${e.message}") + } + return count + } + + /** + * 同步原生 KAVA 历史交易 + * 使用 KavaScan API + */ + suspend fun syncNativeTransactionHistory( + shareId: Long, + address: String, + networkType: NetworkType = NetworkType.MAINNET + ): Result { + return withContext(Dispatchers.IO) { + try { + android.util.Log.d("TssRepository", "[SYNC-KAVA] Syncing native KAVA history for $address") + + val baseUrl = if (networkType == NetworkType.TESTNET) { + "https://testnet.kavascan.com" + } else { + "https://kavascan.com" + } + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + + // KavaScan API 格式(类似 Etherscan) + val apiUrl = "$baseUrl/api?module=account&action=txlist&address=$address&sort=desc" + + val request = okhttp3.Request.Builder() + .url(apiUrl) + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() + + if (responseBody == null) { + android.util.Log.w("TssRepository", "[SYNC-KAVA] Empty response from KavaScan") + return@withContext Result.success(0) + } + + var count = 0 + try { + val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject + val status = json.get("status")?.asString + val result = json.getAsJsonArray("result") + + if (status == "1" && result != null) { + for (tx in result) { + try { + val txObj = tx.asJsonObject + val txHash = txObj.get("hash")?.asString ?: continue + val fromAddr = txObj.get("from")?.asString ?: continue + val toAddr = txObj.get("to")?.asString ?: continue + val valueStr = txObj.get("value")?.asString ?: "0" + val gasPrice = txObj.get("gasPrice")?.asString ?: "0" + val gasUsed = txObj.get("gasUsed")?.asString ?: "0" + val blockNum = txObj.get("blockNumber")?.asString?.toLongOrNull() + val timestamp = txObj.get("timeStamp")?.asString?.toLongOrNull()?.times(1000) + val isError = txObj.get("isError")?.asString == "1" + + // 检查是否已存在 + val existing = transactionRecordDao.getRecordByTxHash(txHash) + if (existing != null) continue + + // 转换金额 (Wei -> KAVA, 18 decimals) + val valueBig = java.math.BigInteger(valueStr) + val valueKava = valueBig.toBigDecimal() + .divide(java.math.BigDecimal("1000000000000000000")) + .stripTrailingZeros() + .toPlainString() + + // 计算交易费 + val txFee = if (gasUsed.isNotEmpty() && gasPrice.isNotEmpty()) { + val fee = java.math.BigInteger(gasUsed).multiply(java.math.BigInteger(gasPrice)) + fee.toBigDecimal() + .divide(java.math.BigDecimal("1000000000000000000")) + .stripTrailingZeros() + .toPlainString() + } else "" + + // 判断方向 + val direction = if (fromAddr.equals(address, ignoreCase = true)) "SENT" else "RECEIVED" + + val entity = TransactionRecordEntity( + shareId = shareId, + fromAddress = fromAddr, + toAddress = toAddr, + amount = valueKava, + tokenType = TokenType.KAVA.name, + txHash = txHash, + gasPrice = gasPrice, + gasUsed = gasUsed, + txFee = txFee, + status = if (isError) "FAILED" else "CONFIRMED", + direction = direction, + blockNumber = blockNum, + createdAt = timestamp ?: System.currentTimeMillis(), + confirmedAt = timestamp ?: System.currentTimeMillis() + ) + transactionRecordDao.insertRecord(entity) + count++ + } catch (e: Exception) { + android.util.Log.w("TssRepository", "[SYNC-KAVA] Error parsing tx: ${e.message}") + } + } + } + } catch (e: Exception) { + android.util.Log.e("TssRepository", "[SYNC-KAVA] Error parsing response: ${e.message}") + } + + android.util.Log.d("TssRepository", "[SYNC-KAVA] Synced $count native KAVA transactions") + Result.success(count) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "[SYNC-KAVA] Error: ${e.message}", e) + Result.failure(e) + } + } + } + + /** + * 同步所有类型的历史交易 + * 首次导入钱包时调用 + */ + suspend fun syncAllTransactionHistory( + shareId: Long, + address: String, + rpcUrl: String, + networkType: NetworkType = NetworkType.MAINNET + ): Result { + return withContext(Dispatchers.IO) { + android.util.Log.d("TssRepository", "[SYNC-ALL] Starting full transaction history sync for $address") + var totalSynced = 0 + + // 同步 ERC-20 代币 + val tokenTypes = listOf( + TokenType.GREEN_POINTS to GreenPointsToken.CONTRACT_ADDRESS, + TokenType.ENERGY_POINTS to EnergyPointsToken.CONTRACT_ADDRESS, + TokenType.FUTURE_POINTS to FuturePointsToken.CONTRACT_ADDRESS + ) + + for ((tokenType, contractAddress) in tokenTypes) { + syncERC20TransactionHistory(shareId, address, contractAddress, tokenType, rpcUrl) + .onSuccess { count -> totalSynced += count } + .onFailure { e -> + android.util.Log.w("TssRepository", "[SYNC-ALL] Failed to sync ${tokenType.name}: ${e.message}") + } + } + + // 同步原生 KAVA + syncNativeTransactionHistory(shareId, address, networkType) + .onSuccess { count -> totalSynced += count } + .onFailure { e -> + android.util.Log.w("TssRepository", "[SYNC-ALL] Failed to sync native KAVA: ${e.message}") + } + + android.util.Log.d("TssRepository", "[SYNC-ALL] Completed: synced $totalSynced total transactions") + Result.success(totalSynced) + } + } +} + private fun ShareRecordEntity.toShareRecord() = ShareRecord( id = id, sessionId = sessionId, diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransactionHistoryScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransactionHistoryScreen.kt new file mode 100644 index 00000000..4534d1c5 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransactionHistoryScreen.kt @@ -0,0 +1,386 @@ +package com.durian.tssparty.presentation.screens + +import android.content.Intent +import android.net.Uri +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.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.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.durian.tssparty.data.local.TransactionRecordEntity +import com.durian.tssparty.domain.model.EnergyPointsToken +import com.durian.tssparty.domain.model.FuturePointsToken +import com.durian.tssparty.domain.model.GreenPointsToken +import com.durian.tssparty.domain.model.NetworkType +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransactionHistoryScreen( + shareId: Long, + walletAddress: String, + transactions: List, + networkType: NetworkType, + isSyncing: Boolean, + onBack: () -> Unit, + onRefresh: () -> Unit +) { + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text("交易记录") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + }, + actions = { + if (isSyncing) { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + strokeWidth = 2.dp + ) + } else { + IconButton(onClick = onRefresh) { + Icon(Icons.Default.Refresh, contentDescription = "刷新") + } + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + ) { + // Wallet address header + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.AccountBalanceWallet, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = walletAddress, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Transaction count + Text( + text = "共 ${transactions.size} 条记录", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + if (transactions.isEmpty()) { + // Empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.Receipt, + 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 = if (isSyncing) "正在同步中..." else "发起转账后将在此显示", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + } + } else { + // Transaction list + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + items( + items = transactions.sortedByDescending { it.createdAt }, + key = { it.id } + ) { tx -> + TransactionItemCard( + transaction = tx, + walletAddress = walletAddress, + networkType = networkType, + onClick = { + // Open transaction in block explorer + val explorerUrl = getExplorerUrl(networkType, tx.txHash) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(explorerUrl)) + context.startActivity(intent) + } + ) + } + } + } + } + } +} + +@Composable +private fun TransactionItemCard( + transaction: TransactionRecordEntity, + walletAddress: String, + networkType: NetworkType, + onClick: () -> Unit +) { + val isSent = transaction.direction == "SENT" || + transaction.fromAddress.equals(walletAddress, ignoreCase = true) + + val statusColor = when (transaction.status) { + "CONFIRMED" -> Color(0xFF4CAF50) // Green + "FAILED" -> MaterialTheme.colorScheme.error + else -> Color(0xFFFF9800) // Orange for PENDING + } + + val tokenColor = when (transaction.tokenType) { + "GREEN_POINTS" -> Color(0xFF4CAF50) + "ENERGY_POINTS" -> Color(0xFF2196F3) + "FUTURE_POINTS" -> Color(0xFF9C27B0) + else -> MaterialTheme.colorScheme.primary // KAVA + } + + val tokenName = when (transaction.tokenType) { + "GREEN_POINTS" -> GreenPointsToken.NAME + "ENERGY_POINTS" -> EnergyPointsToken.NAME + "FUTURE_POINTS" -> FuturePointsToken.NAME + else -> "KAVA" + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + // Row 1: Direction icon + Amount + Status + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Direction icon + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + if (isSent) + MaterialTheme.colorScheme.errorContainer + else + Color(0xFFE8F5E9) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isSent) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward, + contentDescription = if (isSent) "发送" else "接收", + tint = if (isSent) + MaterialTheme.colorScheme.error + else + Color(0xFF4CAF50), + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Amount and token + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${if (isSent) "-" else "+"}${transaction.amount}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isSent) + MaterialTheme.colorScheme.error + else + Color(0xFF4CAF50) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = tokenName, + style = MaterialTheme.typography.bodySmall, + color = tokenColor + ) + } + Text( + text = if (isSent) "发送" else "接收", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Status badge + Surface( + color = statusColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = when (transaction.status) { + "CONFIRMED" -> "已确认" + "FAILED" -> "失败" + else -> "待确认" + }, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = statusColor + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Divider(color = MaterialTheme.colorScheme.outlineVariant) + + Spacer(modifier = Modifier.height(8.dp)) + + // Row 2: Address (to/from) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = if (isSent) "发送至" else "来自", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + Text( + text = if (isSent) shortenAddress(transaction.toAddress) else shortenAddress(transaction.fromAddress), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = "时间", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + Text( + text = formatTimestamp(transaction.createdAt), + style = MaterialTheme.typography.bodySmall + ) + } + } + + // Row 3: Tx Hash (abbreviated) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "交易哈希: ${shortenTxHash(transaction.txHash)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + fontFamily = FontFamily.Monospace + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.OpenInNew, + contentDescription = "查看详情", + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.outline + ) + } + + // Row 4: Fee (if confirmed) + if (transaction.status == "CONFIRMED" && transaction.txFee.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "手续费: ${transaction.txFee} KAVA", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + } +} + +private fun shortenAddress(address: String): String { + return if (address.length > 16) { + "${address.take(10)}...${address.takeLast(6)}" + } else { + address + } +} + +private fun shortenTxHash(txHash: String): String { + return if (txHash.length > 20) { + "${txHash.take(10)}...${txHash.takeLast(8)}" + } else { + txHash + } +} + +private fun formatTimestamp(timestamp: Long): String { + val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) + return sdf.format(Date(timestamp)) +} + +private fun getExplorerUrl(networkType: NetworkType, txHash: String): String { + return when (networkType) { + NetworkType.MAINNET -> "https://kavascan.com/tx/$txHash" + NetworkType.TESTNET -> "https://testnet.kavascan.com/tx/$txHash" + } +} 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 39ac5c71..a4db3ea0 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 @@ -57,6 +57,7 @@ fun WalletsScreen( onDeleteShare: (Long) -> Unit, onRefreshBalance: ((String) -> Unit)? = null, onTransfer: ((shareId: Long) -> Unit)? = null, + onHistory: ((shareId: Long, address: String) -> Unit)? = null, onExportBackup: ((shareId: Long, password: String) -> Unit)? = null, onImportBackup: (() -> Unit)? = null, onCreateWallet: (() -> Unit)? = null @@ -157,6 +158,9 @@ fun WalletsScreen( onTransfer = { onTransfer?.invoke(share.id) }, + onHistory = { + onHistory?.invoke(share.id, share.address) + }, onDelete = { onDeleteShare(share.id) } ) } @@ -225,6 +229,7 @@ private fun WalletItemCard( walletBalance: WalletBalance? = null, onViewDetails: () -> Unit, onTransfer: () -> Unit, + onHistory: () -> Unit, onDelete: () -> Unit ) { var showDeleteDialog by remember { mutableStateOf(false) } @@ -435,6 +440,16 @@ private fun WalletItemCard( Text("转账") } + TextButton(onClick = onHistory) { + Icon( + Icons.Default.Receipt, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("记录") + } + TextButton( onClick = { showDeleteDialog = true }, colors = ButtonDefaults.textButtonColors( 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 index bdb721f0..8818a9c4 100644 --- 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 @@ -2,8 +2,8 @@ package com.durian.tssparty.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.durian.tssparty.data.repository.JoinKeygenViaGrpcResult import com.durian.tssparty.data.repository.TssRepository +import com.durian.tssparty.data.repository.TssRepository.JoinKeygenViaGrpcResult import com.durian.tssparty.domain.model.* import com.durian.tssparty.util.AddressUtils import com.durian.tssparty.util.TransactionUtils @@ -917,6 +917,66 @@ class MainViewModel @Inject constructor( } } + // ========== Transaction Records ========== + + private val _transactionRecords = MutableStateFlow>(emptyList()) + val transactionRecords: StateFlow> = _transactionRecords.asStateFlow() + + private val _isSyncingHistory = MutableStateFlow(false) + val isSyncingHistory: StateFlow = _isSyncingHistory.asStateFlow() + + /** + * 加载钱包的交易记录 + */ + fun loadTransactionRecords(shareId: Long) { + viewModelScope.launch { + repository.getTransactionRecords(shareId).collect { records -> + _transactionRecords.value = records + } + } + } + + /** + * 同步钱包的所有历史交易 + * 首次导入钱包时调用 + */ + fun syncTransactionHistory(shareId: Long, address: String) { + viewModelScope.launch { + _isSyncingHistory.value = true + android.util.Log.d("MainViewModel", "[SYNC] Starting transaction history sync for $address") + + val rpcUrl = _settings.value.kavaRpcUrl + val networkType = _settings.value.networkType + + val result = repository.syncAllTransactionHistory(shareId, address, rpcUrl, networkType) + result.fold( + onSuccess = { count -> + android.util.Log.d("MainViewModel", "[SYNC] Synced $count transactions") + }, + onFailure = { e -> + android.util.Log.e("MainViewModel", "[SYNC] Error syncing: ${e.message}") + } + ) + _isSyncingHistory.value = false + } + } + + /** + * 确认所有待处理的交易 + * 应用启动时调用 + */ + fun confirmPendingTransactions() { + viewModelScope.launch { + val rpcUrl = _settings.value.kavaRpcUrl + val pendingRecords = repository.getPendingTransactions() + android.util.Log.d("MainViewModel", "[TX-CONFIRM] Found ${pendingRecords.size} pending transactions") + + for (record in pendingRecords) { + repository.confirmTransaction(record.txHash, rpcUrl) + } + } + } + // ========== Share Export/Import ========== private val _exportResult = MutableStateFlow(null) @@ -991,6 +1051,10 @@ class MainViewModel @Inject constructor( // Fetch balance for the imported wallet android.util.Log.d("MainViewModel", "[IMPORT] Fetching balance...") fetchBalanceForShare(share) + + // Sync transaction history from blockchain (first-time import) + android.util.Log.d("MainViewModel", "[IMPORT] Starting transaction history sync...") + syncTransactionHistory(share.id, share.address) android.util.Log.d("MainViewModel", "[IMPORT] Import complete!") }, onFailure = { e -> @@ -1390,6 +1454,23 @@ class MainViewModel @Inject constructor( onSuccess = { hash -> android.util.Log.d("MainViewModel", "[BROADCAST] SUCCESS! txHash=$hash") _txHash.value = hash + + // 保存交易记录到本地数据库 + val state = _transferState.value + android.util.Log.d("MainViewModel", "[BROADCAST] Saving transaction record: shareId=${state.shareId}, tokenType=${state.tokenType}") + repository.saveTransactionRecord( + shareId = state.shareId, + fromAddress = tx.from, + toAddress = tx.to, + amount = state.amount, + tokenType = state.tokenType, + txHash = hash, + gasPrice = tx.gasPrice.toString() + ) + + // 启动后台确认交易状态 + confirmTransactionInBackground(hash, rpcUrl) + _uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") } }, onFailure = { e -> @@ -1400,6 +1481,37 @@ class MainViewModel @Inject constructor( } } + /** + * 后台确认交易状态 + * 每 3 秒轮询一次,最多尝试 60 次(3 分钟) + */ + private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) { + viewModelScope.launch { + android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash") + var attempts = 0 + val maxAttempts = 60 + + while (attempts < maxAttempts) { + kotlinx.coroutines.delay(3000) // 等待 3 秒 + attempts++ + + val result = repository.confirmTransaction(txHash, rpcUrl) + result.fold( + onSuccess = { confirmed -> + if (confirmed) { + android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts") + return@launch + } + }, + onFailure = { e -> + android.util.Log.w("MainViewModel", "[TX-CONFIRM] Error checking confirmation: ${e.message}") + } + ) + } + android.util.Log.w("MainViewModel", "[TX-CONFIRM] Max attempts reached, transaction may still be pending") + } + } + /** * Reset transfer state */