This commit is contained in:
SatoshiQaziMuhammed
2026-07-10 16:36:44 +00:00
committed by GitHub
41 changed files with 2001 additions and 202 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ t.start()
def run(): def run():
os.system('adb wait-for-device') 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) shell=True, stdout=sp.PIPE, stderr=sp.PIPE, stdin=sp.PIPE)
return p.communicate() return p.communicate()
success = re.compile(r'OK \(\d+ tests\)') success = re.compile(r'OK \(\d+ tests\)')
+38
View File
@@ -1,6 +1,7 @@
name: Run balances tests name: Run balances tests
on: on:
pull_request:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: '0 */8 * * *' - cron: '0 */8 * * *'
@@ -43,13 +44,50 @@ jobs:
sudo udevadm control --reload-rules sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm 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 - name: Run tests
id: run-tests-attempt-1
continue-on-error: true
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
with: with:
disable-animations: true disable-animations: true
profile: Nexus 6 profile: Nexus 6
api-level: 29 api-level: 29
arch: x86_64 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 script: .github/scripts/run_balances_test.sh
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
+21 -1
View File
@@ -18,7 +18,27 @@ runs:
- name: Install NDK - name: Install NDK
run: | run: |
SDKMANAGER=$(find ${ANDROID_SDK_ROOT}/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -1) 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 shell: bash
- name: Set ndk.dir in local.properties - name: Set ndk.dir in local.properties
+1
View File
@@ -324,6 +324,7 @@ dependencies {
androidTestImplementation androidTestRunnerDep androidTestImplementation androidTestRunnerDep
androidTestImplementation androidTestRulesDep androidTestImplementation androidTestRulesDep
androidTestImplementation androidJunitDep androidTestImplementation androidJunitDep
androidTestImplementation scalarsConverterDep
androidTestImplementation allureKotlinModel androidTestImplementation allureKotlinModel
androidTestImplementation allureKotlinCommons androidTestImplementation allureKotlinCommons
@@ -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<AssetFixture>)
/**
* 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<Context>()
private val dbApi = FeatureUtils.getFeature<DbApi>(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<AssetsFeatureApi>(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
}
}
@@ -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 <T> 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)
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
buildscript { buildscript {
ext { ext {
// App version // App version
versionName = '1.1.1' versionName = '1.1.2'
versionCode = 1 versionCode = 1
applicationId = "io.pezkuwichain.wallet" applicationId = "io.pezkuwichain.wallet"
@@ -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.asEthereumPublicKey
import io.novasama.substrate_sdk_android.extensions.toAccountId 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 io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger import java.math.BigInteger
@@ -113,3 +114,21 @@ fun String.tronAddressToAccountId(): AccountId {
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
fun emptyTronAccountId() = ByteArray(20) { 1 } 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()
@@ -44,6 +44,18 @@ class TronAddressTest {
assertTrue(decodedBack.contentEquals(accountId)) 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 @Test
fun `isValidTronAddress should accept known good address`() { fun `isValidTronAddress should accept known good address`() {
assertTrue(knownTronAddress.isValidTronAddress()) assertTrue(knownTronAddress.isValidTronAddress())
@@ -164,7 +164,12 @@ abstract class ChainDao {
// ------- Queries ------ // ------- 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 @Transaction
abstract suspend fun getJoinChainInfo(): List<JoinedChainInfo> abstract suspend fun getJoinChainInfo(): List<JoinedChainInfo>
@@ -172,7 +177,7 @@ abstract class ChainDao {
@Transaction @Transaction
abstract suspend fun getAllChainIds(): List<String> abstract suspend fun getAllChainIds(): List<String>
@Query("SELECT * FROM chains") @Query("SELECT * FROM chains ORDER BY rowid")
@Transaction @Transaction
abstract fun joinChainInfoFlow(): Flow<List<JoinedChainInfo>> abstract fun joinChainInfoFlow(): Flow<List<JoinedChainInfo>>
@@ -63,6 +63,19 @@ class SubstrateFee(
override val asset: Chain.Asset override val asset: Chain.Asset
) : Fee ) : 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( class SubstrateFeeBase(
override val amount: BigInteger, override val amount: BigInteger,
override val asset: Chain.Asset override val asset: Chain.Asset
@@ -49,7 +49,9 @@ class RealSecretsMetaAccount(
hasChainAccountIn(chain.id) -> { hasChainAccountIn(chain.id) -> {
val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null 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 MultiChainEncryption.Ethereum
} else { } else {
MultiChainEncryption.substrateFrom(cryptoType) MultiChainEncryption.substrateFrom(cryptoType)
@@ -58,6 +60,8 @@ class RealSecretsMetaAccount(
chain.isEthereumBased -> MultiChainEncryption.Ethereum chain.isEthereumBased -> MultiChainEncryption.Ethereum
chain.isTronBased -> MultiChainEncryption.Ethereum
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
} }
} }
@@ -89,6 +89,10 @@ class BalancesUpdateSystem(
try { try {
updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) } updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) }
} catch (e: Exception) { } 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() emptyFlow()
} }
} }
@@ -2,6 +2,8 @@ package io.novafoundation.nova.feature_assets.presentation.balance.list.view
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.transition.AutoTransition
import android.transition.TransitionManager
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -29,6 +31,10 @@ class PezkuwiDashboardAdapter(
private var model: PezkuwiDashboardModel? = null private var model: PezkuwiDashboardModel? = null
private var trackingLoading: Boolean = false 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) { fun setModel(model: PezkuwiDashboardModel) {
this.model = model this.model = model
notifyChangedIfShown() notifyChangedIfShown()
@@ -41,11 +47,11 @@ class PezkuwiDashboardAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PezkuwiDashboardHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PezkuwiDashboardHolder {
val binding = ItemPezkuwiDashboardBinding.inflate(parent.inflater(), parent, false) 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) { 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 { override fun getItemViewType(position: Int): Int {
@@ -55,7 +61,8 @@ class PezkuwiDashboardAdapter(
class PezkuwiDashboardHolder( class PezkuwiDashboardHolder(
private val binder: ItemPezkuwiDashboardBinding, private val binder: ItemPezkuwiDashboardBinding,
handler: PezkuwiDashboardAdapter.Handler handler: PezkuwiDashboardAdapter.Handler,
private val onExpandedChanged: (Boolean) -> Unit
) : RecyclerView.ViewHolder(binder.root) { ) : RecyclerView.ViewHolder(binder.root) {
companion object : WithViewType { companion object : WithViewType {
@@ -67,14 +74,30 @@ class PezkuwiDashboardHolder(
binder.pezkuwiDashboardSignButton.setOnClickListener { handler.onSignClicked() } binder.pezkuwiDashboardSignButton.setOnClickListener { handler.onSignClicked() }
binder.pezkuwiDashboardShareButton.setOnClickListener { handler.onShareReferralClicked() } binder.pezkuwiDashboardShareButton.setOnClickListener { handler.onShareReferralClicked() }
binder.pezkuwiDashboardStartTrackingButton.setOnClickListener { handler.onStartTrackingClicked() } 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) bindRoles(model.roles)
binder.pezkuwiDashboardTrustValue.text = model.trustScore binder.pezkuwiDashboardTrustValue.text = model.trustScore
binder.pezkuwiDashboardTrustValueCollapsed.text = model.trustScore
binder.pezkuwiDashboardWelatiCount.text = model.welatiCount binder.pezkuwiDashboardWelatiCount.text = model.welatiCount
bindButtons(model.citizenshipStatus) 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 val showTracking = !model.isTrackingScore && model.citizenshipStatus == CitizenshipStatus.APPROVED
binder.pezkuwiDashboardStartTrackingButton.visibility = if (showTracking) View.VISIBLE else View.GONE binder.pezkuwiDashboardStartTrackingButton.visibility = if (showTracking) View.VISIBLE else View.GONE
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Minimal collapsed-state pill: solid brand green, fully rounded -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#009639" />
<corners android:radius="22dp" />
</shape>
@@ -10,151 +10,218 @@
app:strokeWidth="0dp"> app:strokeWidth="0dp">
<LinearLayout <LinearLayout
android:id="@+id/pezkuwiDashboardRoot"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/bg_pezkuwi_dashboard" android:orientation="vertical">
android:orientation="vertical"
android:padding="18dp">
<!-- Header: flame badge + title/roles (left), citizen count (right) --> <!-- Collapsed state: minimal single-line pill showing only Trust Score.
Default/visible on first bind each app session. -->
<LinearLayout <LinearLayout
android:id="@+id/pezkuwiDashboardCollapsedBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="44dp"
android:background="@drawable/bg_pezkuwi_dashboard_collapsed"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal"
android:paddingHorizontal="16dp">
<ImageView <ImageView
android:layout_width="40dp" android:layout_width="18dp"
android:layout_height="40dp" android:layout_height="18dp"
android:layout_marginEnd="12dp" android:layout_marginEnd="8dp"
android:src="@drawable/ic_nevroz_flame" /> android:src="@drawable/ic_nevroz_flame" />
<LinearLayout <TextView
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:orientation="vertical"> android:text="@string/pezkuwi_dashboard_trust_score"
android:textColor="@android:color/white"
android:textSize="13sp"
android:textStyle="bold" />
<TextView <TextView
android:id="@+id/pezkuwiDashboardTitle" android:id="@+id/pezkuwiDashboardTrustValueCollapsed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textColor="@android:color/white"
android:textSize="15sp"
android:textStyle="bold" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_chevron_down"
app:tint="@android:color/white" />
</LinearLayout>
<!-- Expanded state: full card, hidden by default. -->
<LinearLayout
android:id="@+id/pezkuwiDashboardExpandedContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_pezkuwi_dashboard"
android:orientation="vertical"
android:padding="18dp"
android:visibility="gone">
<!-- Header: flame badge + title/roles (left), citizen count + collapse chevron (right) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:orientation="horizontal">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="12dp"
android:src="@drawable/ic_nevroz_flame" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/pezkuwiDashboardTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pezkuwi_dashboard_title"
android:textColor="#E0FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/pezkuwiDashboardRoles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
app:alignItems="center"
app:flexWrap="wrap"
app:justifyContent="flex_start" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/pezkuwi_dashboard_title" android:gravity="end"
android:textColor="#E0FFFFFF" android:orientation="vertical">
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.flexbox.FlexboxLayout <TextView
android:id="@+id/pezkuwiDashboardRoles" android:id="@+id/pezkuwiDashboardWelatiCount"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_gravity="end"
app:alignItems="center" android:textColor="#2FC864"
app:flexWrap="wrap" android:textSize="24sp"
app:justifyContent="flex_start" /> android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/pezkuwi_dashboard_kurds_title"
android:textColor="#7AFFFFFF"
android:textSize="10sp" />
</LinearLayout>
<ImageView
android:id="@+id/pezkuwiDashboardCollapseButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="6dp"
android:src="@drawable/ic_chevron_up"
app:tint="#7AFFFFFF" />
</LinearLayout> </LinearLayout>
<!-- Trust score -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="end" android:layout_marginTop="14dp"
android:orientation="vertical"> android:gravity="center_vertical"
android:orientation="horizontal">
<TextView <TextView
android:id="@+id/pezkuwiDashboardWelatiCount"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="end" android:text="@string/pezkuwi_dashboard_trust_score"
android:textColor="#2FC864" android:textColor="#7AFFFFFF"
android:textSize="24sp" android:textSize="12sp" />
<TextView
android:id="@+id/pezkuwiDashboardTrustValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="#FDB813"
android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <com.google.android.material.button.MaterialButton
android:id="@+id/pezkuwiDashboardStartTrackingButton"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="32dp"
android:layout_gravity="end" android:layout_marginStart="8dp"
android:text="@string/pezkuwi_dashboard_kurds_title" android:minWidth="0dp"
android:textColor="#7AFFFFFF" android:paddingHorizontal="10dp"
android:textSize="10sp" /> 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" />
</LinearLayout> </LinearLayout>
</LinearLayout>
<!-- Trust score -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pezkuwi_dashboard_trust_score"
android:textColor="#7AFFFFFF"
android:textSize="12sp" />
<TextView
android:id="@+id/pezkuwiDashboardTrustValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="#FDB813"
android:textSize="16sp"
android:textStyle="bold" />
<!-- Action buttons -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/pezkuwiDashboardStartTrackingButton" android:id="@+id/pezkuwiDashboardBasvuruButton"
style="@style/Widget.MaterialComponents.Button" android:layout_width="match_parent"
android:layout_width="wrap_content" android:layout_height="48dp"
android:layout_height="32dp" android:layout_marginTop="16dp"
android:layout_marginStart="8dp" android:text="@string/pezkuwi_dashboard_basvuru"
android:minWidth="0dp"
android:paddingHorizontal="10dp"
android:text="@string/pezkuwi_dashboard_start_tracking"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:textSize="11sp" android:textStyle="bold"
android:visibility="gone"
app:backgroundTint="#009639" app:backgroundTint="#009639"
app:cornerRadius="8dp" /> app:cornerRadius="14dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/pezkuwiDashboardSignButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:text="@string/citizenship_sign"
android:textAllCaps="false"
android:textColor="@android:color/white"
app:backgroundTint="#E2231A"
app:cornerRadius="14dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/pezkuwiDashboardShareButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:text="@string/citizenship_share_button"
android:textAllCaps="false"
android:textColor="#E0FFFFFF"
app:backgroundTint="#2A2F45"
app:cornerRadius="14dp"
app:strokeColor="#33999EC7"
app:strokeWidth="1dp" />
</LinearLayout> </LinearLayout>
<!-- Action buttons -->
<com.google.android.material.button.MaterialButton
android:id="@+id/pezkuwiDashboardBasvuruButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:text="@string/pezkuwi_dashboard_basvuru"
android:textAllCaps="false"
android:textColor="@android:color/white"
android:textStyle="bold"
app:backgroundTint="#009639"
app:cornerRadius="14dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/pezkuwiDashboardSignButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:text="@string/citizenship_sign"
android:textAllCaps="false"
android:textColor="@android:color/white"
app:backgroundTint="#E2231A"
app:cornerRadius="14dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/pezkuwiDashboardShareButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:text="@string/citizenship_share_button"
android:textAllCaps="false"
android:textColor="#E0FFFFFF"
app:backgroundTint="#2A2F45"
app:cornerRadius="14dp"
app:strokeColor="#33999EC7"
app:strokeWidth="1dp" />
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@@ -61,6 +61,12 @@ class RealNetworkListAdapterItemFactory(
private fun getConnectingState(network: NetworkState): ConnectionStateModel? { private fun getConnectingState(network: NetworkState): ConnectionStateModel? {
if (network.chain.isDisabled) return null 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) { return when (network.connectionState) {
is SocketStateMachine.State.Connected -> null is SocketStateMachine.State.Connected -> null
@@ -8,4 +8,6 @@ sealed interface TransactionExecution {
class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution
class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution
class Tron(val hash: String) : TransactionExecution
} }
+12
View File
@@ -27,6 +27,17 @@ android {
} }
namespace 'io.novafoundation.nova.feature_wallet_impl' namespace 'io.novafoundation.nova.feature_wallet_impl'
testOptions {
unitTests.all {
testLogging {
events "failed"
exceptionFormat "full"
showCauses true
showStackTraces true
}
}
}
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
@@ -71,6 +82,7 @@ dependencies {
testImplementation jUnitDep testImplementation jUnitDep
testImplementation mockitoDep testImplementation mockitoDep
testImplementation project(':test-shared')
implementation substrateSdkDep implementation substrateSdkDep
@@ -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( override suspend fun startSyncingBalance(
chain: Chain, chain: Chain,
chainAsset: Chain.Asset, chainAsset: Chain.Asset,
@@ -138,40 +142,32 @@ class StatemineAssetBalance(
accountId: AccountId, accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> { ): Flow<BalanceSyncUpdate> {
return runCatching { val runtime = chainRegistry.getRuntime(chain.id)
val runtime = chainRegistry.getRuntime(chain.id)
val statemineType = chainAsset.requireStatemine() val statemineType = chainAsset.requireStatemine()
val encodableAssetId = statemineType.prepareIdForEncoding(runtime) val encodableAssetId = statemineType.prepareIdForEncoding(runtime)
val module = runtime.metadata.statemineModule(statemineType) val module = runtime.metadata.statemineModule(statemineType)
val assetAccountStorage = module.storage("Account") val assetAccountStorage = module.storage("Account")
val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId) val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId)
val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder) val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder)
combine( return combine(
subscriptionBuilder.subscribe(assetAccountKey), subscriptionBuilder.subscribe(assetAccountKey),
assetDetailsFlow.map { it.status.transfersFrozen } assetDetailsFlow.map { it.status.transfersFrozen }
) { balanceStorageChange, isAssetFrozen -> ) { balanceStorageChange, isAssetFrozen ->
val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime) val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime)
val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded) val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded)
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount) val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount)
if (assetChanged) { if (assetChanged) {
BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block) BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block)
} else { } else {
BalanceSyncUpdate.NoCause BalanceSyncUpdate.NoCause
}
}.catch { error ->
Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emit(BalanceSyncUpdate.NoCause)
} }
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to start balance sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emptyFlow()
} }
} }
@@ -1,17 +1,26 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative 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 io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
private const val TRON_BALANCE_POLLING_INTERVAL_MS = 30_000L 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 * 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 * `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 * assets are polled instead of pushed: fetch immediately, then re-fetch on an interval, only emitting when the
* balance actually changed. * 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( internal fun pollingBalanceFlow(
intervalMs: Long = TRON_BALANCE_POLLING_INTERVAL_MS, intervalMs: Long = TRON_BALANCE_POLLING_INTERVAL_MS,
@@ -20,9 +29,11 @@ internal fun pollingBalanceFlow(
var lastEmitted: Balance? = null var lastEmitted: Balance? = null
while (true) { 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 lastEmitted = latest
emit(latest) emit(latest)
} }
@@ -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( override suspend fun startSyncingBalance(
chain: Chain, chain: Chain,
chainAsset: Chain.Asset, chainAsset: Chain.Asset,
@@ -160,13 +171,7 @@ class NativeAssetBalance(
): Flow<BalanceSyncUpdate> { ): Flow<BalanceSyncUpdate> {
val runtime = chainRegistry.getRuntime(chain.id) val runtime = chainRegistry.getRuntime(chain.id)
val key = try { val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId)
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()
}
return subscriptionBuilder.subscribe(key) return subscriptionBuilder.subscribe(key)
.map { change -> .map { change ->
@@ -179,10 +184,6 @@ class NativeAssetBalance(
BalanceSyncUpdate.NoCause 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<BlockchainHold>? { private fun bindBalanceHolds(dynamicInstance: Any?): List<BlockchainHold>? {
@@ -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<ExtrinsicSubmission> {
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<TransactionExecution> {
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)
}
}
@@ -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<ExtrinsicSubmission> {
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<TransactionExecution> {
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
}
}
@@ -1,9 +1,20 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron package io.novafoundation.nova.feature_wallet_impl.data.network.tron
import io.novafoundation.nova.common.data.network.UserAgent 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.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.GET
import retrofit2.http.Headers import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Url import retrofit2.http.Url
interface RetrofitTronGridApi { interface RetrofitTronGridApi {
@@ -11,4 +22,28 @@ interface RetrofitTronGridApi {
@GET @GET
@Headers(UserAgent.NOVA) @Headers(UserAgent.NOVA)
suspend fun getAccount(@Url url: String): TronAccountResponse 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
} }
@@ -1,13 +1,82 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron 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_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 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": "<hex>"}` from `broadcasttransaction`.
*/
class TronApiException(message: String) : Exception(message)
interface TronGridApi { interface TronGridApi {
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: 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<String, Long>
suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse
} }
class RealTronGridApi( class RealTronGridApi(
@@ -29,6 +98,98 @@ class RealTronGridApi(
return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO 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<String, Long> {
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( private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount(
url = accountUrl(baseUrl, address) url = accountUrl(baseUrl, address)
).data?.firstOrNull() ).data?.firstOrNull()
@@ -36,4 +197,27 @@ class RealTronGridApi(
private fun accountUrl(baseUrl: String, address: String): String { private fun accountUrl(baseUrl: String, address: String): String {
return "${baseUrl.trimEnd('/')}/v1/accounts/$address" 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") }
}
} }
@@ -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<String>? = 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<String>,
)
/**
* On success: `{"result": true, "txid": "..."}`.
* On failure: `{"code": "CONTRACT_VALIDATE_ERROR", "txid": "...", "message": "<hex-encoded ascii>"}` - 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<TronChainParameter> = 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,
)
@@ -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<ExtrinsicSubmission> = 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<TransactionExecution> {
// 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<TronFee>()?.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)
}
@@ -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
}
}
@@ -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<ExtrinsicSubmission>
suspend fun transactAndAwaitExecution(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
presetFee: Fee?,
intent: TronTransactionIntent
): Result<TransactionExecution>
}
@@ -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.FeePaymentProviderRegistry
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade 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.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.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
@@ -74,6 +75,8 @@ interface WalletFeatureDependencies {
val evmTransactionService: EvmTransactionService val evmTransactionService: EvmTransactionService
val signerProvider: SignerProvider
val chainAssetDao: ChainAssetDao val chainAssetDao: ChainAssetDao
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
@@ -4,16 +4,22 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope 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.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.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.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.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.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.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.RealTronGridApi
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi 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.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 import javax.inject.Qualifier
@Qualifier @Qualifier
@@ -23,11 +29,17 @@ annotation class TronNativeAssets
annotation class Trc20Assets 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 * Phase 1 (read-only): `balance`.
* the app uses for asset types with no send/history support yet (see `UnsupportedAssetsModule`). This is * Phase 2 (send/transfer, this module): `transfers`, via [TronTransactionService] - construction/broadcast
* intentional: no transaction-building/signing code exists for Tron yet - that is future, separate work. * 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 @Module
class TronAssetsModule { class TronAssetsModule {
@@ -42,6 +54,18 @@ class TronAssetsModule {
@FeatureScope @FeatureScope
fun provideTronGridApi(retrofitTronGridApi: RetrofitTronGridApi): TronGridApi = RealTronGridApi(retrofitTronGridApi) 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 @Provides
@FeatureScope @FeatureScope
fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi) fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi)
@@ -50,15 +74,29 @@ class TronAssetsModule {
@FeatureScope @FeatureScope
fun provideTrc20Balance(assetCache: AssetCache, tronGridApi: TronGridApi) = Trc20AssetBalance(assetCache, tronGridApi) 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 @Provides
@TronNativeAssets @TronNativeAssets
@FeatureScope @FeatureScope
fun provideTronNativeAssetSource( fun provideTronNativeAssetSource(
tronNativeAssetBalance: TronNativeAssetBalance, tronNativeAssetBalance: TronNativeAssetBalance,
unsupportedAssetTransfers: UnsupportedAssetTransfers, tronNativeAssetTransfers: TronNativeAssetTransfers,
unsupportedAssetHistory: UnsupportedAssetHistory, unsupportedAssetHistory: UnsupportedAssetHistory,
): AssetSource = StaticAssetSource( ): AssetSource = StaticAssetSource(
transfers = unsupportedAssetTransfers, transfers = tronNativeAssetTransfers,
balance = tronNativeAssetBalance, balance = tronNativeAssetBalance,
history = unsupportedAssetHistory history = unsupportedAssetHistory
) )
@@ -68,10 +106,10 @@ class TronAssetsModule {
@FeatureScope @FeatureScope
fun provideTrc20AssetSource( fun provideTrc20AssetSource(
trc20AssetBalance: Trc20AssetBalance, trc20AssetBalance: Trc20AssetBalance,
unsupportedAssetTransfers: UnsupportedAssetTransfers, trc20AssetTransfers: Trc20AssetTransfers,
unsupportedAssetHistory: UnsupportedAssetHistory, unsupportedAssetHistory: UnsupportedAssetHistory,
): AssetSource = StaticAssetSource( ): AssetSource = StaticAssetSource(
transfers = unsupportedAssetTransfers, transfers = trc20AssetTransfers,
balance = trc20AssetBalance, balance = trc20AssetBalance,
history = unsupportedAssetHistory history = unsupportedAssetHistory
) )
@@ -0,0 +1,263 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import com.google.gson.JsonObject
import io.novafoundation.nova.common.utils.Precision
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.common.utils.sha256
import io.novafoundation.nova.common.utils.toTronHexAddress
import io.novafoundation.nova.common.utils.tronAddressToAccountId
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.any
import org.mockito.Mockito.argThat
import org.mockito.Mockito.eq
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnitRunner
import java.math.BigInteger
// Deliberately NOT using io.novafoundation.nova.test_shared's eq/any/argThat/whenever here: those are thin
// Kotlin wrappers around the raw org.mockito.Mockito statics, and a Kotlin function with a declared non-null
// generic return type T gets a compiler-inserted null-check on that return value whenever T is inferred as
// non-null at the call site (e.g. eq(baseUrl: String) here) - crashing with "eq(...) must not be null", since
// Mockito.eq()/any() genuinely return null at runtime (that's how their matcher-stack recording works). Calling
// org.mockito.Mockito's statics directly avoids this: Kotlin treats a direct Java static call's return as a
// platform type (T!) and does not insert that check. Fixing test_shared itself would apply project-wide and
// wasn't attempted here (shared-infra change out of scope for this test file).
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
/**
* Covers the native-TRX branch of [RealTronTransactionService.transact] - the sign/broadcast pipeline this class'
* own doc comment describes (sha256(raw_data) signed via the existing Ethereum-style ECDSA-raw-hash primitive,
* assembled as a 65-byte r+s+v signature) had no test at all before this: [Trc20TransferAbiTest] only covers the
* TRC-20 ABI-encoding side, and [TronDerivationTest]/[io.novafoundation.nova.common.utils.TronAddressTest] only
* cover address derivation/formatting, not transaction construction or signing.
*
* The owner address used throughout is the same one [TronDerivationTest] cross-validated against the standard
* BIP39 test mnemonic ("abandon x11 about" at the coin-195 path) and against live TronGrid data - reusing it here
* keeps every Tron test in this codebase anchored to a single, independently-verified real-world address instead
* of an arbitrary one. The recipient and raw_data_hex/txID pair are self-consistent fixtures created for this
* test only (unlike [Trc20TransferAbiTest]'s ABI vector, this raw_data_hex was not captured live against
* TronGrid - constructing a real `TransferContract` protobuf is out of scope here, since this class deliberately
* never encodes one itself, see its class doc) - what matters for these tests is that this service correctly
* hashes/signs/forwards whatever raw_data TronGrid returns, which a self-consistent fixture exercises just as
* well as a live-captured one.
*/
@RunWith(MockitoJUnitRunner::class)
class RealTronTransactionServiceTest {
@Mock
lateinit var accountRepository: AccountRepository
@Mock
lateinit var signerProvider: SignerProvider
@Mock
lateinit var tronGridApi: TronGridApi
@Mock
lateinit var metaAccount: MetaAccount
@Mock
lateinit var signer: NovaSigner
private lateinit var subject: RealTronTransactionService
private val ownerAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId()
private val ownerHex = ownerAccountId.toTronHexAddress()
private val recipientAccountId = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex()
private val recipientHex = recipientAccountId.toTronHexAddress()
private val amountSun = BigInteger.valueOf(1_000_000)
private val baseUrl = "https://api.trongrid.io"
private val chain = tronChain(baseUrl)
// Self-consistent fixture: rawDataHex ++ its own sha256 as txID - see class doc.
private val rawDataHex = "0a027a1e2208d1e2b3f4a5b6c7d840e8c896e8b7325a67080112630a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412320a1541dfd8703a5c753e17ed52a96a29cea9d425538dfe1215415d10da10f5c60a8e2d5e3c0a70e1e7f3c1b2a3e41880ade20470a08fc9c9e8b732"
private val expectedTxId = rawDataHex.fromHex().sha256().toHexString(withPrefix = false)
@Before
fun setup() {
subject = RealTronTransactionService(accountRepository, signerProvider, tronGridApi)
whenever(metaAccount.accountIdIn(eq(chain))).thenReturn(ownerAccountId)
whenever(signerProvider.rootSignerFor(eq(metaAccount))).thenReturn(signer)
}
@Test
fun `transact with Native intent should build, sign with sha256(raw_data), and broadcast a 65-byte r+s+v signature`(): Unit = runBlocking {
val unsigned = TronUnsignedTransactionResponse(
visible = false,
txID = expectedTxId,
rawData = JsonObject(),
rawDataHex = rawDataHex
)
val r = ByteArray(32) { (it + 1).toByte() }
val s = ByteArray(32) { (it + 33).toByte() }
val v = byteArrayOf(27)
val fakeSignedRaw = SignedRaw(
SignerPayloadRaw(message = expectedTxId.fromHex(), accountId = ownerAccountId, skipMessageHashing = true),
SignatureWrapper.Ecdsa(v = v, r = r, s = s)
)
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
whenever(signer.signRaw(any())).thenReturn(fakeSignedRaw)
whenever(tronGridApi.broadcastTransaction(eq(baseUrl), eq(unsigned), any())).thenReturn("some-broadcast-tx-hash")
val result = subject.transact(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipient = recipientAccountId,
presetFee = null,
intent = TronTransactionIntent.Native(amountSun)
)
assertTrue(result.isSuccess)
assertEquals("some-broadcast-tx-hash", result.getOrThrow().hash)
// The message actually handed to the signer must be sha256(raw_data), not raw_data or txID itself - a
// regression here would silently produce a signature over the wrong bytes. ByteArray has reference
// equality in Kotlin, so this must compare contents (contentEquals), not rely on SignerPayloadRaw.equals().
val expectedMessage = rawDataHex.fromHex().sha256()
verify(signer).signRaw(
argThat<SignerPayloadRaw> { payload: SignerPayloadRaw ->
payload.message.contentEquals(expectedMessage) &&
payload.accountId.contentEquals(ownerAccountId) &&
payload.skipMessageHashing
}
)
// Tron expects a flat 65-byte r(32)+s(32)+v(1) hex signature - assembled by hand in production code, not
// by any library, so this is the one place that byte order/length could silently regress.
val expectedSignatureHex = (r + s + v).toHexString(withPrefix = false)
verify(tronGridApi).broadcastTransaction(baseUrl, unsigned, expectedSignatureHex)
}
@Test
fun `transact should refuse to sign when TronGrid's txID does not match sha256(raw_data)`(): Unit = runBlocking {
val tamperedTxId = "0".repeat(64) // deliberately wrong - does not match sha256(rawDataHex)
val unsigned = TronUnsignedTransactionResponse(
visible = false,
txID = tamperedTxId,
rawData = JsonObject(),
rawDataHex = rawDataHex
)
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
val result = subject.transact(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipient = recipientAccountId,
presetFee = null,
intent = TronTransactionIntent.Native(amountSun)
)
assertTrue("expected a failed Result when txID doesn't match sha256(raw_data)", result.isFailure)
// Signing (and therefore broadcasting) must never be attempted once the txID/raw_data mismatch is
// detected - this is the guard that stops a tampered/malicious response from getting silently signed.
verify(signer, never()).signRaw(any())
verify(tronGridApi, never()).broadcastTransaction(any(), any(), any())
}
@Test
fun `calculateFee for Native intent should charge bandwidth shortfall at the fallback price when TronGrid's own resource_endpoints are unavailable`(): Unit = runBlocking {
val unsigned = TronUnsignedTransactionResponse(
visible = false,
txID = expectedTxId,
rawData = JsonObject(),
rawDataHex = rawDataHex
)
val txSizeBytes = rawDataHex.length / 2
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
whenever(tronGridApi.getAccountResource(eq(baseUrl), eq(ownerHex))).thenReturn(TronAccountResourceResponse())
whenever(tronGridApi.getChainParameters(eq(baseUrl))).thenReturn(emptyMap<String, Long>())
val fee = subject.calculateFee(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipient = recipientAccountId,
intent = TronTransactionIntent.Native(amountSun)
)
// Zero available bandwidth (empty TronAccountResourceResponse) -> the whole tx size is billed, at the
// fallback bandwidth price (1000 sun/byte) since getChainParameters returned no getTransactionFee entry.
val expectedFeeSun = BigInteger.valueOf(txSizeBytes.toLong()) * BigInteger.valueOf(1000)
assertEquals(expectedFeeSun, fee.amount)
}
private fun tronChain(baseUrl: String): Chain {
val trxAsset = Chain.Asset(
icon = null,
id = 0,
priceId = "tron",
chainId = "tron:mainnet",
symbol = TokenSymbol("TRX"),
precision = Precision(6),
buyProviders = emptyMap(),
sellProviders = emptyMap(),
staking = emptyList(),
type = Chain.Asset.Type.TronNative,
source = Chain.Asset.Source.DEFAULT,
name = "Tron",
enabled = true
)
return Chain(
id = "tron:mainnet",
name = "Tron",
assets = listOf(trxAsset),
nodes = Chain.Nodes(
autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN,
wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance,
nodes = listOf(Chain.Node(chainId = "tron:mainnet", unformattedUrl = baseUrl, name = "TronGrid", orderId = 0, isCustom = false))
),
explorers = emptyList(),
externalApis = emptyList(),
icon = null,
addressPrefix = 0,
legacyAddressPrefix = null,
types = null,
isEthereumBased = false,
isTronBased = true,
isTestNet = false,
source = Chain.Source.DEFAULT,
hasSubstrateRuntime = false,
pushSupport = false,
hasCrowdloans = false,
supportProxy = false,
governance = emptyList(),
swap = emptyList(),
customFee = emptyList(),
multisigSupport = false,
connectionState = Chain.ConnectionState.FULL_SYNC,
parentId = null,
additional = null
)
}
}
@@ -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
}
}
}
+1
View File
@@ -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", "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_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", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY")
buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY") buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY")
@@ -15,6 +15,7 @@ import io.novafoundation.nova.common.utils.findIsInstanceOrNull
import io.novafoundation.nova.common.utils.formatNamed import io.novafoundation.nova.common.utils.formatNamed
import io.novafoundation.nova.common.utils.removeHexPrefix import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.common.utils.emptyTronAccountId 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.substrateAccountId
import io.novafoundation.nova.common.utils.toTronAddress import io.novafoundation.nova.common.utils.toTronAddress
import io.novafoundation.nova.common.utils.tronAddressToAccountId import io.novafoundation.nova.common.utils.tronAddressToAccountId
@@ -361,13 +362,19 @@ fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress {
fun Chain.isValidAddress(address: String): Boolean { fun Chain.isValidAddress(address: String): Boolean {
return runCatching { return runCatching {
if (isEthereumBased) { when {
address.asEthereumAddress().isValid() // Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two
} else { // branches below would ever accept them, so this needs its own dedicated check.
address.toAccountId() // verify supplied address can be converted to account id isTronBased -> address.isValidTronAddress()
addressPrefix.toShort() == address.addressPrefix() || isEthereumBased -> address.asEthereumAddress().isValid()
legacyAddressPrefix?.toShort() == address.addressPrefix()
else -> {
address.toAccountId() // verify supplied address can be converted to account id
addressPrefix.toShort() == address.addressPrefix() ||
legacyAddressPrefix?.toShort() == address.addressPrefix()
}
} }
}.getOrDefault(false) }.getOrDefault(false)
} }
@@ -10,7 +10,14 @@ val TokenSymbol.mainTokensFirstAscendingOrder
"DOT" -> 3 "DOT" -> 3
"KSM" -> 4 "KSM" -> 4
"USDC" -> 5 "USDC" -> 5
else -> 6 "TRX" -> 6
"BTC" -> 7
"ETH" -> 8
"BNB" -> 9
"AVAX" -> 10
"LINK" -> 11
"TAO" -> 12
else -> 13
} }
val TokenSymbol.alphabeticalOrder val TokenSymbol.alphabeticalOrder
@@ -7,7 +7,7 @@ import io.novafoundation.nova.common.utils.RuntimeContext
import io.novafoundation.nova.common.utils.diffed import io.novafoundation.nova.common.utils.diffed
import io.novafoundation.nova.common.utils.filterList import io.novafoundation.nova.common.utils.filterList
import io.novafoundation.nova.common.utils.inBackground 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.mapNotNullToSet
import io.novafoundation.nova.common.utils.provideContext import io.novafoundation.nova.common.utils.provideContext
import io.novafoundation.nova.common.utils.removeHexPrefix 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 io.novasama.substrate_sdk_android.wsrpc.SocketService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -67,14 +68,37 @@ class ChainRegistry(
private val runtimeSyncService: RuntimeSyncService, private val runtimeSyncService: RuntimeSyncService,
private val web3ApiPool: Web3ApiPool, private val web3ApiPool: Web3ApiPool,
private val gson: Gson 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() 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() .diffed()
.map { diff -> .map { diff ->
diff.removed.forEach { unregisterChain(it) } // Each chain's register/unregister is isolated: one malformed/leftover row (e.g. a chain persisted
diff.newOrUpdated.forEach { chain -> registerChain(chain) } // 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.all
} }
@@ -1,7 +1,9 @@
package io.novafoundation.nova.runtime.multiNetwork.asset package io.novafoundation.nova.runtime.multiNetwork.asset
import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import io.novafoundation.nova.common.utils.CollectionDiffer 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.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.ChainAssetDao import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.ChainDao import io.novafoundation.nova.core_db.dao.ChainDao
@@ -40,6 +42,18 @@ class EvmAssetsSyncService(
new.copy(enabled = old?.enabled ?: ENABLED_DEFAULT_BOOL) 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) val diff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false)
chainAssetDao.updateAssets(diff) chainAssetDao.updateAssets(diff)
} }
@@ -1,7 +1,9 @@
package io.novafoundation.nova.runtime.multiNetwork.chain package io.novafoundation.nova.runtime.multiNetwork.chain
import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import io.novafoundation.nova.common.utils.CollectionDiffer 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.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.ChainDao import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.dao.FullAssetIdLocal import io.novafoundation.nova.core_db.dao.FullAssetIdLocal
@@ -40,6 +42,21 @@ class ChainSyncService(
val remoteChains = retryUntilDone { chainFetcher.getChains() } 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 newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) }
val newAssets = remoteChains.flatMap { chain -> val newAssets = remoteChains.flatMap { chain ->
chain.assets.map { chain.assets.map {
@@ -14,7 +14,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@@ -24,35 +26,51 @@ abstract class ChainUpdaterGroupUpdateSystem(
private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
) : UpdateSystem { ) : 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<Updater<*>>): Flow<Updater.SideEffect> { protected suspend fun runUpdaters(chain: Chain, updaters: Collection<Updater<*>>): Flow<Updater.SideEffect> {
val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata return flow {
val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata
val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) -> val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) ->
scope.invalidationFlow().flatMapLatest { scopeValue -> scope.invalidationFlow().flatMapLatest { scopeValue ->
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
val updatersFlow = scopeUpdaters val updatersFlow = scopeUpdaters
.filter { it.requiredModules.all(runtimeMetadata::hasModule) } .filter { it.requiredModules.all(runtimeMetadata::hasModule) }
.map { updater -> .map { updater ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
(updater as Updater<Any?>).listenForUpdates(subscriptionBuilder, scopeValue) (updater as Updater<Any?>).listenForUpdates(subscriptionBuilder, scopeValue)
.catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) } .catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) }
.flowOn(Dispatchers.Default) .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)
}
} }
} }