mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 18:25:50 +00:00
Merge 593fedcdd4 into 0d5a21e5fc
This commit is contained in:
@@ -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\)')
|
||||||
|
|||||||
@@ -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 * * *'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package io.novafoundation.nova.balances
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import io.novafoundation.nova.common.di.FeatureUtils
|
||||||
|
import io.novafoundation.nova.common.utils.fromJson
|
||||||
|
import io.novafoundation.nova.core.model.CryptoType
|
||||||
|
import io.novafoundation.nova.core_db.dao.AssetDao
|
||||||
|
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||||
|
import io.novafoundation.nova.core_db.di.DbApi
|
||||||
|
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
|
||||||
|
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
|
||||||
|
import io.novafoundation.nova.runtime.BuildConfig.TEST_ASSETS_URL
|
||||||
|
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import org.junit.Assert.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 {
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
val stillMissing = fixture.assets.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} total. The real BalancesUpdateSystem " +
|
||||||
|
"pipeline never completed a sync for these - check the standard FullSyncPaymentUpdater/" +
|
||||||
|
"NativeAssetBalance/StatemineAssetBalance error logs in logcat.",
|
||||||
|
stillMissing.isEmpty()
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
updateSystemJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun insertAndSelectWatchAccount(dao: MetaAccountDao, substrateAddress: String): Long {
|
||||||
|
val accountId = substrateAddress.toAccountId()
|
||||||
|
|
||||||
|
val metaAccount = MetaAccountLocal(
|
||||||
|
substratePublicKey = accountId,
|
||||||
|
substrateCryptoType = CryptoType.SR25519,
|
||||||
|
substrateAccountId = accountId,
|
||||||
|
ethereumPublicKey = null,
|
||||||
|
ethereumAddress = null,
|
||||||
|
name = "PezkuwiFullArchitectureBalancesTest",
|
||||||
|
parentMetaId = null,
|
||||||
|
isSelected = false,
|
||||||
|
position = 0,
|
||||||
|
type = MetaAccountLocal.Type.WATCH_ONLY,
|
||||||
|
status = MetaAccountLocal.Status.ACTIVE,
|
||||||
|
globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(),
|
||||||
|
typeExtras = null
|
||||||
|
)
|
||||||
|
|
||||||
|
val metaId = dao.insertMetaAccount(metaAccount)
|
||||||
|
dao.selectMetaAccount(metaId)
|
||||||
|
|
||||||
|
return metaId
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package io.novafoundation.nova.balances
|
||||||
|
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import retrofit2.converter.scalars.ScalarsConverterFactory
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tron is a REST API (TronGrid), not a Substrate runtime - it has no ChainConnection/RuntimeProvider and isn't
|
||||||
|
* reachable through [BalancesIntegrationTest]'s chainRegistry-based mechanism at all. This exercises the exact
|
||||||
|
* same [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi] the production app uses for
|
||||||
|
* balance reads (see TronNativeAssetBalance/Trc20AssetBalance), just via a standalone Retrofit client instead of
|
||||||
|
* the full DI graph, since TronGridApi isn't exposed through a public feature API for tests to reach.
|
||||||
|
*
|
||||||
|
* Test account is the mainnet Founder's Tron address, verified live via TronGrid's public API on 2026-07-09 to
|
||||||
|
* hold a substantial non-zero balance of both native TRX and TRC-20 USDT - not a guessed or empty account.
|
||||||
|
*/
|
||||||
|
class TronBalancesIntegrationTest {
|
||||||
|
|
||||||
|
private val testAddress = "TDGZ4GfvCRe1d8oksj8fBD77ZHw4bkCPBA"
|
||||||
|
private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||||
|
private val baseUrl = "https://api.trongrid.io"
|
||||||
|
|
||||||
|
private val maxAmount = BigInteger.valueOf(10).pow(30)
|
||||||
|
|
||||||
|
private val tronGridApi = run {
|
||||||
|
val retrofit = Retrofit.Builder()
|
||||||
|
.client(OkHttpClient.Builder().build())
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.addConverterFactory(ScalarsConverterFactory.create())
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
RealTronGridApi(retrofit.create(RetrofitTronGridApi::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNativeTrxBalanceLoading() = runBlocking {
|
||||||
|
val freeBalance = tronGridApi.fetchNativeBalance(baseUrl, testAddress)
|
||||||
|
|
||||||
|
assertTrue("TRX balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance)
|
||||||
|
assertTrue("TRX balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTrc20UsdtBalanceLoading() = runBlocking {
|
||||||
|
val freeBalance = tronGridApi.fetchTrc20Balance(baseUrl, testAddress, usdtContractAddress)
|
||||||
|
|
||||||
|
assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance)
|
||||||
|
assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
ext {
|
||||||
// App version
|
// App version
|
||||||
versionName = '1.1.1'
|
versionName = '1.1.2'
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
|
|
||||||
applicationId = "io.pezkuwichain.wallet"
|
applicationId = "io.pezkuwichain.wallet"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.novafoundation.nova.common.utils
|
|||||||
|
|
||||||
import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey
|
import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey
|
||||||
import io.novasama.substrate_sdk_android.extensions.toAccountId
|
import io.novasama.substrate_sdk_android.extensions.toAccountId
|
||||||
|
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||||
import io.novasama.substrate_sdk_android.runtime.AccountId
|
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
@@ -113,3 +114,21 @@ fun String.tronAddressToAccountId(): AccountId {
|
|||||||
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
|
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
|
||||||
|
|
||||||
fun emptyTronAccountId() = ByteArray(20) { 1 }
|
fun emptyTronAccountId() = ByteArray(20) { 1 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hex form of a Tron address (`0x41` prefix byte ++ accountId, hex-encoded, no `0x` prefix), e.g.
|
||||||
|
* `41a614f803b6fd780986a42c78ec9c7f77e6ded13c`. This is the format TronGrid's `/wallet/` transaction
|
||||||
|
* construction/broadcast endpoints expect when called with `"visible": false` (as opposed to the human-facing
|
||||||
|
* Base58Check form used by the `/v1/accounts/{address}` balance endpoint and by [toTronAddress]).
|
||||||
|
*/
|
||||||
|
fun AccountId.toTronHexAddress(): String {
|
||||||
|
require(size == 20) { "Tron account id must be 20 bytes, got $size" }
|
||||||
|
|
||||||
|
return byteArrayOf(TRON_ADDRESS_PREFIX_BYTE).toHexString(withPrefix = false) + toHexString(withPrefix = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a human-facing Base58Check Tron address (e.g. a TRC20 `contractAddress` from chain config) directly
|
||||||
|
* into the hex form described in [toTronHexAddress].
|
||||||
|
*/
|
||||||
|
fun String.tronAddressToHexAddress(): String = tronAddressToAccountId().toTronHexAddress()
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ class TronAddressTest {
|
|||||||
assertTrue(decodedBack.contentEquals(accountId))
|
assertTrue(decodedBack.contentEquals(accountId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toTronHexAddress should produce the known hex form`() {
|
||||||
|
val accountId = knownTronAddressHex.fromHex().copyOfRange(1, 21)
|
||||||
|
|
||||||
|
assertEquals(knownTronAddressHex, accountId.toTronHexAddress())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tronAddressToHexAddress should produce the known hex form directly from a Base58 address`() {
|
||||||
|
assertEquals(knownTronAddressHex, knownTronAddress.tronAddressToHexAddress())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `isValidTronAddress should accept known good address`() {
|
fun `isValidTronAddress should accept known good address`() {
|
||||||
assertTrue(knownTronAddress.isValidTronAddress())
|
assertTrue(knownTronAddress.isValidTronAddress())
|
||||||
|
|||||||
@@ -164,7 +164,12 @@ abstract class ChainDao {
|
|||||||
|
|
||||||
// ------- Queries ------
|
// ------- Queries ------
|
||||||
|
|
||||||
@Query("SELECT * FROM chains")
|
// ORDER BY rowid: without an explicit order, SQLite gives no guarantee about row order for `SELECT *`.
|
||||||
|
// The merged chains.json lists Pezkuwi's own chains first (see wallet-utils' merge_chains()), and that
|
||||||
|
// order is what gets inserted first on initial sync - rowid tracks insertion order, so ordering by it
|
||||||
|
// means Pezkuwi's chains reliably register/connect first instead of being interleaved unpredictably
|
||||||
|
// among the ~90+ Nova-inherited chains, where they could otherwise end up processed last.
|
||||||
|
@Query("SELECT * FROM chains ORDER BY rowid")
|
||||||
@Transaction
|
@Transaction
|
||||||
abstract suspend fun getJoinChainInfo(): List<JoinedChainInfo>
|
abstract suspend fun getJoinChainInfo(): List<JoinedChainInfo>
|
||||||
|
|
||||||
@@ -172,7 +177,7 @@ abstract class ChainDao {
|
|||||||
@Transaction
|
@Transaction
|
||||||
abstract suspend fun getAllChainIds(): List<String>
|
abstract suspend fun getAllChainIds(): List<String>
|
||||||
|
|
||||||
@Query("SELECT * FROM chains")
|
@Query("SELECT * FROM chains ORDER BY rowid")
|
||||||
@Transaction
|
@Transaction
|
||||||
abstract fun joinChainInfoFlow(): Flow<List<JoinedChainInfo>>
|
abstract fun joinChainInfoFlow(): Flow<List<JoinedChainInfo>>
|
||||||
|
|
||||||
|
|||||||
+13
@@ -63,6 +63,19 @@ class SubstrateFee(
|
|||||||
override val asset: Chain.Asset
|
override val asset: Chain.Asset
|
||||||
) : Fee
|
) : Fee
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee for a Tron transaction (native TRX or TRC-20), always denominated in TRX (sun), regardless of which asset
|
||||||
|
* is being sent - Tron has no separate "gas token" concept, network resources (bandwidth/energy) are always
|
||||||
|
* burned as TRX. [amount] is this client's own estimate of that burn (see `RealTronTransactionService`); the
|
||||||
|
* network only ever burns what it actually uses, so the real cost can be lower, but never higher than what this
|
||||||
|
* client authorized via `fee_limit` when submitting.
|
||||||
|
*/
|
||||||
|
class TronFee(
|
||||||
|
override val amount: BigInteger,
|
||||||
|
override val submissionOrigin: SubmissionOrigin,
|
||||||
|
override val asset: Chain.Asset
|
||||||
|
) : Fee
|
||||||
|
|
||||||
class SubstrateFeeBase(
|
class SubstrateFeeBase(
|
||||||
override val amount: BigInteger,
|
override val amount: BigInteger,
|
||||||
override val asset: Chain.Asset
|
override val asset: Chain.Asset
|
||||||
|
|||||||
+5
-1
@@ -49,7 +49,9 @@ class RealSecretsMetaAccount(
|
|||||||
hasChainAccountIn(chain.id) -> {
|
hasChainAccountIn(chain.id) -> {
|
||||||
val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null
|
val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null
|
||||||
|
|
||||||
if (chain.isEthereumBased) {
|
// Tron reuses the same secp256k1 keypair/signing as Ethereum - see
|
||||||
|
// RealTronTransactionService's use of Signer.sign(MultiChainEncryption.Ethereum, ...).
|
||||||
|
if (chain.isEthereumBased || chain.isTronBased) {
|
||||||
MultiChainEncryption.Ethereum
|
MultiChainEncryption.Ethereum
|
||||||
} else {
|
} else {
|
||||||
MultiChainEncryption.substrateFrom(cryptoType)
|
MultiChainEncryption.substrateFrom(cryptoType)
|
||||||
@@ -58,6 +60,8 @@ class RealSecretsMetaAccount(
|
|||||||
|
|
||||||
chain.isEthereumBased -> MultiChainEncryption.Ethereum
|
chain.isEthereumBased -> MultiChainEncryption.Ethereum
|
||||||
|
|
||||||
|
chain.isTronBased -> MultiChainEncryption.Ethereum
|
||||||
|
|
||||||
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -89,6 +89,10 @@ class BalancesUpdateSystem(
|
|||||||
try {
|
try {
|
||||||
updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) }
|
updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// Was silently swallowed here with zero logging - listenForUpdates() itself is a suspend
|
||||||
|
// call that can throw synchronously (e.g. FullSyncPaymentUpdater.listenForUpdates() calling
|
||||||
|
// requireAccountIdIn(chain)), before ever returning a flow for the .catch{} above to guard.
|
||||||
|
Log.e(LOG_TAG, "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e)
|
||||||
emptyFlow()
|
emptyFlow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-4
@@ -2,6 +2,8 @@ package io.novafoundation.nova.feature_assets.presentation.balance.list.view
|
|||||||
|
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.transition.AutoTransition
|
||||||
|
import android.transition.TransitionManager
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -29,6 +31,10 @@ class PezkuwiDashboardAdapter(
|
|||||||
private var model: PezkuwiDashboardModel? = null
|
private var model: PezkuwiDashboardModel? = null
|
||||||
private var trackingLoading: Boolean = false
|
private var trackingLoading: Boolean = false
|
||||||
|
|
||||||
|
// Survives ViewHolder recycling (scroll) within the process, but not process restart —
|
||||||
|
// resets to collapsed (false) whenever the app is freshly opened, by design.
|
||||||
|
private var isExpanded: Boolean = false
|
||||||
|
|
||||||
fun setModel(model: PezkuwiDashboardModel) {
|
fun setModel(model: PezkuwiDashboardModel) {
|
||||||
this.model = model
|
this.model = model
|
||||||
notifyChangedIfShown()
|
notifyChangedIfShown()
|
||||||
@@ -41,11 +47,11 @@ class PezkuwiDashboardAdapter(
|
|||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PezkuwiDashboardHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PezkuwiDashboardHolder {
|
||||||
val binding = ItemPezkuwiDashboardBinding.inflate(parent.inflater(), parent, false)
|
val binding = ItemPezkuwiDashboardBinding.inflate(parent.inflater(), parent, false)
|
||||||
return PezkuwiDashboardHolder(binding, handler)
|
return PezkuwiDashboardHolder(binding, handler) { expanded -> isExpanded = expanded }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: PezkuwiDashboardHolder, position: Int) {
|
override fun onBindViewHolder(holder: PezkuwiDashboardHolder, position: Int) {
|
||||||
model?.let { holder.bind(it, trackingLoading) }
|
model?.let { holder.bind(it, trackingLoading, isExpanded) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
override fun getItemViewType(position: Int): Int {
|
||||||
@@ -55,7 +61,8 @@ class PezkuwiDashboardAdapter(
|
|||||||
|
|
||||||
class PezkuwiDashboardHolder(
|
class PezkuwiDashboardHolder(
|
||||||
private val binder: ItemPezkuwiDashboardBinding,
|
private val binder: ItemPezkuwiDashboardBinding,
|
||||||
handler: PezkuwiDashboardAdapter.Handler
|
handler: PezkuwiDashboardAdapter.Handler,
|
||||||
|
private val onExpandedChanged: (Boolean) -> Unit
|
||||||
) : RecyclerView.ViewHolder(binder.root) {
|
) : RecyclerView.ViewHolder(binder.root) {
|
||||||
|
|
||||||
companion object : WithViewType {
|
companion object : WithViewType {
|
||||||
@@ -67,14 +74,30 @@ class PezkuwiDashboardHolder(
|
|||||||
binder.pezkuwiDashboardSignButton.setOnClickListener { handler.onSignClicked() }
|
binder.pezkuwiDashboardSignButton.setOnClickListener { handler.onSignClicked() }
|
||||||
binder.pezkuwiDashboardShareButton.setOnClickListener { handler.onShareReferralClicked() }
|
binder.pezkuwiDashboardShareButton.setOnClickListener { handler.onShareReferralClicked() }
|
||||||
binder.pezkuwiDashboardStartTrackingButton.setOnClickListener { handler.onStartTrackingClicked() }
|
binder.pezkuwiDashboardStartTrackingButton.setOnClickListener { handler.onStartTrackingClicked() }
|
||||||
|
|
||||||
|
binder.pezkuwiDashboardCollapsedBar.setOnClickListener { setExpanded(true) }
|
||||||
|
binder.pezkuwiDashboardCollapseButton.setOnClickListener { setExpanded(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false) {
|
private fun setExpanded(expanded: Boolean) {
|
||||||
|
TransitionManager.beginDelayedTransition(binder.pezkuwiDashboardRoot, AutoTransition().apply { duration = 200 })
|
||||||
|
binder.pezkuwiDashboardCollapsedBar.visibility = if (expanded) View.GONE else View.VISIBLE
|
||||||
|
binder.pezkuwiDashboardExpandedContent.visibility = if (expanded) View.VISIBLE else View.GONE
|
||||||
|
onExpandedChanged(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false, isExpanded: Boolean = false) {
|
||||||
bindRoles(model.roles)
|
bindRoles(model.roles)
|
||||||
binder.pezkuwiDashboardTrustValue.text = model.trustScore
|
binder.pezkuwiDashboardTrustValue.text = model.trustScore
|
||||||
|
binder.pezkuwiDashboardTrustValueCollapsed.text = model.trustScore
|
||||||
binder.pezkuwiDashboardWelatiCount.text = model.welatiCount
|
binder.pezkuwiDashboardWelatiCount.text = model.welatiCount
|
||||||
bindButtons(model.citizenshipStatus)
|
bindButtons(model.citizenshipStatus)
|
||||||
|
|
||||||
|
// Reflect current expand state without animating (this runs on every bind/rebind,
|
||||||
|
// e.g. after RecyclerView recycling — animation is only for user-initiated toggles).
|
||||||
|
binder.pezkuwiDashboardCollapsedBar.visibility = if (isExpanded) View.GONE else View.VISIBLE
|
||||||
|
binder.pezkuwiDashboardExpandedContent.visibility = if (isExpanded) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
val showTracking = !model.isTrackingScore && model.citizenshipStatus == CitizenshipStatus.APPROVED
|
val showTracking = !model.isTrackingScore && model.citizenshipStatus == CitizenshipStatus.APPROVED
|
||||||
binder.pezkuwiDashboardStartTrackingButton.visibility = if (showTracking) View.VISIBLE else View.GONE
|
binder.pezkuwiDashboardStartTrackingButton.visibility = if (showTracking) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Minimal collapsed-state pill: solid brand green, fully rounded -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
|
||||||
|
<solid android:color="#009639" />
|
||||||
|
|
||||||
|
<corners android:radius="22dp" />
|
||||||
|
</shape>
|
||||||
@@ -10,151 +10,218 @@
|
|||||||
app:strokeWidth="0dp">
|
app:strokeWidth="0dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/pezkuwiDashboardRoot"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/bg_pezkuwi_dashboard"
|
android:orientation="vertical">
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="18dp">
|
|
||||||
|
|
||||||
<!-- Header: flame badge + title/roles (left), citizen count (right) -->
|
<!-- Collapsed state: minimal single-line pill showing only Trust Score.
|
||||||
|
Default/visible on first bind each app session. -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/pezkuwiDashboardCollapsedBar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="44dp"
|
||||||
|
android:background="@drawable/bg_pezkuwi_dashboard_collapsed"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:foreground="?attr/selectableItemBackground"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="40dp"
|
android:layout_width="18dp"
|
||||||
android:layout_height="40dp"
|
android:layout_height="18dp"
|
||||||
android:layout_marginEnd="12dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:src="@drawable/ic_nevroz_flame" />
|
android:src="@drawable/ic_nevroz_flame" />
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="vertical">
|
android:text="@string/pezkuwi_dashboard_trust_score"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/pezkuwiDashboardTitle"
|
android:id="@+id/pezkuwiDashboardTrustValueCollapsed"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:src="@drawable/ic_chevron_down"
|
||||||
|
app:tint="@android:color/white" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Expanded state: full card, hidden by default. -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/pezkuwiDashboardExpandedContent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_pezkuwi_dashboard"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="18dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<!-- Header: flame badge + title/roles (left), citizen count + collapse chevron (right) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="top"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:src="@drawable/ic_nevroz_flame" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pezkuwiDashboardTitle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/pezkuwi_dashboard_title"
|
||||||
|
android:textColor="#E0FFFFFF"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<com.google.android.flexbox.FlexboxLayout
|
||||||
|
android:id="@+id/pezkuwiDashboardRoles"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
app:alignItems="center"
|
||||||
|
app:flexWrap="wrap"
|
||||||
|
app:justifyContent="flex_start" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/pezkuwi_dashboard_title"
|
android:gravity="end"
|
||||||
android:textColor="#E0FFFFFF"
|
android:orientation="vertical">
|
||||||
android:textSize="18sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<com.google.android.flexbox.FlexboxLayout
|
<TextView
|
||||||
android:id="@+id/pezkuwiDashboardRoles"
|
android:id="@+id/pezkuwiDashboardWelatiCount"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="3dp"
|
android:layout_gravity="end"
|
||||||
app:alignItems="center"
|
android:textColor="#2FC864"
|
||||||
app:flexWrap="wrap"
|
android:textSize="24sp"
|
||||||
app:justifyContent="flex_start" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="end"
|
||||||
|
android:text="@string/pezkuwi_dashboard_kurds_title"
|
||||||
|
android:textColor="#7AFFFFFF"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pezkuwiDashboardCollapseButton"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:padding="6dp"
|
||||||
|
android:src="@drawable/ic_chevron_up"
|
||||||
|
app:tint="#7AFFFFFF" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Trust score -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="end"
|
android:layout_marginTop="14dp"
|
||||||
android:orientation="vertical">
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/pezkuwiDashboardWelatiCount"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="end"
|
android:text="@string/pezkuwi_dashboard_trust_score"
|
||||||
android:textColor="#2FC864"
|
android:textColor="#7AFFFFFF"
|
||||||
android:textSize="24sp"
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pezkuwiDashboardTrustValue"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:textColor="#FDB813"
|
||||||
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/pezkuwiDashboardStartTrackingButton"
|
||||||
|
style="@style/Widget.MaterialComponents.Button"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="32dp"
|
||||||
android:layout_gravity="end"
|
android:layout_marginStart="8dp"
|
||||||
android:text="@string/pezkuwi_dashboard_kurds_title"
|
android:minWidth="0dp"
|
||||||
android:textColor="#7AFFFFFF"
|
android:paddingHorizontal="10dp"
|
||||||
android:textSize="10sp" />
|
android:text="@string/pezkuwi_dashboard_start_tracking"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:backgroundTint="#009639"
|
||||||
|
app:cornerRadius="8dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Trust score -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="14dp"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/pezkuwi_dashboard_trust_score"
|
|
||||||
android:textColor="#7AFFFFFF"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/pezkuwiDashboardTrustValue"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:textColor="#FDB813"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/pezkuwiDashboardStartTrackingButton"
|
android:id="@+id/pezkuwiDashboardBasvuruButton"
|
||||||
style="@style/Widget.MaterialComponents.Button"
|
android:layout_width="match_parent"
|
||||||
android:layout_width="wrap_content"
|
android:layout_height="48dp"
|
||||||
android:layout_height="32dp"
|
android:layout_marginTop="16dp"
|
||||||
android:layout_marginStart="8dp"
|
android:text="@string/pezkuwi_dashboard_basvuru"
|
||||||
android:minWidth="0dp"
|
|
||||||
android:paddingHorizontal="10dp"
|
|
||||||
android:text="@string/pezkuwi_dashboard_start_tracking"
|
|
||||||
android:textAllCaps="false"
|
android:textAllCaps="false"
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:textSize="11sp"
|
android:textStyle="bold"
|
||||||
android:visibility="gone"
|
|
||||||
app:backgroundTint="#009639"
|
app:backgroundTint="#009639"
|
||||||
app:cornerRadius="8dp" />
|
app:cornerRadius="14dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/pezkuwiDashboardSignButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/citizenship_sign"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
app:backgroundTint="#E2231A"
|
||||||
|
app:cornerRadius="14dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/pezkuwiDashboardShareButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/citizenship_share_button"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:textColor="#E0FFFFFF"
|
||||||
|
app:backgroundTint="#2A2F45"
|
||||||
|
app:cornerRadius="14dp"
|
||||||
|
app:strokeColor="#33999EC7"
|
||||||
|
app:strokeWidth="1dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/pezkuwiDashboardBasvuruButton"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_marginTop="16dp"
|
|
||||||
android:text="@string/pezkuwi_dashboard_basvuru"
|
|
||||||
android:textAllCaps="false"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
android:textStyle="bold"
|
|
||||||
app:backgroundTint="#009639"
|
|
||||||
app:cornerRadius="14dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/pezkuwiDashboardSignButton"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:text="@string/citizenship_sign"
|
|
||||||
android:textAllCaps="false"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
app:backgroundTint="#E2231A"
|
|
||||||
app:cornerRadius="14dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/pezkuwiDashboardShareButton"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:text="@string/citizenship_share_button"
|
|
||||||
android:textAllCaps="false"
|
|
||||||
android:textColor="#E0FFFFFF"
|
|
||||||
app:backgroundTint="#2A2F45"
|
|
||||||
app:cornerRadius="14dp"
|
|
||||||
app:strokeColor="#33999EC7"
|
|
||||||
app:strokeWidth="1dp" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|||||||
+6
@@ -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
|
||||||
|
|
||||||
|
|||||||
+2
@@ -8,4 +8,6 @@ sealed interface TransactionExecution {
|
|||||||
class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution
|
class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution
|
||||||
|
|
||||||
class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution
|
class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution
|
||||||
|
|
||||||
|
class Tron(val hash: String) : TransactionExecution
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-26
@@ -131,6 +131,10 @@ class StatemineAssetBalance(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deliberately lets setup/subscription failures propagate as exceptions rather than swallowing them into
|
||||||
|
// emptyFlow()/BalanceSyncUpdate.NoCause: the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole
|
||||||
|
// call in a single retryWhen boundary that exists specifically to catch and retry failures like these. If
|
||||||
|
// we swallow here, that boundary never triggers - the asset silently stops syncing instead of retrying.
|
||||||
override suspend fun startSyncingBalance(
|
override suspend fun startSyncingBalance(
|
||||||
chain: Chain,
|
chain: Chain,
|
||||||
chainAsset: Chain.Asset,
|
chainAsset: Chain.Asset,
|
||||||
@@ -138,40 +142,32 @@ class StatemineAssetBalance(
|
|||||||
accountId: AccountId,
|
accountId: AccountId,
|
||||||
subscriptionBuilder: SharedRequestsBuilder
|
subscriptionBuilder: SharedRequestsBuilder
|
||||||
): Flow<BalanceSyncUpdate> {
|
): Flow<BalanceSyncUpdate> {
|
||||||
return runCatching {
|
val runtime = chainRegistry.getRuntime(chain.id)
|
||||||
val runtime = chainRegistry.getRuntime(chain.id)
|
|
||||||
|
|
||||||
val statemineType = chainAsset.requireStatemine()
|
val statemineType = chainAsset.requireStatemine()
|
||||||
val encodableAssetId = statemineType.prepareIdForEncoding(runtime)
|
val encodableAssetId = statemineType.prepareIdForEncoding(runtime)
|
||||||
|
|
||||||
val module = runtime.metadata.statemineModule(statemineType)
|
val module = runtime.metadata.statemineModule(statemineType)
|
||||||
|
|
||||||
val assetAccountStorage = module.storage("Account")
|
val assetAccountStorage = module.storage("Account")
|
||||||
val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId)
|
val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId)
|
||||||
|
|
||||||
val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder)
|
val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder)
|
||||||
|
|
||||||
combine(
|
return combine(
|
||||||
subscriptionBuilder.subscribe(assetAccountKey),
|
subscriptionBuilder.subscribe(assetAccountKey),
|
||||||
assetDetailsFlow.map { it.status.transfersFrozen }
|
assetDetailsFlow.map { it.status.transfersFrozen }
|
||||||
) { balanceStorageChange, isAssetFrozen ->
|
) { balanceStorageChange, isAssetFrozen ->
|
||||||
val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime)
|
val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime)
|
||||||
val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded)
|
val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded)
|
||||||
|
|
||||||
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount)
|
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount)
|
||||||
|
|
||||||
if (assetChanged) {
|
if (assetChanged) {
|
||||||
BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block)
|
BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block)
|
||||||
} else {
|
} else {
|
||||||
BalanceSyncUpdate.NoCause
|
BalanceSyncUpdate.NoCause
|
||||||
}
|
|
||||||
}.catch { error ->
|
|
||||||
Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
|
|
||||||
emit(BalanceSyncUpdate.NoCause)
|
|
||||||
}
|
}
|
||||||
}.getOrElse { error ->
|
|
||||||
Log.e(LOG_TAG, "Failed to start balance sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
|
|
||||||
emptyFlow()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-24
@@ -1,6 +1,7 @@
|
|||||||
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility
|
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo
|
||||||
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
|
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
|
||||||
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
|
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
|
||||||
import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum
|
import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum
|
||||||
@@ -17,7 +18,6 @@ import io.novafoundation.nova.core_db.dao.LockDao
|
|||||||
import io.novafoundation.nova.core_db.model.BalanceHoldLocal
|
import io.novafoundation.nova.core_db.model.BalanceHoldLocal
|
||||||
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
|
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
|
||||||
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
|
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
|
||||||
import io.novafoundation.nova.feature_wallet_api.data.cache.bindAccountInfoOrDefault
|
|
||||||
import io.novafoundation.nova.feature_wallet_api.data.cache.updateAsset
|
import io.novafoundation.nova.feature_wallet_api.data.cache.updateAsset
|
||||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
|
||||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
|
||||||
@@ -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.
|
||||||
|
//
|
||||||
|
// Uses the typed remoteStorage.subscribe { metadata.system.account... } DSL (same as this class's own
|
||||||
|
// subscribeAccountBalanceUpdatePoint() and PooledBalanceUpdater/BalanceLocksUpdater) instead of a raw
|
||||||
|
// subscriptionBuilder.subscribe(key) call: on chains with several other assets/updaters already sharing
|
||||||
|
// the same SharedRequestsBuilder (e.g. Pezkuwi Asset Hub's 5 statemine assets + nomination-pools updater),
|
||||||
|
// the raw form's System.Account subscription was silently never reaching the wire - no exception, no data,
|
||||||
|
// ever - while every other asset on the same chain synced fine. The typed DSL is what every other caller
|
||||||
|
// on a busy shared connection already uses successfully.
|
||||||
override suspend fun startSyncingBalance(
|
override suspend fun startSyncingBalance(
|
||||||
chain: Chain,
|
chain: Chain,
|
||||||
chainAsset: Chain.Asset,
|
chainAsset: Chain.Asset,
|
||||||
@@ -158,31 +169,18 @@ class NativeAssetBalance(
|
|||||||
accountId: AccountId,
|
accountId: AccountId,
|
||||||
subscriptionBuilder: SharedRequestsBuilder
|
subscriptionBuilder: SharedRequestsBuilder
|
||||||
): Flow<BalanceSyncUpdate> {
|
): Flow<BalanceSyncUpdate> {
|
||||||
val runtime = chainRegistry.getRuntime(chain.id)
|
return remoteStorage.subscribe(chain.id, subscriptionBuilder) {
|
||||||
|
metadata.system.account.observeWithRaw(accountId)
|
||||||
|
}.map { change ->
|
||||||
|
val accountInfo = change.value ?: AccountInfo.empty()
|
||||||
|
val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo)
|
||||||
|
|
||||||
val key = try {
|
if (assetChanged) {
|
||||||
runtime.metadata.system().storage("Account").storageKey(runtime, accountId)
|
BalanceSyncUpdate.CauseFetchable(change.at!!)
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
Log.e(LOG_TAG, "Failed to construct account storage key: ${e.message} in ${chain.name}")
|
BalanceSyncUpdate.NoCause
|
||||||
|
}
|
||||||
return emptyFlow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return subscriptionBuilder.subscribe(key)
|
|
||||||
.map { change ->
|
|
||||||
val accountInfo = bindAccountInfoOrDefault(change.value, runtime)
|
|
||||||
val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo)
|
|
||||||
|
|
||||||
if (assetChanged) {
|
|
||||||
BalanceSyncUpdate.CauseFetchable(change.block)
|
|
||||||
} else {
|
|
||||||
BalanceSyncUpdate.NoCause
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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>? {
|
||||||
|
|||||||
+99
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-14
@@ -22,9 +22,14 @@ import io.novafoundation.nova.runtime.ext.enabledAssets
|
|||||||
import io.novafoundation.nova.runtime.ext.localId
|
import io.novafoundation.nova.runtime.ext.localId
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
import io.novasama.substrate_sdk_android.runtime.AccountId
|
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
|
||||||
|
private const val SYNC_RETRY_DELAY_MS = 30_000L
|
||||||
|
|
||||||
internal class FullSyncPaymentUpdater(
|
internal class FullSyncPaymentUpdater(
|
||||||
private val operationDao: OperationDao,
|
private val operationDao: OperationDao,
|
||||||
@@ -41,32 +46,43 @@ internal class FullSyncPaymentUpdater(
|
|||||||
): Flow<Updater.SideEffect> {
|
): Flow<Updater.SideEffect> {
|
||||||
val accountId = scopeValue.requireAccountIdIn(chain)
|
val accountId = scopeValue.requireAccountIdIn(chain)
|
||||||
|
|
||||||
return chain.enabledAssets().mapNotNull { chainAsset ->
|
return chain.enabledAssets().map { chainAsset ->
|
||||||
syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder)
|
syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder)
|
||||||
}
|
}
|
||||||
.mergeIfMultiple()
|
.mergeIfMultiple()
|
||||||
.noSideAffects()
|
.noSideAffects()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncAsset(
|
/**
|
||||||
|
* Wraps both the initial `startSyncingBalance()` call and the resulting flow in a single retry
|
||||||
|
* boundary. Without this, any transient failure - during initial subscription setup (a WSS
|
||||||
|
* hiccup, an orml currencyId-decode edge case, a node not supporting a specific RPC method
|
||||||
|
* during round-robin) or later in the flow (a dropped connection) - would permanently and
|
||||||
|
* silently kill sync for that one asset: no DB row ever gets created/updated for it, so it
|
||||||
|
* vanishes from every UI screen with nothing but a logcat line as evidence, until the app is
|
||||||
|
* restarted (and even then, with the same odds of failing again). Retrying indefinitely on a
|
||||||
|
* fixed interval matches the same fix already applied to Tron's balance polling.
|
||||||
|
*/
|
||||||
|
private fun syncAsset(
|
||||||
chainAsset: Chain.Asset,
|
chainAsset: Chain.Asset,
|
||||||
metaAccount: MetaAccount,
|
metaAccount: MetaAccount,
|
||||||
accountId: AccountId,
|
accountId: AccountId,
|
||||||
storageSubscriptionBuilder: SharedRequestsBuilder
|
storageSubscriptionBuilder: SharedRequestsBuilder
|
||||||
): Flow<BalanceSyncUpdate>? {
|
): Flow<BalanceSyncUpdate> {
|
||||||
val assetSource = assetSourceRegistry.sourceFor(chainAsset)
|
val assetSource = assetSourceRegistry.sourceFor(chainAsset)
|
||||||
|
|
||||||
val assetUpdateFlow = runCatching {
|
return flow {
|
||||||
assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder)
|
val assetUpdateFlow = assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder)
|
||||||
|
emitAll(assetUpdateFlow)
|
||||||
}
|
}
|
||||||
.onFailure { logSyncError(chain, chainAsset, error = it) }
|
.onEach { balanceUpdate ->
|
||||||
.getOrNull()
|
assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId)
|
||||||
?: return null
|
}
|
||||||
|
.retryWhen { cause, _ ->
|
||||||
return assetUpdateFlow.onEach { balanceUpdate ->
|
logSyncError(chain, chainAsset, error = cause)
|
||||||
assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId)
|
delay(SYNC_RETRY_DELAY_MS)
|
||||||
}
|
true
|
||||||
.catch { logSyncError(chain, chainAsset, error = it) }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) {
|
private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) {
|
||||||
|
|||||||
+35
@@ -1,9 +1,20 @@
|
|||||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
||||||
|
|
||||||
import io.novafoundation.nova.common.data.network.UserAgent
|
import io.novafoundation.nova.common.data.network.UserAgent
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
|
||||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResponse
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronChainParametersResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
|
||||||
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Headers
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Url
|
import retrofit2.http.Url
|
||||||
|
|
||||||
interface RetrofitTronGridApi {
|
interface RetrofitTronGridApi {
|
||||||
@@ -11,4 +22,28 @@ interface RetrofitTronGridApi {
|
|||||||
@GET
|
@GET
|
||||||
@Headers(UserAgent.NOVA)
|
@Headers(UserAgent.NOVA)
|
||||||
suspend fun getAccount(@Url url: String): TronAccountResponse
|
suspend fun getAccount(@Url url: String): TronAccountResponse
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun createTransaction(@Url url: String, @Body body: TronCreateTransactionRequest): TronUnsignedTransactionResponse
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun triggerConstantContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun triggerSmartContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun broadcastTransaction(@Url url: String, @Body body: TronBroadcastRequest): TronBroadcastResponse
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun getChainParameters(@Url url: String): TronChainParametersResponse
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun getAccountResource(@Url url: String, @Body body: TronAddressRequest): TronAccountResourceResponse
|
||||||
}
|
}
|
||||||
|
|||||||
+184
@@ -1,13 +1,82 @@
|
|||||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
||||||
|
|
||||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
|
||||||
|
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown whenever TronGrid reports a failure via an HTTP-200 body (rather than an HTTP error status), which is
|
||||||
|
* how most `/wallet/` endpoints signal validation/execution failures, e.g.
|
||||||
|
* `{"Error": "... no OwnerAccount."}` from `createtransaction`, or
|
||||||
|
* `{"code": "CONTRACT_VALIDATE_ERROR", "message": "<hex>"}` from `broadcasttransaction`.
|
||||||
|
*/
|
||||||
|
class TronApiException(message: String) : Exception(message)
|
||||||
|
|
||||||
interface TronGridApi {
|
interface TronGridApi {
|
||||||
|
|
||||||
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
|
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
|
||||||
|
|
||||||
suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance
|
suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`.
|
||||||
|
* [ownerHexAddress]/[toHexAddress] must be in hex form (`41`-prefixed), matching `visible: false`.
|
||||||
|
*
|
||||||
|
* Note: TronGrid refuses to build this for an owner account that has never been activated on-chain
|
||||||
|
* (confirmed live: returns `{"Error": "... no OwnerAccount."}`) - unlike [triggerSmartContract], which
|
||||||
|
* happily builds a transaction for an unfunded/unactivated owner.
|
||||||
|
*/
|
||||||
|
suspend fun createNativeTransfer(baseUrl: String, ownerHexAddress: String, toHexAddress: String, amountSun: BigInteger): TronUnsignedTransactionResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only dry run via `POST /wallet/triggerconstantcontract` - does not require the owner account to hold
|
||||||
|
* any TRX and does not touch chain state. Used to estimate the `energy_used` an actual TRC-20 call would
|
||||||
|
* cost (see [TronTriggerContractResponse.energyUsed]).
|
||||||
|
*/
|
||||||
|
suspend fun triggerConstantContract(
|
||||||
|
baseUrl: String,
|
||||||
|
ownerHexAddress: String,
|
||||||
|
contractHexAddress: String,
|
||||||
|
functionSelector: String,
|
||||||
|
parameterHex: String
|
||||||
|
): TronTriggerContractResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an unsigned TRC-20 contract call via `POST /wallet/triggersmartcontract`. Unlike
|
||||||
|
* [createNativeTransfer], this works even for an owner account that has never been activated on-chain
|
||||||
|
* (confirmed live).
|
||||||
|
*/
|
||||||
|
suspend fun triggerSmartContract(
|
||||||
|
baseUrl: String,
|
||||||
|
ownerHexAddress: String,
|
||||||
|
contractHexAddress: String,
|
||||||
|
functionSelector: String,
|
||||||
|
parameterHex: String,
|
||||||
|
feeLimitSun: BigInteger
|
||||||
|
): TronTriggerContractResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs-and-submits via `POST /wallet/broadcasttransaction`. The full [unsigned] transaction (including its
|
||||||
|
* `raw_data` object, not just `raw_data_hex`) must be echoed back verbatim alongside the signature - sending
|
||||||
|
* only `raw_data_hex` + `signature` was confirmed live to fail with a deserialization error on TronGrid's side.
|
||||||
|
*
|
||||||
|
* @return the transaction hash (`txID`) on success.
|
||||||
|
* @throws TronApiException if TronGrid rejects the broadcast (invalid signature, insufficient balance, etc.)
|
||||||
|
*/
|
||||||
|
suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String
|
||||||
|
|
||||||
|
/** `key -> value` map from `GET /wallet/getchainparameters`, e.g. `getEnergyFee` (sun/energy), `getTransactionFee` (sun/byte). */
|
||||||
|
suspend fun getChainParameters(baseUrl: String): Map<String, Long>
|
||||||
|
|
||||||
|
suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
class RealTronGridApi(
|
class RealTronGridApi(
|
||||||
@@ -29,6 +98,98 @@ class RealTronGridApi(
|
|||||||
return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO
|
return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun createNativeTransfer(
|
||||||
|
baseUrl: String,
|
||||||
|
ownerHexAddress: String,
|
||||||
|
toHexAddress: String,
|
||||||
|
amountSun: BigInteger
|
||||||
|
): TronUnsignedTransactionResponse {
|
||||||
|
val request = TronCreateTransactionRequest(
|
||||||
|
ownerAddress = ownerHexAddress,
|
||||||
|
toAddress = toHexAddress,
|
||||||
|
amount = amountSun.toLongExactOrThrow("amount")
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request)
|
||||||
|
|
||||||
|
return response.requireConstructed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun triggerConstantContract(
|
||||||
|
baseUrl: String,
|
||||||
|
ownerHexAddress: String,
|
||||||
|
contractHexAddress: String,
|
||||||
|
functionSelector: String,
|
||||||
|
parameterHex: String
|
||||||
|
): TronTriggerContractResponse {
|
||||||
|
val request = TronTriggerContractRequest(
|
||||||
|
ownerAddress = ownerHexAddress,
|
||||||
|
contractAddress = contractHexAddress,
|
||||||
|
functionSelector = functionSelector,
|
||||||
|
parameter = parameterHex
|
||||||
|
)
|
||||||
|
|
||||||
|
return retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun triggerSmartContract(
|
||||||
|
baseUrl: String,
|
||||||
|
ownerHexAddress: String,
|
||||||
|
contractHexAddress: String,
|
||||||
|
functionSelector: String,
|
||||||
|
parameterHex: String,
|
||||||
|
feeLimitSun: BigInteger
|
||||||
|
): TronTriggerContractResponse {
|
||||||
|
val request = TronTriggerContractRequest(
|
||||||
|
ownerAddress = ownerHexAddress,
|
||||||
|
contractAddress = contractHexAddress,
|
||||||
|
functionSelector = functionSelector,
|
||||||
|
parameter = parameterHex,
|
||||||
|
feeLimit = feeLimitSun.toLongExactOrThrow("feeLimit")
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request)
|
||||||
|
|
||||||
|
if (response.result?.result != true) {
|
||||||
|
throw TronApiException(response.result?.message ?: response.result?.code ?: "triggersmartcontract failed without a message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only validate that a transaction was actually returned - callers read [TronTriggerContractResponse.transaction] themselves.
|
||||||
|
response.transaction?.requireConstructed()
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String {
|
||||||
|
val txId = requireNotNull(unsigned.txID) { "Cannot broadcast a transaction without a txID" }
|
||||||
|
|
||||||
|
val request = TronBroadcastRequest(
|
||||||
|
visible = unsigned.visible ?: false,
|
||||||
|
txID = txId,
|
||||||
|
rawData = requireNotNull(unsigned.rawData) { "Cannot broadcast a transaction without raw_data" },
|
||||||
|
rawDataHex = requireNotNull(unsigned.rawDataHex) { "Cannot broadcast a transaction without raw_data_hex" },
|
||||||
|
signature = listOf(signatureHex)
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request)
|
||||||
|
|
||||||
|
if (response.result != true) {
|
||||||
|
throw TronApiException(response.decodeErrorMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.txid ?: txId
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getChainParameters(baseUrl: String): Map<String, Long> {
|
||||||
|
return retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters"))
|
||||||
|
.chainParameter
|
||||||
|
.associate { it.key to it.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse {
|
||||||
|
return retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex))
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount(
|
private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount(
|
||||||
url = accountUrl(baseUrl, address)
|
url = accountUrl(baseUrl, address)
|
||||||
).data?.firstOrNull()
|
).data?.firstOrNull()
|
||||||
@@ -36,4 +197,27 @@ class RealTronGridApi(
|
|||||||
private fun accountUrl(baseUrl: String, address: String): String {
|
private fun accountUrl(baseUrl: String, address: String): String {
|
||||||
return "${baseUrl.trimEnd('/')}/v1/accounts/$address"
|
return "${baseUrl.trimEnd('/')}/v1/accounts/$address"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun walletUrl(baseUrl: String, method: String): String {
|
||||||
|
return "${baseUrl.trimEnd('/')}/wallet/$method"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TronUnsignedTransactionResponse.requireConstructed(): TronUnsignedTransactionResponse {
|
||||||
|
if (error != null) throw TronApiException(error)
|
||||||
|
requireNotNull(rawDataHex) { "TronGrid returned no raw_data_hex and no Error" }
|
||||||
|
requireNotNull(txID) { "TronGrid returned no txID and no Error" }
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TronBroadcastResponse.decodeErrorMessage(): String {
|
||||||
|
val decodedMessage = message?.let { hex -> runCatching { hex.fromHex().decodeToString() }.getOrNull() }
|
||||||
|
|
||||||
|
return decodedMessage ?: code ?: "broadcasttransaction failed without a message"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BigInteger.toLongExactOrThrow(fieldName: String): Long {
|
||||||
|
return runCatching { longValueExact() }
|
||||||
|
.getOrElse { throw IllegalArgumentException("$fieldName overflows Long: $this") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
@@ -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,
|
||||||
|
)
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.utils.castOrNull
|
||||||
|
import io.novafoundation.nova.common.utils.sha256
|
||||||
|
import io.novafoundation.nova.common.utils.toEcdsaSignatureData
|
||||||
|
import io.novafoundation.nova.common.utils.toTronHexAddress
|
||||||
|
import io.novafoundation.nova.common.utils.tronAddressToHexAddress
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.model.Fee
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.model.TronFee
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
|
||||||
|
import io.novafoundation.nova.runtime.ext.commissionAsset
|
||||||
|
import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
|
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||||
|
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
private const val ENERGY_FEE_PARAM_KEY = "getEnergyFee"
|
||||||
|
private const val TRANSACTION_FEE_PARAM_KEY = "getTransactionFee"
|
||||||
|
|
||||||
|
// Fallbacks only used if a live `getchainparameters` call fails or is missing the expected key - both values are
|
||||||
|
// what was observed live on Shasta testnet at implementation time, which also match Tron's long-standing mainnet
|
||||||
|
// defaults; the primary path always fetches live values.
|
||||||
|
private val FALLBACK_ENERGY_FEE_SUN = BigInteger.valueOf(420)
|
||||||
|
private val FALLBACK_BANDWIDTH_FEE_SUN = BigInteger.valueOf(1000)
|
||||||
|
|
||||||
|
// Used only if a triggerconstantcontract dry run fails outright (e.g. transient network error) and returns no
|
||||||
|
// energy_used at all - a conservative (intentionally high) stand-in for a simple TRC-20 transfer, which in
|
||||||
|
// practice costs on the order of 15-30k energy. Mirrors EvmErc20AssetTransfers' ERC_20_UPPER_GAS_LIMIT fallback.
|
||||||
|
private val FALLBACK_TRC20_ENERGY_UNITS = 65_000L
|
||||||
|
private val FALLBACK_TRC20_TX_SIZE_BYTES = 350L
|
||||||
|
|
||||||
|
// fee_limit sent with triggersmartcontract: Tron only ever burns what a call actually uses (up to this cap), so
|
||||||
|
// setting this generously above our own estimate does not cost the user more - it only avoids an OUT_OF_ENERGY
|
||||||
|
// failure if our estimate undershoots. Bounded above as a sanity guard against a runaway estimate.
|
||||||
|
private val MIN_FEE_LIMIT_SUN = BigInteger.valueOf(15_000_000) // 15 TRX
|
||||||
|
private val MAX_FEE_LIMIT_SUN = BigInteger.valueOf(100_000_000) // 100 TRX
|
||||||
|
|
||||||
|
private val EMPTY_RESOURCE = TronAccountResourceResponse()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds, signs and broadcasts Tron transactions (native TRX and TRC-20) using TronGrid's own REST endpoints for
|
||||||
|
* construction/broadcast, and this app's existing ECDSA signing primitive for signing - no Tron protobuf
|
||||||
|
* (`Transaction`/`TransferContract`/`TriggerSmartContract`) encoding and no new crypto library were added.
|
||||||
|
*
|
||||||
|
* ## Construction
|
||||||
|
* - Native TRX: `POST /wallet/createtransaction` with `{owner_address, to_address, amount}` (all hex-encoded,
|
||||||
|
* `visible: false`). Requires the owner account to already be activated on-chain (confirmed live: an
|
||||||
|
* unactivated owner gets `{"Error": "... no OwnerAccount."}`) - in practice this is never hit here, since a
|
||||||
|
* user can only reach the send flow with a positive TRX balance to send from, which itself implies the account
|
||||||
|
* was already activated by an earlier incoming transfer.
|
||||||
|
* - TRC-20: `POST /wallet/triggersmartcontract` with the ABI-encoded `transfer(address,uint256)` call (see
|
||||||
|
* [Trc20TransferAbi]). Unlike `createtransaction`, this was confirmed live to work even for a
|
||||||
|
* never-activated owner account.
|
||||||
|
*
|
||||||
|
* ## Signing
|
||||||
|
* Tron's signature is `ECDSA_sign(privateKey, sha256(raw_data))` over secp256k1 - the same curve/primitive this
|
||||||
|
* app already uses for Ethereum. [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw]
|
||||||
|
* (backed by `substrate_sdk_android`'s `Signer.sign(MultiChainEncryption.Ethereum, message, keypair, skipHashing)`
|
||||||
|
* -> `web3j`'s `Sign.signMessage(hash, keyPair, needToHash = false)`) already supports signing a pre-computed
|
||||||
|
* hash directly via `SignerPayloadRaw.skipMessageHashing = true` - this is exactly the primitive Ethereum-style
|
||||||
|
* raw-hash signing needs, and it is reused as-is here. No new cryptographic code or library was added; only the
|
||||||
|
* hash fed into it differs from the EVM path (`sha256(raw_data)` here vs. an EIP-155 RLP-based digest there).
|
||||||
|
*
|
||||||
|
* `web3j`'s `Sign.signMessage` always left-pads `r`/`s` to exactly 32 bytes and encodes `v` as `27/28` (confirmed
|
||||||
|
* by reading `web3j`'s `Sign.java` source) - which is byte-for-byte the same `r(32) + s(32) + v(1)` = 65-byte
|
||||||
|
* compact signature format Tron expects (confirmed against `tronweb`'s own `ECKeySign` implementation, and
|
||||||
|
* independently confirmed live against Shasta testnet - see the Phase 2 implementation notes).
|
||||||
|
*
|
||||||
|
* ## Broadcast
|
||||||
|
* `POST /wallet/broadcasttransaction` with the full unsigned transaction object (not just `raw_data_hex`) plus
|
||||||
|
* `signature: [<65-byte hex signature>]`.
|
||||||
|
*/
|
||||||
|
class RealTronTransactionService(
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
|
private val signerProvider: SignerProvider,
|
||||||
|
private val tronGridApi: TronGridApi,
|
||||||
|
) : TronTransactionService {
|
||||||
|
|
||||||
|
override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee {
|
||||||
|
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
|
||||||
|
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
|
||||||
|
val baseUrl = chain.requireTronGridBaseUrl()
|
||||||
|
val ownerHex = ownerAccountId.toTronHexAddress()
|
||||||
|
|
||||||
|
val feeSun = when (intent) {
|
||||||
|
is TronTransactionIntent.Native -> estimateNativeFee(baseUrl, ownerHex, recipient, intent.amountSun)
|
||||||
|
is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex(
|
||||||
|
baseUrl,
|
||||||
|
ownerHex,
|
||||||
|
recipient,
|
||||||
|
intent.contractAddress.tronAddressToHexAddress(),
|
||||||
|
intent.amountSun
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TronFee(feeSun, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transact(
|
||||||
|
chain: Chain,
|
||||||
|
origin: TransactionOrigin,
|
||||||
|
recipient: AccountId,
|
||||||
|
presetFee: Fee?,
|
||||||
|
intent: TronTransactionIntent
|
||||||
|
): Result<ExtrinsicSubmission> = runCatching {
|
||||||
|
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
|
||||||
|
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
|
||||||
|
val baseUrl = chain.requireTronGridBaseUrl()
|
||||||
|
val ownerHex = ownerAccountId.toTronHexAddress()
|
||||||
|
val recipientHex = recipient.toTronHexAddress()
|
||||||
|
|
||||||
|
val unsigned = when (intent) {
|
||||||
|
is TronTransactionIntent.Native -> tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipientHex, intent.amountSun)
|
||||||
|
|
||||||
|
is TronTransactionIntent.Trc20Transfer -> {
|
||||||
|
val contractHex = intent.contractAddress.tronAddressToHexAddress()
|
||||||
|
val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, intent.amountSun)
|
||||||
|
val feeLimit = feeLimitFor(presetFee, baseUrl, ownerHex, recipient, contractHex, intent.amountSun)
|
||||||
|
|
||||||
|
tronGridApi.triggerSmartContract(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
ownerHexAddress = ownerHex,
|
||||||
|
contractHexAddress = contractHex,
|
||||||
|
functionSelector = Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR,
|
||||||
|
parameterHex = parameterHex,
|
||||||
|
feeLimitSun = feeLimit
|
||||||
|
).transaction ?: error("TronGrid returned no transaction from triggersmartcontract")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val txHash = signAndBroadcast(baseUrl, unsigned, submittingMetaAccount, ownerAccountId)
|
||||||
|
|
||||||
|
ExtrinsicSubmission(
|
||||||
|
hash = txHash,
|
||||||
|
submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId),
|
||||||
|
callExecutionType = CallExecutionType.IMMEDIATE,
|
||||||
|
submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transactAndAwaitExecution(
|
||||||
|
chain: Chain,
|
||||||
|
origin: TransactionOrigin,
|
||||||
|
recipient: AccountId,
|
||||||
|
presetFee: Fee?,
|
||||||
|
intent: TronTransactionIntent
|
||||||
|
): Result<TransactionExecution> {
|
||||||
|
// Tron transactions execute atomically with inclusion (no separate "prepare" step, same as EVM) - so
|
||||||
|
// successful broadcast is already a strong signal. We do not poll for block confirmation here since
|
||||||
|
// this method sits outside the primary send flow's critical path (`SendInteractor`/`RealSendUseCase`
|
||||||
|
// only ever call `transact`, not this) - see Phase 2 implementation notes for what remains unverified.
|
||||||
|
return transact(chain, origin, recipient, presetFee, intent).map { TransactionExecution.Tron(it.hash) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun signAndBroadcast(
|
||||||
|
baseUrl: String,
|
||||||
|
unsigned: TronUnsignedTransactionResponse,
|
||||||
|
metaAccount: MetaAccount,
|
||||||
|
ownerAccountId: AccountId
|
||||||
|
): String {
|
||||||
|
val rawDataHex = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }
|
||||||
|
val messageHash = rawDataHex.fromHex().sha256()
|
||||||
|
|
||||||
|
check(unsigned.txID == null || messageHash.toHexString(withPrefix = false) == unsigned.txID) {
|
||||||
|
"sha256(raw_data) does not match the txID TronGrid reported - refusing to sign a possibly-tampered transaction"
|
||||||
|
}
|
||||||
|
|
||||||
|
val signer = signerProvider.rootSignerFor(metaAccount)
|
||||||
|
val signedRaw = signer.signRaw(SignerPayloadRaw(message = messageHash, accountId = ownerAccountId, skipMessageHashing = true))
|
||||||
|
val signature = signedRaw.toEcdsaSignatureData()
|
||||||
|
|
||||||
|
// Tron's compact signature format is r(32) + s(32) + v(1), v = 27/28 - byte-for-byte identical to what
|
||||||
|
// web3j's Sign.SignatureData already produces for Ethereum signing (see class doc for verification notes).
|
||||||
|
val signatureBytes = signature.r + signature.s + signature.v
|
||||||
|
val signatureHex = signatureBytes.toHexString(withPrefix = false)
|
||||||
|
|
||||||
|
return tronGridApi.broadcastTransaction(baseUrl, unsigned, signatureHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun feeLimitFor(
|
||||||
|
presetFee: Fee?,
|
||||||
|
baseUrl: String,
|
||||||
|
ownerHex: String,
|
||||||
|
recipient: AccountId,
|
||||||
|
contractHex: String,
|
||||||
|
amountSun: BigInteger
|
||||||
|
): BigInteger {
|
||||||
|
val estimatedFee = presetFee?.castOrNull<TronFee>()?.amount
|
||||||
|
?: estimateTrc20FeeFromContractHex(baseUrl, ownerHex, recipient, contractHex, amountSun)
|
||||||
|
|
||||||
|
return (estimatedFee * BigInteger.valueOf(3)).coerceIn(MIN_FEE_LIMIT_SUN, MAX_FEE_LIMIT_SUN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun estimateNativeFee(baseUrl: String, ownerHex: String, recipient: AccountId, amountSun: BigInteger): BigInteger {
|
||||||
|
val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), amountSun)
|
||||||
|
val txSizeBytes = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }.length / 2
|
||||||
|
|
||||||
|
val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE)
|
||||||
|
val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN)
|
||||||
|
|
||||||
|
val bandwidthShortfall = shortfall(txSizeBytes.toLong(), resource.availableBandwidth())
|
||||||
|
|
||||||
|
return bandwidthShortfall.toBigInteger() * bandwidthPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun estimateTrc20FeeFromContractHex(
|
||||||
|
baseUrl: String,
|
||||||
|
ownerHex: String,
|
||||||
|
recipient: AccountId,
|
||||||
|
contractHex: String,
|
||||||
|
amountSun: BigInteger
|
||||||
|
): BigInteger {
|
||||||
|
val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, amountSun)
|
||||||
|
|
||||||
|
val dryRun = runCatching {
|
||||||
|
tronGridApi.triggerConstantContract(baseUrl, ownerHex, contractHex, Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, parameterHex)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
// A dry-run revert (e.g. the sender doesn't yet hold the token) still reports the energy spent up to the
|
||||||
|
// revert point, which remains a meaningful (if slightly different) estimate - it is used as-is rather
|
||||||
|
// than special-cased, only a wholly-failed HTTP call falls back to the conservative constant.
|
||||||
|
val energyUsed = dryRun?.energyUsed ?: FALLBACK_TRC20_ENERGY_UNITS
|
||||||
|
val txSizeBytes = dryRun?.transaction?.rawDataHex?.let { it.length / 2L } ?: FALLBACK_TRC20_TX_SIZE_BYTES
|
||||||
|
|
||||||
|
val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE)
|
||||||
|
val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN)
|
||||||
|
val energyPrice = chainParameterOrDefault(baseUrl, ENERGY_FEE_PARAM_KEY, FALLBACK_ENERGY_FEE_SUN)
|
||||||
|
|
||||||
|
val bandwidthShortfall = shortfall(txSizeBytes, resource.availableBandwidth())
|
||||||
|
val energyShortfall = shortfall(energyUsed, resource.availableEnergy())
|
||||||
|
|
||||||
|
return bandwidthShortfall.toBigInteger() * bandwidthPrice + energyShortfall.toBigInteger() * energyPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun chainParameterOrDefault(baseUrl: String, key: String, default: BigInteger): BigInteger {
|
||||||
|
val params = runCatching { tronGridApi.getChainParameters(baseUrl) }.getOrNull()
|
||||||
|
|
||||||
|
return params?.get(key)?.toBigInteger() ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortfall(needed: Long, available: Long): Long = (needed - available.coerceAtLeast(0)).coerceAtLeast(0)
|
||||||
|
|
||||||
|
private fun TronAccountResourceResponse.availableBandwidth(): Long =
|
||||||
|
(freeNetLimit - freeNetUsed).coerceAtLeast(0) + (netLimit - netUsed).coerceAtLeast(0)
|
||||||
|
|
||||||
|
private fun TronAccountResourceResponse.availableEnergy(): Long =
|
||||||
|
(energyLimit - energyUsed).coerceAtLeast(0)
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
|
||||||
|
|
||||||
|
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal, hand-written Solidity ABI encoding for the single call this client ever makes to a TRC-20 contract:
|
||||||
|
* `transfer(address,uint256)`.
|
||||||
|
*
|
||||||
|
* There is no pre-existing ABI-encoding utility reused here: Phase 1's TRC-20 balance reads
|
||||||
|
* (`Trc20AssetBalance`) go through TronGrid's `/v1/accounts` REST endpoint, not an on-chain `balanceOf` call -
|
||||||
|
* so no prior ABI-encoding code exists in this codebase.
|
||||||
|
*
|
||||||
|
* Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad
|
||||||
|
* each to 32 bytes and concatenate" - no dynamic-type/offset table is needed. The 4-byte function selector is
|
||||||
|
* intentionally NOT computed client-side: TronGrid accepts the human-readable `function_selector` string
|
||||||
|
* directly and hashes it server-side (confirmed live against Shasta testnet - a call with
|
||||||
|
* `function_selector: "transfer(address,uint256)"` and no client-computed selector correctly resolved to the
|
||||||
|
* standard `a9059cbb` selector in the resulting `raw_data`), which avoids needing a keccak256 implementation here.
|
||||||
|
*/
|
||||||
|
object Trc20TransferAbi {
|
||||||
|
|
||||||
|
const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address -
|
||||||
|
* ABI-encoded Solidity `address` parameters use the bare 20-byte form, confirmed live).
|
||||||
|
*/
|
||||||
|
fun encodeTransferParameters(recipient: AccountId, amountSun: BigInteger): String {
|
||||||
|
require(recipient.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${recipient.size}" }
|
||||||
|
require(amountSun.signum() >= 0) { "Amount must not be negative, got $amountSun" }
|
||||||
|
|
||||||
|
val addressParam = recipient.toHexString(withPrefix = false).padStart(64, '0')
|
||||||
|
val amountParam = amountSun.toString(16).padStart(64, '0')
|
||||||
|
|
||||||
|
return addressParam + amountParam
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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>
|
||||||
|
}
|
||||||
+3
@@ -37,6 +37,7 @@ import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicServic
|
|||||||
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
|
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
|
||||||
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade
|
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade
|
||||||
import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository
|
import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
|
||||||
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
||||||
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
|
||||||
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
|
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
|
||||||
@@ -74,6 +75,8 @@ interface WalletFeatureDependencies {
|
|||||||
|
|
||||||
val evmTransactionService: EvmTransactionService
|
val evmTransactionService: EvmTransactionService
|
||||||
|
|
||||||
|
val signerProvider: SignerProvider
|
||||||
|
|
||||||
val chainAssetDao: ChainAssetDao
|
val chainAssetDao: ChainAssetDao
|
||||||
|
|
||||||
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
|
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
|
||||||
|
|||||||
+47
-9
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+52
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ android {
|
|||||||
buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\""
|
buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\""
|
||||||
|
|
||||||
buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\""
|
buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\""
|
||||||
|
buildConfigField "String", "TEST_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/pezkuwi_assets_for_testBalance.json\""
|
||||||
|
|
||||||
buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY")
|
buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY")
|
||||||
buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY")
|
buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import io.novafoundation.nova.common.utils.findIsInstanceOrNull
|
|||||||
import io.novafoundation.nova.common.utils.formatNamed
|
import io.novafoundation.nova.common.utils.formatNamed
|
||||||
import io.novafoundation.nova.common.utils.removeHexPrefix
|
import io.novafoundation.nova.common.utils.removeHexPrefix
|
||||||
import io.novafoundation.nova.common.utils.emptyTronAccountId
|
import io.novafoundation.nova.common.utils.emptyTronAccountId
|
||||||
|
import io.novafoundation.nova.common.utils.isValidTronAddress
|
||||||
import io.novafoundation.nova.common.utils.substrateAccountId
|
import io.novafoundation.nova.common.utils.substrateAccountId
|
||||||
import io.novafoundation.nova.common.utils.toTronAddress
|
import io.novafoundation.nova.common.utils.toTronAddress
|
||||||
import io.novafoundation.nova.common.utils.tronAddressToAccountId
|
import io.novafoundation.nova.common.utils.tronAddressToAccountId
|
||||||
@@ -361,13 +362,19 @@ fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress {
|
|||||||
|
|
||||||
fun Chain.isValidAddress(address: String): Boolean {
|
fun Chain.isValidAddress(address: String): Boolean {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
if (isEthereumBased) {
|
when {
|
||||||
address.asEthereumAddress().isValid()
|
// Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two
|
||||||
} else {
|
// branches below would ever accept them, so this needs its own dedicated check.
|
||||||
address.toAccountId() // verify supplied address can be converted to account id
|
isTronBased -> address.isValidTronAddress()
|
||||||
|
|
||||||
addressPrefix.toShort() == address.addressPrefix() ||
|
isEthereumBased -> address.asEthereumAddress().isValid()
|
||||||
legacyAddressPrefix?.toShort() == address.addressPrefix()
|
|
||||||
|
else -> {
|
||||||
|
address.toAccountId() // verify supplied address can be converted to account id
|
||||||
|
|
||||||
|
addressPrefix.toShort() == address.addressPrefix() ||
|
||||||
|
legacyAddressPrefix?.toShort() == address.addressPrefix()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ val TokenSymbol.mainTokensFirstAscendingOrder
|
|||||||
"DOT" -> 3
|
"DOT" -> 3
|
||||||
"KSM" -> 4
|
"KSM" -> 4
|
||||||
"USDC" -> 5
|
"USDC" -> 5
|
||||||
else -> 6
|
"BTC" -> 6
|
||||||
|
"ETH" -> 7
|
||||||
|
"BNB" -> 8
|
||||||
|
"TRX" -> 9
|
||||||
|
"AVAX" -> 10
|
||||||
|
"LINK" -> 11
|
||||||
|
"UNI" -> 12
|
||||||
|
"TAO" -> 13
|
||||||
|
else -> 14
|
||||||
}
|
}
|
||||||
|
|
||||||
val TokenSymbol.alphabeticalOrder
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -1,7 +1,9 @@
|
|||||||
package io.novafoundation.nova.runtime.multiNetwork.chain
|
package io.novafoundation.nova.runtime.multiNetwork.chain
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import io.novafoundation.nova.common.utils.CollectionDiffer
|
import io.novafoundation.nova.common.utils.CollectionDiffer
|
||||||
|
import io.novafoundation.nova.common.utils.LOG_TAG
|
||||||
import io.novafoundation.nova.common.utils.retryUntilDone
|
import io.novafoundation.nova.common.utils.retryUntilDone
|
||||||
import io.novafoundation.nova.core_db.dao.ChainDao
|
import io.novafoundation.nova.core_db.dao.ChainDao
|
||||||
import io.novafoundation.nova.core_db.dao.FullAssetIdLocal
|
import io.novafoundation.nova.core_db.dao.FullAssetIdLocal
|
||||||
@@ -40,6 +42,21 @@ class ChainSyncService(
|
|||||||
|
|
||||||
val remoteChains = retryUntilDone { chainFetcher.getChains() }
|
val remoteChains = retryUntilDone { chainFetcher.getChains() }
|
||||||
|
|
||||||
|
// A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make
|
||||||
|
// chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of
|
||||||
|
// throwing. Applying that as a diff against a populated local DB would delete most or all of the
|
||||||
|
// user's chains/assets - not a sync failure, but active data loss, for something that self-heals on
|
||||||
|
// the next successful sync if we just skip applying it. Only guard when we HAD data: an empty result
|
||||||
|
// on a genuinely first-ever sync is normal and must proceed.
|
||||||
|
if (oldChains.isNotEmpty() && remoteChains.size < oldChains.size / 2) {
|
||||||
|
Log.e(
|
||||||
|
LOG_TAG,
|
||||||
|
"Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " +
|
||||||
|
"(would remove more than half). Likely a transient fetch issue - skipping this sync cycle."
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) }
|
val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) }
|
||||||
val newAssets = remoteChains.flatMap { chain ->
|
val newAssets = remoteChains.flatMap { chain ->
|
||||||
chain.assets.map {
|
chain.assets.map {
|
||||||
|
|||||||
+40
-22
@@ -14,7 +14,9 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
@@ -24,35 +26,51 @@ abstract class ChainUpdaterGroupUpdateSystem(
|
|||||||
private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
|
private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
|
||||||
) : UpdateSystem {
|
) : UpdateSystem {
|
||||||
|
|
||||||
|
// Callers (MultiChainUpdateSystem, SingleChainUpdateSystem) merge several chains' runUpdaters() results
|
||||||
|
// into one flow. chainRegistry.getRuntime(chain.id) throws for a chain whose runtime isn't ready yet
|
||||||
|
// (including a disabled chain, via DisabledChainException) - if that throw escapes this function
|
||||||
|
// uncaught, it propagates through the merge and kills governance/staking/crowdloan sync for every OTHER
|
||||||
|
// chain in the group too, not just the failing one. Wrapping the whole body in flow{} + catch isolates
|
||||||
|
// that failure to this chain alone.
|
||||||
protected suspend fun runUpdaters(chain: Chain, updaters: Collection<Updater<*>>): Flow<Updater.SideEffect> {
|
protected suspend fun runUpdaters(chain: Chain, updaters: Collection<Updater<*>>): Flow<Updater.SideEffect> {
|
||||||
val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata
|
return flow {
|
||||||
|
val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata
|
||||||
|
|
||||||
val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
|
val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
|
||||||
val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
|
val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
|
||||||
|
|
||||||
val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) ->
|
val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) ->
|
||||||
scope.invalidationFlow().flatMapLatest { scopeValue ->
|
scope.invalidationFlow().flatMapLatest { scopeValue ->
|
||||||
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
|
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
|
||||||
|
|
||||||
val updatersFlow = scopeUpdaters
|
val updatersFlow = scopeUpdaters
|
||||||
.filter { it.requiredModules.all(runtimeMetadata::hasModule) }
|
.filter { it.requiredModules.all(runtimeMetadata::hasModule) }
|
||||||
.map { updater ->
|
.map { updater ->
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
(updater as Updater<Any?>).listenForUpdates(subscriptionBuilder, scopeValue)
|
(updater as Updater<Any?>).listenForUpdates(subscriptionBuilder, scopeValue)
|
||||||
.catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) }
|
.catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) }
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatersFlow.isNotEmpty()) {
|
||||||
|
subscriptionBuilder.subscribe(coroutineContext)
|
||||||
|
|
||||||
|
updatersFlow.merge()
|
||||||
|
} else {
|
||||||
|
emptyFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatersFlow.isNotEmpty()) {
|
|
||||||
subscriptionBuilder.subscribe(coroutineContext)
|
|
||||||
|
|
||||||
updatersFlow.merge()
|
|
||||||
} else {
|
|
||||||
emptyFlow()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return scopeFlows.merge()
|
emitAll(scopeFlows.merge())
|
||||||
|
}.catch { error ->
|
||||||
|
// Explicitly qualified: unqualified LOG_TAG here would resolve against the nearest implicit
|
||||||
|
// receiver, which is this catch lambda's FlowCollector, not this class - Any.LOG_TAG applies to
|
||||||
|
// any receiver, so it would silently compile but log the wrong (unhelpful) tag.
|
||||||
|
val outerLogTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
|
||||||
|
val outerSelfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
|
||||||
|
|
||||||
|
Log.e(outerLogTag, "Failed to start updaters in $outerSelfName for ${chain.name}", error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user