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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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 } From fb1e0c9cf0f155253f437af06c7014d8314ce111 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 09:05:03 -0700 Subject: [PATCH 35/56] fix: add explicit : Unit return type to @Test functions using runBlocking JUnit4's BlockJUnit4ClassRunner requires @Test methods to compile with a void return type. 'fun test() = runBlocking { ... }' infers the function's return type from the block's last expression - two of these tests ended on a Mockito verify(...).someMethod(...) call, whose return type leaks through as the inferred type (e.g. String, since TronGridApi.broadcastTransaction returns String) instead of Unit, so the whole test class failed ParentRunner validation before any test could even run. Declaring the return type explicitly as Unit makes Kotlin discard the expression's value (unit-coercion) rather than infer it - added to all three tests for consistency, not just the two that were actually failing. --- .../tron/transaction/RealTronTransactionServiceTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 1827ce15..3bfbd1a7 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 @@ -97,7 +97,7 @@ class RealTronTransactionServiceTest { } @Test - fun `transact with Native intent should build, sign with sha256(raw_data), and broadcast a 65-byte r+s+v signature`() = runBlocking { + fun `transact with Native intent should build, sign with sha256(raw_data), and broadcast a 65-byte r+s+v signature`(): Unit = runBlocking { val unsigned = TronUnsignedTransactionResponse( visible = false, txID = expectedTxId, @@ -147,7 +147,7 @@ class RealTronTransactionServiceTest { } @Test - fun `transact should refuse to sign when TronGrid's txID does not match sha256(raw_data)`() = runBlocking { + fun `transact should refuse to sign when TronGrid's txID does not match sha256(raw_data)`(): Unit = runBlocking { val tamperedTxId = "0".repeat(64) // deliberately wrong - does not match sha256(rawDataHex) val unsigned = TronUnsignedTransactionResponse( visible = false, @@ -175,7 +175,7 @@ class RealTronTransactionServiceTest { } @Test - fun `calculateFee for Native intent should charge bandwidth shortfall at the fallback price when TronGrid's own resource_endpoints are unavailable`() = runBlocking { + fun `calculateFee for Native intent should charge bandwidth shortfall at the fallback price when TronGrid's own resource_endpoints are unavailable`(): Unit = runBlocking { val unsigned = TronUnsignedTransactionResponse( visible = false, txID = expectedTxId, From a8d2636f669e3d9db5d85ea739f6770386403e7c Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 09:36:41 -0700 Subject: [PATCH 36/56] fix: use raw org.mockito.Mockito statics instead of test_shared wrappers Root cause of 'eq(...) must not be null' NPE: test_shared's eq()/any()/ argThat() are thin Kotlin wrappers with a declared non-null generic return type T. Mockito.eq()/any() genuinely return null at runtime (that's how their matcher-stack recording works) - fine when called directly from Kotlin (a raw Java static call's return is a platform type, no null-check inserted), but going through a Kotlin-declared wrapper whose T infers as non-null at the call site (e.g. eq(baseUrl: String) here) gets a compiler-inserted null-check on the wrapper's return, which then fires. Scoped this fix to this test file only rather than touching test_shared's shared implementation, which every other module's tests also depend on. --- .../RealTronTransactionServiceTest.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 3bfbd1a7..8f5b9ab1 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 @@ -15,10 +15,6 @@ 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 @@ -31,11 +27,25 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.any +import org.mockito.Mockito.argThat +import org.mockito.Mockito.eq import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnitRunner import java.math.BigInteger +// Deliberately NOT using io.novafoundation.nova.test_shared's eq/any/argThat/whenever here: those are thin +// Kotlin wrappers around the raw org.mockito.Mockito statics, and a Kotlin function with a declared non-null +// generic return type T gets a compiler-inserted null-check on that return value whenever T is inferred as +// non-null at the call site (e.g. eq(baseUrl: String) here) - crashing with "eq(...) must not be null", since +// Mockito.eq()/any() genuinely return null at runtime (that's how their matcher-stack recording works). Calling +// org.mockito.Mockito's statics directly avoids this: Kotlin treats a direct Java static call's return as a +// platform type (T!) and does not insert that check. Fixing test_shared itself would apply project-wide and +// wasn't attempted here (shared-infra change out of scope for this test file). +private fun whenever(methodCall: T?) = Mockito.`when`(methodCall) + /** * 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, From cc5d00c4abf7bab3c9bd720803ab42c3a627abcf Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 10:23:56 -0700 Subject: [PATCH 37/56] fix: make local eq/any/argThat wrappers never return actual null Switching eq/any/argThat to the raw org.mockito.Mockito statics did not avoid the 'eq(...) must not be null' crash (it moved from the @Test bodies to the shared @Before setup(), since JUnit's @Before runs before every test and failed first there) - Mockito.eq()/any() genuinely return null regardless of which Kotlin entry point calls them, and that null still gets checked once it flows into a Kotlin non-null-typed parameter downstream. Local wrappers now guarantee a non-null return instead: eq() falls back to the real passed-in value (harmless - the matcher is already recorded on Mockito's thread-local stack by then), any()/argThat() return an unchecked-cast dummy, mirroring mockito-kotlin's own internal implementation of the same helpers. --- .../RealTronTransactionServiceTest.kt | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) 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 8f5b9ab1..194e7902 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 @@ -26,24 +26,36 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentMatcher import org.mockito.Mock import org.mockito.Mockito -import org.mockito.Mockito.any -import org.mockito.Mockito.argThat -import org.mockito.Mockito.eq import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnitRunner import java.math.BigInteger -// Deliberately NOT using io.novafoundation.nova.test_shared's eq/any/argThat/whenever here: those are thin -// Kotlin wrappers around the raw org.mockito.Mockito statics, and a Kotlin function with a declared non-null -// generic return type T gets a compiler-inserted null-check on that return value whenever T is inferred as -// non-null at the call site (e.g. eq(baseUrl: String) here) - crashing with "eq(...) must not be null", since -// Mockito.eq()/any() genuinely return null at runtime (that's how their matcher-stack recording works). Calling -// org.mockito.Mockito's statics directly avoids this: Kotlin treats a direct Java static call's return as a -// platform type (T!) and does not insert that check. Fixing test_shared itself would apply project-wide and -// wasn't attempted here (shared-infra change out of scope for this test file). +// eq()/any()/argThat() genuinely return null at runtime - that's how Mockito's matcher-stack recording works, +// regardless of whether they're called via test_shared's Kotlin wrappers or the raw org.mockito.Mockito statics +// directly (confirmed: switching to the raw statics did not avoid the "eq(...) must not be null" crash once that +// null flows into a Kotlin non-null-typed parameter somewhere downstream of the call site). Instead of returning +// the real (null) value, these local wrappers fall back to a definitely-non-null stand-in - the real value itself +// for eq() (harmless: the matcher was already recorded on Mockito's thread-local stack by the time this returns, +// so the fallback value is never actually used for matching) and an unchecked-cast dummy for any()/argThat() +// (mirrors mockito-kotlin's own internal implementation of the same helpers). +private fun eq(value: T): T = Mockito.eq(value) ?: value + +@Suppress("UNCHECKED_CAST") +private fun any(): T { + Mockito.any() + return null as T +} + +@Suppress("UNCHECKED_CAST") +private fun argThat(matcher: (T) -> Boolean): T { + Mockito.argThat(ArgumentMatcher { matcher(it) }) + return null as T +} + private fun whenever(methodCall: T?) = Mockito.`when`(methodCall) /** From 46f8abc04ca0f69d0dc9250595e3f1eff8f91ffd Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 12:45:50 -0700 Subject: [PATCH 38/56] fix: backfill Tron address for pre-existing wallets - TRX was invisible for every account created before Tron support shipped Found via manual device testing against a real, pre-Tron production wallet: TRX/USDT-TRC20 didn't appear anywhere in the Assets list, not even as a zero balance - unlike every other configured chain (KSM, USDC, ETH, BNB, AVAX, LINK all show up at 0). Root cause: MetaAccount.hasAccountIn() gates Tron balance sync on tronAddress != null, but tronAddress is only ever populated once, at fresh-mnemonic-creation time in AccountSecretsFactory.metaAccountSecrets(). The migration that added the tronPublicKey/tronAddress columns (73_74_AddTronSupport) is pure ALTER TABLE like every other migration in this codebase - it never derived a value for pre-existing rows. Net effect: BalancesUpdateSystem silently skips Tron entirely for every wallet that existed before this feature shipped, with zero error surfaced anywhere. No automated test catches this because every automated test creates a fresh (post-Tron) account - this is exactly the class of bug that only manual testing against a real, aged wallet can find. Adds TronAddressBackfillMigration: a one-time, flag-gated pass (mirroring the existing AccountDataMigration idiom) over SECRETS-type accounts that still hold their mnemonic entropy in SecretStoreV2 but have no TronKeypair yet. Derives the Tron keypair via AccountSecretsFactory.chainAccountSecrets (isEthereum=true, same BIP32/secp256k1 path Tron already uses everywhere else) at TRON_DEFAULT_DERIVATION_PATH - the same call fresh-account creation already makes - so a backfilled wallet ends up with byte-for-byte the same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation. Watch-only/Ledger/Json/multisig/ proxied accounts (never had a Tron-capable mnemonic) and raw-seed imports (no entropy) are correctly left untouched, same as they already are for Ethereum. Includes a dedicated unit test asserting the backfill derives the exact same reference Tron address (TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH) that TronDerivationTest already cross-validated against live TronGrid data for the standard BIP39 test mnemonic - this touches real wallets' key material, so it's pinned to a known-good vector rather than just asserting against its own output. --- .../model/chain/account/MetaAccountLocal.kt | 26 +++ .../datasource/AccountDataSourceImpl.kt | 18 +- .../migration/TronAddressBackfillMigration.kt | 105 +++++++++ .../di/AccountFeatureModule.kt | 16 +- .../TronAddressBackfillMigrationTest.kt | 199 ++++++++++++++++++ 5 files changed, 358 insertions(+), 6 deletions(-) create mode 100644 feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt create mode 100644 feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt index 4886b762..edc1c3e3 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt @@ -96,6 +96,32 @@ class MetaAccountLocal( } } + // We do not use copy as we need explicitly set id + fun addTronAccount( + tronPublicKey: ByteArray, + tronAddress: ByteArray, + ): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumPublicKey = ethereumPublicKey, + ethereumAddress = ethereumAddress, + name = name, + parentMetaId = parentMetaId, + isSelected = isSelected, + position = position, + type = type, + status = status, + globallyUniqueId = globallyUniqueId, + typeExtras = typeExtras, + tronPublicKey = tronPublicKey, + tronAddress = tronAddress + ).also { + it.id = id + } + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is MetaAccountLocal) return false diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt index b4021b68..580998af 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -30,6 +30,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTypeToLocal import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData import io.novafoundation.nova.runtime.ext.accountIdOf @@ -64,15 +65,22 @@ class AccountDataSourceImpl( private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, secretStoreV1: SecretStoreV1, accountDataMigration: AccountDataMigration, + tronAddressBackfillMigration: TronAddressBackfillMigration, ) : AccountDataSource, SecretStoreV1 by secretStoreV1 { init { - migrateIfNeeded(accountDataMigration) - } + // Run sequentially in one coroutine, not as two independent migrateIfNeeded() launches - the Tron + // backfill reads accounts/secrets that the legacy migration may still be in the middle of writing for + // very old (pre-MetaAccount) installs, and two separate GlobalScope.launch calls give no ordering + // guarantee relative to each other. + async { + if (accountDataMigration.migrationNeeded()) { + accountDataMigration.migrate(::saveSecuritySource) + } - private fun migrateIfNeeded(migration: AccountDataMigration) = async { - if (migration.migrationNeeded()) { - migration.migrate(::saveSecuritySource) + if (tronAddressBackfillMigration.migrationNeeded()) { + tronAddressBackfillMigration.migrate() + } } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt new file mode 100644 index 00000000..c0723cf6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration + +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair +import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.updateMetaAccount +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.feature_account_impl.data.secrets.TRON_DEFAULT_DERIVATION_PATH +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val PREFS_TRON_ADDRESS_BACKFILL_DONE = "tron_address_backfill_1_1_3" + +/** + * One-time backfill for accounts created before Tron support existed. `73_74_AddTronSupport` (the migration + * that added `meta_accounts.tronPublicKey`/`tronAddress`) is, like every other migration in this codebase, pure + * `ALTER TABLE` - it never derives a value for pre-existing rows. `tronAddress` is otherwise only ever set once, + * at fresh-mnemonic-creation time in [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory.metaAccountSecrets], + * so without this backfill `MetaAccount.hasAccountIn(tronChain)` (`tronAddress != null`) permanently returns + * false for every pre-existing seed-derived wallet, which makes `BalancesUpdateSystem` skip Tron entirely for + * that account - no address, no balance, no send, with no error surfaced anywhere. Found via manual testing + * against a real pre-Tron production wallet; no automated test catches this because every automated test + * creates a fresh (post-Tron) account. + * + * Only touches accounts that are: + * - `Type.SECRETS` (mnemonic-derived) - watch-only/Ledger/Json/multisig/proxied accounts never had a + * Tron-capable mnemonic and are correctly left with `tronAddress == null` forever, same as they already are + * for Ethereum. + * - still holding their `Entropy` in [SecretStoreV2] - an account imported from a raw seed/keypair rather than + * a mnemonic has no entropy either and is likewise correctly left alone. + * - missing a `TronKeypair` - i.e. not already backfilled and not created after Tron support shipped. + * + * The Tron keypair is derived via [AccountSecretsFactory.chainAccountSecrets] with `isEthereum = true` (Tron + * reuses the exact same secp256k1/BIP32 derivation as Ethereum, just under its own SLIP-44 coin-type-195 path - + * see that class's own doc comment) at [TRON_DEFAULT_DERIVATION_PATH], the same call this codebase's own + * `metaAccountSecrets()` makes for a fresh account - so a backfilled account ends up with byte-for-byte the + * same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation. + */ +class TronAddressBackfillMigration( + private val preferences: Preferences, + private val secretStoreV2: SecretStoreV2, + private val metaAccountDao: MetaAccountDao, + private val accountSecretsFactory: AccountSecretsFactory, +) { + + suspend fun migrationNeeded(): Boolean = withContext(Dispatchers.Default) { + !preferences.getBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, false) + } + + suspend fun migrate() = withContext(Dispatchers.Default) { + val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS } + + secretsAccounts.forEach { account -> + backfillIfNeeded(account) + } + + preferences.putBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, true) + } + + private suspend fun backfillIfNeeded(account: MetaAccountLocal) { + val secrets = secretStoreV2.getMetaAccountSecrets(account.id) ?: return + val entropy = secrets.entropy ?: return + if (secrets.tronKeypair != null) return + val substrateCryptoType = account.substrateCryptoType ?: return + + val mnemonic = MnemonicCreator.fromEntropy(entropy).words + + val tronChainSecrets = accountSecretsFactory.chainAccountSecrets( + derivationPath = TRON_DEFAULT_DERIVATION_PATH, + accountSource = AccountSecretsFactory.AccountSource.Mnemonic(substrateCryptoType, mnemonic), + isEthereum = true + ).secrets + + val tronKeypair = mapKeypairStructToKeypair(tronChainSecrets[ChainAccountSecrets.Keypair]) + + val updatedSecrets = MetaAccountSecrets( + substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair), + entropy = secrets.entropy, + substrateSeed = secrets.seed, + substrateDerivationPath = secrets.substrateDerivationPath, + ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair), + ethereumDerivationPath = secrets.ethereumDerivationPath, + tronKeypair = tronKeypair, + tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH + ) + + secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets) + + val tronAccountId = tronKeypair.publicKey.tronPublicKeyToAccountId() + metaAccountDao.updateMetaAccount(account.id) { it.addTronAccount(tronKeypair.publicKey, tronAccountId) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt index 42a1baec..3532aa3e 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt @@ -104,6 +104,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Ac import io.novafoundation.nova.feature_account_impl.data.repository.datasource.RealSecretsMetaAccountLocalFactory import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule @@ -402,6 +403,7 @@ class AccountFeatureModule { nodeDao: NodeDao, secretStoreV1: SecretStoreV1, accountDataMigration: AccountDataMigration, + tronAddressBackfillMigration: TronAddressBackfillMigration, metaAccountDao: MetaAccountDao, secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, secretStoreV2: SecretStoreV2, @@ -416,7 +418,8 @@ class AccountFeatureModule { secretStoreV2, secretsMetaAccountLocalFactory, secretStoreV1, - accountDataMigration + accountDataMigration, + tronAddressBackfillMigration ) } @@ -439,6 +442,17 @@ class AccountFeatureModule { return AccountDataMigration(preferences, encryptedPreferences, accountDao) } + @Provides + @FeatureScope + fun provideTronAddressBackfillMigration( + preferences: Preferences, + secretStoreV2: SecretStoreV2, + metaAccountDao: MetaAccountDao, + accountSecretsFactory: AccountSecretsFactory, + ): TronAddressBackfillMigration { + return TronAddressBackfillMigration(preferences, secretStoreV2, metaAccountDao, accountSecretsFactory) + } + @Provides @FeatureScope fun provideExternalAccountActions( diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt new file mode 100644 index 00000000..3cfc195d --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt @@ -0,0 +1,199 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration + +import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.tronAddressToAccountId +import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatcher +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +// Same guaranteed-non-null-return wrappers as RealTronTransactionServiceTest, and for the same reason: the +// shared test_shared eq()/any() helpers crash with "eq(...) must not be null" once Mockito's genuinely-null +// runtime return flows into a Kotlin non-null-typed parameter - see that test's class doc for the full writeup. +private fun eq(value: T): T = Mockito.eq(value) ?: value + +@Suppress("UNCHECKED_CAST") +private fun any(): T { + Mockito.any() + return null as T +} + +@Suppress("UNCHECKED_CAST") +private fun argThat(matcher: (T) -> Boolean): T { + Mockito.argThat(ArgumentMatcher { matcher(it) }) + return null as T +} + +private fun whenever(methodCall: T?) = Mockito.`when`(methodCall) + +/** + * This is the money-safety-critical half of the Tron backfill fix (see TronAddressBackfillMigration's class + * doc for the full context): a wrong derivation here would silently give a pre-existing wallet a Tron address + * it does NOT actually control, or fail to skip an account it shouldn't touch. Uses a real (non-mocked) + * AccountSecretsFactory so the actual BIP32/secp256k1 derivation math runs for real, and asserts against the + * same live-TronGrid-cross-validated reference address TronDerivationTest already established for the standard + * BIP39 test mnemonic, so this test and that one are pinned to the same known-good vector. + */ +@RunWith(MockitoJUnitRunner::class) +class TronAddressBackfillMigrationTest { + + @Mock + lateinit var preferences: Preferences + + @Mock + lateinit var secretStoreV2: SecretStoreV2 + + @Mock + lateinit var metaAccountDao: MetaAccountDao + + @Mock + lateinit var jsonDecoder: JsonDecoder + + private lateinit var subject: TronAddressBackfillMigration + + private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private val expectedTronAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId() + + @Before + fun setup() { + // Real factory, not a mock - the whole point of this test is to exercise the actual derivation. + val accountSecretsFactory = AccountSecretsFactory(jsonDecoder) + subject = TronAddressBackfillMigration(preferences, secretStoreV2, metaAccountDao, accountSecretsFactory) + + // any() would try to unbox a null placeholder into the primitive Boolean parameter and crash - Mockito's + // own anyBoolean() returns a real `false` default instead, avoiding that entirely. + whenever(preferences.getBoolean(any(), Mockito.anyBoolean())).thenReturn(false) + } + + @Test + fun `migrate should derive and persist the well-known reference Tron address for a pre-existing mnemonic account`(): Unit = runBlocking { + val account = secretsAccount(id = 42, substrateCryptoType = CryptoType.SR25519) + val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = null) + + whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account)) + whenever(metaAccountDao.getMetaAccount(eq(42L))).thenReturn(account) + whenever(secretStoreV2.getMetaAccountSecrets(eq(42L))).thenReturn(secrets) + + subject.migrate() + + verify(metaAccountDao).updateMetaAccount( + argThat { updated -> + updated.id == 42L && updated.tronAddress.contentEquals(expectedTronAccountId) + } + ) + + verify(secretStoreV2).putMetaAccountSecrets( + eq(42L), + argThat> { updatedSecrets -> + val tronKeypairStruct = updatedSecrets.tronKeypair + tronKeypairStruct != null && + mapKeypairStructToKeypair(tronKeypairStruct).publicKey.tronPublicKeyToAccountId().contentEquals(expectedTronAccountId) + } + ) + } + + @Test + fun `migrate should skip an account that already has a Tron keypair`(): Unit = runBlocking { + val account = secretsAccount(id = 7, substrateCryptoType = CryptoType.SR25519) + val existingTronKeypair = KeyPairSchema { keypair -> + keypair[KeyPairSchema.PublicKey] = ByteArray(33) { 9 } + keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 8 } + keypair[KeyPairSchema.Nonce] = null + } + val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = existingTronKeypair) + + whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account)) + whenever(secretStoreV2.getMetaAccountSecrets(eq(7L))).thenReturn(secrets) + + subject.migrate() + + verify(metaAccountDao, never()).updateMetaAccount(any()) + // metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong(). + verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any()) + } + + @Test + fun `migrate should skip an account with no entropy (raw-seed import, not a mnemonic)`(): Unit = runBlocking { + val account = secretsAccount(id = 11, substrateCryptoType = CryptoType.SR25519) + val secrets = accountSecrets(entropy = null, tronKeypair = null) + + whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account)) + whenever(secretStoreV2.getMetaAccountSecrets(eq(11L))).thenReturn(secrets) + + subject.migrate() + + verify(metaAccountDao, never()).updateMetaAccount(any()) + // metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong(). + verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any()) + } + + @Test + fun `migrate should skip an account with no stored secrets at all (watch-only, Ledger, etc)`(): Unit = runBlocking { + val account = secretsAccount(id = 13, substrateCryptoType = CryptoType.SR25519) + + whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account)) + whenever(secretStoreV2.getMetaAccountSecrets(eq(13L))).thenReturn(null) + + subject.migrate() + + verify(metaAccountDao, never()).updateMetaAccount(any()) + // metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong(). + verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any()) + } + + private fun secretsAccount(id: Long, substrateCryptoType: CryptoType): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = ByteArray(32) { 1 }, + substrateCryptoType = substrateCryptoType, + substrateAccountId = ByteArray(32) { 2 }, + ethereumPublicKey = null, + ethereumAddress = null, + name = "Test account", + parentMetaId = null, + isSelected = true, + position = 0, + type = MetaAccountLocal.Type.SECRETS, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = "test-guid-$id", + typeExtras = null + ).also { it.id = id } + } + + private fun accountSecrets(entropy: ByteArray?, tronKeypair: EncodableStruct?): EncodableStruct { + val dummySubstrateKeypair = KeyPairSchema { keypair -> + keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 5 } + keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 6 } + keypair[KeyPairSchema.Nonce] = ByteArray(8) { 7 } + } + + return MetaAccountSecrets( + substrateKeyPair = mapKeypairStructToKeypair(dummySubstrateKeypair), + entropy = entropy, + substrateSeed = null, + substrateDerivationPath = null, + ethereumKeypair = null, + ethereumDerivationPath = null, + tronKeypair = tronKeypair?.let(::mapKeypairStructToKeypair), + tronDerivationPath = null + ) + } +} From d719dbe35c3b5335ae1dd0a901cc7642e0475d03 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 13:47:34 -0700 Subject: [PATCH 39/56] fix: import the invoke operator needed for KeyPairSchema { ... } builder syntax in the test --- .../datasource/migration/TronAddressBackfillMigrationTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt index 3cfc195d..96ccef2f 100644 --- a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt @@ -6,6 +6,7 @@ import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair import io.novafoundation.nova.common.data.secrets.v2.tronKeypair import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.invoke import io.novafoundation.nova.common.utils.tronAddressToAccountId import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId import io.novafoundation.nova.core.model.CryptoType From 634a318fdfd5f6369233fb95820e863c4e1d1029 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 14:24:20 -0700 Subject: [PATCH 40/56] fix: unnecessary stubbing + missing assertTrue import in Tron backfill test @Before's preferences.getBoolean stub was never invoked by migrate()-only tests (only migrationNeeded() reads that preference), tripping Mockito's strict-stubs UnnecessaryStubbingException across the whole class. Moved it into a dedicated migrationNeeded() test, matched via any() rather than the production class's private preference-key constant (which isn't visible here), and added the missing assertTrue import. --- .../migration/TronAddressBackfillMigrationTest.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt index 96ccef2f..3c1f4c50 100644 --- a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt @@ -17,6 +17,7 @@ import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator import io.novasama.substrate_sdk_android.scale.EncodableStruct import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -79,10 +80,18 @@ class TronAddressBackfillMigrationTest { // Real factory, not a mock - the whole point of this test is to exercise the actual derivation. val accountSecretsFactory = AccountSecretsFactory(jsonDecoder) subject = TronAddressBackfillMigration(preferences, secretStoreV2, metaAccountDao, accountSecretsFactory) + } - // any() would try to unbox a null placeholder into the primitive Boolean parameter and crash - Mockito's - // own anyBoolean() returns a real `false` default instead, avoiding that entirely. + @Test + fun `migrationNeeded should reflect the persisted flag`(): Unit = runBlocking { + // The flag's preference key is a private implementation detail of the production class - matched via + // any() here rather than duplicating the literal key string, which would let this test pass even if + // that string silently drifted out of sync with the production code. whenever(preferences.getBoolean(any(), Mockito.anyBoolean())).thenReturn(false) + assertTrue(subject.migrationNeeded()) + + whenever(preferences.getBoolean(any(), Mockito.anyBoolean())).thenReturn(true) + assertTrue(!subject.migrationNeeded()) } @Test From 29395576a30493f3c148ce53058223fd19113088 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 15:56:34 -0700 Subject: [PATCH 41/56] debug: add logging to Tron address backfill migration TRX still missing on a real device after installing the backfill fix, with no way to tell whether the migration ran, skipped, or threw for that specific account - the migration had zero logging. Adds Log.d at every decision point (per-account skip reason, success) plus a try-catch around each account so one account's failure can't silently abort the whole migration or crash app startup for everyone else. --- .../datasource/AccountDataSourceImpl.kt | 6 +++ .../migration/TronAddressBackfillMigration.kt | 41 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt index 580998af..50e3df22 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -74,13 +74,19 @@ class AccountDataSourceImpl( // very old (pre-MetaAccount) installs, and two separate GlobalScope.launch calls give no ordering // guarantee relative to each other. async { + Log.d("AccountDataSourceImpl", "migrations block starting") + if (accountDataMigration.migrationNeeded()) { accountDataMigration.migrate(::saveSecuritySource) } + Log.d("AccountDataSourceImpl", "about to check tronAddressBackfillMigration") + if (tronAddressBackfillMigration.migrationNeeded()) { tronAddressBackfillMigration.migrate() } + + Log.d("AccountDataSourceImpl", "migrations block done") } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt index c0723cf6..e7747000 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt @@ -1,5 +1,6 @@ package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration +import android.util.Log import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 @@ -23,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext private const val PREFS_TRON_ADDRESS_BACKFILL_DONE = "tron_address_backfill_1_1_3" +private const val TAG = "TronAddressBackfill" /** * One-time backfill for accounts created before Tron support existed. `73_74_AddTronSupport` (the migration @@ -57,24 +59,49 @@ class TronAddressBackfillMigration( ) { suspend fun migrationNeeded(): Boolean = withContext(Dispatchers.Default) { - !preferences.getBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, false) + val needed = !preferences.getBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, false) + Log.d(TAG, "migrationNeeded = $needed") + needed } suspend fun migrate() = withContext(Dispatchers.Default) { val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS } + Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}") secretsAccounts.forEach { account -> - backfillIfNeeded(account) + try { + backfillIfNeeded(account) + } catch (e: Throwable) { + Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e) + } } preferences.putBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, true) + Log.d(TAG, "migrate() done, flag persisted") } private suspend fun backfillIfNeeded(account: MetaAccountLocal) { - val secrets = secretStoreV2.getMetaAccountSecrets(account.id) ?: return - val entropy = secrets.entropy ?: return - if (secrets.tronKeypair != null) return - val substrateCryptoType = account.substrateCryptoType ?: return + val secrets = secretStoreV2.getMetaAccountSecrets(account.id) + if (secrets == null) { + Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping") + return + } + val entropy = secrets.entropy + if (entropy == null) { + Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping") + return + } + if (secrets.tronKeypair != null) { + Log.d(TAG, "metaId=${account.id}: already has a TronKeypair - skipping") + return + } + val substrateCryptoType = account.substrateCryptoType + if (substrateCryptoType == null) { + Log.d(TAG, "metaId=${account.id}: substrateCryptoType is null - skipping") + return + } + + Log.d(TAG, "metaId=${account.id}: deriving Tron keypair") val mnemonic = MnemonicCreator.fromEntropy(entropy).words @@ -101,5 +128,7 @@ class TronAddressBackfillMigration( val tronAccountId = tronKeypair.publicKey.tronPublicKeyToAccountId() metaAccountDao.updateMetaAccount(account.id) { it.addTronAccount(tronKeypair.publicKey, tronAccountId) } + + Log.d(TAG, "metaId=${account.id}: backfilled successfully, tronAddress set (${tronAccountId.size} bytes)") } } From 5f5f74989a8352807472eef0bdbd32ae3300de4f Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 16:11:15 -0700 Subject: [PATCH 42/56] fix: enable returnDefaultValues for feature-account-impl unit tests Log.d/Log.e added to TronAddressBackfillMigration throw 'Method ... not mocked' in plain JVM unit tests without this - the framework's own stubs are meant to be configured this way for exactly this case, not worked around by avoiding Log calls in production code. --- feature-account-impl/build.gradle | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/feature-account-impl/build.gradle b/feature-account-impl/build.gradle index 1283cf5e..75de034a 100644 --- a/feature-account-impl/build.gradle +++ b/feature-account-impl/build.gradle @@ -13,6 +13,16 @@ android { buildFeatures { viewBinding true } + + testOptions { + unitTests { + // android.util.Log.* throws "Method ... not mocked" by default in plain JVM unit tests (no real + // Android framework, no Robolectric) - TronAddressBackfillMigrationTest is the first test in this + // module to exercise code that calls Log.d/Log.e. This makes those stubbed calls return harmless + // defaults instead of throwing, which is what the framework's own stubs are meant for in this context. + returnDefaultValues = true + } + } } dependencies { From 7f071f4821eb11b00db397ddbe5ef49ec2d531be Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 10 Jul 2026 17:25:31 -0700 Subject: [PATCH 43/56] debug: log Tron chain arrival + gate evaluation in BalancesUpdateSystem TRX is completely absent from the Assets list even for a brand-new wallet on a completely fresh install - ruled out per-account backfill (fresh wallet correctly derives tronAddress) and stale per-device chain state (fresh install, no carried-over connectionState). Need to see empirically whether the Tron chain even reaches currentChains at all, and if so, which of hasAccountIn/connectionState.isDisabled gates it. --- .../data/network/BalancesUpdateSystem.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 820ef106..b7c39765 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 @@ -39,12 +39,24 @@ class BalancesUpdateSystem( override fun start(): Flow { return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount -> chainRegistry.currentChains.transformLatestDiffed { chain -> + if (chain.isTronBased) { + Log.d(LOG_TAG, "TronDebug: currentChains delivered chain=${chain.id} name=${chain.name}") + } emitAll(balancesSync(chain, metaAccount)) } }.flowOn(Dispatchers.Default) } private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow { + if (chain.isTronBased) { + Log.d( + LOG_TAG, + "TronDebug: gate check chain=${chain.id} name=${chain.name} hasAccountIn=${metaAccount.hasAccountIn(chain)} " + + "connectionState=${chain.connectionState} isDisabled=${chain.connectionState.isDisabled} " + + "hasSubstrateRuntime=${chain.hasSubstrateRuntime} canPerformFullSync=${chain.canPerformFullSync()}" + ) + } + return when { !metaAccount.hasAccountIn(chain) -> emptyFlow() chain.connectionState.isDisabled -> emptyFlow() From c713ebf6b25e8dbb9b8d76a59a535c63d22b2649 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 02:10:00 -0700 Subject: [PATCH 44/56] debug: dump all meta accounts' tronAddress/tronPublicKey presence at startup hasAccountIn=false was observed live for the currently active account despite every derivation/mapping code path (creation, backfill, DB->domain mapping) tracing correctly - need to see the actual DB state per account to know whether this is a write-time or read-time gap. --- .../data/repository/datasource/AccountDataSourceImpl.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt index 50e3df22..d5fc3184 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -87,6 +87,15 @@ class AccountDataSourceImpl( } Log.d("AccountDataSourceImpl", "migrations block done") + + metaAccountDao.getMetaAccounts().forEach { + Log.d( + "TronDiag", + "metaId=${it.id} name=${it.name} type=${it.type} isSelected=${it.isSelected} " + + "tronAddress=${it.tronAddress?.joinToString("") { b -> "%02x".format(b) } ?: "NULL"} " + + "tronPublicKey=${if (it.tronPublicKey != null) "present(${it.tronPublicKey!!.size}B)" else "NULL"}" + ) + } } } From c78b325e6deb86f49cc8d1f6ceb9858d3af99e2a Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 04:09:34 -0700 Subject: [PATCH 45/56] fix: cloud backup schema dropped Tron address/keypair on every round trip WalletPublicInfo/WalletPrivateInfo had no tron field at all, so any wallet created via the (default) cloud-backup wallet creation flow, or restored from a cloud backup, silently lost its Tron address and keypair even though AccountSecretsFactory/SecretsMetaAccountLocalFactory derived them correctly moments earlier - this is why TRX/USDT-TRC20 never appeared for any wallet, new or old, confirmed via a device-side diagnostic dump of the actual DB row (tronAddress=NULL) after a fresh wallet creation. Also reserves (but does not wire up) solana/bitcoin fields in the same schema, since native derivation for those doesn't exist yet - adding them now means that work won't need another silent-drop-prone pass through this same schema later. Adds a regression test exercising the exact apply-diff path that lost the data, plus tron support in the shared cloud-backup test builder DSL. --- .../RealLocalAccountsCloudBackupFacade.kt | 25 ++++++- .../RealLocalAccountsCloudBackupFacadeTest.kt | 68 +++++++++++++++++++ .../domain/model/CloudBackup.kt | 58 +++++++++++++++- .../CloudBackupBuilder.kt | 40 ++++++++++- 4 files changed, 184 insertions(+), 7 deletions(-) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt index 3717e617..e3dab431 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt @@ -17,6 +17,8 @@ import io.novafoundation.nova.common.data.secrets.v2.publicKey import io.novafoundation.nova.common.data.secrets.v2.seed import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair +import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair import io.novafoundation.nova.common.utils.filterNotNull import io.novafoundation.nova.common.utils.findById import io.novafoundation.nova.common.utils.mapToSet @@ -90,6 +92,7 @@ class RealLocalAccountsCloudBackupFacade( substrate = baseSecrets.getSubstrateBackupSecrets(), ethereum = baseSecrets.getEthereumBackupSecrets(), chainAccounts = emptyList(), + tron = baseSecrets.getTronBackupSecrets(), ) return CloudBackup( @@ -357,6 +360,7 @@ class RealLocalAccountsCloudBackupFacade( substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo), ethereum = baseSecrets.getEthereumBackupSecrets(), chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets, + tron = baseSecrets.getTronBackupSecrets(), ) } @@ -466,7 +470,9 @@ class RealLocalAccountsCloudBackupFacade( substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null, substrateDerivationPath = substrate?.derivationPath, ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(), - ethereumDerivationPath = ethereum?.derivationPath + ethereumDerivationPath = ethereum?.derivationPath, + tronKeypair = tron?.keypair?.toLocalKeyPair(), + tronDerivationPath = tron?.derivationPath ) } @@ -479,6 +485,15 @@ class RealLocalAccountsCloudBackupFacade( ) } + private fun EncodableStruct?.getTronBackupSecrets(): CloudBackup.WalletPrivateInfo.TronSecrets? { + if (this == null) return null + + return CloudBackup.WalletPrivateInfo.TronSecrets( + keypair = tronKeypair?.toBackupKeypairSecrets() ?: return null, + derivationPath = tronDerivationPath + ) + } + private fun EncodableStruct?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? { if (this == null) return null @@ -520,7 +535,9 @@ class RealLocalAccountsCloudBackupFacade( ethereumPublicKey = metaAccount.ethereumPublicKey, name = metaAccount.name, type = metaAccount.type.toBackupWalletType() ?: return null, - chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) } + chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) }, + tronAddress = metaAccount.tronAddress, + tronPublicKey = metaAccount.tronPublicKey, ) } @@ -542,7 +559,9 @@ class RealLocalAccountsCloudBackupFacade( isSelected = isSelected, position = accountPosition, status = MetaAccountLocal.Status.ACTIVE, - typeExtras = null + typeExtras = null, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, ).also { if (localIdOverwrite != null) { it.id = localIdOverwrite diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt index 864d33f0..a426577a 100644 --- a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt @@ -7,6 +7,8 @@ import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair import io.novafoundation.nova.core.model.CryptoType import io.novafoundation.nova.core_db.dao.MetaAccountDao import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal @@ -536,6 +538,64 @@ class RealLocalAccountsCloudBackupFacadeTest { verifyEvent(expectedEvent) } + // Regression test for a wallet's Tron address/keypair being silently dropped on a cloud backup round trip: + // WalletPublicInfo/WalletPrivateInfo had no tron field at all until this fix, so a backup written from a + // wallet that genuinely had a Tron address, once applied back to local (e.g. after "clear all data" + + // restore, or on a fresh install created via the cloud-backup-first-wallet flow), landed with + // tronAddress/tronPublicKey/tronKeypair = null - found via real-device testing, not by this test. + @Test + fun shouldApplyAddAccountDiffWithTronSecrets() = runBlocking { + LocalAccountsMocker.setupMocks(metaAccountDao) {} + SecretStoreMocker.setupMocks(secretStore) {} + + allChainsAreEvm(false) + + val localBackup = buildTestCloudBackup { + publicData { } + privateData { } + } + + val bytes32 = bytes32of(0) + val tronAddressBytes = bytes20of(1) + val tronDerivationPath = "//44//195//0/0/0" + + val cloudBackup = buildTestCloudBackup { + publicData { + wallet(walletUUid(0)) { + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + tronPublicKey(bytes32) + tronAddress(tronAddressBytes) + } + } + + privateData { + wallet(walletUUid(0)) { + entropy(bytes32) + + substrate { + seed(bytes32) + keypair(KeyPairSecrets(bytes32, bytes32, bytes32)) + } + + tron { + derivationPath(tronDerivationPath) + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + } + } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + verify(metaAccountDao).insertMetaAccount(metaAccountWithTronAddress(tronAddressBytes)) + verify(secretStore).putMetaAccountSecrets(eq(0), metaAccountSecretsWithTronDerivationPath(tronDerivationPath)) + } + @Test fun shouldApplyRemoveAccountDiff(): Unit = runBlocking { allChainsAreEvm(false) @@ -1205,6 +1265,14 @@ class RealLocalAccountsCloudBackupFacadeTest { return argThat { it.globallyUniqueId == id } } + private fun metaAccountWithTronAddress(tronAddress: ByteArray): MetaAccountLocal { + return argThat { it.tronAddress.contentEquals(tronAddress) } + } + + private fun metaAccountSecretsWithTronDerivationPath(derivationPath: String): EncodableStruct { + return argThat { it.tronDerivationPath == derivationPath && it.tronKeypair != null } + } + private suspend fun verifyNoAdditionalSecretsInserted() { verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any()) } diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt index 579a6bfc..43024573 100644 --- a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt @@ -23,7 +23,19 @@ data class CloudBackup( val ethereumPublicKey: ByteArray?, val name: String, val type: Type, - val chainAccounts: Set + val chainAccounts: Set, + // All three below are nullable and defaulted so that Gson deserializing a backup written before that + // particular chain family existed - which has no such field in its JSON at all - lands on null here + // rather than failing. Solana/bitcoin have no derivation anywhere in this codebase yet (no native + // support, unlike Tron) - the fields are reserved now purely so that adding that support later never + // needs another silent-drop-prone trip through every call site that touches this schema, the way Tron + // did when it was bolted onto a schema that only knew about substrate/ethereum. + val tronAddress: ByteArray? = null, + val tronPublicKey: ByteArray? = null, + val solanaAddress: ByteArray? = null, + val solanaPublicKey: ByteArray? = null, + val bitcoinAddress: ByteArray? = null, + val bitcoinPublicKey: ByteArray? = null, ) : Identifiable { override val identifier: String = walletId @@ -72,7 +84,13 @@ data class CloudBackup( ethereumPublicKey.contentEquals(other.ethereumPublicKey) && name == other.name && type == other.type && - chainAccounts == other.chainAccounts + chainAccounts == other.chainAccounts && + tronAddress.contentEquals(other.tronAddress) && + tronPublicKey.contentEquals(other.tronPublicKey) && + solanaAddress.contentEquals(other.solanaAddress) && + solanaPublicKey.contentEquals(other.solanaPublicKey) && + bitcoinAddress.contentEquals(other.bitcoinAddress) && + bitcoinPublicKey.contentEquals(other.bitcoinPublicKey) } override fun hashCode(): Int { @@ -85,6 +103,12 @@ data class CloudBackup( result = 31 * result + name.hashCode() result = 31 * result + type.hashCode() result = 31 * result + chainAccounts.hashCode() + result = 31 * result + (tronAddress?.contentHashCode() ?: 0) + result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0) + result = 31 * result + (solanaAddress?.contentHashCode() ?: 0) + result = 31 * result + (solanaPublicKey?.contentHashCode() ?: 0) + result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0) + result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0) result = 31 * result + identifier.hashCode() return result } @@ -100,6 +124,11 @@ data class CloudBackup( val substrate: SubstrateSecrets?, val ethereum: EthereumSecrets?, val chainAccounts: List, + // See the matching comment on WalletPublicInfo: tron is fully wired end to end, solana/bitcoin are + // schema-only reservations until native derivation for them exists. + val tron: TronSecrets? = null, + val solana: SolanaSecrets? = null, + val bitcoin: BitcoinSecrets? = null, ) : Identifiable { override val identifier: String = walletId @@ -118,6 +147,9 @@ data class CloudBackup( if (substrate != other.substrate) return false if (ethereum != other.ethereum) return false if (chainAccounts != other.chainAccounts) return false + if (tron != other.tron) return false + if (solana != other.solana) return false + if (bitcoin != other.bitcoin) return false return identifier == other.identifier } @@ -127,6 +159,9 @@ data class CloudBackup( result = 31 * result + (substrate?.hashCode() ?: 0) result = 31 * result + (ethereum?.hashCode() ?: 0) result = 31 * result + chainAccounts.hashCode() + result = 31 * result + (tron?.hashCode() ?: 0) + result = 31 * result + (solana?.hashCode() ?: 0) + result = 31 * result + (bitcoin?.hashCode() ?: 0) result = 31 * result + identifier.hashCode() return result } @@ -201,6 +236,23 @@ data class CloudBackup( val derivationPath: String?, ) + data class TronSecrets( + val keypair: KeyPairSecrets, + val derivationPath: String?, + ) + + // Reserved shape for when native Solana derivation is added - unused until then, see the class-level comment. + data class SolanaSecrets( + val keypair: KeyPairSecrets, + val derivationPath: String?, + ) + + // Reserved shape for when native Bitcoin derivation is added - unused until then, see the class-level comment. + data class BitcoinSecrets( + val keypair: KeyPairSecrets, + val derivationPath: String?, + ) + data class KeyPairSecrets( val publicKey: ByteArray, val privateKey: ByteArray, @@ -234,5 +286,5 @@ data class CloudBackup( } fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean { - return entropy == null && substrate == null && ethereum == null && chainAccounts.isEmpty() + return entropy == null && substrate == null && ethereum == null && tron == null && chainAccounts.isEmpty() } diff --git a/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt b/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt index cd7e1901..e5b24c6f 100644 --- a/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt +++ b/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt @@ -7,6 +7,7 @@ import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup. import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.EthereumSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.KeyPairSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.SubstrateSecrets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.TronSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType @@ -99,6 +100,7 @@ class WalletPrivateInfoBuilder( private var substrate: SubstrateSecrets? = null private var ethereum: EthereumSecrets? = null + private var tron: TronSecrets? = null private val chainAccounts = mutableListOf() @@ -114,6 +116,10 @@ class WalletPrivateInfoBuilder( ethereum = BackupEthereumSecretsBuilder().apply(builder).build() } + fun tron(builder: BackupTronSecretsBuilder.() -> Unit) { + tron = BackupTronSecretsBuilder().apply(builder).build() + } + fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) { val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build() chainAccounts.add(element) @@ -126,6 +132,7 @@ class WalletPrivateInfoBuilder( substrate = substrate, ethereum = ethereum, chainAccounts = chainAccounts, + tron = tron, ) } } @@ -173,6 +180,25 @@ class BackupEthereumSecretsBuilder { } } +@CloudBackupBuildDsl +class BackupTronSecretsBuilder { + + private var _keypair: KeyPairSecrets? = null + private var _derivationPath: String? = null + + fun derivationPath(value: String?) { + _derivationPath = value + } + + fun keypair(keypair: KeyPairSecrets) { + _keypair = keypair + } + + fun build(): TronSecrets { + return TronSecrets(requireNotNull(_keypair), _derivationPath) + } +} + @CloudBackupBuildDsl class BackupChainAccountSecretsBuilder(private val accountId: AccountId) { @@ -223,6 +249,8 @@ class WalletPublicInfoBuilder( private var _substrateAccountId: ByteArray? = null private var _ethereumPublicKey: ByteArray? = null private var _ethereumAddress: ByteArray? = null + private var _tronPublicKey: ByteArray? = null + private var _tronAddress: ByteArray? = null private var _name: String = "" private var _isSelected: Boolean = false private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS @@ -252,6 +280,14 @@ class WalletPublicInfoBuilder( _ethereumAddress = value } + fun tronPublicKey(value: ByteArray?) { + _tronPublicKey = value + } + + fun tronAddress(value: ByteArray?) { + _tronAddress = value + } + fun name(value: String) { _name = value } @@ -274,7 +310,9 @@ class WalletPublicInfoBuilder( ethereumPublicKey = _ethereumPublicKey, name = _name, type = _type, - chainAccounts = chainAccounts.toSet() + chainAccounts = chainAccounts.toSet(), + tronAddress = _tronAddress, + tronPublicKey = _tronPublicKey, ) } } From e1679367d71f36eb4075a40c6b923159ccdf4071 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 05:29:11 -0700 Subject: [PATCH 46/56] fix: make Tron address backfill self-healing instead of one-shot TronAddressBackfillMigration was gated behind a global SharedPreferences flag ("has this migration ever run on this install") rather than checking per-account whether a Tron keypair is actually missing. Once that flag was set - even by a run that found nothing to fix, or partially failed - the migration never ran again on that install, so an account that lost its Tron keypair some other way (the cloud-backup schema gap fixed in c78b325) could never be repaired without a fresh reinstall. backfillIfNeeded() already does a cheap, correct per-account check (skips instantly if the account already has a keypair, isn't SECRETS-type, has no entropy, etc.), so there's no need for an outer one-shot gate at all - just run it unconditionally on every app start. --- .../datasource/AccountDataSourceImpl.kt | 6 ++-- .../migration/TronAddressBackfillMigration.kt | 34 ++++++++----------- .../di/AccountFeatureModule.kt | 3 +- .../TronAddressBackfillMigrationTest.kt | 19 +---------- 4 files changed, 18 insertions(+), 44 deletions(-) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt index d5fc3184..4253256d 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -80,11 +80,9 @@ class AccountDataSourceImpl( accountDataMigration.migrate(::saveSecuritySource) } - Log.d("AccountDataSourceImpl", "about to check tronAddressBackfillMigration") + Log.d("AccountDataSourceImpl", "about to run tronAddressBackfillMigration") - if (tronAddressBackfillMigration.migrationNeeded()) { - tronAddressBackfillMigration.migrate() - } + tronAddressBackfillMigration.migrate() Log.d("AccountDataSourceImpl", "migrations block done") diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt index e7747000..58b3f07a 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt @@ -12,7 +12,6 @@ import io.novafoundation.nova.common.data.secrets.v2.seed import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair import io.novafoundation.nova.common.data.secrets.v2.tronKeypair -import io.novafoundation.nova.common.data.storage.Preferences import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId import io.novafoundation.nova.core_db.dao.MetaAccountDao import io.novafoundation.nova.core_db.dao.updateMetaAccount @@ -23,19 +22,22 @@ import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -private const val PREFS_TRON_ADDRESS_BACKFILL_DONE = "tron_address_backfill_1_1_3" private const val TAG = "TronAddressBackfill" /** - * One-time backfill for accounts created before Tron support existed. `73_74_AddTronSupport` (the migration - * that added `meta_accounts.tronPublicKey`/`tronAddress`) is, like every other migration in this codebase, pure - * `ALTER TABLE` - it never derives a value for pre-existing rows. `tronAddress` is otherwise only ever set once, - * at fresh-mnemonic-creation time in [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory.metaAccountSecrets], - * so without this backfill `MetaAccount.hasAccountIn(tronChain)` (`tronAddress != null`) permanently returns - * false for every pre-existing seed-derived wallet, which makes `BalancesUpdateSystem` skip Tron entirely for - * that account - no address, no balance, no send, with no error surfaced anywhere. Found via manual testing - * against a real pre-Tron production wallet; no automated test catches this because every automated test - * creates a fresh (post-Tron) account. + * Idempotent, per-account backfill for accounts that don't yet have a Tron keypair - both accounts created + * before Tron support existed, AND accounts whose Tron keypair was lost some other way (e.g. the cloud-backup + * schema round trip that used to silently drop it before every wallet had a `tron` field to serialize into - + * see CloudBackup.kt). `73_74_AddTronSupport` (the migration that added `meta_accounts.tronPublicKey`/ + * `tronAddress`) is, like every other migration in this codebase, pure `ALTER TABLE` - it never derives a value + * for pre-existing rows. + * + * Deliberately has NO "have I already run once" flag: an earlier version of this class gated itself behind a + * one-shot SharedPreferences flag, which meant that once it ran and marked itself done - even for an account + * that legitimately still lacked a Tron keypair afterwards (e.g. because a *different*, since-fixed bug kept + * re-losing it) - it would never run again for that account, ever, on that install. [backfillIfNeeded] is cheap + * to call for an account that doesn't need it (a handful of null-checks, no derivation), so this just runs + * unconditionally on every app start instead: self-healing by construction, no stuck-flag failure mode possible. * * Only touches accounts that are: * - `Type.SECRETS` (mnemonic-derived) - watch-only/Ledger/Json/multisig/proxied accounts never had a @@ -52,18 +54,11 @@ private const val TAG = "TronAddressBackfill" * same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation. */ class TronAddressBackfillMigration( - private val preferences: Preferences, private val secretStoreV2: SecretStoreV2, private val metaAccountDao: MetaAccountDao, private val accountSecretsFactory: AccountSecretsFactory, ) { - suspend fun migrationNeeded(): Boolean = withContext(Dispatchers.Default) { - val needed = !preferences.getBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, false) - Log.d(TAG, "migrationNeeded = $needed") - needed - } - suspend fun migrate() = withContext(Dispatchers.Default) { val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS } Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}") @@ -76,8 +71,7 @@ class TronAddressBackfillMigration( } } - preferences.putBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, true) - Log.d(TAG, "migrate() done, flag persisted") + Log.d(TAG, "migrate() done") } private suspend fun backfillIfNeeded(account: MetaAccountLocal) { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt index 3532aa3e..d6e8edcf 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt @@ -445,12 +445,11 @@ class AccountFeatureModule { @Provides @FeatureScope fun provideTronAddressBackfillMigration( - preferences: Preferences, secretStoreV2: SecretStoreV2, metaAccountDao: MetaAccountDao, accountSecretsFactory: AccountSecretsFactory, ): TronAddressBackfillMigration { - return TronAddressBackfillMigration(preferences, secretStoreV2, metaAccountDao, accountSecretsFactory) + return TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory) } @Provides diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt index 3c1f4c50..6e075706 100644 --- a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt @@ -5,7 +5,6 @@ import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair import io.novafoundation.nova.common.data.secrets.v2.tronKeypair -import io.novafoundation.nova.common.data.storage.Preferences import io.novafoundation.nova.common.utils.invoke import io.novafoundation.nova.common.utils.tronAddressToAccountId import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId @@ -17,7 +16,6 @@ import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator import io.novasama.substrate_sdk_android.scale.EncodableStruct import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -58,9 +56,6 @@ private fun whenever(methodCall: T?) = Mockito.`when`(methodCall) @RunWith(MockitoJUnitRunner::class) class TronAddressBackfillMigrationTest { - @Mock - lateinit var preferences: Preferences - @Mock lateinit var secretStoreV2: SecretStoreV2 @@ -79,19 +74,7 @@ class TronAddressBackfillMigrationTest { fun setup() { // Real factory, not a mock - the whole point of this test is to exercise the actual derivation. val accountSecretsFactory = AccountSecretsFactory(jsonDecoder) - subject = TronAddressBackfillMigration(preferences, secretStoreV2, metaAccountDao, accountSecretsFactory) - } - - @Test - fun `migrationNeeded should reflect the persisted flag`(): Unit = runBlocking { - // The flag's preference key is a private implementation detail of the production class - matched via - // any() here rather than duplicating the literal key string, which would let this test pass even if - // that string silently drifted out of sync with the production code. - whenever(preferences.getBoolean(any(), Mockito.anyBoolean())).thenReturn(false) - assertTrue(subject.migrationNeeded()) - - whenever(preferences.getBoolean(any(), Mockito.anyBoolean())).thenReturn(true) - assertTrue(!subject.migrationNeeded()) + subject = TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory) } @Test From e055e1a84c551867bd2efa061ad22eee1fcca6ae Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 06:44:35 -0700 Subject: [PATCH 47/56] feat: order and label per-chain token breakdown by ecosystem + standard The per-chain list shown when picking an action (Send/Receive/Swap/Buy/Sell/ Gift) for a token that exists on multiple chains (e.g. USDT) now orders Ethereum and Tron right after the existing Pezkuwi/Polkadot/Kusama priority chains instead of falling into the alphabetical "everything else" bucket, and appends a token-standard label - "(PEZ-20)"/"(ERC-20)"/"(TRC-20)" - to disambiguate which issuance this is, since a bare chain name alone doesn't convey that. The label is intentionally chain-specific, not derived from Chain.Asset.Type, since every Statemine-type chain (Polkadot AH, Kusama AH, Pezkuwi AH) shares the same asset type but only Pezkuwi AH's issuance needs the PEZ-20 label. --- .../flow/network/NetworkFlowViewModel.kt | 10 ++++++++- .../nova/runtime/ext/ChainExt.kt | 16 ++++++++++++++ .../nova/runtime/ext/ChainSorting.kt | 22 +++++++++++-------- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt index bc7da05a..eeac28fc 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt @@ -16,8 +16,10 @@ import io.novafoundation.nova.feature_assets.presentation.flow.network.model.Net import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.runtime.ext.assetStandardLabelOrNull import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map @@ -73,7 +75,7 @@ abstract class NetworkFlowViewModel( NetworkFlowRvItem( it.chain.id, it.asset.token.configuration.id, - it.chain.name, + it.chain.displayNameWithAssetStandard(), it.chain.icon, amountFormatter.formatAmountToAmountModel( amount = getAssetBalance(it).amount, @@ -83,4 +85,10 @@ abstract class NetworkFlowViewModel( ) } } + + private fun Chain.displayNameWithAssetStandard(): String { + val standardLabel = assetStandardLabelOrNull ?: return name + + return "$name ($standardLabel)" + } } 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 51e1a19f..ecc3f540 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 @@ -488,6 +488,7 @@ object ChainGeneses { object ChainIds { const val ETHEREUM = "$EIP_155_PREFIX:1" + const val TRON = "tron:0x2b6653dc" const val MOONBEAM = ChainGeneses.MOONBEAM const val MOONRIVER = ChainGeneses.MOONRIVER @@ -499,6 +500,21 @@ val Chain.Companion.Geneses val Chain.Companion.Ids get() = ChainIds +/** + * A short, user-facing token-standard label for chains where disambiguating "which token standard is this" + * is actually useful (multiple ecosystems all issue their own USDT/USDC etc., so a bare chain name isn't + * always enough context). Deliberately NOT derived from [Chain.Asset.Type] (e.g. every Statemine-type chain + * would get the same label) - this is chain-specific by design, matching exactly which labels are + * recognizable/expected by users (PEZ-20, ERC-20, TRC-20), not a mechanical one-label-per-asset-type mapping. + */ +val Chain.assetStandardLabelOrNull: String? + get() = when { + genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> "PEZ-20" + id == Chain.Ids.ETHEREUM -> "ERC-20" + id == Chain.Ids.TRON -> "TRC-20" + else -> null + } + fun Chain.Asset.requireStatemine(): Type.Statemine { require(type is Type.Statemine) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt index 1b8fcd49..9c41b816 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt @@ -3,19 +3,23 @@ package io.novafoundation.nova.runtime.ext import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain val Chain.mainChainsFirstAscendingOrder - get() = when (genesisHash) { + get() = when { // Pezkuwi ecosystem first - Chain.Geneses.PEZKUWI -> 0 - Chain.Geneses.PEZKUWI_ASSET_HUB -> 1 - Chain.Geneses.PEZKUWI_PEOPLE -> 2 + genesisHash == Chain.Geneses.PEZKUWI -> 0 + genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> 1 + genesisHash == Chain.Geneses.PEZKUWI_PEOPLE -> 2 // Then Polkadot ecosystem - Chain.Geneses.POLKADOT -> 3 - Chain.Geneses.POLKADOT_ASSET_HUB -> 4 + genesisHash == Chain.Geneses.POLKADOT -> 3 + genesisHash == Chain.Geneses.POLKADOT_ASSET_HUB -> 4 // Then Kusama ecosystem - Chain.Geneses.KUSAMA -> 5 - Chain.Geneses.KUSAMA_ASSET_HUB -> 6 + genesisHash == Chain.Geneses.KUSAMA -> 5 + genesisHash == Chain.Geneses.KUSAMA_ASSET_HUB -> 6 + // Then Ethereum, then Tron - not identified by genesisHash (that's substrate-only), so this can't + // stay a `when (genesisHash)` subject match once these two are added + id == Chain.Ids.ETHEREUM -> 7 + id == Chain.Ids.TRON -> 8 // Everything else - else -> 7 + else -> 9 } val Chain.testnetsLastAscendingOrder From 141d2b1f42cd4eaa846c834fe507c49bd202a227 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 06:50:27 -0700 Subject: [PATCH 48/56] fix: Networks screen never showed live health/feedback for Tron's toggle nodesHealthState() only ever looked at wssNodes(), so an HTTPS-only chain (Tron - no wss endpoint at all) always got an empty node list here: the enable/disable switch itself worked correctly (it reads/writes chain.isEnabled directly, unrelated to this list), but with nothing ever rendering underneath it, the screen looked frozen/unresponsive - this is almost certainly why a single real toggle needed several taps to land correctly, since there was no visible confirmation whichever tap actually took effect. Falls back to httpNodes() when a chain has no wss nodes, and adds a real TronNodeHealthStateTester (GET /wallet/getchainparameters, needs no address) instead of naively reusing EthereumNodeHealthStateTester's eth_getBalance call, which TronGrid doesn't speak and would have always reported the node as down regardless of its actual health. --- .../NetworkManagementChainInteractor.kt | 9 +++- .../nova/runtime/di/ChainRegistryModule.kt | 10 ++++- .../NodeHealthStateTesterFactory.kt | 17 +++++--- .../healthState/TronNodeHealthStateTester.kt | 41 +++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/TronNodeHealthStateTester.kt diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt index 78260131..2c60d1e1 100644 --- a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt @@ -9,6 +9,7 @@ import io.novafoundation.nova.runtime.ext.isCustomNetwork import io.novafoundation.nova.runtime.ext.isDisabled import io.novafoundation.nova.runtime.ext.isEnabled import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull +import io.novafoundation.nova.runtime.ext.httpNodes import io.novafoundation.nova.runtime.ext.wssNodes import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain @@ -132,7 +133,13 @@ class RealNetworkManagementChainInteractor( } private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow> { - return chain.nodes.wssNodes().map { + // wssNodes() alone leaves an HTTPS-only chain (Tron today - no wss endpoint at all) with an empty list + // here, which silently renders as "nothing to show" rather than a real health state - fall back to the + // http nodes only when there are no wss ones, since a chain that genuinely has wss nodes should still + // prefer testing those. + val nodesToCheck = chain.nodes.wssNodes().ifEmpty { chain.nodes.httpNodes() } + + return nodesToCheck.map { nodeHealthState(chain, it, coroutineScope) }.combine() } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt index 97e74a26..5b347e11 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt @@ -37,7 +37,9 @@ import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchro import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher import io.novasama.substrate_sdk_android.wsrpc.SocketService import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit import org.web3j.protocol.http.HttpService import javax.inject.Provider @@ -165,7 +167,13 @@ class ChainRegistryModule { socketProvider, connectionSecrets, bulkRetriever, - web3ApiFactory + web3ApiFactory, + // A short-lived, minimally-configured client is enough for a health-check ping - unlike Web3ApiFactory's + // client, this never needs to survive/reuse connections across a long-lived RPC session. + OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() ) @Provides diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt index b9986a1b..7197def1 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt @@ -5,6 +5,7 @@ import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets import io.novasama.substrate_sdk_android.wsrpc.SocketService +import okhttp3.OkHttpClient import javax.inject.Provider import kotlinx.coroutines.CoroutineScope @@ -12,15 +13,21 @@ class NodeHealthStateTesterFactory( private val socketServiceProvider: Provider, private val connectionSecrets: ConnectionSecrets, private val bulkRetriever: BulkRetriever, - private val web3ApiFactory: Web3ApiFactory + private val web3ApiFactory: Web3ApiFactory, + private val httpClient: OkHttpClient, ) { fun create(chain: Chain, node: Chain.Node, coroutineScope: CoroutineScope): NodeHealthStateTester { val nodeIsSupported = chain.nodes.nodes.any { it.unformattedUrl == node.unformattedUrl } require(nodeIsSupported) - return if (chain.hasSubstrateRuntime) { - SubstrateNodeHealthStateTester( + return when { + chain.isTronBased -> TronNodeHealthStateTester( + node = node, + httpClient = httpClient + ) + + chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester( chain = chain, socketService = socketServiceProvider.get(), connectionSecrets = connectionSecrets, @@ -28,8 +35,8 @@ class NodeHealthStateTesterFactory( node = node, coroutineScope = coroutineScope ) - } else { - EthereumNodeHealthStateTester( + + else -> EthereumNodeHealthStateTester( socketService = socketServiceProvider.get(), connectionSecrets = connectionSecrets, node = node, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/TronNodeHealthStateTester.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/TronNodeHealthStateTester.kt new file mode 100644 index 00000000..c19576ac --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/TronNodeHealthStateTester.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +/** + * TronGrid speaks a plain REST API, not Ethereum JSON-RPC - reusing [EthereumNodeHealthStateTester] against it + * (as this codebase used to, before Tron nodes were included in health checks at all) would send an + * `eth_getBalance` call TronGrid doesn't understand, always reporting the node as unreachable regardless of its + * actual health. `GET /wallet/getchainparameters` needs no account/address context and is cheap on TronGrid's + * side, making it a good generic liveness ping - same endpoint this codebase already uses elsewhere + * (`TronGridApi.getChainParameters`), just called directly here since `runtime` cannot depend on + * `feature-wallet-impl` (wrong direction) to reuse that Retrofit interface. + */ +class TronNodeHealthStateTester( + private val node: Chain.Node, + private val httpClient: OkHttpClient, +) : NodeHealthStateTester { + + @OptIn(ExperimentalTime::class) + override suspend fun testNodeHealthState(): Result = withContext(Dispatchers.IO) { + runCatching { + val request = Request.Builder() + .url("${node.unformattedUrl.trimEnd('/')}/wallet/getchainparameters") + .build() + + val duration = measureTime { + httpClient.newCall(request).execute().use { response -> + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + duration.inWholeMilliseconds + } + } +} From 07c9848118dee6d9373667fb911963b90ce99350 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 08:55:56 -0700 Subject: [PATCH 49/56] fix: isolate per-chain/per-asset failures in ChainSyncService.syncUp() A single malformed or not-yet-understood remote chain/asset entry used to abort the whole sync via a plain .map{} - chainDao.applyDiff() never even gets called, so a brand new install (empty local DB) ends up with zero cached chains forever, i.e. a completely empty tokens list, until the remote data or the app's parsing code changes. This is exactly what happened in production: master's config received a batch of upstream changes the still-live app version couldn't parse, and every fresh install got stuck with a blank list while existing installs (which already had a populated local DB from a prior successful sync) were unaffected. Mirrors the same mapListNotNull + runCatching pattern already used on the read side (ChainRegistry.currentChains) - one bad chain, or one bad asset within an otherwise-fine chain, is now logged and skipped instead of taking the rest of the sync down with it. --- .../multiNetwork/chain/ChainSyncService.kt | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) 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 497c6efe..5e571e0b 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 @@ -57,17 +57,33 @@ class ChainSyncService( return@withContext } - val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) } - val newAssets = remoteChains.flatMap { chain -> - chain.assets.map { - val fullAssetId = FullAssetIdLocal(chain.chainId, it.assetId) - val oldAsset = associatedOldAssets[fullAssetId] - mapRemoteAssetToLocal(chain, it, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL) + // One malformed/incompatible chain (a new field the app's mapper doesn't understand yet, a bad + // publish, etc.) must not take down sync for every other chain - a plain .map{} here means a single + // throwing chain aborts before chainDao.applyDiff() is ever called, leaving a brand new install with + // zero locally-cached chains forever (a completely empty tokens list), since nothing else in this + // function ever gets a chance to run. Isolate failures per chain, and per asset within a chain that + // otherwise mapped fine, instead. + val remoteChainsWithLocal = remoteChains.mapNotNull { chainRemote -> + runCatching { chainRemote to mapRemoteChainToLocal(chainRemote, oldChainsById[chainRemote.chainId], source = ChainLocal.Source.DEFAULT, gson) } + .onFailure { Log.e(LOG_TAG, "Failed to map remote chain ${chainRemote.chainId} (${chainRemote.name}), skipping it for this sync cycle", it) } + .getOrNull() + } + + val newChains = remoteChainsWithLocal.map { (_, chainLocal) -> chainLocal } + val newAssets = remoteChainsWithLocal.flatMap { (chain, _) -> + chain.assets.mapNotNull { assetRemote -> + runCatching { + val fullAssetId = FullAssetIdLocal(chain.chainId, assetRemote.assetId) + val oldAsset = associatedOldAssets[fullAssetId] + mapRemoteAssetToLocal(chain, assetRemote, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL) + }.onFailure { + Log.e(LOG_TAG, "Failed to map asset ${assetRemote.assetId} (${assetRemote.symbol}) on chain ${chain.chainId}, skipping it", it) + }.getOrNull() } } - val newNodes = remoteChains.flatMap(::mapRemoteNodesToLocal) - val newExplorers = remoteChains.flatMap(::mapRemoteExplorersToLocal) - val newExternalApis = remoteChains.flatMap(::mapExternalApisToLocal) + val newNodes = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteNodesToLocal(chain) } + val newExplorers = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteExplorersToLocal(chain) } + val newExternalApis = remoteChainsWithLocal.flatMap { (chain, _) -> mapExternalApisToLocal(chain) } val newNodeSelectionPreferences = nodeSelectionPreferencesFor(newChains, oldNodeSelectionPreferences) val chainsDiff = CollectionDiffer.findDiff(newChains, oldChains, forceUseNewItems = false) From ff7624c1ac114aee103d05f97894071088219807 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 09:09:17 -0700 Subject: [PATCH 50/56] temp: point CHAINS_URL at pending/post-fix-release, not master wallet-util's master/main got reset to the last content the still-live Play Store release can parse (see wallet-util repo history around 2026-07-11 - an unrelated production incident, not a regression on this branch). master no longer serves Tron config/icons, so this branch's test builds would silently regress to "no Tron" - not because of anything wrong here, but because the shared config source moved out from under it. pending/post-fix-release preserves exactly what master had before the reset. MUST be reverted to "master" before this branch is merged - this override should never ship. --- runtime/build.gradle | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/runtime/build.gradle b/runtime/build.gradle index 7d2d4e0e..c9824e66 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -5,15 +5,19 @@ android { defaultConfig { - + // TEMPORARY - points at pending/post-fix-release, NOT master. wallet-util's master/main were reset to + // the last content the still-live Play Store release can parse (an unrelated incident, see wallet-util + // repo history around 2026-07-11), so master no longer has Tron config/icons this branch needs to test + // against. pending/post-fix-release is where that work (and master's pre-reset state) is preserved. + // MUST be pointed back at "master" before this branch merges - do not ship this override. - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" - buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" - buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" - buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" + buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/android/chains.json\"" + buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/assets/evm/v3/assets.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/chains.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/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", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/chains_for_testBalance.json\"" + buildConfigField "String", "TEST_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/pezkuwi_assets_for_testBalance.json\"" buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY") buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY") @@ -28,10 +32,10 @@ android { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" - buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" - buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" - buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" + buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/android/chains.json\"" + buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/assets/evm/v3/assets.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/chains.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/details\"" } } namespace 'io.novafoundation.nova.runtime' From cf02896a587411759d4fb7c244d884ed16cc1b36 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 14:07:59 -0700 Subject: [PATCH 51/56] fix: apply the chain/asset-standard label to the main balance list too The Send/Receive/etc. network picker (NetworkFlowViewModel) already showed "Ethereum (ERC-20)"/"Tron (TRC-20)" for a multi-chain token's per-chain rows, but the main Assets dashboard's own expandable per-token breakdown (tap a token like USDT to see every chain it exists on) is a completely separate code path (TokenAssetMappers/TokenAssetViewHolder) that still showed a bare chain name - found via a real device screenshot of that specific screen. Moved the shared display-name-with-label logic to a public extension (Chain.displayNameWithAssetStandard(), runtime/ext/ChainExt.kt) so both screens build the exact same string instead of duplicating (and now diverging) the same logic twice. --- .../balance/common/mappers/TokenAssetMappers.kt | 12 ++++++++++-- .../flow/network/NetworkFlowViewModel.kt | 9 +-------- .../io/novafoundation/nova/runtime/ext/ChainExt.kt | 12 ++++++++++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt index a299abf8..7ecf6276 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt @@ -6,7 +6,8 @@ import io.novafoundation.nova.common.presentation.AssetIconProvider import io.novafoundation.nova.common.presentation.getAssetIconOrFallback import io.novafoundation.nova.common.utils.formatting.formatAsChange import io.novafoundation.nova.common.utils.orZero -import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard import io.novafoundation.nova.feature_assets.R import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork @@ -90,7 +91,14 @@ class TokenAssetFormatter( group.getId(), mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)), assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration), - mapChainToUi(it.chain) + // Not mapChainToUi() here - this row's subtitle needs to disambiguate which issuance of the + // token this is (e.g. "Ethereum (ERC-20)" vs "Tron (TRC-20)"), which a bare chain name alone + // doesn't when multiple ecosystems share the same symbol (USDT, USDC, etc.). + ChainUi( + id = it.chain.id, + name = it.chain.displayNameWithAssetStandard(), + icon = it.chain.icon + ) ) } } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt index eeac28fc..35dc6de4 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt @@ -16,10 +16,9 @@ import io.novafoundation.nova.feature_assets.presentation.flow.network.model.Net import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig -import io.novafoundation.nova.runtime.ext.assetStandardLabelOrNull +import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.asset -import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map @@ -85,10 +84,4 @@ abstract class NetworkFlowViewModel( ) } } - - private fun Chain.displayNameWithAssetStandard(): String { - val standardLabel = assetStandardLabelOrNull ?: return name - - return "$name ($standardLabel)" - } } 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 ecc3f540..debf3e53 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 @@ -515,6 +515,18 @@ val Chain.assetStandardLabelOrNull: String? else -> null } +/** + * Chain display name with its token-standard label appended where [assetStandardLabelOrNull] applies, e.g. + * "Ethereum (ERC-20)". Shared across every screen that lists the same token symbol once per chain (the + * Send/Receive/etc. network picker, the main balance list's per-token chain breakdown) - a bare chain name + * alone doesn't convey which issuance this is when multiple ecosystems share the same symbol. + */ +fun Chain.displayNameWithAssetStandard(): String { + val standardLabel = assetStandardLabelOrNull ?: return name + + return "$name ($standardLabel)" +} + fun Chain.Asset.requireStatemine(): Type.Statemine { require(type is Type.Statemine) From 0eab8a8ea2150f9e01a599e5c008e8f986031c3f Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 15:21:24 -0700 Subject: [PATCH 52/56] fix: TRC-20 balance always read 0 for never-activated holders Trc20AssetBalance/RealTronGridApi.fetchTrc20Balance() read through TronGrid's /v1/accounts/{address} REST endpoint - but a TRC-20 balance lives entirely in the token contract's own storage, not the holder's Account object. An address that has only ever received TRC-20 tokens (never native TRX, never otherwise "activated" on-chain) has no Account object at all, so /v1/accounts silently returns `data: []` for it regardless of its real token balance, and the old code treated that the same as a genuinely empty/zero account. Found via a live test: a real wallet received 5 USDT-TRC20, the exchange confirmed the transfer complete on-chain, but the app kept showing 0. Independently verified live: /v1/accounts returned empty for the address, while a direct balanceOf(address) contract call (triggerconstantcontract) correctly returned 5000000. Rewrote fetchTrc20Balance() to read via balanceOf(address) instead - reuses the existing triggerConstantContract() call already used for TRC-20 transfer fee estimation, plus a new encodeBalanceOfParameters() ABI helper alongside the existing transfer() one. Added a regression test pinned to this exact real address/balance so this can't silently regress again. --- .../balances/TronBalancesIntegrationTest.kt | 23 ++++++++++++++- .../balances/trc20/Trc20AssetBalance.kt | 13 ++++----- .../data/network/tron/TronGridApi.kt | 29 +++++++++++++++---- .../tron/transaction/Trc20TransferAbi.kt | 22 ++++++++++---- 4 files changed, 66 insertions(+), 21 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 a9baeac1..395a3065 100644 --- a/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt @@ -1,5 +1,6 @@ package io.novafoundation.nova.balances +import io.novafoundation.nova.common.utils.tronAddressToAccountId 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 @@ -29,6 +30,14 @@ class TronBalancesIntegrationTest { private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" private val baseUrl = "https://api.trongrid.io" + // A real wallet that received exactly 5 USDT-TRC20 (2026-07-11) and has NEVER had any native TRX/other + // on-chain activity - confirmed live to have no Account object at all (`/v1/accounts` returns `data: []`) + // despite genuinely holding the token (`balanceOf` correctly returns 5000000). Regression coverage for the + // exact bug this uncovered: fetchTrc20Balance used to read through `/v1/accounts` and silently returned 0 + // for any address in this state, well after it was live and had already deceived a real user mid-transfer. + private val unactivatedHolderAddress = "TUdvwdGeqcag51XkhgRK21KmhH2qw37LZG" + private val unactivatedHolderExpectedUsdtBalance = BigInteger.valueOf(5_000_000L) + private val maxAmount = BigInteger.valueOf(10).pow(30) private val tronGridApi = run { @@ -68,9 +77,21 @@ class TronBalancesIntegrationTest { @Test fun testTrc20UsdtBalanceLoading() = runBlocking { - val freeBalance = retryOn429 { tronGridApi.fetchTrc20Balance(baseUrl, testAddress, usdtContractAddress) } + val freeBalance = retryOn429 { tronGridApi.fetchTrc20Balance(baseUrl, testAddress.tronAddressToAccountId(), usdtContractAddress) } assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) } + + @Test + fun testTrc20BalanceLoadingForNeverActivatedHolder() = runBlocking { + val freeBalance = retryOn429 { + tronGridApi.fetchTrc20Balance(baseUrl, unactivatedHolderAddress.tronAddressToAccountId(), usdtContractAddress) + } + + assertTrue( + "USDT-TRC20 balance for a never-activated holder: expected $unactivatedHolderExpectedUsdtBalance, got $freeBalance", + freeBalance == unactivatedHolderExpectedUsdtBalance + ) + } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt index d3120b3b..2826a3d1 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt @@ -10,7 +10,6 @@ import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.b import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.pollingBalanceFlow import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi -import io.novafoundation.nova.runtime.ext.addressOf import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl import io.novafoundation.nova.runtime.ext.requireTrc20 import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain @@ -21,9 +20,9 @@ import kotlinx.coroutines.flow.map import java.math.BigInteger /** - * TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via TronGrid's REST API (the same - * `/v1/accounts/{address}` endpoint used for native TRX - TronGrid returns both in one response) and polls for - * updates. No transfer/history support here - see `TronAssetsModule`. + * TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via an on-chain `balanceOf` contract + * call (see [TronGridApi.fetchTrc20Balance] for why this can't reuse the `/v1/accounts` endpoint that native + * TRX balance reads from) and polls for updates. No transfer/history support here - see `TronAssetsModule`. */ class Trc20AssetBalance( private val assetCache: AssetCache, @@ -52,9 +51,8 @@ class Trc20AssetBalance( override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { val contractAddress = chainAsset.requireTrc20().contractAddress - val address = chain.addressOf(accountId) - val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), address, contractAddress) + val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), accountId, contractAddress) return ChainAssetBalance.fromFree(chainAsset, balance) } @@ -77,9 +75,8 @@ class Trc20AssetBalance( ): Flow { val contractAddress = chainAsset.requireTrc20().contractAddress val baseUrl = chain.requireTronGridBaseUrl() - val address = chain.addressOf(accountId) - return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, address, contractAddress) } + return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, accountId, contractAddress) } .map { balance -> assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance) 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 8fa93b1b..afb2b032 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,5 +1,7 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.tron +import io.novafoundation.nova.common.utils.toTronHexAddress +import io.novafoundation.nova.common.utils.tronAddressToHexAddress 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 @@ -9,7 +11,9 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCr 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.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.Trc20TransferAbi import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.AccountId import java.math.BigInteger /** @@ -24,7 +28,15 @@ interface TronGridApi { suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance - suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance + /** + * Reads via an on-chain `balanceOf(address)` call (`triggerconstantcontract`), NOT `/v1/accounts` - a + * TRC-20 balance lives in the token contract's own storage, not in the holder's Account object, so an + * address that has only ever received TRC-20 tokens (never native TRX, never otherwise "activated") has no + * Account object at all and `/v1/accounts` returns empty for it regardless of its real token balance. + * Confirmed live: a wallet holding exactly 5 USDT-TRC20 and zero TRX/activation history returned `data: []` + * from `/v1/accounts` while `balanceOf` correctly returned 5000000. + */ + suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance /** * Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`. @@ -89,13 +101,18 @@ class RealTronGridApi( return accountData.balance?.toBigInteger() ?: BigInteger.ZERO } - override suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance { - val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO + override suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance { + val response = triggerConstantContract( + baseUrl = baseUrl, + ownerHexAddress = holderAccountId.toTronHexAddress(), + contractHexAddress = contractAddress.tronAddressToHexAddress(), + functionSelector = Trc20TransferAbi.BALANCE_OF_FUNCTION_SELECTOR, + parameterHex = Trc20TransferAbi.encodeBalanceOfParameters(holderAccountId) + ) - val rawBalance = accountData.trc20.orEmpty() - .firstNotNullOfOrNull { entry -> entry[contractAddress] } + val resultHex = response.constantResult?.firstOrNull() ?: return BigInteger.ZERO - return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO + return runCatching { BigInteger(resultHex, 16) }.getOrDefault(BigInteger.ZERO) } override suspend fun createNativeTransfer( 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 index 90da3f41..2f411740 100644 --- 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 @@ -5,12 +5,11 @@ 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. + * Minimal, hand-written Solidity ABI encoding for the two calls this client makes to a TRC-20 contract: + * `transfer(address,uint256)` (sending) and `balanceOf(address)` (reading a balance - `Trc20AssetBalance` can't + * use TronGrid's `/v1/accounts` REST endpoint for this: a TRC-20 balance lives in the token contract's own + * storage, not the holder's Account object, so an address that has only ever received TRC-20 tokens has no + * Account object at all and `/v1/accounts` silently returns empty for it regardless of its real balance). * * 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 @@ -22,6 +21,7 @@ import java.math.BigInteger object Trc20TransferAbi { const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)" + const val BALANCE_OF_FUNCTION_SELECTOR = "balanceOf(address)" /** * @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address - @@ -36,4 +36,14 @@ object Trc20TransferAbi { return addressParam + amountParam } + + /** + * @param holder raw 20-byte Ethereum/Tron-style account id - same encoding as [encodeTransferParameters]'s + * `recipient`. + */ + fun encodeBalanceOfParameters(holder: AccountId): String { + require(holder.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${holder.size}" } + + return holder.toHexString(withPrefix = false).padStart(64, '0') + } } From e1d199931c81e91046e1815527e55f6efbad0202 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 18:08:33 -0700 Subject: [PATCH 53/56] fix: native TRX fee estimation crashes when amount is not yet entered TronGrid's createtransaction rejects amount=0 with a ContractValidateException. estimateNativeFee called it unguarded (unlike the TRC-20 path, which already wraps its dry run in runCatching), so the send screen's reactive fee loader surfaced this as a generic "Network not responding" error whenever it ran before the user typed an amount - found live testing the send flow end-to-end. --- .../network/tron/transaction/RealTronTransactionService.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 2568dc1e..19ce0546 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 @@ -208,7 +208,12 @@ class RealTronTransactionService( } private suspend fun estimateNativeFee(baseUrl: String, ownerHex: String, recipient: AccountId, amountSun: BigInteger): BigInteger { - val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), amountSun) + // TronGrid's createtransaction rejects amount=0 outright with a ContractValidateException (confirmed + // live) - the send screen's fee loader calls calculateFee reactively as the user types, including before + // any amount has been entered. Substitute a minimal placeholder purely for this dry-run construction call; + // it does not affect the real amount used when the transfer is actually built in transact(). + val dryRunAmountSun = amountSun.takeIf { it > BigInteger.ZERO } ?: BigInteger.ONE + val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), dryRunAmountSun) val txSizeBytes = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }.length / 2 val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE) From bc9087136c875013d8a9d2cc14a27b332625c7a0 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 19:20:02 -0700 Subject: [PATCH 54/56] fix: Tron transfers signed with the wrong key (or crashed) due to missing multi-chain-encryption case SecretsSigner.multiChainEncryptionFor() only recognized substrate accounts, standard Ethereum accounts, and explicit per-chain override accounts. A Tron account's accountId matches none of those (same secp256k1 scheme as Ethereum, but a different SLIP-44 derivation path/keypair), so it fell through to null and crashed on the `!!` - found live testing the send flow end-to-end, reproduced on every Confirm tap. Fixing just the null case would have signed Tron transfers with the Ethereum keypair instead of the Tron one (getMetaAccountKeypair only distinguished ethereum/substrate), producing an invalid signature. Threaded a proper isTronBased flag through down to mapMetaAccountSecretsToKeypair so Tron gets its own MetaAccountSecrets.TronKeypair. --- .../common/data/secrets/v2/SecretStoreV2.kt | 17 ++++++++++++----- .../data/signer/secrets/SecretsSigner.kt | 13 ++++++++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt index b62f364a..2a4ea761 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt @@ -156,20 +156,25 @@ val AccountSecrets.isChainAccountSecrets suspend fun SecretStoreV2.getMetaAccountKeypair( metaId: Long, isEthereum: Boolean, + isTron: Boolean = false, ): Keypair = withContext(Dispatchers.Default) { val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) - mapMetaAccountSecretsToKeypair(secrets, isEthereum) + mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron) } fun mapMetaAccountSecretsToKeypair( secrets: EncodableStruct, ethereum: Boolean, + tron: Boolean = false, ): Keypair { - val keypairStruct = if (ethereum) { - secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() - } else { - secrets[MetaAccountSecrets.SubstrateKeypair] + // Tron reuses Ethereum's secp256k1 curve but derives its own keypair under a different SLIP-44 path - it + // must be checked before `ethereum`, not folded into it, or a Tron account would get signed with the + // wrong (Ethereum) private key. + val keypairStruct = when { + tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret() + ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() + else -> secrets[MetaAccountSecrets.SubstrateKeypair] } return mapKeypairStructToKeypair(keypairStruct) @@ -198,6 +203,8 @@ private fun noChainSecrets(metaId: Long, accountId: ByteArray): Nothing { private fun noEthereumSecret(): Nothing = error("No ethereum keypair found") +private fun noTronSecret(): Nothing = error("No tron keypair found") + fun mapKeypairStructToKeypair(struct: EncodableStruct): Keypair { return Keypair( publicKey = struct[KeyPairSchema.PublicKey], diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt index 2336b9ef..f7435613 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt @@ -142,11 +142,13 @@ class SecretsSigner( private suspend fun getKeypair(accountId: AccountId): Keypair { val chainsById = chainRegistry.chainsById() val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!! + val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true return secretStoreV2.getKeypair( metaAccount = metaAccount, accountId = accountId, - isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum + isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum, + isTronBased = isTronBased ) } @@ -159,11 +161,12 @@ class SecretsSigner( private suspend fun SecretStoreV2.getKeypair( metaAccount: MetaAccount, accountId: AccountId, - isEthereumBased: Boolean + isEthereumBased: Boolean, + isTronBased: Boolean = false, ) = if (hasChainSecrets(metaAccount.id, accountId)) { getChainAccountKeypair(metaAccount.id, accountId) } else { - getMetaAccountKeypair(metaAccount.id, isEthereumBased) + getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased) } /** @@ -173,6 +176,10 @@ class SecretsSigner( return when { substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum + // Tron reuses the exact same secp256k1 signing scheme as Ethereum - it just has its own accountId + // (different SLIP-44 derivation path), so it doesn't match ethereumAccountId() above and was + // falling through to the chainAccounts lookup, which doesn't cover it either -> null -> NPE on `!!`. + tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum else -> { val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null val cryptoType = chainAccount.cryptoType ?: return null From 04c0c41a2dda9625edea9b3fae3b8ca0997d79d6 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sun, 12 Jul 2026 04:54:58 -0700 Subject: [PATCH 55/56] fix: transparently retry TronGrid calls on HTTP 429 instead of surfacing it to the user TronGrid's public (no API key) endpoint rate-limits aggressively under normal, human-paced usage - confirmed live during send-flow testing, where every Confirm tap (each re-running fee estimation + broadcast) started hitting bare 429s after only a few attempts, with no way through except retrying by hand until one happened to land outside the rate-limit window. Added retryOn429 (exponential backoff, same shape as the existing test-only helper in TronBalancesIntegrationTest) at the RealTronGridApi level so every call - fee estimation, broadcast, balance reads - retries transparently. Broadcast is safe to retry on 429 specifically since it means TronGrid rejected the request before processing it, not that the transaction may already be in flight. --- .../data/network/tron/TronGridApi.kt | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) 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 afb2b032..f0a2f234 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,6 +14,8 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUn import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.Trc20TransferAbi import io.novasama.substrate_sdk_android.extensions.fromHex import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.delay +import retrofit2.HttpException import java.math.BigInteger /** @@ -95,6 +97,24 @@ class RealTronGridApi( private val retrofitApi: RetrofitTronGridApi ) : TronGridApi { + // TronGrid's public (no API key) endpoint rate-limits aggressively - confirmed live to return a bare HTTP + // 429 under normal, human-paced usage (not just load testing) once a handful of requests land in a short + // window. Without this, a 429 on any call in the send flow (fee estimation re-runs on every keystroke, + // broadcast, etc.) surfaced straight to the user as a raw error dialog, and the only way through was to + // keep tapping Confirm until a request happened to land outside the rate-limit window. Retrying here means + // every TronGrid call gets this transparently, not just the ones a caller remembered to wrap. + 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(1_000L * (attempt + 1)) + } + } + return block() + } + override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance { val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO @@ -127,7 +147,7 @@ class RealTronGridApi( amount = amountSun.toLongExactOrThrow("amount") ) - val response = retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request) + val response = retryOn429 { retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request) } return response.requireConstructed() } @@ -146,7 +166,7 @@ class RealTronGridApi( parameter = parameterHex ) - return retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request) + return retryOn429 { retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request) } } override suspend fun triggerSmartContract( @@ -165,7 +185,7 @@ class RealTronGridApi( feeLimit = feeLimitSun.toLongExactOrThrow("feeLimit") ) - val response = retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request) + val response = retryOn429 { retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request) } if (response.result?.result != true) { throw TronApiException(response.result?.message ?: response.result?.code ?: "triggersmartcontract failed without a message") @@ -188,7 +208,10 @@ class RealTronGridApi( signature = listOf(signatureHex) ) - val response = retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request) + // Safe to retry on 429 specifically: a 429 means TronGrid rejected the request before processing it + // (rate limit), not that the transaction may have already been broadcast - unlike a timeout, it can't + // cause a double-send. + val response = retryOn429 { retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request) } if (response.result != true) { throw TronApiException(response.decodeErrorMessage()) @@ -198,18 +221,18 @@ class RealTronGridApi( } override suspend fun getChainParameters(baseUrl: String): Map { - return retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters")) + return retryOn429 { 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)) + return retryOn429 { retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex)) } } - private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount( - url = accountUrl(baseUrl, address) - ).data?.firstOrNull() + private suspend fun fetchAccountData(baseUrl: String, address: String) = retryOn429 { + retrofitApi.getAccount(url = accountUrl(baseUrl, address)) + }.data?.firstOrNull() private fun accountUrl(baseUrl: String, address: String): String { return "${baseUrl.trimEnd('/')}/v1/accounts/$address" From 32002db5dac5beb9be545981cf8268d46b62bebd Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sun, 12 Jul 2026 05:08:22 -0700 Subject: [PATCH 56/56] feat: send TronGrid API key on every request to raise the anonymous rate limit Complements the retryOn429 fix - having a key means requests are far less likely to hit the rate limit at all, rather than just retrying transparently after they do. Wired the same way as INFURA_API_KEY/DWELLIR_API_KEY: buildConfigField read from local.properties or CI secret, added TRONGRID_API_KEY to android_build.yml's secret passthrough (every caller already uses secrets: inherit, so no per-workflow changes needed beyond this). --- .github/workflows/android_build.yml | 4 ++++ .../data/network/tron/RetrofitTronGridApi.kt | 21 ++++++++++++------- runtime/build.gradle | 1 + 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android_build.yml b/.github/workflows/android_build.yml index d657dfab..fcd1cc25 100644 --- a/.github/workflows/android_build.yml +++ b/.github/workflows/android_build.yml @@ -62,6 +62,9 @@ on: # RPC provider - use own nodes or Dwellir DWELLIR_API_KEY: required: false + # Tron - raises TronGrid's aggressive anonymous rate limit + TRONGRID_API_KEY: + required: false # WalletConnect - REQUIRED for dApp connections WALLET_CONNECT_PROJECT_ID: required: true @@ -111,6 +114,7 @@ env: EHTERSCAN_API_KEY_ETHEREUM: ${{ secrets.EHTERSCAN_API_KEY_ETHEREUM }} INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }} DWELLIR_API_KEY: ${{ secrets.DWELLIR_API_KEY }} + TRONGRID_API_KEY: ${{ secrets.TRONGRID_API_KEY }} WALLET_CONNECT_PROJECT_ID: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} DEBUG_GOOGLE_OAUTH_ID: ${{ secrets.DEBUG_GOOGLE_OAUTH_ID }} RELEASE_GOOGLE_OAUTH_ID: ${{ secrets.RELEASE_GOOGLE_OAUTH_ID }} 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 15025fcd..c1616e91 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 @@ -11,39 +11,46 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCr 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.novafoundation.nova.runtime.BuildConfig import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Headers import retrofit2.http.POST import retrofit2.http.Url +// TronGrid's anonymous rate limit is aggressive enough to surface under normal, human-paced app usage (see +// RealTronGridApi.retryOn429's doc comment) - a free API key from trongrid.io raises it substantially. Sent on +// every request rather than only when a 429 is hit, since the point is to avoid needing the retry in the first +// place, not just to have a fallback. +private const val TRON_API_KEY_HEADER = "TRON-PRO-API-KEY: " + BuildConfig.TRONGRID_API_KEY + interface RetrofitTronGridApi { @GET - @Headers(UserAgent.NOVA) + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) suspend fun getAccount(@Url url: String): TronAccountResponse @POST - @Headers(UserAgent.NOVA) + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) suspend fun createTransaction(@Url url: String, @Body body: TronCreateTransactionRequest): TronUnsignedTransactionResponse @POST - @Headers(UserAgent.NOVA) + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) suspend fun triggerConstantContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse @POST - @Headers(UserAgent.NOVA) + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) suspend fun triggerSmartContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse @POST - @Headers(UserAgent.NOVA) + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) suspend fun broadcastTransaction(@Url url: String, @Body body: TronBroadcastRequest): TronBroadcastResponse @GET - @Headers(UserAgent.NOVA) + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) suspend fun getChainParameters(@Url url: String): TronChainParametersResponse @POST - @Headers(UserAgent.NOVA) + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) suspend fun getAccountResource(@Url url: String, @Body body: TronAddressRequest): TronAccountResourceResponse } diff --git a/runtime/build.gradle b/runtime/build.gradle index c9824e66..df246f23 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -21,6 +21,7 @@ android { buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY") buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY") + buildConfigField "String", "TRONGRID_API_KEY", readStringSecret("TRONGRID_API_KEY") } buildTypes {