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:
Kayos 2026-03-28 14:12:58 -07:00
parent 9613a1e6fc
commit efcc9cb841

View file

@ -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)
}
}