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..a9baeac1 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.balances + +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory +import java.math.BigInteger + +/** + * Tron is a REST API (TronGrid), not a Substrate runtime - it has no ChainConnection/RuntimeProvider and isn't + * reachable through [BalancesIntegrationTest]'s chainRegistry-based mechanism at all. This exercises the exact + * same [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi] the production app uses for + * balance reads (see TronNativeAssetBalance/Trc20AssetBalance), just via a standalone Retrofit client instead of + * the full DI graph, since TronGridApi isn't exposed through a public feature API for tests to reach. + * + * Test account is the mainnet Founder's Tron address, verified live via TronGrid's public API on 2026-07-09 to + * hold a substantial non-zero balance of both native TRX and TRC-20 USDT - not a guessed or empty account. + */ +class TronBalancesIntegrationTest { + + private val testAddress = "TDGZ4GfvCRe1d8oksj8fBD77ZHw4bkCPBA" + private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + private val baseUrl = "https://api.trongrid.io" + + private val maxAmount = BigInteger.valueOf(10).pow(30) + + private val tronGridApi = run { + val retrofit = Retrofit.Builder() + .client(OkHttpClient.Builder().build()) + .baseUrl(baseUrl) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + RealTronGridApi(retrofit.create(RetrofitTronGridApi::class.java)) + } + + // 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, usdtContractAddress) } + + assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) + assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) + } +} 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/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/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt index d683e7fa..da9fcb16 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt @@ -63,6 +63,19 @@ class SubstrateFee( override val asset: Chain.Asset ) : Fee +/** + * Fee for a Tron transaction (native TRX or TRC-20), always denominated in TRX (sun), regardless of which asset + * is being sent - Tron has no separate "gas token" concept, network resources (bandwidth/energy) are always + * burned as TRX. [amount] is this client's own estimate of that burn (see `RealTronTransactionService`); the + * network only ever burns what it actually uses, so the real cost can be lower, but never higher than what this + * client authorized via `fee_limit` when submitting. + */ +class TronFee( + override val amount: BigInteger, + override val submissionOrigin: SubmissionOrigin, + override val asset: Chain.Asset +) : Fee + class SubstrateFeeBase( override val amount: BigInteger, override val asset: Chain.Asset diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt index 7b88a202..57852a55 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt @@ -49,7 +49,9 @@ class RealSecretsMetaAccount( hasChainAccountIn(chain.id) -> { val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null - if (chain.isEthereumBased) { + // Tron reuses the same secp256k1 keypair/signing as Ethereum - see + // RealTronTransactionService's use of Signer.sign(MultiChainEncryption.Ethereum, ...). + if (chain.isEthereumBased || chain.isTronBased) { MultiChainEncryption.Ethereum } else { MultiChainEncryption.substrateFrom(cryptoType) @@ -58,6 +60,8 @@ class RealSecretsMetaAccount( chain.isEthereumBased -> MultiChainEncryption.Ethereum + chain.isTronBased -> MultiChainEncryption.Ethereum + else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) } } diff --git a/feature-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..820ef106 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt @@ -89,6 +89,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/list/view/PezkuwiDashboardAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PezkuwiDashboardAdapter.kt index 8352e3b1..d869ad42 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PezkuwiDashboardAdapter.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PezkuwiDashboardAdapter.kt @@ -2,6 +2,8 @@ package io.novafoundation.nova.feature_assets.presentation.balance.list.view import android.content.res.ColorStateList import android.graphics.Color +import android.transition.AutoTransition +import android.transition.TransitionManager import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -29,6 +31,10 @@ class PezkuwiDashboardAdapter( private var model: PezkuwiDashboardModel? = null private var trackingLoading: Boolean = false + // Survives ViewHolder recycling (scroll) within the process, but not process restart — + // resets to collapsed (false) whenever the app is freshly opened, by design. + private var isExpanded: Boolean = false + fun setModel(model: PezkuwiDashboardModel) { this.model = model notifyChangedIfShown() @@ -41,11 +47,11 @@ class PezkuwiDashboardAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PezkuwiDashboardHolder { val binding = ItemPezkuwiDashboardBinding.inflate(parent.inflater(), parent, false) - return PezkuwiDashboardHolder(binding, handler) + return PezkuwiDashboardHolder(binding, handler) { expanded -> isExpanded = expanded } } override fun onBindViewHolder(holder: PezkuwiDashboardHolder, position: Int) { - model?.let { holder.bind(it, trackingLoading) } + model?.let { holder.bind(it, trackingLoading, isExpanded) } } override fun getItemViewType(position: Int): Int { @@ -55,7 +61,8 @@ class PezkuwiDashboardAdapter( class PezkuwiDashboardHolder( private val binder: ItemPezkuwiDashboardBinding, - handler: PezkuwiDashboardAdapter.Handler + handler: PezkuwiDashboardAdapter.Handler, + private val onExpandedChanged: (Boolean) -> Unit ) : RecyclerView.ViewHolder(binder.root) { companion object : WithViewType { @@ -67,14 +74,30 @@ class PezkuwiDashboardHolder( binder.pezkuwiDashboardSignButton.setOnClickListener { handler.onSignClicked() } binder.pezkuwiDashboardShareButton.setOnClickListener { handler.onShareReferralClicked() } binder.pezkuwiDashboardStartTrackingButton.setOnClickListener { handler.onStartTrackingClicked() } + + binder.pezkuwiDashboardCollapsedBar.setOnClickListener { setExpanded(true) } + binder.pezkuwiDashboardCollapseButton.setOnClickListener { setExpanded(false) } } - fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false) { + private fun setExpanded(expanded: Boolean) { + TransitionManager.beginDelayedTransition(binder.pezkuwiDashboardRoot, AutoTransition().apply { duration = 200 }) + binder.pezkuwiDashboardCollapsedBar.visibility = if (expanded) View.GONE else View.VISIBLE + binder.pezkuwiDashboardExpandedContent.visibility = if (expanded) View.VISIBLE else View.GONE + onExpandedChanged(expanded) + } + + fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false, isExpanded: Boolean = false) { bindRoles(model.roles) binder.pezkuwiDashboardTrustValue.text = model.trustScore + binder.pezkuwiDashboardTrustValueCollapsed.text = model.trustScore binder.pezkuwiDashboardWelatiCount.text = model.welatiCount bindButtons(model.citizenshipStatus) + // Reflect current expand state without animating (this runs on every bind/rebind, + // e.g. after RecyclerView recycling — animation is only for user-initiated toggles). + binder.pezkuwiDashboardCollapsedBar.visibility = if (isExpanded) View.GONE else View.VISIBLE + binder.pezkuwiDashboardExpandedContent.visibility = if (isExpanded) View.VISIBLE else View.GONE + val showTracking = !model.isTrackingScore && model.citizenshipStatus == CitizenshipStatus.APPROVED binder.pezkuwiDashboardStartTrackingButton.visibility = if (showTracking) View.VISIBLE else View.GONE diff --git a/feature-assets/src/main/res/drawable/bg_pezkuwi_dashboard_collapsed.xml b/feature-assets/src/main/res/drawable/bg_pezkuwi_dashboard_collapsed.xml new file mode 100644 index 00000000..eb131abc --- /dev/null +++ b/feature-assets/src/main/res/drawable/bg_pezkuwi_dashboard_collapsed.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/feature-assets/src/main/res/layout/item_pezkuwi_dashboard.xml b/feature-assets/src/main/res/layout/item_pezkuwi_dashboard.xml index 53d795b9..ad70aa25 100644 --- a/feature-assets/src/main/res/layout/item_pezkuwi_dashboard.xml +++ b/feature-assets/src/main/res/layout/item_pezkuwi_dashboard.xml @@ -10,151 +10,218 @@ app:strokeWidth="0dp"> + android:orientation="vertical"> - + + android:orientation="horizontal" + android:paddingHorizontal="16dp"> - + android:text="@string/pezkuwi_dashboard_trust_score" + android:textColor="@android:color/white" + android:textSize="13sp" + android:textStyle="bold" /> - + + + + + + + + + + + + + + + + + + + + + android:gravity="end" + android:orientation="vertical"> - + + + + + + + + android:layout_marginTop="14dp" + android:gravity="center_vertical" + android:orientation="horizontal"> + + - + android:layout_height="32dp" + android:layout_marginStart="8dp" + android:minWidth="0dp" + android:paddingHorizontal="10dp" + android:text="@string/pezkuwi_dashboard_start_tracking" + android:textAllCaps="false" + android:textColor="@android:color/white" + android:textSize="11sp" + android:visibility="gone" + app:backgroundTint="#009639" + app:cornerRadius="8dp" /> - - - - - - - - + + app:cornerRadius="14dp" /> + + + + - - - - - - - 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 b4ec88b6..1bc5d98d 100644 --- a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt @@ -8,4 +8,6 @@ sealed interface TransactionExecution { class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution + + class Tron(val hash: String) : TransactionExecution } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/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/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..15025fcd 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt @@ -1,9 +1,20 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.tron import io.novafoundation.nova.common.data.network.UserAgent +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronChainParametersResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Headers +import retrofit2.http.POST import retrofit2.http.Url interface RetrofitTronGridApi { @@ -11,4 +22,28 @@ interface RetrofitTronGridApi { @GET @Headers(UserAgent.NOVA) suspend fun getAccount(@Url url: String): TronAccountResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun createTransaction(@Url url: String, @Body body: TronCreateTransactionRequest): TronUnsignedTransactionResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun triggerConstantContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun triggerSmartContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun broadcastTransaction(@Url url: String, @Body body: TronBroadcastRequest): TronBroadcastResponse + + @GET + @Headers(UserAgent.NOVA) + suspend fun getChainParameters(@Url url: String): TronChainParametersResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun getAccountResource(@Url url: String, @Body body: TronAddressRequest): TronAccountResourceResponse } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt index 22853ca7..8fa93b1b 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt @@ -1,13 +1,82 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.tron import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novasama.substrate_sdk_android.extensions.fromHex import java.math.BigInteger +/** + * Thrown whenever TronGrid reports a failure via an HTTP-200 body (rather than an HTTP error status), which is + * how most `/wallet/` endpoints signal validation/execution failures, e.g. + * `{"Error": "... no OwnerAccount."}` from `createtransaction`, or + * `{"code": "CONTRACT_VALIDATE_ERROR", "message": ""}` from `broadcasttransaction`. + */ +class TronApiException(message: String) : Exception(message) + interface TronGridApi { suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance + + /** + * Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`. + * [ownerHexAddress]/[toHexAddress] must be in hex form (`41`-prefixed), matching `visible: false`. + * + * Note: TronGrid refuses to build this for an owner account that has never been activated on-chain + * (confirmed live: returns `{"Error": "... no OwnerAccount."}`) - unlike [triggerSmartContract], which + * happily builds a transaction for an unfunded/unactivated owner. + */ + suspend fun createNativeTransfer(baseUrl: String, ownerHexAddress: String, toHexAddress: String, amountSun: BigInteger): TronUnsignedTransactionResponse + + /** + * Read-only dry run via `POST /wallet/triggerconstantcontract` - does not require the owner account to hold + * any TRX and does not touch chain state. Used to estimate the `energy_used` an actual TRC-20 call would + * cost (see [TronTriggerContractResponse.energyUsed]). + */ + suspend fun triggerConstantContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String + ): TronTriggerContractResponse + + /** + * Builds an unsigned TRC-20 contract call via `POST /wallet/triggersmartcontract`. Unlike + * [createNativeTransfer], this works even for an owner account that has never been activated on-chain + * (confirmed live). + */ + suspend fun triggerSmartContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String, + feeLimitSun: BigInteger + ): TronTriggerContractResponse + + /** + * Signs-and-submits via `POST /wallet/broadcasttransaction`. The full [unsigned] transaction (including its + * `raw_data` object, not just `raw_data_hex`) must be echoed back verbatim alongside the signature - sending + * only `raw_data_hex` + `signature` was confirmed live to fail with a deserialization error on TronGrid's side. + * + * @return the transaction hash (`txID`) on success. + * @throws TronApiException if TronGrid rejects the broadcast (invalid signature, insufficient balance, etc.) + */ + suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String + + /** `key -> value` map from `GET /wallet/getchainparameters`, e.g. `getEnergyFee` (sun/energy), `getTransactionFee` (sun/byte). */ + suspend fun getChainParameters(baseUrl: String): Map + + suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse } class RealTronGridApi( @@ -29,6 +98,98 @@ class RealTronGridApi( return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO } + override suspend fun createNativeTransfer( + baseUrl: String, + ownerHexAddress: String, + toHexAddress: String, + amountSun: BigInteger + ): TronUnsignedTransactionResponse { + val request = TronCreateTransactionRequest( + ownerAddress = ownerHexAddress, + toAddress = toHexAddress, + amount = amountSun.toLongExactOrThrow("amount") + ) + + val response = retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request) + + return response.requireConstructed() + } + + override suspend fun triggerConstantContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String + ): TronTriggerContractResponse { + val request = TronTriggerContractRequest( + ownerAddress = ownerHexAddress, + contractAddress = contractHexAddress, + functionSelector = functionSelector, + parameter = parameterHex + ) + + return retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request) + } + + override suspend fun triggerSmartContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String, + feeLimitSun: BigInteger + ): TronTriggerContractResponse { + val request = TronTriggerContractRequest( + ownerAddress = ownerHexAddress, + contractAddress = contractHexAddress, + functionSelector = functionSelector, + parameter = parameterHex, + feeLimit = feeLimitSun.toLongExactOrThrow("feeLimit") + ) + + val response = retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request) + + if (response.result?.result != true) { + throw TronApiException(response.result?.message ?: response.result?.code ?: "triggersmartcontract failed without a message") + } + + // Only validate that a transaction was actually returned - callers read [TronTriggerContractResponse.transaction] themselves. + response.transaction?.requireConstructed() + + return response + } + + override suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String { + val txId = requireNotNull(unsigned.txID) { "Cannot broadcast a transaction without a txID" } + + val request = TronBroadcastRequest( + visible = unsigned.visible ?: false, + txID = txId, + rawData = requireNotNull(unsigned.rawData) { "Cannot broadcast a transaction without raw_data" }, + rawDataHex = requireNotNull(unsigned.rawDataHex) { "Cannot broadcast a transaction without raw_data_hex" }, + signature = listOf(signatureHex) + ) + + val response = retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request) + + if (response.result != true) { + throw TronApiException(response.decodeErrorMessage()) + } + + return response.txid ?: txId + } + + override suspend fun getChainParameters(baseUrl: String): Map { + return retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters")) + .chainParameter + .associate { it.key to it.value } + } + + override suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse { + return retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex)) + } + private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount( url = accountUrl(baseUrl, address) ).data?.firstOrNull() @@ -36,4 +197,27 @@ class RealTronGridApi( private fun accountUrl(baseUrl: String, address: String): String { return "${baseUrl.trimEnd('/')}/v1/accounts/$address" } + + private fun walletUrl(baseUrl: String, method: String): String { + return "${baseUrl.trimEnd('/')}/wallet/$method" + } + + private fun TronUnsignedTransactionResponse.requireConstructed(): TronUnsignedTransactionResponse { + if (error != null) throw TronApiException(error) + requireNotNull(rawDataHex) { "TronGrid returned no raw_data_hex and no Error" } + requireNotNull(txID) { "TronGrid returned no txID and no Error" } + + return this + } + + private fun TronBroadcastResponse.decodeErrorMessage(): String { + val decodedMessage = message?.let { hex -> runCatching { hex.fromHex().decodeToString() }.getOrNull() } + + return decodedMessage ?: code ?: "broadcasttransaction failed without a message" + } + + private fun BigInteger.toLongExactOrThrow(fieldName: String): Long { + return runCatching { longValueExact() } + .getOrElse { throw IllegalArgumentException("$fieldName overflows Long: $this") } + } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt new file mode 100644 index 00000000..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..2568dc1e --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt @@ -0,0 +1,264 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.sha256 +import io.novafoundation.nova.common.utils.toEcdsaSignatureData +import io.novafoundation.nova.common.utils.toTronHexAddress +import io.novafoundation.nova.common.utils.tronAddressToHexAddress +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.TronFee +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import java.math.BigInteger + +private const val ENERGY_FEE_PARAM_KEY = "getEnergyFee" +private const val TRANSACTION_FEE_PARAM_KEY = "getTransactionFee" + +// Fallbacks only used if a live `getchainparameters` call fails or is missing the expected key - both values are +// what was observed live on Shasta testnet at implementation time, which also match Tron's long-standing mainnet +// defaults; the primary path always fetches live values. +private val FALLBACK_ENERGY_FEE_SUN = BigInteger.valueOf(420) +private val FALLBACK_BANDWIDTH_FEE_SUN = BigInteger.valueOf(1000) + +// Used only if a triggerconstantcontract dry run fails outright (e.g. transient network error) and returns no +// energy_used at all - a conservative (intentionally high) stand-in for a simple TRC-20 transfer, which in +// practice costs on the order of 15-30k energy. Mirrors EvmErc20AssetTransfers' ERC_20_UPPER_GAS_LIMIT fallback. +private val FALLBACK_TRC20_ENERGY_UNITS = 65_000L +private val FALLBACK_TRC20_TX_SIZE_BYTES = 350L + +// fee_limit sent with triggersmartcontract: Tron only ever burns what a call actually uses (up to this cap), so +// setting this generously above our own estimate does not cost the user more - it only avoids an OUT_OF_ENERGY +// failure if our estimate undershoots. Bounded above as a sanity guard against a runaway estimate. +private val MIN_FEE_LIMIT_SUN = BigInteger.valueOf(15_000_000) // 15 TRX +private val MAX_FEE_LIMIT_SUN = BigInteger.valueOf(100_000_000) // 100 TRX + +private val EMPTY_RESOURCE = TronAccountResourceResponse() + +/** + * Builds, signs and broadcasts Tron transactions (native TRX and TRC-20) using TronGrid's own REST endpoints for + * construction/broadcast, and this app's existing ECDSA signing primitive for signing - no Tron protobuf + * (`Transaction`/`TransferContract`/`TriggerSmartContract`) encoding and no new crypto library were added. + * + * ## Construction + * - Native TRX: `POST /wallet/createtransaction` with `{owner_address, to_address, amount}` (all hex-encoded, + * `visible: false`). Requires the owner account to already be activated on-chain (confirmed live: an + * unactivated owner gets `{"Error": "... no OwnerAccount."}`) - in practice this is never hit here, since a + * user can only reach the send flow with a positive TRX balance to send from, which itself implies the account + * was already activated by an earlier incoming transfer. + * - TRC-20: `POST /wallet/triggersmartcontract` with the ABI-encoded `transfer(address,uint256)` call (see + * [Trc20TransferAbi]). Unlike `createtransaction`, this was confirmed live to work even for a + * never-activated owner account. + * + * ## Signing + * Tron's signature is `ECDSA_sign(privateKey, sha256(raw_data))` over secp256k1 - the same curve/primitive this + * app already uses for Ethereum. [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw] + * (backed by `substrate_sdk_android`'s `Signer.sign(MultiChainEncryption.Ethereum, message, keypair, skipHashing)` + * -> `web3j`'s `Sign.signMessage(hash, keyPair, needToHash = false)`) already supports signing a pre-computed + * hash directly via `SignerPayloadRaw.skipMessageHashing = true` - this is exactly the primitive Ethereum-style + * raw-hash signing needs, and it is reused as-is here. No new cryptographic code or library was added; only the + * hash fed into it differs from the EVM path (`sha256(raw_data)` here vs. an EIP-155 RLP-based digest there). + * + * `web3j`'s `Sign.signMessage` always left-pads `r`/`s` to exactly 32 bytes and encodes `v` as `27/28` (confirmed + * by reading `web3j`'s `Sign.java` source) - which is byte-for-byte the same `r(32) + s(32) + v(1)` = 65-byte + * compact signature format Tron expects (confirmed against `tronweb`'s own `ECKeySign` implementation, and + * independently confirmed live against Shasta testnet - see the Phase 2 implementation notes). + * + * ## Broadcast + * `POST /wallet/broadcasttransaction` with the full unsigned transaction object (not just `raw_data_hex`) plus + * `signature: [<65-byte hex signature>]`. + */ +class RealTronTransactionService( + private val accountRepository: AccountRepository, + private val signerProvider: SignerProvider, + private val tronGridApi: TronGridApi, +) : TronTransactionService { + + override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val baseUrl = chain.requireTronGridBaseUrl() + val ownerHex = ownerAccountId.toTronHexAddress() + + val feeSun = when (intent) { + is TronTransactionIntent.Native -> estimateNativeFee(baseUrl, ownerHex, recipient, intent.amountSun) + is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex( + baseUrl, + ownerHex, + recipient, + intent.contractAddress.tronAddressToHexAddress(), + intent.amountSun + ) + } + + return TronFee(feeSun, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset) + } + + override suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result = runCatching { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val baseUrl = chain.requireTronGridBaseUrl() + val ownerHex = ownerAccountId.toTronHexAddress() + val recipientHex = recipient.toTronHexAddress() + + val unsigned = when (intent) { + is TronTransactionIntent.Native -> tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipientHex, intent.amountSun) + + is TronTransactionIntent.Trc20Transfer -> { + val contractHex = intent.contractAddress.tronAddressToHexAddress() + val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, intent.amountSun) + val feeLimit = feeLimitFor(presetFee, baseUrl, ownerHex, recipient, contractHex, intent.amountSun) + + tronGridApi.triggerSmartContract( + baseUrl = baseUrl, + ownerHexAddress = ownerHex, + contractHexAddress = contractHex, + functionSelector = Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, + parameterHex = parameterHex, + feeLimitSun = feeLimit + ).transaction ?: error("TronGrid returned no transaction from triggersmartcontract") + } + } + + val txHash = signAndBroadcast(baseUrl, unsigned, submittingMetaAccount, ownerAccountId) + + ExtrinsicSubmission( + hash = txHash, + submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId), + callExecutionType = CallExecutionType.IMMEDIATE, + submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE) + ) + } + + override suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result { + // Tron transactions execute atomically with inclusion (no separate "prepare" step, same as EVM) - so + // successful broadcast is already a strong signal. We do not poll for block confirmation here since + // this method sits outside the primary send flow's critical path (`SendInteractor`/`RealSendUseCase` + // only ever call `transact`, not this) - see Phase 2 implementation notes for what remains unverified. + return transact(chain, origin, recipient, presetFee, intent).map { TransactionExecution.Tron(it.hash) } + } + + private suspend fun signAndBroadcast( + baseUrl: String, + unsigned: TronUnsignedTransactionResponse, + metaAccount: MetaAccount, + ownerAccountId: AccountId + ): String { + val rawDataHex = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" } + val messageHash = rawDataHex.fromHex().sha256() + + check(unsigned.txID == null || messageHash.toHexString(withPrefix = false) == unsigned.txID) { + "sha256(raw_data) does not match the txID TronGrid reported - refusing to sign a possibly-tampered transaction" + } + + val signer = signerProvider.rootSignerFor(metaAccount) + val signedRaw = signer.signRaw(SignerPayloadRaw(message = messageHash, accountId = ownerAccountId, skipMessageHashing = true)) + val signature = signedRaw.toEcdsaSignatureData() + + // Tron's compact signature format is r(32) + s(32) + v(1), v = 27/28 - byte-for-byte identical to what + // web3j's Sign.SignatureData already produces for Ethereum signing (see class doc for verification notes). + val signatureBytes = signature.r + signature.s + signature.v + val signatureHex = signatureBytes.toHexString(withPrefix = false) + + return tronGridApi.broadcastTransaction(baseUrl, unsigned, signatureHex) + } + + private suspend fun feeLimitFor( + presetFee: Fee?, + baseUrl: String, + ownerHex: String, + recipient: AccountId, + contractHex: String, + amountSun: BigInteger + ): BigInteger { + val estimatedFee = presetFee?.castOrNull()?.amount + ?: estimateTrc20FeeFromContractHex(baseUrl, ownerHex, recipient, contractHex, amountSun) + + return (estimatedFee * BigInteger.valueOf(3)).coerceIn(MIN_FEE_LIMIT_SUN, MAX_FEE_LIMIT_SUN) + } + + private suspend fun estimateNativeFee(baseUrl: String, ownerHex: String, recipient: AccountId, amountSun: BigInteger): BigInteger { + val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), amountSun) + val txSizeBytes = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }.length / 2 + + val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE) + val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN) + + val bandwidthShortfall = shortfall(txSizeBytes.toLong(), resource.availableBandwidth()) + + return bandwidthShortfall.toBigInteger() * bandwidthPrice + } + + private suspend fun estimateTrc20FeeFromContractHex( + baseUrl: String, + ownerHex: String, + recipient: AccountId, + contractHex: String, + amountSun: BigInteger + ): BigInteger { + val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, amountSun) + + val dryRun = runCatching { + tronGridApi.triggerConstantContract(baseUrl, ownerHex, contractHex, Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, parameterHex) + }.getOrNull() + + // A dry-run revert (e.g. the sender doesn't yet hold the token) still reports the energy spent up to the + // revert point, which remains a meaningful (if slightly different) estimate - it is used as-is rather + // than special-cased, only a wholly-failed HTTP call falls back to the conservative constant. + val energyUsed = dryRun?.energyUsed ?: FALLBACK_TRC20_ENERGY_UNITS + val txSizeBytes = dryRun?.transaction?.rawDataHex?.let { it.length / 2L } ?: FALLBACK_TRC20_TX_SIZE_BYTES + + val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE) + val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN) + val energyPrice = chainParameterOrDefault(baseUrl, ENERGY_FEE_PARAM_KEY, FALLBACK_ENERGY_FEE_SUN) + + val bandwidthShortfall = shortfall(txSizeBytes, resource.availableBandwidth()) + val energyShortfall = shortfall(energyUsed, resource.availableEnergy()) + + return bandwidthShortfall.toBigInteger() * bandwidthPrice + energyShortfall.toBigInteger() * energyPrice + } + + private suspend fun chainParameterOrDefault(baseUrl: String, key: String, default: BigInteger): BigInteger { + val params = runCatching { tronGridApi.getChainParameters(baseUrl) }.getOrNull() + + return params?.get(key)?.toBigInteger() ?: default + } + + private fun shortfall(needed: Long, available: Long): Long = (needed - available.coerceAtLeast(0)).coerceAtLeast(0) + + private fun TronAccountResourceResponse.availableBandwidth(): Long = + (freeNetLimit - freeNetUsed).coerceAtLeast(0) + (netLimit - netUsed).coerceAtLeast(0) + + private fun TronAccountResourceResponse.availableEnergy(): Long = + (energyLimit - energyUsed).coerceAtLeast(0) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt new file mode 100644 index 00000000..90da3f41 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +/** + * Minimal, hand-written Solidity ABI encoding for the single call this client ever makes to a TRC-20 contract: + * `transfer(address,uint256)`. + * + * There is no pre-existing ABI-encoding utility reused here: Phase 1's TRC-20 balance reads + * (`Trc20AssetBalance`) go through TronGrid's `/v1/accounts` REST endpoint, not an on-chain `balanceOf` call - + * so no prior ABI-encoding code exists in this codebase. + * + * Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad + * each to 32 bytes and concatenate" - no dynamic-type/offset table is needed. The 4-byte function selector is + * intentionally NOT computed client-side: TronGrid accepts the human-readable `function_selector` string + * directly and hashes it server-side (confirmed live against Shasta testnet - a call with + * `function_selector: "transfer(address,uint256)"` and no client-computed selector correctly resolved to the + * standard `a9059cbb` selector in the resulting `raw_data`), which avoids needing a keccak256 implementation here. + */ +object Trc20TransferAbi { + + const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)" + + /** + * @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address - + * ABI-encoded Solidity `address` parameters use the bare 20-byte form, confirmed live). + */ + fun encodeTransferParameters(recipient: AccountId, amountSun: BigInteger): String { + require(recipient.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${recipient.size}" } + require(amountSun.signum() >= 0) { "Amount must not be negative, got $amountSun" } + + val addressParam = recipient.toHexString(withPrefix = false).padStart(64, '0') + val amountParam = amountSun.toString(16).padStart(64, '0') + + return addressParam + amountParam + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt new file mode 100644 index 00000000..2261418d --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +/** + * What kind of Tron transaction to build. Both cases ultimately burn TRX for bandwidth/energy per Tron's default + * protocol behavior - this service never stakes/rents Energy or Bandwidth, it only estimates the automatic burn + * and lets the caller (asset transfer validation) block the send if the user's TRX balance can't cover it. + */ +sealed class TronTransactionIntent { + + class Native(val amountSun: BigInteger) : TronTransactionIntent() + + /** @param contractAddress Base58Check TRC-20 contract address, as stored in chain config (`Type.Trc20.contractAddress`). */ + class Trc20Transfer(val contractAddress: String, val amountSun: BigInteger) : TronTransactionIntent() +} + +/** + * Mirrors [io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService]'s shape + * (calculateFee/transact/transactAndAwaitExecution over a sending origin), but for Tron. Unlike the EVM service, + * this lives entirely in `feature-wallet-impl` rather than being split across `feature-account-api`/`-impl`, + * since (for now, Phase 2 send-only scope) it is only ever consumed by `TronNativeAssetTransfers`/ + * `Trc20AssetTransfers` in this module, and it needs [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi], + * which itself lives in this module (feature-account-impl cannot depend on feature-wallet-impl). + * + * Construction goes through TronGrid's own `/wallet/createtransaction` and `/wallet/triggersmartcontract` + * endpoints rather than hand-rolled protobuf encoding - see `RealTronTransactionService` for details and the + * live-testnet verification notes. + */ +interface TronTransactionService { + + suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee + + suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result + + suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt index dd102795..96f5d25e 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt @@ -37,6 +37,7 @@ import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicServic import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope @@ -74,6 +75,8 @@ interface WalletFeatureDependencies { val evmTransactionService: EvmTransactionService + val signerProvider: SignerProvider + val chainAssetDao: ChainAssetDao val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt index 90f633e5..09608781 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt @@ -4,16 +4,22 @@ import dagger.Module import dagger.Provides import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.trc20.Trc20AssetBalance import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.TronNativeAssetBalance import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory -import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.UnsupportedAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.trc20.Trc20AssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.tronNative.TronNativeAssetTransfers import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.RealTronTransactionService +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService import javax.inject.Qualifier @Qualifier @@ -23,11 +29,17 @@ annotation class TronNativeAssets annotation class Trc20Assets /** - * Tron/TRC-20 support - Phase 1, read-only. + * Tron/TRC-20 support. * - * Only `balance` is implemented for real; `transfers`/`history` reuse the same `Unsupported*` stubs the rest of - * the app uses for asset types with no send/history support yet (see `UnsupportedAssetsModule`). This is - * intentional: no transaction-building/signing code exists for Tron yet - that is future, separate work. + * Phase 1 (read-only): `balance`. + * Phase 2 (send/transfer, this module): `transfers`, via [TronTransactionService] - construction/broadcast + * through TronGrid's own REST endpoints, signing through the app's existing Ethereum-style ECDSA signer (see + * `RealTronTransactionService` for the full verification notes). No Energy/Bandwidth staking or rental: only + * Tron's default protocol behavior (auto-burning TRX when free resources are insufficient) is estimated and + * enforced. + * + * `history` remains unsupported (out of scope for this phase, same as the rest of the app's `Unsupported*` + * stubs used for asset types without history support). */ @Module class TronAssetsModule { @@ -42,6 +54,18 @@ class TronAssetsModule { @FeatureScope fun provideTronGridApi(retrofitTronGridApi: RetrofitTronGridApi): TronGridApi = RealTronGridApi(retrofitTronGridApi) + @Provides + @FeatureScope + fun provideTronTransactionService( + accountRepository: AccountRepository, + signerProvider: SignerProvider, + tronGridApi: TronGridApi, + ): TronTransactionService = RealTronTransactionService( + accountRepository = accountRepository, + signerProvider = signerProvider, + tronGridApi = tronGridApi + ) + @Provides @FeatureScope fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi) @@ -50,15 +74,29 @@ class TronAssetsModule { @FeatureScope fun provideTrc20Balance(assetCache: AssetCache, tronGridApi: TronGridApi) = Trc20AssetBalance(assetCache, tronGridApi) + @Provides + @FeatureScope + fun provideTronNativeAssetTransfers( + tronTransactionService: TronTransactionService, + assetSourceRegistry: AssetSourceRegistry, + ) = TronNativeAssetTransfers(tronTransactionService, assetSourceRegistry) + + @Provides + @FeatureScope + fun provideTrc20AssetTransfers( + tronTransactionService: TronTransactionService, + assetSourceRegistry: AssetSourceRegistry, + ) = Trc20AssetTransfers(tronTransactionService, assetSourceRegistry) + @Provides @TronNativeAssets @FeatureScope fun provideTronNativeAssetSource( tronNativeAssetBalance: TronNativeAssetBalance, - unsupportedAssetTransfers: UnsupportedAssetTransfers, + tronNativeAssetTransfers: TronNativeAssetTransfers, unsupportedAssetHistory: UnsupportedAssetHistory, ): AssetSource = StaticAssetSource( - transfers = unsupportedAssetTransfers, + transfers = tronNativeAssetTransfers, balance = tronNativeAssetBalance, history = unsupportedAssetHistory ) @@ -68,10 +106,10 @@ class TronAssetsModule { @FeatureScope fun provideTrc20AssetSource( trc20AssetBalance: Trc20AssetBalance, - unsupportedAssetTransfers: UnsupportedAssetTransfers, + trc20AssetTransfers: Trc20AssetTransfers, unsupportedAssetHistory: UnsupportedAssetHistory, ): AssetSource = StaticAssetSource( - transfers = unsupportedAssetTransfers, + transfers = trc20AssetTransfers, balance = trc20AssetBalance, history = unsupportedAssetHistory ) diff --git a/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt new file mode 100644 index 00000000..b1d96244 --- /dev/null +++ b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novasama.substrate_sdk_android.extensions.fromHex +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigInteger + +class Trc20TransferAbiTest { + + /** + * Real request/response pair captured live against TronGrid's Shasta testnet + * (`POST https://api.shasta.trongrid.io/wallet/triggerconstantcontract`) for a `transfer(address,uint256)` + * call with recipient accountId `dfd8703a5c753e17ed52a96a29cea9d425538dfe` and amount `1000000` (sun) - + * TronGrid accepted this exact `parameter` value and correctly resolved `function_selector` to the standard + * `a9059cbb` selector in the resulting `raw_data.contract[0].parameter.value.data`. + */ + @Test + fun `encodeTransferParameters should match a live-verified TronGrid request`() { + val recipient = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex() + val amountSun = BigInteger.valueOf(1_000_000) + + val expectedParameter = "000000000000000000000000dfd8703a5c753e17ed52a96a29cea9d425538dfe" + + "00000000000000000000000000000000000000000000000000000000000f4240" + + assertEquals(expectedParameter, Trc20TransferAbi.encodeTransferParameters(recipient, amountSun)) + } + + @Test + fun `encodeTransferParameters should reject a negative amount`() { + val recipient = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex() + + assertThrowsIllegalArgument { + Trc20TransferAbi.encodeTransferParameters(recipient, BigInteger.valueOf(-1)) + } + } + + @Test + fun `encodeTransferParameters should reject a non-20-byte account id`() { + assertThrowsIllegalArgument { + Trc20TransferAbi.encodeTransferParameters(ByteArray(19), BigInteger.ONE) + } + } + + private fun assertThrowsIllegalArgument(block: () -> Unit) { + try { + block() + throw AssertionError("Expected IllegalArgumentException") + } catch (expected: IllegalArgumentException) { + // expected + } + } +} diff --git a/runtime/build.gradle b/runtime/build.gradle index 207a8520..7d2d4e0e 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -13,6 +13,7 @@ android { buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\"" + buildConfigField "String", "TEST_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/pezkuwi_assets_for_testBalance.json\"" buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY") buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY") diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt index 2f7afc1f..51e1a19f 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt @@ -15,6 +15,7 @@ import io.novafoundation.nova.common.utils.findIsInstanceOrNull import io.novafoundation.nova.common.utils.formatNamed import io.novafoundation.nova.common.utils.removeHexPrefix import io.novafoundation.nova.common.utils.emptyTronAccountId +import io.novafoundation.nova.common.utils.isValidTronAddress import io.novafoundation.nova.common.utils.substrateAccountId import io.novafoundation.nova.common.utils.toTronAddress import io.novafoundation.nova.common.utils.tronAddressToAccountId @@ -361,13 +362,19 @@ fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress { fun Chain.isValidAddress(address: String): Boolean { return runCatching { - if (isEthereumBased) { - address.asEthereumAddress().isValid() - } else { - address.toAccountId() // verify supplied address can be converted to account id + when { + // Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two + // branches below would ever accept them, so this needs its own dedicated check. + isTronBased -> address.isValidTronAddress() - addressPrefix.toShort() == address.addressPrefix() || - legacyAddressPrefix?.toShort() == address.addressPrefix() + isEthereumBased -> address.asEthereumAddress().isValid() + + else -> { + address.toAccountId() // verify supplied address can be converted to account id + + addressPrefix.toShort() == address.addressPrefix() || + legacyAddressPrefix?.toShort() == address.addressPrefix() + } } }.getOrDefault(false) } 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..6596531b 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt @@ -10,7 +10,15 @@ val TokenSymbol.mainTokensFirstAscendingOrder "DOT" -> 3 "KSM" -> 4 "USDC" -> 5 - else -> 6 + "BTC" -> 6 + "ETH" -> 7 + "BNB" -> 8 + "TRX" -> 9 + "AVAX" -> 10 + "LINK" -> 11 + "UNI" -> 12 + "TAO" -> 13 + else -> 14 } 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 1ae735b7..9c7885d2 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt @@ -7,7 +7,7 @@ import io.novafoundation.nova.common.utils.RuntimeContext import io.novafoundation.nova.common.utils.diffed import io.novafoundation.nova.common.utils.filterList import io.novafoundation.nova.common.utils.inBackground -import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.mapListNotNull import io.novafoundation.nova.common.utils.mapNotNullToSet import io.novafoundation.nova.common.utils.provideContext import io.novafoundation.nova.common.utils.removeHexPrefix @@ -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..497c6efe 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt @@ -1,7 +1,9 @@ package io.novafoundation.nova.runtime.multiNetwork.chain +import android.util.Log import com.google.gson.Gson import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.LOG_TAG import io.novafoundation.nova.common.utils.retryUntilDone import io.novafoundation.nova.core_db.dao.ChainDao import io.novafoundation.nova.core_db.dao.FullAssetIdLocal @@ -40,6 +42,21 @@ class ChainSyncService( val remoteChains = retryUntilDone { chainFetcher.getChains() } + // A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make + // chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of + // throwing. Applying that as a diff against a populated local DB would delete most or all of the + // user's chains/assets - not a sync failure, but active data loss, for something that self-heals on + // the next successful sync if we just skip applying it. Only guard when we HAD data: an empty result + // on a genuinely first-ever sync is normal and must proceed. + if (oldChains.isNotEmpty() && remoteChains.size < oldChains.size / 2) { + Log.e( + LOG_TAG, + "Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " + + "(would remove more than half). Likely a transient fetch issue - skipping this sync cycle." + ) + return@withContext + } + val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) } val newAssets = remoteChains.flatMap { chain -> chain.assets.map { 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) + } } }