From c5db77d23ac21e3197308474a2507243a78405e6 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 19 Jan 2026 23:36:58 -0800 Subject: [PATCH] =?UTF-8?q?feat(tss):=20=E6=B7=BB=E5=8A=A0=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E8=82=A1(eUSDT)=E5=92=8C=E7=A7=AF=E5=88=86=E5=80=BC(f?= =?UTF-8?q?USDT)=E4=BB=A3=E5=B8=81=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 添加 ENERGY_POINTS (积分股/eUSDT) 和 FUTURE_POINTS (积分值/fUSDT) 代币类型 - 实现所有 ERC-20 代币的通用余额查询功能 - 支持四种代币的转账功能 (KAVA, dUSDT, eUSDT, fUSDT) - 更新 UI 显示所有代币余额和代币选择器 代币合约地址 (Kava EVM): - dUSDT (绿积分): 0xA9F3A35dBa8699c8E681D8db03F0c1A8CEB9D7c3 - eUSDT (积分股): 0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931 - fUSDT (积分值): 0x14dc4f7d3E4197438d058C3D156dd9826A161134 技术改进: - 添加 TokenConfig 工具类统一管理代币配置 - 添加 ERC20Selectors 常量类定义合约方法选择器 - 添加 transaction_records 表用于存储转账历史 (数据库版本升级到 v4) - 重构余额查询和转账逻辑支持多代币类型 - 所有 ERC-20 代币使用 6 位小数精度 受影响文件: - Android: Models.kt, TssRepository.kt, TransactionUtils.kt, Database.kt, AppModule.kt, TransferScreen.kt, WalletsScreen.kt - Electron: transaction.ts, Home.tsx Co-Authored-By: Claude Opus 4.5 --- .../durian/tssparty/data/local/Database.kt | 148 +++++++++++- .../tssparty/data/repository/TssRepository.kt | 82 ++++++- .../java/com/durian/tssparty/di/AppModule.kt | 50 +++- .../durian/tssparty/domain/model/Models.kt | 136 ++++++++++- .../presentation/screens/TransferScreen.kt | 179 ++++++++++---- .../presentation/screens/WalletsScreen.kt | 163 +++++++++---- .../durian/tssparty/util/TransactionUtils.kt | 55 +++-- .../service-party-app/src/pages/Home.tsx | 99 ++++++-- .../src/utils/transaction.ts | 219 +++++++++++++++--- 9 files changed, 940 insertions(+), 191 deletions(-) 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..653678fa 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,159 @@ 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, ENERGY_POINTS, FUTURE_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 + + @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 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..120c1b94 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() @@ -1996,10 +1999,19 @@ class TssRepository @Inject constructor( } /** - * Get Green Points (绿积分/dUSDT) token balance for an address + * Get ERC-20 token balance for an address * Uses eth_call to call balanceOf(address) on the ERC-20 contract + * @param address The wallet address + * @param rpcUrl The RPC endpoint URL + * @param contractAddress The ERC-20 token contract address + * @param decimals The token decimals (default 6 for USDT-like tokens) */ - suspend fun getGreenPointsBalance(address: String, rpcUrl: String): Result { + suspend fun getERC20Balance( + address: String, + rpcUrl: String, + contractAddress: String, + decimals: Int = 6 + ): Result { return withContext(Dispatchers.IO) { try { val client = okhttp3.OkHttpClient() @@ -2009,14 +2021,14 @@ class TssRepository @Inject constructor( // Function selector: 0x70a08231 // Address parameter: padded to 32 bytes val paddedAddress = address.removePrefix("0x").lowercase().padStart(64, '0') - val callData = "${GreenPointsToken.BALANCE_OF_SELECTOR}$paddedAddress" + val callData = "${ERC20Selectors.BALANCE_OF}$paddedAddress" val requestBody = """ { "jsonrpc": "2.0", "method": "eth_call", "params": [{ - "to": "${GreenPointsToken.CONTRACT_ADDRESS}", + "to": "$contractAddress", "data": "$callData" }, "latest"], "id": 1 @@ -2038,42 +2050,88 @@ class TssRepository @Inject constructor( } val hexBalance = json.get("result").asString - // Convert hex to decimal, then apply 6 decimals (dUSDT uses 6 decimals like USDT) + // Convert hex to decimal, then apply decimals val rawBalance = java.math.BigInteger(hexBalance.removePrefix("0x"), 16) + val divisor = java.math.BigDecimal.TEN.pow(decimals) val tokenBalance = java.math.BigDecimal(rawBalance).divide( - java.math.BigDecimal("1000000"), // 10^6 for 6 decimals - 6, + divisor, + decimals, java.math.RoundingMode.DOWN ) Result.success(tokenBalance.toPlainString()) } catch (e: Exception) { - android.util.Log.e("TssRepository", "Failed to get Green Points balance: ${e.message}") + android.util.Log.e("TssRepository", "Failed to get ERC20 balance for $contractAddress: ${e.message}") Result.failure(e) } } } /** - * Get both KAVA and Green Points balances for an address + * Get Green Points (绿积分/dUSDT) token balance for an address + * Uses eth_call to call balanceOf(address) on the ERC-20 contract + */ + suspend fun getGreenPointsBalance(address: String, rpcUrl: String): Result { + return getERC20Balance( + address = address, + rpcUrl = rpcUrl, + contractAddress = GreenPointsToken.CONTRACT_ADDRESS, + decimals = GreenPointsToken.DECIMALS + ) + } + + /** + * Get Energy Points (积分股/eUSDT) token balance for an address + */ + suspend fun getEnergyPointsBalance(address: String, rpcUrl: String): Result { + return getERC20Balance( + address = address, + rpcUrl = rpcUrl, + contractAddress = EnergyPointsToken.CONTRACT_ADDRESS, + decimals = EnergyPointsToken.DECIMALS + ) + } + + /** + * Get Future Points (积分值/fUSDT) token balance for an address + */ + suspend fun getFuturePointsBalance(address: String, rpcUrl: String): Result { + return getERC20Balance( + address = address, + rpcUrl = rpcUrl, + contractAddress = FuturePointsToken.CONTRACT_ADDRESS, + decimals = FuturePointsToken.DECIMALS + ) + } + + /** + * Get all token balances for an address (KAVA + all ERC-20 tokens) */ suspend fun getWalletBalance(address: String, rpcUrl: String): Result { return withContext(Dispatchers.IO) { try { - // Fetch both balances in parallel + // Fetch all balances in parallel val kavaDeferred = async { getBalance(address, rpcUrl) } val greenPointsDeferred = async { getGreenPointsBalance(address, rpcUrl) } + val energyPointsDeferred = async { getEnergyPointsBalance(address, rpcUrl) } + val futurePointsDeferred = async { getFuturePointsBalance(address, rpcUrl) } val kavaResult = kavaDeferred.await() val greenPointsResult = greenPointsDeferred.await() + val energyPointsResult = energyPointsDeferred.await() + val futurePointsResult = futurePointsDeferred.await() val kavaBalance = kavaResult.getOrDefault("0") val greenPointsBalance = greenPointsResult.getOrDefault("0") + val energyPointsBalance = energyPointsResult.getOrDefault("0") + val futurePointsBalance = futurePointsResult.getOrDefault("0") Result.success(WalletBalance( address = address, kavaBalance = kavaBalance, - greenPointsBalance = greenPointsBalance + greenPointsBalance = greenPointsBalance, + energyPointsBalance = energyPointsBalance, + futurePointsBalance = futurePointsBalance )) } catch (e: Exception) { Result.failure(e) 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..ebb1a2ab 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 @@ -130,7 +130,21 @@ enum class NetworkType { */ enum class TokenType { KAVA, // Native KAVA token - GREEN_POINTS // 绿积分 (dUSDT) ERC-20 token + GREEN_POINTS, // 绿积分 (dUSDT) ERC-20 token + ENERGY_POINTS, // 积分股 (eUSDT) ERC-20 token + FUTURE_POINTS // 积分值 (fUSDT) ERC-20 token +} + +/** + * ERC-20 通用函数签名(keccak256 哈希的前4字节) + * Common ERC-20 function selectors + */ +object ERC20Selectors { + const val BALANCE_OF = "0x70a08231" // balanceOf(address) + const val TRANSFER = "0xa9059cbb" // transfer(address,uint256) + const val APPROVE = "0x095ea7b3" // approve(address,uint256) + const val ALLOWANCE = "0xdd62ed3e" // allowance(address,address) + const val TOTAL_SUPPLY = "0x18160ddd" // totalSupply() } /** @@ -143,22 +157,122 @@ object GreenPointsToken { const val SYMBOL = "dUSDT" const val DECIMALS = 6 - // ERC-20 function signatures (first 4 bytes of keccak256 hash) - const val BALANCE_OF_SELECTOR = "0x70a08231" // balanceOf(address) - const val TRANSFER_SELECTOR = "0xa9059cbb" // transfer(address,uint256) - const val APPROVE_SELECTOR = "0x095ea7b3" // approve(address,uint256) - const val ALLOWANCE_SELECTOR = "0xdd62ed3e" // allowance(address,address) - const val TOTAL_SUPPLY_SELECTOR = "0x18160ddd" // totalSupply() + // ERC-20 function signatures (kept for backward compatibility) + const val BALANCE_OF_SELECTOR = ERC20Selectors.BALANCE_OF + const val TRANSFER_SELECTOR = ERC20Selectors.TRANSFER + const val APPROVE_SELECTOR = ERC20Selectors.APPROVE + const val ALLOWANCE_SELECTOR = ERC20Selectors.ALLOWANCE + const val TOTAL_SUPPLY_SELECTOR = ERC20Selectors.TOTAL_SUPPLY } /** - * Wallet balance containing both native and token balances + * Energy Points (积分股) Token Contract Configuration + * eUSDT - ERC-20 token on Kava EVM + * 总供应量:100.02亿 (10,002,000,000) + */ +object EnergyPointsToken { + const val CONTRACT_ADDRESS = "0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931" + const val NAME = "积分股" + const val SYMBOL = "eUSDT" + const val DECIMALS = 6 // 与 dUSDT 相同的精度 +} + +/** + * Future Points (积分值) Token Contract Configuration + * fUSDT - ERC-20 token on Kava EVM + * 总供应量:1万亿 (1,000,000,000,000) + */ +object FuturePointsToken { + const val CONTRACT_ADDRESS = "0x14dc4f7d3E4197438d058C3D156dd9826A161134" + const val NAME = "积分值" + const val SYMBOL = "fUSDT" + const val DECIMALS = 6 // 与 dUSDT 相同的精度 +} + +/** + * 代币配置工具类 + * Token configuration utility + */ +object TokenConfig { + /** + * 获取代币合约地址 + */ + fun getContractAddress(tokenType: TokenType): String? { + return when (tokenType) { + TokenType.KAVA -> null // 原生代币无合约地址 + TokenType.GREEN_POINTS -> GreenPointsToken.CONTRACT_ADDRESS + TokenType.ENERGY_POINTS -> EnergyPointsToken.CONTRACT_ADDRESS + TokenType.FUTURE_POINTS -> FuturePointsToken.CONTRACT_ADDRESS + } + } + + /** + * 获取代币精度 + */ + fun getDecimals(tokenType: TokenType): Int { + return when (tokenType) { + TokenType.KAVA -> 18 // KAVA 原生代币精度 + TokenType.GREEN_POINTS -> GreenPointsToken.DECIMALS + TokenType.ENERGY_POINTS -> EnergyPointsToken.DECIMALS + TokenType.FUTURE_POINTS -> FuturePointsToken.DECIMALS + } + } + + /** + * 获取代币名称 + */ + fun getName(tokenType: TokenType): String { + return when (tokenType) { + TokenType.KAVA -> "KAVA" + TokenType.GREEN_POINTS -> GreenPointsToken.NAME + TokenType.ENERGY_POINTS -> EnergyPointsToken.NAME + TokenType.FUTURE_POINTS -> FuturePointsToken.NAME + } + } + + /** + * 获取代币符号 + */ + fun getSymbol(tokenType: TokenType): String { + return when (tokenType) { + TokenType.KAVA -> "KAVA" + TokenType.GREEN_POINTS -> GreenPointsToken.SYMBOL + TokenType.ENERGY_POINTS -> EnergyPointsToken.SYMBOL + TokenType.FUTURE_POINTS -> FuturePointsToken.SYMBOL + } + } + + /** + * 判断是否为 ERC-20 代币 + */ + fun isERC20(tokenType: TokenType): Boolean { + return tokenType != TokenType.KAVA + } +} + +/** + * Wallet balance containing native and all token balances + * 钱包余额,包含原生代币和所有 ERC-20 代币余额 */ data class WalletBalance( val address: String, - val kavaBalance: String = "0", // Native KAVA balance - val greenPointsBalance: String = "0" // 绿积分 (dUSDT) balance -) + val kavaBalance: String = "0", // Native KAVA balance + val greenPointsBalance: String = "0", // 绿积分 (dUSDT) balance + val energyPointsBalance: String = "0", // 积分股 (eUSDT) balance + val futurePointsBalance: String = "0" // 积分值 (fUSDT) balance +) { + /** + * 根据代币类型获取余额 + */ + fun getBalance(tokenType: TokenType): String { + return when (tokenType) { + TokenType.KAVA -> kavaBalance + TokenType.GREEN_POINTS -> greenPointsBalance + TokenType.ENERGY_POINTS -> energyPointsBalance + TokenType.FUTURE_POINTS -> futurePointsBalance + } + } +} /** * Share backup data for export/import diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt index d9239113..df4c53c4 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/screens/TransferScreen.kt @@ -27,10 +27,13 @@ import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.ui.graphics.asImageBitmap +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 com.durian.tssparty.domain.model.SessionStatus import com.durian.tssparty.domain.model.ShareRecord +import com.durian.tssparty.domain.model.TokenConfig import com.durian.tssparty.domain.model.TokenType import com.durian.tssparty.domain.model.WalletBalance import com.durian.tssparty.util.TransactionUtils @@ -156,10 +159,8 @@ fun TransferScreen( rpcUrl = rpcUrl, onSubmit = { // Get current balance for the selected token type - val currentBalance = when (selectedTokenType) { - TokenType.KAVA -> walletBalance?.kavaBalance ?: balance - TokenType.GREEN_POINTS -> walletBalance?.greenPointsBalance - } + val currentBalance = walletBalance?.getBalance(selectedTokenType) + ?: if (selectedTokenType == TokenType.KAVA) balance else null when { toAddress.isBlank() -> validationError = "请输入收款地址" !toAddress.startsWith("0x") || toAddress.length != 42 -> validationError = "地址格式不正确" @@ -257,14 +258,9 @@ private fun TransferInputScreen( var isCalculatingMax by remember { mutableStateOf(false) } // Get current balance for the selected token type - val currentBalance = when (selectedTokenType) { - TokenType.KAVA -> walletBalance?.kavaBalance ?: balance - TokenType.GREEN_POINTS -> walletBalance?.greenPointsBalance - } - val tokenSymbol = when (selectedTokenType) { - TokenType.KAVA -> "KAVA" - TokenType.GREEN_POINTS -> GreenPointsToken.NAME - } + val currentBalance = walletBalance?.getBalance(selectedTokenType) + ?: if (selectedTokenType == TokenType.KAVA) balance else null + val tokenSymbol = TokenConfig.getName(selectedTokenType) Column( modifier = Modifier @@ -293,38 +289,74 @@ private fun TransferInputScreen( ) Spacer(modifier = Modifier.height(8.dp)) - // Show both balances - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - // KAVA balance - Column { - Text( - text = "KAVA", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = walletBalance?.kavaBalance ?: balance ?: "加载中...", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary - ) + // Show all token balances in a 2x2 grid + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // KAVA balance + Column { + Text( + text = "KAVA", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = walletBalance?.kavaBalance ?: balance ?: "加载中...", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } + // Green Points balance (绿积分) + Column(horizontalAlignment = Alignment.End) { + Text( + text = GreenPointsToken.NAME, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = walletBalance?.greenPointsBalance ?: "加载中...", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = Color(0xFF4CAF50) + ) + } } - // Green Points balance - Column(horizontalAlignment = Alignment.End) { - Text( - text = GreenPointsToken.NAME, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = walletBalance?.greenPointsBalance ?: "加载中...", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - color = Color(0xFF4CAF50) - ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Energy Points balance (积分股) + Column { + Text( + text = EnergyPointsToken.NAME, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = walletBalance?.energyPointsBalance ?: "加载中...", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = Color(0xFF2196F3) // Blue + ) + } + // Future Points balance (积分值) + Column(horizontalAlignment = Alignment.End) { + Text( + text = FuturePointsToken.NAME, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = walletBalance?.futurePointsBalance ?: "加载中...", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = Color(0xFF9C27B0) // Purple + ) + } } } } @@ -339,6 +371,7 @@ private fun TransferInputScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) + // First row: KAVA and Green Points Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -359,7 +392,7 @@ private fun TransferInputScreen( }, modifier = Modifier.weight(1f) ) - // Green Points option + // Green Points option (绿积分) FilterChip( selected = selectedTokenType == TokenType.GREEN_POINTS, onClick = { onTokenTypeChange(TokenType.GREEN_POINTS) }, @@ -380,6 +413,53 @@ private fun TransferInputScreen( modifier = Modifier.weight(1f) ) } + Spacer(modifier = Modifier.height(8.dp)) + // Second row: Energy Points and Future Points + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Energy Points option (积分股) + FilterChip( + selected = selectedTokenType == TokenType.ENERGY_POINTS, + onClick = { onTokenTypeChange(TokenType.ENERGY_POINTS) }, + label = { Text(EnergyPointsToken.NAME) }, + leadingIcon = { + if (selectedTokenType == TokenType.ENERGY_POINTS) { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFF2196F3).copy(alpha = 0.2f), + selectedLabelColor = Color(0xFF2196F3) + ), + modifier = Modifier.weight(1f) + ) + // Future Points option (积分值) + FilterChip( + selected = selectedTokenType == TokenType.FUTURE_POINTS, + onClick = { onTokenTypeChange(TokenType.FUTURE_POINTS) }, + label = { Text(FuturePointsToken.NAME) }, + leadingIcon = { + if (selectedTokenType == TokenType.FUTURE_POINTS) { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFF9C27B0).copy(alpha = 0.2f), + selectedLabelColor = Color(0xFF9C27B0) + ), + modifier = Modifier.weight(1f) + ) + } Spacer(modifier = Modifier.height(16.dp)) @@ -418,9 +498,14 @@ private fun TransferInputScreen( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), leadingIcon = { Icon( - if (selectedTokenType == TokenType.GREEN_POINTS) Icons.Default.Stars else Icons.Default.AttachMoney, + if (selectedTokenType == TokenType.KAVA) Icons.Default.AttachMoney else Icons.Default.Stars, contentDescription = null, - tint = if (selectedTokenType == TokenType.GREEN_POINTS) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant + tint = when (selectedTokenType) { + TokenType.KAVA -> MaterialTheme.colorScheme.onSurfaceVariant + TokenType.GREEN_POINTS -> Color(0xFF4CAF50) + TokenType.ENERGY_POINTS -> Color(0xFF2196F3) + TokenType.FUTURE_POINTS -> Color(0xFF9C27B0) + } ) }, trailingIcon = { @@ -439,7 +524,7 @@ private fun TransferInputScreen( onAmountChange(currentBalance) } } else { - // For tokens, use the full balance + // For ERC-20 tokens (dUSDT, eUSDT, fUSDT), use the full balance onAmountChange(currentBalance) } isCalculatingMax = false 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..39ac5c71 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 @@ -35,6 +35,8 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import android.content.Intent import android.net.Uri +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 com.durian.tssparty.domain.model.ShareRecord @@ -281,62 +283,123 @@ private fun WalletItemCard( Spacer(modifier = Modifier.height(12.dp)) - // Balance display - now shows both KAVA and Green Points - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - // KAVA balance - Column { - Text( - text = "KAVA", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.AccountBalance, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(4.dp)) + // Balance display - shows all token balances in a 2x2 grid + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // KAVA balance + Column { Text( - text = walletBalance?.kavaBalance ?: balance ?: "加载中...", - style = MaterialTheme.typography.bodyMedium, - color = if (walletBalance != null || balance != null) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.outline, - fontWeight = FontWeight.Medium + text = "KAVA", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.AccountBalance, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = walletBalance?.kavaBalance ?: balance ?: "加载中...", + style = MaterialTheme.typography.bodyMedium, + color = if (walletBalance != null || balance != null) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.outline, + fontWeight = FontWeight.Medium + ) + } + } + + // Green Points (绿积分) balance + Column(horizontalAlignment = Alignment.End) { + Text( + text = GreenPointsToken.NAME, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Stars, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color(0xFF4CAF50) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = walletBalance?.greenPointsBalance ?: "加载中...", + style = MaterialTheme.typography.bodyMedium, + color = if (walletBalance != null) + Color(0xFF4CAF50) + else + MaterialTheme.colorScheme.outline, + fontWeight = FontWeight.Medium + ) + } } } - - // Green Points (绿积分) balance - Column(horizontalAlignment = Alignment.End) { - Text( - text = GreenPointsToken.NAME, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Stars, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = Color(0xFF4CAF50) // Green color for Green Points - ) - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Energy Points (积分股) balance + Column { Text( - text = walletBalance?.greenPointsBalance ?: "加载中...", - style = MaterialTheme.typography.bodyMedium, - color = if (walletBalance != null) - Color(0xFF4CAF50) - else - MaterialTheme.colorScheme.outline, - fontWeight = FontWeight.Medium + text = EnergyPointsToken.NAME, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Stars, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color(0xFF2196F3) // Blue + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = walletBalance?.energyPointsBalance ?: "加载中...", + style = MaterialTheme.typography.bodyMedium, + color = if (walletBalance != null) + Color(0xFF2196F3) + else + MaterialTheme.colorScheme.outline, + fontWeight = FontWeight.Medium + ) + } + } + + // Future Points (积分值) balance + Column(horizontalAlignment = Alignment.End) { + Text( + text = FuturePointsToken.NAME, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Stars, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color(0xFF9C27B0) // Purple + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = walletBalance?.futurePointsBalance ?: "加载中...", + style = MaterialTheme.typography.bodyMedium, + color = if (walletBalance != null) + Color(0xFF9C27B0) + else + MaterialTheme.colorScheme.outline, + fontWeight = FontWeight.Medium + ) + } } } } diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/TransactionUtils.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/TransactionUtils.kt index 04296d24..36e0ca6d 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/TransactionUtils.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/util/TransactionUtils.kt @@ -1,6 +1,10 @@ package com.durian.tssparty.util +import com.durian.tssparty.domain.model.ERC20Selectors +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.TokenConfig import com.durian.tssparty.domain.model.TokenType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -61,7 +65,7 @@ object TransactionUtils { /** * Prepare a transaction for signing * Gets nonce, gas price, estimates gas, and calculates sign hash - * Supports both native KAVA transfers and ERC-20 token transfers (绿积分) + * Supports both native KAVA transfers and ERC-20 token transfers (绿积分/积分股/积分值) */ suspend fun prepareTransaction(params: TransactionParams): Result = withContext(Dispatchers.IO) { try { @@ -77,13 +81,16 @@ object TransactionUtils { // Native KAVA transfer Triple(params.to, kavaToWei(params.amount), ByteArray(0)) } - TokenType.GREEN_POINTS -> { - // ERC-20 token transfer (绿积分) + TokenType.GREEN_POINTS, TokenType.ENERGY_POINTS, TokenType.FUTURE_POINTS -> { + // ERC-20 token transfer // To address is the contract, value is 0 // Data is transfer(recipient, amount) encoded - val tokenAmount = greenPointsToRaw(params.amount) + val contractAddress = TokenConfig.getContractAddress(params.tokenType) + ?: return@withContext Result.failure(Exception("Invalid token type")) + val decimals = TokenConfig.getDecimals(params.tokenType) + val tokenAmount = tokenToRaw(params.amount, decimals) val transferData = encodeErc20Transfer(params.to, tokenAmount) - Triple(GreenPointsToken.CONTRACT_ADDRESS, BigInteger.ZERO, transferData) + Triple(contractAddress, BigInteger.ZERO, transferData) } } @@ -98,7 +105,7 @@ object TransactionUtils { // Default gas limits when (params.tokenType) { TokenType.KAVA -> BigInteger.valueOf(21000) - TokenType.GREEN_POINTS -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas + else -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas } } @@ -139,7 +146,7 @@ object TransactionUtils { */ private fun encodeErc20Transfer(to: String, amount: BigInteger): ByteArray { // Function selector: transfer(address,uint256) = 0xa9059cbb - val selector = GreenPointsToken.TRANSFER_SELECTOR.removePrefix("0x").hexToByteArray() + val selector = ERC20Selectors.TRANSFER.removePrefix("0x").hexToByteArray() // Encode recipient address (padded to 32 bytes) val paddedAddress = to.removePrefix("0x").lowercase().padStart(64, '0').hexToByteArray() @@ -152,21 +159,43 @@ object TransactionUtils { } /** - * Convert Green Points amount to raw units (6 decimals) + * Convert token amount to raw units based on decimals + * @param amount Human-readable amount (e.g., "100.5") + * @param decimals Token decimals (e.g., 6 for USDT-like tokens, 18 for native) */ - fun greenPointsToRaw(amount: String): BigInteger { + fun tokenToRaw(amount: String, decimals: Int): BigInteger { val decimal = BigDecimal(amount) - val rawDecimal = decimal.multiply(BigDecimal("1000000")) // 10^6 + val multiplier = BigDecimal.TEN.pow(decimals) + val rawDecimal = decimal.multiply(multiplier) return rawDecimal.toBigInteger() } + /** + * Convert raw units to human-readable token amount + * @param raw Raw amount in smallest units + * @param decimals Token decimals (e.g., 6 for USDT-like tokens, 18 for native) + */ + fun rawToToken(raw: BigInteger, decimals: Int): String { + val rawDecimal = BigDecimal(raw) + val divisor = BigDecimal.TEN.pow(decimals) + val displayDecimal = rawDecimal.divide(divisor, decimals, java.math.RoundingMode.DOWN) + return displayDecimal.toPlainString() + } + + /** + * Convert Green Points amount to raw units (6 decimals) + * @deprecated Use tokenToRaw(amount, 6) instead + */ + fun greenPointsToRaw(amount: String): BigInteger { + return tokenToRaw(amount, GreenPointsToken.DECIMALS) + } + /** * Convert raw units to Green Points display amount + * @deprecated Use rawToToken(raw, 6) instead */ fun rawToGreenPoints(raw: BigInteger): String { - val rawDecimal = BigDecimal(raw) - val displayDecimal = rawDecimal.divide(BigDecimal("1000000"), 6, java.math.RoundingMode.DOWN) - return displayDecimal.toPlainString() + return rawToToken(raw, GreenPointsToken.DECIMALS) } /** diff --git a/backend/mpc-system/services/service-party-app/src/pages/Home.tsx b/backend/mpc-system/services/service-party-app/src/pages/Home.tsx index 8001694e..1ee89134 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Home.tsx +++ b/backend/mpc-system/services/service-party-app/src/pages/Home.tsx @@ -11,7 +11,12 @@ import { getCurrentRpcUrl, getGasPrice, fetchGreenPointsBalance, + fetchEnergyPointsBalance, + fetchFuturePointsBalance, GREEN_POINTS_TOKEN, + ENERGY_POINTS_TOKEN, + FUTURE_POINTS_TOKEN, + TOKEN_CONFIG, type PreparedTransaction, type TokenType, } from '../utils/transaction'; @@ -32,6 +37,8 @@ interface ShareWithAddress extends ShareItem { evmAddress?: string; kavaBalance?: string; greenPointsBalance?: string; + energyPointsBalance?: string; + futurePointsBalance?: string; balanceLoading?: boolean; } @@ -89,15 +96,30 @@ export default function Home() { const [isCalculatingMax, setIsCalculatingMax] = useState(false); const [copySuccess, setCopySuccess] = useState(false); + // 获取当前选择代币的余额 + const getTokenBalance = (share: ShareWithAddress | null, tokenType: TokenType): string => { + if (!share) return '0'; + switch (tokenType) { + case 'KAVA': + return share.kavaBalance || '0'; + case 'GREEN_POINTS': + return share.greenPointsBalance || '0'; + case 'ENERGY_POINTS': + return share.energyPointsBalance || '0'; + case 'FUTURE_POINTS': + return share.futurePointsBalance || '0'; + } + }; + // 计算扣除 Gas 费后的最大可转账金额 const calculateMaxAmount = async () => { if (!transferShare?.evmAddress) return; setIsCalculatingMax(true); try { - if (transferTokenType === 'GREEN_POINTS') { - // For token transfers, use the full token balance (gas is paid in KAVA) - const balance = transferShare.greenPointsBalance || '0'; + if (TOKEN_CONFIG.isERC20(transferTokenType)) { + // For ERC-20 token transfers, use the full token balance (gas is paid in KAVA) + const balance = getTokenBalance(transferShare, transferTokenType); setTransferAmount(balance); setTransferError(null); } else { @@ -131,8 +153,8 @@ export default function Home() { } } catch (error) { console.error('Failed to calculate max amount:', error); - if (transferTokenType === 'GREEN_POINTS') { - setTransferAmount(transferShare.greenPointsBalance || '0'); + if (TOKEN_CONFIG.isERC20(transferTokenType)) { + setTransferAmount(getTokenBalance(transferShare, transferTokenType)); } else { // 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000) const defaultGasFee = 0.000021; // ~21000 * 1 gwei @@ -165,12 +187,14 @@ export default function Home() { const updatedShares = await Promise.all( sharesWithAddrs.map(async (share) => { if (share.evmAddress) { - // Fetch both balances in parallel - const [kavaBalance, greenPointsBalance] = await Promise.all([ + // Fetch all balances in parallel + const [kavaBalance, greenPointsBalance, energyPointsBalance, futurePointsBalance] = await Promise.all([ fetchKavaBalance(share.evmAddress), fetchGreenPointsBalance(share.evmAddress), + fetchEnergyPointsBalance(share.evmAddress), + fetchFuturePointsBalance(share.evmAddress), ]); - return { ...share, kavaBalance, greenPointsBalance, balanceLoading: false }; + return { ...share, kavaBalance, greenPointsBalance, energyPointsBalance, futurePointsBalance, balanceLoading: false }; } return { ...share, balanceLoading: false }; }) @@ -315,11 +339,7 @@ export default function Home() { return '转账金额无效'; } const amount = parseFloat(transferAmount); - const balance = parseFloat( - transferTokenType === 'GREEN_POINTS' - ? (transferShare?.greenPointsBalance || '0') - : (transferShare?.kavaBalance || '0') - ); + const balance = parseFloat(getTokenBalance(transferShare, transferTokenType)); if (amount > balance) { return '余额不足'; } @@ -486,7 +506,7 @@ export default function Home() { )} - {/* 余额显示 - KAVA 和 绿积分 */} + {/* 余额显示 - 所有代币 */} {share.evmAddress && (
@@ -509,6 +529,26 @@ export default function Home() { )}
+
+ {ENERGY_POINTS_TOKEN.name} + + {share.balanceLoading ? ( + 加载中... + ) : ( + <>{share.energyPointsBalance || '0'} + )} + +
+
+ {FUTURE_POINTS_TOKEN.name} + + {share.balanceLoading ? ( + 加载中... + ) : ( + <>{share.futurePointsBalance || '0'} + )} + +
)} @@ -578,7 +618,10 @@ export default function Home() {
{transferShare.walletName}
- KAVA: {transferShare.kavaBalance || '0'} | {GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'} + KAVA: {transferShare.kavaBalance || '0'} | {GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'} +
+
+ {ENERGY_POINTS_TOKEN.name}: {transferShare.energyPointsBalance || '0'} | {FUTURE_POINTS_TOKEN.name}: {transferShare.futurePointsBalance || '0'}
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'} @@ -605,6 +648,22 @@ export default function Home() { {GREEN_POINTS_TOKEN.name}
+
+ + +
{/* 收款地址 */} @@ -622,7 +681,7 @@ export default function Home() { {/* 转账金额 */}
转账类型 - - {transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'} + + {TOKEN_CONFIG.getName(transferTokenType)}
@@ -699,8 +758,8 @@ export default function Home() {
转账金额 - - {transferAmount} {transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'} + + {transferAmount} {TOKEN_CONFIG.getName(transferTokenType)}
diff --git a/backend/mpc-system/services/service-party-app/src/utils/transaction.ts b/backend/mpc-system/services/service-party-app/src/utils/transaction.ts index 870304e7..55c9221e 100644 --- a/backend/mpc-system/services/service-party-app/src/utils/transaction.ts +++ b/backend/mpc-system/services/service-party-app/src/utils/transaction.ts @@ -17,17 +17,97 @@ export const KAVA_RPC_URL = { }; // Token types -export type TokenType = 'KAVA' | 'GREEN_POINTS'; +export type TokenType = 'KAVA' | 'GREEN_POINTS' | 'ENERGY_POINTS' | 'FUTURE_POINTS'; -// Green Points (绿积分) Token Configuration +// ERC-20 通用函数选择器 +export const ERC20_SELECTORS = { + balanceOf: '0x70a08231', // balanceOf(address) + transfer: '0xa9059cbb', // transfer(address,uint256) + approve: '0x095ea7b3', // approve(address,uint256) + allowance: '0xdd62ed3e', // allowance(address,address) + totalSupply: '0x18160ddd', // totalSupply() +}; + +// Green Points (绿积分) Token Configuration - dUSDT export const GREEN_POINTS_TOKEN = { contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3', name: '绿积分', symbol: 'dUSDT', decimals: 6, - // ERC-20 function selectors - balanceOfSelector: '0x70a08231', - transferSelector: '0xa9059cbb', + // ERC-20 function selectors (kept for backward compatibility) + balanceOfSelector: ERC20_SELECTORS.balanceOf, + transferSelector: ERC20_SELECTORS.transfer, +}; + +// Energy Points (积分股) Token Configuration - eUSDT +export const ENERGY_POINTS_TOKEN = { + contractAddress: '0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931', + name: '积分股', + symbol: 'eUSDT', + decimals: 6, +}; + +// Future Points (积分值) Token Configuration - fUSDT +export const FUTURE_POINTS_TOKEN = { + contractAddress: '0x14dc4f7d3E4197438d058C3D156dd9826A161134', + name: '积分值', + symbol: 'fUSDT', + decimals: 6, +}; + +// Token configuration utility +export const TOKEN_CONFIG = { + getContractAddress: (tokenType: TokenType): string | null => { + switch (tokenType) { + case 'KAVA': + return null; // Native token has no contract + case 'GREEN_POINTS': + return GREEN_POINTS_TOKEN.contractAddress; + case 'ENERGY_POINTS': + return ENERGY_POINTS_TOKEN.contractAddress; + case 'FUTURE_POINTS': + return FUTURE_POINTS_TOKEN.contractAddress; + } + }, + getDecimals: (tokenType: TokenType): number => { + switch (tokenType) { + case 'KAVA': + return 18; + case 'GREEN_POINTS': + return GREEN_POINTS_TOKEN.decimals; + case 'ENERGY_POINTS': + return ENERGY_POINTS_TOKEN.decimals; + case 'FUTURE_POINTS': + return FUTURE_POINTS_TOKEN.decimals; + } + }, + getName: (tokenType: TokenType): string => { + switch (tokenType) { + case 'KAVA': + return 'KAVA'; + case 'GREEN_POINTS': + return GREEN_POINTS_TOKEN.name; + case 'ENERGY_POINTS': + return ENERGY_POINTS_TOKEN.name; + case 'FUTURE_POINTS': + return FUTURE_POINTS_TOKEN.name; + } + }, + getSymbol: (tokenType: TokenType): string => { + switch (tokenType) { + case 'KAVA': + return 'KAVA'; + case 'GREEN_POINTS': + return GREEN_POINTS_TOKEN.symbol; + case 'ENERGY_POINTS': + return ENERGY_POINTS_TOKEN.symbol; + case 'FUTURE_POINTS': + return FUTURE_POINTS_TOKEN.symbol; + } + }, + isERC20: (tokenType: TokenType): boolean => { + return tokenType !== 'KAVA'; + }, }; // 当前网络配置 (从 localStorage 读取或使用默认值) @@ -327,44 +407,69 @@ export function weiToKava(wei: bigint): string { } /** - * 将绿积分金额转换为最小单位 (6 decimals) + * 将代币金额转换为最小单位 + * @param amount Human-readable amount + * @param decimals Token decimals (default 6 for USDT-like tokens) */ -export function greenPointsToRaw(amount: string): bigint { +export function tokenToRaw(amount: string, decimals: number = 6): bigint { const parts = amount.split('.'); const whole = BigInt(parts[0] || '0'); let fraction = parts[1] || ''; - // 补齐或截断到 6 位 - if (fraction.length > 6) { - fraction = fraction.substring(0, 6); + // 补齐或截断到指定位数 + if (fraction.length > decimals) { + fraction = fraction.substring(0, decimals); } else { - fraction = fraction.padEnd(6, '0'); + fraction = fraction.padEnd(decimals, '0'); } - return whole * BigInt(10 ** 6) + BigInt(fraction); + return whole * BigInt(10 ** decimals) + BigInt(fraction); } /** - * 将最小单位转换为绿积分金额 + * 将最小单位转换为代币金额 + * @param raw Raw amount in smallest units + * @param decimals Token decimals (default 6 for USDT-like tokens) */ -export function rawToGreenPoints(raw: bigint): string { - const rawStr = raw.toString().padStart(7, '0'); - const whole = rawStr.slice(0, -6) || '0'; - const fraction = rawStr.slice(-6).replace(/0+$/, ''); +export function rawToToken(raw: bigint, decimals: number = 6): string { + const rawStr = raw.toString().padStart(decimals + 1, '0'); + const whole = rawStr.slice(0, -decimals) || '0'; + const fraction = rawStr.slice(-decimals).replace(/0+$/, ''); return fraction ? `${whole}.${fraction}` : whole; } /** - * 查询绿积分 (ERC-20) 余额 + * 将绿积分金额转换为最小单位 (6 decimals) + * @deprecated Use tokenToRaw(amount, 6) instead */ -export async function fetchGreenPointsBalance(address: string): Promise { +export function greenPointsToRaw(amount: string): bigint { + return tokenToRaw(amount, GREEN_POINTS_TOKEN.decimals); +} + +/** + * 将最小单位转换为绿积分金额 + * @deprecated Use rawToToken(raw, 6) instead + */ +export function rawToGreenPoints(raw: bigint): string { + return rawToToken(raw, GREEN_POINTS_TOKEN.decimals); +} + +/** + * 查询 ERC-20 代币余额 + * @param address Wallet address + * @param contractAddress Token contract address + * @param decimals Token decimals + */ +export async function fetchERC20Balance( + address: string, + contractAddress: string, + decimals: number = 6 +): Promise { try { const rpcUrl = getCurrentRpcUrl(); // Encode balanceOf(address) call data - // Function selector: 0x70a08231 - // Address parameter: padded to 32 bytes const paddedAddress = address.toLowerCase().replace('0x', '').padStart(64, '0'); - const callData = GREEN_POINTS_TOKEN.balanceOfSelector + paddedAddress; + const callData = ERC20_SELECTORS.balanceOf + paddedAddress; const response = await fetch(rpcUrl, { method: 'POST', @@ -374,7 +479,7 @@ export async function fetchGreenPointsBalance(address: string): Promise method: 'eth_call', params: [ { - to: GREEN_POINTS_TOKEN.contractAddress, + to: contractAddress, data: callData, }, 'latest', @@ -386,21 +491,65 @@ export async function fetchGreenPointsBalance(address: string): Promise const data = await response.json(); if (data.result && data.result !== '0x') { const balanceRaw = BigInt(data.result); - return rawToGreenPoints(balanceRaw); + return rawToToken(balanceRaw, decimals); } return '0'; } catch (error) { - console.error('Failed to fetch Green Points balance:', error); + console.error('Failed to fetch ERC20 balance:', error); return '0'; } } +/** + * 查询绿积分 (ERC-20) 余额 + */ +export async function fetchGreenPointsBalance(address: string): Promise { + return fetchERC20Balance(address, GREEN_POINTS_TOKEN.contractAddress, GREEN_POINTS_TOKEN.decimals); +} + +/** + * 查询积分股 (eUSDT) 余额 + */ +export async function fetchEnergyPointsBalance(address: string): Promise { + return fetchERC20Balance(address, ENERGY_POINTS_TOKEN.contractAddress, ENERGY_POINTS_TOKEN.decimals); +} + +/** + * 查询积分值 (fUSDT) 余额 + */ +export async function fetchFuturePointsBalance(address: string): Promise { + return fetchERC20Balance(address, FUTURE_POINTS_TOKEN.contractAddress, FUTURE_POINTS_TOKEN.decimals); +} + +/** + * 查询所有代币余额 + */ +export async function fetchAllTokenBalances(address: string): Promise<{ + kava: string; + greenPoints: string; + energyPoints: string; + futurePoints: string; +}> { + const [greenPoints, energyPoints, futurePoints] = await Promise.all([ + fetchGreenPointsBalance(address), + fetchEnergyPointsBalance(address), + fetchFuturePointsBalance(address), + ]); + // Note: KAVA balance is fetched separately via eth_getBalance + return { + kava: '0', // Caller should fetch KAVA balance separately + greenPoints, + energyPoints, + futurePoints, + }; +} + /** * Encode ERC-20 transfer function call */ function encodeErc20Transfer(to: string, amount: bigint): string { // Function selector: transfer(address,uint256) = 0xa9059cbb - const selector = GREEN_POINTS_TOKEN.transferSelector; + const selector = ERC20_SELECTORS.transfer; // Encode recipient address (padded to 32 bytes) const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0'); // Encode amount (padded to 32 bytes) @@ -476,13 +625,15 @@ export async function estimateGas(params: { from: string; to: string; value: str // For token transfers, we need different params let txParams: { from: string; to: string; value: string; data?: string }; - if (tokenType === 'GREEN_POINTS') { + if (TOKEN_CONFIG.isERC20(tokenType)) { // ERC-20 transfer: to is contract, value is 0, data is transfer call - const tokenAmount = greenPointsToRaw(params.value); + const contractAddress = TOKEN_CONFIG.getContractAddress(tokenType); + const decimals = TOKEN_CONFIG.getDecimals(tokenType); + const tokenAmount = tokenToRaw(params.value, decimals); const transferData = encodeErc20Transfer(params.to, tokenAmount); txParams = { from: params.from, - to: GREEN_POINTS_TOKEN.contractAddress, + to: contractAddress!, value: '0x0', data: transferData, }; @@ -511,7 +662,7 @@ export async function estimateGas(params: { from: string; to: string; value: str if (data.error) { // 如果估算失败,使用默认值 console.warn('Gas 估算失败,使用默认值:', data.error); - return tokenType === 'GREEN_POINTS' ? BigInt(65000) : BigInt(21000); + return TOKEN_CONFIG.isERC20(tokenType) ? BigInt(65000) : BigInt(21000); } return BigInt(data.result); } @@ -543,12 +694,14 @@ export async function prepareTransaction(params: TransactionParams): Promise