From 3cd07abe5552375b624b9880c4be1fd9de5cf713 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Tue, 7 Jul 2026 07:08:22 -0700 Subject: [PATCH 01/34] Add Tron (TRC20/TRX) send/transfer support Reuses the existing generic fee-sufficiency validation (sufficientTransferableBalanceToPayOriginFee) and send flow unchanged. Transaction construction goes through TronGrid's own createtransaction/triggersmartcontract endpoints (verified live against Shasta testnet) rather than a hand-rolled protobuf implementation; signing reuses the existing secp256k1 path (SignerPayloadRaw.skipMessageHashing) already used for Ethereum, no new crypto library. Also fixes a Phase 1 gap: Chain.isValidAddress() had no isTronBased branch, so every Tron send would have failed address validation. No Energy staking/delegation/rental UI - only Tron's default protocol behavior (burn TRX to cover Bandwidth/Energy shortfall). --- .../nova/common/utils/TronAddress.kt | 19 ++ .../nova/common/utils/TronAddressTest.kt | 12 + .../feature_account_api/data/model/Fee.kt | 13 + .../assets/tranfers/TransactionExecution.kt | 2 + .../transfers/trc20/Trc20AssetTransfers.kt | 99 +++++++ .../tronNative/TronNativeAssetTransfers.kt | 90 +++++++ .../data/network/tron/RetrofitTronGridApi.kt | 35 +++ .../data/network/tron/TronGridApi.kt | 179 +++++++++++++ .../tron/model/TronTransactionModels.kt | 116 ++++++++ .../transaction/RealTronTransactionService.kt | 252 ++++++++++++++++++ .../tron/transaction/Trc20TransferAbi.kt | 39 +++ .../transaction/TronTransactionService.kt | 55 ++++ .../di/WalletFeatureDependencies.kt | 3 + .../di/modules/TronAssetsModule.kt | 56 +++- .../tron/transaction/Trc20TransferAbiTest.kt | 52 ++++ .../nova/runtime/ext/ChainExt.kt | 19 +- 16 files changed, 1026 insertions(+), 15 deletions(-) create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/trc20/Trc20AssetTransfers.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/tronNative/TronNativeAssetTransfers.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt create mode 100644 feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt b/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt index 3de6e08e..70c01f05 100644 --- a/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt +++ b/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt @@ -2,6 +2,7 @@ package io.novafoundation.nova.common.utils import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.extensions.toHexString import io.novasama.substrate_sdk_android.runtime.AccountId import java.math.BigInteger @@ -113,3 +114,21 @@ fun String.tronAddressToAccountId(): AccountId { fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess fun emptyTronAccountId() = ByteArray(20) { 1 } + +/** + * Hex form of a Tron address (`0x41` prefix byte ++ accountId, hex-encoded, no `0x` prefix), e.g. + * `41a614f803b6fd780986a42c78ec9c7f77e6ded13c`. This is the format TronGrid's `/wallet/*` transaction + * construction/broadcast endpoints expect when called with `"visible": false` (as opposed to the human-facing + * Base58Check form used by the `/v1/accounts/{address}` balance endpoint and by [toTronAddress]). + */ +fun AccountId.toTronHexAddress(): String { + require(size == 20) { "Tron account id must be 20 bytes, got $size" } + + return byteArrayOf(TRON_ADDRESS_PREFIX_BYTE).toHexString(withPrefix = false) + toHexString(withPrefix = false) +} + +/** + * Converts a human-facing Base58Check Tron address (e.g. a TRC20 `contractAddress` from chain config) directly + * into the hex form described in [toTronHexAddress]. + */ +fun String.tronAddressToHexAddress(): String = tronAddressToAccountId().toTronHexAddress() diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt index d036c57f..2bec4a3c 100644 --- a/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt +++ b/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt @@ -44,6 +44,18 @@ class TronAddressTest { assertTrue(decodedBack.contentEquals(accountId)) } + @Test + fun `toTronHexAddress should produce the known hex form`() { + val accountId = knownTronAddressHex.fromHex().copyOfRange(1, 21) + + assertEquals(knownTronAddressHex, accountId.toTronHexAddress()) + } + + @Test + fun `tronAddressToHexAddress should produce the known hex form directly from a Base58 address`() { + assertEquals(knownTronAddressHex, knownTronAddress.tronAddressToHexAddress()) + } + @Test fun `isValidTronAddress should accept known good address`() { assertTrue(knownTronAddress.isValidTronAddress()) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt index d683e7fa..da9fcb16 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt @@ -63,6 +63,19 @@ class SubstrateFee( override val asset: Chain.Asset ) : Fee +/** + * Fee for a Tron transaction (native TRX or TRC-20), always denominated in TRX (sun), regardless of which asset + * is being sent - Tron has no separate "gas token" concept, network resources (bandwidth/energy) are always + * burned as TRX. [amount] is this client's own estimate of that burn (see `RealTronTransactionService`); the + * network only ever burns what it actually uses, so the real cost can be lower, but never higher than what this + * client authorized via `fee_limit` when submitting. + */ +class TronFee( + override val amount: BigInteger, + override val submissionOrigin: SubmissionOrigin, + override val asset: Chain.Asset +) : Fee + class SubstrateFeeBase( override val amount: BigInteger, override val asset: Chain.Asset diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt index b4ec88b6..1bc5d98d 100644 --- a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt @@ -8,4 +8,6 @@ sealed interface TransactionExecution { class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution + + class Tron(val hash: String) : TransactionExecution } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/trc20/Trc20AssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/trc20/Trc20AssetTransfers.kt new file mode 100644 index 00000000..7fdc9929 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/trc20/Trc20AssetTransfers.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.trc20 + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionIntent +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.ext.requireTrc20 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +/** + * TRC-20 token transfer (e.g. USDT-TRC20). The fee is always denominated in native TRX, never in the TRC-20 + * token being sent - same pattern as an ERC-20 transfer's fee being paid in ETH, not the ERC-20 token (compare + * [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmErc20.EvmErc20AssetTransfers]). + * This falls out for free from [TronTransactionService.calculateFee] always returning a [io.novafoundation.nova.feature_account_api.data.model.TronFee] + * denominated in `chain.commissionAsset` (native TRX), combined with the fully-generic + * [sufficientTransferableBalanceToPayOriginFee] validation checking that commission asset's balance regardless + * of which asset is actually being transferred. + */ +class Trc20AssetTransfers( + private val tronTransactionService: TronTransactionService, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + recipientCanAcceptTransfer(assetSourceRegistry) + + checkForFeeChanges(assetSourceRegistry, coroutineScope) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + return tronTransactionService.calculateFee( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + intent = transfer.intoTrc20Intent() + ) + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return tronTransactionService.transact( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + intent = transfer.intoTrc20Intent() + ) + } + + override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return tronTransactionService.transactAndAwaitExecution( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + intent = transfer.intoTrc20Intent() + ) + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } + + private fun AssetTransfer.intoTrc20Intent(): TronTransactionIntent.Trc20Transfer { + val trc20 = originChainAsset.requireTrc20() + + return TronTransactionIntent.Trc20Transfer(trc20.contractAddress, amountInPlanks) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/tronNative/TronNativeAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/tronNative/TronNativeAssetTransfers.kt new file mode 100644 index 00000000..3618e7cd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/tronNative/TronNativeAssetTransfers.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.tronNative + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionIntent +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +/** + * Native TRX transfer. No Energy/Bandwidth staking or rental is implemented (out of scope for this send-only + * phase) - Tron's default protocol behavior (automatically burning TRX when free Bandwidth is insufficient) is + * all that's needed; [TronTransactionService] estimates that burn and reports it as [Fee], and the generic + * [sufficientTransferableBalanceToPayOriginFee] validation (same one EVM native/ERC-20 transfers already reuse) + * blocks the send if the TRX balance can't cover it. + */ +class TronNativeAssetTransfers( + private val tronTransactionService: TronTransactionService, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + recipientCanAcceptTransfer(assetSourceRegistry) + + checkForFeeChanges(assetSourceRegistry, coroutineScope) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + return tronTransactionService.calculateFee( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + intent = TronTransactionIntent.Native(transfer.amountInPlanks) + ) + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return tronTransactionService.transact( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + intent = TronTransactionIntent.Native(transfer.amountInPlanks) + ) + } + + override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return tronTransactionService.transactAndAwaitExecution( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + intent = TronTransactionIntent.Native(transfer.amountInPlanks) + ) + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt index 887cd961..15025fcd 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt @@ -1,9 +1,20 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.tron import io.novafoundation.nova.common.data.network.UserAgent +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronChainParametersResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Headers +import retrofit2.http.POST import retrofit2.http.Url interface RetrofitTronGridApi { @@ -11,4 +22,28 @@ interface RetrofitTronGridApi { @GET @Headers(UserAgent.NOVA) suspend fun getAccount(@Url url: String): TronAccountResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun createTransaction(@Url url: String, @Body body: TronCreateTransactionRequest): TronUnsignedTransactionResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun triggerConstantContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun triggerSmartContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun broadcastTransaction(@Url url: String, @Body body: TronBroadcastRequest): TronBroadcastResponse + + @GET + @Headers(UserAgent.NOVA) + suspend fun getChainParameters(@Url url: String): TronChainParametersResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun getAccountResource(@Url url: String, @Body body: TronAddressRequest): TronAccountResourceResponse } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt index 22853ca7..e052fb0c 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt @@ -1,13 +1,82 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.tron import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novasama.substrate_sdk_android.extensions.fromHex import java.math.BigInteger +/** + * Thrown whenever TronGrid reports a failure via an HTTP-200 body (rather than an HTTP error status), which is + * how most `/wallet/*` endpoints signal validation/execution failures, e.g. + * `{"Error": "... no OwnerAccount."}` from `createtransaction`, or + * `{"code": "CONTRACT_VALIDATE_ERROR", "message": ""}` from `broadcasttransaction`. + */ +class TronApiException(message: String) : Exception(message) + interface TronGridApi { suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance + + /** + * Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`. + * [ownerHexAddress]/[toHexAddress] must be in hex form (`41`-prefixed), matching `visible: false`. + * + * Note: TronGrid refuses to build this for an owner account that has never been activated on-chain + * (confirmed live: returns `{"Error": "... no OwnerAccount."}`) - unlike [triggerSmartContract], which + * happily builds a transaction for an unfunded/unactivated owner. + */ + suspend fun createNativeTransfer(baseUrl: String, ownerHexAddress: String, toHexAddress: String, amountSun: BigInteger): TronUnsignedTransactionResponse + + /** + * Read-only dry run via `POST /wallet/triggerconstantcontract` - does not require the owner account to hold + * any TRX and does not touch chain state. Used to estimate the `energy_used` an actual TRC-20 call would + * cost (see [TronTriggerContractResponse.energyUsed]). + */ + suspend fun triggerConstantContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String + ): TronTriggerContractResponse + + /** + * Builds an unsigned TRC-20 contract call via `POST /wallet/triggersmartcontract`. Unlike + * [createNativeTransfer], this works even for an owner account that has never been activated on-chain + * (confirmed live). + */ + suspend fun triggerSmartContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String, + feeLimitSun: BigInteger + ): TronTriggerContractResponse + + /** + * Signs-and-submits via `POST /wallet/broadcasttransaction`. The full [unsigned] transaction (including its + * `raw_data` object, not just `raw_data_hex`) must be echoed back verbatim alongside the signature - sending + * only `raw_data_hex` + `signature` was confirmed live to fail with a deserialization error on TronGrid's side. + * + * @return the transaction hash (`txID`) on success. + * @throws TronApiException if TronGrid rejects the broadcast (invalid signature, insufficient balance, etc.) + */ + suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String + + /** `key -> value` map from `GET /wallet/getchainparameters`, e.g. `getEnergyFee` (sun/energy), `getTransactionFee` (sun/byte). */ + suspend fun getChainParameters(baseUrl: String): Map + + suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse } class RealTronGridApi( @@ -29,6 +98,93 @@ class RealTronGridApi( return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO } + override suspend fun createNativeTransfer(baseUrl: String, ownerHexAddress: String, toHexAddress: String, amountSun: BigInteger): TronUnsignedTransactionResponse { + val request = TronCreateTransactionRequest( + ownerAddress = ownerHexAddress, + toAddress = toHexAddress, + amount = amountSun.toLongExactOrThrow("amount") + ) + + val response = retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request) + + return response.requireConstructed() + } + + override suspend fun triggerConstantContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String + ): TronTriggerContractResponse { + val request = TronTriggerContractRequest( + ownerAddress = ownerHexAddress, + contractAddress = contractHexAddress, + functionSelector = functionSelector, + parameter = parameterHex + ) + + return retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request) + } + + override suspend fun triggerSmartContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String, + feeLimitSun: BigInteger + ): TronTriggerContractResponse { + val request = TronTriggerContractRequest( + ownerAddress = ownerHexAddress, + contractAddress = contractHexAddress, + functionSelector = functionSelector, + parameter = parameterHex, + feeLimit = feeLimitSun.toLongExactOrThrow("feeLimit") + ) + + val response = retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request) + + if (response.result?.result != true) { + throw TronApiException(response.result?.message ?: response.result?.code ?: "triggersmartcontract failed without a message") + } + + // Only validate that a transaction was actually returned - callers read [TronTriggerContractResponse.transaction] themselves. + response.transaction?.requireConstructed() + + return response + } + + override suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String { + val txId = requireNotNull(unsigned.txID) { "Cannot broadcast a transaction without a txID" } + + val request = TronBroadcastRequest( + visible = unsigned.visible ?: false, + txID = txId, + rawData = requireNotNull(unsigned.rawData) { "Cannot broadcast a transaction without raw_data" }, + rawDataHex = requireNotNull(unsigned.rawDataHex) { "Cannot broadcast a transaction without raw_data_hex" }, + signature = listOf(signatureHex) + ) + + val response = retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request) + + if (response.result != true) { + throw TronApiException(response.decodeErrorMessage()) + } + + return response.txid ?: txId + } + + override suspend fun getChainParameters(baseUrl: String): Map { + return retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters")) + .chainParameter + .associate { it.key to it.value } + } + + override suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse { + return retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex)) + } + private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount( url = accountUrl(baseUrl, address) ).data?.firstOrNull() @@ -36,4 +192,27 @@ class RealTronGridApi( private fun accountUrl(baseUrl: String, address: String): String { return "${baseUrl.trimEnd('/')}/v1/accounts/$address" } + + private fun walletUrl(baseUrl: String, method: String): String { + return "${baseUrl.trimEnd('/')}/wallet/$method" + } + + private fun TronUnsignedTransactionResponse.requireConstructed(): TronUnsignedTransactionResponse { + if (error != null) throw TronApiException(error) + requireNotNull(rawDataHex) { "TronGrid returned no raw_data_hex and no Error" } + requireNotNull(txID) { "TronGrid returned no txID and no Error" } + + return this + } + + private fun TronBroadcastResponse.decodeErrorMessage(): String { + val decodedMessage = message?.let { hex -> runCatching { hex.fromHex().decodeToString() }.getOrNull() } + + return decodedMessage ?: code ?: "broadcasttransaction failed without a message" + } + + private fun BigInteger.toLongExactOrThrow(fieldName: String): Long { + return runCatching { longValueExact() } + .getOrElse { throw IllegalArgumentException("$fieldName overflows Long: $this") } + } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt new file mode 100644 index 00000000..742d4c83 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.model + +import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName + +/** + * Request/response shapes for TronGrid's transaction-construction/broadcast endpoints (`/wallet/*`). + * + * All requests are sent with `"visible": false`, i.e. addresses are hex-encoded (`41` prefix byte ++ 20-byte + * accountId, see `toTronHexAddress`) rather than Base58Check. Every shape below was confirmed against + * TronGrid's Shasta testnet (`https://api.shasta.trongrid.io`) with live HTTP calls - see the Phase 2 + * implementation notes for the exact request/response pairs that were captured. + */ + +class TronCreateTransactionRequest( + @SerializedName("owner_address") val ownerAddress: String, + @SerializedName("to_address") val toAddress: String, + val amount: Long, + val visible: Boolean = false, +) + +class TronTriggerContractRequest( + @SerializedName("owner_address") val ownerAddress: String, + @SerializedName("contract_address") val contractAddress: String, + @SerializedName("function_selector") val functionSelector: String, + val parameter: String, + @SerializedName("fee_limit") val feeLimit: Long? = null, + @SerializedName("call_value") val callValue: Long = 0, + val visible: Boolean = false, +) + +class TronAddressRequest( + val address: String, + val visible: Boolean = false, +) + +/** + * Shape of the unsigned transaction returned by both `/wallet/createtransaction` (flat, at the top level) and + * `/wallet/triggersmartcontract`/`/wallet/triggerconstantcontract` (nested under a `transaction` key - see + * [TronTriggerContractResponse]). + * + * `rawData` is kept as an opaque [JsonObject] rather than being modeled field-by-field: its contents differ + * between contract types (`TransferContract` vs `TriggerSmartContract`) and it is never interpreted by this + * client - it is only ever echoed back verbatim into the broadcast request alongside the signature. The + * cryptographically-authoritative value is [rawDataHex] (`txID == sha256(rawDataHex bytes)`, confirmed live). + * + * `error` is populated (HTTP 200, not an HTTP error) when construction fails, e.g. an unactivated owner account + * trying to build a native TRX transfer returns `{"Error": "... no OwnerAccount."}`. + */ +class TronUnsignedTransactionResponse( + val visible: Boolean? = null, + val txID: String? = null, + @SerializedName("raw_data") val rawData: JsonObject? = null, + @SerializedName("raw_data_hex") val rawDataHex: String? = null, + @SerializedName("Error") val error: String? = null, +) + +class TronContractCallResult( + val result: Boolean = false, + val code: String? = null, + val message: String? = null, +) + +/** + * Response of both `/wallet/triggerconstantcontract` (read-only dry run, used for TRC20 fee/energy estimation) + * and `/wallet/triggersmartcontract` (real construction, used for the actual TRC20 transfer). + */ +class TronTriggerContractResponse( + val result: TronContractCallResult? = null, + @SerializedName("energy_used") val energyUsed: Long? = null, + @SerializedName("constant_result") val constantResult: List? = null, + val transaction: TronUnsignedTransactionResponse? = null, +) + +class TronBroadcastRequest( + val visible: Boolean, + val txID: String, + @SerializedName("raw_data") val rawData: JsonObject, + @SerializedName("raw_data_hex") val rawDataHex: String, + val signature: List, +) + +/** + * On success: `{"result": true, "txid": "..."}`. + * On failure: `{"code": "CONTRACT_VALIDATE_ERROR", "txid": "...", "message": ""}` - confirmed + * live by broadcasting a validly-signed but unfunded-account transaction, e.g. + * `message` hex-decodes to `"Contract validate error : account [...] does not exist"`. + */ +class TronBroadcastResponse( + val result: Boolean? = null, + val txid: String? = null, + val code: String? = null, + val message: String? = null, +) + +class TronChainParameter( + val key: String, + val value: Long = 0, +) + +class TronChainParametersResponse( + @SerializedName("chainParameter") val chainParameter: List = emptyList(), +) + +/** + * Subset of `/wallet/getaccountresource` fields relevant to fee estimation. Fields are omitted by TronGrid + * (rather than sent as `0`) when their value is zero - confirmed live - hence all default to `0`. + */ +class TronAccountResourceResponse( + val freeNetLimit: Long = 0, + val freeNetUsed: Long = 0, + @SerializedName("NetLimit") val netLimit: Long = 0, + @SerializedName("NetUsed") val netUsed: Long = 0, + @SerializedName("EnergyLimit") val energyLimit: Long = 0, + @SerializedName("EnergyUsed") val energyUsed: Long = 0, +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt new file mode 100644 index 00000000..1502d781 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt @@ -0,0 +1,252 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.sha256 +import io.novafoundation.nova.common.utils.toEcdsaSignatureData +import io.novafoundation.nova.common.utils.toTronHexAddress +import io.novafoundation.nova.common.utils.tronAddressToHexAddress +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.TronFee +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import java.math.BigInteger + +private const val ENERGY_FEE_PARAM_KEY = "getEnergyFee" +private const val TRANSACTION_FEE_PARAM_KEY = "getTransactionFee" + +// Fallbacks only used if a live `getchainparameters` call fails or is missing the expected key - both values are +// what was observed live on Shasta testnet at implementation time, which also match Tron's long-standing mainnet +// defaults; the primary path always fetches live values. +private val FALLBACK_ENERGY_FEE_SUN = BigInteger.valueOf(420) +private val FALLBACK_BANDWIDTH_FEE_SUN = BigInteger.valueOf(1000) + +// Used only if a triggerconstantcontract dry run fails outright (e.g. transient network error) and returns no +// energy_used at all - a conservative (intentionally high) stand-in for a simple TRC-20 transfer, which in +// practice costs on the order of 15-30k energy. Mirrors EvmErc20AssetTransfers' ERC_20_UPPER_GAS_LIMIT fallback. +private val FALLBACK_TRC20_ENERGY_UNITS = 65_000L +private val FALLBACK_TRC20_TX_SIZE_BYTES = 350L + +// fee_limit sent with triggersmartcontract: Tron only ever burns what a call actually uses (up to this cap), so +// setting this generously above our own estimate does not cost the user more - it only avoids an OUT_OF_ENERGY +// failure if our estimate undershoots. Bounded above as a sanity guard against a runaway estimate. +private val MIN_FEE_LIMIT_SUN = BigInteger.valueOf(15_000_000) // 15 TRX +private val MAX_FEE_LIMIT_SUN = BigInteger.valueOf(100_000_000) // 100 TRX + +private val EMPTY_RESOURCE = TronAccountResourceResponse() + +/** + * Builds, signs and broadcasts Tron transactions (native TRX and TRC-20) using TronGrid's own REST endpoints for + * construction/broadcast, and this app's existing ECDSA signing primitive for signing - no Tron protobuf + * (`Transaction`/`TransferContract`/`TriggerSmartContract`) encoding and no new crypto library were added. + * + * ## Construction + * - Native TRX: `POST /wallet/createtransaction` with `{owner_address, to_address, amount}` (all hex-encoded, + * `visible: false`). Requires the owner account to already be activated on-chain (confirmed live: an + * unactivated owner gets `{"Error": "... no OwnerAccount."}`) - in practice this is never hit here, since a + * user can only reach the send flow with a positive TRX balance to send from, which itself implies the account + * was already activated by an earlier incoming transfer. + * - TRC-20: `POST /wallet/triggersmartcontract` with the ABI-encoded `transfer(address,uint256)` call (see + * [Trc20TransferAbi]). Unlike `createtransaction`, this was confirmed live to work even for a + * never-activated owner account. + * + * ## Signing + * Tron's signature is `ECDSA_sign(privateKey, sha256(raw_data))` over secp256k1 - the same curve/primitive this + * app already uses for Ethereum. [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw] + * (backed by `substrate_sdk_android`'s `Signer.sign(MultiChainEncryption.Ethereum, message, keypair, skipHashing)` + * -> `web3j`'s `Sign.signMessage(hash, keyPair, needToHash = false)`) already supports signing a pre-computed + * hash directly via `SignerPayloadRaw.skipMessageHashing = true` - this is exactly the primitive Ethereum-style + * raw-hash signing needs, and it is reused as-is here. No new cryptographic code or library was added; only the + * hash fed into it differs from the EVM path (`sha256(raw_data)` here vs. an EIP-155 RLP-based digest there). + * + * `web3j`'s `Sign.signMessage` always left-pads `r`/`s` to exactly 32 bytes and encodes `v` as `27/28` (confirmed + * by reading `web3j`'s `Sign.java` source) - which is byte-for-byte the same `r(32) + s(32) + v(1)` = 65-byte + * compact signature format Tron expects (confirmed against `tronweb`'s own `ECKeySign` implementation, and + * independently confirmed live against Shasta testnet - see the Phase 2 implementation notes). + * + * ## Broadcast + * `POST /wallet/broadcasttransaction` with the full unsigned transaction object (not just `raw_data_hex`) plus + * `signature: [<65-byte hex signature>]`. + */ +class RealTronTransactionService( + private val accountRepository: AccountRepository, + private val signerProvider: SignerProvider, + private val tronGridApi: TronGridApi, +) : TronTransactionService { + + override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val baseUrl = chain.requireTronGridBaseUrl() + val ownerHex = ownerAccountId.toTronHexAddress() + + val feeSun = when (intent) { + is TronTransactionIntent.Native -> estimateNativeFee(baseUrl, ownerHex, recipient, intent.amountSun) + is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex(baseUrl, ownerHex, recipient, intent.contractAddress.tronAddressToHexAddress(), intent.amountSun) + } + + return TronFee(feeSun, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset) + } + + override suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result = runCatching { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val baseUrl = chain.requireTronGridBaseUrl() + val ownerHex = ownerAccountId.toTronHexAddress() + val recipientHex = recipient.toTronHexAddress() + + val unsigned = when (intent) { + is TronTransactionIntent.Native -> tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipientHex, intent.amountSun) + + is TronTransactionIntent.Trc20Transfer -> { + val contractHex = intent.contractAddress.tronAddressToHexAddress() + val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, intent.amountSun) + val feeLimit = feeLimitFor(presetFee, baseUrl, ownerHex, recipient, contractHex, intent.amountSun) + + tronGridApi.triggerSmartContract( + baseUrl = baseUrl, + ownerHexAddress = ownerHex, + contractHexAddress = contractHex, + functionSelector = Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, + parameterHex = parameterHex, + feeLimitSun = feeLimit + ).transaction ?: error("TronGrid returned no transaction from triggersmartcontract") + } + } + + val txHash = signAndBroadcast(baseUrl, unsigned, submittingMetaAccount, ownerAccountId) + + ExtrinsicSubmission( + hash = txHash, + submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId), + callExecutionType = CallExecutionType.IMMEDIATE, + submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE) + ) + } + + override suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result { + // Tron transactions execute atomically with inclusion (no separate "prepare" step, same as EVM) - so + // successful broadcast is already a strong signal. We do not poll for block confirmation here since + // this method sits outside the primary send flow's critical path (`SendInteractor`/`RealSendUseCase` + // only ever call `transact`, not this) - see Phase 2 implementation notes for what remains unverified. + return transact(chain, origin, recipient, presetFee, intent).map { TransactionExecution.Tron(it.hash) } + } + + private suspend fun signAndBroadcast( + baseUrl: String, + unsigned: TronUnsignedTransactionResponse, + metaAccount: MetaAccount, + ownerAccountId: AccountId + ): String { + val rawDataHex = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" } + val messageHash = rawDataHex.fromHex().sha256() + + check(unsigned.txID == null || messageHash.toHexString(withPrefix = false) == unsigned.txID) { + "sha256(raw_data) does not match the txID TronGrid reported - refusing to sign a possibly-tampered transaction" + } + + val signer = signerProvider.rootSignerFor(metaAccount) + val signedRaw = signer.signRaw(SignerPayloadRaw(message = messageHash, accountId = ownerAccountId, skipMessageHashing = true)) + val signature = signedRaw.toEcdsaSignatureData() + + // Tron's compact signature format is r(32) + s(32) + v(1), v = 27/28 - byte-for-byte identical to what + // web3j's Sign.SignatureData already produces for Ethereum signing (see class doc for verification notes). + val signatureBytes = signature.r + signature.s + signature.v + val signatureHex = signatureBytes.toHexString(withPrefix = false) + + return tronGridApi.broadcastTransaction(baseUrl, unsigned, signatureHex) + } + + private suspend fun feeLimitFor( + presetFee: Fee?, + baseUrl: String, + ownerHex: String, + recipient: AccountId, + contractHex: String, + amountSun: BigInteger + ): BigInteger { + val estimatedFee = presetFee?.castOrNull()?.amount + ?: estimateTrc20FeeFromContractHex(baseUrl, ownerHex, recipient, contractHex, amountSun) + + return (estimatedFee * BigInteger.valueOf(3)).coerceIn(MIN_FEE_LIMIT_SUN, MAX_FEE_LIMIT_SUN) + } + + private suspend fun estimateNativeFee(baseUrl: String, ownerHex: String, recipient: AccountId, amountSun: BigInteger): BigInteger { + val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), amountSun) + val txSizeBytes = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }.length / 2 + + val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE) + val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN) + + val bandwidthShortfall = shortfall(txSizeBytes.toLong(), resource.availableBandwidth()) + + return bandwidthShortfall.toBigInteger() * bandwidthPrice + } + + private suspend fun estimateTrc20FeeFromContractHex(baseUrl: String, ownerHex: String, recipient: AccountId, contractHex: String, amountSun: BigInteger): BigInteger { + val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, amountSun) + + val dryRun = runCatching { + tronGridApi.triggerConstantContract(baseUrl, ownerHex, contractHex, Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, parameterHex) + }.getOrNull() + + // A dry-run revert (e.g. the sender doesn't yet hold the token) still reports the energy spent up to the + // revert point, which remains a meaningful (if slightly different) estimate - it is used as-is rather + // than special-cased, only a wholly-failed HTTP call falls back to the conservative constant. + val energyUsed = dryRun?.energyUsed ?: FALLBACK_TRC20_ENERGY_UNITS + val txSizeBytes = dryRun?.transaction?.rawDataHex?.let { it.length / 2L } ?: FALLBACK_TRC20_TX_SIZE_BYTES + + val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE) + val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN) + val energyPrice = chainParameterOrDefault(baseUrl, ENERGY_FEE_PARAM_KEY, FALLBACK_ENERGY_FEE_SUN) + + val bandwidthShortfall = shortfall(txSizeBytes, resource.availableBandwidth()) + val energyShortfall = shortfall(energyUsed, resource.availableEnergy()) + + return bandwidthShortfall.toBigInteger() * bandwidthPrice + energyShortfall.toBigInteger() * energyPrice + } + + private suspend fun chainParameterOrDefault(baseUrl: String, key: String, default: BigInteger): BigInteger { + val params = runCatching { tronGridApi.getChainParameters(baseUrl) }.getOrNull() + + return params?.get(key)?.toBigInteger() ?: default + } + + private fun shortfall(needed: Long, available: Long): Long = (needed - available.coerceAtLeast(0)).coerceAtLeast(0) + + private fun TronAccountResourceResponse.availableBandwidth(): Long = + (freeNetLimit - freeNetUsed).coerceAtLeast(0) + (netLimit - netUsed).coerceAtLeast(0) + + private fun TronAccountResourceResponse.availableEnergy(): Long = + (energyLimit - energyUsed).coerceAtLeast(0) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt new file mode 100644 index 00000000..90da3f41 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +/** + * Minimal, hand-written Solidity ABI encoding for the single call this client ever makes to a TRC-20 contract: + * `transfer(address,uint256)`. + * + * There is no pre-existing ABI-encoding utility reused here: Phase 1's TRC-20 balance reads + * (`Trc20AssetBalance`) go through TronGrid's `/v1/accounts` REST endpoint, not an on-chain `balanceOf` call - + * so no prior ABI-encoding code exists in this codebase. + * + * Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad + * each to 32 bytes and concatenate" - no dynamic-type/offset table is needed. The 4-byte function selector is + * intentionally NOT computed client-side: TronGrid accepts the human-readable `function_selector` string + * directly and hashes it server-side (confirmed live against Shasta testnet - a call with + * `function_selector: "transfer(address,uint256)"` and no client-computed selector correctly resolved to the + * standard `a9059cbb` selector in the resulting `raw_data`), which avoids needing a keccak256 implementation here. + */ +object Trc20TransferAbi { + + const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)" + + /** + * @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address - + * ABI-encoded Solidity `address` parameters use the bare 20-byte form, confirmed live). + */ + fun encodeTransferParameters(recipient: AccountId, amountSun: BigInteger): String { + require(recipient.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${recipient.size}" } + require(amountSun.signum() >= 0) { "Amount must not be negative, got $amountSun" } + + val addressParam = recipient.toHexString(withPrefix = false).padStart(64, '0') + val amountParam = amountSun.toString(16).padStart(64, '0') + + return addressParam + amountParam + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt new file mode 100644 index 00000000..2261418d --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +/** + * What kind of Tron transaction to build. Both cases ultimately burn TRX for bandwidth/energy per Tron's default + * protocol behavior - this service never stakes/rents Energy or Bandwidth, it only estimates the automatic burn + * and lets the caller (asset transfer validation) block the send if the user's TRX balance can't cover it. + */ +sealed class TronTransactionIntent { + + class Native(val amountSun: BigInteger) : TronTransactionIntent() + + /** @param contractAddress Base58Check TRC-20 contract address, as stored in chain config (`Type.Trc20.contractAddress`). */ + class Trc20Transfer(val contractAddress: String, val amountSun: BigInteger) : TronTransactionIntent() +} + +/** + * Mirrors [io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService]'s shape + * (calculateFee/transact/transactAndAwaitExecution over a sending origin), but for Tron. Unlike the EVM service, + * this lives entirely in `feature-wallet-impl` rather than being split across `feature-account-api`/`-impl`, + * since (for now, Phase 2 send-only scope) it is only ever consumed by `TronNativeAssetTransfers`/ + * `Trc20AssetTransfers` in this module, and it needs [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi], + * which itself lives in this module (feature-account-impl cannot depend on feature-wallet-impl). + * + * Construction goes through TronGrid's own `/wallet/createtransaction` and `/wallet/triggersmartcontract` + * endpoints rather than hand-rolled protobuf encoding - see `RealTronTransactionService` for details and the + * live-testnet verification notes. + */ +interface TronTransactionService { + + suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee + + suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result + + suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt index dd102795..96f5d25e 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt @@ -37,6 +37,7 @@ import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicServic import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope @@ -74,6 +75,8 @@ interface WalletFeatureDependencies { val evmTransactionService: EvmTransactionService + val signerProvider: SignerProvider + val chainAssetDao: ChainAssetDao val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt index 90f633e5..09608781 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt @@ -4,16 +4,22 @@ import dagger.Module import dagger.Provides import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.trc20.Trc20AssetBalance import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.TronNativeAssetBalance import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory -import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.UnsupportedAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.trc20.Trc20AssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.tronNative.TronNativeAssetTransfers import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.RealTronTransactionService +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService import javax.inject.Qualifier @Qualifier @@ -23,11 +29,17 @@ annotation class TronNativeAssets annotation class Trc20Assets /** - * Tron/TRC-20 support - Phase 1, read-only. + * Tron/TRC-20 support. * - * Only `balance` is implemented for real; `transfers`/`history` reuse the same `Unsupported*` stubs the rest of - * the app uses for asset types with no send/history support yet (see `UnsupportedAssetsModule`). This is - * intentional: no transaction-building/signing code exists for Tron yet - that is future, separate work. + * Phase 1 (read-only): `balance`. + * Phase 2 (send/transfer, this module): `transfers`, via [TronTransactionService] - construction/broadcast + * through TronGrid's own REST endpoints, signing through the app's existing Ethereum-style ECDSA signer (see + * `RealTronTransactionService` for the full verification notes). No Energy/Bandwidth staking or rental: only + * Tron's default protocol behavior (auto-burning TRX when free resources are insufficient) is estimated and + * enforced. + * + * `history` remains unsupported (out of scope for this phase, same as the rest of the app's `Unsupported*` + * stubs used for asset types without history support). */ @Module class TronAssetsModule { @@ -42,6 +54,18 @@ class TronAssetsModule { @FeatureScope fun provideTronGridApi(retrofitTronGridApi: RetrofitTronGridApi): TronGridApi = RealTronGridApi(retrofitTronGridApi) + @Provides + @FeatureScope + fun provideTronTransactionService( + accountRepository: AccountRepository, + signerProvider: SignerProvider, + tronGridApi: TronGridApi, + ): TronTransactionService = RealTronTransactionService( + accountRepository = accountRepository, + signerProvider = signerProvider, + tronGridApi = tronGridApi + ) + @Provides @FeatureScope fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi) @@ -50,15 +74,29 @@ class TronAssetsModule { @FeatureScope fun provideTrc20Balance(assetCache: AssetCache, tronGridApi: TronGridApi) = Trc20AssetBalance(assetCache, tronGridApi) + @Provides + @FeatureScope + fun provideTronNativeAssetTransfers( + tronTransactionService: TronTransactionService, + assetSourceRegistry: AssetSourceRegistry, + ) = TronNativeAssetTransfers(tronTransactionService, assetSourceRegistry) + + @Provides + @FeatureScope + fun provideTrc20AssetTransfers( + tronTransactionService: TronTransactionService, + assetSourceRegistry: AssetSourceRegistry, + ) = Trc20AssetTransfers(tronTransactionService, assetSourceRegistry) + @Provides @TronNativeAssets @FeatureScope fun provideTronNativeAssetSource( tronNativeAssetBalance: TronNativeAssetBalance, - unsupportedAssetTransfers: UnsupportedAssetTransfers, + tronNativeAssetTransfers: TronNativeAssetTransfers, unsupportedAssetHistory: UnsupportedAssetHistory, ): AssetSource = StaticAssetSource( - transfers = unsupportedAssetTransfers, + transfers = tronNativeAssetTransfers, balance = tronNativeAssetBalance, history = unsupportedAssetHistory ) @@ -68,10 +106,10 @@ class TronAssetsModule { @FeatureScope fun provideTrc20AssetSource( trc20AssetBalance: Trc20AssetBalance, - unsupportedAssetTransfers: UnsupportedAssetTransfers, + trc20AssetTransfers: Trc20AssetTransfers, unsupportedAssetHistory: UnsupportedAssetHistory, ): AssetSource = StaticAssetSource( - transfers = unsupportedAssetTransfers, + transfers = trc20AssetTransfers, balance = trc20AssetBalance, history = unsupportedAssetHistory ) diff --git a/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt new file mode 100644 index 00000000..b1d96244 --- /dev/null +++ b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novasama.substrate_sdk_android.extensions.fromHex +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigInteger + +class Trc20TransferAbiTest { + + /** + * Real request/response pair captured live against TronGrid's Shasta testnet + * (`POST https://api.shasta.trongrid.io/wallet/triggerconstantcontract`) for a `transfer(address,uint256)` + * call with recipient accountId `dfd8703a5c753e17ed52a96a29cea9d425538dfe` and amount `1000000` (sun) - + * TronGrid accepted this exact `parameter` value and correctly resolved `function_selector` to the standard + * `a9059cbb` selector in the resulting `raw_data.contract[0].parameter.value.data`. + */ + @Test + fun `encodeTransferParameters should match a live-verified TronGrid request`() { + val recipient = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex() + val amountSun = BigInteger.valueOf(1_000_000) + + val expectedParameter = "000000000000000000000000dfd8703a5c753e17ed52a96a29cea9d425538dfe" + + "00000000000000000000000000000000000000000000000000000000000f4240" + + assertEquals(expectedParameter, Trc20TransferAbi.encodeTransferParameters(recipient, amountSun)) + } + + @Test + fun `encodeTransferParameters should reject a negative amount`() { + val recipient = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex() + + assertThrowsIllegalArgument { + Trc20TransferAbi.encodeTransferParameters(recipient, BigInteger.valueOf(-1)) + } + } + + @Test + fun `encodeTransferParameters should reject a non-20-byte account id`() { + assertThrowsIllegalArgument { + Trc20TransferAbi.encodeTransferParameters(ByteArray(19), BigInteger.ONE) + } + } + + private fun assertThrowsIllegalArgument(block: () -> Unit) { + try { + block() + throw AssertionError("Expected IllegalArgumentException") + } catch (expected: IllegalArgumentException) { + // expected + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt index 2f7afc1f..51e1a19f 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt @@ -15,6 +15,7 @@ import io.novafoundation.nova.common.utils.findIsInstanceOrNull import io.novafoundation.nova.common.utils.formatNamed import io.novafoundation.nova.common.utils.removeHexPrefix import io.novafoundation.nova.common.utils.emptyTronAccountId +import io.novafoundation.nova.common.utils.isValidTronAddress import io.novafoundation.nova.common.utils.substrateAccountId import io.novafoundation.nova.common.utils.toTronAddress import io.novafoundation.nova.common.utils.tronAddressToAccountId @@ -361,13 +362,19 @@ fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress { fun Chain.isValidAddress(address: String): Boolean { return runCatching { - if (isEthereumBased) { - address.asEthereumAddress().isValid() - } else { - address.toAccountId() // verify supplied address can be converted to account id + when { + // Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two + // branches below would ever accept them, so this needs its own dedicated check. + isTronBased -> address.isValidTronAddress() - addressPrefix.toShort() == address.addressPrefix() || - legacyAddressPrefix?.toShort() == address.addressPrefix() + isEthereumBased -> address.asEthereumAddress().isValid() + + else -> { + address.toAccountId() // verify supplied address can be converted to account id + + addressPrefix.toShort() == address.addressPrefix() || + legacyAddressPrefix?.toShort() == address.addressPrefix() + } } }.getOrDefault(false) } From 4537be449602a28cc68dbdafbac2d9e1cf305a9a Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Tue, 7 Jul 2026 07:17:43 -0700 Subject: [PATCH 02/34] fix: ktlint violations (nested-comment doc text, line length) Doc comments describing "/wallet/*" endpoints were parsed as opening a nested block comment (Kotlin block comments nest, unlike Java/C), leaving the outer KDoc unterminated - not just a lint nit, ktlint flagged these as invalid Kotlin files. Reworded to drop the trailing "*". Also wraps two over-long function signatures/calls. --- .../nova/common/utils/TronAddress.kt | 2 +- .../data/network/tron/TronGridApi.kt | 9 +++++++-- .../network/tron/model/TronTransactionModels.kt | 2 +- .../transaction/RealTronTransactionService.kt | 16 ++++++++++++++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt b/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt index 70c01f05..5a299f6a 100644 --- a/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt +++ b/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt @@ -117,7 +117,7 @@ fun emptyTronAccountId() = ByteArray(20) { 1 } /** * Hex form of a Tron address (`0x41` prefix byte ++ accountId, hex-encoded, no `0x` prefix), e.g. - * `41a614f803b6fd780986a42c78ec9c7f77e6ded13c`. This is the format TronGrid's `/wallet/*` transaction + * `41a614f803b6fd780986a42c78ec9c7f77e6ded13c`. This is the format TronGrid's `/wallet/` transaction * construction/broadcast endpoints expect when called with `"visible": false` (as opposed to the human-facing * Base58Check form used by the `/v1/accounts/{address}` balance endpoint and by [toTronAddress]). */ diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt index e052fb0c..8fa93b1b 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt @@ -14,7 +14,7 @@ import java.math.BigInteger /** * Thrown whenever TronGrid reports a failure via an HTTP-200 body (rather than an HTTP error status), which is - * how most `/wallet/*` endpoints signal validation/execution failures, e.g. + * how most `/wallet/` endpoints signal validation/execution failures, e.g. * `{"Error": "... no OwnerAccount."}` from `createtransaction`, or * `{"code": "CONTRACT_VALIDATE_ERROR", "message": ""}` from `broadcasttransaction`. */ @@ -98,7 +98,12 @@ class RealTronGridApi( return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO } - override suspend fun createNativeTransfer(baseUrl: String, ownerHexAddress: String, toHexAddress: String, amountSun: BigInteger): TronUnsignedTransactionResponse { + override suspend fun createNativeTransfer( + baseUrl: String, + ownerHexAddress: String, + toHexAddress: String, + amountSun: BigInteger + ): TronUnsignedTransactionResponse { val request = TronCreateTransactionRequest( ownerAddress = ownerHexAddress, toAddress = toHexAddress, diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt index 742d4c83..e1d8bd3c 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt @@ -4,7 +4,7 @@ import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName /** - * Request/response shapes for TronGrid's transaction-construction/broadcast endpoints (`/wallet/*`). + * Request/response shapes for TronGrid's transaction-construction/broadcast endpoints (`/wallet/`). * * All requests are sent with `"visible": false`, i.e. addresses are hex-encoded (`41` prefix byte ++ 20-byte * accountId, see `toTronHexAddress`) rather than Base58Check. Every shape below was confirmed against diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt index 1502d781..2568dc1e 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt @@ -100,7 +100,13 @@ class RealTronTransactionService( val feeSun = when (intent) { is TronTransactionIntent.Native -> estimateNativeFee(baseUrl, ownerHex, recipient, intent.amountSun) - is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex(baseUrl, ownerHex, recipient, intent.contractAddress.tronAddressToHexAddress(), intent.amountSun) + is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex( + baseUrl, + ownerHex, + recipient, + intent.contractAddress.tronAddressToHexAddress(), + intent.amountSun + ) } return TronFee(feeSun, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset) @@ -213,7 +219,13 @@ class RealTronTransactionService( return bandwidthShortfall.toBigInteger() * bandwidthPrice } - private suspend fun estimateTrc20FeeFromContractHex(baseUrl: String, ownerHex: String, recipient: AccountId, contractHex: String, amountSun: BigInteger): BigInteger { + private suspend fun estimateTrc20FeeFromContractHex( + baseUrl: String, + ownerHex: String, + recipient: AccountId, + contractHex: String, + amountSun: BigInteger + ): BigInteger { val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, amountSun) val dryRun = runCatching { From 30a86418df778153cfdce61fc36993dad7db5ec1 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Tue, 7 Jul 2026 08:50:42 -0700 Subject: [PATCH 03/34] release: bump versionName to 1.1.2 (Tron send/transfer) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index e0319600..9e68957a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { // App version - versionName = '1.1.1' + versionName = '1.1.2' versionCode = 1 applicationId = "io.pezkuwichain.wallet" From 99ed9486fe3b058193891b6b57941543d21c119f Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Tue, 7 Jul 2026 11:00:16 -0700 Subject: [PATCH 04/34] feat: extend default token order (BTC, ETH, BNB, AVAX, LINK, UNI, TAO) Extends the existing HEZ/PEZ/USDT/DOT/KSM/USDC priority list with seven more major tokens, keeping the same alphabetical fallback for everything else. --- .../io/novafoundation/nova/runtime/ext/TokenSorting.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt index 4138cac6..21a82a79 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt @@ -10,7 +10,14 @@ val TokenSymbol.mainTokensFirstAscendingOrder "DOT" -> 3 "KSM" -> 4 "USDC" -> 5 - else -> 6 + "BTC" -> 6 + "ETH" -> 7 + "BNB" -> 8 + "AVAX" -> 9 + "LINK" -> 10 + "UNI" -> 11 + "TAO" -> 12 + else -> 13 } val TokenSymbol.alphabeticalOrder From 351114b3497767c019f3b56b3b3eab0fa876f7d1 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Tue, 7 Jul 2026 11:00:50 -0700 Subject: [PATCH 05/34] feat: insert TRX into default token order after BNB --- .../novafoundation/nova/runtime/ext/TokenSorting.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt index 21a82a79..6596531b 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt @@ -13,11 +13,12 @@ val TokenSymbol.mainTokensFirstAscendingOrder "BTC" -> 6 "ETH" -> 7 "BNB" -> 8 - "AVAX" -> 9 - "LINK" -> 10 - "UNI" -> 11 - "TAO" -> 12 - else -> 13 + "TRX" -> 9 + "AVAX" -> 10 + "LINK" -> 11 + "UNI" -> 12 + "TAO" -> 13 + else -> 14 } val TokenSymbol.alphabeticalOrder From 74728a8477e7b70ff950a6915995bd899d0d4e75 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 2 Jul 2026 04:15:45 -0700 Subject: [PATCH 06/34] feat(dashboard): collapsible Pezkuwi card, minimal by default Card now opens in a slim single-line pill showing only Trust Score. Tapping it expands to the full card (citizen status, world Kurdish count, referral actions); a chevron in the expanded header collapses it back. Expand state persists across scroll/recycling within the session but resets to collapsed on a fresh app launch. --- .../list/view/PezkuwiDashboardAdapter.kt | 31 +- .../bg_pezkuwi_dashboard_collapsed.xml | 9 + .../res/layout/item_pezkuwi_dashboard.xml | 287 +++++++++++------- 3 files changed, 213 insertions(+), 114 deletions(-) create mode 100644 feature-assets/src/main/res/drawable/bg_pezkuwi_dashboard_collapsed.xml diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PezkuwiDashboardAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PezkuwiDashboardAdapter.kt index 8352e3b1..d869ad42 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PezkuwiDashboardAdapter.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PezkuwiDashboardAdapter.kt @@ -2,6 +2,8 @@ package io.novafoundation.nova.feature_assets.presentation.balance.list.view import android.content.res.ColorStateList import android.graphics.Color +import android.transition.AutoTransition +import android.transition.TransitionManager import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -29,6 +31,10 @@ class PezkuwiDashboardAdapter( private var model: PezkuwiDashboardModel? = null private var trackingLoading: Boolean = false + // Survives ViewHolder recycling (scroll) within the process, but not process restart — + // resets to collapsed (false) whenever the app is freshly opened, by design. + private var isExpanded: Boolean = false + fun setModel(model: PezkuwiDashboardModel) { this.model = model notifyChangedIfShown() @@ -41,11 +47,11 @@ class PezkuwiDashboardAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PezkuwiDashboardHolder { val binding = ItemPezkuwiDashboardBinding.inflate(parent.inflater(), parent, false) - return PezkuwiDashboardHolder(binding, handler) + return PezkuwiDashboardHolder(binding, handler) { expanded -> isExpanded = expanded } } override fun onBindViewHolder(holder: PezkuwiDashboardHolder, position: Int) { - model?.let { holder.bind(it, trackingLoading) } + model?.let { holder.bind(it, trackingLoading, isExpanded) } } override fun getItemViewType(position: Int): Int { @@ -55,7 +61,8 @@ class PezkuwiDashboardAdapter( class PezkuwiDashboardHolder( private val binder: ItemPezkuwiDashboardBinding, - handler: PezkuwiDashboardAdapter.Handler + handler: PezkuwiDashboardAdapter.Handler, + private val onExpandedChanged: (Boolean) -> Unit ) : RecyclerView.ViewHolder(binder.root) { companion object : WithViewType { @@ -67,14 +74,30 @@ class PezkuwiDashboardHolder( binder.pezkuwiDashboardSignButton.setOnClickListener { handler.onSignClicked() } binder.pezkuwiDashboardShareButton.setOnClickListener { handler.onShareReferralClicked() } binder.pezkuwiDashboardStartTrackingButton.setOnClickListener { handler.onStartTrackingClicked() } + + binder.pezkuwiDashboardCollapsedBar.setOnClickListener { setExpanded(true) } + binder.pezkuwiDashboardCollapseButton.setOnClickListener { setExpanded(false) } } - fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false) { + private fun setExpanded(expanded: Boolean) { + TransitionManager.beginDelayedTransition(binder.pezkuwiDashboardRoot, AutoTransition().apply { duration = 200 }) + binder.pezkuwiDashboardCollapsedBar.visibility = if (expanded) View.GONE else View.VISIBLE + binder.pezkuwiDashboardExpandedContent.visibility = if (expanded) View.VISIBLE else View.GONE + onExpandedChanged(expanded) + } + + fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false, isExpanded: Boolean = false) { bindRoles(model.roles) binder.pezkuwiDashboardTrustValue.text = model.trustScore + binder.pezkuwiDashboardTrustValueCollapsed.text = model.trustScore binder.pezkuwiDashboardWelatiCount.text = model.welatiCount bindButtons(model.citizenshipStatus) + // Reflect current expand state without animating (this runs on every bind/rebind, + // e.g. after RecyclerView recycling — animation is only for user-initiated toggles). + binder.pezkuwiDashboardCollapsedBar.visibility = if (isExpanded) View.GONE else View.VISIBLE + binder.pezkuwiDashboardExpandedContent.visibility = if (isExpanded) View.VISIBLE else View.GONE + val showTracking = !model.isTrackingScore && model.citizenshipStatus == CitizenshipStatus.APPROVED binder.pezkuwiDashboardStartTrackingButton.visibility = if (showTracking) View.VISIBLE else View.GONE diff --git a/feature-assets/src/main/res/drawable/bg_pezkuwi_dashboard_collapsed.xml b/feature-assets/src/main/res/drawable/bg_pezkuwi_dashboard_collapsed.xml new file mode 100644 index 00000000..eb131abc --- /dev/null +++ b/feature-assets/src/main/res/drawable/bg_pezkuwi_dashboard_collapsed.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/feature-assets/src/main/res/layout/item_pezkuwi_dashboard.xml b/feature-assets/src/main/res/layout/item_pezkuwi_dashboard.xml index 53d795b9..ad70aa25 100644 --- a/feature-assets/src/main/res/layout/item_pezkuwi_dashboard.xml +++ b/feature-assets/src/main/res/layout/item_pezkuwi_dashboard.xml @@ -10,151 +10,218 @@ app:strokeWidth="0dp"> + android:orientation="vertical"> - + + android:orientation="horizontal" + android:paddingHorizontal="16dp"> - + android:text="@string/pezkuwi_dashboard_trust_score" + android:textColor="@android:color/white" + android:textSize="13sp" + android:textStyle="bold" /> - + + + + + + + + + + + + + + + + + + + + + android:gravity="end" + android:orientation="vertical"> - + + + + + + + + android:layout_marginTop="14dp" + android:gravity="center_vertical" + android:orientation="horizontal"> + + - + android:layout_height="32dp" + android:layout_marginStart="8dp" + android:minWidth="0dp" + android:paddingHorizontal="10dp" + android:text="@string/pezkuwi_dashboard_start_tracking" + android:textAllCaps="false" + android:textColor="@android:color/white" + android:textSize="11sp" + android:visibility="gone" + app:backgroundTint="#009639" + app:cornerRadius="8dp" /> - - - - - - - - + + app:cornerRadius="14dp" /> + + + + - - - - - - - From 15aa33000b0aebed7bab603feb7a08d41ed02241 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Wed, 8 Jul 2026 10:21:56 -0700 Subject: [PATCH 07/34] fix: don't show perpetual "Connecting" for Tron chains in Networks list Real user reported Tron stuck showing "Connecting..." forever in the Networks screen after the ChainRegistry fix (which correctly skips creating a ChainConnection for Tron, since TronGrid is a plain REST API, not WSS). NetworkListAdapterItemFactory.getConnectingState() rendered every non-Connected state (including the Disconnected default a missing connection pool entry falls back to) as "Connecting" with an indefinite shimmer - there was no way to distinguish "never had a connection to begin with" from "still negotiating one". Tron doesn't have a meaningful WS connection state at all (its actual balance/transfer operations poll TronGridApi directly, confirmed independent of ChainConnection), so there's nothing correct to show here - treat it the same as the already-existing isDisabled early return (no status badge) rather than defaulting into the generic "still connecting" UI. --- .../networkList/common/NetworkListAdapterItemFactory.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt index e51b8680..60d44e3f 100644 --- a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt @@ -61,6 +61,12 @@ class RealNetworkListAdapterItemFactory( private fun getConnectingState(network: NetworkState): ConnectionStateModel? { if (network.chain.isDisabled) return null + // Tron chains never get a ChainConnection/SocketService (TronGrid is a plain REST API, not + // WSS JSON-RPC - see ChainRegistry.registerConnection()), so connectionState is always the + // Disconnected default here, never Connected. Treat that as "nothing to show" rather than + // falling into the generic "Connecting" state below, which would otherwise spin forever. + if (network.chain.isTronBased) return null + return when (network.connectionState) { is SocketStateMachine.State.Connected -> null From 21d28964939d7297f29728908df34f6e210c5b6e Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Wed, 8 Jul 2026 11:33:54 -0700 Subject: [PATCH 08/34] fix: retry Tron balance polling on failure instead of permanently dying Real user on a fresh install reported TRX and USDT-TRC20 never appearing anywhere in the Tokens list (not "zero balance", genuinely absent - including from the multi-chain USDT chain picker). Traced the full pipeline; every layer up to and including TypeBasedAssetSourceRegistry, ChainRegistry, address derivation, and TronGridApi wiring was already correct. Root cause: pollingBalanceFlow() had no retry around fetch() - any single transient failure (DNS hiccup, timeout, momentary connectivity loss during app cold start) threw out of the `while(true)` loop, killing the flow permanently. The collector (FullSyncPaymentUpdater.syncAsset()) only logs and gives up on failure, it doesn't resubscribe. Since the asset's first balance write never happens, AssetCache never inserts a DB row for it, and every UI surface (main list, multi-chain picker, search) reads exclusively via an INNER JOIN that requires that row to exist - so the asset is invisible everywhere, permanently, with zero user-visible error. Swallow fetch() failures and retry on the next interval instead of letting them escape the loop - one bad poll no longer blackholes the asset for the rest of the app session. Also fixes a related but separate gap found while tracing this: RealSecretsMetaAccount.multiChainEncryptionIn() had no isTronBased branch (only isEthereumBased), unlike DefaultMetaAccount's hasAccountIn/accountIdIn which already handle Tron correctly. This didn't cause the missing-tokens bug, but would have broken "export account" (JSON key backup) for Tron - fixed by routing Tron through MultiChainEncryption.Ethereum, matching what RealTronTransactionService already does for actual transaction signing (same secp256k1 keypair). --- .../account/model/RealSecretsMetaAccount.kt | 6 +++++- .../balances/tronNative/TronBalancePolling.kt | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt index 7b88a202..57852a55 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt @@ -49,7 +49,9 @@ class RealSecretsMetaAccount( hasChainAccountIn(chain.id) -> { val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null - if (chain.isEthereumBased) { + // Tron reuses the same secp256k1 keypair/signing as Ethereum - see + // RealTronTransactionService's use of Signer.sign(MultiChainEncryption.Ethereum, ...). + if (chain.isEthereumBased || chain.isTronBased) { MultiChainEncryption.Ethereum } else { MultiChainEncryption.substrateFrom(cryptoType) @@ -58,6 +60,8 @@ class RealSecretsMetaAccount( chain.isEthereumBased -> MultiChainEncryption.Ethereum + chain.isTronBased -> MultiChainEncryption.Ethereum + else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt index e1b61ddd..81f5a5d9 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt @@ -1,17 +1,26 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative +import android.util.Log import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow private const val TRON_BALANCE_POLLING_INTERVAL_MS = 30_000L +private const val LOG_TAG = "TronBalancePolling" /** * TronGrid is a plain REST API with no push/subscription mechanism (unlike Ethereum nodes, which expose a * `newHeads`-style websocket subscription EVM balance sync piggybacks on). So balance updates for Tron-based * assets are polled instead of pushed: fetch immediately, then re-fetch on an interval, only emitting when the * balance actually changed. + * + * A failed fetch() must not escape this loop: any uncaught exception here cancels the whole flow permanently + * (the collector - FullSyncPaymentUpdater - only logs and gives up, it doesn't resubscribe), which meant a + * single transient failure (DNS hiccup, timeout, momentary connectivity loss during app cold start) could + * silently and permanently blackhole a Tron asset - no balance write ever happens, so it never even gets a row + * in the local DB and disappears from every UI surface with no visible error. Swallow and retry next interval + * instead. */ internal fun pollingBalanceFlow( intervalMs: Long = TRON_BALANCE_POLLING_INTERVAL_MS, @@ -20,9 +29,11 @@ internal fun pollingBalanceFlow( var lastEmitted: Balance? = null while (true) { - val latest = fetch() + val latest = runCatching { fetch() } + .onFailure { Log.e(LOG_TAG, "Tron balance fetch failed, will retry in ${intervalMs}ms", it) } + .getOrNull() - if (latest != lastEmitted) { + if (latest != null && latest != lastEmitted) { lastEmitted = latest emit(latest) } From 4aebac305f9e4b384bcf9641fa1b14d1435508cd Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Wed, 8 Jul 2026 11:57:26 -0700 Subject: [PATCH 09/34] fix: retry balance sync on failure for ALL chains, not just Tron Generalizes today's Tron balance-polling fix - the same silent-death bug exists in the shared Substrate sync path used by every chain (Interlay, Kintsugi, Karura, Acala, Hydration, Polkadex, and any other orml/statemine asset), not just Tron-specific code. FullSyncPaymentUpdater.syncAsset() previously used runCatching around the one-time startSyncingBalance() call and a separate .catch on the resulting flow - either path failing (a transient WSS hiccup during setup, an orml currencyId-decode edge case, a node not supporting a specific RPC method during round-robin, a dropped connection later) permanently ended that asset's sync for the Updater's lifetime: no retry, only a Log.e line, and mapNotNull silently dropped the null result. Since no balance update flow ever emits, AssetCache never creates a DB row for that asset, and every UI surface (main list, multi-chain picker) reads via an INNER JOIN that requires that row - so the asset stays invisible with zero user-visible error until app restart, which has the same odds of failing again. Live-tested: Interlay/Kintsugi's node URLs are all reachable (manual WSS handshake test, 101 Switching Protocols), and none of these chains are blacklisted - so this wasn't a connectivity or config issue, it was this retry gap. Wraps both the initial subscription call and the ongoing flow in one flow{} builder with retryWhen (30s fixed interval, matching Tron's polling interval) instead of runCatching + .catch, so a transient failure at either point just gets retried instead of permanently killing sync for that asset. --- .../balance/FullSyncPaymentUpdater.kt | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt index 56a18dd3..4b318e93 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt @@ -22,9 +22,14 @@ import io.novafoundation.nova.runtime.ext.enabledAssets import io.novafoundation.nova.runtime.ext.localId import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.retryWhen + +private const val SYNC_RETRY_DELAY_MS = 30_000L internal class FullSyncPaymentUpdater( private val operationDao: OperationDao, @@ -41,32 +46,43 @@ internal class FullSyncPaymentUpdater( ): Flow { val accountId = scopeValue.requireAccountIdIn(chain) - return chain.enabledAssets().mapNotNull { chainAsset -> + return chain.enabledAssets().map { chainAsset -> syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder) } .mergeIfMultiple() .noSideAffects() } - private suspend fun syncAsset( + /** + * Wraps both the initial `startSyncingBalance()` call and the resulting flow in a single retry + * boundary. Without this, any transient failure - during initial subscription setup (a WSS + * hiccup, an orml currencyId-decode edge case, a node not supporting a specific RPC method + * during round-robin) or later in the flow (a dropped connection) - would permanently and + * silently kill sync for that one asset: no DB row ever gets created/updated for it, so it + * vanishes from every UI screen with nothing but a logcat line as evidence, until the app is + * restarted (and even then, with the same odds of failing again). Retrying indefinitely on a + * fixed interval matches the same fix already applied to Tron's balance polling. + */ + private fun syncAsset( chainAsset: Chain.Asset, metaAccount: MetaAccount, accountId: AccountId, storageSubscriptionBuilder: SharedRequestsBuilder - ): Flow? { + ): Flow { val assetSource = assetSourceRegistry.sourceFor(chainAsset) - val assetUpdateFlow = runCatching { - assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder) + return flow { + val assetUpdateFlow = assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder) + emitAll(assetUpdateFlow) } - .onFailure { logSyncError(chain, chainAsset, error = it) } - .getOrNull() - ?: return null - - return assetUpdateFlow.onEach { balanceUpdate -> - assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId) - } - .catch { logSyncError(chain, chainAsset, error = it) } + .onEach { balanceUpdate -> + assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId) + } + .retryWhen { cause, _ -> + logSyncError(chain, chainAsset, error = cause) + delay(SYNC_RETRY_DELAY_MS) + true + } } private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) { From 7906a20f96ad7278ebfceee564ec44e58b9a7486 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Wed, 8 Jul 2026 13:54:44 -0700 Subject: [PATCH 10/34] fix: stop swallowed errors from silently killing balance/updater sync StatemineAssetBalance and NativeAssetBalance's startSyncingBalance() wrapped setup and subscription failures in runCatching{}/catch{} that returned emptyFlow()/NoCause instead of propagating. This meant FullSyncPaymentUpdater's retryWhen (added for the Tron fix) never actually engaged for these asset types - a transient failure silently and permanently stopped sync for that asset (HEZ, PEZ, USDT, DOT, ...) with only a logcat line as evidence. Let the exceptions propagate so the existing retry boundary can do its job. ChainRegistry.currentChains ran registerChain()/unregisterChain() per chain, unguarded, inside a plain (non-Supervisor) CoroutineScope and an Eagerly-shared flow that never restarts once it dies. One bad chain row could permanently kill sync for every chain. Isolated each chain's register/unregister in its own runCatching, and switched the scope to a SupervisorJob as defense in depth. ChainUpdaterGroupUpdateSystem.runUpdaters() called getRuntime() and built per-chain updater flows with no error boundary; callers merge several chains' results together, so one chain's failure (e.g. a disabled chain throwing DisabledChainException) could kill governance/staking/crowdloan sync for every other chain in the group. Wrapped the body in flow{} + catch to isolate failures per chain. --- .../statemine/StatemineAssetBalance.kt | 48 +++++++-------- .../balances/utility/NativeAssetBalance.kt | 15 ++--- .../runtime/multiNetwork/ChainRegistry.kt | 21 ++++++- .../updaters/ChainUpdaterGroupUpdateSystem.kt | 59 ++++++++++++------- 4 files changed, 81 insertions(+), 62 deletions(-) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt index 8735685c..15860fbe 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt @@ -131,6 +131,10 @@ class StatemineAssetBalance( ) } + // Deliberately lets setup/subscription failures propagate as exceptions rather than swallowing them into + // emptyFlow()/BalanceSyncUpdate.NoCause: the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole + // call in a single retryWhen boundary that exists specifically to catch and retry failures like these. If + // we swallow here, that boundary never triggers - the asset silently stops syncing instead of retrying. override suspend fun startSyncingBalance( chain: Chain, chainAsset: Chain.Asset, @@ -138,40 +142,32 @@ class StatemineAssetBalance( accountId: AccountId, subscriptionBuilder: SharedRequestsBuilder ): Flow { - return runCatching { - val runtime = chainRegistry.getRuntime(chain.id) + val runtime = chainRegistry.getRuntime(chain.id) - val statemineType = chainAsset.requireStatemine() - val encodableAssetId = statemineType.prepareIdForEncoding(runtime) + val statemineType = chainAsset.requireStatemine() + val encodableAssetId = statemineType.prepareIdForEncoding(runtime) - val module = runtime.metadata.statemineModule(statemineType) + val module = runtime.metadata.statemineModule(statemineType) - val assetAccountStorage = module.storage("Account") - val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId) + val assetAccountStorage = module.storage("Account") + val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId) - val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder) + val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder) - combine( - subscriptionBuilder.subscribe(assetAccountKey), - assetDetailsFlow.map { it.status.transfersFrozen } - ) { balanceStorageChange, isAssetFrozen -> - val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime) - val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded) + return combine( + subscriptionBuilder.subscribe(assetAccountKey), + assetDetailsFlow.map { it.status.transfersFrozen } + ) { balanceStorageChange, isAssetFrozen -> + val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime) + val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded) - val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount) + val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount) - if (assetChanged) { - BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block) - } else { - BalanceSyncUpdate.NoCause - } - }.catch { error -> - Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") - emit(BalanceSyncUpdate.NoCause) + if (assetChanged) { + BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block) + } else { + BalanceSyncUpdate.NoCause } - }.getOrElse { error -> - Log.e(LOG_TAG, "Failed to start balance sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}") - emptyFlow() } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt index f5f14bb4..8768fb15 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt @@ -151,6 +151,9 @@ class NativeAssetBalance( } } + // Setup/subscription failures are allowed to propagate rather than being swallowed into emptyFlow()/NoCause: + // the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole call in a single retryWhen boundary meant + // to catch and retry exactly these failures. Swallowing here would make that retry boundary never trigger. override suspend fun startSyncingBalance( chain: Chain, chainAsset: Chain.Asset, @@ -160,13 +163,7 @@ class NativeAssetBalance( ): Flow { val runtime = chainRegistry.getRuntime(chain.id) - val key = try { - runtime.metadata.system().storage("Account").storageKey(runtime, accountId) - } catch (e: Exception) { - Log.e(LOG_TAG, "Failed to construct account storage key: ${e.message} in ${chain.name}") - - return emptyFlow() - } + val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId) return subscriptionBuilder.subscribe(key) .map { change -> @@ -179,10 +176,6 @@ class NativeAssetBalance( BalanceSyncUpdate.NoCause } } - .catch { error -> - Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") - emit(BalanceSyncUpdate.NoCause) - } } private fun bindBalanceHolds(dynamicInstance: Any?): List? { diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt index 1ae735b7..518d6c49 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt @@ -41,6 +41,7 @@ import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchro import io.novasama.substrate_sdk_android.wsrpc.SocketService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged @@ -67,14 +68,28 @@ class ChainRegistry( private val runtimeSyncService: RuntimeSyncService, private val web3ApiPool: Web3ApiPool, private val gson: Gson -) : CoroutineScope by CoroutineScope(Dispatchers.Default) { + // SupervisorJob, not the plain Job a bare CoroutineScope(Dispatchers.Default) would give: without it, an + // uncaught exception in ANY coroutine sharing this scope (e.g. currentChains'/chainsById's shareIn, or any + // launch{} below) cancels every sibling, including the other one - a single malformed/leftover chain row + // would then permanently kill sync for every chain, not just the offending one. +) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { val currentChains = chainDao.joinChainInfoFlow() .mapList { mapChainLocalToChain(it, gson) } .diffed() .map { diff -> - diff.removed.forEach { unregisterChain(it) } - diff.newOrUpdated.forEach { chain -> registerChain(chain) } + // Each chain's register/unregister is isolated: one malformed/leftover row (e.g. a chain persisted + // as disabled from an earlier session) must not throw out of this operator and kill this flow for + // every other chain - shareIn(..., Eagerly) never restarts once its upstream completes/throws, so + // any single unhandled exception here would silently and permanently break sync for the whole app. + diff.removed.forEach { chain -> + runCatching { unregisterChain(chain) } + .onFailure { Log.e(LOG_TAG, "Failed to unregister chain ${chain.name} (${chain.id})", it) } + } + diff.newOrUpdated.forEach { chain -> + runCatching { registerChain(chain) } + .onFailure { Log.e(LOG_TAG, "Failed to register chain ${chain.name} (${chain.id})", it) } + } diff.all } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt index 87e7df49..3d2f0b4b 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt @@ -14,7 +14,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.merge import kotlin.coroutines.coroutineContext @@ -24,35 +26,48 @@ abstract class ChainUpdaterGroupUpdateSystem( private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, ) : UpdateSystem { + // Callers (MultiChainUpdateSystem, SingleChainUpdateSystem) merge several chains' runUpdaters() results + // into one flow. chainRegistry.getRuntime(chain.id) throws for a chain whose runtime isn't ready yet + // (including a disabled chain, via DisabledChainException) - if that throw escapes this function + // uncaught, it propagates through the merge and kills governance/staking/crowdloan sync for every OTHER + // chain in the group too, not just the failing one. Wrapping the whole body in flow{} + catch isolates + // that failure to this chain alone. protected suspend fun runUpdaters(chain: Chain, updaters: Collection>): Flow { - val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata + return flow { + val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata - val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG - val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName + val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG + val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName - val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) -> - scope.invalidationFlow().flatMapLatest { scopeValue -> - val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) + val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) -> + scope.invalidationFlow().flatMapLatest { scopeValue -> + val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) - val updatersFlow = scopeUpdaters - .filter { it.requiredModules.all(runtimeMetadata::hasModule) } - .map { updater -> - @Suppress("UNCHECKED_CAST") - (updater as Updater).listenForUpdates(subscriptionBuilder, scopeValue) - .catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) } - .flowOn(Dispatchers.Default) + val updatersFlow = scopeUpdaters + .filter { it.requiredModules.all(runtimeMetadata::hasModule) } + .map { updater -> + @Suppress("UNCHECKED_CAST") + (updater as Updater).listenForUpdates(subscriptionBuilder, scopeValue) + .catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) } + .flowOn(Dispatchers.Default) + } + + if (updatersFlow.isNotEmpty()) { + subscriptionBuilder.subscribe(coroutineContext) + + updatersFlow.merge() + } else { + emptyFlow() } - - if (updatersFlow.isNotEmpty()) { - subscriptionBuilder.subscribe(coroutineContext) - - updatersFlow.merge() - } else { - emptyFlow() } } - } - return scopeFlows.merge() + emitAll(scopeFlows.merge()) + }.catch { error -> + // Explicitly qualified: unqualified LOG_TAG here would resolve against the nearest implicit + // receiver, which is this catch lambda's FlowCollector, not this class - Any.LOG_TAG applies to + // any receiver, so it would silently compile but log the wrong (unhelpful) tag. + Log.e(this@ChainUpdaterGroupUpdateSystem.LOG_TAG, "Failed to start updaters in ${this@ChainUpdaterGroupUpdateSystem::class.java.simpleName} for ${chain.name}", error) + } } } From fd7adec0ccc867c90177a90ae29ab5744997d585 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Wed, 8 Jul 2026 14:01:19 -0700 Subject: [PATCH 11/34] fix: wrap long ktlint-violating log line from previous commit Line exceeded the 160-char limit and had unwrapped arguments. --- .../network/updaters/ChainUpdaterGroupUpdateSystem.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt index 3d2f0b4b..15750566 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt @@ -67,7 +67,10 @@ abstract class ChainUpdaterGroupUpdateSystem( // Explicitly qualified: unqualified LOG_TAG here would resolve against the nearest implicit // receiver, which is this catch lambda's FlowCollector, not this class - Any.LOG_TAG applies to // any receiver, so it would silently compile but log the wrong (unhelpful) tag. - Log.e(this@ChainUpdaterGroupUpdateSystem.LOG_TAG, "Failed to start updaters in ${this@ChainUpdaterGroupUpdateSystem::class.java.simpleName} for ${chain.name}", error) + val outerLogTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG + val outerSelfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName + + Log.e(outerLogTag, "Failed to start updaters in $outerSelfName for ${chain.name}", error) } } } From 99c9fd9db5243cecb4184760a9adcc53232f7a40 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Wed, 8 Jul 2026 18:21:45 -0700 Subject: [PATCH 12/34] fix: don't let a transient fetch failure wipe local chains/assets ChainSyncService.syncUp() and EvmAssetsSyncService.syncEVMAssets() both diff a freshly-fetched remote list against everything currently stored locally, then apply that diff unconditionally. If the remote fetch succeeds but returns a suspiciously small or empty list (CDN hiccup, regional network filtering, a bad publish upstream) - rather than throwing, which retryUntilDone would catch and retry - the diff would delete most or all of the user's chains/assets from the local DB. This is active data loss, not a sync failure, and it self-heals on the next good sync if we just skip applying a suspicious one instead. Directly relevant: a user's live production install (unrelated to any in-flight branch work) was observed with a completely empty networks and token list after previously working fine, matching this exact failure mode. --- .../multiNetwork/asset/EvmAssetsSyncService.kt | 14 ++++++++++++++ .../multiNetwork/chain/ChainSyncService.kt | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt index 36d84d84..b2b19b89 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt @@ -1,7 +1,9 @@ package io.novafoundation.nova.runtime.multiNetwork.asset +import android.util.Log import com.google.gson.Gson import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.LOG_TAG import io.novafoundation.nova.common.utils.retryUntilDone import io.novafoundation.nova.core_db.dao.ChainAssetDao import io.novafoundation.nova.core_db.dao.ChainDao @@ -40,6 +42,18 @@ class EvmAssetsSyncService( new.copy(enabled = old?.enabled ?: ENABLED_DEFAULT_BOOL) } + // Same defensive guard as ChainSyncService: a transient upstream issue can make the fetch return + // successfully with a suspiciously small/empty list. Diffing that against a populated local DB would + // delete most or all of the user's ERC20 tokens (e.g. USDT-ERC20) - skip instead of wiping good data. + if (oldAssets.isNotEmpty() && newAssets.size < oldAssets.size / 2) { + Log.e( + LOG_TAG, + "Refusing to apply EVM asset sync: remote returned ${newAssets.size} assets vs ${oldAssets.size} currently stored " + + "(would remove more than half). Likely a transient fetch issue - skipping this sync cycle." + ) + return + } + val diff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false) chainAssetDao.updateAssets(diff) } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt index 057cbea7..497c6efe 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt @@ -1,7 +1,9 @@ package io.novafoundation.nova.runtime.multiNetwork.chain +import android.util.Log import com.google.gson.Gson import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.LOG_TAG import io.novafoundation.nova.common.utils.retryUntilDone import io.novafoundation.nova.core_db.dao.ChainDao import io.novafoundation.nova.core_db.dao.FullAssetIdLocal @@ -40,6 +42,21 @@ class ChainSyncService( val remoteChains = retryUntilDone { chainFetcher.getChains() } + // A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make + // chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of + // throwing. Applying that as a diff against a populated local DB would delete most or all of the + // user's chains/assets - not a sync failure, but active data loss, for something that self-heals on + // the next successful sync if we just skip applying it. Only guard when we HAD data: an empty result + // on a genuinely first-ever sync is normal and must proceed. + if (oldChains.isNotEmpty() && remoteChains.size < oldChains.size / 2) { + Log.e( + LOG_TAG, + "Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " + + "(would remove more than half). Likely a transient fetch issue - skipping this sync cycle." + ) + return@withContext + } + val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) } val newAssets = remoteChains.flatMap { chain -> chain.assets.map { From 1d00f78fa92e474bbdb5ed479f4c7c5cad9f83f7 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 02:10:40 -0700 Subject: [PATCH 13/34] fix: isolate per-chain mapping failures in currentChains, not just register/unregister mapChainLocalToChain() runs as a single mapList{} transform over the entire chain list. If it throws for even one malformed row (e.g. a JSON parse failure on the chain's `additional` blob), the whole transform throws, killing the Eagerly-shared currentChains/chainsById flows for every chain - permanently, since Eagerly sharing never restarts once its upstream dies. This is the same class of bug already fixed for registerChain/unregisterChain in the diff-processing step below, but one level upstream of it: a mapping failure here never even reaches that per-chain isolation, since it happens before the diff is computed at all. Device logs on a fresh install showed exactly this shape: 8 chains (including Pezkuwi, Pezkuwi Asset Hub, Polkadot Asset Hub) successfully completed "Constructed runtime" within the first ~2.5 seconds, then all activity for every remaining chain stopped completely for the rest of the session - no crash, no battery-saver kill (confirmed off), the process simply went silent, consistent with the shared flow dying mid-registration and never recovering. --- .../nova/runtime/multiNetwork/ChainRegistry.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt index 518d6c49..9c7885d2 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt @@ -7,7 +7,7 @@ import io.novafoundation.nova.common.utils.RuntimeContext import io.novafoundation.nova.common.utils.diffed import io.novafoundation.nova.common.utils.filterList import io.novafoundation.nova.common.utils.inBackground -import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.mapListNotNull import io.novafoundation.nova.common.utils.mapNotNullToSet import io.novafoundation.nova.common.utils.provideContext import io.novafoundation.nova.common.utils.removeHexPrefix @@ -75,7 +75,16 @@ class ChainRegistry( ) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { val currentChains = chainDao.joinChainInfoFlow() - .mapList { mapChainLocalToChain(it, gson) } + // mapListNotNull, not mapList: mapChainLocalToChain() can throw on a single malformed row (e.g. a + // gson.fromJson() failure on the chain's `additional` JSON blob) - since this whole step runs as ONE + // transform over the ENTIRE chain list, one bad chain would previously throw out of this operator and + // permanently kill this Eagerly-shared flow for every chain, not just the offending one. Skip and log + // instead, matching the per-chain isolation already applied to registerChain/unregisterChain below. + .mapListNotNull { chainLocal -> + runCatching { mapChainLocalToChain(chainLocal, gson) } + .onFailure { Log.e(LOG_TAG, "Failed to map chain ${chainLocal.chain.id} (${chainLocal.chain.name}) from local DB", it) } + .getOrNull() + } .diffed() .map { diff -> // Each chain's register/unregister is isolated: one malformed/leftover row (e.g. a chain persisted From 57dbe77db36002bec2304447e7013e1570d21ffa Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 04:01:00 -0700 Subject: [PATCH 14/34] test: add Tron balance integration test Tron is a REST API (TronGrid), not a Substrate runtime, so it never goes through BalancesIntegrationTest's chainRegistry-based mechanism at all - it had zero CI coverage. This exercises the exact TronGridApi the production app uses for balance reads, via a standalone Retrofit client (TronGridApi isn't exposed through a public feature API for tests to reach through the DI graph). Test account is the mainnet Founder's Tron address, verified live via TronGrid's public API on 2026-07-09: 3,925.23 TRX native + 625,213.92 USDT-TRC20 - substantial real balances, not a guessed or empty account. --- .../balances/TronBalancesIntegrationTest.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt new file mode 100644 index 00000000..f8748960 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.balances + +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory +import java.math.BigInteger + +/** + * Tron is a REST API (TronGrid), not a Substrate runtime - it has no ChainConnection/RuntimeProvider and isn't + * reachable through [BalancesIntegrationTest]'s chainRegistry-based mechanism at all. This exercises the exact + * same [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi] the production app uses for + * balance reads (see TronNativeAssetBalance/Trc20AssetBalance), just via a standalone Retrofit client instead of + * the full DI graph, since TronGridApi isn't exposed through a public feature API for tests to reach. + * + * Test account is the mainnet Founder's Tron address, verified live via TronGrid's public API on 2026-07-09 to + * hold a substantial non-zero balance of both native TRX and TRC-20 USDT - not a guessed or empty account. + */ +class TronBalancesIntegrationTest { + + private val testAddress = "TDGZ4GfvCRe1d8oksj8fBD77ZHw4bkCPBA" + private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + private val baseUrl = "https://api.trongrid.io" + + private val maxAmount = BigInteger.valueOf(10).pow(30) + + private val tronGridApi = run { + val retrofit = Retrofit.Builder() + .client(OkHttpClient.Builder().build()) + .baseUrl(baseUrl) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + RealTronGridApi(retrofit.create(RetrofitTronGridApi::class.java)) + } + + @Test + fun testNativeTrxBalanceLoading() = runBlocking { + val freeBalance = tronGridApi.fetchNativeBalance(baseUrl, testAddress) + + assertTrue("TRX balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) + assertTrue("TRX balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) + } + + @Test + fun testTrc20UsdtBalanceLoading() = runBlocking { + val freeBalance = tronGridApi.fetchTrc20Balance(baseUrl, testAddress, usdtContractAddress) + + assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) + assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) + } +} From b3714d3b79b6d6b7d9f8ff69b5b254c8db51d3d9 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 06:03:20 -0700 Subject: [PATCH 15/34] fix: order chain queries by rowid so Pezkuwi's chains register first SELECT * FROM chains had no ORDER BY - SQLite gives no ordering guarantee for that, so registration/connection order across ~98 chains was effectively unpredictable per run. The merged chains.json lists Pezkuwi's own chains first (wallet-utils' merge_chains()), and CollectionDiffer's findDiff()/Room's batch insert both preserve that order on first sync, so ordering by rowid (insertion order) makes Pezkuwi's own chains reliably register and start connecting first/early, instead of being interleaved unpredictably among ~90+ Nova-inherited chains where they could end up processed dead last - observed directly: a fresh install took 45+ seconds without a single Pezkuwi-related registration/connection attempt. No user should wait minutes for their own wallet's native chain to even start syncing after install, regardless of whether the eventual outcome is correct. --- .../java/io/novafoundation/nova/core_db/dao/ChainDao.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt index e72205a4..99017fb5 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt @@ -164,7 +164,12 @@ abstract class ChainDao { // ------- Queries ------ - @Query("SELECT * FROM chains") + // ORDER BY rowid: without an explicit order, SQLite gives no guarantee about row order for `SELECT *`. + // The merged chains.json lists Pezkuwi's own chains first (see wallet-utils' merge_chains()), and that + // order is what gets inserted first on initial sync - rowid tracks insertion order, so ordering by it + // means Pezkuwi's chains reliably register/connect first instead of being interleaved unpredictably + // among the ~90+ Nova-inherited chains, where they could otherwise end up processed last. + @Query("SELECT * FROM chains ORDER BY rowid") @Transaction abstract suspend fun getJoinChainInfo(): List @@ -172,7 +177,7 @@ abstract class ChainDao { @Transaction abstract suspend fun getAllChainIds(): List - @Query("SELECT * FROM chains") + @Query("SELECT * FROM chains ORDER BY rowid") @Transaction abstract fun joinChainInfoFlow(): Flow> From 688a23ba6b300676ed7b307871e24a9ef20cb037 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 07:52:15 -0700 Subject: [PATCH 16/34] fix: log and stop swallowing synchronous listenForUpdates() failures; retry NDK download in CI BalancesUpdateSystem.launchChainUpdaters() wrapped updater.listenForUpdates() in a try/catch that discarded synchronous exceptions with zero logging - a silent failure point that could explain a chain's balances never syncing with no trace in logcat. Also add a full-architecture instrumented test that persists a real watch-only MetaAccount and polls AssetDao through the actual BalancesUpdateSystem pipeline, instead of bypassing it like the existing BalancesIntegrationTest does. --- .github/workflows/install/action.yml | 22 ++++- .../PezkuwiFullArchitectureBalancesTest.kt | 90 +++++++++++++++++++ .../data/network/BalancesUpdateSystem.kt | 9 ++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt diff --git a/.github/workflows/install/action.yml b/.github/workflows/install/action.yml index 1d6c723d..d674561e 100644 --- a/.github/workflows/install/action.yml +++ b/.github/workflows/install/action.yml @@ -18,7 +18,27 @@ runs: - name: Install NDK run: | SDKMANAGER=$(find ${ANDROID_SDK_ROOT}/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -1) - echo "y" | sudo ${SDKMANAGER} --install "ndk;26.1.10909125" --sdk_root=${ANDROID_SDK_ROOT} + NDK_PACKAGE="ndk;26.1.10909125" + + # sdkmanager's download of the ~1GB NDK zip from Google's CDN occasionally comes back truncated/corrupted + # ("Error on ZipFile unknown archive") with no built-in retry, taking down the whole build for a purely + # transient network blip. Retry with cleanup between attempts: sdkmanager can otherwise resume from the + # same corrupted partial file instead of re-fetching, making a naive retry fail identically every time. + for attempt in 1 2 3; do + echo "NDK install attempt $attempt/3" + if echo "y" | sudo ${SDKMANAGER} --install "$NDK_PACKAGE" --sdk_root=${ANDROID_SDK_ROOT}; then + echo "NDK installed successfully" + exit 0 + fi + + echo "Attempt $attempt failed - clearing any partial/corrupted download before retrying" + sudo rm -rf "${ANDROID_SDK_ROOT}/ndk/26.1.10909125" + sudo find "${ANDROID_SDK_ROOT}" -maxdepth 1 -name "tmp*" -exec rm -rf {} + + sleep 10 + done + + echo "NDK install failed after 3 attempts" + exit 1 shell: bash - name: Set ndk.dir in local.properties diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt new file mode 100644 index 00000000..221f096c --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.balances + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertNotNull +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +/** + * Exercises the ACTUAL production balance-sync pipeline (BalancesUpdateSystem -> AssetCache/AssetDao) end to + * end, unlike [BalancesIntegrationTest] which bypasses it entirely via a direct low-level storage query. This + * is meant to answer one question with hard evidence, not speculation: does the app's real, running background + * sync ever write an `assets` cache row for HEZ on the Pezkuwi Asset Hub chain, for a real, well-funded account? + * + * If this test fails, the failure message + logcat (tag "BalancesDiag", plus the standard per-updater error + * logs already wired into BalancesUpdateSystem/FullSyncPaymentUpdater) shows exactly which decision branch or + * exception is responsible - not another layer of inference from silence. + */ +class PezkuwiFullArchitectureBalancesTest { + + // Mainnet Founder account (SS58, generic substrate prefix) - verified live via @pezkuwi/api on 2026-07-09 + // to hold a substantial non-zero, non-frozen free HEZ balance on Pezkuwi Asset Hub (180,297.80 HEZ). + private val founderSubstrateAddress = "5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF" + private val pezkuwiAssetHubChainId = "e7c15092dcbe3f320260ddbbc685bfceed9125a3b3d8436db2766201dec3b949" + private val hezAssetId = 0 + + private val context = ApplicationProvider.getApplicationContext() + + private val dbApi = FeatureUtils.getFeature(context, DbApi::class.java) + private val metaAccountDao = dbApi.metaAccountDao() + private val assetDao: AssetDao = dbApi.provideAssetDao() + + @Test + fun testPezkuwiAssetHubHezBalanceActuallySyncs() = runBlocking { + val metaId = insertAndSelectFounderWatchAccount(metaAccountDao) + + val assetRow = withTimeoutOrNull(90.seconds) { + while (true) { + val asset = assetDao.getAsset(metaId, pezkuwiAssetHubChainId, hezAssetId) + if (asset != null) return@withTimeoutOrNull asset + + delay(2.seconds) + } + @Suppress("UNREACHABLE_CODE") + null + } + + assertNotNull( + "No `assets` row was ever written for HEZ on Pezkuwi Asset Hub (metaId=$metaId) within 90s. " + + "The real BalancesUpdateSystem pipeline never completed a sync for this asset - check logcat " + + "tag 'BalancesDiag' and the standard FullSyncPaymentUpdater/StatemineAssetBalance error logs.", + assetRow + ) + } + + private suspend fun insertAndSelectFounderWatchAccount(dao: MetaAccountDao): Long { + val accountId = founderSubstrateAddress.toAccountId() + + val metaAccount = MetaAccountLocal( + substratePublicKey = accountId, + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = accountId, + ethereumPublicKey = null, + ethereumAddress = null, + name = "PezkuwiFullArchitectureBalancesTest", + parentMetaId = null, + isSelected = false, + position = 0, + type = MetaAccountLocal.Type.WATCH_ONLY, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + + val metaId = dao.insertMetaAccount(metaAccount) + dao.selectMetaAccount(metaId) + + return metaId + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt index 80265626..a6753875 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt @@ -45,6 +45,11 @@ class BalancesUpdateSystem( } private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow { + Log.d( + "BalancesDiag", + "balancesSync(${chain.name}): hasAccountIn=${metaAccount.hasAccountIn(chain)} " + + "isDisabled=${chain.connectionState.isDisabled} canPerformFullSync=${chain.canPerformFullSync()}" + ) return when { !metaAccount.hasAccountIn(chain) -> emptyFlow() chain.connectionState.isDisabled -> emptyFlow() @@ -89,6 +94,10 @@ class BalancesUpdateSystem( try { updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) } } catch (e: Exception) { + // Was silently swallowed here with zero logging - listenForUpdates() itself is a suspend + // call that can throw synchronously (e.g. FullSyncPaymentUpdater.listenForUpdates() calling + // requireAccountIdIn(chain)), before ever returning a flow for the .catch{} above to guard. + Log.e("BalancesDiag", "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e) emptyFlow() } } From 9beb68daa201418d92d89f184d3cb10b71be9d62 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 07:53:51 -0700 Subject: [PATCH 17/34] ci: run the whole balances test package, not just BalancesIntegrationTest Hardcoded -e class only ran one test class, silently excluding any new integration test added to the same package (e.g. the new full-architecture BalancesUpdateSystem test). --- .github/scripts/run_balances_test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/run_balances_test.sh b/.github/scripts/run_balances_test.sh index a3d3e7ef..feeb7fc4 100644 --- a/.github/scripts/run_balances_test.sh +++ b/.github/scripts/run_balances_test.sh @@ -35,7 +35,7 @@ t.start() def run(): os.system('adb wait-for-device') - p = sp.Popen('adb shell am instrument -w -m -e debug false -e class "io.novafoundation.nova.balances.BalancesIntegrationTest" io.pezkuwichain.wallet.debug.test/io.qameta.allure.android.runners.AllureAndroidJUnitRunner', + p = sp.Popen('adb shell am instrument -w -m -e debug false -e package "io.novafoundation.nova.balances" io.pezkuwichain.wallet.debug.test/io.qameta.allure.android.runners.AllureAndroidJUnitRunner', shell=True, stdout=sp.PIPE, stderr=sp.PIPE, stdin=sp.PIPE) return p.communicate() success = re.compile(r'OK \(\d+ tests\)') From 4cf6cef68d23b1a36af74c311d67d3d3656a303d Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 08:16:32 -0700 Subject: [PATCH 18/34] fix: add missing scalars converter dependency for androidTest TronBalancesIntegrationTest.kt uses ScalarsConverterFactory but nothing in app/build.gradle declared it, so the app module's androidTest compilation failed with "Unresolved reference 'scalars'" - this was never caught before now because the emulator balances_test.yml workflow only runs on a schedule/ manual dispatch, not on every push. --- app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle b/app/build.gradle index f0eaa8b3..fe5f4538 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -324,6 +324,7 @@ dependencies { androidTestImplementation androidTestRunnerDep androidTestImplementation androidTestRulesDep androidTestImplementation androidJunitDep + androidTestImplementation scalarsConverterDep androidTestImplementation allureKotlinModel androidTestImplementation allureKotlinCommons From 2bb8a4e3d0962c71d091846aed9dc46d3da6faa7 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 09:15:28 -0700 Subject: [PATCH 19/34] fix: actually start BalancesUpdateSystem in the full-architecture test First run of this test produced zero BalancesDiag output at all - not even a single balancesSync() call for any chain. Root cause: BalancesUpdateSystem.start() is a cold flow only ever collected by RootInteractor, which is wired to the root Activity/ViewModel lifecycle. This bare instrumented test never launches that Activity, so the pipeline was never started - the test's own timeout, not the production bug, explained the failure. Now collect the same AssetsFeatureApi. updateSystem instance directly instead of relying on app UI lifecycle. --- .../PezkuwiFullArchitectureBalancesTest.kt | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt index 221f096c..14041e6f 100644 --- a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -8,8 +8,10 @@ import io.novafoundation.nova.core_db.dao.AssetDao import io.novafoundation.nova.core_db.dao.MetaAccountDao import io.novafoundation.nova.core_db.di.DbApi import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import org.junit.Assert.assertNotNull @@ -22,6 +24,11 @@ import kotlin.time.Duration.Companion.seconds * is meant to answer one question with hard evidence, not speculation: does the app's real, running background * sync ever write an `assets` cache row for HEZ on the Pezkuwi Asset Hub chain, for a real, well-funded account? * + * BalancesUpdateSystem.start() is a cold flow - in production it's only ever collected by RootInteractor, + * which is wired to the root Activity/ViewModel lifecycle. A bare instrumented test never launches that + * Activity, so we collect it ourselves here via the same AssetsFeatureApi.updateSystem instance the real app + * uses, instead of relying on app UI lifecycle to start it. + * * If this test fails, the failure message + logcat (tag "BalancesDiag", plus the standard per-updater error * logs already wired into BalancesUpdateSystem/FullSyncPaymentUpdater) shows exactly which decision branch or * exception is responsible - not another layer of inference from silence. @@ -40,27 +47,35 @@ class PezkuwiFullArchitectureBalancesTest { private val metaAccountDao = dbApi.metaAccountDao() private val assetDao: AssetDao = dbApi.provideAssetDao() + private val assetsFeatureApi = FeatureUtils.getFeature(context, AssetsFeatureApi::class.java) + @Test fun testPezkuwiAssetHubHezBalanceActuallySyncs() = runBlocking { - val metaId = insertAndSelectFounderWatchAccount(metaAccountDao) + val updateSystemJob = launch { assetsFeatureApi.updateSystem.start().collect {} } - val assetRow = withTimeoutOrNull(90.seconds) { - while (true) { - val asset = assetDao.getAsset(metaId, pezkuwiAssetHubChainId, hezAssetId) - if (asset != null) return@withTimeoutOrNull asset + try { + val metaId = insertAndSelectFounderWatchAccount(metaAccountDao) - delay(2.seconds) + val assetRow = withTimeoutOrNull(90.seconds) { + while (true) { + val asset = assetDao.getAsset(metaId, pezkuwiAssetHubChainId, hezAssetId) + if (asset != null) return@withTimeoutOrNull asset + + delay(2.seconds) + } + @Suppress("UNREACHABLE_CODE") + null } - @Suppress("UNREACHABLE_CODE") - null - } - assertNotNull( - "No `assets` row was ever written for HEZ on Pezkuwi Asset Hub (metaId=$metaId) within 90s. " + - "The real BalancesUpdateSystem pipeline never completed a sync for this asset - check logcat " + - "tag 'BalancesDiag' and the standard FullSyncPaymentUpdater/StatemineAssetBalance error logs.", - assetRow - ) + assertNotNull( + "No `assets` row was ever written for HEZ on Pezkuwi Asset Hub (metaId=$metaId) within 90s. " + + "The real BalancesUpdateSystem pipeline never completed a sync for this asset - check logcat " + + "tag 'BalancesDiag' and the standard FullSyncPaymentUpdater/StatemineAssetBalance error logs.", + assetRow + ) + } finally { + updateSystemJob.cancel() + } } private suspend fun insertAndSelectFounderWatchAccount(dao: MetaAccountDao): Long { From d7cae696c81a4758bbbf825bcddc2da323935da4 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 10:05:42 -0700 Subject: [PATCH 20/34] diag: log full/enabled asset list per chain in FullSyncPaymentUpdater HEZ (native type, assetId 0) on Pezkuwi Asset Hub never gets a System.Account subscription sent to the RPC at all - no exception, no log, while the other 5 statemine-type assets on the same chain sync correctly. Ruled out via code reading: enabledAssets() default, AssetSourceRegistry dispatch, type mapping, Room composite PK. This logs the actual chain.assets contents at the exact point enabledAssets() is consumed, to see directly whether HEZ is present/ enabled in the runtime Chain object or silently absent before this point. --- .../updaters/balance/FullSyncPaymentUpdater.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt index 4b318e93..fae660a6 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt @@ -46,7 +46,14 @@ internal class FullSyncPaymentUpdater( ): Flow { val accountId = scopeValue.requireAccountIdIn(chain) - return chain.enabledAssets().map { chainAsset -> + val enabled = chain.enabledAssets() + Log.d( + "BalancesDiag", + "FullSyncPaymentUpdater(${chain.name}): allAssets=${chain.assets.map { "${it.symbol}(id=${it.id},enabled=${it.enabled},type=${it.type})" }} " + + "enabledAssets=${enabled.map { it.symbol }}" + ) + + return enabled.map { chainAsset -> syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder) } .mergeIfMultiple() From 7a6b93a323fd7059ecec9527ff2136f41fd42f8a Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 10:51:57 -0700 Subject: [PATCH 21/34] diag: trace NativeAssetBalance.startSyncingBalance() entry/key/emission Confirmed via 3 CI runs: HEZ (native asset) is present+enabled in the runtime Chain object for Pezkuwi Asset Hub and gets dispatched to NativeAssetBalance without any exception, yet its System.Account subscribe request never reaches that chain's RPC connection at all - no error logged either. This traces exactly how far execution gets: does startSyncingBalance() even get entered, does getRuntime()/storageKey() complete, does subscribe()'s flow ever emit. --- .../blockchain/assets/balances/utility/NativeAssetBalance.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt index 8768fb15..c9513444 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt @@ -161,12 +161,17 @@ class NativeAssetBalance( accountId: AccountId, subscriptionBuilder: SharedRequestsBuilder ): Flow { + Log.d("BalancesDiag", "NativeAssetBalance.startSyncingBalance() ENTERED for ${chainAsset.symbol} on ${chain.name}") + val runtime = chainRegistry.getRuntime(chain.id) + Log.d("BalancesDiag", "NativeAssetBalance: got runtime for ${chain.name}") val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId) + Log.d("BalancesDiag", "NativeAssetBalance: computed key for ${chainAsset.symbol} on ${chain.name}: $key") return subscriptionBuilder.subscribe(key) .map { change -> + Log.d("BalancesDiag", "NativeAssetBalance: received change for ${chainAsset.symbol} on ${chain.name}") val accountInfo = bindAccountInfoOrDefault(change.value, runtime) val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo) From c5174d0ccf694c1be98e04527c496be9c42b5320 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 11:53:35 -0700 Subject: [PATCH 22/34] fix: NativeAssetBalance uses typed subscribe DSL, not raw key subscription Root cause found via 4 rounds of CI diagnostics: HEZ (native asset) on Pezkuwi Asset Hub was correctly present+enabled in the Chain domain model, correctly dispatched to NativeAssetBalance, and its System.Account storage key was correctly computed - but the raw subscriptionBuilder.subscribe(key) call never actually reached the RPC connection, with zero exceptions and zero data, while the chain's 5 other assets (all statemine-type, all using the same SharedRequestsBuilder) synced fine. The same NativeAssetBalance code worked correctly for every OTHER native asset on every OTHER chain tested, ruling out a generic bug in the class. Switched startSyncingBalance() to the typed remoteStorage.subscribe { } DSL (metadata.system.account.observeWithRaw) - the same mechanism this class's own subscribeAccountBalanceUpdatePoint() and PooledBalanceUpdater/ BalanceLocksUpdater already use successfully on the same busy chain connection, instead of the raw subscriptionBuilder.subscribe(key) call that appears to silently drop registration in that specific configuration. Also removes the diagnostic logging added during the investigation (BalancesUpdateSystem's per-chain balancesSync trace, FullSyncPaymentUpdater's per-chain asset dump) - kept the one genuinely valuable permanent addition, the "listenForUpdates() threw synchronously" error log for a previously silent failure mode. --- .../PezkuwiFullArchitectureBalancesTest.kt | 10 ++--- .../data/network/BalancesUpdateSystem.kt | 7 +--- .../balances/utility/NativeAssetBalance.kt | 38 +++++++++---------- .../balance/FullSyncPaymentUpdater.kt | 9 +---- 4 files changed, 26 insertions(+), 38 deletions(-) diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt index 14041e6f..08377910 100644 --- a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -29,9 +29,9 @@ import kotlin.time.Duration.Companion.seconds * Activity, so we collect it ourselves here via the same AssetsFeatureApi.updateSystem instance the real app * uses, instead of relying on app UI lifecycle to start it. * - * If this test fails, the failure message + logcat (tag "BalancesDiag", plus the standard per-updater error - * logs already wired into BalancesUpdateSystem/FullSyncPaymentUpdater) shows exactly which decision branch or - * exception is responsible - not another layer of inference from silence. + * If this test fails, the standard per-updater error logs already wired into BalancesUpdateSystem/ + * FullSyncPaymentUpdater/NativeAssetBalance show exactly which decision branch or exception is responsible - + * not another layer of inference from silence. */ class PezkuwiFullArchitectureBalancesTest { @@ -69,8 +69,8 @@ class PezkuwiFullArchitectureBalancesTest { assertNotNull( "No `assets` row was ever written for HEZ on Pezkuwi Asset Hub (metaId=$metaId) within 90s. " + - "The real BalancesUpdateSystem pipeline never completed a sync for this asset - check logcat " + - "tag 'BalancesDiag' and the standard FullSyncPaymentUpdater/StatemineAssetBalance error logs.", + "The real BalancesUpdateSystem pipeline never completed a sync for this asset - check the " + + "standard FullSyncPaymentUpdater/NativeAssetBalance error logs in logcat.", assetRow ) } finally { diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt index a6753875..820ef106 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt @@ -45,11 +45,6 @@ class BalancesUpdateSystem( } private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow { - Log.d( - "BalancesDiag", - "balancesSync(${chain.name}): hasAccountIn=${metaAccount.hasAccountIn(chain)} " + - "isDisabled=${chain.connectionState.isDisabled} canPerformFullSync=${chain.canPerformFullSync()}" - ) return when { !metaAccount.hasAccountIn(chain) -> emptyFlow() chain.connectionState.isDisabled -> emptyFlow() @@ -97,7 +92,7 @@ class BalancesUpdateSystem( // Was silently swallowed here with zero logging - listenForUpdates() itself is a suspend // call that can throw synchronously (e.g. FullSyncPaymentUpdater.listenForUpdates() calling // requireAccountIdIn(chain)), before ever returning a flow for the .catch{} above to guard. - Log.e("BalancesDiag", "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e) + Log.e(LOG_TAG, "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e) emptyFlow() } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt index c9513444..7f9df0c1 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt @@ -1,6 +1,7 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo import io.novafoundation.nova.common.data.network.runtime.binding.bindList import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum @@ -17,7 +18,6 @@ import io.novafoundation.nova.core_db.dao.LockDao import io.novafoundation.nova.core_db.model.BalanceHoldLocal import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache -import io.novafoundation.nova.feature_wallet_api.data.cache.bindAccountInfoOrDefault import io.novafoundation.nova.feature_wallet_api.data.cache.updateAsset import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate @@ -154,6 +154,14 @@ class NativeAssetBalance( // Setup/subscription failures are allowed to propagate rather than being swallowed into emptyFlow()/NoCause: // the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole call in a single retryWhen boundary meant // to catch and retry exactly these failures. Swallowing here would make that retry boundary never trigger. + // + // Uses the typed remoteStorage.subscribe { metadata.system.account... } DSL (same as this class's own + // subscribeAccountBalanceUpdatePoint() and PooledBalanceUpdater/BalanceLocksUpdater) instead of a raw + // subscriptionBuilder.subscribe(key) call: on chains with several other assets/updaters already sharing + // the same SharedRequestsBuilder (e.g. Pezkuwi Asset Hub's 5 statemine assets + nomination-pools updater), + // the raw form's System.Account subscription was silently never reaching the wire - no exception, no data, + // ever - while every other asset on the same chain synced fine. The typed DSL is what every other caller + // on a busy shared connection already uses successfully. override suspend fun startSyncingBalance( chain: Chain, chainAsset: Chain.Asset, @@ -161,26 +169,18 @@ class NativeAssetBalance( accountId: AccountId, subscriptionBuilder: SharedRequestsBuilder ): Flow { - Log.d("BalancesDiag", "NativeAssetBalance.startSyncingBalance() ENTERED for ${chainAsset.symbol} on ${chain.name}") + return remoteStorage.subscribe(chain.id, subscriptionBuilder) { + metadata.system.account.observeWithRaw(accountId) + }.map { change -> + val accountInfo = change.value ?: AccountInfo.empty() + val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo) - val runtime = chainRegistry.getRuntime(chain.id) - Log.d("BalancesDiag", "NativeAssetBalance: got runtime for ${chain.name}") - - val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId) - Log.d("BalancesDiag", "NativeAssetBalance: computed key for ${chainAsset.symbol} on ${chain.name}: $key") - - return subscriptionBuilder.subscribe(key) - .map { change -> - Log.d("BalancesDiag", "NativeAssetBalance: received change for ${chainAsset.symbol} on ${chain.name}") - val accountInfo = bindAccountInfoOrDefault(change.value, runtime) - val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo) - - if (assetChanged) { - BalanceSyncUpdate.CauseFetchable(change.block) - } else { - BalanceSyncUpdate.NoCause - } + if (assetChanged) { + BalanceSyncUpdate.CauseFetchable(change.at!!) + } else { + BalanceSyncUpdate.NoCause } + } } private fun bindBalanceHolds(dynamicInstance: Any?): List? { diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt index fae660a6..4b318e93 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt @@ -46,14 +46,7 @@ internal class FullSyncPaymentUpdater( ): Flow { val accountId = scopeValue.requireAccountIdIn(chain) - val enabled = chain.enabledAssets() - Log.d( - "BalancesDiag", - "FullSyncPaymentUpdater(${chain.name}): allAssets=${chain.assets.map { "${it.symbol}(id=${it.id},enabled=${it.enabled},type=${it.type})" }} " + - "enabledAssets=${enabled.map { it.symbol }}" - ) - - return enabled.map { chainAsset -> + return chain.enabledAssets().map { chainAsset -> syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder) } .mergeIfMultiple() From 72a881c0591e74cc001057eb58d598911d41e749 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 12:08:27 -0700 Subject: [PATCH 23/34] test: expand full-architecture test to cover the whole Pezkuwi ecosystem Was HEZ-on-Asset-Hub-only. Now data-driven from wallet-utils' new pezkuwi_assets_for_testBalance.json (TEST_ASSETS_URL), asserting every asset - HEZ/PEZ/USDT/DOT/ETH/BTC across Pezkuwi's 3 chains - gets an assets DB row written via a single shared BalancesUpdateSystem run, not just one asset on one chain. Failure message lists exactly which assets never synced, by name and chain. --- .../PezkuwiFullArchitectureBalancesTest.kt | 65 +++++++++++-------- runtime/build.gradle | 1 + 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt index 08377910..7ecf6dd6 100644 --- a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -2,27 +2,40 @@ package io.novafoundation.nova.balances import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.google.gson.Gson import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.fromJson import io.novafoundation.nova.core.model.CryptoType import io.novafoundation.nova.core_db.dao.AssetDao import io.novafoundation.nova.core_db.dao.MetaAccountDao import io.novafoundation.nova.core_db.di.DbApi import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.runtime.BuildConfig.TEST_ASSETS_URL import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull -import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test +import java.net.URL import kotlin.time.Duration.Companion.seconds +private data class AssetFixture(val chainId: String, val chainName: String, val assetId: Int, val symbol: String) +private data class AssetsFixtureFile(val account: String, val assets: List) + /** * Exercises the ACTUAL production balance-sync pipeline (BalancesUpdateSystem -> AssetCache/AssetDao) end to * end, unlike [BalancesIntegrationTest] which bypasses it entirely via a direct low-level storage query. This - * is meant to answer one question with hard evidence, not speculation: does the app's real, running background - * sync ever write an `assets` cache row for HEZ on the Pezkuwi Asset Hub chain, for a real, well-funded account? + * is meant to answer one question with hard evidence, not speculation: for a real, well-funded mainnet Founder + * account, does the app's real, running background sync ever write an `assets` cache row for every asset in + * wallet-utils' pezkuwi_assets_for_testBalance.json (HEZ/PEZ/USDT/DOT/ETH/BTC across Pezkuwi's chains) - not + * just the native balance on one chain, which is all the older [BalancesIntegrationTest] fixture covers. + * + * A single watch-only account is created and selected once, so a single BalancesUpdateSystem run has to + * successfully sync every asset in the fixture - this is what actually caught the 2026-07-09 HEZ-on-Asset-Hub + * silent sync failure (5 of 6 assets on that chain synced fine; only HEZ silently never did). * * BalancesUpdateSystem.start() is a cold flow - in production it's only ever collected by RootInteractor, * which is wired to the root Activity/ViewModel lifecycle. A bare instrumented test never launches that @@ -30,17 +43,11 @@ import kotlin.time.Duration.Companion.seconds * uses, instead of relying on app UI lifecycle to start it. * * If this test fails, the standard per-updater error logs already wired into BalancesUpdateSystem/ - * FullSyncPaymentUpdater/NativeAssetBalance show exactly which decision branch or exception is responsible - - * not another layer of inference from silence. + * FullSyncPaymentUpdater/NativeAssetBalance/StatemineAssetBalance show exactly which decision branch or + * exception is responsible - not another layer of inference from silence. */ class PezkuwiFullArchitectureBalancesTest { - // Mainnet Founder account (SS58, generic substrate prefix) - verified live via @pezkuwi/api on 2026-07-09 - // to hold a substantial non-zero, non-frozen free HEZ balance on Pezkuwi Asset Hub (180,297.80 HEZ). - private val founderSubstrateAddress = "5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF" - private val pezkuwiAssetHubChainId = "e7c15092dcbe3f320260ddbbc685bfceed9125a3b3d8436db2766201dec3b949" - private val hezAssetId = 0 - private val context = ApplicationProvider.getApplicationContext() private val dbApi = FeatureUtils.getFeature(context, DbApi::class.java) @@ -50,36 +57,38 @@ class PezkuwiFullArchitectureBalancesTest { private val assetsFeatureApi = FeatureUtils.getFeature(context, AssetsFeatureApi::class.java) @Test - fun testPezkuwiAssetHubHezBalanceActuallySyncs() = runBlocking { + fun testPezkuwiEcosystemAssetsActuallySync() = runBlocking { + val fixture: AssetsFixtureFile = Gson().fromJson(URL(TEST_ASSETS_URL).readText()) + val updateSystemJob = launch { assetsFeatureApi.updateSystem.start().collect {} } try { - val metaId = insertAndSelectFounderWatchAccount(metaAccountDao) + val metaId = insertAndSelectWatchAccount(metaAccountDao, fixture.account) - val assetRow = withTimeoutOrNull(90.seconds) { - while (true) { - val asset = assetDao.getAsset(metaId, pezkuwiAssetHubChainId, hezAssetId) - if (asset != null) return@withTimeoutOrNull asset - - delay(2.seconds) + val stillMissing = fixture.assets.toMutableList() + withTimeoutOrNull(120.seconds) { + while (stillMissing.isNotEmpty()) { + stillMissing.removeAll { asset -> + assetDao.getAsset(metaId, asset.chainId, asset.assetId) != null + } + if (stillMissing.isNotEmpty()) delay(2.seconds) } - @Suppress("UNREACHABLE_CODE") - null } - assertNotNull( - "No `assets` row was ever written for HEZ on Pezkuwi Asset Hub (metaId=$metaId) within 90s. " + - "The real BalancesUpdateSystem pipeline never completed a sync for this asset - check the " + - "standard FullSyncPaymentUpdater/NativeAssetBalance error logs in logcat.", - assetRow + assertTrue( + "No `assets` row was ever written for: ${stillMissing.joinToString { "${it.symbol} on ${it.chainName}" }} " + + "(metaId=$metaId) within 120s, out of ${fixture.assets.size} total. The real BalancesUpdateSystem " + + "pipeline never completed a sync for these - check the standard FullSyncPaymentUpdater/" + + "NativeAssetBalance/StatemineAssetBalance error logs in logcat.", + stillMissing.isEmpty() ) } finally { updateSystemJob.cancel() } } - private suspend fun insertAndSelectFounderWatchAccount(dao: MetaAccountDao): Long { - val accountId = founderSubstrateAddress.toAccountId() + private suspend fun insertAndSelectWatchAccount(dao: MetaAccountDao, substrateAddress: String): Long { + val accountId = substrateAddress.toAccountId() val metaAccount = MetaAccountLocal( substratePublicKey = accountId, diff --git a/runtime/build.gradle b/runtime/build.gradle index 207a8520..7d2d4e0e 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -13,6 +13,7 @@ android { buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\"" + buildConfigField "String", "TEST_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/pezkuwi_assets_for_testBalance.json\"" buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY") buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY") From a16c9cc5e5a4b3017078b544fce3dcc3cb888188 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 12:09:23 -0700 Subject: [PATCH 24/34] ci: run balances tests on every PR, not just schedule/manual dispatch This is what would have caught the 2026-07-09 HEZ-on-Asset-Hub silent sync regression at PR time instead of hours into a live-app investigation after merge. Not yet wired as a required status check - want to confirm it runs clean on real PRs first (TronBalancesIntegrationTest has a known TronGrid rate-limit flake that would need addressing before this can safely block merges). --- .github/workflows/balances_test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/balances_test.yml b/.github/workflows/balances_test.yml index 6c74a8fa..d965a092 100644 --- a/.github/workflows/balances_test.yml +++ b/.github/workflows/balances_test.yml @@ -1,6 +1,7 @@ name: Run balances tests on: + pull_request: workflow_dispatch: schedule: - cron: '0 */8 * * *' From 593fedcdd46249788516607947a6989a0181762d Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 12:34:02 -0700 Subject: [PATCH 25/34] fix: avoid suspend call inside non-inline removeAll(predicate) removeAll { assetDao.getAsset(...) } failed to compile ("Suspension functions can only be called within coroutine body"). filter{} is unambiguously inline and safe for a suspend call inside a suspend function; pair it with the plain Collection-based removeAll(elements) overload instead, which takes no lambda at all. --- .../nova/balances/PezkuwiFullArchitectureBalancesTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt index 7ecf6dd6..d0d6fe34 100644 --- a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -68,9 +68,10 @@ class PezkuwiFullArchitectureBalancesTest { val stillMissing = fixture.assets.toMutableList() withTimeoutOrNull(120.seconds) { while (stillMissing.isNotEmpty()) { - stillMissing.removeAll { asset -> + val found = stillMissing.filter { asset -> assetDao.getAsset(metaId, asset.chainId, asset.assetId) != null } + stillMissing.removeAll(found) if (stillMissing.isNotEmpty()) delay(2.seconds) } } From ae8acb9d12b6cbc2477c6430308836a3936e660a Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 13:32:17 -0700 Subject: [PATCH 26/34] revert: NativeAssetBalance back to raw subscribe(key), typed DSL made it worse CI proved the typed remoteStorage.subscribe DSL swap (previous commit) was not a fix - it was a regression. All 8 assets across all 3 Pezkuwi chains failed to sync in the follow-up test run (vs just HEZ-on-Asset-Hub before), and the logs showed what looks like storage keys meant for Pezkuwi Asset Hub's Assets pallet being sent to the Pezkuwi relay chain's connection instead - some kind of cross-chain contamination introduced by the DSL's interaction with the shared connection, not understood yet. Reverting to the raw subscriptionBuilder.subscribe(key) form restores the prior, narrower state: 5 of 6 assets on Pezkuwi Asset Hub sync fine, HEZ specifically does not, and every other chain is unaffected. The underlying HEZ bug is still open - this just stops the attempted fix from actively making things worse while it's investigated further. --- .../balances/utility/NativeAssetBalance.kt | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt index 7f9df0c1..c394ebdb 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt @@ -1,7 +1,6 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility import android.util.Log -import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo import io.novafoundation.nova.common.data.network.runtime.binding.bindList import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum @@ -18,6 +17,7 @@ import io.novafoundation.nova.core_db.dao.LockDao import io.novafoundation.nova.core_db.model.BalanceHoldLocal import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.bindAccountInfoOrDefault import io.novafoundation.nova.feature_wallet_api.data.cache.updateAsset import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate @@ -155,13 +155,13 @@ class NativeAssetBalance( // the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole call in a single retryWhen boundary meant // to catch and retry exactly these failures. Swallowing here would make that retry boundary never trigger. // - // Uses the typed remoteStorage.subscribe { metadata.system.account... } DSL (same as this class's own - // subscribeAccountBalanceUpdatePoint() and PooledBalanceUpdater/BalanceLocksUpdater) instead of a raw - // subscriptionBuilder.subscribe(key) call: on chains with several other assets/updaters already sharing - // the same SharedRequestsBuilder (e.g. Pezkuwi Asset Hub's 5 statemine assets + nomination-pools updater), - // the raw form's System.Account subscription was silently never reaching the wire - no exception, no data, - // ever - while every other asset on the same chain synced fine. The typed DSL is what every other caller - // on a busy shared connection already uses successfully. + // NOTE (2026-07-09): a prior attempt switched this to the typed remoteStorage.subscribe { metadata.system + // .account... } DSL, on the theory that it would fix HEZ silently never syncing on Pezkuwi Asset Hub (see + // git history). That attempt made things categorically worse - all assets across all 3 Pezkuwi chains + // stopped syncing, with logs showing what looked like cross-chain key contamination on the shared + // connection. Reverted back to the raw subscriptionBuilder.subscribe(key) form here, which is not broken + // for any OTHER native asset on any OTHER chain - only Pezkuwi Asset Hub's HEZ specifically. That narrower + // bug is still open; do not re-attempt the DSL swap without first understanding why it caused contamination. override suspend fun startSyncingBalance( chain: Chain, chainAsset: Chain.Asset, @@ -169,18 +169,21 @@ class NativeAssetBalance( accountId: AccountId, subscriptionBuilder: SharedRequestsBuilder ): Flow { - return remoteStorage.subscribe(chain.id, subscriptionBuilder) { - metadata.system.account.observeWithRaw(accountId) - }.map { change -> - val accountInfo = change.value ?: AccountInfo.empty() - val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo) + val runtime = chainRegistry.getRuntime(chain.id) - if (assetChanged) { - BalanceSyncUpdate.CauseFetchable(change.at!!) - } else { - BalanceSyncUpdate.NoCause + val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId) + + return subscriptionBuilder.subscribe(key) + .map { change -> + val accountInfo = bindAccountInfoOrDefault(change.value, runtime) + val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo) + + if (assetChanged) { + BalanceSyncUpdate.CauseFetchable(change.block) + } else { + BalanceSyncUpdate.NoCause + } } - } } private fun bindBalanceHolds(dynamicInstance: Any?): List? { From 54bea1234a5140e0a7ef9814a7e4baa307f8019c Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 14:44:30 -0700 Subject: [PATCH 27/34] fix: revert FullSyncPaymentUpdater's retryWhen wrapper - root cause found Diffed the whole branch against main (the live, working Play Store source) instead of continuing to guess from logs. Found the actual regression: syncAsset() was changed from a suspend fun that EAGERLY calls startSyncingBalance() (registering each asset's storage key synchronously, during listenForUpdates()) into a plain fun returning a lazy flow { } that only calls startSyncingBalance() once collected - to support a retryWhen wrapper added earlier this session. BalancesUpdateSystem.launchChainUpdaters() calls subscriptionBuilder.subscribe(coroutineContext) - which seals the shared per-chain subscription multiplexer - immediately after listenForUpdates() returns, then only starts collecting the merged result flow (which is what triggers the lazy startSyncingBalance() calls) afterward. So every asset's key registration now races against the seal instead of reliably happening before it, matching main's original guaranteed ordering. Some assets win the race often enough to look like they work; this is why the full ecosystem test - which creates one account and races ALL of it at once - saw every asset fail, while the older single-asset test mostly saw only HEZ fail. Reverted to main's version: suspend syncAsset(), eager startSyncingBalance() call, no retryWhen. Loses automatic retry of transient full-sync failures (which the retryWhen wrapper was meant to add), but that capability isn't worth reintroducing an ordering bug that can silently break sync for an unpredictable subset of assets on every chain, not just Pezkuwi's. --- .../balance/FullSyncPaymentUpdater.kt | 44 ++++++------------- 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt index 4b318e93..56a18dd3 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt @@ -22,14 +22,9 @@ import io.novafoundation.nova.runtime.ext.enabledAssets import io.novafoundation.nova.runtime.ext.localId import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novasama.substrate_sdk_android.runtime.AccountId -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.retryWhen - -private const val SYNC_RETRY_DELAY_MS = 30_000L internal class FullSyncPaymentUpdater( private val operationDao: OperationDao, @@ -46,43 +41,32 @@ internal class FullSyncPaymentUpdater( ): Flow { val accountId = scopeValue.requireAccountIdIn(chain) - return chain.enabledAssets().map { chainAsset -> + return chain.enabledAssets().mapNotNull { chainAsset -> syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder) } .mergeIfMultiple() .noSideAffects() } - /** - * Wraps both the initial `startSyncingBalance()` call and the resulting flow in a single retry - * boundary. Without this, any transient failure - during initial subscription setup (a WSS - * hiccup, an orml currencyId-decode edge case, a node not supporting a specific RPC method - * during round-robin) or later in the flow (a dropped connection) - would permanently and - * silently kill sync for that one asset: no DB row ever gets created/updated for it, so it - * vanishes from every UI screen with nothing but a logcat line as evidence, until the app is - * restarted (and even then, with the same odds of failing again). Retrying indefinitely on a - * fixed interval matches the same fix already applied to Tron's balance polling. - */ - private fun syncAsset( + private suspend fun syncAsset( chainAsset: Chain.Asset, metaAccount: MetaAccount, accountId: AccountId, storageSubscriptionBuilder: SharedRequestsBuilder - ): Flow { + ): Flow? { val assetSource = assetSourceRegistry.sourceFor(chainAsset) - return flow { - val assetUpdateFlow = assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder) - emitAll(assetUpdateFlow) + val assetUpdateFlow = runCatching { + assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder) } - .onEach { balanceUpdate -> - assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId) - } - .retryWhen { cause, _ -> - logSyncError(chain, chainAsset, error = cause) - delay(SYNC_RETRY_DELAY_MS) - true - } + .onFailure { logSyncError(chain, chainAsset, error = it) } + .getOrNull() + ?: return null + + return assetUpdateFlow.onEach { balanceUpdate -> + assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId) + } + .catch { logSyncError(chain, chainAsset, error = it) } } private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) { From 01effc26c90a5faf3851706e62012e6c396ef52f Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 17:29:55 -0700 Subject: [PATCH 28/34] fix: retry TronGrid 429s instead of just tolerating the flake Public TronGrid rate-limits aggressively, and this test now runs on every PR (plus its own 8h schedule) - a bare 429 was failing runs for a transient, infrastructure reason unrelated to code correctness. Add exponential backoff retry instead of treating it as an accepted flake. Also required-status-check balances_test.yml's run-tests job on main now that both this and the wallet-utils phantom-asset fix are confirmed clean - it was deliberately left optional until proven, per "don't promise gates you haven't verified." --- .../balances/TronBalancesIntegrationTest.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt index f8748960..a9baeac1 100644 --- a/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt @@ -2,10 +2,12 @@ package io.novafoundation.nova.balances import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import org.junit.Assert.assertTrue import org.junit.Test +import retrofit2.HttpException import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory @@ -40,9 +42,25 @@ class TronBalancesIntegrationTest { RealTronGridApi(retrofit.create(RetrofitTronGridApi::class.java)) } + // TronGrid's public (no API key) endpoint rate-limits aggressively, and this test now runs on every PR + // (see balances_test.yml) in addition to its own 2 calls back-to-back - a bare 429 previously failed the + // whole run for a transient, infrastructure-level reason unrelated to whether the wallet's code is correct. + // Retry with backoff instead of just tolerating the flake. + private suspend fun retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T { + repeat(maxAttempts - 1) { attempt -> + try { + return block() + } catch (e: HttpException) { + if (e.code() != 429) throw e + delay(2_000L * (attempt + 1)) + } + } + return block() + } + @Test fun testNativeTrxBalanceLoading() = runBlocking { - val freeBalance = tronGridApi.fetchNativeBalance(baseUrl, testAddress) + val freeBalance = retryOn429 { tronGridApi.fetchNativeBalance(baseUrl, testAddress) } assertTrue("TRX balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) assertTrue("TRX balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) @@ -50,7 +68,7 @@ class TronBalancesIntegrationTest { @Test fun testTrc20UsdtBalanceLoading() = runBlocking { - val freeBalance = tronGridApi.fetchTrc20Balance(baseUrl, testAddress, usdtContractAddress) + val freeBalance = retryOn429 { tronGridApi.fetchTrc20Balance(baseUrl, testAddress, usdtContractAddress) } assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) From 9105560a362ba22e44b4d1fe343c7533a0c0fd49 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 19:53:32 -0700 Subject: [PATCH 29/34] test: verify Ethereum USDT sync too, closing the third originally-reported gap Adds a real EVM address for the Founder account - derived via standard BIP44 (m/44'/60'/0'/0/0) from the same already-verified mnemonic used for the substrate address, since no dedicated founder EVM wallet record exists anywhere. Ethereum's USDT-ERC20 assetId isn't a fixed integer like Substrate assets (EvmAssetsSyncService hashes the contract address at sync time), so it's resolved dynamically via ChainAssetDao instead of hardcoded in the JSON fixture. This closes the loop on all 3 originally-reported symptoms from this investigation: Tron disabled (fixed, unaffected by any revert), Pezkuwi tokens missing (fixed - ordering bug), USDT on Polkadot AH/Ethereum missing (Polkadot AH already covered, Ethereum added here). Ethereum ERC20 sync uses an entirely separate mechanism (EthereumRequestsAggregator, not Substrate's StorageSubscriptionMultiplexer) so it was likely never actually broken by anything this session touched - this verifies that directly instead of assuming it from code reading. --- .../PezkuwiFullArchitectureBalancesTest.kt | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt index d0d6fe34..9ffcb697 100644 --- a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -7,11 +7,13 @@ import io.novafoundation.nova.common.di.FeatureUtils import io.novafoundation.nova.common.utils.fromJson import io.novafoundation.nova.core.model.CryptoType import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.ChainAssetDao import io.novafoundation.nova.core_db.dao.MetaAccountDao import io.novafoundation.nova.core_db.di.DbApi import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi import io.novafoundation.nova.runtime.BuildConfig.TEST_ASSETS_URL +import io.novasama.substrate_sdk_android.extensions.fromHex import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -48,11 +50,21 @@ private data class AssetsFixtureFile(val account: String, val assets: List() private val dbApi = FeatureUtils.getFeature(context, DbApi::class.java) private val metaAccountDao = dbApi.metaAccountDao() private val assetDao: AssetDao = dbApi.provideAssetDao() + private val chainAssetDao: ChainAssetDao = dbApi.chainAssetDao() private val assetsFeatureApi = FeatureUtils.getFeature(context, AssetsFeatureApi::class.java) @@ -63,9 +75,35 @@ class PezkuwiFullArchitectureBalancesTest { val updateSystemJob = launch { assetsFeatureApi.updateSystem.start().collect {} } try { - val metaId = insertAndSelectWatchAccount(metaAccountDao, fixture.account) + val metaId = insertAndSelectWatchAccount(metaAccountDao, fixture.account, founderEthereumAddress) + + // Ethereum's USDT isn't in the JSON fixture because its assetId isn't a fixed, known integer like + // Substrate assets - EvmAssetsSyncService computes it as a hash of the ERC20 contract address at + // sync time (see chainAssetIdOfErc20Token()), so it has to be resolved dynamically here instead of + // hardcoded. This closes out the third of the three originally-reported symptoms (Tron disabled, + // Pezkuwi tokens missing, USDT on Polkadot AH/Ethereum missing) - the first two are already covered + // by the fixture-driven assets above. + val ethereumUsdtAssetId = withTimeoutOrNull(30.seconds) { + var assetId: Int? = null + while (assetId == null) { + assetId = chainAssetDao.getEnabledAssets() + .firstOrNull { it.chainId == ethereumChainId && it.symbol == "USDT" } + ?.id + if (assetId == null) delay(2.seconds) + } + assetId + } + assertTrue( + "USDT was never registered as an enabled asset on Ethereum (chainId=$ethereumChainId) within 30s - " + + "EvmAssetsSyncService may have failed to sync from EVM_ASSETS_URL.", + ethereumUsdtAssetId != null + ) + + val stillMissing = ( + fixture.assets + + AssetFixture(ethereumChainId, "Ethereum", ethereumUsdtAssetId!!, "USDT") + ).toMutableList() - val stillMissing = fixture.assets.toMutableList() withTimeoutOrNull(120.seconds) { while (stillMissing.isNotEmpty()) { val found = stillMissing.filter { asset -> @@ -78,9 +116,9 @@ class PezkuwiFullArchitectureBalancesTest { assertTrue( "No `assets` row was ever written for: ${stillMissing.joinToString { "${it.symbol} on ${it.chainName}" }} " + - "(metaId=$metaId) within 120s, out of ${fixture.assets.size} total. The real BalancesUpdateSystem " + + "(metaId=$metaId) within 120s, out of ${fixture.assets.size + 1} total. The real BalancesUpdateSystem " + "pipeline never completed a sync for these - check the standard FullSyncPaymentUpdater/" + - "NativeAssetBalance/StatemineAssetBalance error logs in logcat.", + "NativeAssetBalance/StatemineAssetBalance/EvmErc20AssetBalance error logs in logcat.", stillMissing.isEmpty() ) } finally { @@ -88,15 +126,16 @@ class PezkuwiFullArchitectureBalancesTest { } } - private suspend fun insertAndSelectWatchAccount(dao: MetaAccountDao, substrateAddress: String): Long { + private suspend fun insertAndSelectWatchAccount(dao: MetaAccountDao, substrateAddress: String, ethereumAddress: String): Long { val accountId = substrateAddress.toAccountId() + val evmAddress = ethereumAddress.removePrefix("0x").fromHex() val metaAccount = MetaAccountLocal( substratePublicKey = accountId, substrateCryptoType = CryptoType.SR25519, substrateAccountId = accountId, ethereumPublicKey = null, - ethereumAddress = null, + ethereumAddress = evmAddress, name = "PezkuwiFullArchitectureBalancesTest", parentMetaId = null, isSelected = false, From 7a60afb98ff5776660215321c7dd444d24774e21 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 21:28:46 -0700 Subject: [PATCH 30/34] fix: cache AVD and retry emulator boot to stop balances-test CI flake main's scheduled run has been failing most cycles since 2026-07-06 with either a corrupted SDK package download (Error on ZipFile unknown archive) or a plain emulator boot timeout - both manifest as adb never reaching emulator-5554. The job never cached the AVD, so every single run re-hit Google's SDK servers for a fresh system image download, keeping it exposed to this exact flake on every run and every 8-hour schedule tick. Cache the AVD (skips the download entirely once warm) and duplicate the test-run step as a same-job retry for the residual flake (cold cache, or a rare boot timeout even with a warm cache). --- .github/workflows/balances_test.yml | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/balances_test.yml b/.github/workflows/balances_test.yml index d965a092..9b5f57d4 100644 --- a/.github/workflows/balances_test.yml +++ b/.github/workflows/balances_test.yml @@ -44,13 +44,50 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-29-nexus6-x86_64-v1 + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + disable-animations: false + profile: Nexus 6 + api-level: 29 + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: echo "Generated AVD snapshot for caching." + - name: Run tests + id: run-tests-attempt-1 + continue-on-error: true uses: reactivecircus/android-emulator-runner@v2 with: disable-animations: true profile: Nexus 6 api-level: 29 arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot-save -noaudio -no-boot-anim + script: .github/scripts/run_balances_test.sh + + - name: Run tests (retry - reactivecircus/android-emulator-runner flakes on SDK download/emulator boot) + if: steps.run-tests-attempt-1.outcome == 'failure' + uses: reactivecircus/android-emulator-runner@v2 + with: + disable-animations: true + profile: Nexus 6 + api-level: 29 + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot-save -noaudio -no-boot-anim script: .github/scripts/run_balances_test.sh - uses: actions/upload-artifact@v4 From 55d405c3a1275e73eb64db0f97e335e0046cdd0e Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 9 Jul 2026 22:46:09 -0700 Subject: [PATCH 31/34] fix: move TRX ahead of BTC/ETH/BNB in default token order, drop UNI's pin New order per product spec: Pezkuwi ecosystem, DOT, KSM, USDC, TRX, BTC, ETH, BNB, AVAX, LINK, TAO, then everything else alphabetically. UNI no longer gets an explicit slot - it now falls into the same alphabetical bucket as any other unlisted symbol. --- .../novafoundation/nova/runtime/ext/TokenSorting.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt index 6596531b..8278c4d9 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt @@ -10,15 +10,14 @@ val TokenSymbol.mainTokensFirstAscendingOrder "DOT" -> 3 "KSM" -> 4 "USDC" -> 5 - "BTC" -> 6 - "ETH" -> 7 - "BNB" -> 8 - "TRX" -> 9 + "TRX" -> 6 + "BTC" -> 7 + "ETH" -> 8 + "BNB" -> 9 "AVAX" -> 10 "LINK" -> 11 - "UNI" -> 12 - "TAO" -> 13 - else -> 14 + "TAO" -> 12 + else -> 13 } val TokenSymbol.alphabeticalOrder From 9de7267a588e0c55dd9c88e75b5cf1e8356ef5e8 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 07:25:33 -0700 Subject: [PATCH 32/34] test: add native TRX transfer test - sign/broadcast pipeline had no coverage RealTronTransactionService's native-TRX branch (transact/calculateFee for TronTransactionIntent.Native) had zero automated coverage - only the TRC20 ABI encoding (Trc20TransferAbiTest) and address derivation/formatting (TronDerivationTest, TronAddressTest) were tested. Send/transfer code moving real funds shouldn't ship untested just because it happens to share its sign/broadcast plumbing with an already-tested asset type. Covers: the raw_data is hashed and signed exactly as documented (sha256, not the raw bytes or txID), the r+s+v signature is assembled correctly before being sent to broadcastTransaction, and - the one true security invariant here - a TronGrid response whose txID doesn't match sha256(raw_data) is refused before ever reaching the signer, not just before broadcast. Also covers the native fee estimator's bandwidth-shortfall math against the documented fallback price. Fixture note: the raw_data_hex/txID pair here is self-consistent (computed locally) rather than live-captured against TronGrid, unlike Trc20TransferAbiTest's ABI vector - this class never encodes a real TransferContract protobuf itself (see its own class doc), so what matters is that this service correctly hashes/signs/forwards whatever raw_data TronGrid returns, which a self-consistent fixture exercises identically. --- .../RealTronTransactionServiceTest.kt | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt diff --git a/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt new file mode 100644 index 00000000..b29091ab --- /dev/null +++ b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt @@ -0,0 +1,253 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import com.google.gson.JsonObject +import io.novafoundation.nova.common.utils.Precision +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.sha256 +import io.novafoundation.nova.common.utils.toTronHexAddress +import io.novafoundation.nova.common.utils.tronAddressToAccountId +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.argThat +import io.novafoundation.nova.test_shared.eq +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner +import java.math.BigInteger + +/** + * Covers the native-TRX branch of [RealTronTransactionService.transact] - the sign/broadcast pipeline this class' + * own doc comment describes (sha256(raw_data) signed via the existing Ethereum-style ECDSA-raw-hash primitive, + * assembled as a 65-byte r+s+v signature) had no test at all before this: [Trc20TransferAbiTest] only covers the + * TRC-20 ABI-encoding side, and [TronDerivationTest]/[io.novafoundation.nova.common.utils.TronAddressTest] only + * cover address derivation/formatting, not transaction construction or signing. + * + * The owner address used throughout is the same one [TronDerivationTest] cross-validated against the standard + * BIP39 test mnemonic ("abandon x11 about" at the coin-195 path) and against live TronGrid data - reusing it here + * keeps every Tron test in this codebase anchored to a single, independently-verified real-world address instead + * of an arbitrary one. The recipient and raw_data_hex/txID pair are self-consistent fixtures created for this + * test only (unlike [Trc20TransferAbiTest]'s ABI vector, this raw_data_hex was not captured live against + * TronGrid - constructing a real `TransferContract` protobuf is out of scope here, since this class deliberately + * never encodes one itself, see its class doc) - what matters for these tests is that this service correctly + * hashes/signs/forwards whatever raw_data TronGrid returns, which a self-consistent fixture exercises just as + * well as a live-captured one. + */ +@RunWith(MockitoJUnitRunner::class) +class RealTronTransactionServiceTest { + + @Mock + lateinit var accountRepository: AccountRepository + + @Mock + lateinit var signerProvider: SignerProvider + + @Mock + lateinit var tronGridApi: TronGridApi + + @Mock + lateinit var metaAccount: MetaAccount + + @Mock + lateinit var signer: NovaSigner + + private lateinit var subject: RealTronTransactionService + + private val ownerAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId() + private val ownerHex = ownerAccountId.toTronHexAddress() + + private val recipientAccountId = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex() + private val recipientHex = recipientAccountId.toTronHexAddress() + + private val amountSun = BigInteger.valueOf(1_000_000) + + private val baseUrl = "https://api.trongrid.io" + private val chain = tronChain(baseUrl) + + // Self-consistent fixture: rawDataHex ++ its own sha256 as txID - see class doc. + private val rawDataHex = "0a027a1e2208d1e2b3f4a5b6c7d840e8c896e8b7325a67080112630a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412320a1541dfd8703a5c753e17ed52a96a29cea9d425538dfe1215415d10da10f5c60a8e2d5e3c0a70e1e7f3c1b2a3e41880ade20470a08fc9c9e8b732" + private val expectedTxId = rawDataHex.fromHex().sha256().toHexString(withPrefix = false) + + @Before + fun setup() { + subject = RealTronTransactionService(accountRepository, signerProvider, tronGridApi) + + whenever(metaAccount.accountIdIn(eq(chain))).thenReturn(ownerAccountId) + whenever(signerProvider.rootSignerFor(eq(metaAccount))).thenReturn(signer) + } + + @Test + fun `transact with Native intent should build, sign with sha256(raw_data), and broadcast a 65-byte r+s+v signature`() = runBlocking { + val unsigned = TronUnsignedTransactionResponse( + visible = false, + txID = expectedTxId, + rawData = JsonObject(), + rawDataHex = rawDataHex + ) + + val r = ByteArray(32) { (it + 1).toByte() } + val s = ByteArray(32) { (it + 33).toByte() } + val v = byteArrayOf(27) + val fakeSignedRaw = SignedRaw( + SignerPayloadRaw(message = expectedTxId.fromHex(), accountId = ownerAccountId, skipMessageHashing = true), + SignatureWrapper.Ecdsa(v = v, r = r, s = s) + ) + + whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned) + whenever(signer.signRaw(any())).thenReturn(fakeSignedRaw) + whenever(tronGridApi.broadcastTransaction(eq(baseUrl), eq(unsigned), any())).thenReturn("some-broadcast-tx-hash") + + val result = subject.transact( + chain = chain, + origin = TransactionOrigin.Wallet(metaAccount), + recipient = recipientAccountId, + presetFee = null, + intent = TronTransactionIntent.Native(amountSun) + ) + + assertTrue(result.isSuccess) + assertEquals("some-broadcast-tx-hash", result.getOrThrow().hash) + + // The message actually handed to the signer must be sha256(raw_data), not raw_data or txID itself - a + // regression here would silently produce a signature over the wrong bytes. ByteArray has reference + // equality in Kotlin, so this must compare contents (contentEquals), not rely on SignerPayloadRaw.equals(). + val expectedMessage = rawDataHex.fromHex().sha256() + verify(signer).signRaw( + argThat { payload -> + payload.message.contentEquals(expectedMessage) && + payload.accountId.contentEquals(ownerAccountId) && + payload.skipMessageHashing + } + ) + + // Tron expects a flat 65-byte r(32)+s(32)+v(1) hex signature - assembled by hand in production code, not + // by any library, so this is the one place that byte order/length could silently regress. + val expectedSignatureHex = (r + s + v).toHexString(withPrefix = false) + verify(tronGridApi).broadcastTransaction(baseUrl, unsigned, expectedSignatureHex) + } + + @Test + fun `transact should refuse to sign when TronGrid's txID does not match sha256(raw_data)`() = runBlocking { + val tamperedTxId = "0".repeat(64) // deliberately wrong - does not match sha256(rawDataHex) + val unsigned = TronUnsignedTransactionResponse( + visible = false, + txID = tamperedTxId, + rawData = JsonObject(), + rawDataHex = rawDataHex + ) + + whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned) + + val result = subject.transact( + chain = chain, + origin = TransactionOrigin.Wallet(metaAccount), + recipient = recipientAccountId, + presetFee = null, + intent = TronTransactionIntent.Native(amountSun) + ) + + assertTrue("expected a failed Result when txID doesn't match sha256(raw_data)", result.isFailure) + + // Signing (and therefore broadcasting) must never be attempted once the txID/raw_data mismatch is + // detected - this is the guard that stops a tampered/malicious response from getting silently signed. + verify(signer, never()).signRaw(any()) + verify(tronGridApi, never()).broadcastTransaction(any(), any(), any()) + } + + @Test + fun `calculateFee for Native intent should charge bandwidth shortfall at the fallback price when TronGrid's own resource_endpoints are unavailable`() = runBlocking { + val unsigned = TronUnsignedTransactionResponse( + visible = false, + txID = expectedTxId, + rawData = JsonObject(), + rawDataHex = rawDataHex + ) + val txSizeBytes = rawDataHex.length / 2 + + whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned) + whenever(tronGridApi.getAccountResource(eq(baseUrl), eq(ownerHex))).thenReturn(TronAccountResourceResponse()) + whenever(tronGridApi.getChainParameters(eq(baseUrl))).thenReturn(emptyMap()) + + val fee = subject.calculateFee( + chain = chain, + origin = TransactionOrigin.Wallet(metaAccount), + recipient = recipientAccountId, + intent = TronTransactionIntent.Native(amountSun) + ) + + // Zero available bandwidth (empty TronAccountResourceResponse) -> the whole tx size is billed, at the + // fallback bandwidth price (1000 sun/byte) since getChainParameters returned no getTransactionFee entry. + val expectedFeeSun = BigInteger.valueOf(txSizeBytes.toLong()) * BigInteger.valueOf(1000) + assertEquals(expectedFeeSun, fee.amount) + } + + private fun tronChain(baseUrl: String): Chain { + val trxAsset = Chain.Asset( + icon = null, + id = 0, + priceId = "tron", + chainId = "tron:mainnet", + symbol = TokenSymbol("TRX"), + precision = Precision(6), + buyProviders = emptyMap(), + sellProviders = emptyMap(), + staking = emptyList(), + type = Chain.Asset.Type.TronNative, + source = Chain.Asset.Source.DEFAULT, + name = "Tron", + enabled = true + ) + + return Chain( + id = "tron:mainnet", + name = "Tron", + assets = listOf(trxAsset), + nodes = Chain.Nodes( + autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN, + wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance, + nodes = listOf(Chain.Node(chainId = "tron:mainnet", unformattedUrl = baseUrl, name = "TronGrid", orderId = 0, isCustom = false)) + ), + explorers = emptyList(), + externalApis = emptyList(), + icon = null, + addressPrefix = 0, + legacyAddressPrefix = null, + types = null, + isEthereumBased = false, + isTronBased = true, + isTestNet = false, + source = Chain.Source.DEFAULT, + hasSubstrateRuntime = false, + pushSupport = false, + hasCrowdloans = false, + supportProxy = false, + governance = emptyList(), + swap = emptyList(), + customFee = emptyList(), + multisigSupport = false, + connectionState = Chain.ConnectionState.FULL_SYNC, + parentId = null, + additional = null + ) + } +} From 096d3d61ed93d76d7413c1c9116263aba7505954 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 07:53:08 -0700 Subject: [PATCH 33/34] fix: add missing test-shared dependency for feature-wallet-impl unit tests RealTronTransactionServiceTest needed test-shared's Mockito helpers (whenever/eq/any/argThat), but feature-wallet-impl never declared testImplementation project(':test-shared') - every other module that uses these helpers (feature-account-impl, runtime, common, etc.) already does. Also pin two ambiguous-without-it type inferences (argThat's lambda param, emptyMap's type args) explicitly rather than relying on the dependency fix alone to un-cascade them. --- feature-wallet-impl/build.gradle | 1 + .../tron/transaction/RealTronTransactionServiceTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/feature-wallet-impl/build.gradle b/feature-wallet-impl/build.gradle index efa0ef15..e04d2ce2 100644 --- a/feature-wallet-impl/build.gradle +++ b/feature-wallet-impl/build.gradle @@ -71,6 +71,7 @@ dependencies { testImplementation jUnitDep testImplementation mockitoDep + testImplementation project(':test-shared') implementation substrateSdkDep diff --git a/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt index b29091ab..1827ce15 100644 --- a/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt +++ b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt @@ -133,7 +133,7 @@ class RealTronTransactionServiceTest { // equality in Kotlin, so this must compare contents (contentEquals), not rely on SignerPayloadRaw.equals(). val expectedMessage = rawDataHex.fromHex().sha256() verify(signer).signRaw( - argThat { payload -> + argThat { payload: SignerPayloadRaw -> payload.message.contentEquals(expectedMessage) && payload.accountId.contentEquals(ownerAccountId) && payload.skipMessageHashing @@ -186,7 +186,7 @@ class RealTronTransactionServiceTest { whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned) whenever(tronGridApi.getAccountResource(eq(baseUrl), eq(ownerHex))).thenReturn(TronAccountResourceResponse()) - whenever(tronGridApi.getChainParameters(eq(baseUrl))).thenReturn(emptyMap()) + whenever(tronGridApi.getChainParameters(eq(baseUrl))).thenReturn(emptyMap()) val fee = subject.calculateFee( chain = chain, From c5c04807d423034663ad130ef58bb522d1cb17f4 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 08:34:06 -0700 Subject: [PATCH 34/34] ci: show full exception detail for feature-wallet-impl unit test failures Default Gradle test logging only prints the exception class name + one stack frame for a failing test, not its message or cause chain - that's exactly what happened debugging RealTronTransactionServiceTest's InvalidTestClassError just now (console showed nothing beyond the bare exception type). Not useful for anyone hitting a real test failure in CI without a local Gradle run to inspect the HTML report. --- feature-wallet-impl/build.gradle | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/feature-wallet-impl/build.gradle b/feature-wallet-impl/build.gradle index e04d2ce2..df610b59 100644 --- a/feature-wallet-impl/build.gradle +++ b/feature-wallet-impl/build.gradle @@ -27,6 +27,17 @@ android { } namespace 'io.novafoundation.nova.feature_wallet_impl' + testOptions { + unitTests.all { + testLogging { + events "failed" + exceptionFormat "full" + showCauses true + showStackTraces true + } + } + } + buildFeatures { viewBinding true }