fix(android): 使用 Kavascan Etherscan API 同步交易记录

替换之前的 BlockScout v2 API(返回404)为 Kavascan 的 Etherscan 兼容 API:
- action=tokentx: 获取 ERC-20 代币交易
- action=txlist: 获取原生 KAVA 交易

优势:
- 一次请求获取所有历史交易,无需分批扫描区块
- 速度快(<5秒 vs 之前的30-45秒)
- API 稳定可靠

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-26 08:51:40 -08:00
parent d974fddda5
commit 656f75a4d1
2 changed files with 142 additions and 136 deletions

View File

@ -798,7 +798,18 @@
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"cat /home/ceshi/rwadurian/backend/services/mining-service/src/application/services/batch-mining.service.ts | head -250\")", "Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"cat /home/ceshi/rwadurian/backend/services/mining-service/src/application/services/batch-mining.service.ts | head -250\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"docker logs rwa-mining-admin-service --tail 50 2>&1 | grep ''第一条数据\\\\|最后一条数据''\")", "Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"docker logs rwa-mining-admin-service --tail 50 2>&1 | grep ''第一条数据\\\\|最后一条数据''\")",
"Bash(npx xlsx-cli 挖矿.xlsx)", "Bash(npx xlsx-cli 挖矿.xlsx)",
"Bash(DATABASE_URL=\"postgresql://postgres:password@localhost:5432/mining_db?schema=public\" npx prisma migrate dev:*)" "Bash(DATABASE_URL=\"postgresql://postgres:password@localhost:5432/mining_db?schema=public\" npx prisma migrate dev:*)",
"Bash(md-to-pdf:*)",
"Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\docs\\\\deployment\\\\*.pdf\")",
"Bash(./gradlew compileDebugKotlin:*)",
"Bash(cmd.exe /c \"cd /d c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android && gradlew.bat :app:compileDebugKotlin --no-daemon\")",
"Bash(powershell -Command \"Set-Location 'c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android'; .\\\\gradlew.bat :app:compileDebugKotlin --no-daemon 2>&1\":*)",
"Bash(powershell -Command \"Set-Location ''c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android''; .\\\\gradlew.bat :app:compileDebugKotlin --no-daemon 2>&1 | Select-Object -Last 20\")",
"Bash(cmd.exe /c \"gradlew.bat installDebug && adb logcat -c && adb logcat | findstr /C:\"\"EXPORT\"\" /C:\"\"IMPORT\"\" /C:\"\"STATE\"\"\")",
"Bash(./gradlew:*)",
"Bash(adb shell \"run-as com.durian.tssparty sqlite3 /data/data/com.durian.tssparty/databases/tss_party.db ''SELECT id, tx_hash, from_address, to_address, amount, token_type, status, direction, created_at FROM transaction_records ORDER BY id DESC LIMIT 5;''\")",
"WebFetch(domain:docs.kava.io)",
"WebFetch(domain:kavascan.com)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -3510,7 +3510,7 @@ data class ParticipantStatusInfo(
* 首次导入钱包时调用 * 首次导入钱包时调用
*/ */
/** /**
* 使用 BlockScout API 同步交易历史更快的方式 * 使用 Kavascan Etherscan 兼容 API 同步交易历史
* 一次性获取地址的所有交易无需分批扫描区块 * 一次性获取地址的所有交易无需分批扫描区块
*/ */
suspend fun syncTransactionHistoryViaBlockScout( suspend fun syncTransactionHistoryViaBlockScout(
@ -3520,7 +3520,7 @@ data class ParticipantStatusInfo(
): Result<Int> { ): Result<Int> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
android.util.Log.d("TssRepository", "[SYNC-BLOCKSCOUT] Syncing via BlockScout API for $address") android.util.Log.d("TssRepository", "[SYNC-KAVASCAN] Syncing via Kavascan API for $address")
val baseUrl = if (networkType == NetworkType.TESTNET) { val baseUrl = if (networkType == NetworkType.TESTNET) {
"https://explorer.evm-alpha.kava.io" "https://explorer.evm-alpha.kava.io"
@ -3534,7 +3534,6 @@ data class ParticipantStatusInfo(
.build() .build()
var totalSynced = 0 var totalSynced = 0
var nextPageParams: String? = null
// 代币合约地址映射 // 代币合约地址映射
val contractToTokenType = mapOf( val contractToTokenType = mapOf(
@ -3543,39 +3542,23 @@ data class ParticipantStatusInfo(
FuturePointsToken.CONTRACT_ADDRESS.lowercase() to TokenType.FUTURE_POINTS FuturePointsToken.CONTRACT_ADDRESS.lowercase() to TokenType.FUTURE_POINTS
) )
// 分页获取所有交易 // 1. 获取 ERC-20 代币交易 (tokentx)
do { val tokenUrl = "$baseUrl/api?module=account&action=tokentx&address=$address&startblock=0&endblock=99999999&sort=desc"
val url = if (nextPageParams != null) { android.util.Log.d("TssRepository", "[SYNC-KAVASCAN] Fetching token tx: $tokenUrl")
"$baseUrl/api/v2/addresses/$address/transactions?$nextPageParams"
} else {
"$baseUrl/api/v2/addresses/$address/transactions"
}
android.util.Log.d("TssRepository", "[SYNC-BLOCKSCOUT] Fetching: $url") val tokenRequest = okhttp3.Request.Builder().url(tokenUrl).get().build()
val tokenResponse = client.newCall(tokenRequest).execute()
val tokenBody = tokenResponse.body?.string()
val request = okhttp3.Request.Builder() if (tokenBody != null && tokenResponse.code == 200) {
.url(url) val tokenJson = com.google.gson.JsonParser.parseString(tokenBody).asJsonObject
.get() val tokenStatus = tokenJson.get("status")?.asString
.build() val tokenResult = tokenJson.getAsJsonArray("result")
val response = client.newCall(request).execute() if (tokenStatus == "1" && tokenResult != null) {
val responseBody = response.body?.string() android.util.Log.d("TssRepository", "[SYNC-KAVASCAN] Processing ${tokenResult.size()} token transactions")
if (responseBody == null || response.code != 200) { for (item in tokenResult) {
android.util.Log.e("TssRepository", "[SYNC-BLOCKSCOUT] Request failed: code=${response.code}")
break
}
android.util.Log.d("TssRepository", "[SYNC-BLOCKSCOUT] Response preview: ${responseBody.take(500)}")
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
val items = json.getAsJsonArray("items")
val nextPageParamsObj = json.getAsJsonObject("next_page_params")
if (items != null) {
android.util.Log.d("TssRepository", "[SYNC-BLOCKSCOUT] Processing ${items.size()} transactions")
for (item in items) {
try { try {
val txObj = item.asJsonObject val txObj = item.asJsonObject
val txHash = txObj.get("hash")?.asString ?: continue val txHash = txObj.get("hash")?.asString ?: continue
@ -3584,122 +3567,134 @@ data class ParticipantStatusInfo(
val existing = transactionRecordDao.getRecordByTxHash(txHash) val existing = transactionRecordDao.getRecordByTxHash(txHash)
if (existing != null) continue if (existing != null) continue
val fromAddr = txObj.get("from")?.asJsonObject?.get("hash")?.asString ?: continue val contractAddr = txObj.get("contractAddress")?.asString?.lowercase() ?: continue
val toAddr = txObj.get("to")?.asJsonObject?.get("hash")?.asString val tokenType = contractToTokenType[contractAddr] ?: continue
val valueStr = txObj.get("value")?.asString ?: "0"
val gasPrice = txObj.get("gas_price")?.asString ?: "0"
val gasUsed = txObj.get("gas_used")?.asString ?: "0"
val blockNumber = txObj.get("block")?.asLong
val timestamp = txObj.get("timestamp")?.asString
val status = txObj.get("status")?.asString
val tokenTransfers = txObj.getAsJsonArray("token_transfers")
// 统一地址格式 val fromAddr = txObj.get("from")?.asString?.lowercase() ?: continue
val normalizedFrom = fromAddr.lowercase() val toAddr = txObj.get("to")?.asString?.lowercase() ?: continue
val normalizedTo = toAddr?.lowercase() val value = txObj.get("value")?.asString ?: "0"
val normalizedAddress = address.lowercase() val decimals = txObj.get("tokenDecimal")?.asString?.toIntOrNull() ?: 18
val gasPrice = txObj.get("gasPrice")?.asString ?: "0"
val gasUsed = txObj.get("gasUsed")?.asString ?: "0"
val blockNumber = txObj.get("blockNumber")?.asString?.toLongOrNull()
val timestamp = txObj.get("timeStamp")?.asString?.toLongOrNull()?.times(1000)
// 处理代币转账 val amount = java.math.BigInteger(value)
if (tokenTransfers != null && tokenTransfers.size() > 0) { .toBigDecimal()
for (transfer in tokenTransfers) { .divide(java.math.BigDecimal.TEN.pow(decimals))
val transferObj = transfer.asJsonObject .stripTrailingZeros()
val tokenAddr = transferObj.get("token")?.asJsonObject?.get("address")?.asString?.lowercase() .toPlainString()
val tokenType = contractToTokenType[tokenAddr] ?: continue
val transferFrom = transferObj.get("from")?.asJsonObject?.get("hash")?.asString?.lowercase() val direction = if (fromAddr == address.lowercase()) "SENT" else "RECEIVED"
val transferTo = transferObj.get("to")?.asJsonObject?.get("hash")?.asString?.lowercase()
val transferValue = transferObj.get("total")?.asJsonObject?.get("value")?.asString ?: "0"
val decimals = transferObj.get("total")?.asJsonObject?.get("decimals")?.asInt ?: 18
val amount = java.math.BigInteger(transferValue) val entity = TransactionRecordEntity(
.toBigDecimal() shareId = shareId,
.divide(java.math.BigDecimal.TEN.pow(decimals)) fromAddress = fromAddr,
.stripTrailingZeros() toAddress = toAddr,
.toPlainString() amount = amount,
tokenType = tokenType.name,
val direction = if (transferFrom == normalizedAddress) "SENT" else "RECEIVED" txHash = txHash,
gasPrice = gasPrice,
val timestampMs = try { gasUsed = gasUsed,
java.time.Instant.parse(timestamp).toEpochMilli() txFee = "",
} catch (e: Exception) { status = "CONFIRMED",
System.currentTimeMillis() direction = direction,
} blockNumber = blockNumber,
createdAt = timestamp ?: System.currentTimeMillis(),
val entity = TransactionRecordEntity( confirmedAt = timestamp ?: System.currentTimeMillis()
shareId = shareId, )
fromAddress = transferFrom ?: "", transactionRecordDao.insertRecord(entity)
toAddress = transferTo ?: "", totalSynced++
amount = amount,
tokenType = tokenType.name,
txHash = txHash,
gasPrice = gasPrice,
gasUsed = gasUsed,
txFee = "",
status = if (status == "ok") "CONFIRMED" else "FAILED",
direction = direction,
blockNumber = blockNumber,
createdAt = timestampMs,
confirmedAt = timestampMs
)
transactionRecordDao.insertRecord(entity)
totalSynced++
}
} else if (normalizedTo != null && (normalizedFrom == normalizedAddress || normalizedTo == normalizedAddress)) {
// 原生 KAVA 转账
val valueBig = java.math.BigInteger(valueStr.removePrefix("0x").ifEmpty { "0" }, 16)
val valueKava = valueBig.toBigDecimal()
.divide(java.math.BigDecimal("1000000000000000000"))
.stripTrailingZeros()
.toPlainString()
if (valueBig > java.math.BigInteger.ZERO) {
val direction = if (normalizedFrom == normalizedAddress) "SENT" else "RECEIVED"
val timestampMs = try {
java.time.Instant.parse(timestamp).toEpochMilli()
} catch (e: Exception) {
System.currentTimeMillis()
}
val entity = TransactionRecordEntity(
shareId = shareId,
fromAddress = normalizedFrom,
toAddress = normalizedTo,
amount = valueKava,
tokenType = TokenType.KAVA.name,
txHash = txHash,
gasPrice = gasPrice,
gasUsed = gasUsed,
txFee = "",
status = if (status == "ok") "CONFIRMED" else "FAILED",
direction = direction,
blockNumber = blockNumber,
createdAt = timestampMs,
confirmedAt = timestampMs
)
transactionRecordDao.insertRecord(entity)
totalSynced++
}
}
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("TssRepository", "[SYNC-BLOCKSCOUT] Error parsing tx: ${e.message}") android.util.Log.w("TssRepository", "[SYNC-KAVASCAN] Error parsing token tx: ${e.message}")
} }
} }
}
// 获取下一页参数
nextPageParams = if (nextPageParamsObj != null && !nextPageParamsObj.isJsonNull) {
nextPageParamsObj.entrySet().joinToString("&") { "${it.key}=${it.value.asString}" }
} else { } else {
null android.util.Log.d("TssRepository", "[SYNC-KAVASCAN] Token tx response: status=$tokenStatus")
} }
} else {
android.util.Log.e("TssRepository", "[SYNC-KAVASCAN] Token tx request failed: code=${tokenResponse.code}")
}
} while (nextPageParams != null) // 2. 获取原生 KAVA 交易 (txlist)
val txUrl = "$baseUrl/api?module=account&action=txlist&address=$address&startblock=0&endblock=99999999&sort=desc"
android.util.Log.d("TssRepository", "[SYNC-KAVASCAN] Fetching native tx: $txUrl")
android.util.Log.d("TssRepository", "[SYNC-BLOCKSCOUT] Completed: synced $totalSynced transactions") val txRequest = okhttp3.Request.Builder().url(txUrl).get().build()
val txResponse = client.newCall(txRequest).execute()
val txBody = txResponse.body?.string()
if (txBody != null && txResponse.code == 200) {
val txJson = com.google.gson.JsonParser.parseString(txBody).asJsonObject
val txStatus = txJson.get("status")?.asString
val txResult = txJson.getAsJsonArray("result")
if (txStatus == "1" && txResult != null) {
android.util.Log.d("TssRepository", "[SYNC-KAVASCAN] Processing ${txResult.size()} native transactions")
for (item in txResult) {
try {
val txObj = item.asJsonObject
val txHash = txObj.get("hash")?.asString ?: continue
// 检查是否已存在
val existing = transactionRecordDao.getRecordByTxHash(txHash)
if (existing != null) continue
val fromAddr = txObj.get("from")?.asString?.lowercase() ?: continue
val toAddr = txObj.get("to")?.asString?.lowercase() ?: continue
val value = txObj.get("value")?.asString ?: "0"
val gasPrice = txObj.get("gasPrice")?.asString ?: "0"
val gasUsed = txObj.get("gasUsed")?.asString ?: "0"
val blockNumber = txObj.get("blockNumber")?.asString?.toLongOrNull()
val timestamp = txObj.get("timeStamp")?.asString?.toLongOrNull()?.times(1000)
val isError = txObj.get("isError")?.asString
// 将 value 从 wei 转换为 KAVA
val amount = java.math.BigInteger(value)
.toBigDecimal()
.divide(java.math.BigDecimal("1000000000000000000"))
.stripTrailingZeros()
.toPlainString()
// 只保存有 value 的交易
if (java.math.BigInteger(value) > java.math.BigInteger.ZERO) {
val direction = if (fromAddr == address.lowercase()) "SENT" else "RECEIVED"
val entity = TransactionRecordEntity(
shareId = shareId,
fromAddress = fromAddr,
toAddress = toAddr,
amount = amount,
tokenType = TokenType.KAVA.name,
txHash = txHash,
gasPrice = gasPrice,
gasUsed = gasUsed,
txFee = "",
status = if (isError == "0") "CONFIRMED" else "FAILED",
direction = direction,
blockNumber = blockNumber,
createdAt = timestamp ?: System.currentTimeMillis(),
confirmedAt = timestamp ?: System.currentTimeMillis()
)
transactionRecordDao.insertRecord(entity)
totalSynced++
}
} catch (e: Exception) {
android.util.Log.w("TssRepository", "[SYNC-KAVASCAN] Error parsing native tx: ${e.message}")
}
}
} else {
android.util.Log.d("TssRepository", "[SYNC-KAVASCAN] Native tx response: status=$txStatus")
}
} else {
android.util.Log.e("TssRepository", "[SYNC-KAVASCAN] Native tx request failed: code=${txResponse.code}")
}
android.util.Log.d("TssRepository", "[SYNC-KAVASCAN] Completed: synced $totalSynced transactions")
Result.success(totalSynced) Result.success(totalSynced)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("TssRepository", "[SYNC-BLOCKSCOUT] Error: ${e.message}", e) android.util.Log.e("TssRepository", "[SYNC-KAVASCAN] Error: ${e.message}", e)
Result.failure(e) Result.failure(e)
} }
} }