feat(token): add Green Points (绿积分) ERC-20 token support

Add support for the dUSDT token "绿积分" (Green Points) on both Android
and Electron applications:

Android changes:
- Add TokenType enum and GreenPointsToken config in Models.kt
- Implement ERC-20 balance fetching and transfer encoding in TssRepository
- Update TransactionUtils with ERC-20 transfer support
- Add dual balance display (KAVA + 绿积分) in WalletsScreen
- Add token type selector in TransferScreen

Electron changes:
- Add TokenType and GREEN_POINTS_TOKEN config in transaction.ts
- Implement fetchGreenPointsBalance and ERC-20 transfer encoding
- Update Home.tsx with dual balance display and token selector
- Add token selector styles in Home.module.css

Token contract: 0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3 (Kava mainnet)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-02 03:01:05 -08:00
parent b3822e48eb
commit 9b9612bd5f
17 changed files with 1415 additions and 137 deletions

View File

@ -17,6 +17,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.durian.tssparty.domain.model.AppReadyState
import com.durian.tssparty.domain.model.TokenType
import com.durian.tssparty.presentation.components.BottomNavItem
import com.durian.tssparty.presentation.components.TssBottomNavigation
import com.durian.tssparty.presentation.screens.*
@ -63,6 +64,7 @@ fun TssPartyApp(
val settings by viewModel.settings.collectAsState()
val createdInviteCode by viewModel.createdInviteCode.collectAsState()
val balances by viewModel.balances.collectAsState()
val walletBalances by viewModel.walletBalances.collectAsState()
val currentSessionId by viewModel.currentSessionId.collectAsState()
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
val currentRound by viewModel.currentRound.collectAsState()
@ -167,6 +169,7 @@ fun TssPartyApp(
shares = shares,
isConnected = uiState.isConnected,
balances = balances,
walletBalances = walletBalances,
networkType = settings.networkType,
onDeleteShare = { viewModel.deleteShare(it) },
onRefreshBalance = { address -> viewModel.fetchBalance(address) },
@ -189,6 +192,7 @@ fun TssPartyApp(
TransferScreen(
wallet = wallet,
balance = balances[wallet.address],
walletBalance = walletBalances[wallet.address],
sessionStatus = sessionStatus,
participants = signParticipants,
currentRound = signCurrentRound,
@ -202,8 +206,8 @@ fun TssPartyApp(
error = uiState.error,
networkType = settings.networkType,
rpcUrl = settings.kavaRpcUrl,
onPrepareTransaction = { toAddress, amount ->
viewModel.prepareTransfer(shareId, toAddress, amount)
onPrepareTransaction = { toAddress, amount, tokenType ->
viewModel.prepareTransfer(shareId, toAddress, amount, tokenType)
},
onConfirmTransaction = {
viewModel.initiateSignSession(shareId, "")

View File

@ -1902,6 +1902,92 @@ class TssRepository @Inject constructor(
}
}
/**
* 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 withContext(Dispatchers.IO) {
try {
val client = okhttp3.OkHttpClient()
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
// Encode balanceOf(address) call data
// 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 requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{
"to": "${GreenPointsToken.CONTRACT_ADDRESS}",
"data": "$callData"
}, "latest"],
"id": 1
}
""".trimIndent()
val request = okhttp3.Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
// Parse JSON response
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
}
val hexBalance = json.get("result").asString
// Convert hex to decimal, then apply 6 decimals (dUSDT uses 6 decimals like USDT)
val rawBalance = java.math.BigInteger(hexBalance.removePrefix("0x"), 16)
val tokenBalance = java.math.BigDecimal(rawBalance).divide(
java.math.BigDecimal("1000000"), // 10^6 for 6 decimals
6,
java.math.RoundingMode.DOWN
)
Result.success(tokenBalance.toPlainString())
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Failed to get Green Points balance: ${e.message}")
Result.failure(e)
}
}
}
/**
* Get both KAVA and Green Points balances for an address
*/
suspend fun getWalletBalance(address: String, rpcUrl: String): Result<WalletBalance> {
return withContext(Dispatchers.IO) {
try {
// Fetch both balances in parallel
val kavaDeferred = async { getBalance(address, rpcUrl) }
val greenPointsDeferred = async { getGreenPointsBalance(address, rpcUrl) }
val kavaResult = kavaDeferred.await()
val greenPointsResult = greenPointsDeferred.await()
val kavaBalance = kavaResult.getOrDefault("0")
val greenPointsBalance = greenPointsResult.getOrDefault("0")
Result.success(WalletBalance(
address = address,
kavaBalance = kavaBalance,
greenPointsBalance = greenPointsBalance
))
} catch (e: Exception) {
Result.failure(e)
}
}
}
// ========== Transfer / Sign Session Methods ==========
/**
@ -1912,7 +1998,8 @@ class TssRepository @Inject constructor(
to: String,
amount: String,
rpcUrl: String,
chainId: Int = TransactionUtils.KAVA_TESTNET_CHAIN_ID
chainId: Int = TransactionUtils.KAVA_TESTNET_CHAIN_ID,
tokenType: TokenType = TokenType.KAVA
): Result<TransactionUtils.PreparedTransaction> {
return TransactionUtils.prepareTransaction(
TransactionUtils.TransactionParams(
@ -1920,7 +2007,8 @@ class TssRepository @Inject constructor(
to = to,
amount = amount,
rpcUrl = rpcUrl,
chainId = chainId
chainId = chainId,
tokenType = tokenType
)
)
}

View File

@ -123,3 +123,38 @@ enum class NetworkType {
MAINNET,
TESTNET
}
/**
* Token type for transfers
*/
enum class TokenType {
KAVA, // Native KAVA token
GREEN_POINTS // 绿积分 (dUSDT) ERC-20 token
}
/**
* Green Points (绿积分) Token Contract Configuration
* dUSDT - Fixed supply ERC-20 token on Kava EVM
*/
object GreenPointsToken {
const val CONTRACT_ADDRESS = "0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3"
const val NAME = "绿积分"
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()
}
/**
* Wallet balance containing both native and token balances
*/
data class WalletBalance(
val address: String,
val kavaBalance: String = "0", // Native KAVA balance
val greenPointsBalance: String = "0" // 绿积分 (dUSDT) balance
)

View File

@ -9,25 +9,34 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.ShareRecord
import com.durian.tssparty.domain.model.WalletBalance
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
shares: List<ShareRecord>,
walletBalances: Map<String, WalletBalance>,
isConnected: Boolean,
onNavigateToJoin: () -> Unit,
onNavigateToSign: (Long) -> Unit,
onNavigateToSettings: () -> Unit,
onDeleteShare: (Long) -> Unit
onDeleteShare: (Long) -> Unit,
onRefreshBalances: () -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("TSS Party") },
actions = {
// Refresh button
IconButton(onClick = onRefreshBalances) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
// Connection status indicator
Icon(
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
@ -94,6 +103,7 @@ fun HomeScreen(
items(shares) { share ->
WalletCard(
share = share,
walletBalance = walletBalances[share.address],
onSign = { onNavigateToSign(share.id) },
onDelete = { onDeleteShare(share.id) }
)
@ -107,6 +117,7 @@ fun HomeScreen(
@Composable
fun WalletCard(
share: ShareRecord,
walletBalance: WalletBalance?,
onSign: () -> Unit,
onDelete: () -> Unit
) {
@ -138,6 +149,42 @@ fun WalletCard(
}
}
Spacer(modifier = Modifier.height(12.dp))
// Balance section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// KAVA balance
Column {
Text(
text = "KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = walletBalance?.kavaBalance ?: "Loading...",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
// Green Points balance
Column(horizontalAlignment = Alignment.End) {
Text(
text = GreenPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = walletBalance?.greenPointsBalance ?: "Loading...",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(

View File

@ -27,9 +27,12 @@ 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.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.TokenType
import com.durian.tssparty.domain.model.WalletBalance
import com.durian.tssparty.util.TransactionUtils
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
@ -57,6 +60,7 @@ import java.math.BigInteger
fun TransferScreen(
wallet: ShareRecord,
balance: String?,
walletBalance: WalletBalance? = null,
sessionStatus: SessionStatus,
participants: List<String> = emptyList(),
currentRound: Int = 0,
@ -70,7 +74,7 @@ fun TransferScreen(
error: String? = null,
networkType: NetworkType = NetworkType.MAINNET,
rpcUrl: String = "https://evm.kava.io",
onPrepareTransaction: (toAddress: String, amount: String) -> Unit,
onPrepareTransaction: (toAddress: String, amount: String, tokenType: TokenType) -> Unit,
onConfirmTransaction: () -> Unit,
onCopyInviteCode: () -> Unit,
onBroadcastTransaction: () -> Unit,
@ -79,6 +83,7 @@ fun TransferScreen(
) {
var toAddress by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
var selectedTokenType by remember { mutableStateOf(TokenType.KAVA) }
var validationError by remember { mutableStateOf<String?>(null) }
// QR Scanner launcher for recipient address
@ -140,6 +145,9 @@ fun TransferScreen(
"input" -> TransferInputScreen(
wallet = wallet,
balance = balance,
walletBalance = walletBalance,
selectedTokenType = selectedTokenType,
onTokenTypeChange = { selectedTokenType = it },
toAddress = toAddress,
onToAddressChange = { toAddress = it },
amount = amount,
@ -147,15 +155,20 @@ fun TransferScreen(
error = validationError ?: error,
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
}
when {
toAddress.isBlank() -> validationError = "请输入收款地址"
!toAddress.startsWith("0x") || toAddress.length != 42 -> validationError = "地址格式不正确"
amount.isBlank() -> validationError = "请输入金额"
amount.toDoubleOrNull() == null || amount.toDouble() <= 0 -> validationError = "金额无效"
balance != null && amount.toDouble() > balance.toDouble() -> validationError = "余额不足"
currentBalance != null && amount.toDouble() > currentBalance.toDouble() -> validationError = "余额不足"
else -> {
validationError = null
onPrepareTransaction(toAddress.trim(), amount.trim())
onPrepareTransaction(toAddress.trim(), amount.trim(), selectedTokenType)
}
}
},
@ -226,6 +239,9 @@ fun TransferScreen(
private fun TransferInputScreen(
wallet: ShareRecord,
balance: String?,
walletBalance: WalletBalance?,
selectedTokenType: TokenType,
onTokenTypeChange: (TokenType) -> Unit,
toAddress: String,
onToAddressChange: (String) -> Unit,
amount: String,
@ -238,6 +254,17 @@ private fun TransferInputScreen(
) {
val scope = rememberCoroutineScope()
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
}
Column(
modifier = Modifier
.fillMaxSize()
@ -264,25 +291,96 @@ private fun TransferInputScreen(
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
// Show both balances
Row(
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "余额: ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (balance != null) "$balance KAVA" else "加载中...",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
// 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)
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
// Token type selector
Text(
text = "选择转账类型",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// KAVA option
FilterChip(
selected = selectedTokenType == TokenType.KAVA,
onClick = { onTokenTypeChange(TokenType.KAVA) },
label = { Text("KAVA") },
leadingIcon = {
if (selectedTokenType == TokenType.KAVA) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
},
modifier = Modifier.weight(1f)
)
// Green Points option
FilterChip(
selected = selectedTokenType == TokenType.GREEN_POINTS,
onClick = { onTokenTypeChange(TokenType.GREEN_POINTS) },
label = { Text(GreenPointsToken.NAME) },
leadingIcon = {
if (selectedTokenType == TokenType.GREEN_POINTS) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF4CAF50).copy(alpha = 0.2f),
selectedLabelColor = Color(0xFF4CAF50)
),
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(16.dp))
// Recipient address with QR scan button
OutlinedTextField(
@ -308,30 +406,40 @@ private fun TransferInputScreen(
Spacer(modifier = Modifier.height(16.dp))
// Amount
// Amount with dynamic label based on token type
OutlinedTextField(
value = amount,
onValueChange = onAmountChange,
label = { Text("金额 (KAVA)") },
label = { Text("金额 ($tokenSymbol)") },
placeholder = { Text("0.0") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
leadingIcon = {
Icon(Icons.Default.AttachMoney, contentDescription = null)
Icon(
if (selectedTokenType == TokenType.GREEN_POINTS) Icons.Default.Stars else Icons.Default.AttachMoney,
contentDescription = null,
tint = if (selectedTokenType == TokenType.GREEN_POINTS) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingIcon = {
if (balance != null) {
if (currentBalance != null) {
TextButton(
onClick = {
scope.launch {
isCalculatingMax = true
val result = TransactionUtils.calculateMaxTransferAmount(balance, rpcUrl)
result.onSuccess { maxAmount ->
onAmountChange(maxAmount)
}.onFailure {
// Fallback to balance if calculation fails
onAmountChange(balance)
if (selectedTokenType == TokenType.KAVA) {
// For KAVA, calculate max after deducting gas
val result = TransactionUtils.calculateMaxTransferAmount(currentBalance, rpcUrl)
result.onSuccess { maxAmount ->
onAmountChange(maxAmount)
}.onFailure {
// Fallback to balance if calculation fails
onAmountChange(currentBalance)
}
} else {
// For tokens, use the full balance
onAmountChange(currentBalance)
}
isCalculatingMax = false
}

View File

@ -35,8 +35,10 @@ 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.GreenPointsToken
import com.durian.tssparty.domain.model.NetworkType
import com.durian.tssparty.domain.model.ShareRecord
import com.durian.tssparty.domain.model.WalletBalance
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.delay
@ -48,6 +50,7 @@ fun WalletsScreen(
shares: List<ShareRecord>,
isConnected: Boolean,
balances: Map<String, String> = emptyMap(),
walletBalances: Map<String, WalletBalance> = emptyMap(),
networkType: NetworkType = NetworkType.MAINNET,
onDeleteShare: (Long) -> Unit,
onRefreshBalance: ((String) -> Unit)? = null,
@ -146,6 +149,7 @@ fun WalletsScreen(
WalletItemCard(
share = share,
balance = balances[share.address],
walletBalance = walletBalances[share.address],
onViewDetails = { selectedWallet = share },
onTransfer = {
onTransfer?.invoke(share.id)
@ -196,6 +200,7 @@ fun WalletsScreen(
private fun WalletItemCard(
share: ShareRecord,
balance: String? = null,
walletBalance: WalletBalance? = null,
onViewDetails: () -> Unit,
onTransfer: () -> Unit,
onDelete: () -> Unit
@ -256,31 +261,63 @@ private fun WalletItemCard(
Spacer(modifier = Modifier.height(12.dp))
// Balance display
// Balance display - now shows both KAVA and Green Points
Row(
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
Icons.Default.AccountBalance,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
if (balance != null) {
// KAVA balance
Column {
Text(
text = "$balance KAVA",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
} else {
// Loading state
Text(
text = "加载中...",
style = MaterialTheme.typography.bodyMedium,
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) // Green color for Green Points
)
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
)
}
}
}

View File

@ -1034,22 +1034,31 @@ class MainViewModel @Inject constructor(
_createdInviteCode.value = null
}
// Wallet balances cache
// Wallet balances cache (KAVA only - deprecated, use walletBalances instead)
private val _balances = MutableStateFlow<Map<String, String>>(emptyMap())
val balances: StateFlow<Map<String, String>> = _balances.asStateFlow()
// Combined wallet balances (KAVA + Green Points)
private val _walletBalances = MutableStateFlow<Map<String, WalletBalance>>(emptyMap())
val walletBalances: StateFlow<Map<String, WalletBalance>> = _walletBalances.asStateFlow()
/**
* Fetch balance for a wallet using share data (handles both EVM and Cosmos address formats)
* Now fetches both KAVA and Green Points (绿积分) balances
*/
fun fetchBalanceForShare(share: ShareRecord) {
viewModelScope.launch {
val rpcUrl = _settings.value.kavaRpcUrl
// Ensure we use EVM address format for RPC calls
val evmAddress = AddressUtils.getEvmAddress(share.address, share.publicKey)
val result = repository.getBalance(evmAddress, rpcUrl)
result.onSuccess { balance ->
// Store balance with original address as key (for UI lookup)
_balances.update { it + (share.address to balance) }
// Fetch combined wallet balance (KAVA + Green Points)
val result = repository.getWalletBalance(evmAddress, rpcUrl)
result.onSuccess { walletBalance ->
// Store with original address as key (for UI lookup)
_walletBalances.update { it + (share.address to walletBalance) }
// Also update legacy balances map for backward compatibility
_balances.update { it + (share.address to walletBalance.kavaBalance) }
}
}
}
@ -1060,9 +1069,10 @@ class MainViewModel @Inject constructor(
fun fetchBalance(address: String) {
viewModelScope.launch {
val rpcUrl = _settings.value.kavaRpcUrl
val result = repository.getBalance(address, rpcUrl)
result.onSuccess { balance ->
_balances.update { it + (address to balance) }
val result = repository.getWalletBalance(address, rpcUrl)
result.onSuccess { walletBalance ->
_walletBalances.update { it + (address to walletBalance) }
_balances.update { it + (address to walletBalance.kavaBalance) }
}
}
}
@ -1110,10 +1120,10 @@ class MainViewModel @Inject constructor(
/**
* Prepare a transfer transaction
*/
fun prepareTransfer(shareId: Long, toAddress: String, amount: String) {
fun prepareTransfer(shareId: Long, toAddress: String, amount: String, tokenType: TokenType = TokenType.KAVA) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
_transferState.update { it.copy(shareId = shareId, toAddress = toAddress, amount = amount) }
_transferState.update { it.copy(shareId = shareId, toAddress = toAddress, amount = amount, tokenType = tokenType) }
val share = repository.getShareById(shareId)
if (share == null) {
@ -1133,7 +1143,8 @@ class MainViewModel @Inject constructor(
to = toAddress,
amount = amount,
rpcUrl = rpcUrl,
chainId = chainId
chainId = chainId,
tokenType = tokenType
)
result.fold(
@ -1347,7 +1358,8 @@ data class MainUiState(
data class TransferState(
val shareId: Long = 0,
val toAddress: String = "",
val amount: String = ""
val amount: String = "",
val tokenType: TokenType = TokenType.KAVA
)
/**

View File

@ -1,5 +1,7 @@
package com.durian.tssparty.util
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.TokenType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@ -50,14 +52,16 @@ object TransactionUtils {
data class TransactionParams(
val from: String,
val to: String,
val amount: String, // In KAVA (not wei)
val amount: String, // In KAVA or token units (not wei)
val rpcUrl: String,
val chainId: Int = KAVA_TESTNET_CHAIN_ID
val chainId: Int = KAVA_TESTNET_CHAIN_ID,
val tokenType: TokenType = TokenType.KAVA // Token type for transfer
)
/**
* 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 (绿积分)
*/
suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
try {
@ -67,16 +71,36 @@ object TransactionUtils {
// 2. Get gas price
val gasPrice = getGasPrice(params.rpcUrl).getOrThrow()
// 3. Convert amount to wei (1 KAVA = 10^18 wei)
val valueWei = kavaToWei(params.amount)
// 3. Prepare transaction based on token type
val (toAddress, valueWei, txData) = when (params.tokenType) {
TokenType.KAVA -> {
// Native KAVA transfer
Triple(params.to, kavaToWei(params.amount), ByteArray(0))
}
TokenType.GREEN_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 transferData = encodeErc20Transfer(params.to, tokenAmount)
Triple(GreenPointsToken.CONTRACT_ADDRESS, BigInteger.ZERO, transferData)
}
}
// 4. Estimate gas
val gasLimit = estimateGas(
val gasLimit = estimateGasWithData(
from = params.from,
to = params.to,
to = toAddress,
value = valueWei,
data = txData,
rpcUrl = params.rpcUrl
).getOrElse { BigInteger.valueOf(21000) } // Default for simple transfer
).getOrElse {
// Default gas limits
when (params.tokenType) {
TokenType.KAVA -> BigInteger.valueOf(21000)
TokenType.GREEN_POINTS -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas
}
}
// 5. RLP encode for signing (Legacy Type 0 format)
// Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
@ -84,9 +108,9 @@ object TransactionUtils {
nonce = nonce,
gasPrice = gasPrice,
gasLimit = gasLimit,
to = params.to,
to = toAddress,
value = valueWei,
data = ByteArray(0),
data = txData,
chainId = params.chainId
)
@ -97,9 +121,10 @@ object TransactionUtils {
nonce = nonce,
gasPrice = gasPrice,
gasLimit = gasLimit,
to = params.to,
to = toAddress,
from = params.from,
value = valueWei,
data = txData,
chainId = params.chainId,
signHash = "0x" + signHash.toHexString(),
rawTxForSigning = rawTxForSigning
@ -109,6 +134,41 @@ object TransactionUtils {
}
}
/**
* Encode ERC-20 transfer(address,uint256) function call
*/
private fun encodeErc20Transfer(to: String, amount: BigInteger): ByteArray {
// Function selector: transfer(address,uint256) = 0xa9059cbb
val selector = GreenPointsToken.TRANSFER_SELECTOR.removePrefix("0x").hexToByteArray()
// Encode recipient address (padded to 32 bytes)
val paddedAddress = to.removePrefix("0x").lowercase().padStart(64, '0').hexToByteArray()
// Encode amount (padded to 32 bytes)
val amountHex = amount.toString(16).padStart(64, '0')
val paddedAmount = amountHex.hexToByteArray()
return selector + paddedAddress + paddedAmount
}
/**
* Convert Green Points amount to raw units (6 decimals)
*/
fun greenPointsToRaw(amount: String): BigInteger {
val decimal = BigDecimal(amount)
val rawDecimal = decimal.multiply(BigDecimal("1000000")) // 10^6
return rawDecimal.toBigInteger()
}
/**
* Convert raw units to Green Points display amount
*/
fun rawToGreenPoints(raw: BigInteger): String {
val rawDecimal = BigDecimal(raw)
val displayDecimal = rawDecimal.divide(BigDecimal("1000000"), 6, java.math.RoundingMode.DOWN)
return displayDecimal.toPlainString()
}
/**
* Finalize transaction with signature
* Returns the signed raw transaction hex string ready for broadcast
@ -297,9 +357,20 @@ object TransactionUtils {
to: String,
value: BigInteger,
rpcUrl: String
): Result<BigInteger> = withContext(Dispatchers.IO) {
estimateGasWithData(from, to, value, ByteArray(0), rpcUrl)
}
private suspend fun estimateGasWithData(
from: String,
to: String,
value: BigInteger,
data: ByteArray,
rpcUrl: String
): Result<BigInteger> = withContext(Dispatchers.IO) {
try {
val valueHex = "0x" + value.toString(16)
val dataHex = if (data.isEmpty()) "" else "\"data\": \"0x${data.toHexString()}\","
val requestBody = """
{
"jsonrpc": "2.0",
@ -307,7 +378,7 @@ object TransactionUtils {
"params": [{
"from": "$from",
"to": "$to",
"value": "$valueHex"
"value": "$valueHex"${if (dataHex.isNotEmpty()) ",\n ${dataHex.trimEnd(',')}" else ""}
}],
"id": 1
}

View File

@ -0,0 +1,115 @@
# Durian USDT (dUSDT) 代币合约
## 合约概述
Durian USDT 是一个部署在 Kava EVM 主网上的固定供应量 ERC-20 代币。该合约**完全禁止增发**,所有代币在部署时一次性铸造给部署者地址。
## 合约详情
| 项目 | 值 |
|------|-----|
| 合约地址 | `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3` |
| 代币名称 | Durian USDT |
| 代币符号 | dUSDT |
| 精度 (Decimals) | 6 |
| 总供应量 | 1,000,000,000,000 dUSDT (1万亿) |
| 总供应量 (最小单位) | 1,000,000,000,000,000,000 (10^18) |
## 网络信息
| 项目 | 值 |
|------|-----|
| 网络名称 | Kava EVM Mainnet |
| Chain ID | 2222 |
| RPC URL | https://evm.kava.io |
| 区块浏览器 | https://kavascan.com |
| 原生代币 | KAVA |
## 持有人/管理人信息
| 项目 | 值 |
|------|-----|
| 地址 | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
| 私钥 | `0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a` |
| 初始 dUSDT 余额 | 1,000,000,000,000 dUSDT (全部) |
> **安全警告**: 私钥必须妥善保管,切勿泄露给他人。
## 部署信息
| 项目 | 值 |
|------|-----|
| 部署时间 | 2026-01-02 |
| 部署交易哈希 | `0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d` |
| Solidity 版本 | 0.8.19 |
| EVM 版本 | Paris (无 PUSH0 操作码) |
| 优化 | 启用 (runs: 200) |
## 合约特性
### 固定供应量 - 无增发机制
该合约的核心特性是**完全禁止增发**
1. **无 mint 函数**: 合约代码中不存在任何铸造新代币的函数
2. **无 owner/admin 权限**: 合约没有特权角色,无人能修改供应量
3. **供应量在构造函数中固定**: 所有代币在部署时一次性创建
4. **totalSupply 是 constant**: 总供应量声明为常量,无法修改
### 支持的 ERC-20 标准函数
| 函数 | 描述 |
|------|------|
| `name()` | 返回代币名称 "Durian USDT" |
| `symbol()` | 返回代币符号 "dUSDT" |
| `decimals()` | 返回精度 6 |
| `totalSupply()` | 返回总供应量 |
| `balanceOf(address)` | 查询地址余额 |
| `transfer(address, uint256)` | 转账 |
| `approve(address, uint256)` | 授权 |
| `allowance(address, address)` | 查询授权额度 |
| `transferFrom(address, address, uint256)` | 授权转账 |
## 查看链接
- 合约: https://kavascan.com/address/0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3
- 持有人: https://kavascan.com/address/0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
- 部署交易: https://kavascan.com/tx/0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d
## 在钱包中添加代币
在 MetaMask 或其他钱包中添加自定义代币:
1. 网络: Kava EVM (Chain ID: 2222)
2. 合约地址: `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3`
3. 代币符号: `dUSDT`
4. 精度: `6`
## 文件说明
| 文件 | 描述 |
|------|------|
| `DurianUSDT.sol` | Solidity 源代码 |
| `DurianUSDT.abi` | 合约 ABI (Application Binary Interface) |
| `DurianUSDT.bin` | 编译后的字节码 |
| `CONTRACT_INFO.md` | 本文档 |
## 代码审计要点
该合约经过精简设计,关键安全特性:
1. **无 owner 模式**: 没有特权地址可以执行管理操作
2. **无升级机制**: 合约不可升级,代码永久固定
3. **无暂停功能**: 转账功能无法被暂停
4. **无黑名单功能**: 没有地址可以被限制转账
5. **使用 unchecked 块**: 在已验证的情况下使用,节省 gas
## 与标准 USDT 的对比
| 特性 | dUSDT | 标准 USDT |
|------|-------|----------|
| 精度 | 6 | 6 |
| 可增发 | 否 | 是 |
| 可暂停 | 否 | 是 |
| 黑名单功能 | 否 | 是 |
| 中心化管理 | 否 | 是 |

View File

@ -0,0 +1,229 @@
[
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Approval",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Transfer",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "address",
"name": "spender",
"type": "address"
}
],
"name": "allowance",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "approve",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "decimals",
"outputs": [
{
"internalType": "uint8",
"name": "",
"type": "uint8"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "name",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "symbol",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalSupply",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transfer",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transferFrom",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
}
]

View File

@ -0,0 +1 @@
608060405234801561001057600080fd5b5033600081815260208181526040808320670de0b6b3a76400009081905590519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a36106fb8061006d6000396000f3fe608060405234801561001057600080fd5b50600436106100935760003560e01c8063313ce56711610066578063313ce5671461012b57806370a082311461014557806395d89b411461016e578063a9059cbb14610192578063dd62ed3e146101a557600080fd5b806306fdde0314610098578063095ea7b3146100d857806318160ddd146100fb57806323b872dd14610118575b600080fd5b6100c26040518060400160405280600b81526020016a111d5c9a585b881554d11560aa1b81525081565b6040516100cf91906105a0565b60405180910390f35b6100eb6100e636600461060a565b6101de565b60405190151581526020016100cf565b61010a670de0b6b3a764000081565b6040519081526020016100cf565b6100eb610126366004610634565b6102a0565b610133600681565b60405160ff90911681526020016100cf565b61010a610153366004610670565b6001600160a01b031660009081526020819052604090205490565b6100c260405180604001604052806005815260200164191554d11560da1b81525081565b6100eb6101a036600461060a565b61049a565b61010a6101b3366004610692565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b60006001600160a01b03831661023b5760405162461bcd60e51b815260206004820152601760248201527f417070726f766520746f207a65726f206164647265737300000000000000000060448201526064015b60405180910390fd5b3360008181526001602090815260408083206001600160a01b03881680855290835292819020869055518581529192917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92591015b60405180910390a350600192915050565b60006001600160a01b0384166102f85760405162461bcd60e51b815260206004820152601a60248201527f5472616e736665722066726f6d207a65726f20616464726573730000000000006044820152606401610232565b6001600160a01b0383166103495760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b6001600160a01b0384166000908152602081905260409020548211156103a85760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b6001600160a01b03841660009081526001602090815260408083203384529091529020548211156104145760405162461bcd60e51b8152602060048201526016602482015275496e73756666696369656e7420616c6c6f77616e636560501b6044820152606401610232565b6001600160a01b03848116600081815260208181526040808320805488900390559387168083528483208054880190558383526001825284832033845282529184902080548790039055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b60006001600160a01b0383166104ed5760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b336000908152602081905260409020548211156105435760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b33600081815260208181526040808320805487900390556001600160a01b03871680845292819020805487019055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910161028f565b600060208083528351808285015260005b818110156105cd578581018301518582016040015282016105b1565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b038116811461060557600080fd5b919050565b6000806040838503121561061d57600080fd5b610626836105ee565b946020939093013593505050565b60008060006060848603121561064957600080fd5b610652846105ee565b9250610660602085016105ee565b9150604084013590509250925092565b60006020828403121561068257600080fd5b61068b826105ee565b9392505050565b600080604083850312156106a557600080fd5b6106ae836105ee565b91506106bc602084016105ee565b9050925092905056fea264697066735822122028c97073f6e7db0ad943d101cb6873b31c3eb19bcea3eda83148447ab676a5ee64736f6c63430008130033

View File

@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
/**
* @title DurianUSDT
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
* Total Supply: 1,000,000,000,000 (1 Trillion) tokens with 6 decimals (matching USDT)
*
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
* All tokens are minted to the deployer at construction time.
*/
contract DurianUSDT {
string public constant name = "Durian USDT";
string public constant symbol = "dUSDT";
uint8 public constant decimals = 6;
// Fixed total supply: 1 trillion tokens (1,000,000,000,000 * 10^6)
uint256 public constant totalSupply = 1_000_000_000_000 * 10**6;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
/**
* @dev Constructor - mints entire fixed supply to deployer
* No mint function exists - supply is permanently fixed
*/
constructor() {
_balances[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(_balances[msg.sender] >= amount, "Insufficient balance");
unchecked {
_balances[msg.sender] -= amount;
_balances[to] += amount;
}
emit Transfer(msg.sender, to, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Approve to zero address");
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(from != address(0), "Transfer from zero address");
require(to != address(0), "Transfer to zero address");
require(_balances[from] >= amount, "Insufficient balance");
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
unchecked {
_balances[from] -= amount;
_balances[to] += amount;
_allowances[from][msg.sender] -= amount;
}
emit Transfer(from, to, amount);
return true;
}
}

View File

@ -0,0 +1,120 @@
# Kava EVM 网络配置
## 主网配置
| 项目 | 值 |
|------|-----|
| 网络名称 | Kava EVM Mainnet |
| Chain ID | 2222 |
| Currency Symbol | KAVA |
| RPC URL | https://evm.kava.io |
| WebSocket URL | wss://wevm.kava.io |
| 区块浏览器 | https://kavascan.com |
## 测试网配置
| 项目 | 值 |
|------|-----|
| 网络名称 | Kava EVM Testnet |
| Chain ID | 2221 |
| Currency Symbol | KAVA |
| RPC URL | https://evm.testnet.kava.io |
| 区块浏览器 | https://testnet.kavascan.com |
| 水龙头 | https://faucet.kava.io |
## RPC 端点列表
### 主网 RPC
```
https://evm.kava.io
https://kava-evm.publicnode.com
https://kava.api.onfinality.io/public
https://evm.kava.chainstacklabs.com
```
### WebSocket (主网)
```
wss://wevm.kava.io
wss://kava-evm.publicnode.com
```
## Gas 配置
| 项目 | 值 |
|------|-----|
| Gas Price | ~1 Gwei (动态) |
| 合约部署 Gas Limit | ~500,000 - 1,000,000 |
| 代币转账 Gas Limit | ~65,000 |
| 原生转账 Gas Limit | ~21,000 |
## 在 MetaMask 中添加网络
### 主网
1. 打开 MetaMask
2. 点击网络选择器 > 添加网络
3. 填写以下信息:
- 网络名称: `Kava EVM`
- RPC URL: `https://evm.kava.io`
- Chain ID: `2222`
- 货币符号: `KAVA`
- 区块浏览器: `https://kavascan.com`
### 测试网
1. 打开 MetaMask
2. 点击网络选择器 > 添加网络
3. 填写以下信息:
- 网络名称: `Kava EVM Testnet`
- RPC URL: `https://evm.testnet.kava.io`
- Chain ID: `2221`
- 货币符号: `KAVA`
- 区块浏览器: `https://testnet.kavascan.com`
## Kava 双地址系统
Kava 网络支持两种地址格式:
| 类型 | 格式 | 示例 |
|------|------|------|
| Cosmos 地址 | kava1... | `kava1...abc` |
| EVM 地址 | 0x... | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
同一个私钥可以派生出两种地址,它们共享相同的余额。
## EVM 兼容性
Kava EVM 兼容以太坊 EVM支持:
- Solidity 智能合约
- ERC-20/ERC-721/ERC-1155 代币标准
- Web3.js / ethers.js
- MetaMask 等以太坊钱包
### 注意事项
- Kava EVM **不支持 PUSH0 操作码** (Shanghai 升级的特性)
- 编译合约时需要使用 `evmVersion: "paris"` 或更早版本
- 推荐使用 Solidity 0.8.19 或更早版本
## 常用合约地址
### 主网
| 代币 | 地址 |
|------|------|
| WKAVA (Wrapped KAVA) | `0xc86c7C0eFbd6A49B35E8714C5f59D99De09A225b` |
| USDT | `0x919C1c267BC06a7039e03fcc2eF738525769109c` |
| USDC | `0xfA9343C3897324496A05fC75abeD6bAC29f8A40f` |
| DAI | `0x765277EebeCA2e31912C9946eAe1021199B39C61` |
| dUSDT (Durian USDT) | `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3` |
## 资源链接
- 官网: https://www.kava.io/
- 文档: https://docs.kava.io/
- GitHub: https://github.com/Kava-Labs
- 区块浏览器: https://kavascan.com/
- Discord: https://discord.com/invite/kQzh3Uv

View File

@ -0,0 +1,83 @@
# 钱包密钥信息
> **重要安全警告**: 本文件包含私钥,仅供内部使用。切勿将此文件提交到公开仓库或分享给他人。
## 管理员钱包
该钱包用于部署和管理 dUSDT 代币合约。
### 地址信息
| 项目 | 值 |
|------|-----|
| EVM 地址 | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
| 私钥 | `0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a` |
### 余额信息
| 代币 | 余额 | 备注 |
|------|------|------|
| KAVA | ~0.45 KAVA | 用于支付 Gas 费用 |
| dUSDT | 1,000,000,000,000 | 1万亿全部供应量 |
### 查看链接
- 地址: https://kavascan.com/address/0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
## 导入钱包
### MetaMask
1. 打开 MetaMask
2. 点击账户图标 > 导入账户
3. 选择类型: 私钥
4. 粘贴私钥: `886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
5. 点击导入
### ethers.js
```javascript
import { ethers } from 'ethers';
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
const provider = new ethers.JsonRpcProvider('https://evm.kava.io');
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
console.log('Address:', wallet.address);
// Output: 0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
```
### Android/Kotlin
```kotlin
// 使用 Web3j 或其他库
val privateKey = "886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a"
val credentials = Credentials.create(privateKey)
println("Address: ${credentials.address}")
// Output: 0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
```
## 地址派生
该地址是通过以下步骤从私钥派生的:
1. 私钥 (32 bytes): `886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
2. 公钥 (65 bytes, uncompressed): `047e0b2f84204a2f859f51be78e09af3c504e9525f49d8ab1c537ab9c2a4deb28c3b16870449f50b9b79e959649a78144a5329958a95f6697534be0156b421588b`
3. Keccak-256(公钥[1:65])
4. 取后 20 bytes: `4f7e78d6b7c5fc502ec7039848690f08c8970f1e`
5. 添加 0x 前缀: `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` (含校验和)
## 安全建议
1. **备份私钥**: 将私钥安全存储在离线环境中
2. **不要分享**: 永远不要将私钥分享给任何人
3. **不要提交**: 确保 .gitignore 包含此文件
4. **硬件钱包**: 考虑将大额资产转移到硬件钱包
5. **多签**: 对于生产环境,考虑使用多签钱包
## 相关交易
### 合约部署交易
- 交易哈希: `0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d`
- 查看: https://kavascan.com/tx/0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d

View File

@ -377,14 +377,20 @@
/* Balance Section */
.balanceSection {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: column;
gap: var(--spacing-sm);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-md);
}
.balanceRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.balanceLabel {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
@ -519,6 +525,42 @@
color: white;
}
/* Token Type Selector */
.tokenTypeSelector {
display: flex;
gap: var(--spacing-sm);
}
.tokenTypeButton {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.tokenTypeButton:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.tokenTypeActive {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.tokenTypeActive:hover {
background-color: var(--primary-light);
border-color: var(--primary-light);
color: white;
}
.transferError {
background-color: rgba(220, 53, 69, 0.1);
color: #dc3545;

View File

@ -10,7 +10,10 @@ import {
getCurrentNetwork,
getCurrentRpcUrl,
getGasPrice,
fetchGreenPointsBalance,
GREEN_POINTS_TOKEN,
type PreparedTransaction,
type TokenType,
} from '../utils/transaction';
interface ShareItem {
@ -28,6 +31,7 @@ interface ShareItem {
interface ShareWithAddress extends ShareItem {
evmAddress?: string;
kavaBalance?: string;
greenPointsBalance?: string;
balanceLoading?: boolean;
}
@ -78,6 +82,7 @@ export default function Home() {
const [transferTo, setTransferTo] = useState('');
const [transferAmount, setTransferAmount] = useState('');
const [transferPassword, setTransferPassword] = useState('');
const [transferTokenType, setTransferTokenType] = useState<TokenType>('KAVA');
const [transferStep, setTransferStep] = useState<'input' | 'confirm' | 'preparing' | 'error'>('input');
const [transferError, setTransferError] = useState<string | null>(null);
const [preparedTx, setPreparedTx] = useState<PreparedTransaction | null>(null);
@ -86,44 +91,56 @@ export default function Home() {
// 计算扣除 Gas 费后的最大可转账金额
const calculateMaxAmount = async () => {
if (!transferShare?.kavaBalance || !transferShare?.evmAddress) return;
if (!transferShare?.evmAddress) return;
setIsCalculatingMax(true);
try {
const balance = parseFloat(transferShare.kavaBalance);
if (balance <= 0) {
setTransferAmount('0');
return;
}
// 获取当前 gas 价格
const { maxFeePerGas } = await getGasPrice();
// 简单转账的 gas 限制是 21000
const gasLimit = BigInt(21000);
const gasFee = maxFeePerGas * gasLimit;
// 转换为 KAVA (18 位小数)
const gasFeeKava = Number(gasFee) / 1e18;
// 计算最大可转账金额 = 余额 - Gas费
const maxAmount = balance - gasFeeKava;
if (maxAmount <= 0) {
setTransferError('余额不足以支付 Gas 费');
setTransferAmount('0');
} else {
// 保留 6 位小数,向下取整避免精度问题
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
setTransferAmount(formattedMax.toString());
if (transferTokenType === 'GREEN_POINTS') {
// For token transfers, use the full token balance (gas is paid in KAVA)
const balance = transferShare.greenPointsBalance || '0';
setTransferAmount(balance);
setTransferError(null);
} else {
// For KAVA transfers, deduct gas fee
const balance = parseFloat(transferShare.kavaBalance || '0');
if (balance <= 0) {
setTransferAmount('0');
return;
}
// 获取当前 gas 价格
const { maxFeePerGas } = await getGasPrice();
// 简单转账的 gas 限制是 21000
const gasLimit = BigInt(21000);
const gasFee = maxFeePerGas * gasLimit;
// 转换为 KAVA (18 位小数)
const gasFeeKava = Number(gasFee) / 1e18;
// 计算最大可转账金额 = 余额 - Gas费
const maxAmount = balance - gasFeeKava;
if (maxAmount <= 0) {
setTransferError('余额不足以支付 Gas 费');
setTransferAmount('0');
} else {
// 保留 6 位小数,向下取整避免精度问题
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
setTransferAmount(formattedMax.toString());
setTransferError(null);
}
}
} catch (error) {
console.error('Failed to calculate max amount:', error);
// 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000)
const defaultGasFee = 0.000021; // ~21000 * 1 gwei
const balance = parseFloat(transferShare.kavaBalance);
const maxAmount = Math.max(0, balance - defaultGasFee);
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
setTransferAmount(formattedMax.toString());
if (transferTokenType === 'GREEN_POINTS') {
setTransferAmount(transferShare.greenPointsBalance || '0');
} else {
// 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000)
const defaultGasFee = 0.000021; // ~21000 * 1 gwei
const balance = parseFloat(transferShare.kavaBalance || '0');
const maxAmount = Math.max(0, balance - defaultGasFee);
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
setTransferAmount(formattedMax.toString());
}
} finally {
setIsCalculatingMax(false);
}
@ -143,13 +160,17 @@ export default function Home() {
return sharesWithAddresses;
}, []);
// 单独获取所有钱包的余额
// 单独获取所有钱包的余额 (KAVA 和 绿积分)
const fetchAllBalances = useCallback(async (sharesWithAddrs: ShareWithAddress[]) => {
const updatedShares = await Promise.all(
sharesWithAddrs.map(async (share) => {
if (share.evmAddress) {
const kavaBalance = await fetchKavaBalance(share.evmAddress);
return { ...share, kavaBalance, balanceLoading: false };
// Fetch both balances in parallel
const [kavaBalance, greenPointsBalance] = await Promise.all([
fetchKavaBalance(share.evmAddress),
fetchGreenPointsBalance(share.evmAddress),
]);
return { ...share, kavaBalance, greenPointsBalance, balanceLoading: false };
}
return { ...share, balanceLoading: false };
})
@ -265,6 +286,7 @@ export default function Home() {
setTransferTo('');
setTransferAmount('');
setTransferPassword('');
setTransferTokenType('KAVA');
setTransferStep('input');
setTransferError(null);
setPreparedTx(null);
@ -293,7 +315,11 @@ export default function Home() {
return '转账金额无效';
}
const amount = parseFloat(transferAmount);
const balance = parseFloat(transferShare?.kavaBalance || '0');
const balance = parseFloat(
transferTokenType === 'GREEN_POINTS'
? (transferShare?.greenPointsBalance || '0')
: (transferShare?.kavaBalance || '0')
);
if (amount > balance) {
return '余额不足';
}
@ -316,6 +342,7 @@ export default function Home() {
from: transferShare!.evmAddress!,
to: transferTo.trim().toLowerCase(),
value: transferAmount.trim(),
tokenType: transferTokenType,
});
setPreparedTx(prepared);
setTransferStep('confirm');
@ -352,6 +379,7 @@ export default function Home() {
amount: transferAmount,
from: transferShare.evmAddress,
walletName: transferShare.walletName,
tokenType: transferTokenType,
};
sessionStorage.setItem(`tx_${result.sessionId}`, JSON.stringify(txToStore));
@ -458,17 +486,29 @@ export default function Home() {
</div>
)}
{/* KAVA 余额显示 */}
{/* 余额显示 - KAVA 和 绿积分 */}
{share.evmAddress && (
<div className={styles.balanceSection}>
<span className={styles.balanceLabel}>KAVA </span>
<span className={styles.balanceValue}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.kavaBalance || '0'} KAVA</>
)}
</span>
<div className={styles.balanceRow}>
<span className={styles.balanceLabel}>KAVA</span>
<span className={styles.balanceValue}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.kavaBalance || '0'}</>
)}
</span>
</div>
<div className={styles.balanceRow}>
<span className={styles.balanceLabel} style={{ color: '#4CAF50' }}>{GREEN_POINTS_TOKEN.name}</span>
<span className={styles.balanceValue} style={{ color: '#4CAF50' }}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.greenPointsBalance || '0'}</>
)}
</span>
</div>
</div>
)}
@ -538,7 +578,7 @@ export default function Home() {
<div className={styles.transferWalletInfo}>
<div className={styles.transferWalletName}>{transferShare.walletName}</div>
<div className={styles.transferWalletBalance}>
: {transferShare.kavaBalance || '0'} KAVA
KAVA: {transferShare.kavaBalance || '0'} | {GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'}
</div>
<div className={styles.transferNetwork}>
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'}
@ -547,6 +587,26 @@ export default function Home() {
{transferStep === 'input' && (
<div className={styles.transferForm}>
{/* 代币类型选择 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}></label>
<div className={styles.tokenTypeSelector}>
<button
className={`${styles.tokenTypeButton} ${transferTokenType === 'KAVA' ? styles.tokenTypeActive : ''}`}
onClick={() => { setTransferTokenType('KAVA'); setTransferAmount(''); }}
>
KAVA
</button>
<button
className={`${styles.tokenTypeButton} ${transferTokenType === 'GREEN_POINTS' ? styles.tokenTypeActive : ''}`}
onClick={() => { setTransferTokenType('GREEN_POINTS'); setTransferAmount(''); }}
style={transferTokenType === 'GREEN_POINTS' ? { backgroundColor: '#4CAF50', borderColor: '#4CAF50' } : {}}
>
{GREEN_POINTS_TOKEN.name}
</button>
</div>
</div>
{/* 收款地址 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}></label>
@ -561,7 +621,9 @@ export default function Home() {
{/* 转账金额 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}> (KAVA)</label>
<label className={styles.transferLabel}>
({transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'})
</label>
<div className={styles.transferAmountWrapper}>
<input
type="text"
@ -625,13 +687,21 @@ export default function Home() {
<h3 className={styles.confirmTitle}></h3>
<div className={styles.confirmDetails}>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
{transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue}>{formatAddress(transferTo, 8, 6)}</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue}>{transferAmount} KAVA</span>
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
{transferAmount} {transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}> Gas </span>

View File

@ -1,6 +1,7 @@
/**
* Kava EVM
* EIP-1559
* KAVA ERC-20 (绿)
*/
// Kava EVM Chain IDs
@ -15,6 +16,20 @@ export const KAVA_RPC_URL = {
testnet: 'https://evm.testnet.kava.io',
};
// Token types
export type TokenType = 'KAVA' | 'GREEN_POINTS';
// Green Points (绿积分) Token Configuration
export const GREEN_POINTS_TOKEN = {
contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
name: '绿积分',
symbol: 'dUSDT',
decimals: 6,
// ERC-20 function selectors
balanceOfSelector: '0x70a08231',
transferSelector: '0xa9059cbb',
};
// 当前网络配置 (从 localStorage 读取或使用默认值)
export function getCurrentNetwork(): 'mainnet' | 'testnet' {
if (typeof window !== 'undefined' && window.localStorage) {
@ -39,13 +54,14 @@ export function getCurrentRpcUrl(): string {
*/
export interface TransactionParams {
to: string; // 收款地址
value: string; // 转账金额 (KAVA, 字符串以支持大数)
value: string; // 转账金额 (KAVA 或代币单位, 字符串以支持大数)
from: string; // 发送地址
nonce?: number; // 可选,自动获取
gasLimit?: bigint; // 可选,默认 21000
maxFeePerGas?: bigint; // 可选,自动获取
maxPriorityFeePerGas?: bigint; // 可选,自动获取
data?: string; // 可选,合约调用数据
tokenType?: TokenType; // 可选,默认 KAVA
}
/**
@ -310,6 +326,88 @@ export function weiToKava(wei: bigint): string {
return fraction ? `${whole}.${fraction}` : whole;
}
/**
* 绿 (6 decimals)
*/
export function greenPointsToRaw(amount: string): 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);
} else {
fraction = fraction.padEnd(6, '0');
}
return whole * BigInt(10 ** 6) + BigInt(fraction);
}
/**
* 绿
*/
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+$/, '');
return fraction ? `${whole}.${fraction}` : whole;
}
/**
* 绿 (ERC-20)
*/
export async function fetchGreenPointsBalance(address: string): Promise<string> {
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 response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_call',
params: [
{
to: GREEN_POINTS_TOKEN.contractAddress,
data: callData,
},
'latest',
],
id: 1,
}),
});
const data = await response.json();
if (data.result && data.result !== '0x') {
const balanceRaw = BigInt(data.result);
return rawToGreenPoints(balanceRaw);
}
return '0';
} catch (error) {
console.error('Failed to fetch Green Points balance:', error);
return '0';
}
}
/**
* 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;
// Encode recipient address (padded to 32 bytes)
const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0');
// Encode amount (padded to 32 bytes)
const amountHex = amount.toString(16).padStart(64, '0');
return selector + paddedAddress + amountHex;
}
/**
* RPC nonce
*/
@ -371,29 +469,49 @@ export async function getGasPrice(): Promise<{ maxFeePerGas: bigint; maxPriority
/**
* gas
*/
export async function estimateGas(params: { from: string; to: string; value: string; data?: string }): Promise<bigint> {
export async function estimateGas(params: { from: string; to: string; value: string; data?: string; tokenType?: TokenType }): Promise<bigint> {
const rpcUrl = getCurrentRpcUrl();
const tokenType = params.tokenType || 'KAVA';
// For token transfers, we need different params
let txParams: { from: string; to: string; value: string; data?: string };
if (tokenType === 'GREEN_POINTS') {
// ERC-20 transfer: to is contract, value is 0, data is transfer call
const tokenAmount = greenPointsToRaw(params.value);
const transferData = encodeErc20Transfer(params.to, tokenAmount);
txParams = {
from: params.from,
to: GREEN_POINTS_TOKEN.contractAddress,
value: '0x0',
data: transferData,
};
} else {
// Native KAVA transfer
txParams = {
from: params.from,
to: params.to,
value: toHex(kavaToWei(params.value)),
data: params.data || '0x',
};
}
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_estimateGas',
params: [{
from: params.from,
to: params.to,
value: toHex(kavaToWei(params.value)),
data: params.data || '0x',
}],
params: [txParams],
id: 1,
}),
});
const data = await response.json();
if (data.error) {
// 如果估算失败,使用默认值 21000 (普通转账)
// 如果估算失败,使用默认值
console.warn('Gas 估算失败,使用默认值:', data.error);
return BigInt(21000);
return tokenType === 'GREEN_POINTS' ? BigInt(65000) : BigInt(21000);
}
return BigInt(data.result);
}
@ -402,9 +520,11 @@ export async function estimateGas(params: { from: string; to: string; value: str
* Legacy (Type 0)
* KAVA EIP-1559使 Legacy
*
* KAVA ERC-20 (绿)
*/
export async function prepareTransaction(params: TransactionParams): Promise<PreparedTransaction> {
const chainId = getCurrentChainId();
const tokenType = params.tokenType || 'KAVA';
// 获取或使用提供的参数
const nonce = params.nonce ?? await getNonce(params.from);
@ -415,10 +535,28 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
to: params.to,
value: params.value,
data: params.data,
tokenType,
});
const value = kavaToWei(params.value);
const data = params.data || '0x';
// Prepare transaction based on token type
let toAddress: string;
let value: bigint;
let data: string;
if (tokenType === 'GREEN_POINTS') {
// ERC-20 token transfer
// To address is the contract, value is 0
// Data is transfer(recipient, amount) encoded
const tokenAmount = greenPointsToRaw(params.value);
toAddress = GREEN_POINTS_TOKEN.contractAddress.toLowerCase();
value = BigInt(0);
data = encodeErc20Transfer(params.to, tokenAmount);
} else {
// Native KAVA transfer
toAddress = params.to.toLowerCase();
value = kavaToWei(params.value);
data = params.data || '0x';
}
// Legacy 交易字段顺序 (EIP-155)
// [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
@ -427,7 +565,7 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
nonce,
gasPrice,
gasLimit,
params.to.toLowerCase(),
toAddress,
value,
data,
chainId,
@ -447,7 +585,7 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
nonce,
gasPrice,
gasLimit,
to: params.to.toLowerCase(),
to: toAddress,
value,
data,
signHash,