feat(tss): 添加积分股(eUSDT)和积分值(fUSDT)代币支持

新增功能:
- 添加 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-19 23:36:58 -08:00
parent d332ef99a7
commit c5db77d23a
9 changed files with 940 additions and 191 deletions

View File

@ -93,15 +93,159 @@ interface AppSettingDao {
suspend fun setValue(setting: AppSettingEntity) 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<List<TransactionRecordEntity>>
@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<TransactionRecordEntity>
@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<List<TransactionRecordEntity>>
@Query("SELECT * FROM transaction_records WHERE status = 'PENDING' ORDER BY created_at ASC")
suspend fun getPendingRecords(): List<TransactionRecordEntity>
@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 * Room database
*/ */
@Database( @Database(
entities = [ShareRecordEntity::class, AppSettingEntity::class], entities = [ShareRecordEntity::class, AppSettingEntity::class, TransactionRecordEntity::class],
version = 3, // Version 3: added party_id column to share_records version = 4, // Version 4: added transaction_records table for transfer history
exportSchema = false exportSchema = false
) )
abstract class TssDatabase : RoomDatabase() { abstract class TssDatabase : RoomDatabase() {
abstract fun shareRecordDao(): ShareRecordDao abstract fun shareRecordDao(): ShareRecordDao
abstract fun appSettingDao(): AppSettingDao abstract fun appSettingDao(): AppSettingDao
abstract fun transactionRecordDao(): TransactionRecordDao
} }

View File

@ -5,6 +5,8 @@ import com.durian.tssparty.data.local.AppSettingDao
import com.durian.tssparty.data.local.AppSettingEntity import com.durian.tssparty.data.local.AppSettingEntity
import com.durian.tssparty.data.local.ShareRecordDao import com.durian.tssparty.data.local.ShareRecordDao
import com.durian.tssparty.data.local.ShareRecordEntity 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.local.TssNativeBridge
import com.durian.tssparty.data.remote.GrpcClient import com.durian.tssparty.data.remote.GrpcClient
import com.durian.tssparty.data.remote.GrpcConnectionEvent import com.durian.tssparty.data.remote.GrpcConnectionEvent
@ -31,7 +33,8 @@ class TssRepository @Inject constructor(
private val grpcClient: GrpcClient, private val grpcClient: GrpcClient,
private val tssNativeBridge: TssNativeBridge, private val tssNativeBridge: TssNativeBridge,
private val shareRecordDao: ShareRecordDao, private val shareRecordDao: ShareRecordDao,
private val appSettingDao: AppSettingDao private val appSettingDao: AppSettingDao,
private val transactionRecordDao: TransactionRecordDao
) { ) {
private val _currentSession = MutableStateFlow<TssSession?>(null) private val _currentSession = MutableStateFlow<TssSession?>(null)
val currentSession: StateFlow<TssSession?> = _currentSession.asStateFlow() val currentSession: StateFlow<TssSession?> = _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 * 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<String> { suspend fun getERC20Balance(
address: String,
rpcUrl: String,
contractAddress: String,
decimals: Int = 6
): Result<String> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val client = okhttp3.OkHttpClient() val client = okhttp3.OkHttpClient()
@ -2009,14 +2021,14 @@ class TssRepository @Inject constructor(
// Function selector: 0x70a08231 // Function selector: 0x70a08231
// Address parameter: padded to 32 bytes // Address parameter: padded to 32 bytes
val paddedAddress = address.removePrefix("0x").lowercase().padStart(64, '0') val paddedAddress = address.removePrefix("0x").lowercase().padStart(64, '0')
val callData = "${GreenPointsToken.BALANCE_OF_SELECTOR}$paddedAddress" val callData = "${ERC20Selectors.BALANCE_OF}$paddedAddress"
val requestBody = """ val requestBody = """
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "eth_call", "method": "eth_call",
"params": [{ "params": [{
"to": "${GreenPointsToken.CONTRACT_ADDRESS}", "to": "$contractAddress",
"data": "$callData" "data": "$callData"
}, "latest"], }, "latest"],
"id": 1 "id": 1
@ -2038,42 +2050,88 @@ class TssRepository @Inject constructor(
} }
val hexBalance = json.get("result").asString 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 rawBalance = java.math.BigInteger(hexBalance.removePrefix("0x"), 16)
val divisor = java.math.BigDecimal.TEN.pow(decimals)
val tokenBalance = java.math.BigDecimal(rawBalance).divide( val tokenBalance = java.math.BigDecimal(rawBalance).divide(
java.math.BigDecimal("1000000"), // 10^6 for 6 decimals divisor,
6, decimals,
java.math.RoundingMode.DOWN java.math.RoundingMode.DOWN
) )
Result.success(tokenBalance.toPlainString()) Result.success(tokenBalance.toPlainString())
} catch (e: Exception) { } 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) 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<String> {
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<String> {
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<String> {
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<WalletBalance> { suspend fun getWalletBalance(address: String, rpcUrl: String): Result<WalletBalance> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
// Fetch both balances in parallel // Fetch all balances in parallel
val kavaDeferred = async { getBalance(address, rpcUrl) } val kavaDeferred = async { getBalance(address, rpcUrl) }
val greenPointsDeferred = async { getGreenPointsBalance(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 kavaResult = kavaDeferred.await()
val greenPointsResult = greenPointsDeferred.await() val greenPointsResult = greenPointsDeferred.await()
val energyPointsResult = energyPointsDeferred.await()
val futurePointsResult = futurePointsDeferred.await()
val kavaBalance = kavaResult.getOrDefault("0") val kavaBalance = kavaResult.getOrDefault("0")
val greenPointsBalance = greenPointsResult.getOrDefault("0") val greenPointsBalance = greenPointsResult.getOrDefault("0")
val energyPointsBalance = energyPointsResult.getOrDefault("0")
val futurePointsBalance = futurePointsResult.getOrDefault("0")
Result.success(WalletBalance( Result.success(WalletBalance(
address = address, address = address,
kavaBalance = kavaBalance, kavaBalance = kavaBalance,
greenPointsBalance = greenPointsBalance greenPointsBalance = greenPointsBalance,
energyPointsBalance = energyPointsBalance,
futurePointsBalance = futurePointsBalance
)) ))
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)

View File

@ -6,6 +6,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.durian.tssparty.data.local.AppSettingDao import com.durian.tssparty.data.local.AppSettingDao
import com.durian.tssparty.data.local.ShareRecordDao 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.TssDatabase
import com.durian.tssparty.data.local.TssNativeBridge import com.durian.tssparty.data.local.TssNativeBridge
import com.durian.tssparty.data.remote.GrpcClient 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 @Provides
@Singleton @Singleton
fun provideGson(): Gson { fun provideGson(): Gson {
@ -59,7 +96,7 @@ object AppModule {
TssDatabase::class.java, TssDatabase::class.java,
"tss_party.db" "tss_party.db"
) )
.addMigrations(MIGRATION_1_2, MIGRATION_2_3) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build() .build()
} }
@ -75,6 +112,12 @@ object AppModule {
return database.appSettingDao() return database.appSettingDao()
} }
@Provides
@Singleton
fun provideTransactionRecordDao(database: TssDatabase): TransactionRecordDao {
return database.transactionRecordDao()
}
@Provides @Provides
@Singleton @Singleton
fun provideGrpcClient(): GrpcClient { fun provideGrpcClient(): GrpcClient {
@ -93,8 +136,9 @@ object AppModule {
grpcClient: GrpcClient, grpcClient: GrpcClient,
tssNativeBridge: TssNativeBridge, tssNativeBridge: TssNativeBridge,
shareRecordDao: ShareRecordDao, shareRecordDao: ShareRecordDao,
appSettingDao: AppSettingDao appSettingDao: AppSettingDao,
transactionRecordDao: TransactionRecordDao
): TssRepository { ): TssRepository {
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao) return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao, transactionRecordDao)
} }
} }

View File

@ -130,7 +130,21 @@ enum class NetworkType {
*/ */
enum class TokenType { enum class TokenType {
KAVA, // Native KAVA token 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 SYMBOL = "dUSDT"
const val DECIMALS = 6 const val DECIMALS = 6
// ERC-20 function signatures (first 4 bytes of keccak256 hash) // ERC-20 function signatures (kept for backward compatibility)
const val BALANCE_OF_SELECTOR = "0x70a08231" // balanceOf(address) const val BALANCE_OF_SELECTOR = ERC20Selectors.BALANCE_OF
const val TRANSFER_SELECTOR = "0xa9059cbb" // transfer(address,uint256) const val TRANSFER_SELECTOR = ERC20Selectors.TRANSFER
const val APPROVE_SELECTOR = "0x095ea7b3" // approve(address,uint256) const val APPROVE_SELECTOR = ERC20Selectors.APPROVE
const val ALLOWANCE_SELECTOR = "0xdd62ed3e" // allowance(address,address) const val ALLOWANCE_SELECTOR = ERC20Selectors.ALLOWANCE
const val TOTAL_SUPPLY_SELECTOR = "0x18160ddd" // totalSupply() 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( data class WalletBalance(
val address: String, val address: String,
val kavaBalance: String = "0", // Native KAVA balance val kavaBalance: String = "0", // Native KAVA balance
val greenPointsBalance: String = "0" // 绿积分 (dUSDT) 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 * Share backup data for export/import

View File

@ -27,10 +27,13 @@ import android.graphics.Bitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.ui.graphics.asImageBitmap 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.GreenPointsToken
import com.durian.tssparty.domain.model.NetworkType import com.durian.tssparty.domain.model.NetworkType
import com.durian.tssparty.domain.model.SessionStatus import com.durian.tssparty.domain.model.SessionStatus
import com.durian.tssparty.domain.model.ShareRecord 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.TokenType
import com.durian.tssparty.domain.model.WalletBalance import com.durian.tssparty.domain.model.WalletBalance
import com.durian.tssparty.util.TransactionUtils import com.durian.tssparty.util.TransactionUtils
@ -156,10 +159,8 @@ fun TransferScreen(
rpcUrl = rpcUrl, rpcUrl = rpcUrl,
onSubmit = { onSubmit = {
// Get current balance for the selected token type // Get current balance for the selected token type
val currentBalance = when (selectedTokenType) { val currentBalance = walletBalance?.getBalance(selectedTokenType)
TokenType.KAVA -> walletBalance?.kavaBalance ?: balance ?: if (selectedTokenType == TokenType.KAVA) balance else null
TokenType.GREEN_POINTS -> walletBalance?.greenPointsBalance
}
when { when {
toAddress.isBlank() -> validationError = "请输入收款地址" toAddress.isBlank() -> validationError = "请输入收款地址"
!toAddress.startsWith("0x") || toAddress.length != 42 -> validationError = "地址格式不正确" !toAddress.startsWith("0x") || toAddress.length != 42 -> validationError = "地址格式不正确"
@ -257,14 +258,9 @@ private fun TransferInputScreen(
var isCalculatingMax by remember { mutableStateOf(false) } var isCalculatingMax by remember { mutableStateOf(false) }
// Get current balance for the selected token type // Get current balance for the selected token type
val currentBalance = when (selectedTokenType) { val currentBalance = walletBalance?.getBalance(selectedTokenType)
TokenType.KAVA -> walletBalance?.kavaBalance ?: balance ?: if (selectedTokenType == TokenType.KAVA) balance else null
TokenType.GREEN_POINTS -> walletBalance?.greenPointsBalance val tokenSymbol = TokenConfig.getName(selectedTokenType)
}
val tokenSymbol = when (selectedTokenType) {
TokenType.KAVA -> "KAVA"
TokenType.GREEN_POINTS -> GreenPointsToken.NAME
}
Column( Column(
modifier = Modifier modifier = Modifier
@ -293,7 +289,8 @@ private fun TransferInputScreen(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Show both balances // Show all token balances in a 2x2 grid
Column {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@ -312,7 +309,7 @@ private fun TransferInputScreen(
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
// Green Points balance // Green Points balance (绿积分)
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text( Text(
text = GreenPointsToken.NAME, text = GreenPointsToken.NAME,
@ -327,6 +324,41 @@ private fun TransferInputScreen(
) )
} }
} }
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 color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// First row: KAVA and Green Points
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -359,7 +392,7 @@ private fun TransferInputScreen(
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// Green Points option // Green Points option (绿积分)
FilterChip( FilterChip(
selected = selectedTokenType == TokenType.GREEN_POINTS, selected = selectedTokenType == TokenType.GREEN_POINTS,
onClick = { onTokenTypeChange(TokenType.GREEN_POINTS) }, onClick = { onTokenTypeChange(TokenType.GREEN_POINTS) },
@ -380,6 +413,53 @@ private fun TransferInputScreen(
modifier = Modifier.weight(1f) 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)) Spacer(modifier = Modifier.height(16.dp))
@ -418,9 +498,14 @@ private fun TransferInputScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
leadingIcon = { leadingIcon = {
Icon( 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, 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 = { trailingIcon = {
@ -439,7 +524,7 @@ private fun TransferInputScreen(
onAmountChange(currentBalance) onAmountChange(currentBalance)
} }
} else { } else {
// For tokens, use the full balance // For ERC-20 tokens (dUSDT, eUSDT, fUSDT), use the full balance
onAmountChange(currentBalance) onAmountChange(currentBalance)
} }
isCalculatingMax = false isCalculatingMax = false

View File

@ -35,6 +35,8 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import android.content.Intent import android.content.Intent
import android.net.Uri 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.GreenPointsToken
import com.durian.tssparty.domain.model.NetworkType import com.durian.tssparty.domain.model.NetworkType
import com.durian.tssparty.domain.model.ShareRecord import com.durian.tssparty.domain.model.ShareRecord
@ -281,7 +283,8 @@ private fun WalletItemCard(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Balance display - now shows both KAVA and Green Points // Balance display - shows all token balances in a 2x2 grid
Column {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@ -325,7 +328,7 @@ private fun WalletItemCard(
Icons.Default.Stars, Icons.Default.Stars,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = Color(0xFF4CAF50) // Green color for Green Points tint = Color(0xFF4CAF50)
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
@ -340,6 +343,66 @@ private fun WalletItemCard(
} }
} }
} }
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Energy Points (积分股) balance
Column {
Text(
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
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View File

@ -1,6 +1,10 @@
package com.durian.tssparty.util 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.GreenPointsToken
import com.durian.tssparty.domain.model.TokenConfig
import com.durian.tssparty.domain.model.TokenType import com.durian.tssparty.domain.model.TokenType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -61,7 +65,7 @@ object TransactionUtils {
/** /**
* Prepare a transaction for signing * Prepare a transaction for signing
* Gets nonce, gas price, estimates gas, and calculates sign hash * 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<PreparedTransaction> = withContext(Dispatchers.IO) { suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
try { try {
@ -77,13 +81,16 @@ object TransactionUtils {
// Native KAVA transfer // Native KAVA transfer
Triple(params.to, kavaToWei(params.amount), ByteArray(0)) Triple(params.to, kavaToWei(params.amount), ByteArray(0))
} }
TokenType.GREEN_POINTS -> { TokenType.GREEN_POINTS, TokenType.ENERGY_POINTS, TokenType.FUTURE_POINTS -> {
// ERC-20 token transfer (绿积分) // ERC-20 token transfer
// To address is the contract, value is 0 // To address is the contract, value is 0
// Data is transfer(recipient, amount) encoded // 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) 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 // Default gas limits
when (params.tokenType) { when (params.tokenType) {
TokenType.KAVA -> BigInteger.valueOf(21000) 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 { private fun encodeErc20Transfer(to: String, amount: BigInteger): ByteArray {
// Function selector: transfer(address,uint256) = 0xa9059cbb // 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) // Encode recipient address (padded to 32 bytes)
val paddedAddress = to.removePrefix("0x").lowercase().padStart(64, '0').hexToByteArray() 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 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() 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 * Convert raw units to Green Points display amount
* @deprecated Use rawToToken(raw, 6) instead
*/ */
fun rawToGreenPoints(raw: BigInteger): String { fun rawToGreenPoints(raw: BigInteger): String {
val rawDecimal = BigDecimal(raw) return rawToToken(raw, GreenPointsToken.DECIMALS)
val displayDecimal = rawDecimal.divide(BigDecimal("1000000"), 6, java.math.RoundingMode.DOWN)
return displayDecimal.toPlainString()
} }
/** /**

View File

@ -11,7 +11,12 @@ import {
getCurrentRpcUrl, getCurrentRpcUrl,
getGasPrice, getGasPrice,
fetchGreenPointsBalance, fetchGreenPointsBalance,
fetchEnergyPointsBalance,
fetchFuturePointsBalance,
GREEN_POINTS_TOKEN, GREEN_POINTS_TOKEN,
ENERGY_POINTS_TOKEN,
FUTURE_POINTS_TOKEN,
TOKEN_CONFIG,
type PreparedTransaction, type PreparedTransaction,
type TokenType, type TokenType,
} from '../utils/transaction'; } from '../utils/transaction';
@ -32,6 +37,8 @@ interface ShareWithAddress extends ShareItem {
evmAddress?: string; evmAddress?: string;
kavaBalance?: string; kavaBalance?: string;
greenPointsBalance?: string; greenPointsBalance?: string;
energyPointsBalance?: string;
futurePointsBalance?: string;
balanceLoading?: boolean; balanceLoading?: boolean;
} }
@ -89,15 +96,30 @@ export default function Home() {
const [isCalculatingMax, setIsCalculatingMax] = useState(false); const [isCalculatingMax, setIsCalculatingMax] = useState(false);
const [copySuccess, setCopySuccess] = 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 费后的最大可转账金额 // 计算扣除 Gas 费后的最大可转账金额
const calculateMaxAmount = async () => { const calculateMaxAmount = async () => {
if (!transferShare?.evmAddress) return; if (!transferShare?.evmAddress) return;
setIsCalculatingMax(true); setIsCalculatingMax(true);
try { try {
if (transferTokenType === 'GREEN_POINTS') { if (TOKEN_CONFIG.isERC20(transferTokenType)) {
// For token transfers, use the full token balance (gas is paid in KAVA) // For ERC-20 token transfers, use the full token balance (gas is paid in KAVA)
const balance = transferShare.greenPointsBalance || '0'; const balance = getTokenBalance(transferShare, transferTokenType);
setTransferAmount(balance); setTransferAmount(balance);
setTransferError(null); setTransferError(null);
} else { } else {
@ -131,8 +153,8 @@ export default function Home() {
} }
} catch (error) { } catch (error) {
console.error('Failed to calculate max amount:', error); console.error('Failed to calculate max amount:', error);
if (transferTokenType === 'GREEN_POINTS') { if (TOKEN_CONFIG.isERC20(transferTokenType)) {
setTransferAmount(transferShare.greenPointsBalance || '0'); setTransferAmount(getTokenBalance(transferShare, transferTokenType));
} else { } else {
// 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000) // 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000)
const defaultGasFee = 0.000021; // ~21000 * 1 gwei const defaultGasFee = 0.000021; // ~21000 * 1 gwei
@ -165,12 +187,14 @@ export default function Home() {
const updatedShares = await Promise.all( const updatedShares = await Promise.all(
sharesWithAddrs.map(async (share) => { sharesWithAddrs.map(async (share) => {
if (share.evmAddress) { if (share.evmAddress) {
// Fetch both balances in parallel // Fetch all balances in parallel
const [kavaBalance, greenPointsBalance] = await Promise.all([ const [kavaBalance, greenPointsBalance, energyPointsBalance, futurePointsBalance] = await Promise.all([
fetchKavaBalance(share.evmAddress), fetchKavaBalance(share.evmAddress),
fetchGreenPointsBalance(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 }; return { ...share, balanceLoading: false };
}) })
@ -315,11 +339,7 @@ export default function Home() {
return '转账金额无效'; return '转账金额无效';
} }
const amount = parseFloat(transferAmount); const amount = parseFloat(transferAmount);
const balance = parseFloat( const balance = parseFloat(getTokenBalance(transferShare, transferTokenType));
transferTokenType === 'GREEN_POINTS'
? (transferShare?.greenPointsBalance || '0')
: (transferShare?.kavaBalance || '0')
);
if (amount > balance) { if (amount > balance) {
return '余额不足'; return '余额不足';
} }
@ -486,7 +506,7 @@ export default function Home() {
</div> </div>
)} )}
{/* 余额显示 - KAVA 和 绿积分 */} {/* 余额显示 - 所有代币 */}
{share.evmAddress && ( {share.evmAddress && (
<div className={styles.balanceSection}> <div className={styles.balanceSection}>
<div className={styles.balanceRow}> <div className={styles.balanceRow}>
@ -509,6 +529,26 @@ export default function Home() {
)} )}
</span> </span>
</div> </div>
<div className={styles.balanceRow}>
<span className={styles.balanceLabel} style={{ color: '#2196F3' }}>{ENERGY_POINTS_TOKEN.name}</span>
<span className={styles.balanceValue} style={{ color: '#2196F3' }}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.energyPointsBalance || '0'}</>
)}
</span>
</div>
<div className={styles.balanceRow}>
<span className={styles.balanceLabel} style={{ color: '#9C27B0' }}>{FUTURE_POINTS_TOKEN.name}</span>
<span className={styles.balanceValue} style={{ color: '#9C27B0' }}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.futurePointsBalance || '0'}</>
)}
</span>
</div>
</div> </div>
)} )}
@ -578,7 +618,10 @@ export default function Home() {
<div className={styles.transferWalletInfo}> <div className={styles.transferWalletInfo}>
<div className={styles.transferWalletName}>{transferShare.walletName}</div> <div className={styles.transferWalletName}>{transferShare.walletName}</div>
<div className={styles.transferWalletBalance}> <div className={styles.transferWalletBalance}>
KAVA: {transferShare.kavaBalance || '0'} | {GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'} KAVA: {transferShare.kavaBalance || '0'} | <span style={{color: '#4CAF50'}}>{GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'}</span>
</div>
<div className={styles.transferWalletBalance}>
<span style={{color: '#2196F3'}}>{ENERGY_POINTS_TOKEN.name}: {transferShare.energyPointsBalance || '0'}</span> | <span style={{color: '#9C27B0'}}>{FUTURE_POINTS_TOKEN.name}: {transferShare.futurePointsBalance || '0'}</span>
</div> </div>
<div className={styles.transferNetwork}> <div className={styles.transferNetwork}>
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'} 网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'}
@ -605,6 +648,22 @@ export default function Home() {
{GREEN_POINTS_TOKEN.name} {GREEN_POINTS_TOKEN.name}
</button> </button>
</div> </div>
<div className={styles.tokenTypeSelector} style={{ marginTop: '8px' }}>
<button
className={`${styles.tokenTypeButton} ${transferTokenType === 'ENERGY_POINTS' ? styles.tokenTypeActive : ''}`}
onClick={() => { setTransferTokenType('ENERGY_POINTS'); setTransferAmount(''); }}
style={transferTokenType === 'ENERGY_POINTS' ? { backgroundColor: '#2196F3', borderColor: '#2196F3' } : {}}
>
{ENERGY_POINTS_TOKEN.name}
</button>
<button
className={`${styles.tokenTypeButton} ${transferTokenType === 'FUTURE_POINTS' ? styles.tokenTypeActive : ''}`}
onClick={() => { setTransferTokenType('FUTURE_POINTS'); setTransferAmount(''); }}
style={transferTokenType === 'FUTURE_POINTS' ? { backgroundColor: '#9C27B0', borderColor: '#9C27B0' } : {}}
>
{FUTURE_POINTS_TOKEN.name}
</button>
</div>
</div> </div>
{/* 收款地址 */} {/* 收款地址 */}
@ -622,7 +681,7 @@ export default function Home() {
{/* 转账金额 */} {/* 转账金额 */}
<div className={styles.transferInputGroup}> <div className={styles.transferInputGroup}>
<label className={styles.transferLabel}> <label className={styles.transferLabel}>
({transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}) ({TOKEN_CONFIG.getName(transferTokenType)})
</label> </label>
<div className={styles.transferAmountWrapper}> <div className={styles.transferAmountWrapper}>
<input <input
@ -689,8 +748,8 @@ export default function Home() {
<div className={styles.confirmDetails}> <div className={styles.confirmDetails}>
<div className={styles.confirmRow}> <div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span> <span className={styles.confirmLabel}></span>
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}> <span className={styles.confirmValue} style={TOKEN_CONFIG.isERC20(transferTokenType) ? { color: transferTokenType === 'GREEN_POINTS' ? '#4CAF50' : transferTokenType === 'ENERGY_POINTS' ? '#2196F3' : '#9C27B0' } : {}}>
{transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'} {TOKEN_CONFIG.getName(transferTokenType)}
</span> </span>
</div> </div>
<div className={styles.confirmRow}> <div className={styles.confirmRow}>
@ -699,8 +758,8 @@ export default function Home() {
</div> </div>
<div className={styles.confirmRow}> <div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span> <span className={styles.confirmLabel}></span>
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}> <span className={styles.confirmValue} style={TOKEN_CONFIG.isERC20(transferTokenType) ? { color: transferTokenType === 'GREEN_POINTS' ? '#4CAF50' : transferTokenType === 'ENERGY_POINTS' ? '#2196F3' : '#9C27B0' } : {}}>
{transferAmount} {transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'} {transferAmount} {TOKEN_CONFIG.getName(transferTokenType)}
</span> </span>
</div> </div>
<div className={styles.confirmRow}> <div className={styles.confirmRow}>

View File

@ -17,17 +17,97 @@ export const KAVA_RPC_URL = {
}; };
// Token types // 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 = { export const GREEN_POINTS_TOKEN = {
contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3', contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
name: '绿积分', name: '绿积分',
symbol: 'dUSDT', symbol: 'dUSDT',
decimals: 6, decimals: 6,
// ERC-20 function selectors // ERC-20 function selectors (kept for backward compatibility)
balanceOfSelector: '0x70a08231', balanceOfSelector: ERC20_SELECTORS.balanceOf,
transferSelector: '0xa9059cbb', 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 读取或使用默认值) // 当前网络配置 (从 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 parts = amount.split('.');
const whole = BigInt(parts[0] || '0'); const whole = BigInt(parts[0] || '0');
let fraction = parts[1] || ''; let fraction = parts[1] || '';
// 补齐或截断到 6 位 // 补齐或截断到指定位数
if (fraction.length > 6) { if (fraction.length > decimals) {
fraction = fraction.substring(0, 6); fraction = fraction.substring(0, decimals);
} else { } 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 { export function rawToToken(raw: bigint, decimals: number = 6): string {
const rawStr = raw.toString().padStart(7, '0'); const rawStr = raw.toString().padStart(decimals + 1, '0');
const whole = rawStr.slice(0, -6) || '0'; const whole = rawStr.slice(0, -decimals) || '0';
const fraction = rawStr.slice(-6).replace(/0+$/, ''); const fraction = rawStr.slice(-decimals).replace(/0+$/, '');
return fraction ? `${whole}.${fraction}` : whole; return fraction ? `${whole}.${fraction}` : whole;
} }
/** /**
* 绿 (ERC-20) * 绿 (6 decimals)
* @deprecated Use tokenToRaw(amount, 6) instead
*/ */
export async function fetchGreenPointsBalance(address: string): Promise<string> { 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<string> {
try { try {
const rpcUrl = getCurrentRpcUrl(); const rpcUrl = getCurrentRpcUrl();
// Encode balanceOf(address) call data // Encode balanceOf(address) call data
// Function selector: 0x70a08231
// Address parameter: padded to 32 bytes
const paddedAddress = address.toLowerCase().replace('0x', '').padStart(64, '0'); 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, { const response = await fetch(rpcUrl, {
method: 'POST', method: 'POST',
@ -374,7 +479,7 @@ export async function fetchGreenPointsBalance(address: string): Promise<string>
method: 'eth_call', method: 'eth_call',
params: [ params: [
{ {
to: GREEN_POINTS_TOKEN.contractAddress, to: contractAddress,
data: callData, data: callData,
}, },
'latest', 'latest',
@ -386,21 +491,65 @@ export async function fetchGreenPointsBalance(address: string): Promise<string>
const data = await response.json(); const data = await response.json();
if (data.result && data.result !== '0x') { if (data.result && data.result !== '0x') {
const balanceRaw = BigInt(data.result); const balanceRaw = BigInt(data.result);
return rawToGreenPoints(balanceRaw); return rawToToken(balanceRaw, decimals);
} }
return '0'; return '0';
} catch (error) { } catch (error) {
console.error('Failed to fetch Green Points balance:', error); console.error('Failed to fetch ERC20 balance:', error);
return '0'; return '0';
} }
} }
/**
* 绿 (ERC-20)
*/
export async function fetchGreenPointsBalance(address: string): Promise<string> {
return fetchERC20Balance(address, GREEN_POINTS_TOKEN.contractAddress, GREEN_POINTS_TOKEN.decimals);
}
/**
* (eUSDT)
*/
export async function fetchEnergyPointsBalance(address: string): Promise<string> {
return fetchERC20Balance(address, ENERGY_POINTS_TOKEN.contractAddress, ENERGY_POINTS_TOKEN.decimals);
}
/**
* (fUSDT)
*/
export async function fetchFuturePointsBalance(address: string): Promise<string> {
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 * Encode ERC-20 transfer function call
*/ */
function encodeErc20Transfer(to: string, amount: bigint): string { function encodeErc20Transfer(to: string, amount: bigint): string {
// Function selector: transfer(address,uint256) = 0xa9059cbb // Function selector: transfer(address,uint256) = 0xa9059cbb
const selector = GREEN_POINTS_TOKEN.transferSelector; const selector = ERC20_SELECTORS.transfer;
// Encode recipient address (padded to 32 bytes) // Encode recipient address (padded to 32 bytes)
const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0'); const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0');
// Encode amount (padded to 32 bytes) // 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 // For token transfers, we need different params
let txParams: { from: string; to: string; value: string; data?: string }; 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 // 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); const transferData = encodeErc20Transfer(params.to, tokenAmount);
txParams = { txParams = {
from: params.from, from: params.from,
to: GREEN_POINTS_TOKEN.contractAddress, to: contractAddress!,
value: '0x0', value: '0x0',
data: transferData, data: transferData,
}; };
@ -511,7 +662,7 @@ export async function estimateGas(params: { from: string; to: string; value: str
if (data.error) { if (data.error) {
// 如果估算失败,使用默认值 // 如果估算失败,使用默认值
console.warn('Gas 估算失败,使用默认值:', 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); return BigInt(data.result);
} }
@ -543,12 +694,14 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
let value: bigint; let value: bigint;
let data: string; let data: string;
if (tokenType === 'GREEN_POINTS') { if (TOKEN_CONFIG.isERC20(tokenType)) {
// ERC-20 token transfer // ERC-20 token transfer
// To address is the contract, value is 0 // To address is the contract, value is 0
// Data is transfer(recipient, amount) encoded // Data is transfer(recipient, amount) encoded
const tokenAmount = greenPointsToRaw(params.value); const contractAddress = TOKEN_CONFIG.getContractAddress(tokenType);
toAddress = GREEN_POINTS_TOKEN.contractAddress.toLowerCase(); const decimals = TOKEN_CONFIG.getDecimals(tokenType);
const tokenAmount = tokenToRaw(params.value, decimals);
toAddress = contractAddress!.toLowerCase();
value = BigInt(0); value = BigInt(0);
data = encodeErc20Transfer(params.to, tokenAmount); data = encodeErc20Transfer(params.to, tokenAmount);
} else { } else {