diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f9cf524f..84aa9f0e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -790,7 +790,11 @@ "Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/\")", "Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/services/\")", "Bash(where:*)", - "Bash(npx md-to-pdf:*)" + "Bash(npx md-to-pdf:*)", + "Bash(node -e \"\nconst { ethers } = require\\(''ethers''\\);\nconst provider = new ethers.JsonRpcProvider\\(''https://evm.kava.io''\\);\nconst wallet = new ethers.Wallet\\(''0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a'', provider\\);\n\\(async \\(\\) => {\n console.log\\(''Deployer address:'', wallet.address\\);\n const balance = await provider.getBalance\\(wallet.address\\);\n console.log\\(''KAVA Balance:'', ethers.formatEther\\(balance\\), ''KAVA''\\);\n}\\)\\(\\);\n\")", + "Bash(./gradlew compileDebugKotlin:*)", + "Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart)", + "Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"fix\\(kline\\): 稀疏数据时让最新K线居中而非整组居中\" -m \"修正居中逻辑:当K线数量少时,让最新的那根K线(最右边)显示在屏幕中央,而不是让整个K线组居中。\" -m \"Co-Authored-By: Claude Opus 4.5 \")" ], "deny": [], "ask": [] 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 3ac592fc..0223374c 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 @@ -256,6 +256,10 @@ fun TssPartyApp( transferWalletId = shareId navController.navigate("transfer/$shareId") }, + onViewTransactionHistory = { shareId -> + // 导航到转账历史页面 + navController.navigate("transaction_history/$shareId") + }, onExportBackup = { shareId, _ -> // Get address for filename val share = shares.find { it.id == shareId } @@ -325,6 +329,56 @@ fun TssPartyApp( } } + // Transaction History Screen (转账记录) + composable("transaction_history/{shareId}") { backStackEntry -> + val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull() + val wallet = shareId?.let { viewModel.getWalletById(it) } + + if (wallet != null) { + // 获取转账记录 + val transactions by viewModel.getTransactionRecordsFlow(shareId).collectAsState() + val ledgerSummary by viewModel.ledgerSummary.collectAsState() + var selectedTokenFilter by remember { mutableStateOf(null) } + + // 首次加载时获取分类账汇总 + LaunchedEffect(shareId) { + viewModel.loadLedgerSummary(shareId, TokenType.KAVA) + viewModel.checkPendingTransactions() + } + + TransactionHistoryScreen( + wallet = wallet, + transactions = if (selectedTokenFilter != null) { + transactions.filter { it.tokenType == selectedTokenFilter } + } else { + transactions + }, + ledgerSummary = ledgerSummary, + networkType = settings.networkType, + onRefresh = { + viewModel.checkPendingTransactions() + if (selectedTokenFilter != null) { + viewModel.loadLedgerSummary(shareId, selectedTokenFilter!!) + } else { + viewModel.loadLedgerSummary(shareId, TokenType.KAVA) + } + }, + onFilterByToken = { tokenType -> + selectedTokenFilter = tokenType + if (tokenType != null) { + viewModel.loadLedgerSummary(shareId, tokenType) + } else { + viewModel.loadLedgerSummary(shareId, TokenType.KAVA) + } + }, + onBack = { + viewModel.clearLedgerSummary() + navController.popBackStack() + } + ) + } + } + // 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/local/Database.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/local/Database.kt index 2c537ab4..2a7567d5 100644 --- 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 @@ -93,15 +93,210 @@ interface AppSettingDao { suspend fun setValue(setting: AppSettingEntity) } +/** + * 转账记录数据库实体 + * Entity for storing transaction history records + */ +@Entity( + tableName = "transaction_records", + foreignKeys = [ + ForeignKey( + entity = ShareRecordEntity::class, + parentColumns = ["id"], + childColumns = ["share_id"], + onDelete = ForeignKey.CASCADE // 删除钱包时自动删除关联的转账记录 + ) + ], + indices = [ + Index(value = ["share_id"]), + Index(value = ["tx_hash"], unique = true), + Index(value = ["from_address"]), + Index(value = ["to_address"]), + Index(value = ["created_at"]) + ] +) +data class TransactionRecordEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + @ColumnInfo(name = "share_id") + val shareId: Long, // 关联的钱包ID + + @ColumnInfo(name = "from_address") + val fromAddress: String, // 发送方地址 + + @ColumnInfo(name = "to_address") + val toAddress: String, // 接收方地址 + + @ColumnInfo(name = "amount") + val amount: String, // 转账金额(人类可读格式) + + @ColumnInfo(name = "token_type") + val tokenType: String, // 代币类型:KAVA 或 GREEN_POINTS + + @ColumnInfo(name = "tx_hash") + val txHash: String, // 交易哈希 + + @ColumnInfo(name = "gas_price") + val gasPrice: String, // Gas 价格(Wei) + + @ColumnInfo(name = "gas_used") + val gasUsed: String = "", // 实际消耗的 Gas + + @ColumnInfo(name = "tx_fee") + val txFee: String = "", // 交易手续费 + + @ColumnInfo(name = "status") + val status: String, // 交易状态:PENDING, CONFIRMED, FAILED + + @ColumnInfo(name = "direction") + val direction: String, // 交易方向:SENT, RECEIVED + + @ColumnInfo(name = "note") + val note: String = "", // 备注 + + @ColumnInfo(name = "created_at") + val createdAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "confirmed_at") + val confirmedAt: Long? = null, // 确认时间 + + @ColumnInfo(name = "block_number") + val blockNumber: Long? = null // 区块高度 +) + +/** + * 转账记录 DAO + * Data Access Object for transaction records + */ +@Dao +interface TransactionRecordDao { + /** + * 插入新的转账记录 + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRecord(record: TransactionRecordEntity): Long + + /** + * 根据ID获取转账记录 + */ + @Query("SELECT * FROM transaction_records WHERE id = :id") + suspend fun getRecordById(id: Long): TransactionRecordEntity? + + /** + * 根据交易哈希获取转账记录 + */ + @Query("SELECT * FROM transaction_records WHERE tx_hash = :txHash") + suspend fun getRecordByTxHash(txHash: String): TransactionRecordEntity? + + /** + * 获取指定钱包的所有转账记录(按时间倒序) + */ + @Query("SELECT * FROM transaction_records WHERE share_id = :shareId ORDER BY created_at DESC") + fun getRecordsForShare(shareId: Long): Flow> + + /** + * 获取指定钱包的转账记录(带分页) + */ + @Query("SELECT * FROM transaction_records WHERE share_id = :shareId ORDER BY created_at DESC LIMIT :limit OFFSET :offset") + suspend fun getRecordsForSharePaged(shareId: Long, limit: Int, offset: Int): List + + /** + * 获取指定地址的所有转账记录(作为发送方或接收方) + */ + @Query("SELECT * FROM transaction_records WHERE from_address = :address OR to_address = :address ORDER BY created_at DESC") + fun getRecordsByAddress(address: String): Flow> + + /** + * 获取指定钱包和代币类型的转账记录 + */ + @Query("SELECT * FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType ORDER BY created_at DESC") + fun getRecordsForShareByToken(shareId: Long, tokenType: String): Flow> + + /** + * 获取待确认的转账记录 + */ + @Query("SELECT * FROM transaction_records WHERE status = 'PENDING' ORDER BY created_at ASC") + suspend fun getPendingRecords(): List + + /** + * 更新转账记录状态 + */ + @Query("UPDATE transaction_records SET status = :status, confirmed_at = :confirmedAt, block_number = :blockNumber, gas_used = :gasUsed, tx_fee = :txFee WHERE id = :id") + suspend fun updateStatus(id: Long, status: String, confirmedAt: Long?, blockNumber: Long?, gasUsed: String, txFee: String) + + /** + * 获取指定钱包的转账记录统计 + */ + @Query(""" + SELECT + COUNT(*) as total_count, + SUM(CASE WHEN direction = 'SENT' THEN 1 ELSE 0 END) as sent_count, + SUM(CASE WHEN direction = 'RECEIVED' THEN 1 ELSE 0 END) as received_count + FROM transaction_records + WHERE share_id = :shareId AND token_type = :tokenType + """) + suspend fun getTransactionStats(shareId: Long, tokenType: String): TransactionStats + + /** + * 获取指定钱包的总转出金额(按代币类型) + */ + @Query("SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType AND direction = 'SENT' AND status = 'CONFIRMED'") + suspend fun getTotalSentAmount(shareId: Long, tokenType: String): Double + + /** + * 获取指定钱包的总转入金额(按代币类型) + */ + @Query("SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType AND direction = 'RECEIVED' AND status = 'CONFIRMED'") + suspend fun getTotalReceivedAmount(shareId: Long, tokenType: String): Double + + /** + * 获取指定钱包的总手续费 + */ + @Query("SELECT COALESCE(SUM(CAST(tx_fee AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND direction = 'SENT' AND status = 'CONFIRMED'") + suspend fun getTotalTxFee(shareId: Long): Double + + /** + * 删除指定的转账记录 + */ + @Query("DELETE FROM transaction_records WHERE id = :id") + suspend fun deleteRecordById(id: Long) + + /** + * 删除指定钱包的所有转账记录 + */ + @Query("DELETE FROM transaction_records WHERE share_id = :shareId") + suspend fun deleteRecordsForShare(shareId: Long) + + /** + * 获取转账记录总数 + */ + @Query("SELECT COUNT(*) FROM transaction_records WHERE share_id = :shareId") + suspend fun getRecordCount(shareId: Long): Int +} + +/** + * 转账统计数据类 + */ +data class TransactionStats( + @ColumnInfo(name = "total_count") + val totalCount: Int, + @ColumnInfo(name = "sent_count") + val sentCount: Int, + @ColumnInfo(name = "received_count") + val receivedCount: Int +) + /** * Room database */ @Database( - entities = [ShareRecordEntity::class, AppSettingEntity::class], - version = 3, // Version 3: added party_id column to share_records + entities = [ShareRecordEntity::class, AppSettingEntity::class, TransactionRecordEntity::class], + version = 4, // Version 4: added transaction_records table for transfer history exportSchema = false ) abstract class TssDatabase : RoomDatabase() { abstract fun shareRecordDao(): ShareRecordDao abstract fun appSettingDao(): AppSettingDao + abstract fun transactionRecordDao(): TransactionRecordDao } 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 cc8372d8..6645b2f4 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 @@ -5,6 +5,8 @@ import com.durian.tssparty.data.local.AppSettingDao import com.durian.tssparty.data.local.AppSettingEntity import com.durian.tssparty.data.local.ShareRecordDao import com.durian.tssparty.data.local.ShareRecordEntity +import com.durian.tssparty.data.local.TransactionRecordDao +import com.durian.tssparty.data.local.TransactionRecordEntity import com.durian.tssparty.data.local.TssNativeBridge import com.durian.tssparty.data.remote.GrpcClient import com.durian.tssparty.data.remote.GrpcConnectionEvent @@ -31,7 +33,8 @@ class TssRepository @Inject constructor( private val grpcClient: GrpcClient, private val tssNativeBridge: TssNativeBridge, private val shareRecordDao: ShareRecordDao, - private val appSettingDao: AppSettingDao + private val appSettingDao: AppSettingDao, + private val transactionRecordDao: TransactionRecordDao ) { private val _currentSession = MutableStateFlow(null) val currentSession: StateFlow = _currentSession.asStateFlow() @@ -1883,20 +1886,32 @@ class TssRepository @Inject constructor( } /** - * Export a share as backup JSON string + * Export a share as backup JSON string (includes transaction records) + * 导出钱包备份(包含转账记录) + * * @param shareId The ID of the share to export + * @param includeTransactions Whether to include transaction records (default: true) * @return Result containing the backup JSON string */ - suspend fun exportShareBackup(shareId: Long): Result { + suspend fun exportShareBackup(shareId: Long, includeTransactions: Boolean = true): Result { return withContext(Dispatchers.IO) { try { val share = shareRecordDao.getShareById(shareId) ?: return@withContext Result.failure(Exception("钱包不存在")) - val backup = ShareBackup.fromShareRecord(share.toShareRecord()) + val backup = if (includeTransactions) { + // 获取该钱包的所有转账记录 + val transactionEntities = transactionRecordDao.getRecordsForSharePaged(shareId, Int.MAX_VALUE, 0) + val transactionRecords = transactionEntities.map { it.toTransactionRecord() } + ShareBackup.fromShareRecordWithTransactions(share.toShareRecord(), transactionRecords) + } else { + ShareBackup.fromShareRecord(share.toShareRecord()) + } + val json = com.google.gson.Gson().toJson(backup) - android.util.Log.d("TssRepository", "Exported share backup for address: ${share.address}") + val txCount = backup.transactionRecords?.size ?: 0 + android.util.Log.d("TssRepository", "Exported share backup for address: ${share.address}, with $txCount transaction records") Result.success(json) } catch (e: Exception) { android.util.Log.e("TssRepository", "Failed to export share backup", e) @@ -1906,7 +1921,9 @@ class TssRepository @Inject constructor( } /** - * Import a share from backup JSON string + * Import a share from backup JSON string (includes transaction records restoration) + * 从备份 JSON 字符串导入钱包(包含转账记录恢复) + * * @param backupJson The backup JSON string to import * @return Result containing the imported ShareRecord */ @@ -1940,7 +1957,44 @@ class TssRepository @Inject constructor( val newId = shareRecordDao.insertShare(entity) val savedShare = shareRecord.copy(id = newId) - android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}, partyId: ${backup.partyId}") + // 恢复转账记录(如果备份中包含) + var restoredTxCount = 0 + if (backup.hasTransactionRecords()) { + val transactionRecords = backup.getTransactionRecords(newId) + for (txRecord in transactionRecords) { + try { + // 检查是否已存在相同的交易哈希(防止重复导入) + val existingTx = transactionRecordDao.getRecordByTxHash(txRecord.txHash) + if (existingTx == null) { + val txEntity = TransactionRecordEntity( + shareId = newId, + fromAddress = txRecord.fromAddress, + toAddress = txRecord.toAddress, + amount = txRecord.amount, + tokenType = txRecord.tokenType.name, + txHash = txRecord.txHash, + gasPrice = txRecord.gasPrice, + gasUsed = txRecord.gasUsed, + txFee = txRecord.txFee, + status = txRecord.status.name, + direction = txRecord.direction.name, + note = txRecord.note, + createdAt = txRecord.createdAt, + confirmedAt = txRecord.confirmedAt, + blockNumber = txRecord.blockNumber + ) + transactionRecordDao.insertRecord(txEntity) + restoredTxCount++ + } + } catch (e: Exception) { + android.util.Log.w("TssRepository", "Failed to restore transaction ${txRecord.txHash}: ${e.message}") + // 继续恢复其他记录,不中断整个导入过程 + } + } + android.util.Log.d("TssRepository", "Restored $restoredTxCount transaction records") + } + + android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}, partyId: ${backup.partyId}, transactions: $restoredTxCount") Result.success(savedShare) } catch (e: com.google.gson.JsonSyntaxException) { android.util.Log.e("TssRepository", "Invalid JSON format in backup", e) @@ -2510,6 +2564,310 @@ class TssRepository @Inject constructor( return TransactionUtils.getTransactionReceipt(txHash, rpcUrl) } + /** + * 广播交易并保存转账记录 + * Broadcast transaction and save transaction record + * + * @param preparedTx 准备好的交易 + * @param signature 签名 + * @param rpcUrl RPC URL + * @param shareId 钱包ID + * @param amount 转账金额(人类可读格式) + * @param tokenType 代币类型 + * @param note 备注(可选) + * @return 交易哈希 + */ + suspend fun broadcastTransactionAndSaveRecord( + preparedTx: TransactionUtils.PreparedTransaction, + signature: String, + rpcUrl: String, + shareId: Long, + amount: String, + tokenType: TokenType, + note: String = "" + ): Result { + return withContext(Dispatchers.IO) { + try { + // 先广播交易 + val broadcastResult = broadcastTransaction(preparedTx, signature, rpcUrl) + + if (broadcastResult.isFailure) { + return@withContext Result.failure(broadcastResult.exceptionOrNull()!!) + } + + val txHash = broadcastResult.getOrThrow() + + // 保存转账记录 + val saveResult = saveTransactionRecord( + shareId = shareId, + fromAddress = preparedTx.from, + toAddress = preparedTx.to, + amount = amount, + tokenType = tokenType, + txHash = txHash, + gasPrice = preparedTx.gasPrice.toString(), + direction = TransactionDirection.SENT, + note = note + ) + + if (saveResult.isFailure) { + android.util.Log.w("TssRepository", "Transaction broadcast succeeded but failed to save record: ${saveResult.exceptionOrNull()?.message}") + // 不影响返回结果,交易已成功 + } else { + android.util.Log.d("TssRepository", "Transaction record saved: recordId=${saveResult.getOrNull()}, txHash=$txHash") + } + + Result.success(txHash) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Failed to broadcast transaction and save record", e) + Result.failure(e) + } + } + } + + // ========== 转账记录管理方法 ========== + + /** + * 保存转账记录 + * Save a transaction record to the database + * + * @param shareId 钱包ID + * @param fromAddress 发送方地址 + * @param toAddress 接收方地址 + * @param amount 转账金额(人类可读格式) + * @param tokenType 代币类型 + * @param txHash 交易哈希 + * @param gasPrice Gas价格 + * @param direction 交易方向(SENT/RECEIVED) + * @param note 备注(可选) + */ + suspend fun saveTransactionRecord( + shareId: Long, + fromAddress: String, + toAddress: String, + amount: String, + tokenType: TokenType, + txHash: String, + gasPrice: String, + direction: TransactionDirection, + note: String = "" + ): Result { + return withContext(Dispatchers.IO) { + try { + val entity = TransactionRecordEntity( + shareId = shareId, + fromAddress = fromAddress, + toAddress = toAddress, + amount = amount, + tokenType = tokenType.name, + txHash = txHash, + gasPrice = gasPrice, + status = TransactionStatus.PENDING.name, + direction = direction.name, + note = note + ) + val id = transactionRecordDao.insertRecord(entity) + android.util.Log.d("TssRepository", "Saved transaction record: id=$id, txHash=$txHash") + Result.success(id) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Failed to save transaction record", e) + Result.failure(e) + } + } + } + + /** + * 更新转账记录状态(交易确认后调用) + * Update transaction status after confirmation + */ + suspend fun updateTransactionStatus( + txHash: String, + status: TransactionStatus, + blockNumber: Long? = null, + gasUsed: String = "", + txFee: String = "" + ): Result { + return withContext(Dispatchers.IO) { + try { + val record = transactionRecordDao.getRecordByTxHash(txHash) + if (record != null) { + val confirmedAt = if (status == TransactionStatus.CONFIRMED) System.currentTimeMillis() else null + transactionRecordDao.updateStatus( + id = record.id, + status = status.name, + confirmedAt = confirmedAt, + blockNumber = blockNumber, + gasUsed = gasUsed, + txFee = txFee + ) + android.util.Log.d("TssRepository", "Updated transaction status: txHash=$txHash, status=$status") + Result.success(Unit) + } else { + Result.failure(Exception("Transaction record not found for txHash: $txHash")) + } + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Failed to update transaction status", e) + Result.failure(e) + } + } + } + + /** + * 获取指定钱包的转账记录列表(实时更新) + * Get transaction records for a wallet as Flow + */ + fun getTransactionRecordsFlow(shareId: Long): Flow> { + return transactionRecordDao.getRecordsForShare(shareId).map { entities -> + entities.map { it.toTransactionRecord() } + } + } + + /** + * 获取指定钱包的转账记录列表(分页) + * Get transaction records with pagination + */ + suspend fun getTransactionRecordsPaged(shareId: Long, limit: Int, offset: Int): Result> { + return withContext(Dispatchers.IO) { + try { + val records = transactionRecordDao.getRecordsForSharePaged(shareId, limit, offset) + Result.success(records.map { it.toTransactionRecord() }) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + /** + * 获取指定钱包和代币类型的转账记录 + * Get transaction records filtered by token type + */ + fun getTransactionRecordsByTokenFlow(shareId: Long, tokenType: TokenType): Flow> { + return transactionRecordDao.getRecordsForShareByToken(shareId, tokenType.name).map { entities -> + entities.map { it.toTransactionRecord() } + } + } + + /** + * 获取转账记录分类账汇总 + * Get transaction ledger summary for a wallet + */ + suspend fun getTransactionLedgerSummary(shareId: Long, tokenType: TokenType): Result { + return withContext(Dispatchers.IO) { + try { + val share = shareRecordDao.getShareById(shareId) + ?: return@withContext Result.failure(Exception("Wallet not found")) + + val stats = transactionRecordDao.getTransactionStats(shareId, tokenType.name) + val totalSent = transactionRecordDao.getTotalSentAmount(shareId, tokenType.name) + val totalReceived = transactionRecordDao.getTotalReceivedAmount(shareId, tokenType.name) + val totalTxFee = transactionRecordDao.getTotalTxFee(shareId) + + val summary = TransactionLedgerSummary( + address = share.address, + tokenType = tokenType, + totalSent = formatAmount(totalSent, tokenType), + totalReceived = formatAmount(totalReceived, tokenType), + totalTxFee = formatKavaAmount(totalTxFee), + transactionCount = stats.totalCount, + sentCount = stats.sentCount, + receivedCount = stats.receivedCount + ) + + Result.success(summary) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Failed to get ledger summary", e) + Result.failure(e) + } + } + } + + /** + * 检查并更新待确认的转账记录 + * Check and update pending transactions + */ + suspend fun checkPendingTransactions(rpcUrl: String): Result { + return withContext(Dispatchers.IO) { + try { + val pendingRecords = transactionRecordDao.getPendingRecords() + var confirmedCount = 0 + + for (record in pendingRecords) { + try { + val receiptResult = TransactionUtils.getTransactionReceipt(record.txHash, rpcUrl) + if (receiptResult.isSuccess) { + val receipt = receiptResult.getOrNull() + if (receipt != null) { + // receipt.status 是 Boolean: true = 成功, false = 失败 + val status = if (receipt.status) TransactionStatus.CONFIRMED else TransactionStatus.FAILED + // receipt.gasUsed 是 BigInteger + val gasUsedBi = receipt.gasUsed + // 计算手续费:gasUsed * gasPrice + val gasPriceWei = java.math.BigInteger(record.gasPrice) + val txFeeWei = gasPriceWei.multiply(gasUsedBi) + val txFeeKava = java.math.BigDecimal(txFeeWei) + .divide(java.math.BigDecimal("1000000000000000000"), 9, java.math.RoundingMode.DOWN) + .stripTrailingZeros() + .toPlainString() + // blockNumber 是 String (hex),需要解析 + val blockNumber = receipt.blockNumber.removePrefix("0x").toLongOrNull(16) + + transactionRecordDao.updateStatus( + id = record.id, + status = status.name, + confirmedAt = System.currentTimeMillis(), + blockNumber = blockNumber, + gasUsed = gasUsedBi.toString(), + txFee = txFeeKava + ) + confirmedCount++ + android.util.Log.d("TssRepository", "Transaction ${record.txHash} confirmed with status: $status") + } + } + } catch (e: Exception) { + android.util.Log.w("TssRepository", "Failed to check transaction ${record.txHash}", e) + } + } + + Result.success(confirmedCount) + } catch (e: Exception) { + android.util.Log.e("TssRepository", "Failed to check pending transactions", e) + Result.failure(e) + } + } + } + + /** + * 获取转账记录总数 + * Get total count of transaction records for a wallet + */ + suspend fun getTransactionRecordCount(shareId: Long): Int { + return transactionRecordDao.getRecordCount(shareId) + } + + /** + * 格式化金额显示 + */ + private fun formatAmount(amount: Double, tokenType: TokenType): String { + return when (tokenType) { + TokenType.KAVA -> formatKavaAmount(amount) + TokenType.GREEN_POINTS -> formatGreenPointsAmount(amount) + } + } + + private fun formatKavaAmount(amount: Double): String { + return java.math.BigDecimal(amount) + .setScale(6, java.math.RoundingMode.DOWN) + .stripTrailingZeros() + .toPlainString() + } + + private fun formatGreenPointsAmount(amount: Double): String { + return java.math.BigDecimal(amount) + .setScale(2, java.math.RoundingMode.DOWN) + .stripTrailingZeros() + .toPlainString() + } + // ========== Connection Test Methods ========== /** @@ -2794,3 +3152,25 @@ private fun ShareRecordEntity.toShareRecord() = ShareRecord( address = address, createdAt = createdAt ) + +/** + * 转账记录实体转换为领域模型 + */ +private fun TransactionRecordEntity.toTransactionRecord() = TransactionRecord( + id = id, + shareId = shareId, + fromAddress = fromAddress, + toAddress = toAddress, + amount = amount, + tokenType = TokenType.valueOf(tokenType), + txHash = txHash, + gasPrice = gasPrice, + gasUsed = gasUsed, + txFee = txFee, + status = TransactionStatus.valueOf(status), + direction = TransactionDirection.valueOf(direction), + note = note, + createdAt = createdAt, + confirmedAt = confirmedAt, + blockNumber = blockNumber +) 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 index b8b1d244..b4aa329a 100644 --- 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 @@ -6,6 +6,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.durian.tssparty.data.local.AppSettingDao import com.durian.tssparty.data.local.ShareRecordDao +import com.durian.tssparty.data.local.TransactionRecordDao import com.durian.tssparty.data.local.TssDatabase import com.durian.tssparty.data.local.TssNativeBridge import com.durian.tssparty.data.remote.GrpcClient @@ -45,6 +46,42 @@ object AppModule { } } + // Migration from version 3 to 4: add transaction_records table for transfer history + // 添加转账记录表,用于存储交易历史和分类账 + private val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(database: SupportSQLiteDatabase) { + // 创建转账记录表 + database.execSQL(""" + CREATE TABLE IF NOT EXISTS `transaction_records` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `share_id` INTEGER NOT NULL, + `from_address` TEXT NOT NULL, + `to_address` TEXT NOT NULL, + `amount` TEXT NOT NULL, + `token_type` TEXT NOT NULL, + `tx_hash` TEXT NOT NULL, + `gas_price` TEXT NOT NULL, + `gas_used` TEXT NOT NULL DEFAULT '', + `tx_fee` TEXT NOT NULL DEFAULT '', + `status` TEXT NOT NULL, + `direction` TEXT NOT NULL, + `note` TEXT NOT NULL DEFAULT '', + `created_at` INTEGER NOT NULL, + `confirmed_at` INTEGER, + `block_number` INTEGER, + FOREIGN KEY(`share_id`) REFERENCES `share_records`(`id`) ON DELETE CASCADE + ) + """.trimIndent()) + + // 创建索引以优化查询性能 + database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_share_id` ON `transaction_records` (`share_id`)") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_transaction_records_tx_hash` ON `transaction_records` (`tx_hash`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_from_address` ON `transaction_records` (`from_address`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_to_address` ON `transaction_records` (`to_address`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_created_at` ON `transaction_records` (`created_at`)") + } + } + @Provides @Singleton fun provideGson(): Gson { @@ -59,7 +96,7 @@ object AppModule { TssDatabase::class.java, "tss_party.db" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .build() } @@ -75,6 +112,12 @@ object AppModule { return database.appSettingDao() } + @Provides + @Singleton + fun provideTransactionRecordDao(database: TssDatabase): TransactionRecordDao { + return database.transactionRecordDao() + } + @Provides @Singleton fun provideGrpcClient(): GrpcClient { @@ -93,8 +136,9 @@ object AppModule { grpcClient: GrpcClient, tssNativeBridge: TssNativeBridge, shareRecordDao: ShareRecordDao, - appSettingDao: AppSettingDao + appSettingDao: AppSettingDao, + transactionRecordDao: TransactionRecordDao ): TssRepository { - return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao) + return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao, transactionRecordDao) } } 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 index 6a2573f4..47a4bcd7 100644 --- 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 @@ -163,10 +163,15 @@ data class WalletBalance( /** * Share backup data for export/import * Contains all necessary information to restore a wallet share + * + * Version history: + * - v1: Initial version + * - v2: Added partyId field for proper backup/restore + * - v3: Added transactionRecords for transaction history backup */ data class ShareBackup( @SerializedName("version") - val version: Int = 2, // Version 2: added partyId field for proper backup/restore + val version: Int = 3, // Version 3: added transactionRecords for transaction history backup @SerializedName("sessionId") val sessionId: String, @@ -196,14 +201,18 @@ data class ShareBackup( val createdAt: Long, @SerializedName("exportedAt") - val exportedAt: Long = System.currentTimeMillis() + val exportedAt: Long = System.currentTimeMillis(), + + @SerializedName("transactionRecords") + val transactionRecords: List? = null // 转账记录(可选,v3新增) ) { companion object { const val FILE_EXTENSION = "tss-backup" const val MIME_TYPE = "application/octet-stream" + const val CURRENT_VERSION = 3 /** - * Create backup from ShareRecord + * Create backup from ShareRecord (without transaction records) */ fun fromShareRecord(share: ShareRecord): ShareBackup { return ShareBackup( @@ -218,6 +227,28 @@ data class ShareBackup( createdAt = share.createdAt ) } + + /** + * Create backup from ShareRecord with transaction records + * 创建包含转账记录的完整备份 + */ + fun fromShareRecordWithTransactions( + share: ShareRecord, + transactions: List + ): ShareBackup { + return ShareBackup( + sessionId = share.sessionId, + publicKey = share.publicKey, + encryptedShare = share.encryptedShare, + thresholdT = share.thresholdT, + thresholdN = share.thresholdN, + partyIndex = share.partyIndex, + partyId = share.partyId, + address = share.address, + createdAt = share.createdAt, + transactionRecords = transactions.map { TransactionRecordBackup.fromTransactionRecord(it) } + ) + } } /** @@ -237,4 +268,173 @@ data class ShareBackup( createdAt = createdAt ) } + + /** + * Get transaction records converted to domain model + * 获取转换后的转账记录列表(需要提供 shareId) + */ + fun getTransactionRecords(shareId: Long): List { + return transactionRecords?.map { it.toTransactionRecord(shareId) } ?: emptyList() + } + + /** + * Check if this backup has transaction records + * 检查是否包含转账记录 + */ + fun hasTransactionRecords(): Boolean { + return !transactionRecords.isNullOrEmpty() + } +} + +/** + * 转账记录状态枚举 + * Transaction status for tracking transfer lifecycle + */ +enum class TransactionStatus { + PENDING, // 交易已广播,等待确认 + CONFIRMED, // 交易已确认 + FAILED // 交易失败 +} + +/** + * 转账方向枚举 + * Direction of the transaction relative to the wallet + */ +enum class TransactionDirection { + SENT, // 转出(发送) + RECEIVED // 转入(接收) +} + +/** + * 转账记录数据模型 + * Stores transaction history for a wallet + */ +data class TransactionRecord( + val id: Long = 0, + val shareId: Long, // 关联的钱包ID + val fromAddress: String, // 发送方地址 + val toAddress: String, // 接收方地址 + val amount: String, // 转账金额(人类可读格式,如 "1.5") + val tokenType: TokenType, // 代币类型:KAVA 或 GREEN_POINTS + val txHash: String, // 交易哈希 + val gasPrice: String, // Gas 价格(Wei) + val gasUsed: String = "", // 实际消耗的 Gas(确认后填入) + val txFee: String = "", // 交易手续费(确认后计算) + val status: TransactionStatus, // 交易状态 + val direction: TransactionDirection, // 交易方向(转入/转出) + val note: String = "", // 备注(可选) + val createdAt: Long = System.currentTimeMillis(), // 创建时间 + val confirmedAt: Long? = null, // 确认时间(可选) + val blockNumber: Long? = null // 区块高度(确认后填入) +) + +/** + * 转账记录分类账汇总 + * Summary statistics for transaction ledger + */ +data class TransactionLedgerSummary( + val address: String, + val tokenType: TokenType, + val totalSent: String, // 总转出金额 + val totalReceived: String, // 总转入金额 + val totalTxFee: String, // 总手续费 + val transactionCount: Int, // 交易笔数 + val sentCount: Int, // 转出笔数 + val receivedCount: Int // 转入笔数 +) + +/** + * 转账记录备份数据 + * Transaction record backup data for export/import + */ +data class TransactionRecordBackup( + @SerializedName("fromAddress") + val fromAddress: String, + + @SerializedName("toAddress") + val toAddress: String, + + @SerializedName("amount") + val amount: String, + + @SerializedName("tokenType") + val tokenType: String, // KAVA 或 GREEN_POINTS + + @SerializedName("txHash") + val txHash: String, + + @SerializedName("gasPrice") + val gasPrice: String, + + @SerializedName("gasUsed") + val gasUsed: String, + + @SerializedName("txFee") + val txFee: String, + + @SerializedName("status") + val status: String, // PENDING, CONFIRMED, FAILED + + @SerializedName("direction") + val direction: String, // SENT, RECEIVED + + @SerializedName("note") + val note: String, + + @SerializedName("createdAt") + val createdAt: Long, + + @SerializedName("confirmedAt") + val confirmedAt: Long?, + + @SerializedName("blockNumber") + val blockNumber: Long? +) { + companion object { + /** + * 从 TransactionRecord 创建备份 + */ + fun fromTransactionRecord(record: TransactionRecord): TransactionRecordBackup { + return TransactionRecordBackup( + fromAddress = record.fromAddress, + toAddress = record.toAddress, + amount = record.amount, + tokenType = record.tokenType.name, + txHash = record.txHash, + gasPrice = record.gasPrice, + gasUsed = record.gasUsed, + txFee = record.txFee, + status = record.status.name, + direction = record.direction.name, + note = record.note, + createdAt = record.createdAt, + confirmedAt = record.confirmedAt, + blockNumber = record.blockNumber + ) + } + } + + /** + * 转换为 TransactionRecord(需要提供 shareId) + */ + fun toTransactionRecord(shareId: Long): TransactionRecord { + return TransactionRecord( + id = 0, // 自动生成 + shareId = shareId, + fromAddress = fromAddress, + toAddress = toAddress, + amount = amount, + tokenType = TokenType.valueOf(tokenType), + txHash = txHash, + gasPrice = gasPrice, + gasUsed = gasUsed, + txFee = txFee, + status = TransactionStatus.valueOf(status), + direction = TransactionDirection.valueOf(direction), + note = note, + createdAt = createdAt, + confirmedAt = confirmedAt, + blockNumber = blockNumber + ) + } } 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..7026ac79 --- /dev/null +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransactionHistoryScreen.kt @@ -0,0 +1,728 @@ +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.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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +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.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.durian.tssparty.domain.model.* +import java.text.SimpleDateFormat +import java.util.* + +/** + * 转账历史界面 + * Transaction History Screen - displays transaction records and ledger summary + * + * Features: + * - 交易记录列表(按时间倒序) + * - 分类账汇总统计 + * - 按代币类型筛选 + * - 交易详情查看 + * - 区块链浏览器链接 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransactionHistoryScreen( + wallet: ShareRecord, + transactions: List, + ledgerSummary: TransactionLedgerSummary?, + networkType: NetworkType = NetworkType.MAINNET, + onRefresh: () -> Unit, + onFilterByToken: (TokenType?) -> Unit, + onBack: () -> Unit +) { + var selectedTokenFilter by remember { mutableStateOf(null) } + var showLedgerSummary by remember { mutableStateOf(true) } + var selectedTransaction by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // 顶部导航栏 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + Text( + text = "转账记录", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + IconButton(onClick = onRefresh) { + Icon(Icons.Default.Refresh, contentDescription = "刷新") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 钱包地址显示 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.AccountBalanceWallet, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = formatAddress(wallet.address), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 代币筛选标签 + TokenFilterChips( + selectedToken = selectedTokenFilter, + onSelectToken = { token -> + selectedTokenFilter = token + onFilterByToken(token) + } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 分类账汇总卡片(可折叠) + LedgerSummaryCard( + summary = ledgerSummary, + expanded = showLedgerSummary, + onToggle = { showLedgerSummary = !showLedgerSummary } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 交易记录列表 + if (transactions.isEmpty()) { + // 空状态 + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.Receipt, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.outline + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "暂无转账记录", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.outline + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "完成转账后记录会显示在这里", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(transactions) { transaction -> + TransactionRecordItem( + transaction = transaction, + walletAddress = wallet.address, + networkType = networkType, + onClick = { selectedTransaction = transaction } + ) + } + } + } + } + + // 交易详情弹窗 + selectedTransaction?.let { tx -> + TransactionDetailDialog( + transaction = tx, + networkType = networkType, + onDismiss = { selectedTransaction = null } + ) + } +} + +/** + * 代币筛选标签 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TokenFilterChips( + selectedToken: TokenType?, + onSelectToken: (TokenType?) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedToken == null, + onClick = { onSelectToken(null) }, + label = { Text("全部") }, + leadingIcon = if (selectedToken == null) { + { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) } + } else null + ) + FilterChip( + selected = selectedToken == TokenType.KAVA, + onClick = { onSelectToken(TokenType.KAVA) }, + label = { Text("KAVA") }, + leadingIcon = if (selectedToken == TokenType.KAVA) { + { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) } + } else null + ) + FilterChip( + selected = selectedToken == TokenType.GREEN_POINTS, + onClick = { onSelectToken(TokenType.GREEN_POINTS) }, + label = { Text("绿积分") }, + leadingIcon = if (selectedToken == TokenType.GREEN_POINTS) { + { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) } + } else null + ) + } +} + +/** + * 分类账汇总卡片 + */ +@Composable +private fun LedgerSummaryCard( + summary: TransactionLedgerSummary?, + expanded: Boolean, + onToggle: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() } + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Analytics, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "分类账汇总", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + } + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "收起" else "展开" + ) + } + + if (expanded && summary != null) { + Spacer(modifier = Modifier.height(12.dp)) + Divider() + Spacer(modifier = Modifier.height(12.dp)) + + // 汇总数据 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + // 总转出 + SummaryItem( + label = "总转出", + value = summary.totalSent, + unit = getTokenSymbol(summary.tokenType), + color = MaterialTheme.colorScheme.error + ) + // 总转入 + SummaryItem( + label = "总转入", + value = summary.totalReceived, + unit = getTokenSymbol(summary.tokenType), + color = Color(0xFF4CAF50) + ) + // 总手续费 + SummaryItem( + label = "总手续费", + value = summary.totalTxFee, + unit = "KAVA", + color = MaterialTheme.colorScheme.outline + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 交易笔数 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "共 ${summary.transactionCount} 笔交易(转出 ${summary.sentCount} / 转入 ${summary.receivedCount})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (expanded && summary == null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "加载中...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + } +} + +/** + * 汇总项 + */ +@Composable +private fun SummaryItem( + label: String, + value: String, + unit: String, + color: Color +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + text = unit, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * 交易记录列表项 + */ +@Composable +private fun TransactionRecordItem( + transaction: TransactionRecord, + walletAddress: String, + networkType: NetworkType, + onClick: () -> Unit +) { + val isSent = transaction.direction == TransactionDirection.SENT + val context = LocalContext.current + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 方向图标 + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (isSent) MaterialTheme.colorScheme.errorContainer + else Color(0xFF4CAF50).copy(alpha = 0.2f) + ), + 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(24.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // 交易信息 + Column(modifier = Modifier.weight(1f)) { + // 方向和地址 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (isSent) "转出" else "转入", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + // 状态标签 + StatusChip(status = transaction.status) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // 对方地址 + Text( + text = if (isSent) "至 ${formatAddress(transaction.toAddress)}" + else "来自 ${formatAddress(transaction.fromAddress)}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // 时间 + Text( + text = formatDateTime(transaction.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // 金额 + Column(horizontalAlignment = Alignment.End) { + Text( + text = "${if (isSent) "-" else "+"}${transaction.amount}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isSent) MaterialTheme.colorScheme.error else Color(0xFF4CAF50) + ) + Text( + text = getTokenSymbol(transaction.tokenType), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +/** + * 状态标签 + */ +@Composable +private fun StatusChip(status: TransactionStatus) { + val (text, color) = when (status) { + TransactionStatus.PENDING -> "待确认" to Color(0xFFFFA000) + TransactionStatus.CONFIRMED -> "已确认" to Color(0xFF4CAF50) + TransactionStatus.FAILED -> "失败" to MaterialTheme.colorScheme.error + } + + Surface( + shape = RoundedCornerShape(4.dp), + color = color.copy(alpha = 0.15f) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } +} + +/** + * 交易详情弹窗 + */ +@Composable +private fun TransactionDetailDialog( + transaction: TransactionRecord, + networkType: NetworkType, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val isSent = transaction.direction == TransactionDirection.SENT + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (isSent) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward, + contentDescription = null, + tint = if (isSent) MaterialTheme.colorScheme.error else Color(0xFF4CAF50) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = if (isSent) "转出详情" else "转入详情") + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 金额 + DetailRow( + label = "金额", + value = "${transaction.amount} ${getTokenSymbol(transaction.tokenType)}" + ) + + // 状态 + DetailRow( + label = "状态", + value = when (transaction.status) { + TransactionStatus.PENDING -> "待确认" + TransactionStatus.CONFIRMED -> "已确认" + TransactionStatus.FAILED -> "失败" + } + ) + + // 发送方 + DetailRow( + label = "发送方", + value = transaction.fromAddress, + isAddress = true, + onCopy = { clipboardManager.setText(AnnotatedString(transaction.fromAddress)) } + ) + + // 接收方 + DetailRow( + label = "接收方", + value = transaction.toAddress, + isAddress = true, + onCopy = { clipboardManager.setText(AnnotatedString(transaction.toAddress)) } + ) + + // 交易哈希 + DetailRow( + label = "交易哈希", + value = transaction.txHash, + isAddress = true, + onCopy = { clipboardManager.setText(AnnotatedString(transaction.txHash)) } + ) + + // 手续费(如果有) + if (transaction.txFee.isNotEmpty() && transaction.txFee != "0") { + DetailRow( + label = "手续费", + value = "${transaction.txFee} KAVA" + ) + } + + // 区块高度(如果有) + transaction.blockNumber?.let { blockNum -> + DetailRow( + label = "区块高度", + value = blockNum.toString() + ) + } + + // 时间 + DetailRow( + label = "发起时间", + value = formatDateTime(transaction.createdAt) + ) + + transaction.confirmedAt?.let { confirmedAt -> + DetailRow( + label = "确认时间", + value = formatDateTime(confirmedAt) + ) + } + + // 备注(如果有) + if (transaction.note.isNotEmpty()) { + DetailRow( + label = "备注", + value = transaction.note + ) + } + } + }, + confirmButton = { + Row { + // 在浏览器中查看 + TextButton( + onClick = { + val explorerUrl = getExplorerUrl(transaction.txHash, networkType) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(explorerUrl)) + context.startActivity(intent) + } + ) { + Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("浏览器查看") + } + + TextButton(onClick = onDismiss) { + Text("关闭") + } + } + } + ) +} + +/** + * 详情行 + */ +@Composable +private fun DetailRow( + label: String, + value: String, + isAddress: Boolean = false, + onCopy: (() -> Unit)? = null +) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(2.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (isAddress) formatAddressLong(value) else value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (isAddress) FontFamily.Monospace else FontFamily.Default, + modifier = Modifier.weight(1f), + maxLines = if (isAddress) 2 else Int.MAX_VALUE + ) + if (onCopy != null) { + IconButton( + onClick = onCopy, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "复制", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} + +// ========== 辅助函数 ========== + +/** + * 格式化地址显示(简短形式) + */ +private fun formatAddress(address: String): String { + return if (address.length > 16) { + "${address.take(8)}...${address.takeLast(6)}" + } else { + address + } +} + +/** + * 格式化地址显示(长形式,用于详情) + */ +private fun formatAddressLong(address: String): String { + return if (address.length > 20) { + "${address.take(10)}...${address.takeLast(8)}" + } else { + address + } +} + +/** + * 获取代币符号 + */ +private fun getTokenSymbol(tokenType: TokenType): String { + return when (tokenType) { + TokenType.KAVA -> "KAVA" + TokenType.GREEN_POINTS -> "绿积分" + } +} + +/** + * 格式化日期时间 + */ +private fun formatDateTime(timestamp: Long): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + return sdf.format(Date(timestamp)) +} + +/** + * 获取区块链浏览器 URL + */ +private fun getExplorerUrl(txHash: String, networkType: NetworkType): 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 cad45ef4..52a1c54e 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 @@ -55,6 +55,7 @@ fun WalletsScreen( onDeleteShare: (Long) -> Unit, onRefreshBalance: ((String) -> Unit)? = null, onTransfer: ((shareId: Long) -> Unit)? = null, + onViewTransactionHistory: ((shareId: Long) -> Unit)? = null, // 新增:查看转账记录 onExportBackup: ((shareId: Long, password: String) -> Unit)? = null, onImportBackup: (() -> Unit)? = null, onCreateWallet: (() -> Unit)? = null @@ -155,6 +156,9 @@ fun WalletsScreen( onTransfer = { onTransfer?.invoke(share.id) }, + onViewHistory = { + onViewTransactionHistory?.invoke(share.id) + }, onDelete = { onDeleteShare(share.id) } ) } @@ -223,6 +227,7 @@ private fun WalletItemCard( walletBalance: WalletBalance? = null, onViewDetails: () -> Unit, onTransfer: () -> Unit, + onViewHistory: () -> Unit, // 新增:查看转账记录 onDelete: () -> Unit ) { var showDeleteDialog by remember { mutableStateOf(false) } @@ -372,6 +377,16 @@ private fun WalletItemCard( Text("转账") } + TextButton(onClick = onViewHistory) { + 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 d40d38fa..bf0fcfed 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 @@ -1334,7 +1334,8 @@ class MainViewModel @Inject constructor( } /** - * Broadcast the signed transaction + * Broadcast the signed transaction and save transaction record + * 广播签名后的交易并保存转账记录 */ fun broadcastTransaction() { android.util.Log.d("MainViewModel", "[BROADCAST] broadcastTransaction() called") @@ -1344,9 +1345,11 @@ class MainViewModel @Inject constructor( val tx = _preparedTx.value val sig = _signature.value + val transferStateValue = _transferState.value android.util.Log.d("MainViewModel", "[BROADCAST] preparedTx: ${tx?.let { "present" } ?: "null"}") android.util.Log.d("MainViewModel", "[BROADCAST] signature: ${sig?.let { "${it.take(20)}..." } ?: "null"}") + android.util.Log.d("MainViewModel", "[BROADCAST] transferState: shareId=${transferStateValue.shareId}, amount=${transferStateValue.amount}, tokenType=${transferStateValue.tokenType}") if (tx == null || sig == null) { android.util.Log.e("MainViewModel", "[BROADCAST] Missing tx or signature! tx=$tx, sig=$sig") @@ -1356,9 +1359,18 @@ class MainViewModel @Inject constructor( val rpcUrl = _settings.value.kavaRpcUrl android.util.Log.d("MainViewModel", "[BROADCAST] Using RPC URL: $rpcUrl") - val result = repository.broadcastTransaction(tx, sig, rpcUrl) - android.util.Log.d("MainViewModel", "[BROADCAST] repository.broadcastTransaction returned: isSuccess=${result.isSuccess}") + // 使用 broadcastTransactionAndSaveRecord 同时广播交易和保存记录 + val result = repository.broadcastTransactionAndSaveRecord( + preparedTx = tx, + signature = sig, + rpcUrl = rpcUrl, + shareId = transferStateValue.shareId, + amount = transferStateValue.amount, + tokenType = transferStateValue.tokenType + ) + + android.util.Log.d("MainViewModel", "[BROADCAST] repository.broadcastTransactionAndSaveRecord returned: isSuccess=${result.isSuccess}") result.fold( onSuccess = { hash -> @@ -1374,6 +1386,69 @@ class MainViewModel @Inject constructor( } } + // ========== 转账记录方法 Transaction History Methods ========== + + /** + * 获取指定钱包的转账记录列表(Flow) + * Get transaction records for a wallet as Flow (reactive) + */ + fun getTransactionRecordsFlow(shareId: Long): StateFlow> { + return repository.getTransactionRecordsFlow(shareId) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + + /** + * 获取指定钱包和代币类型的转账记录(Flow) + * Get transaction records filtered by token type + */ + fun getTransactionRecordsByTokenFlow(shareId: Long, tokenType: TokenType): StateFlow> { + return repository.getTransactionRecordsByTokenFlow(shareId, tokenType) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } + + // 转账记录分类账汇总 + private val _ledgerSummary = MutableStateFlow(null) + val ledgerSummary: StateFlow = _ledgerSummary.asStateFlow() + + /** + * 加载转账记录分类账汇总 + * Load transaction ledger summary for a wallet + */ + fun loadLedgerSummary(shareId: Long, tokenType: TokenType) { + viewModelScope.launch { + val result = repository.getTransactionLedgerSummary(shareId, tokenType) + result.onSuccess { summary -> + _ledgerSummary.value = summary + }.onFailure { e -> + android.util.Log.e("MainViewModel", "Failed to load ledger summary: ${e.message}") + } + } + } + + /** + * 清除分类账汇总缓存 + * Clear ledger summary cache + */ + fun clearLedgerSummary() { + _ledgerSummary.value = null + } + + /** + * 检查并更新待确认的转账记录 + * Check and update pending transactions + */ + fun checkPendingTransactions() { + viewModelScope.launch { + val rpcUrl = _settings.value.kavaRpcUrl + val result = repository.checkPendingTransactions(rpcUrl) + result.onSuccess { confirmedCount -> + if (confirmedCount > 0) { + android.util.Log.d("MainViewModel", "$confirmedCount transactions confirmed") + } + } + } + } + /** * Reset transfer state */ diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart index 3fe7c504..686db2d1 100644 --- a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart @@ -70,7 +70,7 @@ class _KlineChartWidgetState extends State { // 初始化会在 LayoutBuilder 中完成 } - /// 初始化 K 线宽度,让所有 K 线占满屏幕 + /// 初始化 K 线宽度 void _initializeCandleWidth(double chartWidth) { if (_initialized || widget.klines.isEmpty || chartWidth == 0) return; @@ -87,9 +87,6 @@ class _KlineChartWidgetState extends State { _candleWidth = idealWidth.clamp(_minCandleWidth, _maxCandleWidth); } _prevCandleWidth = _candleWidth; - - // 首次加载时让最新 K 线靠右显示 - _scrollToEnd(); } /// 滚动使最新 K 线居中显示 @@ -577,23 +574,17 @@ class _KlineChartWidgetState extends State { return _VisibleData(klines: [], startIndex: 0, candleWidth: _candleWidth); } - // 计算K线总宽度(不包含左右padding) - const rightPadding = 50.0; // 与 painter 中的 rightPadding 保持一致 - const leftPadding = 8.0; // 与 painter 中的 leftPadding 保持一致 - final availableWidth = chartWidth - rightPadding - leftPadding; - final totalWidth = widget.klines.length * _candleWidth; + // 计算K线总宽度和可用绘图区域 + const leftPadding = 8.0; + const rightPadding = 50.0; + final availableWidth = chartWidth - leftPadding - rightPadding; + final totalKlineWidth = widget.klines.length * _candleWidth; - // 计算左侧偏移量:当K线总宽度小于可用宽度时,让最新K线(最右边)居中显示 - // K线从左边开始画,第一根K线在 x=0,最后一根K线中心在 x=(n-1)*candleWidth + candleWidth/2 - // 我们要让最后一根K线的中心位于屏幕中央 (availableWidth/2) - // 所以:leftOffset + (n-1)*candleWidth + candleWidth/2 = availableWidth/2 - // leftOffset = availableWidth/2 - (n-1)*candleWidth - candleWidth/2 - // leftOffset = availableWidth/2 - n*candleWidth + candleWidth/2 - // leftOffset = (availableWidth - totalWidth + candleWidth) / 2 + // 当K线数据稀疏(总宽度小于可用宽度)时,计算leftOffset使K线靠右显示 double leftOffset = 0.0; - if (totalWidth < availableWidth) { - final lastKlineCenter = (widget.klines.length - 1) * _candleWidth + _candleWidth / 2; - leftOffset = (availableWidth / 2) - lastKlineCenter; + if (totalKlineWidth < availableWidth) { + // K线靠右:leftOffset = 可用宽度 - K线总宽度 + leftOffset = availableWidth - totalKlineWidth; } // 根据滚动位置计算可见的K线范围