fix(transfer): MAX button now deducts gas fee from balance
Both Electron and Android apps now calculate the maximum transferable amount by subtracting estimated gas fees from the balance: Electron (Home.tsx): - Added calculateMaxAmount() async function that fetches gas price - Uses 21000 gas limit for simple transfers - Shows loading state while calculating Android (TransferScreen.kt): - Added calculateMaxTransferAmount() in TransactionUtils - Uses coroutine to fetch gas price asynchronously - Shows "..." while calculating, falls back to balance on error Both implementations: - Add 10% buffer to gas price for safety - Round down to 6 decimal places - Show error if balance insufficient for gas 🤖 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
7f66ed0ebe
commit
fd5f4d10ed
|
|
@ -195,6 +195,7 @@ fun TssPartyApp(
|
||||||
isLoading = uiState.isLoading,
|
isLoading = uiState.isLoading,
|
||||||
error = uiState.error,
|
error = uiState.error,
|
||||||
networkType = settings.networkType,
|
networkType = settings.networkType,
|
||||||
|
rpcUrl = settings.kavaRpcUrl,
|
||||||
onPrepareTransaction = { toAddress, amount ->
|
onPrepareTransaction = { toAddress, amount ->
|
||||||
viewModel.prepareTransfer(shareId, toAddress, amount)
|
viewModel.prepareTransfer(shareId, toAddress, amount)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ 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.util.TransactionUtils
|
import com.durian.tssparty.util.TransactionUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,6 +58,7 @@ fun TransferScreen(
|
||||||
isLoading: Boolean = false,
|
isLoading: Boolean = false,
|
||||||
error: String? = null,
|
error: String? = null,
|
||||||
networkType: NetworkType = NetworkType.MAINNET,
|
networkType: NetworkType = NetworkType.MAINNET,
|
||||||
|
rpcUrl: String = "https://evm.kava.io",
|
||||||
onPrepareTransaction: (toAddress: String, amount: String) -> Unit,
|
onPrepareTransaction: (toAddress: String, amount: String) -> Unit,
|
||||||
onConfirmTransaction: (password: String) -> Unit,
|
onConfirmTransaction: (password: String) -> Unit,
|
||||||
onCopyInviteCode: () -> Unit,
|
onCopyInviteCode: () -> Unit,
|
||||||
|
|
@ -115,6 +117,7 @@ fun TransferScreen(
|
||||||
amount = amount,
|
amount = amount,
|
||||||
onAmountChange = { amount = it },
|
onAmountChange = { amount = it },
|
||||||
error = validationError ?: error,
|
error = validationError ?: error,
|
||||||
|
rpcUrl = rpcUrl,
|
||||||
onSubmit = {
|
onSubmit = {
|
||||||
when {
|
when {
|
||||||
toAddress.isBlank() -> validationError = "请输入收款地址"
|
toAddress.isBlank() -> validationError = "请输入收款地址"
|
||||||
|
|
@ -196,9 +199,12 @@ private fun TransferInputScreen(
|
||||||
amount: String,
|
amount: String,
|
||||||
onAmountChange: (String) -> Unit,
|
onAmountChange: (String) -> Unit,
|
||||||
error: String?,
|
error: String?,
|
||||||
|
rpcUrl: String,
|
||||||
onSubmit: () -> Unit,
|
onSubmit: () -> Unit,
|
||||||
onCancel: () -> Unit
|
onCancel: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var isCalculatingMax by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -275,9 +281,25 @@ private fun TransferInputScreen(
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (balance != null) {
|
if (balance != null) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { onAmountChange(balance) }
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
isCalculatingMax = true
|
||||||
|
val result = TransactionUtils.calculateMaxTransferAmount(balance, rpcUrl)
|
||||||
|
result.onSuccess { maxAmount ->
|
||||||
|
onAmountChange(maxAmount)
|
||||||
|
}.onFailure {
|
||||||
|
// Fallback to balance if calculation fails
|
||||||
|
onAmountChange(balance)
|
||||||
|
}
|
||||||
|
isCalculatingMax = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isCalculatingMax
|
||||||
) {
|
) {
|
||||||
Text("全部", style = MaterialTheme.typography.labelSmall)
|
Text(
|
||||||
|
if (isCalculatingMax) "..." else "全部",
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,64 @@ object TransactionUtils {
|
||||||
return gweiDecimal.toPlainString()
|
return gweiDecimal.toPlainString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate maximum transferable amount after deducting gas fee
|
||||||
|
* @param balance Current balance in KAVA
|
||||||
|
* @param rpcUrl RPC endpoint URL
|
||||||
|
* @return Maximum amount in KAVA string, or "0" if insufficient balance
|
||||||
|
*/
|
||||||
|
suspend fun calculateMaxTransferAmount(balance: String, rpcUrl: String): Result<String> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val balanceKava = BigDecimal(balance)
|
||||||
|
if (balanceKava <= BigDecimal.ZERO) {
|
||||||
|
return@withContext Result.success("0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current gas price
|
||||||
|
val gasPriceResult = getGasPrice(rpcUrl)
|
||||||
|
val gasPrice = gasPriceResult.getOrElse {
|
||||||
|
// Default to 1 gwei if failed
|
||||||
|
BigInteger.valueOf(1000000000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 10% buffer to gas price
|
||||||
|
val gasPriceWithBuffer = gasPrice.multiply(BigInteger.valueOf(110)).divide(BigInteger.valueOf(100))
|
||||||
|
|
||||||
|
// Simple transfer gas limit is 21000
|
||||||
|
val gasLimit = BigInteger.valueOf(21000)
|
||||||
|
val gasFee = gasPriceWithBuffer.multiply(gasLimit)
|
||||||
|
|
||||||
|
// Convert gas fee to KAVA
|
||||||
|
val gasFeeKava = BigDecimal(gasFee).divide(BigDecimal("1000000000000000000"), 8, java.math.RoundingMode.UP)
|
||||||
|
|
||||||
|
// Calculate max amount = balance - gas fee
|
||||||
|
val maxAmount = balanceKava.subtract(gasFeeKava)
|
||||||
|
|
||||||
|
if (maxAmount <= BigDecimal.ZERO) {
|
||||||
|
return@withContext Result.success("0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round down to 6 decimal places
|
||||||
|
val formattedMax = maxAmount.setScale(6, java.math.RoundingMode.DOWN).stripTrailingZeros().toPlainString()
|
||||||
|
Result.success(formattedMax)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback: use default gas estimate (21000 * 1 gwei = 0.000021 KAVA)
|
||||||
|
try {
|
||||||
|
val balanceKava = BigDecimal(balance)
|
||||||
|
val defaultGasFee = BigDecimal("0.000021")
|
||||||
|
val maxAmount = balanceKava.subtract(defaultGasFee)
|
||||||
|
if (maxAmount <= BigDecimal.ZERO) {
|
||||||
|
Result.success("0")
|
||||||
|
} else {
|
||||||
|
val formattedMax = maxAmount.setScale(6, java.math.RoundingMode.DOWN).stripTrailingZeros().toPlainString()
|
||||||
|
Result.success(formattedMax)
|
||||||
|
}
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun keccak256(data: ByteArray): ByteArray {
|
private fun keccak256(data: ByteArray): ByteArray {
|
||||||
val keccak = Keccak.Digest256()
|
val keccak = Keccak.Digest256()
|
||||||
return keccak.digest(data)
|
return keccak.digest(data)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
isValidAmount,
|
isValidAmount,
|
||||||
getCurrentNetwork,
|
getCurrentNetwork,
|
||||||
getCurrentRpcUrl,
|
getCurrentRpcUrl,
|
||||||
|
getGasPrice,
|
||||||
type PreparedTransaction,
|
type PreparedTransaction,
|
||||||
} from '../utils/transaction';
|
} from '../utils/transaction';
|
||||||
|
|
||||||
|
|
@ -80,6 +81,52 @@ export default function Home() {
|
||||||
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);
|
||||||
|
const [isCalculatingMax, setIsCalculatingMax] = useState(false);
|
||||||
|
|
||||||
|
// 计算扣除 Gas 费后的最大可转账金额
|
||||||
|
const calculateMaxAmount = async () => {
|
||||||
|
if (!transferShare?.kavaBalance || !transferShare?.evmAddress) return;
|
||||||
|
|
||||||
|
setIsCalculatingMax(true);
|
||||||
|
try {
|
||||||
|
const balance = parseFloat(transferShare.kavaBalance);
|
||||||
|
if (balance <= 0) {
|
||||||
|
setTransferAmount('0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前 gas 价格
|
||||||
|
const { maxFeePerGas } = await getGasPrice();
|
||||||
|
// 简单转账的 gas 限制是 21000
|
||||||
|
const gasLimit = BigInt(21000);
|
||||||
|
const gasFee = maxFeePerGas * gasLimit;
|
||||||
|
// 转换为 KAVA (18 位小数)
|
||||||
|
const gasFeeKava = Number(gasFee) / 1e18;
|
||||||
|
|
||||||
|
// 计算最大可转账金额 = 余额 - Gas费
|
||||||
|
const maxAmount = balance - gasFeeKava;
|
||||||
|
|
||||||
|
if (maxAmount <= 0) {
|
||||||
|
setTransferError('余额不足以支付 Gas 费');
|
||||||
|
setTransferAmount('0');
|
||||||
|
} else {
|
||||||
|
// 保留 6 位小数,向下取整避免精度问题
|
||||||
|
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
|
||||||
|
setTransferAmount(formattedMax.toString());
|
||||||
|
setTransferError(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to calculate max amount:', error);
|
||||||
|
// 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000)
|
||||||
|
const defaultGasFee = 0.000021; // ~21000 * 1 gwei
|
||||||
|
const balance = parseFloat(transferShare.kavaBalance);
|
||||||
|
const maxAmount = Math.max(0, balance - defaultGasFee);
|
||||||
|
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
|
||||||
|
setTransferAmount(formattedMax.toString());
|
||||||
|
} finally {
|
||||||
|
setIsCalculatingMax(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise<ShareWithAddress[]> => {
|
const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise<ShareWithAddress[]> => {
|
||||||
const sharesWithAddresses: ShareWithAddress[] = [];
|
const sharesWithAddresses: ShareWithAddress[] = [];
|
||||||
|
|
@ -519,9 +566,10 @@ export default function Home() {
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className={styles.maxButton}
|
className={styles.maxButton}
|
||||||
onClick={() => setTransferAmount(transferShare.kavaBalance || '0')}
|
onClick={calculateMaxAmount}
|
||||||
|
disabled={isCalculatingMax}
|
||||||
>
|
>
|
||||||
MAX
|
{isCalculatingMax ? '...' : 'MAX'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue