fix(wallet): use direct HTTP calls for Koios API
The cardano-client-lib KoiosBackendService was returning empty responses for funded addresses because it uses an outdated API format. This fix: - Uses OkHttp with direct POST requests to Koios v1 endpoints - Correctly formats requests with _addresses array in body - Parses JSON responses to extract balance and UTXOs - Keeps cardano-client-lib backend for tx submission and protocol params Tested with preprod address showing 10B lovelace balance correctly.
This commit is contained in:
parent
9613a1e6fc
commit
efcc9cb841
1 changed files with 189 additions and 90 deletions
|
|
@ -23,10 +23,18 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Cardano blockchain client using the Koios public API.
|
||||
* Uses direct HTTP calls for reliable API compatibility.
|
||||
*/
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
||||
|
|
@ -36,8 +44,18 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
|||
private const val INITIAL_BACKOFF_MS = 1000L
|
||||
private const val MAX_BACKOFF_MS = 10000L
|
||||
private const val MIN_REQUEST_INTERVAL_MS = 100L
|
||||
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||
}
|
||||
|
||||
private val httpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Fallback to cardano-client-lib for protocol params and tx submission
|
||||
private val backendService: BackendService by lazy {
|
||||
Timber.tag(TAG).d("Initializing Koios backend for ${CardanoNetworkConfig.NETWORK_NAME}")
|
||||
KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
|
||||
|
|
@ -51,63 +69,94 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
|||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val result = backendService.addressService.getAddressInfo(address)
|
||||
Timber.tag(TAG).d("getBalance result: isSuccessful=${result.isSuccessful}, response=${result.response?.take(200)}")
|
||||
when {
|
||||
result.isSuccessful -> {
|
||||
val info = result.value
|
||||
val lovelace = info.amount
|
||||
?.find { it.unit == "lovelace" }
|
||||
?.quantity
|
||||
?.toLong()
|
||||
?: 0L
|
||||
Result.success(lovelace)
|
||||
}
|
||||
result.response?.contains("Empty") == true -> {
|
||||
// Empty response means unfunded address - return 0 balance
|
||||
Timber.tag(TAG).d("Address has no history, returning 0 balance")
|
||||
Result.success(0L)
|
||||
}
|
||||
else -> {
|
||||
Result.failure(parseError(result.response))
|
||||
}
|
||||
// Use direct HTTP POST to Koios address_info endpoint
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}address_info"
|
||||
val body = JSONObject().apply {
|
||||
put("_addresses", JSONArray().put(address))
|
||||
}.toString()
|
||||
|
||||
Timber.tag(TAG).d("getBalance calling: $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
Timber.tag(TAG).d("getBalance response: code=${response.code}, body=${responseBody.take(500)}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
if (jsonArray.length() == 0) {
|
||||
// No data means unfunded address
|
||||
Timber.tag(TAG).d("Address not found in response, returning 0")
|
||||
return@withContext Result.success(0L)
|
||||
}
|
||||
|
||||
val addressInfo = jsonArray.getJSONObject(0)
|
||||
val balance = addressInfo.optString("balance", "0").toLongOrNull() ?: 0L
|
||||
Timber.tag(TAG).d("getBalance result: $balance lovelace")
|
||||
Result.success(balance)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getUtxos(address: String): Result<List<Utxo>> =
|
||||
withRetry("getUtxos($address)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val result = backendService.utxoService.getUtxos(address, 100, 1)
|
||||
when {
|
||||
result.isSuccessful -> {
|
||||
val utxos = result.value.map { utxo ->
|
||||
val lovelace = utxo.amount
|
||||
?.find { it.unit == "lovelace" }
|
||||
?.quantity
|
||||
?.toLong()
|
||||
?: 0L
|
||||
// Use direct HTTP POST to Koios address_info endpoint (includes utxo_set)
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}address_info"
|
||||
val body = JSONObject().apply {
|
||||
put("_addresses", JSONArray().put(address))
|
||||
}.toString()
|
||||
|
||||
Utxo(
|
||||
txHash = utxo.txHash,
|
||||
outputIndex = utxo.outputIndex,
|
||||
amount = lovelace,
|
||||
address = address,
|
||||
)
|
||||
}
|
||||
Result.success(utxos)
|
||||
}
|
||||
result.response?.contains("Empty") == true -> {
|
||||
// Empty response means no UTXOs - return empty list
|
||||
Result.success(emptyList())
|
||||
}
|
||||
else -> {
|
||||
Result.failure(parseError(result.response))
|
||||
}
|
||||
Timber.tag(TAG).d("getUtxos calling: $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
if (jsonArray.length() == 0) {
|
||||
return@withContext Result.success(emptyList())
|
||||
}
|
||||
|
||||
val addressInfo = jsonArray.getJSONObject(0)
|
||||
val utxoSet = addressInfo.optJSONArray("utxo_set") ?: JSONArray()
|
||||
|
||||
val utxos = (0 until utxoSet.length()).map { i ->
|
||||
val utxoJson = utxoSet.getJSONObject(i)
|
||||
val lovelace = utxoJson.optString("value", "0").toLongOrNull() ?: 0L
|
||||
Utxo(
|
||||
txHash = utxoJson.getString("tx_hash"),
|
||||
outputIndex = utxoJson.getInt("tx_index"),
|
||||
amount = lovelace,
|
||||
address = address,
|
||||
)
|
||||
}
|
||||
|
||||
Timber.tag(TAG).d("getUtxos result: ${utxos.size} UTXOs, total=${utxos.sumOf { it.amount }}")
|
||||
Result.success(utxos)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun submitTx(signedTxCbor: String): Result<String> =
|
||||
withRetry("submitTx") {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
@ -189,29 +238,61 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
|||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val result = backendService.addressService.getAddressInfo(address)
|
||||
Timber.tag(TAG).d("getBalance result: isSuccessful=${result.isSuccessful}, response=${result.response?.take(200)}")
|
||||
if (result.isSuccessful) {
|
||||
val info = result.value
|
||||
val assets = info.amount
|
||||
?.filter { it.unit != "lovelace" }
|
||||
?.map { amount ->
|
||||
// Unit format is policyId + assetNameHex
|
||||
val policyId = amount.unit.take(56)
|
||||
val assetNameHex = amount.unit.drop(56)
|
||||
NativeAsset(
|
||||
policyId = policyId,
|
||||
assetName = assetNameHex,
|
||||
quantity = amount.quantity?.toLong() ?: 0L,
|
||||
displayName = null,
|
||||
fingerprint = null,
|
||||
)
|
||||
}
|
||||
?: emptyList()
|
||||
Result.success(assets)
|
||||
} else {
|
||||
Result.failure(parseError(result.response))
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}address_info"
|
||||
val body = JSONObject().apply {
|
||||
put("_addresses", JSONArray().put(address))
|
||||
}.toString()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
if (jsonArray.length() == 0) {
|
||||
return@withContext Result.success(emptyList())
|
||||
}
|
||||
|
||||
val addressInfo = jsonArray.getJSONObject(0)
|
||||
val utxoSet = addressInfo.optJSONArray("utxo_set") ?: JSONArray()
|
||||
|
||||
val assetMap = mutableMapOf<String, Long>()
|
||||
|
||||
for (i in 0 until utxoSet.length()) {
|
||||
val utxoJson = utxoSet.getJSONObject(i)
|
||||
val assetList = utxoJson.optJSONArray("asset_list") ?: continue
|
||||
|
||||
for (j in 0 until assetList.length()) {
|
||||
val asset = assetList.getJSONObject(j)
|
||||
val policyId = asset.getString("policy_id")
|
||||
val assetName = asset.optString("asset_name", "")
|
||||
val quantity = asset.optString("quantity", "0").toLongOrNull() ?: 0L
|
||||
val key = "$policyId$assetName"
|
||||
assetMap[key] = (assetMap[key] ?: 0L) + quantity
|
||||
}
|
||||
}
|
||||
|
||||
val assets = assetMap.map { (key, quantity) ->
|
||||
val policyId = key.take(56)
|
||||
val assetNameHex = key.drop(56)
|
||||
NativeAsset(
|
||||
policyId = policyId,
|
||||
assetName = assetNameHex,
|
||||
quantity = quantity,
|
||||
displayName = null,
|
||||
fingerprint = null,
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(assets)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,21 +301,36 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
|||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val result = backendService.addressService.getTransactions(address, limit, 1, null)
|
||||
if (result.isSuccessful) {
|
||||
val txs = result.value.map { tx ->
|
||||
TxSummary(
|
||||
txHash = tx.txHash,
|
||||
blockTime = tx.blockTime ?: 0L,
|
||||
totalOutput = 0L, // Would need additional API call to get output amount
|
||||
fee = 0L, // Would need additional API call
|
||||
direction = TxSummary.Direction.RECEIVED, // Simplified - would need UTXO analysis
|
||||
)
|
||||
}
|
||||
Result.success(txs)
|
||||
} else {
|
||||
Result.failure(parseError(result.response))
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}address_txs"
|
||||
val body = JSONObject().apply {
|
||||
put("_addresses", JSONArray().put(address))
|
||||
}.toString()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
val txs = (0 until minOf(jsonArray.length(), limit)).map { i ->
|
||||
val txJson = jsonArray.getJSONObject(i)
|
||||
TxSummary(
|
||||
txHash = txJson.getString("tx_hash"),
|
||||
blockTime = txJson.optLong("block_time", 0L),
|
||||
totalOutput = 0L,
|
||||
fee = 0L,
|
||||
direction = TxSummary.Direction.RECEIVED,
|
||||
)
|
||||
}
|
||||
Result.success(txs)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,24 +393,27 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
|||
}
|
||||
}
|
||||
|
||||
private fun parseHttpError(code: Int, response: String): CardanoException {
|
||||
return when (code) {
|
||||
429 -> CardanoException.RateLimitException()
|
||||
404 -> CardanoException.ApiException("Resource not found", response)
|
||||
in 500..599 -> CardanoException.NetworkException("Server error", statusCode = code)
|
||||
else -> CardanoException.ApiException("HTTP $code: $response", response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseError(response: String?): CardanoException {
|
||||
if (response == null) {
|
||||
return CardanoException.NetworkException("No response from server")
|
||||
}
|
||||
|
||||
return when {
|
||||
response.contains("429") -> {
|
||||
CardanoException.RateLimitException()
|
||||
}
|
||||
response.contains("404") -> {
|
||||
CardanoException.ApiException("Resource not found", response)
|
||||
}
|
||||
response.contains("429") -> CardanoException.RateLimitException()
|
||||
response.contains("404") -> CardanoException.ApiException("Resource not found", response)
|
||||
response.contains("500") || response.contains("502") || response.contains("503") -> {
|
||||
CardanoException.NetworkException("Server error", statusCode = 500)
|
||||
}
|
||||
else -> {
|
||||
CardanoException.ApiException("API error: $response", response)
|
||||
}
|
||||
else -> CardanoException.ApiException("API error: $response", response)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue