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:
parent
b3822e48eb
commit
9b9612bd5f
|
|
@ -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, "")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
| 可增发 | 否 | 是 |
|
||||||
|
| 可暂停 | 否 | 是 |
|
||||||
|
| 黑名单功能 | 否 | 是 |
|
||||||
|
| 中心化管理 | 否 | 是 |
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
608060405234801561001057600080fd5b5033600081815260208181526040808320670de0b6b3a76400009081905590519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a36106fb8061006d6000396000f3fe608060405234801561001057600080fd5b50600436106100935760003560e01c8063313ce56711610066578063313ce5671461012b57806370a082311461014557806395d89b411461016e578063a9059cbb14610192578063dd62ed3e146101a557600080fd5b806306fdde0314610098578063095ea7b3146100d857806318160ddd146100fb57806323b872dd14610118575b600080fd5b6100c26040518060400160405280600b81526020016a111d5c9a585b881554d11560aa1b81525081565b6040516100cf91906105a0565b60405180910390f35b6100eb6100e636600461060a565b6101de565b60405190151581526020016100cf565b61010a670de0b6b3a764000081565b6040519081526020016100cf565b6100eb610126366004610634565b6102a0565b610133600681565b60405160ff90911681526020016100cf565b61010a610153366004610670565b6001600160a01b031660009081526020819052604090205490565b6100c260405180604001604052806005815260200164191554d11560da1b81525081565b6100eb6101a036600461060a565b61049a565b61010a6101b3366004610692565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b60006001600160a01b03831661023b5760405162461bcd60e51b815260206004820152601760248201527f417070726f766520746f207a65726f206164647265737300000000000000000060448201526064015b60405180910390fd5b3360008181526001602090815260408083206001600160a01b03881680855290835292819020869055518581529192917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92591015b60405180910390a350600192915050565b60006001600160a01b0384166102f85760405162461bcd60e51b815260206004820152601a60248201527f5472616e736665722066726f6d207a65726f20616464726573730000000000006044820152606401610232565b6001600160a01b0383166103495760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b6001600160a01b0384166000908152602081905260409020548211156103a85760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b6001600160a01b03841660009081526001602090815260408083203384529091529020548211156104145760405162461bcd60e51b8152602060048201526016602482015275496e73756666696369656e7420616c6c6f77616e636560501b6044820152606401610232565b6001600160a01b03848116600081815260208181526040808320805488900390559387168083528483208054880190558383526001825284832033845282529184902080548790039055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b60006001600160a01b0383166104ed5760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b336000908152602081905260409020548211156105435760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b33600081815260208181526040808320805487900390556001600160a01b03871680845292819020805487019055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910161028f565b600060208083528351808285015260005b818110156105cd578581018301518582016040015282016105b1565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b038116811461060557600080fd5b919050565b6000806040838503121561061d57600080fd5b610626836105ee565b946020939093013593505050565b60008060006060848603121561064957600080fd5b610652846105ee565b9250610660602085016105ee565b9150604084013590509250925092565b60006020828403121561068257600080fd5b61068b826105ee565b9392505050565b600080604083850312156106a557600080fd5b6106ae836105ee565b91506106bc602084016105ee565b9050925092905056fea264697066735822122028c97073f6e7db0ad943d101cb6873b31c3eb19bcea3eda83148447ab676a5ee64736f6c63430008130033
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue