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\)') diff --git a/.github/workflows/balances_test.yml b/.github/workflows/balances_test.yml index 6c74a8fa..9b5f57d4 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 * * *' @@ -43,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 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/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 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..9ffcb697 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -0,0 +1,154 @@ +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.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 +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +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: 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 + * 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 standard per-updater error logs already wired into BalancesUpdateSystem/ + * FullSyncPaymentUpdater/NativeAssetBalance/StatemineAssetBalance show exactly which decision branch or + * exception is responsible - not another layer of inference from silence. + */ +class PezkuwiFullArchitectureBalancesTest { + + // Standard BIP44 Ethereum derivation (m/44'/60'/0'/0/0) from the same already-verified Founder mnemonic + // used for the substrate address below - this is exactly what Nova/Pezkuwi Wallet's own unified account + // creation derives when a single seed produces both a substrate and an Ethereum account. No dedicated + // "founder EVM wallet" record exists anywhere else, so this is the correct, non-fabricated way to get a + // real EVM address to test USDT-on-Ethereum sync with - it doesn't need to hold any balance, since this + // test only asserts a row gets written (see below), not that the balance is non-zero. + private val founderEthereumAddress = "0x1aa2EA1292c62BdC6E49E0C12134263efc73713A" + private val ethereumChainId = "eip155:1" + + 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() + private val chainAssetDao: ChainAssetDao = dbApi.chainAssetDao() + + private val assetsFeatureApi = FeatureUtils.getFeature(context, AssetsFeatureApi::class.java) + + @Test + fun testPezkuwiEcosystemAssetsActuallySync() = runBlocking { + val fixture: AssetsFixtureFile = Gson().fromJson(URL(TEST_ASSETS_URL).readText()) + + val updateSystemJob = launch { assetsFeatureApi.updateSystem.start().collect {} } + + try { + 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() + + withTimeoutOrNull(120.seconds) { + while (stillMissing.isNotEmpty()) { + val found = stillMissing.filter { asset -> + assetDao.getAsset(metaId, asset.chainId, asset.assetId) != null + } + stillMissing.removeAll(found) + if (stillMissing.isNotEmpty()) delay(2.seconds) + } + } + + assertTrue( + "No `assets` row was ever written for: ${stillMissing.joinToString { "${it.symbol} on ${it.chainName}" }} " + + "(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/EvmErc20AssetBalance error logs in logcat.", + stillMissing.isEmpty() + ) + } finally { + updateSystemJob.cancel() + } + } + + 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 = evmAddress, + 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/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..395a3065 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt @@ -0,0 +1,97 @@ +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 +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 +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" + + // 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 { + val retrofit = Retrofit.Builder() + .client(OkHttpClient.Builder().build()) + .baseUrl(baseUrl) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + 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 = 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) + } + + @Test + fun testTrc20UsdtBalanceLoading() = runBlocking { + 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/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" 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 2b6916eb..1b44c7d0 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,24 +156,28 @@ val AccountSecrets.isChainAccountSecrets suspend fun SecretStoreV2.getMetaAccountKeypair( metaId: Long, isEthereum: Boolean, + isTron: Boolean = false, isBitcoin: Boolean = false, ): Keypair = withContext(Dispatchers.Default) { val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) - mapMetaAccountSecretsToKeypair(secrets, isEthereum, isBitcoin) + mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin) } fun mapMetaAccountSecretsToKeypair( secrets: EncodableStruct, ethereum: Boolean, + tron: Boolean = false, bitcoin: Boolean = false, ): Keypair { - val keypairStruct = if (bitcoin) { - secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret() - } else if (ethereum) { - secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() - } else { - secrets[MetaAccountSecrets.SubstrateKeypair] + // Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different + // SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get + // signed with the wrong (Ethereum) private key. + val keypairStruct = when { + tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret() + bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret() + ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() + else -> secrets[MetaAccountSecrets.SubstrateKeypair] } return mapKeypairStructToKeypair(keypairStruct) @@ -207,6 +211,8 @@ private fun noEthereumSecret(): Nothing = error("No ethereum keypair found") private fun noBitcoinSecret(): Nothing = error("No bitcoin 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/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..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 @@ -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/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> 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 c4c3983e..ed001602 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 @@ -131,6 +131,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-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 2bedc7a7..b6be9f54 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 @@ -75,6 +75,19 @@ class BitcoinFee( 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-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 { 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 a446508b..2e066d22 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 @@ -19,6 +19,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 @@ -93,6 +95,7 @@ class RealLocalAccountsCloudBackupFacade( ethereum = baseSecrets.getEthereumBackupSecrets(), chainAccounts = emptyList(), bitcoin = baseSecrets.getBitcoinBackupSecrets(), + tron = baseSecrets.getTronBackupSecrets(), ) return CloudBackup( @@ -361,6 +364,7 @@ class RealLocalAccountsCloudBackupFacade( ethereum = baseSecrets.getEthereumBackupSecrets(), chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets, bitcoin = baseSecrets.getBitcoinBackupSecrets(), + tron = baseSecrets.getTronBackupSecrets(), ) } @@ -472,7 +476,9 @@ class RealLocalAccountsCloudBackupFacade( ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(), ethereumDerivationPath = ethereum?.derivationPath, bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(), - bitcoinDerivationPath = bitcoin?.derivationPath + bitcoinDerivationPath = bitcoin?.derivationPath, + tronKeypair = tron?.keypair?.toLocalKeyPair(), + tronDerivationPath = tron?.derivationPath ) } @@ -494,6 +500,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 @@ -537,7 +552,9 @@ class RealLocalAccountsCloudBackupFacade( type = metaAccount.type.toBackupWalletType() ?: return null, chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) }, bitcoinAddress = metaAccount.bitcoinAddress, - bitcoinPublicKey = metaAccount.bitcoinPublicKey + bitcoinPublicKey = metaAccount.bitcoinPublicKey, + tronAddress = metaAccount.tronAddress, + tronPublicKey = metaAccount.tronPublicKey, ) } @@ -561,7 +578,9 @@ class RealLocalAccountsCloudBackupFacade( status = MetaAccountLocal.Status.ACTIVE, typeExtras = null, bitcoinAddress = bitcoinAddress, - bitcoinPublicKey = bitcoinPublicKey + bitcoinPublicKey = bitcoinPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, ).also { if (localIdOverwrite != null) { it.id = localIdOverwrite 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 65ca3145..6d997afd 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 @@ -31,6 +31,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTy 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.BitcoinAddressBackfillMigration +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 @@ -65,17 +66,40 @@ class AccountDataSourceImpl( private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, secretStoreV1: SecretStoreV1, accountDataMigration: AccountDataMigration, - private val bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration, + tronAddressBackfillMigration: TronAddressBackfillMigration, + bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration, ) : AccountDataSource, SecretStoreV1 by secretStoreV1 { init { - migrateIfNeeded(accountDataMigration) - async { bitcoinAddressBackfillMigration.migrate() } - } + // Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin backfills read + // accounts/secrets that the legacy migration may still be in the middle of writing for very old + // (pre-MetaAccount) installs, and separate GlobalScope.launch calls give no ordering guarantee + // relative to each other. + async { + Log.d("AccountDataSourceImpl", "migrations block starting") - private fun migrateIfNeeded(migration: AccountDataMigration) = async { - if (migration.migrationNeeded()) { - migration.migrate(::saveSecuritySource) + if (accountDataMigration.migrationNeeded()) { + accountDataMigration.migrate(::saveSecuritySource) + } + + Log.d("AccountDataSourceImpl", "about to run tronAddressBackfillMigration") + + tronAddressBackfillMigration.migrate() + + Log.d("AccountDataSourceImpl", "about to run bitcoinAddressBackfillMigration") + + bitcoinAddressBackfillMigration.migrate() + + 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"}" + ) + } } } 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..58b3f07a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt @@ -0,0 +1,128 @@ +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 +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.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 TAG = "TronAddressBackfill" + +/** + * 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 + * 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 secretStoreV2: SecretStoreV2, + private val metaAccountDao: MetaAccountDao, + private val accountSecretsFactory: AccountSecretsFactory, +) { + + 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 -> + try { + backfillIfNeeded(account) + } catch (e: Throwable) { + Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e) + } + } + + Log.d(TAG, "migrate() done") + } + + private suspend fun backfillIfNeeded(account: MetaAccountLocal) { + 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 + + 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) } + + Log.d(TAG, "metaId=${account.id}: backfilled successfully, tronAddress set (${tronAccountId.size} bytes)") + } +} 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 74264155..1d0cd56f 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,12 +142,15 @@ 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 + val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true return secretStoreV2.getKeypair( metaAccount = metaAccount, accountId = accountId, isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum, - isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true + isTronBased = isTronBased, + isBitcoinBased = isBitcoinBased ) } @@ -161,11 +164,12 @@ class SecretsSigner( metaAccount: MetaAccount, accountId: AccountId, isEthereumBased: Boolean, + isTronBased: Boolean = false, isBitcoinBased: Boolean = false, ) = if (hasChainSecrets(metaAccount.id, accountId)) { getChainAccountKeypair(metaAccount.id, accountId) } else { - getMetaAccountKeypair(metaAccount.id, isEthereumBased, isBitcoinBased) + getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased) } /** @@ -175,6 +179,11 @@ class SecretsSigner( return when { substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum + // Tron and Bitcoin both reuse the exact same secp256k1 signing scheme as Ethereum - they just have + // their own accountId (different SLIP-44 derivation path), so neither matches ethereumAccountId() + // above and was falling through to the chainAccounts lookup, which doesn't cover them either -> + // null -> NPE on `!!`. + tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum else -> { val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null 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 ee7b594e..404edfcb 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 @@ -105,6 +105,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Re 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.BitcoinAddressBackfillMigration +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 @@ -403,6 +404,7 @@ class AccountFeatureModule { nodeDao: NodeDao, secretStoreV1: SecretStoreV1, accountDataMigration: AccountDataMigration, + tronAddressBackfillMigration: TronAddressBackfillMigration, metaAccountDao: MetaAccountDao, secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, secretStoreV2: SecretStoreV2, @@ -419,6 +421,7 @@ class AccountFeatureModule { secretsMetaAccountLocalFactory, secretStoreV1, accountDataMigration, + tronAddressBackfillMigration, bitcoinAddressBackfillMigration ) } @@ -452,6 +455,16 @@ class AccountFeatureModule { return AccountDataMigration(preferences, encryptedPreferences, accountDao) } + @Provides + @FeatureScope + fun provideTronAddressBackfillMigration( + secretStoreV2: SecretStoreV2, + metaAccountDao: MetaAccountDao, + accountSecretsFactory: AccountSecretsFactory, + ): TronAddressBackfillMigration { + return TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory) + } + @Provides @FeatureScope fun provideExternalAccountActions( 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 8b597107..f29bc849 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 @@ -53,7 +53,10 @@ class RealSecretsMetaAccount( hasChainAccountIn(chain.id) -> { val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null - if (chain.isEthereumBased) { + // Tron and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see + // RealTronTransactionService/RealBitcoinTransactionService's use of + // Signer.sign(MultiChainEncryption.Ethereum, ...). + if (chain.isEthereumBased || chain.isTronBased || chain.isBitcoinBased) { MultiChainEncryption.Ethereum } else { MultiChainEncryption.substrateFrom(cryptoType) @@ -62,6 +65,10 @@ class RealSecretsMetaAccount( chain.isEthereumBased -> MultiChainEncryption.Ethereum + chain.isTronBased -> MultiChainEncryption.Ethereum + + chain.isBitcoinBased -> MultiChainEncryption.Ethereum + else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) } } 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-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..6e075706 --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt @@ -0,0 +1,192 @@ +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.utils.invoke +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 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(secretStoreV2, metaAccountDao, accountSecretsFactory) + } + + @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 + ) + } +} 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..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() @@ -89,6 +101,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(LOG_TAG, "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e) emptyFlow() } } 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/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/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..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,6 +16,7 @@ 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.displayNameWithAssetStandard import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.asset import kotlinx.coroutines.flow.Flow @@ -73,7 +74,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, 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" /> + + + + - - - - - - - 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 db178c87..3a4a1aa0 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 @@ -24,8 +24,16 @@ data class CloudBackup( val name: String, val type: Type, val chainAccounts: Set, - // Nullable and defaulted so that Gson deserializing a backup written before Bitcoin support existed - - // which has no such field in its JSON at all - lands on null here rather than failing. + // All 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 has no derivation anywhere in this codebase yet (no native support, + // unlike Tron/Bitcoin) - its 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/Bitcoin did when they were 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 { @@ -77,6 +85,10 @@ data class CloudBackup( name == other.name && type == other.type && 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) } @@ -91,6 +103,10 @@ 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() @@ -108,6 +124,10 @@ data class CloudBackup( val substrate: SubstrateSecrets?, val ethereum: EthereumSecrets?, val chainAccounts: List, + // See the matching comment on WalletPublicInfo: tron/bitcoin are fully wired end to end, solana is a + // schema-only reservation until native derivation for it exists. + val tron: TronSecrets? = null, + val solana: SolanaSecrets? = null, val bitcoin: BitcoinSecrets? = null, ) : Identifiable { @@ -127,6 +147,8 @@ 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 } @@ -137,6 +159,8 @@ 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 @@ -212,6 +236,17 @@ 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?, + ) + data class BitcoinSecrets( val keypair: KeyPairSecrets, val derivationPath: String?, @@ -250,5 +285,5 @@ data class CloudBackup( } fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean { - return entropy == null && substrate == null && ethereum == null && bitcoin == null && chainAccounts.isEmpty() + return entropy == null && substrate == null && ethereum == null && tron == null && bitcoin == 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, ) } } 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/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 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 e1e5d3dc..9de4217f 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 @@ -10,4 +10,6 @@ sealed interface TransactionExecution { class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution class Bitcoin(val hash: String) : TransactionExecution + + class Tron(val hash: String) : TransactionExecution } diff --git a/feature-wallet-impl/build.gradle b/feature-wallet-impl/build.gradle index efa0ef15..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 } @@ -71,6 +82,7 @@ dependencies { testImplementation jUnitDep testImplementation mockitoDep + testImplementation project(':test-shared') implementation substrateSdkDep 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/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/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) } 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..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 @@ -151,6 +151,17 @@ 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. + // + // 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, @@ -160,13 +171,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 +184,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/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..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 @@ -1,14 +1,56 @@ 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 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, TRON_API_KEY_HEADER) + suspend fun createTransaction(@Url url: String, @Body body: TronCreateTransactionRequest): TronUnsignedTransactionResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun triggerConstantContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun triggerSmartContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun broadcastTransaction(@Url url: String, @Body body: TronBroadcastRequest): TronBroadcastResponse + + @GET + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun getChainParameters(@Url url: String): TronChainParametersResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + 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..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 @@ -1,39 +1,263 @@ 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 +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.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 +/** + * 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 + /** + * 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`. + * [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( 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 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) } - private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount( - url = accountUrl(baseUrl, address) - ).data?.firstOrNull() + 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 = retryOn429 { 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 retryOn429 { 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 = 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") + } + + // 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) + ) + + // 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()) + } + + return response.txid ?: txId + } + + override suspend fun getChainParameters(baseUrl: String): Map { + return retryOn429 { retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters")) } + .chainParameter + .associate { it.key to it.value } + } + + override suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse { + return retryOn429 { retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex)) } + } + + 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" } + + 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..e1d8bd3c --- /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..19ce0546 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt @@ -0,0 +1,269 @@ +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 { + // 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) + 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..2f411740 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt @@ -0,0 +1,49 @@ +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 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 + * 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)" + 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 - + * 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 + } + + /** + * @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') + } +} 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 351c6326..b10a097d 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 @@ -75,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/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..194e7902 --- /dev/null +++ b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt @@ -0,0 +1,275 @@ +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.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.ArgumentMatcher +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner +import java.math.BigInteger + +// 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) + +/** + * 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`(): Unit = 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: SignerPayloadRaw -> + 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)`(): Unit = 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`(): Unit = 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 + ) + } +} 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/build.gradle b/runtime/build.gradle index 2a540db4..e9f43ae3 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -16,10 +16,12 @@ android { 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_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") + buildConfigField "String", "TRONGRID_API_KEY", readStringSecret("TRONGRID_API_KEY") } buildTypes { 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 8b674db2..eff94b38 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 @@ -39,6 +39,7 @@ 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 @@ -162,13 +163,17 @@ class ChainRegistryModule { bulkRetriever: BulkRetriever, connectionSecrets: ConnectionSecrets, web3ApiFactory: Web3ApiFactory, - httpClient: OkHttpClient, ) = NodeHealthStateTesterFactory( socketProvider, connectionSecrets, bulkRetriever, web3ApiFactory, - httpClient + // 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/di/RuntimeDependencies.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt index 56efddc7..f56a081e 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt @@ -10,14 +10,11 @@ import io.novafoundation.nova.core_db.dao.ChainAssetDao import io.novafoundation.nova.core_db.dao.ChainDao import io.novafoundation.nova.core_db.dao.StorageDao import io.novasama.substrate_sdk_android.wsrpc.SocketService -import okhttp3.OkHttpClient interface RuntimeDependencies { fun networkApiCreator(): NetworkApiCreator - fun okHttpClient(): OkHttpClient - fun socketServiceCreator(): SocketService fun gson(): Gson 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 477d95e3..c7a649f3 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 @@ -19,6 +19,7 @@ import io.novafoundation.nova.common.utils.formatNamed import io.novafoundation.nova.common.utils.isValidBitcoinAddress 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.toBitcoinAddress import io.novafoundation.nova.common.utils.toTronAddress @@ -372,7 +373,13 @@ fun Chain.isValidAddress(address: String): Boolean { return runCatching { when { isBitcoinBased -> address.isValidBitcoinAddress() + + // 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() + isEthereumBased -> address.asEthereumAddress().isValid() + else -> { address.toAccountId() // verify supplied address can be converted to account id @@ -492,6 +499,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 @@ -503,6 +511,33 @@ 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 + } + +/** + * 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) 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 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..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,7 +10,14 @@ val TokenSymbol.mainTokensFirstAscendingOrder "DOT" -> 3 "KSM" -> 4 "USDC" -> 5 - else -> 6 + "TRX" -> 6 + "BTC" -> 7 + "ETH" -> 8 + "BNB" -> 9 + "AVAX" -> 10 + "LINK" -> 11 + "TAO" -> 12 + else -> 13 } val TokenSymbol.alphabeticalOrder 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 a82ff975..56259063 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 @@ -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,37 @@ 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) } + // 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 -> - 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/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..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 @@ -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,17 +42,48 @@ class ChainSyncService( val remoteChains = retryUntilDone { chainFetcher.getChains() } - 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) + // 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 + } + + // 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) 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 afac3d1d..2588da8b 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 @@ -27,6 +27,11 @@ class NodeHealthStateTesterFactory( httpClient = httpClient ) + chain.isTronBased -> TronNodeHealthStateTester( + node = node, + httpClient = httpClient + ) + chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester( chain = chain, socketService = socketServiceProvider.get(), 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 + } + } +} 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..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 @@ -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,51 @@ 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. + 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) + } } }