merge: reconcile with feature/trc20-tron-send into one combined branch

Bitcoin was branched off main independently instead of continuing on top
of the Tron send branch, which caused real divergence: both branches touch
the same shared signing/DI code (multiChainEncryptionFor, getMetaAccountKeypair,
NodeHealthStateTesterFactory, AssetsModule/TypeBasedAssetSourceRegistry,
CloudBackup schema, Fee model), and both need to ship together in one
coordinated release once wallet-util's master is unblocked. Merging now
reconciles that before it gets any harder, and gives one branch/versionCode
history for device testing instead of two that silently diverge.

Conflict resolutions of note:
- SecretStoreV2/SecretsSigner: getKeypair and multiChainEncryptionFor now
  recognize Tron AND Bitcoin accounts (isTronBased/isBitcoinBased both
  threaded through, checked before the Ethereum fallback).
- AccountDataSourceImpl: both address-backfill migrations now run
  sequentially in one coroutine (Tron's fix for a race against the legacy
  migration applies equally to Bitcoin's backfill).
- CloudBackup: kept Tron's forward-looking Solana schema reservation
  alongside Tron's and Bitcoin's now-real fields.
- ChainRegistryModule/NodeHealthStateTesterFactory: adopted Tron's
  self-contained short-lived OkHttpClient (simpler than threading the
  shared app-wide client through RuntimeDependencies, which is reverted).
- RealSecretsMetaAccount: extended Tron's chain-account MultiChainEncryption
  fix to also cover Bitcoin, for the same underlying reason.
- ChainExt.isValidAddress: Tron's branch independently closed the
  pre-existing Tron validation gap; kept alongside Bitcoin's own check.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-07-12 19:02:08 -07:00
commit 60493f9aeb
62 files changed
+2704 -118

No files matched your search

+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,97 @@
package io.novafoundation.nova.balances
import io.novafoundation.nova.common.utils.tronAddressToAccountId
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.math.BigInteger
/**
* Tron is a REST API (TronGrid), not a Substrate runtime - it has no ChainConnection/RuntimeProvider and isn't
* reachable through [BalancesIntegrationTest]'s chainRegistry-based mechanism at all. This exercises the exact
* same [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi] the production app uses for
* balance reads (see TronNativeAssetBalance/Trc20AssetBalance), just via a standalone Retrofit client instead of
* the full DI graph, since TronGridApi isn't exposed through a public feature API for tests to reach.
*
* Test account is the mainnet Founder's Tron address, verified live via TronGrid's public API on 2026-07-09 to
* hold a substantial non-zero balance of both native TRX and TRC-20 USDT - not a guessed or empty account.
*/
class TronBalancesIntegrationTest {
private val testAddress = "TDGZ4GfvCRe1d8oksj8fBD77ZHw4bkCPBA"
private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
private val baseUrl = "https://api.trongrid.io"
// A real wallet that received exactly 5 USDT-TRC20 (2026-07-11) and has NEVER had any native TRX/other
// on-chain activity - confirmed live to have no Account object at all (`/v1/accounts` returns `data: []`)
// despite genuinely holding the token (`balanceOf` correctly returns 5000000). Regression coverage for the
// exact bug this uncovered: fetchTrc20Balance used to read through `/v1/accounts` and silently returned 0
// for any address in this state, well after it was live and had already deceived a real user mid-transfer.
private val unactivatedHolderAddress = "TUdvwdGeqcag51XkhgRK21KmhH2qw37LZG"
private val unactivatedHolderExpectedUsdtBalance = BigInteger.valueOf(5_000_000L)
private val maxAmount = BigInteger.valueOf(10).pow(30)
private val tronGridApi = run {
val retrofit = Retrofit.Builder()
.client(OkHttpClient.Builder().build())
.baseUrl(baseUrl)
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.build()
RealTronGridApi(retrofit.create(RetrofitTronGridApi::class.java))
}
// TronGrid's public (no API key) endpoint rate-limits aggressively, and this test now runs on every PR
// (see balances_test.yml) in addition to its own 2 calls back-to-back - a bare 429 previously failed the
// whole run for a transient, infrastructure-level reason unrelated to whether the wallet's code is correct.
// Retry with backoff instead of just tolerating the flake.
private suspend fun <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.tronAddressToAccountId(), usdtContractAddress) }
assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance)
assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance)
}
@Test
fun testTrc20BalanceLoadingForNeverActivatedHolder() = runBlocking {
val freeBalance = retryOn429 {
tronGridApi.fetchTrc20Balance(baseUrl, unactivatedHolderAddress.tronAddressToAccountId(), usdtContractAddress)
}
assertTrue(
"USDT-TRC20 balance for a never-activated holder: expected $unactivatedHolderExpectedUsdtBalance, got $freeBalance",
freeBalance == unactivatedHolderExpectedUsdtBalance
)
}
}
+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"
@@ -156,24 +156,28 @@ val AccountSecrets.isChainAccountSecrets
suspend fun SecretStoreV2.getMetaAccountKeypair( suspend fun SecretStoreV2.getMetaAccountKeypair(
metaId: Long, metaId: Long,
isEthereum: Boolean, isEthereum: Boolean,
isTron: Boolean = false,
isBitcoin: Boolean = false, isBitcoin: Boolean = false,
): Keypair = withContext(Dispatchers.Default) { ): Keypair = withContext(Dispatchers.Default) {
val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId)
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isBitcoin) mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin)
} }
fun mapMetaAccountSecretsToKeypair( fun mapMetaAccountSecretsToKeypair(
secrets: EncodableStruct<MetaAccountSecrets>, secrets: EncodableStruct<MetaAccountSecrets>,
ethereum: Boolean, ethereum: Boolean,
tron: Boolean = false,
bitcoin: Boolean = false, bitcoin: Boolean = false,
): Keypair { ): Keypair {
val keypairStruct = if (bitcoin) { // Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different
secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret() // SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get
} else if (ethereum) { // signed with the wrong (Ethereum) private key.
secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() val keypairStruct = when {
} else { tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret()
secrets[MetaAccountSecrets.SubstrateKeypair] bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret()
ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
else -> secrets[MetaAccountSecrets.SubstrateKeypair]
} }
return mapKeypairStructToKeypair(keypairStruct) return mapKeypairStructToKeypair(keypairStruct)
@@ -207,6 +211,8 @@ private fun noEthereumSecret(): Nothing = error("No ethereum keypair found")
private fun noBitcoinSecret(): Nothing = error("No bitcoin keypair found") private fun noBitcoinSecret(): Nothing = error("No bitcoin keypair found")
private fun noTronSecret(): Nothing = error("No tron keypair found")
fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair { fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair {
return Keypair( return Keypair(
publicKey = struct[KeyPairSchema.PublicKey], publicKey = struct[KeyPairSchema.PublicKey],
@@ -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>>
@@ -131,6 +131,32 @@ class MetaAccountLocal(
} }
} }
// We do not use copy as we need explicitly set id
fun addTronAccount(
tronPublicKey: ByteArray,
tronAddress: ByteArray,
): MetaAccountLocal {
return MetaAccountLocal(
substratePublicKey = substratePublicKey,
substrateCryptoType = substrateCryptoType,
substrateAccountId = substrateAccountId,
ethereumPublicKey = ethereumPublicKey,
ethereumAddress = ethereumAddress,
name = name,
parentMetaId = parentMetaId,
isSelected = isSelected,
position = position,
type = type,
status = status,
globallyUniqueId = globallyUniqueId,
typeExtras = typeExtras,
tronPublicKey = tronPublicKey,
tronAddress = tronAddress
).also {
it.id = id
}
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is MetaAccountLocal) return false if (other !is MetaAccountLocal) return false
@@ -75,6 +75,19 @@ class BitcoinFee(
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
+10
View File
@@ -13,6 +13,16 @@ android {
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
testOptions {
unitTests {
// android.util.Log.* throws "Method ... not mocked" by default in plain JVM unit tests (no real
// Android framework, no Robolectric) - TronAddressBackfillMigrationTest is the first test in this
// module to exercise code that calls Log.d/Log.e. This makes those stubbed calls return harmless
// defaults instead of throwing, which is what the framework's own stubs are meant for in this context.
returnDefaultValues = true
}
}
} }
dependencies { dependencies {
@@ -19,6 +19,8 @@ import io.novafoundation.nova.common.data.secrets.v2.publicKey
import io.novafoundation.nova.common.data.secrets.v2.seed import io.novafoundation.nova.common.data.secrets.v2.seed
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.common.utils.filterNotNull import io.novafoundation.nova.common.utils.filterNotNull
import io.novafoundation.nova.common.utils.findById import io.novafoundation.nova.common.utils.findById
import io.novafoundation.nova.common.utils.mapToSet import io.novafoundation.nova.common.utils.mapToSet
@@ -93,6 +95,7 @@ class RealLocalAccountsCloudBackupFacade(
ethereum = baseSecrets.getEthereumBackupSecrets(), ethereum = baseSecrets.getEthereumBackupSecrets(),
chainAccounts = emptyList(), chainAccounts = emptyList(),
bitcoin = baseSecrets.getBitcoinBackupSecrets(), bitcoin = baseSecrets.getBitcoinBackupSecrets(),
tron = baseSecrets.getTronBackupSecrets(),
) )
return CloudBackup( return CloudBackup(
@@ -361,6 +364,7 @@ class RealLocalAccountsCloudBackupFacade(
ethereum = baseSecrets.getEthereumBackupSecrets(), ethereum = baseSecrets.getEthereumBackupSecrets(),
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets, chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
bitcoin = baseSecrets.getBitcoinBackupSecrets(), bitcoin = baseSecrets.getBitcoinBackupSecrets(),
tron = baseSecrets.getTronBackupSecrets(),
) )
} }
@@ -472,7 +476,9 @@ class RealLocalAccountsCloudBackupFacade(
ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(), ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(),
ethereumDerivationPath = ethereum?.derivationPath, ethereumDerivationPath = ethereum?.derivationPath,
bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(), bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(),
bitcoinDerivationPath = bitcoin?.derivationPath bitcoinDerivationPath = bitcoin?.derivationPath,
tronKeypair = tron?.keypair?.toLocalKeyPair(),
tronDerivationPath = tron?.derivationPath
) )
} }
@@ -494,6 +500,15 @@ class RealLocalAccountsCloudBackupFacade(
) )
} }
private fun EncodableStruct<MetaAccountSecrets>?.getTronBackupSecrets(): CloudBackup.WalletPrivateInfo.TronSecrets? {
if (this == null) return null
return CloudBackup.WalletPrivateInfo.TronSecrets(
keypair = tronKeypair?.toBackupKeypairSecrets() ?: return null,
derivationPath = tronDerivationPath
)
}
private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? { private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? {
if (this == null) return null if (this == null) return null
@@ -537,7 +552,9 @@ class RealLocalAccountsCloudBackupFacade(
type = metaAccount.type.toBackupWalletType() ?: return null, type = metaAccount.type.toBackupWalletType() ?: return null,
chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) }, chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) },
bitcoinAddress = metaAccount.bitcoinAddress, bitcoinAddress = metaAccount.bitcoinAddress,
bitcoinPublicKey = metaAccount.bitcoinPublicKey bitcoinPublicKey = metaAccount.bitcoinPublicKey,
tronAddress = metaAccount.tronAddress,
tronPublicKey = metaAccount.tronPublicKey,
) )
} }
@@ -561,7 +578,9 @@ class RealLocalAccountsCloudBackupFacade(
status = MetaAccountLocal.Status.ACTIVE, status = MetaAccountLocal.Status.ACTIVE,
typeExtras = null, typeExtras = null,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey bitcoinPublicKey = bitcoinPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
).also { ).also {
if (localIdOverwrite != null) { if (localIdOverwrite != null) {
it.id = localIdOverwrite it.id = localIdOverwrite
@@ -31,6 +31,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTy
import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData
import io.novafoundation.nova.runtime.ext.accountIdOf import io.novafoundation.nova.runtime.ext.accountIdOf
@@ -65,17 +66,40 @@ class AccountDataSourceImpl(
private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
secretStoreV1: SecretStoreV1, secretStoreV1: SecretStoreV1,
accountDataMigration: AccountDataMigration, accountDataMigration: AccountDataMigration,
private val bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration, tronAddressBackfillMigration: TronAddressBackfillMigration,
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
) : AccountDataSource, SecretStoreV1 by secretStoreV1 { ) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
init { init {
migrateIfNeeded(accountDataMigration) // Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin backfills read
async { bitcoinAddressBackfillMigration.migrate() } // accounts/secrets that the legacy migration may still be in the middle of writing for very old
// (pre-MetaAccount) installs, and separate GlobalScope.launch calls give no ordering guarantee
// relative to each other.
async {
Log.d("AccountDataSourceImpl", "migrations block starting")
if (accountDataMigration.migrationNeeded()) {
accountDataMigration.migrate(::saveSecuritySource)
} }
private fun migrateIfNeeded(migration: AccountDataMigration) = async { Log.d("AccountDataSourceImpl", "about to run tronAddressBackfillMigration")
if (migration.migrationNeeded()) {
migration.migrate(::saveSecuritySource) tronAddressBackfillMigration.migrate()
Log.d("AccountDataSourceImpl", "about to run bitcoinAddressBackfillMigration")
bitcoinAddressBackfillMigration.migrate()
Log.d("AccountDataSourceImpl", "migrations block done")
metaAccountDao.getMetaAccounts().forEach {
Log.d(
"TronDiag",
"metaId=${it.id} name=${it.name} type=${it.type} isSelected=${it.isSelected} " +
"tronAddress=${it.tronAddress?.joinToString("") { b -> "%02x".format(b) } ?: "NULL"} " +
"tronPublicKey=${if (it.tronPublicKey != null) "present(${it.tronPublicKey!!.size}B)" else "NULL"}"
)
}
} }
} }
@@ -0,0 +1,128 @@
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
import android.util.Log
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.entropy
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
import io.novafoundation.nova.common.data.secrets.v2.seed
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.dao.updateMetaAccount
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
import io.novafoundation.nova.feature_account_impl.data.secrets.TRON_DEFAULT_DERIVATION_PATH
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "TronAddressBackfill"
/**
* Idempotent, per-account backfill for accounts that don't yet have a Tron keypair - both accounts created
* before Tron support existed, AND accounts whose Tron keypair was lost some other way (e.g. the cloud-backup
* schema round trip that used to silently drop it before every wallet had a `tron` field to serialize into -
* see CloudBackup.kt). `73_74_AddTronSupport` (the migration that added `meta_accounts.tronPublicKey`/
* `tronAddress`) is, like every other migration in this codebase, pure `ALTER TABLE` - it never derives a value
* for pre-existing rows.
*
* Deliberately has NO "have I already run once" flag: an earlier version of this class gated itself behind a
* one-shot SharedPreferences flag, which meant that once it ran and marked itself done - even for an account
* that legitimately still lacked a Tron keypair afterwards (e.g. because a *different*, since-fixed bug kept
* re-losing it) - it would never run again for that account, ever, on that install. [backfillIfNeeded] is cheap
* to call for an account that doesn't need it (a handful of null-checks, no derivation), so this just runs
* unconditionally on every app start instead: self-healing by construction, no stuck-flag failure mode possible.
*
* Only touches accounts that are:
* - `Type.SECRETS` (mnemonic-derived) - watch-only/Ledger/Json/multisig/proxied accounts never had a
* Tron-capable mnemonic and are correctly left with `tronAddress == null` forever, same as they already are
* for Ethereum.
* - still holding their `Entropy` in [SecretStoreV2] - an account imported from a raw seed/keypair rather than
* a mnemonic has no entropy either and is likewise correctly left alone.
* - missing a `TronKeypair` - i.e. not already backfilled and not created after Tron support shipped.
*
* The Tron keypair is derived via [AccountSecretsFactory.chainAccountSecrets] with `isEthereum = true` (Tron
* reuses the exact same secp256k1/BIP32 derivation as Ethereum, just under its own SLIP-44 coin-type-195 path -
* see that class's own doc comment) at [TRON_DEFAULT_DERIVATION_PATH], the same call this codebase's own
* `metaAccountSecrets()` makes for a fresh account - so a backfilled account ends up with byte-for-byte the
* same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation.
*/
class TronAddressBackfillMigration(
private val secretStoreV2: SecretStoreV2,
private val metaAccountDao: MetaAccountDao,
private val accountSecretsFactory: AccountSecretsFactory,
) {
suspend fun migrate() = withContext(Dispatchers.Default) {
val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS }
Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}")
secretsAccounts.forEach { account ->
try {
backfillIfNeeded(account)
} catch (e: Throwable) {
Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e)
}
}
Log.d(TAG, "migrate() done")
}
private suspend fun backfillIfNeeded(account: MetaAccountLocal) {
val secrets = secretStoreV2.getMetaAccountSecrets(account.id)
if (secrets == null) {
Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping")
return
}
val entropy = secrets.entropy
if (entropy == null) {
Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping")
return
}
if (secrets.tronKeypair != null) {
Log.d(TAG, "metaId=${account.id}: already has a TronKeypair - skipping")
return
}
val substrateCryptoType = account.substrateCryptoType
if (substrateCryptoType == null) {
Log.d(TAG, "metaId=${account.id}: substrateCryptoType is null - skipping")
return
}
Log.d(TAG, "metaId=${account.id}: deriving Tron keypair")
val mnemonic = MnemonicCreator.fromEntropy(entropy).words
val tronChainSecrets = accountSecretsFactory.chainAccountSecrets(
derivationPath = TRON_DEFAULT_DERIVATION_PATH,
accountSource = AccountSecretsFactory.AccountSource.Mnemonic(substrateCryptoType, mnemonic),
isEthereum = true
).secrets
val tronKeypair = mapKeypairStructToKeypair(tronChainSecrets[ChainAccountSecrets.Keypair])
val updatedSecrets = MetaAccountSecrets(
substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair),
entropy = secrets.entropy,
substrateSeed = secrets.seed,
substrateDerivationPath = secrets.substrateDerivationPath,
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
ethereumDerivationPath = secrets.ethereumDerivationPath,
tronKeypair = tronKeypair,
tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH
)
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
val tronAccountId = tronKeypair.publicKey.tronPublicKeyToAccountId()
metaAccountDao.updateMetaAccount(account.id) { it.addTronAccount(tronKeypair.publicKey, tronAccountId) }
Log.d(TAG, "metaId=${account.id}: backfilled successfully, tronAddress set (${tronAccountId.size} bytes)")
}
}
@@ -142,12 +142,15 @@ class SecretsSigner(
private suspend fun getKeypair(accountId: AccountId): Keypair { private suspend fun getKeypair(accountId: AccountId): Keypair {
val chainsById = chainRegistry.chainsById() val chainsById = chainRegistry.chainsById()
val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!! val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!!
val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true
val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
return secretStoreV2.getKeypair( return secretStoreV2.getKeypair(
metaAccount = metaAccount, metaAccount = metaAccount,
accountId = accountId, accountId = accountId,
isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum, isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum,
isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true isTronBased = isTronBased,
isBitcoinBased = isBitcoinBased
) )
} }
@@ -161,11 +164,12 @@ class SecretsSigner(
metaAccount: MetaAccount, metaAccount: MetaAccount,
accountId: AccountId, accountId: AccountId,
isEthereumBased: Boolean, isEthereumBased: Boolean,
isTronBased: Boolean = false,
isBitcoinBased: Boolean = false, isBitcoinBased: Boolean = false,
) = if (hasChainSecrets(metaAccount.id, accountId)) { ) = if (hasChainSecrets(metaAccount.id, accountId)) {
getChainAccountKeypair(metaAccount.id, accountId) getChainAccountKeypair(metaAccount.id, accountId)
} else { } else {
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isBitcoinBased) getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased)
} }
/** /**
@@ -175,6 +179,11 @@ class SecretsSigner(
return when { return when {
substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum
// Tron and Bitcoin both reuse the exact same secp256k1 signing scheme as Ethereum - they just have
// their own accountId (different SLIP-44 derivation path), so neither matches ethereumAccountId()
// above and was falling through to the chainAccounts lookup, which doesn't cover them either ->
// null -> NPE on `!!`.
tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
else -> { else -> {
val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null
@@ -105,6 +105,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Re
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule
@@ -403,6 +404,7 @@ class AccountFeatureModule {
nodeDao: NodeDao, nodeDao: NodeDao,
secretStoreV1: SecretStoreV1, secretStoreV1: SecretStoreV1,
accountDataMigration: AccountDataMigration, accountDataMigration: AccountDataMigration,
tronAddressBackfillMigration: TronAddressBackfillMigration,
metaAccountDao: MetaAccountDao, metaAccountDao: MetaAccountDao,
secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
secretStoreV2: SecretStoreV2, secretStoreV2: SecretStoreV2,
@@ -419,6 +421,7 @@ class AccountFeatureModule {
secretsMetaAccountLocalFactory, secretsMetaAccountLocalFactory,
secretStoreV1, secretStoreV1,
accountDataMigration, accountDataMigration,
tronAddressBackfillMigration,
bitcoinAddressBackfillMigration bitcoinAddressBackfillMigration
) )
} }
@@ -452,6 +455,16 @@ class AccountFeatureModule {
return AccountDataMigration(preferences, encryptedPreferences, accountDao) return AccountDataMigration(preferences, encryptedPreferences, accountDao)
} }
@Provides
@FeatureScope
fun provideTronAddressBackfillMigration(
secretStoreV2: SecretStoreV2,
metaAccountDao: MetaAccountDao,
accountSecretsFactory: AccountSecretsFactory,
): TronAddressBackfillMigration {
return TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
}
@Provides @Provides
@FeatureScope @FeatureScope
fun provideExternalAccountActions( fun provideExternalAccountActions(
@@ -53,7 +53,10 @@ 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 and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see
// RealTronTransactionService/RealBitcoinTransactionService's use of
// Signer.sign(MultiChainEncryption.Ethereum, ...).
if (chain.isEthereumBased || chain.isTronBased || chain.isBitcoinBased) {
MultiChainEncryption.Ethereum MultiChainEncryption.Ethereum
} else { } else {
MultiChainEncryption.substrateFrom(cryptoType) MultiChainEncryption.substrateFrom(cryptoType)
@@ -62,6 +65,10 @@ class RealSecretsMetaAccount(
chain.isEthereumBased -> MultiChainEncryption.Ethereum chain.isEthereumBased -> MultiChainEncryption.Ethereum
chain.isTronBased -> MultiChainEncryption.Ethereum
chain.isBitcoinBased -> MultiChainEncryption.Ethereum
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
} }
} }
@@ -7,6 +7,8 @@ import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.entropy import io.novafoundation.nova.common.data.secrets.v2.entropy
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.core.model.CryptoType import io.novafoundation.nova.core.model.CryptoType
import io.novafoundation.nova.core_db.dao.MetaAccountDao import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal
@@ -536,6 +538,64 @@ class RealLocalAccountsCloudBackupFacadeTest {
verifyEvent(expectedEvent) verifyEvent(expectedEvent)
} }
// Regression test for a wallet's Tron address/keypair being silently dropped on a cloud backup round trip:
// WalletPublicInfo/WalletPrivateInfo had no tron field at all until this fix, so a backup written from a
// wallet that genuinely had a Tron address, once applied back to local (e.g. after "clear all data" +
// restore, or on a fresh install created via the cloud-backup-first-wallet flow), landed with
// tronAddress/tronPublicKey/tronKeypair = null - found via real-device testing, not by this test.
@Test
fun shouldApplyAddAccountDiffWithTronSecrets() = runBlocking {
LocalAccountsMocker.setupMocks(metaAccountDao) {}
SecretStoreMocker.setupMocks(secretStore) {}
allChainsAreEvm(false)
val localBackup = buildTestCloudBackup {
publicData { }
privateData { }
}
val bytes32 = bytes32of(0)
val tronAddressBytes = bytes20of(1)
val tronDerivationPath = "//44//195//0/0/0"
val cloudBackup = buildTestCloudBackup {
publicData {
wallet(walletUUid(0)) {
substrateAccountId(bytes32)
substrateCryptoType(CryptoType.SR25519)
substratePublicKey(bytes32)
tronPublicKey(bytes32)
tronAddress(tronAddressBytes)
}
}
privateData {
wallet(walletUUid(0)) {
entropy(bytes32)
substrate {
seed(bytes32)
keypair(KeyPairSecrets(bytes32, bytes32, bytes32))
}
tron {
derivationPath(tronDerivationPath)
keypair(KeyPairSecrets(bytes32, bytes32, nonce = null))
}
}
}
}
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal())
facade.applyBackupDiff(diff, cloudBackup)
verify(metaAccountDao).insertMetaAccount(metaAccountWithTronAddress(tronAddressBytes))
verify(secretStore).putMetaAccountSecrets(eq(0), metaAccountSecretsWithTronDerivationPath(tronDerivationPath))
}
@Test @Test
fun shouldApplyRemoveAccountDiff(): Unit = runBlocking { fun shouldApplyRemoveAccountDiff(): Unit = runBlocking {
allChainsAreEvm(false) allChainsAreEvm(false)
@@ -1205,6 +1265,14 @@ class RealLocalAccountsCloudBackupFacadeTest {
return argThat { it.globallyUniqueId == id } return argThat { it.globallyUniqueId == id }
} }
private fun metaAccountWithTronAddress(tronAddress: ByteArray): MetaAccountLocal {
return argThat { it.tronAddress.contentEquals(tronAddress) }
}
private fun metaAccountSecretsWithTronDerivationPath(derivationPath: String): EncodableStruct<MetaAccountSecrets> {
return argThat { it.tronDerivationPath == derivationPath && it.tronKeypair != null }
}
private suspend fun verifyNoAdditionalSecretsInserted() { private suspend fun verifyNoAdditionalSecretsInserted() {
verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any()) verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any())
} }
@@ -0,0 +1,192 @@
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.common.utils.invoke
import io.novafoundation.nova.common.utils.tronAddressToAccountId
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
import io.novafoundation.nova.core.model.CryptoType
import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import io.novasama.substrate_sdk_android.scale.EncodableStruct
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatcher
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnitRunner
// Same guaranteed-non-null-return wrappers as RealTronTransactionServiceTest, and for the same reason: the
// shared test_shared eq()/any() helpers crash with "eq(...) must not be null" once Mockito's genuinely-null
// runtime return flows into a Kotlin non-null-typed parameter - see that test's class doc for the full writeup.
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
@Suppress("UNCHECKED_CAST")
private fun <T> any(): T {
Mockito.any<T>()
return null as T
}
@Suppress("UNCHECKED_CAST")
private fun <T> argThat(matcher: (T) -> Boolean): T {
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
return null as T
}
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
/**
* This is the money-safety-critical half of the Tron backfill fix (see TronAddressBackfillMigration's class
* doc for the full context): a wrong derivation here would silently give a pre-existing wallet a Tron address
* it does NOT actually control, or fail to skip an account it shouldn't touch. Uses a real (non-mocked)
* AccountSecretsFactory so the actual BIP32/secp256k1 derivation math runs for real, and asserts against the
* same live-TronGrid-cross-validated reference address TronDerivationTest already established for the standard
* BIP39 test mnemonic, so this test and that one are pinned to the same known-good vector.
*/
@RunWith(MockitoJUnitRunner::class)
class TronAddressBackfillMigrationTest {
@Mock
lateinit var secretStoreV2: SecretStoreV2
@Mock
lateinit var metaAccountDao: MetaAccountDao
@Mock
lateinit var jsonDecoder: JsonDecoder
private lateinit var subject: TronAddressBackfillMigration
private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
private val expectedTronAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId()
@Before
fun setup() {
// Real factory, not a mock - the whole point of this test is to exercise the actual derivation.
val accountSecretsFactory = AccountSecretsFactory(jsonDecoder)
subject = TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
}
@Test
fun `migrate should derive and persist the well-known reference Tron address for a pre-existing mnemonic account`(): Unit = runBlocking {
val account = secretsAccount(id = 42, substrateCryptoType = CryptoType.SR25519)
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = null)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(metaAccountDao.getMetaAccount(eq(42L))).thenReturn(account)
whenever(secretStoreV2.getMetaAccountSecrets(eq(42L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao).updateMetaAccount(
argThat<MetaAccountLocal> { updated ->
updated.id == 42L && updated.tronAddress.contentEquals(expectedTronAccountId)
}
)
verify(secretStoreV2).putMetaAccountSecrets(
eq(42L),
argThat<EncodableStruct<MetaAccountSecrets>> { updatedSecrets ->
val tronKeypairStruct = updatedSecrets.tronKeypair
tronKeypairStruct != null &&
mapKeypairStructToKeypair(tronKeypairStruct).publicKey.tronPublicKeyToAccountId().contentEquals(expectedTronAccountId)
}
)
}
@Test
fun `migrate should skip an account that already has a Tron keypair`(): Unit = runBlocking {
val account = secretsAccount(id = 7, substrateCryptoType = CryptoType.SR25519)
val existingTronKeypair = KeyPairSchema { keypair ->
keypair[KeyPairSchema.PublicKey] = ByteArray(33) { 9 }
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 8 }
keypair[KeyPairSchema.Nonce] = null
}
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = existingTronKeypair)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(7L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
@Test
fun `migrate should skip an account with no entropy (raw-seed import, not a mnemonic)`(): Unit = runBlocking {
val account = secretsAccount(id = 11, substrateCryptoType = CryptoType.SR25519)
val secrets = accountSecrets(entropy = null, tronKeypair = null)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(11L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
@Test
fun `migrate should skip an account with no stored secrets at all (watch-only, Ledger, etc)`(): Unit = runBlocking {
val account = secretsAccount(id = 13, substrateCryptoType = CryptoType.SR25519)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(13L))).thenReturn(null)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
private fun secretsAccount(id: Long, substrateCryptoType: CryptoType): MetaAccountLocal {
return MetaAccountLocal(
substratePublicKey = ByteArray(32) { 1 },
substrateCryptoType = substrateCryptoType,
substrateAccountId = ByteArray(32) { 2 },
ethereumPublicKey = null,
ethereumAddress = null,
name = "Test account",
parentMetaId = null,
isSelected = true,
position = 0,
type = MetaAccountLocal.Type.SECRETS,
status = MetaAccountLocal.Status.ACTIVE,
globallyUniqueId = "test-guid-$id",
typeExtras = null
).also { it.id = id }
}
private fun accountSecrets(entropy: ByteArray?, tronKeypair: EncodableStruct<KeyPairSchema>?): EncodableStruct<MetaAccountSecrets> {
val dummySubstrateKeypair = KeyPairSchema { keypair ->
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 5 }
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 6 }
keypair[KeyPairSchema.Nonce] = ByteArray(8) { 7 }
}
return MetaAccountSecrets(
substrateKeyPair = mapKeypairStructToKeypair(dummySubstrateKeypair),
entropy = entropy,
substrateSeed = null,
substrateDerivationPath = null,
ethereumKeypair = null,
ethereumDerivationPath = null,
tronKeypair = tronKeypair?.let(::mapKeypairStructToKeypair),
tronDerivationPath = null
)
}
}
@@ -39,12 +39,24 @@ class BalancesUpdateSystem(
override fun start(): Flow<Updater.SideEffect> { override fun start(): Flow<Updater.SideEffect> {
return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount -> return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount ->
chainRegistry.currentChains.transformLatestDiffed { chain -> chainRegistry.currentChains.transformLatestDiffed { chain ->
if (chain.isTronBased) {
Log.d(LOG_TAG, "TronDebug: currentChains delivered chain=${chain.id} name=${chain.name}")
}
emitAll(balancesSync(chain, metaAccount)) emitAll(balancesSync(chain, metaAccount))
} }
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
} }
private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow<Updater.SideEffect> { private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow<Updater.SideEffect> {
if (chain.isTronBased) {
Log.d(
LOG_TAG,
"TronDebug: gate check chain=${chain.id} name=${chain.name} hasAccountIn=${metaAccount.hasAccountIn(chain)} " +
"connectionState=${chain.connectionState} isDisabled=${chain.connectionState.isDisabled} " +
"hasSubstrateRuntime=${chain.hasSubstrateRuntime} canPerformFullSync=${chain.canPerformFullSync()}"
)
}
return when { return when {
!metaAccount.hasAccountIn(chain) -> emptyFlow() !metaAccount.hasAccountIn(chain) -> emptyFlow()
chain.connectionState.isDisabled -> emptyFlow() chain.connectionState.isDisabled -> emptyFlow()
@@ -89,6 +101,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()
} }
} }
@@ -6,7 +6,8 @@ import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.presentation.getAssetIconOrFallback import io.novafoundation.nova.common.presentation.getAssetIconOrFallback
import io.novafoundation.nova.common.utils.formatting.formatAsChange import io.novafoundation.nova.common.utils.formatting.formatAsChange
import io.novafoundation.nova.common.utils.orZero import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard
import io.novafoundation.nova.feature_assets.R import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
@@ -90,7 +91,14 @@ class TokenAssetFormatter(
group.getId(), group.getId(),
mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)), mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)),
assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration), assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration),
mapChainToUi(it.chain) // Not mapChainToUi() here - this row's subtitle needs to disambiguate which issuance of the
// token this is (e.g. "Ethereum (ERC-20)" vs "Tron (TRC-20)"), which a bare chain name alone
// doesn't when multiple ecosystems share the same symbol (USDT, USDC, etc.).
ChainUi(
id = it.chain.id,
name = it.chain.displayNameWithAssetStandard(),
icon = it.chain.icon
)
) )
} }
} }
@@ -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
@@ -16,6 +16,7 @@ import io.novafoundation.nova.feature_assets.presentation.flow.network.model.Net
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig
import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset import io.novafoundation.nova.runtime.multiNetwork.asset
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -73,7 +74,7 @@ abstract class NetworkFlowViewModel(
NetworkFlowRvItem( NetworkFlowRvItem(
it.chain.id, it.chain.id,
it.asset.token.configuration.id, it.asset.token.configuration.id,
it.chain.name, it.chain.displayNameWithAssetStandard(),
it.chain.icon, it.chain.icon,
amountFormatter.formatAmountToAmountModel( amountFormatter.formatAmountToAmountModel(
amount = getAssetBalance(it).amount, amount = getAssetBalance(it).amount,
@@ -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,17 +10,71 @@
app:strokeWidth="0dp"> app:strokeWidth="0dp">
<LinearLayout <LinearLayout
android:id="@+id/pezkuwiDashboardRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Collapsed state: minimal single-line pill showing only Trust Score.
Default/visible on first bind each app session. -->
<LinearLayout
android:id="@+id/pezkuwiDashboardCollapsedBar"
android:layout_width="match_parent"
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:orientation="horizontal"
android:paddingHorizontal="16dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_nevroz_flame" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/pezkuwi_dashboard_trust_score"
android:textColor="@android:color/white"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/bg_pezkuwi_dashboard" android:background="@drawable/bg_pezkuwi_dashboard"
android:orientation="vertical" android:orientation="vertical"
android:padding="18dp"> android:padding="18dp"
android:visibility="gone">
<!-- Header: flame badge + title/roles (left), citizen count (right) --> <!-- Header: flame badge + title/roles (left), citizen count + collapse chevron (right) -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="top"
android:orientation="horizontal"> android:orientation="horizontal">
<ImageView <ImageView
@@ -77,6 +131,18 @@
android:textColor="#7AFFFFFF" android:textColor="#7AFFFFFF"
android:textSize="10sp" /> android:textSize="10sp" />
</LinearLayout> </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 --> <!-- Trust score -->
@@ -157,4 +223,5 @@
app:strokeColor="#33999EC7" app:strokeColor="#33999EC7"
app:strokeWidth="1dp" /> app:strokeWidth="1dp" />
</LinearLayout> </LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@@ -24,8 +24,16 @@ data class CloudBackup(
val name: String, val name: String,
val type: Type, val type: Type,
val chainAccounts: Set<ChainAccountInfo>, val chainAccounts: Set<ChainAccountInfo>,
// Nullable and defaulted so that Gson deserializing a backup written before Bitcoin support existed - // All below are nullable and defaulted so that Gson deserializing a backup written before that
// which has no such field in its JSON at all - lands on null here rather than failing. // particular chain family existed - which has no such field in its JSON at all - lands on null here
// rather than failing. Solana has no derivation anywhere in this codebase yet (no native support,
// unlike Tron/Bitcoin) - its fields are reserved now purely so that adding that support later never
// needs another silent-drop-prone trip through every call site that touches this schema, the way
// Tron/Bitcoin did when they were bolted onto a schema that only knew about substrate/ethereum.
val tronAddress: ByteArray? = null,
val tronPublicKey: ByteArray? = null,
val solanaAddress: ByteArray? = null,
val solanaPublicKey: ByteArray? = null,
val bitcoinAddress: ByteArray? = null, val bitcoinAddress: ByteArray? = null,
val bitcoinPublicKey: ByteArray? = null, val bitcoinPublicKey: ByteArray? = null,
) : Identifiable { ) : Identifiable {
@@ -77,6 +85,10 @@ data class CloudBackup(
name == other.name && name == other.name &&
type == other.type && type == other.type &&
chainAccounts == other.chainAccounts && chainAccounts == other.chainAccounts &&
tronAddress.contentEquals(other.tronAddress) &&
tronPublicKey.contentEquals(other.tronPublicKey) &&
solanaAddress.contentEquals(other.solanaAddress) &&
solanaPublicKey.contentEquals(other.solanaPublicKey) &&
bitcoinAddress.contentEquals(other.bitcoinAddress) && bitcoinAddress.contentEquals(other.bitcoinAddress) &&
bitcoinPublicKey.contentEquals(other.bitcoinPublicKey) bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)
} }
@@ -91,6 +103,10 @@ data class CloudBackup(
result = 31 * result + name.hashCode() result = 31 * result + name.hashCode()
result = 31 * result + type.hashCode() result = 31 * result + type.hashCode()
result = 31 * result + chainAccounts.hashCode() result = 31 * result + chainAccounts.hashCode()
result = 31 * result + (tronAddress?.contentHashCode() ?: 0)
result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (solanaAddress?.contentHashCode() ?: 0)
result = 31 * result + (solanaPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0) result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0) result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
result = 31 * result + identifier.hashCode() result = 31 * result + identifier.hashCode()
@@ -108,6 +124,10 @@ data class CloudBackup(
val substrate: SubstrateSecrets?, val substrate: SubstrateSecrets?,
val ethereum: EthereumSecrets?, val ethereum: EthereumSecrets?,
val chainAccounts: List<ChainAccountSecrets>, val chainAccounts: List<ChainAccountSecrets>,
// See the matching comment on WalletPublicInfo: tron/bitcoin are fully wired end to end, solana is a
// schema-only reservation until native derivation for it exists.
val tron: TronSecrets? = null,
val solana: SolanaSecrets? = null,
val bitcoin: BitcoinSecrets? = null, val bitcoin: BitcoinSecrets? = null,
) : Identifiable { ) : Identifiable {
@@ -127,6 +147,8 @@ data class CloudBackup(
if (substrate != other.substrate) return false if (substrate != other.substrate) return false
if (ethereum != other.ethereum) return false if (ethereum != other.ethereum) return false
if (chainAccounts != other.chainAccounts) return false if (chainAccounts != other.chainAccounts) return false
if (tron != other.tron) return false
if (solana != other.solana) return false
if (bitcoin != other.bitcoin) return false if (bitcoin != other.bitcoin) return false
return identifier == other.identifier return identifier == other.identifier
} }
@@ -137,6 +159,8 @@ data class CloudBackup(
result = 31 * result + (substrate?.hashCode() ?: 0) result = 31 * result + (substrate?.hashCode() ?: 0)
result = 31 * result + (ethereum?.hashCode() ?: 0) result = 31 * result + (ethereum?.hashCode() ?: 0)
result = 31 * result + chainAccounts.hashCode() result = 31 * result + chainAccounts.hashCode()
result = 31 * result + (tron?.hashCode() ?: 0)
result = 31 * result + (solana?.hashCode() ?: 0)
result = 31 * result + (bitcoin?.hashCode() ?: 0) result = 31 * result + (bitcoin?.hashCode() ?: 0)
result = 31 * result + identifier.hashCode() result = 31 * result + identifier.hashCode()
return result return result
@@ -212,6 +236,17 @@ data class CloudBackup(
val derivationPath: String?, val derivationPath: String?,
) )
data class TronSecrets(
val keypair: KeyPairSecrets,
val derivationPath: String?,
)
// Reserved shape for when native Solana derivation is added - unused until then, see the class-level comment.
data class SolanaSecrets(
val keypair: KeyPairSecrets,
val derivationPath: String?,
)
data class BitcoinSecrets( data class BitcoinSecrets(
val keypair: KeyPairSecrets, val keypair: KeyPairSecrets,
val derivationPath: String?, val derivationPath: String?,
@@ -250,5 +285,5 @@ data class CloudBackup(
} }
fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean { fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean {
return entropy == null && substrate == null && ethereum == null && bitcoin == null && chainAccounts.isEmpty() return entropy == null && substrate == null && ethereum == null && tron == null && bitcoin == null && chainAccounts.isEmpty()
} }
@@ -7,6 +7,7 @@ import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.EthereumSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.EthereumSecrets
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.KeyPairSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.KeyPairSecrets
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.SubstrateSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.SubstrateSecrets
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.TronSecrets
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType
@@ -99,6 +100,7 @@ class WalletPrivateInfoBuilder(
private var substrate: SubstrateSecrets? = null private var substrate: SubstrateSecrets? = null
private var ethereum: EthereumSecrets? = null private var ethereum: EthereumSecrets? = null
private var tron: TronSecrets? = null
private val chainAccounts = mutableListOf<ChainAccountSecrets>() private val chainAccounts = mutableListOf<ChainAccountSecrets>()
@@ -114,6 +116,10 @@ class WalletPrivateInfoBuilder(
ethereum = BackupEthereumSecretsBuilder().apply(builder).build() ethereum = BackupEthereumSecretsBuilder().apply(builder).build()
} }
fun tron(builder: BackupTronSecretsBuilder.() -> Unit) {
tron = BackupTronSecretsBuilder().apply(builder).build()
}
fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) { fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) {
val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build() val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build()
chainAccounts.add(element) chainAccounts.add(element)
@@ -126,6 +132,7 @@ class WalletPrivateInfoBuilder(
substrate = substrate, substrate = substrate,
ethereum = ethereum, ethereum = ethereum,
chainAccounts = chainAccounts, chainAccounts = chainAccounts,
tron = tron,
) )
} }
} }
@@ -173,6 +180,25 @@ class BackupEthereumSecretsBuilder {
} }
} }
@CloudBackupBuildDsl
class BackupTronSecretsBuilder {
private var _keypair: KeyPairSecrets? = null
private var _derivationPath: String? = null
fun derivationPath(value: String?) {
_derivationPath = value
}
fun keypair(keypair: KeyPairSecrets) {
_keypair = keypair
}
fun build(): TronSecrets {
return TronSecrets(requireNotNull(_keypair), _derivationPath)
}
}
@CloudBackupBuildDsl @CloudBackupBuildDsl
class BackupChainAccountSecretsBuilder(private val accountId: AccountId) { class BackupChainAccountSecretsBuilder(private val accountId: AccountId) {
@@ -223,6 +249,8 @@ class WalletPublicInfoBuilder(
private var _substrateAccountId: ByteArray? = null private var _substrateAccountId: ByteArray? = null
private var _ethereumPublicKey: ByteArray? = null private var _ethereumPublicKey: ByteArray? = null
private var _ethereumAddress: ByteArray? = null private var _ethereumAddress: ByteArray? = null
private var _tronPublicKey: ByteArray? = null
private var _tronAddress: ByteArray? = null
private var _name: String = "" private var _name: String = ""
private var _isSelected: Boolean = false private var _isSelected: Boolean = false
private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS
@@ -252,6 +280,14 @@ class WalletPublicInfoBuilder(
_ethereumAddress = value _ethereumAddress = value
} }
fun tronPublicKey(value: ByteArray?) {
_tronPublicKey = value
}
fun tronAddress(value: ByteArray?) {
_tronAddress = value
}
fun name(value: String) { fun name(value: String) {
_name = value _name = value
} }
@@ -274,7 +310,9 @@ class WalletPublicInfoBuilder(
ethereumPublicKey = _ethereumPublicKey, ethereumPublicKey = _ethereumPublicKey,
name = _name, name = _name,
type = _type, type = _type,
chainAccounts = chainAccounts.toSet() chainAccounts = chainAccounts.toSet(),
tronAddress = _tronAddress,
tronPublicKey = _tronPublicKey,
) )
} }
} }
@@ -9,6 +9,7 @@ import io.novafoundation.nova.runtime.ext.isCustomNetwork
import io.novafoundation.nova.runtime.ext.isDisabled import io.novafoundation.nova.runtime.ext.isDisabled
import io.novafoundation.nova.runtime.ext.isEnabled import io.novafoundation.nova.runtime.ext.isEnabled
import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull
import io.novafoundation.nova.runtime.ext.httpNodes
import io.novafoundation.nova.runtime.ext.wssNodes import io.novafoundation.nova.runtime.ext.wssNodes
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
@@ -132,7 +133,13 @@ class RealNetworkManagementChainInteractor(
} }
private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow<List<NodeHealthState>> { private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow<List<NodeHealthState>> {
return chain.nodes.wssNodes().map { // wssNodes() alone leaves an HTTPS-only chain (Tron today - no wss endpoint at all) with an empty list
// here, which silently renders as "nothing to show" rather than a real health state - fall back to the
// http nodes only when there are no wss ones, since a chain that genuinely has wss nodes should still
// prefer testing those.
val nodesToCheck = chain.nodes.wssNodes().ifEmpty { chain.nodes.httpNodes() }
return nodesToCheck.map {
nodeHealthState(chain, it, coroutineScope) nodeHealthState(chain, it, coroutineScope)
}.combine() }.combine()
} }
@@ -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
@@ -10,4 +10,6 @@ sealed interface TransactionExecution {
class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution
class Bitcoin(val hash: String) : TransactionExecution class Bitcoin(val hash: String) : 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,7 +142,6 @@ 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()
@@ -151,7 +154,7 @@ class StatemineAssetBalance(
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 ->
@@ -165,13 +168,6 @@ class StatemineAssetBalance(
} 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()
} }
} }
@@ -10,7 +10,6 @@ import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.b
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.pollingBalanceFlow import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.pollingBalanceFlow
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.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl
import io.novafoundation.nova.runtime.ext.requireTrc20 import io.novafoundation.nova.runtime.ext.requireTrc20
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
@@ -21,9 +20,9 @@ import kotlinx.coroutines.flow.map
import java.math.BigInteger import java.math.BigInteger
/** /**
* TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via TronGrid's REST API (the same * TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via an on-chain `balanceOf` contract
* `/v1/accounts/{address}` endpoint used for native TRX - TronGrid returns both in one response) and polls for * call (see [TronGridApi.fetchTrc20Balance] for why this can't reuse the `/v1/accounts` endpoint that native
* updates. No transfer/history support here - see `TronAssetsModule`. * TRX balance reads from) and polls for updates. No transfer/history support here - see `TronAssetsModule`.
*/ */
class Trc20AssetBalance( class Trc20AssetBalance(
private val assetCache: AssetCache, private val assetCache: AssetCache,
@@ -52,9 +51,8 @@ class Trc20AssetBalance(
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
val contractAddress = chainAsset.requireTrc20().contractAddress val contractAddress = chainAsset.requireTrc20().contractAddress
val address = chain.addressOf(accountId)
val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), address, contractAddress) val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), accountId, contractAddress)
return ChainAssetBalance.fromFree(chainAsset, balance) return ChainAssetBalance.fromFree(chainAsset, balance)
} }
@@ -77,9 +75,8 @@ class Trc20AssetBalance(
): Flow<BalanceSyncUpdate> { ): Flow<BalanceSyncUpdate> {
val contractAddress = chainAsset.requireTrc20().contractAddress val contractAddress = chainAsset.requireTrc20().contractAddress
val baseUrl = chain.requireTronGridBaseUrl() val baseUrl = chain.requireTronGridBaseUrl()
val address = chain.addressOf(accountId)
return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, address, contractAddress) } return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, accountId, contractAddress) }
.map { balance -> .map { balance ->
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance) assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
@@ -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,14 +1,56 @@
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 io.novafoundation.nova.runtime.BuildConfig
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
// TronGrid's anonymous rate limit is aggressive enough to surface under normal, human-paced app usage (see
// RealTronGridApi.retryOn429's doc comment) - a free API key from trongrid.io raises it substantially. Sent on
// every request rather than only when a 429 is hit, since the point is to avoid needing the retry in the first
// place, not just to have a fallback.
private const val TRON_API_KEY_HEADER = "TRON-PRO-API-KEY: " + BuildConfig.TRONGRID_API_KEY
interface RetrofitTronGridApi { interface RetrofitTronGridApi {
@GET @GET
@Headers(UserAgent.NOVA) @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
suspend fun getAccount(@Url url: String): TronAccountResponse suspend fun getAccount(@Url url: String): TronAccountResponse
@POST
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
suspend fun createTransaction(@Url url: String, @Body body: TronCreateTransactionRequest): TronUnsignedTransactionResponse
@POST
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
suspend fun triggerConstantContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse
@POST
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
suspend fun triggerSmartContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse
@POST
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
suspend fun broadcastTransaction(@Url url: String, @Body body: TronBroadcastRequest): TronBroadcastResponse
@GET
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
suspend fun getChainParameters(@Url url: String): TronChainParametersResponse
@POST
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
suspend fun getAccountResource(@Url url: String, @Body body: TronAddressRequest): TronAccountResourceResponse
} }
@@ -1,39 +1,263 @@
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.utils.toTronHexAddress
import io.novafoundation.nova.common.utils.tronAddressToHexAddress
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.Trc20TransferAbi
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.delay
import retrofit2.HttpException
import java.math.BigInteger 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 /**
* Reads via an on-chain `balanceOf(address)` call (`triggerconstantcontract`), NOT `/v1/accounts` - a
* TRC-20 balance lives in the token contract's own storage, not in the holder's Account object, so an
* address that has only ever received TRC-20 tokens (never native TRX, never otherwise "activated") has no
* Account object at all and `/v1/accounts` returns empty for it regardless of its real token balance.
* Confirmed live: a wallet holding exactly 5 USDT-TRC20 and zero TRX/activation history returned `data: []`
* from `/v1/accounts` while `balanceOf` correctly returned 5000000.
*/
suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance
/**
* Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`.
* [ownerHexAddress]/[toHexAddress] must be in hex form (`41`-prefixed), matching `visible: false`.
*
* Note: TronGrid refuses to build this for an owner account that has never been activated on-chain
* (confirmed live: returns `{"Error": "... no OwnerAccount."}`) - unlike [triggerSmartContract], which
* happily builds a transaction for an unfunded/unactivated owner.
*/
suspend fun createNativeTransfer(baseUrl: String, ownerHexAddress: String, toHexAddress: String, amountSun: BigInteger): TronUnsignedTransactionResponse
/**
* Read-only dry run via `POST /wallet/triggerconstantcontract` - does not require the owner account to hold
* any TRX and does not touch chain state. Used to estimate the `energy_used` an actual TRC-20 call would
* cost (see [TronTriggerContractResponse.energyUsed]).
*/
suspend fun triggerConstantContract(
baseUrl: String,
ownerHexAddress: String,
contractHexAddress: String,
functionSelector: String,
parameterHex: String
): TronTriggerContractResponse
/**
* Builds an unsigned TRC-20 contract call via `POST /wallet/triggersmartcontract`. Unlike
* [createNativeTransfer], this works even for an owner account that has never been activated on-chain
* (confirmed live).
*/
suspend fun triggerSmartContract(
baseUrl: String,
ownerHexAddress: String,
contractHexAddress: String,
functionSelector: String,
parameterHex: String,
feeLimitSun: BigInteger
): TronTriggerContractResponse
/**
* Signs-and-submits via `POST /wallet/broadcasttransaction`. The full [unsigned] transaction (including its
* `raw_data` object, not just `raw_data_hex`) must be echoed back verbatim alongside the signature - sending
* only `raw_data_hex` + `signature` was confirmed live to fail with a deserialization error on TronGrid's side.
*
* @return the transaction hash (`txID`) on success.
* @throws TronApiException if TronGrid rejects the broadcast (invalid signature, insufficient balance, etc.)
*/
suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String
/** `key -> value` map from `GET /wallet/getchainparameters`, e.g. `getEnergyFee` (sun/energy), `getTransactionFee` (sun/byte). */
suspend fun getChainParameters(baseUrl: String): Map<String, Long>
suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse
} }
class RealTronGridApi( class RealTronGridApi(
private val retrofitApi: RetrofitTronGridApi private val retrofitApi: RetrofitTronGridApi
) : TronGridApi { ) : TronGridApi {
// TronGrid's public (no API key) endpoint rate-limits aggressively - confirmed live to return a bare HTTP
// 429 under normal, human-paced usage (not just load testing) once a handful of requests land in a short
// window. Without this, a 429 on any call in the send flow (fee estimation re-runs on every keystroke,
// broadcast, etc.) surfaced straight to the user as a raw error dialog, and the only way through was to
// keep tapping Confirm until a request happened to land outside the rate-limit window. Retrying here means
// every TronGrid call gets this transparently, not just the ones a caller remembered to wrap.
private suspend fun <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(1_000L * (attempt + 1))
}
}
return block()
}
override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance { override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance {
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
return accountData.balance?.toBigInteger() ?: BigInteger.ZERO return accountData.balance?.toBigInteger() ?: BigInteger.ZERO
} }
override suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance { override suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance {
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO val response = triggerConstantContract(
baseUrl = baseUrl,
ownerHexAddress = holderAccountId.toTronHexAddress(),
contractHexAddress = contractAddress.tronAddressToHexAddress(),
functionSelector = Trc20TransferAbi.BALANCE_OF_FUNCTION_SELECTOR,
parameterHex = Trc20TransferAbi.encodeBalanceOfParameters(holderAccountId)
)
val rawBalance = accountData.trc20.orEmpty() val resultHex = response.constantResult?.firstOrNull() ?: return BigInteger.ZERO
.firstNotNullOfOrNull { entry -> entry[contractAddress] }
return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO return runCatching { BigInteger(resultHex, 16) }.getOrDefault(BigInteger.ZERO)
} }
private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount( override suspend fun createNativeTransfer(
url = accountUrl(baseUrl, address) baseUrl: String,
).data?.firstOrNull() ownerHexAddress: String,
toHexAddress: String,
amountSun: BigInteger
): TronUnsignedTransactionResponse {
val request = TronCreateTransactionRequest(
ownerAddress = ownerHexAddress,
toAddress = toHexAddress,
amount = amountSun.toLongExactOrThrow("amount")
)
val response = retryOn429 { retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request) }
return response.requireConstructed()
}
override suspend fun triggerConstantContract(
baseUrl: String,
ownerHexAddress: String,
contractHexAddress: String,
functionSelector: String,
parameterHex: String
): TronTriggerContractResponse {
val request = TronTriggerContractRequest(
ownerAddress = ownerHexAddress,
contractAddress = contractHexAddress,
functionSelector = functionSelector,
parameter = parameterHex
)
return retryOn429 { retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request) }
}
override suspend fun triggerSmartContract(
baseUrl: String,
ownerHexAddress: String,
contractHexAddress: String,
functionSelector: String,
parameterHex: String,
feeLimitSun: BigInteger
): TronTriggerContractResponse {
val request = TronTriggerContractRequest(
ownerAddress = ownerHexAddress,
contractAddress = contractHexAddress,
functionSelector = functionSelector,
parameter = parameterHex,
feeLimit = feeLimitSun.toLongExactOrThrow("feeLimit")
)
val response = retryOn429 { retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request) }
if (response.result?.result != true) {
throw TronApiException(response.result?.message ?: response.result?.code ?: "triggersmartcontract failed without a message")
}
// Only validate that a transaction was actually returned - callers read [TronTriggerContractResponse.transaction] themselves.
response.transaction?.requireConstructed()
return response
}
override suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String {
val txId = requireNotNull(unsigned.txID) { "Cannot broadcast a transaction without a txID" }
val request = TronBroadcastRequest(
visible = unsigned.visible ?: false,
txID = txId,
rawData = requireNotNull(unsigned.rawData) { "Cannot broadcast a transaction without raw_data" },
rawDataHex = requireNotNull(unsigned.rawDataHex) { "Cannot broadcast a transaction without raw_data_hex" },
signature = listOf(signatureHex)
)
// Safe to retry on 429 specifically: a 429 means TronGrid rejected the request before processing it
// (rate limit), not that the transaction may have already been broadcast - unlike a timeout, it can't
// cause a double-send.
val response = retryOn429 { retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request) }
if (response.result != true) {
throw TronApiException(response.decodeErrorMessage())
}
return response.txid ?: txId
}
override suspend fun getChainParameters(baseUrl: String): Map<String, Long> {
return retryOn429 { retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters")) }
.chainParameter
.associate { it.key to it.value }
}
override suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse {
return retryOn429 { retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex)) }
}
private suspend fun fetchAccountData(baseUrl: String, address: String) = retryOn429 {
retrofitApi.getAccount(url = accountUrl(baseUrl, address))
}.data?.firstOrNull()
private fun accountUrl(baseUrl: String, address: String): String { 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,269 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import io.novafoundation.nova.common.utils.castOrNull
import io.novafoundation.nova.common.utils.sha256
import io.novafoundation.nova.common.utils.toEcdsaSignatureData
import io.novafoundation.nova.common.utils.toTronHexAddress
import io.novafoundation.nova.common.utils.tronAddressToHexAddress
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.data.model.TronFee
import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
import io.novafoundation.nova.runtime.ext.commissionAsset
import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import java.math.BigInteger
private const val ENERGY_FEE_PARAM_KEY = "getEnergyFee"
private const val TRANSACTION_FEE_PARAM_KEY = "getTransactionFee"
// Fallbacks only used if a live `getchainparameters` call fails or is missing the expected key - both values are
// what was observed live on Shasta testnet at implementation time, which also match Tron's long-standing mainnet
// defaults; the primary path always fetches live values.
private val FALLBACK_ENERGY_FEE_SUN = BigInteger.valueOf(420)
private val FALLBACK_BANDWIDTH_FEE_SUN = BigInteger.valueOf(1000)
// Used only if a triggerconstantcontract dry run fails outright (e.g. transient network error) and returns no
// energy_used at all - a conservative (intentionally high) stand-in for a simple TRC-20 transfer, which in
// practice costs on the order of 15-30k energy. Mirrors EvmErc20AssetTransfers' ERC_20_UPPER_GAS_LIMIT fallback.
private val FALLBACK_TRC20_ENERGY_UNITS = 65_000L
private val FALLBACK_TRC20_TX_SIZE_BYTES = 350L
// fee_limit sent with triggersmartcontract: Tron only ever burns what a call actually uses (up to this cap), so
// setting this generously above our own estimate does not cost the user more - it only avoids an OUT_OF_ENERGY
// failure if our estimate undershoots. Bounded above as a sanity guard against a runaway estimate.
private val MIN_FEE_LIMIT_SUN = BigInteger.valueOf(15_000_000) // 15 TRX
private val MAX_FEE_LIMIT_SUN = BigInteger.valueOf(100_000_000) // 100 TRX
private val EMPTY_RESOURCE = TronAccountResourceResponse()
/**
* Builds, signs and broadcasts Tron transactions (native TRX and TRC-20) using TronGrid's own REST endpoints for
* construction/broadcast, and this app's existing ECDSA signing primitive for signing - no Tron protobuf
* (`Transaction`/`TransferContract`/`TriggerSmartContract`) encoding and no new crypto library were added.
*
* ## Construction
* - Native TRX: `POST /wallet/createtransaction` with `{owner_address, to_address, amount}` (all hex-encoded,
* `visible: false`). Requires the owner account to already be activated on-chain (confirmed live: an
* unactivated owner gets `{"Error": "... no OwnerAccount."}`) - in practice this is never hit here, since a
* user can only reach the send flow with a positive TRX balance to send from, which itself implies the account
* was already activated by an earlier incoming transfer.
* - TRC-20: `POST /wallet/triggersmartcontract` with the ABI-encoded `transfer(address,uint256)` call (see
* [Trc20TransferAbi]). Unlike `createtransaction`, this was confirmed live to work even for a
* never-activated owner account.
*
* ## Signing
* Tron's signature is `ECDSA_sign(privateKey, sha256(raw_data))` over secp256k1 - the same curve/primitive this
* app already uses for Ethereum. [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw]
* (backed by `substrate_sdk_android`'s `Signer.sign(MultiChainEncryption.Ethereum, message, keypair, skipHashing)`
* -> `web3j`'s `Sign.signMessage(hash, keyPair, needToHash = false)`) already supports signing a pre-computed
* hash directly via `SignerPayloadRaw.skipMessageHashing = true` - this is exactly the primitive Ethereum-style
* raw-hash signing needs, and it is reused as-is here. No new cryptographic code or library was added; only the
* hash fed into it differs from the EVM path (`sha256(raw_data)` here vs. an EIP-155 RLP-based digest there).
*
* `web3j`'s `Sign.signMessage` always left-pads `r`/`s` to exactly 32 bytes and encodes `v` as `27/28` (confirmed
* by reading `web3j`'s `Sign.java` source) - which is byte-for-byte the same `r(32) + s(32) + v(1)` = 65-byte
* compact signature format Tron expects (confirmed against `tronweb`'s own `ECKeySign` implementation, and
* independently confirmed live against Shasta testnet - see the Phase 2 implementation notes).
*
* ## Broadcast
* `POST /wallet/broadcasttransaction` with the full unsigned transaction object (not just `raw_data_hex`) plus
* `signature: [<65-byte hex signature>]`.
*/
class RealTronTransactionService(
private val accountRepository: AccountRepository,
private val signerProvider: SignerProvider,
private val tronGridApi: TronGridApi,
) : TronTransactionService {
override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee {
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
val baseUrl = chain.requireTronGridBaseUrl()
val ownerHex = ownerAccountId.toTronHexAddress()
val feeSun = when (intent) {
is TronTransactionIntent.Native -> estimateNativeFee(baseUrl, ownerHex, recipient, intent.amountSun)
is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex(
baseUrl,
ownerHex,
recipient,
intent.contractAddress.tronAddressToHexAddress(),
intent.amountSun
)
}
return TronFee(feeSun, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset)
}
override suspend fun transact(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
presetFee: Fee?,
intent: TronTransactionIntent
): Result<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 {
// TronGrid's createtransaction rejects amount=0 outright with a ContractValidateException (confirmed
// live) - the send screen's fee loader calls calculateFee reactively as the user types, including before
// any amount has been entered. Substitute a minimal placeholder purely for this dry-run construction call;
// it does not affect the real amount used when the transfer is actually built in transact().
val dryRunAmountSun = amountSun.takeIf { it > BigInteger.ZERO } ?: BigInteger.ONE
val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), dryRunAmountSun)
val txSizeBytes = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }.length / 2
val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE)
val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN)
val bandwidthShortfall = shortfall(txSizeBytes.toLong(), resource.availableBandwidth())
return bandwidthShortfall.toBigInteger() * bandwidthPrice
}
private suspend fun estimateTrc20FeeFromContractHex(
baseUrl: String,
ownerHex: String,
recipient: AccountId,
contractHex: String,
amountSun: BigInteger
): BigInteger {
val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, amountSun)
val dryRun = runCatching {
tronGridApi.triggerConstantContract(baseUrl, ownerHex, contractHex, Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, parameterHex)
}.getOrNull()
// A dry-run revert (e.g. the sender doesn't yet hold the token) still reports the energy spent up to the
// revert point, which remains a meaningful (if slightly different) estimate - it is used as-is rather
// than special-cased, only a wholly-failed HTTP call falls back to the conservative constant.
val energyUsed = dryRun?.energyUsed ?: FALLBACK_TRC20_ENERGY_UNITS
val txSizeBytes = dryRun?.transaction?.rawDataHex?.let { it.length / 2L } ?: FALLBACK_TRC20_TX_SIZE_BYTES
val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE)
val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN)
val energyPrice = chainParameterOrDefault(baseUrl, ENERGY_FEE_PARAM_KEY, FALLBACK_ENERGY_FEE_SUN)
val bandwidthShortfall = shortfall(txSizeBytes, resource.availableBandwidth())
val energyShortfall = shortfall(energyUsed, resource.availableEnergy())
return bandwidthShortfall.toBigInteger() * bandwidthPrice + energyShortfall.toBigInteger() * energyPrice
}
private suspend fun chainParameterOrDefault(baseUrl: String, key: String, default: BigInteger): BigInteger {
val params = runCatching { tronGridApi.getChainParameters(baseUrl) }.getOrNull()
return params?.get(key)?.toBigInteger() ?: default
}
private fun shortfall(needed: Long, available: Long): Long = (needed - available.coerceAtLeast(0)).coerceAtLeast(0)
private fun TronAccountResourceResponse.availableBandwidth(): Long =
(freeNetLimit - freeNetUsed).coerceAtLeast(0) + (netLimit - netUsed).coerceAtLeast(0)
private fun TronAccountResourceResponse.availableEnergy(): Long =
(energyLimit - energyUsed).coerceAtLeast(0)
}
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger
/**
* Minimal, hand-written Solidity ABI encoding for the two calls this client makes to a TRC-20 contract:
* `transfer(address,uint256)` (sending) and `balanceOf(address)` (reading a balance - `Trc20AssetBalance` can't
* use TronGrid's `/v1/accounts` REST endpoint for this: a TRC-20 balance lives in the token contract's own
* storage, not the holder's Account object, so an address that has only ever received TRC-20 tokens has no
* Account object at all and `/v1/accounts` silently returns empty for it regardless of its real balance).
*
* Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad
* each to 32 bytes and concatenate" - no dynamic-type/offset table is needed. The 4-byte function selector is
* intentionally NOT computed client-side: TronGrid accepts the human-readable `function_selector` string
* directly and hashes it server-side (confirmed live against Shasta testnet - a call with
* `function_selector: "transfer(address,uint256)"` and no client-computed selector correctly resolved to the
* standard `a9059cbb` selector in the resulting `raw_data`), which avoids needing a keccak256 implementation here.
*/
object Trc20TransferAbi {
const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)"
const val BALANCE_OF_FUNCTION_SELECTOR = "balanceOf(address)"
/**
* @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address -
* ABI-encoded Solidity `address` parameters use the bare 20-byte form, confirmed live).
*/
fun encodeTransferParameters(recipient: AccountId, amountSun: BigInteger): String {
require(recipient.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${recipient.size}" }
require(amountSun.signum() >= 0) { "Amount must not be negative, got $amountSun" }
val addressParam = recipient.toHexString(withPrefix = false).padStart(64, '0')
val amountParam = amountSun.toString(16).padStart(64, '0')
return addressParam + amountParam
}
/**
* @param holder raw 20-byte Ethereum/Tron-style account id - same encoding as [encodeTransferParameters]'s
* `recipient`.
*/
fun encodeBalanceOfParameters(holder: AccountId): String {
require(holder.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${holder.size}" }
return holder.toHexString(withPrefix = false).padStart(64, '0')
}
}
@@ -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>
}
@@ -75,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,275 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import com.google.gson.JsonObject
import io.novafoundation.nova.common.utils.Precision
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.common.utils.sha256
import io.novafoundation.nova.common.utils.toTronHexAddress
import io.novafoundation.nova.common.utils.tronAddressToAccountId
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatcher
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnitRunner
import java.math.BigInteger
// eq()/any()/argThat() genuinely return null at runtime - that's how Mockito's matcher-stack recording works,
// regardless of whether they're called via test_shared's Kotlin wrappers or the raw org.mockito.Mockito statics
// directly (confirmed: switching to the raw statics did not avoid the "eq(...) must not be null" crash once that
// null flows into a Kotlin non-null-typed parameter somewhere downstream of the call site). Instead of returning
// the real (null) value, these local wrappers fall back to a definitely-non-null stand-in - the real value itself
// for eq() (harmless: the matcher was already recorded on Mockito's thread-local stack by the time this returns,
// so the fallback value is never actually used for matching) and an unchecked-cast dummy for any()/argThat()
// (mirrors mockito-kotlin's own internal implementation of the same helpers).
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
@Suppress("UNCHECKED_CAST")
private fun <T> any(): T {
Mockito.any<T>()
return null as T
}
@Suppress("UNCHECKED_CAST")
private fun <T> argThat(matcher: (T) -> Boolean): T {
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
return null as T
}
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
}
}
}
+3 -1
View File
@@ -16,10 +16,12 @@ android {
buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/chains.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/chains.json\""
buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/details\"" buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/details\""
buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\"" buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/chains_for_testBalance.json\""
buildConfigField "String", "TEST_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/pezkuwi_assets_for_testBalance.json\""
buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY") buildConfigField "String", "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")
buildConfigField "String", "TRONGRID_API_KEY", readStringSecret("TRONGRID_API_KEY")
} }
buildTypes { buildTypes {
@@ -39,6 +39,7 @@ import io.novasama.substrate_sdk_android.wsrpc.SocketService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
import org.web3j.protocol.http.HttpService import org.web3j.protocol.http.HttpService
import javax.inject.Provider import javax.inject.Provider
@@ -162,13 +163,17 @@ class ChainRegistryModule {
bulkRetriever: BulkRetriever, bulkRetriever: BulkRetriever,
connectionSecrets: ConnectionSecrets, connectionSecrets: ConnectionSecrets,
web3ApiFactory: Web3ApiFactory, web3ApiFactory: Web3ApiFactory,
httpClient: OkHttpClient,
) = NodeHealthStateTesterFactory( ) = NodeHealthStateTesterFactory(
socketProvider, socketProvider,
connectionSecrets, connectionSecrets,
bulkRetriever, bulkRetriever,
web3ApiFactory, web3ApiFactory,
httpClient // A short-lived, minimally-configured client is enough for a health-check ping - unlike Web3ApiFactory's
// client, this never needs to survive/reuse connections across a long-lived RPC session.
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
) )
@Provides @Provides
@@ -10,14 +10,11 @@ import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.ChainDao import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.dao.StorageDao import io.novafoundation.nova.core_db.dao.StorageDao
import io.novasama.substrate_sdk_android.wsrpc.SocketService import io.novasama.substrate_sdk_android.wsrpc.SocketService
import okhttp3.OkHttpClient
interface RuntimeDependencies { interface RuntimeDependencies {
fun networkApiCreator(): NetworkApiCreator fun networkApiCreator(): NetworkApiCreator
fun okHttpClient(): OkHttpClient
fun socketServiceCreator(): SocketService fun socketServiceCreator(): SocketService
fun gson(): Gson fun gson(): Gson
@@ -19,6 +19,7 @@ import io.novafoundation.nova.common.utils.formatNamed
import io.novafoundation.nova.common.utils.isValidBitcoinAddress import io.novafoundation.nova.common.utils.isValidBitcoinAddress
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.toBitcoinAddress import io.novafoundation.nova.common.utils.toBitcoinAddress
import io.novafoundation.nova.common.utils.toTronAddress import io.novafoundation.nova.common.utils.toTronAddress
@@ -372,7 +373,13 @@ fun Chain.isValidAddress(address: String): Boolean {
return runCatching { return runCatching {
when { when {
isBitcoinBased -> address.isValidBitcoinAddress() isBitcoinBased -> address.isValidBitcoinAddress()
// Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two
// branches below would ever accept them, so this needs its own dedicated check.
isTronBased -> address.isValidTronAddress()
isEthereumBased -> address.asEthereumAddress().isValid() isEthereumBased -> address.asEthereumAddress().isValid()
else -> { else -> {
address.toAccountId() // verify supplied address can be converted to account id address.toAccountId() // verify supplied address can be converted to account id
@@ -492,6 +499,7 @@ object ChainGeneses {
object ChainIds { object ChainIds {
const val ETHEREUM = "$EIP_155_PREFIX:1" const val ETHEREUM = "$EIP_155_PREFIX:1"
const val TRON = "tron:0x2b6653dc"
const val MOONBEAM = ChainGeneses.MOONBEAM const val MOONBEAM = ChainGeneses.MOONBEAM
const val MOONRIVER = ChainGeneses.MOONRIVER const val MOONRIVER = ChainGeneses.MOONRIVER
@@ -503,6 +511,33 @@ val Chain.Companion.Geneses
val Chain.Companion.Ids val Chain.Companion.Ids
get() = ChainIds get() = ChainIds
/**
* A short, user-facing token-standard label for chains where disambiguating "which token standard is this"
* is actually useful (multiple ecosystems all issue their own USDT/USDC etc., so a bare chain name isn't
* always enough context). Deliberately NOT derived from [Chain.Asset.Type] (e.g. every Statemine-type chain
* would get the same label) - this is chain-specific by design, matching exactly which labels are
* recognizable/expected by users (PEZ-20, ERC-20, TRC-20), not a mechanical one-label-per-asset-type mapping.
*/
val Chain.assetStandardLabelOrNull: String?
get() = when {
genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> "PEZ-20"
id == Chain.Ids.ETHEREUM -> "ERC-20"
id == Chain.Ids.TRON -> "TRC-20"
else -> null
}
/**
* Chain display name with its token-standard label appended where [assetStandardLabelOrNull] applies, e.g.
* "Ethereum (ERC-20)". Shared across every screen that lists the same token symbol once per chain (the
* Send/Receive/etc. network picker, the main balance list's per-token chain breakdown) - a bare chain name
* alone doesn't convey which issuance this is when multiple ecosystems share the same symbol.
*/
fun Chain.displayNameWithAssetStandard(): String {
val standardLabel = assetStandardLabelOrNull ?: return name
return "$name ($standardLabel)"
}
fun Chain.Asset.requireStatemine(): Type.Statemine { fun Chain.Asset.requireStatemine(): Type.Statemine {
require(type is Type.Statemine) require(type is Type.Statemine)
@@ -3,19 +3,23 @@ package io.novafoundation.nova.runtime.ext
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
val Chain.mainChainsFirstAscendingOrder val Chain.mainChainsFirstAscendingOrder
get() = when (genesisHash) { get() = when {
// Pezkuwi ecosystem first // Pezkuwi ecosystem first
Chain.Geneses.PEZKUWI -> 0 genesisHash == Chain.Geneses.PEZKUWI -> 0
Chain.Geneses.PEZKUWI_ASSET_HUB -> 1 genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> 1
Chain.Geneses.PEZKUWI_PEOPLE -> 2 genesisHash == Chain.Geneses.PEZKUWI_PEOPLE -> 2
// Then Polkadot ecosystem // Then Polkadot ecosystem
Chain.Geneses.POLKADOT -> 3 genesisHash == Chain.Geneses.POLKADOT -> 3
Chain.Geneses.POLKADOT_ASSET_HUB -> 4 genesisHash == Chain.Geneses.POLKADOT_ASSET_HUB -> 4
// Then Kusama ecosystem // Then Kusama ecosystem
Chain.Geneses.KUSAMA -> 5 genesisHash == Chain.Geneses.KUSAMA -> 5
Chain.Geneses.KUSAMA_ASSET_HUB -> 6 genesisHash == Chain.Geneses.KUSAMA_ASSET_HUB -> 6
// Then Ethereum, then Tron - not identified by genesisHash (that's substrate-only), so this can't
// stay a `when (genesisHash)` subject match once these two are added
id == Chain.Ids.ETHEREUM -> 7
id == Chain.Ids.TRON -> 8
// Everything else // Everything else
else -> 7 else -> 9
} }
val Chain.testnetsLastAscendingOrder val Chain.testnetsLastAscendingOrder
@@ -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,17 +42,48 @@ class ChainSyncService(
val remoteChains = retryUntilDone { chainFetcher.getChains() } val remoteChains = retryUntilDone { chainFetcher.getChains() }
val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) } // A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make
val newAssets = remoteChains.flatMap { chain -> // chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of
chain.assets.map { // throwing. Applying that as a diff against a populated local DB would delete most or all of the
val fullAssetId = FullAssetIdLocal(chain.chainId, it.assetId) // user's chains/assets - not a sync failure, but active data loss, for something that self-heals on
// the next successful sync if we just skip applying it. Only guard when we HAD data: an empty result
// on a genuinely first-ever sync is normal and must proceed.
if (oldChains.isNotEmpty() && remoteChains.size < oldChains.size / 2) {
Log.e(
LOG_TAG,
"Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " +
"(would remove more than half). Likely a transient fetch issue - skipping this sync cycle."
)
return@withContext
}
// One malformed/incompatible chain (a new field the app's mapper doesn't understand yet, a bad
// publish, etc.) must not take down sync for every other chain - a plain .map{} here means a single
// throwing chain aborts before chainDao.applyDiff() is ever called, leaving a brand new install with
// zero locally-cached chains forever (a completely empty tokens list), since nothing else in this
// function ever gets a chance to run. Isolate failures per chain, and per asset within a chain that
// otherwise mapped fine, instead.
val remoteChainsWithLocal = remoteChains.mapNotNull { chainRemote ->
runCatching { chainRemote to mapRemoteChainToLocal(chainRemote, oldChainsById[chainRemote.chainId], source = ChainLocal.Source.DEFAULT, gson) }
.onFailure { Log.e(LOG_TAG, "Failed to map remote chain ${chainRemote.chainId} (${chainRemote.name}), skipping it for this sync cycle", it) }
.getOrNull()
}
val newChains = remoteChainsWithLocal.map { (_, chainLocal) -> chainLocal }
val newAssets = remoteChainsWithLocal.flatMap { (chain, _) ->
chain.assets.mapNotNull { assetRemote ->
runCatching {
val fullAssetId = FullAssetIdLocal(chain.chainId, assetRemote.assetId)
val oldAsset = associatedOldAssets[fullAssetId] val oldAsset = associatedOldAssets[fullAssetId]
mapRemoteAssetToLocal(chain, it, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL) mapRemoteAssetToLocal(chain, assetRemote, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL)
}.onFailure {
Log.e(LOG_TAG, "Failed to map asset ${assetRemote.assetId} (${assetRemote.symbol}) on chain ${chain.chainId}, skipping it", it)
}.getOrNull()
} }
} }
val newNodes = remoteChains.flatMap(::mapRemoteNodesToLocal) val newNodes = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteNodesToLocal(chain) }
val newExplorers = remoteChains.flatMap(::mapRemoteExplorersToLocal) val newExplorers = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteExplorersToLocal(chain) }
val newExternalApis = remoteChains.flatMap(::mapExternalApisToLocal) val newExternalApis = remoteChainsWithLocal.flatMap { (chain, _) -> mapExternalApisToLocal(chain) }
val newNodeSelectionPreferences = nodeSelectionPreferencesFor(newChains, oldNodeSelectionPreferences) val newNodeSelectionPreferences = nodeSelectionPreferencesFor(newChains, oldNodeSelectionPreferences)
val chainsDiff = CollectionDiffer.findDiff(newChains, oldChains, forceUseNewItems = false) val chainsDiff = CollectionDiffer.findDiff(newChains, oldChains, forceUseNewItems = false)
@@ -27,6 +27,11 @@ class NodeHealthStateTesterFactory(
httpClient = httpClient httpClient = httpClient
) )
chain.isTronBased -> TronNodeHealthStateTester(
node = node,
httpClient = httpClient
)
chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester( chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester(
chain = chain, chain = chain,
socketService = socketServiceProvider.get(), socketService = socketServiceProvider.get(),
@@ -0,0 +1,41 @@
package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.time.ExperimentalTime
import kotlin.time.measureTime
/**
* TronGrid speaks a plain REST API, not Ethereum JSON-RPC - reusing [EthereumNodeHealthStateTester] against it
* (as this codebase used to, before Tron nodes were included in health checks at all) would send an
* `eth_getBalance` call TronGrid doesn't understand, always reporting the node as unreachable regardless of its
* actual health. `GET /wallet/getchainparameters` needs no account/address context and is cheap on TronGrid's
* side, making it a good generic liveness ping - same endpoint this codebase already uses elsewhere
* (`TronGridApi.getChainParameters`), just called directly here since `runtime` cannot depend on
* `feature-wallet-impl` (wrong direction) to reuse that Retrofit interface.
*/
class TronNodeHealthStateTester(
private val node: Chain.Node,
private val httpClient: OkHttpClient,
) : NodeHealthStateTester {
@OptIn(ExperimentalTime::class)
override suspend fun testNodeHealthState(): Result<Long> = withContext(Dispatchers.IO) {
runCatching {
val request = Request.Builder()
.url("${node.unformattedUrl.trimEnd('/')}/wallet/getchainparameters")
.build()
val duration = measureTime {
httpClient.newCall(request).execute().use { response ->
check(response.isSuccessful) { "HTTP ${response.code}" }
}
}
duration.inWholeMilliseconds
}
}
}
@@ -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,7 +26,14 @@ 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> {
return flow {
val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata
val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
@@ -53,6 +62,15 @@ abstract class ChainUpdaterGroupUpdateSystem(
} }
} }
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)
}
} }
} }