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.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.durian.tssparty.domain.model.AppReadyState 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.BottomNavItem
import com.durian.tssparty.presentation.components.TssBottomNavigation import com.durian.tssparty.presentation.components.TssBottomNavigation
import com.durian.tssparty.presentation.screens.* import com.durian.tssparty.presentation.screens.*
@ -63,6 +64,7 @@ fun TssPartyApp(
val settings by viewModel.settings.collectAsState() val settings by viewModel.settings.collectAsState()
val createdInviteCode by viewModel.createdInviteCode.collectAsState() val createdInviteCode by viewModel.createdInviteCode.collectAsState()
val balances by viewModel.balances.collectAsState() val balances by viewModel.balances.collectAsState()
val walletBalances by viewModel.walletBalances.collectAsState()
val currentSessionId by viewModel.currentSessionId.collectAsState() val currentSessionId by viewModel.currentSessionId.collectAsState()
val sessionParticipants by viewModel.sessionParticipants.collectAsState() val sessionParticipants by viewModel.sessionParticipants.collectAsState()
val currentRound by viewModel.currentRound.collectAsState() val currentRound by viewModel.currentRound.collectAsState()
@ -167,6 +169,7 @@ fun TssPartyApp(
shares = shares, shares = shares,
isConnected = uiState.isConnected, isConnected = uiState.isConnected,
balances = balances, balances = balances,
walletBalances = walletBalances,
networkType = settings.networkType, networkType = settings.networkType,
onDeleteShare = { viewModel.deleteShare(it) }, onDeleteShare = { viewModel.deleteShare(it) },
onRefreshBalance = { address -> viewModel.fetchBalance(address) }, onRefreshBalance = { address -> viewModel.fetchBalance(address) },
@ -189,6 +192,7 @@ fun TssPartyApp(
TransferScreen( TransferScreen(
wallet = wallet, wallet = wallet,
balance = balances[wallet.address], balance = balances[wallet.address],
walletBalance = walletBalances[wallet.address],
sessionStatus = sessionStatus, sessionStatus = sessionStatus,
participants = signParticipants, participants = signParticipants,
currentRound = signCurrentRound, currentRound = signCurrentRound,
@ -202,8 +206,8 @@ fun TssPartyApp(
error = uiState.error, error = uiState.error,
networkType = settings.networkType, networkType = settings.networkType,
rpcUrl = settings.kavaRpcUrl, rpcUrl = settings.kavaRpcUrl,
onPrepareTransaction = { toAddress, amount -> onPrepareTransaction = { toAddress, amount, tokenType ->
viewModel.prepareTransfer(shareId, toAddress, amount) viewModel.prepareTransfer(shareId, toAddress, amount, tokenType)
}, },
onConfirmTransaction = { onConfirmTransaction = {
viewModel.initiateSignSession(shareId, "") 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 ========== // ========== Transfer / Sign Session Methods ==========
/** /**
@ -1912,7 +1998,8 @@ class TssRepository @Inject constructor(
to: String, to: String,
amount: String, amount: String,
rpcUrl: String, rpcUrl: String,
chainId: Int = TransactionUtils.KAVA_TESTNET_CHAIN_ID chainId: Int = TransactionUtils.KAVA_TESTNET_CHAIN_ID,
tokenType: TokenType = TokenType.KAVA
): Result<TransactionUtils.PreparedTransaction> { ): Result<TransactionUtils.PreparedTransaction> {
return TransactionUtils.prepareTransaction( return TransactionUtils.prepareTransaction(
TransactionUtils.TransactionParams( TransactionUtils.TransactionParams(
@ -1920,7 +2007,8 @@ class TssRepository @Inject constructor(
to = to, to = to,
amount = amount, amount = amount,
rpcUrl = rpcUrl, rpcUrl = rpcUrl,
chainId = chainId chainId = chainId,
tokenType = tokenType
) )
) )
} }

View File

@ -123,3 +123,38 @@ enum class NetworkType {
MAINNET, MAINNET,
TESTNET 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.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.ShareRecord
import com.durian.tssparty.domain.model.WalletBalance
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
shares: List<ShareRecord>, shares: List<ShareRecord>,
walletBalances: Map<String, WalletBalance>,
isConnected: Boolean, isConnected: Boolean,
onNavigateToJoin: () -> Unit, onNavigateToJoin: () -> Unit,
onNavigateToSign: (Long) -> Unit, onNavigateToSign: (Long) -> Unit,
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit,
onDeleteShare: (Long) -> Unit onDeleteShare: (Long) -> Unit,
onRefreshBalances: () -> Unit = {}
) { ) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("TSS Party") }, title = { Text("TSS Party") },
actions = { actions = {
// Refresh button
IconButton(onClick = onRefreshBalances) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
// Connection status indicator // Connection status indicator
Icon( Icon(
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning, imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
@ -94,6 +103,7 @@ fun HomeScreen(
items(shares) { share -> items(shares) { share ->
WalletCard( WalletCard(
share = share, share = share,
walletBalance = walletBalances[share.address],
onSign = { onNavigateToSign(share.id) }, onSign = { onNavigateToSign(share.id) },
onDelete = { onDeleteShare(share.id) } onDelete = { onDeleteShare(share.id) }
) )
@ -107,6 +117,7 @@ fun HomeScreen(
@Composable @Composable
fun WalletCard( fun WalletCard(
share: ShareRecord, share: ShareRecord,
walletBalance: WalletBalance?,
onSign: () -> Unit, onSign: () -> Unit,
onDelete: () -> 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)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(

View File

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

View File

@ -35,8 +35,10 @@ 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.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
import com.durian.tssparty.domain.model.WalletBalance
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -48,6 +50,7 @@ fun WalletsScreen(
shares: List<ShareRecord>, shares: List<ShareRecord>,
isConnected: Boolean, isConnected: Boolean,
balances: Map<String, String> = emptyMap(), balances: Map<String, String> = emptyMap(),
walletBalances: Map<String, WalletBalance> = emptyMap(),
networkType: NetworkType = NetworkType.MAINNET, networkType: NetworkType = NetworkType.MAINNET,
onDeleteShare: (Long) -> Unit, onDeleteShare: (Long) -> Unit,
onRefreshBalance: ((String) -> Unit)? = null, onRefreshBalance: ((String) -> Unit)? = null,
@ -146,6 +149,7 @@ fun WalletsScreen(
WalletItemCard( WalletItemCard(
share = share, share = share,
balance = balances[share.address], balance = balances[share.address],
walletBalance = walletBalances[share.address],
onViewDetails = { selectedWallet = share }, onViewDetails = { selectedWallet = share },
onTransfer = { onTransfer = {
onTransfer?.invoke(share.id) onTransfer?.invoke(share.id)
@ -196,6 +200,7 @@ fun WalletsScreen(
private fun WalletItemCard( private fun WalletItemCard(
share: ShareRecord, share: ShareRecord,
balance: String? = null, balance: String? = null,
walletBalance: WalletBalance? = null,
onViewDetails: () -> Unit, onViewDetails: () -> Unit,
onTransfer: () -> Unit, onTransfer: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit
@ -256,31 +261,63 @@ private fun WalletItemCard(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Balance display // Balance display - now shows both KAVA and Green Points
Row( Row(
verticalAlignment = Alignment.CenterVertically modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Icon( // KAVA balance
Icons.Default.AccountBalance, Column {
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
if (balance != null) {
Text( Text(
text = "$balance KAVA", text = "KAVA",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
} else {
// Loading state
Text(
text = "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline 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 _createdInviteCode.value = null
} }
// Wallet balances cache // Wallet balances cache (KAVA only - deprecated, use walletBalances instead)
private val _balances = MutableStateFlow<Map<String, String>>(emptyMap()) private val _balances = MutableStateFlow<Map<String, String>>(emptyMap())
val balances: StateFlow<Map<String, String>> = _balances.asStateFlow() 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) * 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) { fun fetchBalanceForShare(share: ShareRecord) {
viewModelScope.launch { viewModelScope.launch {
val rpcUrl = _settings.value.kavaRpcUrl val rpcUrl = _settings.value.kavaRpcUrl
// Ensure we use EVM address format for RPC calls // Ensure we use EVM address format for RPC calls
val evmAddress = AddressUtils.getEvmAddress(share.address, share.publicKey) val evmAddress = AddressUtils.getEvmAddress(share.address, share.publicKey)
val result = repository.getBalance(evmAddress, rpcUrl)
result.onSuccess { balance -> // Fetch combined wallet balance (KAVA + Green Points)
// Store balance with original address as key (for UI lookup) val result = repository.getWalletBalance(evmAddress, rpcUrl)
_balances.update { it + (share.address to balance) } 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) { fun fetchBalance(address: String) {
viewModelScope.launch { viewModelScope.launch {
val rpcUrl = _settings.value.kavaRpcUrl val rpcUrl = _settings.value.kavaRpcUrl
val result = repository.getBalance(address, rpcUrl) val result = repository.getWalletBalance(address, rpcUrl)
result.onSuccess { balance -> result.onSuccess { walletBalance ->
_balances.update { it + (address to balance) } _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 * 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 { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) } _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) val share = repository.getShareById(shareId)
if (share == null) { if (share == null) {
@ -1133,7 +1143,8 @@ class MainViewModel @Inject constructor(
to = toAddress, to = toAddress,
amount = amount, amount = amount,
rpcUrl = rpcUrl, rpcUrl = rpcUrl,
chainId = chainId chainId = chainId,
tokenType = tokenType
) )
result.fold( result.fold(
@ -1347,7 +1358,8 @@ data class MainUiState(
data class TransferState( data class TransferState(
val shareId: Long = 0, val shareId: Long = 0,
val toAddress: String = "", 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 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@ -50,14 +52,16 @@ object TransactionUtils {
data class TransactionParams( data class TransactionParams(
val from: String, val from: String,
val to: 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 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 * 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 (绿积分)
*/ */
suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) { suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
try { try {
@ -67,16 +71,36 @@ object TransactionUtils {
// 2. Get gas price // 2. Get gas price
val gasPrice = getGasPrice(params.rpcUrl).getOrThrow() val gasPrice = getGasPrice(params.rpcUrl).getOrThrow()
// 3. Convert amount to wei (1 KAVA = 10^18 wei) // 3. Prepare transaction based on token type
val valueWei = kavaToWei(params.amount) 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 // 4. Estimate gas
val gasLimit = estimateGas( val gasLimit = estimateGasWithData(
from = params.from, from = params.from,
to = params.to, to = toAddress,
value = valueWei, value = valueWei,
data = txData,
rpcUrl = params.rpcUrl 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) // 5. RLP encode for signing (Legacy Type 0 format)
// Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0] // Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
@ -84,9 +108,9 @@ object TransactionUtils {
nonce = nonce, nonce = nonce,
gasPrice = gasPrice, gasPrice = gasPrice,
gasLimit = gasLimit, gasLimit = gasLimit,
to = params.to, to = toAddress,
value = valueWei, value = valueWei,
data = ByteArray(0), data = txData,
chainId = params.chainId chainId = params.chainId
) )
@ -97,9 +121,10 @@ object TransactionUtils {
nonce = nonce, nonce = nonce,
gasPrice = gasPrice, gasPrice = gasPrice,
gasLimit = gasLimit, gasLimit = gasLimit,
to = params.to, to = toAddress,
from = params.from, from = params.from,
value = valueWei, value = valueWei,
data = txData,
chainId = params.chainId, chainId = params.chainId,
signHash = "0x" + signHash.toHexString(), signHash = "0x" + signHash.toHexString(),
rawTxForSigning = rawTxForSigning 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 * Finalize transaction with signature
* Returns the signed raw transaction hex string ready for broadcast * Returns the signed raw transaction hex string ready for broadcast
@ -297,9 +357,20 @@ object TransactionUtils {
to: String, to: String,
value: BigInteger, value: BigInteger,
rpcUrl: String 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) { ): Result<BigInteger> = withContext(Dispatchers.IO) {
try { try {
val valueHex = "0x" + value.toString(16) val valueHex = "0x" + value.toString(16)
val dataHex = if (data.isEmpty()) "" else "\"data\": \"0x${data.toHexString()}\","
val requestBody = """ val requestBody = """
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@ -307,7 +378,7 @@ object TransactionUtils {
"params": [{ "params": [{
"from": "$from", "from": "$from",
"to": "$to", "to": "$to",
"value": "$valueHex" "value": "$valueHex"${if (dataHex.isNotEmpty()) ",\n ${dataHex.trimEnd(',')}" else ""}
}], }],
"id": 1 "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 */ /* Balance Section */
.balanceSection { .balanceSection {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: var(--spacing-sm);
padding: var(--spacing-md); padding: var(--spacing-md);
margin-bottom: var(--spacing-md); margin-bottom: var(--spacing-md);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.balanceRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.balanceLabel { .balanceLabel {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
@ -519,6 +525,42 @@
color: white; 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 { .transferError {
background-color: rgba(220, 53, 69, 0.1); background-color: rgba(220, 53, 69, 0.1);
color: #dc3545; color: #dc3545;

View File

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

View File

@ -1,6 +1,7 @@
/** /**
* Kava EVM * Kava EVM
* EIP-1559 * EIP-1559
* KAVA ERC-20 (绿)
*/ */
// Kava EVM Chain IDs // Kava EVM Chain IDs
@ -15,6 +16,20 @@ export const KAVA_RPC_URL = {
testnet: 'https://evm.testnet.kava.io', 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 读取或使用默认值) // 当前网络配置 (从 localStorage 读取或使用默认值)
export function getCurrentNetwork(): 'mainnet' | 'testnet' { export function getCurrentNetwork(): 'mainnet' | 'testnet' {
if (typeof window !== 'undefined' && window.localStorage) { if (typeof window !== 'undefined' && window.localStorage) {
@ -39,13 +54,14 @@ export function getCurrentRpcUrl(): string {
*/ */
export interface TransactionParams { export interface TransactionParams {
to: string; // 收款地址 to: string; // 收款地址
value: string; // 转账金额 (KAVA, 字符串以支持大数) value: string; // 转账金额 (KAVA 或代币单位, 字符串以支持大数)
from: string; // 发送地址 from: string; // 发送地址
nonce?: number; // 可选,自动获取 nonce?: number; // 可选,自动获取
gasLimit?: bigint; // 可选,默认 21000 gasLimit?: bigint; // 可选,默认 21000
maxFeePerGas?: bigint; // 可选,自动获取 maxFeePerGas?: bigint; // 可选,自动获取
maxPriorityFeePerGas?: bigint; // 可选,自动获取 maxPriorityFeePerGas?: bigint; // 可选,自动获取
data?: string; // 可选,合约调用数据 data?: string; // 可选,合约调用数据
tokenType?: TokenType; // 可选,默认 KAVA
} }
/** /**
@ -310,6 +326,88 @@ export function weiToKava(wei: bigint): string {
return fraction ? `${whole}.${fraction}` : whole; 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 * RPC nonce
*/ */
@ -371,29 +469,49 @@ export async function getGasPrice(): Promise<{ maxFeePerGas: bigint; maxPriority
/** /**
* gas * 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 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, { const response = await fetch(rpcUrl, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
jsonrpc: '2.0', jsonrpc: '2.0',
method: 'eth_estimateGas', method: 'eth_estimateGas',
params: [{ params: [txParams],
from: params.from,
to: params.to,
value: toHex(kavaToWei(params.value)),
data: params.data || '0x',
}],
id: 1, id: 1,
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) {
// 如果估算失败,使用默认值 21000 (普通转账) // 如果估算失败,使用默认值
console.warn('Gas 估算失败,使用默认值:', data.error); console.warn('Gas 估算失败,使用默认值:', data.error);
return BigInt(21000); return tokenType === 'GREEN_POINTS' ? BigInt(65000) : BigInt(21000);
} }
return BigInt(data.result); return BigInt(data.result);
} }
@ -402,9 +520,11 @@ export async function estimateGas(params: { from: string; to: string; value: str
* Legacy (Type 0) * Legacy (Type 0)
* KAVA EIP-1559使 Legacy * KAVA EIP-1559使 Legacy
* *
* KAVA ERC-20 (绿)
*/ */
export async function prepareTransaction(params: TransactionParams): Promise<PreparedTransaction> { export async function prepareTransaction(params: TransactionParams): Promise<PreparedTransaction> {
const chainId = getCurrentChainId(); const chainId = getCurrentChainId();
const tokenType = params.tokenType || 'KAVA';
// 获取或使用提供的参数 // 获取或使用提供的参数
const nonce = params.nonce ?? await getNonce(params.from); const nonce = params.nonce ?? await getNonce(params.from);
@ -415,10 +535,28 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
to: params.to, to: params.to,
value: params.value, value: params.value,
data: params.data, data: params.data,
tokenType,
}); });
const value = kavaToWei(params.value); // Prepare transaction based on token type
const data = params.data || '0x'; 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) // Legacy 交易字段顺序 (EIP-155)
// [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0] // [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
@ -427,7 +565,7 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
nonce, nonce,
gasPrice, gasPrice,
gasLimit, gasLimit,
params.to.toLowerCase(), toAddress,
value, value,
data, data,
chainId, chainId,
@ -447,7 +585,7 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
nonce, nonce,
gasPrice, gasPrice,
gasLimit, gasLimit,
to: params.to.toLowerCase(), to: toAddress,
value, value,
data, data,
signHash, signHash,