diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt index 36cb0a847a..2f9e88889b 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt @@ -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> = 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 = 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() + + 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) } }