mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 09:05:48 +00:00
merge: reconcile with feature/trc20-tron-send into one combined branch
Bitcoin was branched off main independently instead of continuing on top of the Tron send branch, which caused real divergence: both branches touch the same shared signing/DI code (multiChainEncryptionFor, getMetaAccountKeypair, NodeHealthStateTesterFactory, AssetsModule/TypeBasedAssetSourceRegistry, CloudBackup schema, Fee model), and both need to ship together in one coordinated release once wallet-util's master is unblocked. Merging now reconciles that before it gets any harder, and gives one branch/versionCode history for device testing instead of two that silently diverge. Conflict resolutions of note: - SecretStoreV2/SecretsSigner: getKeypair and multiChainEncryptionFor now recognize Tron AND Bitcoin accounts (isTronBased/isBitcoinBased both threaded through, checked before the Ethereum fallback). - AccountDataSourceImpl: both address-backfill migrations now run sequentially in one coroutine (Tron's fix for a race against the legacy migration applies equally to Bitcoin's backfill). - CloudBackup: kept Tron's forward-looking Solana schema reservation alongside Tron's and Bitcoin's now-real fields. - ChainRegistryModule/NodeHealthStateTesterFactory: adopted Tron's self-contained short-lived OkHttpClient (simpler than threading the shared app-wide client through RuntimeDependencies, which is reverted). - RealSecretsMetaAccount: extended Tron's chain-account MultiChainEncryption fix to also cover Bitcoin, for the same underlying reason. - ChainExt.isValidAddress: Tron's branch independently closed the pre-existing Tron validation gap; kept alongside Bitcoin's own check.
This commit is contained in:
@@ -35,7 +35,7 @@ t.start()
|
||||
|
||||
def run():
|
||||
os.system('adb wait-for-device')
|
||||
p = sp.Popen('adb shell am instrument -w -m -e debug false -e class "io.novafoundation.nova.balances.BalancesIntegrationTest" io.pezkuwichain.wallet.debug.test/io.qameta.allure.android.runners.AllureAndroidJUnitRunner',
|
||||
p = sp.Popen('adb shell am instrument -w -m -e debug false -e package "io.novafoundation.nova.balances" io.pezkuwichain.wallet.debug.test/io.qameta.allure.android.runners.AllureAndroidJUnitRunner',
|
||||
shell=True, stdout=sp.PIPE, stderr=sp.PIPE, stdin=sp.PIPE)
|
||||
return p.communicate()
|
||||
success = re.compile(r'OK \(\d+ tests\)')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Run balances tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 */8 * * *'
|
||||
@@ -43,13 +44,50 @@ jobs:
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: AVD cache
|
||||
uses: actions/cache@v4
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: avd-29-nexus6-x86_64-v1
|
||||
|
||||
- name: Create AVD and generate snapshot for caching
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
disable-animations: false
|
||||
profile: Nexus 6
|
||||
api-level: 29
|
||||
arch: x86_64
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
script: echo "Generated AVD snapshot for caching."
|
||||
|
||||
- name: Run tests
|
||||
id: run-tests-attempt-1
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
disable-animations: true
|
||||
profile: Nexus 6
|
||||
api-level: 29
|
||||
arch: x86_64
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot-save -noaudio -no-boot-anim
|
||||
script: .github/scripts/run_balances_test.sh
|
||||
|
||||
- name: Run tests (retry - reactivecircus/android-emulator-runner flakes on SDK download/emulator boot)
|
||||
if: steps.run-tests-attempt-1.outcome == 'failure'
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
disable-animations: true
|
||||
profile: Nexus 6
|
||||
api-level: 29
|
||||
arch: x86_64
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot-save -noaudio -no-boot-anim
|
||||
script: .github/scripts/run_balances_test.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -18,7 +18,27 @@ runs:
|
||||
- name: Install NDK
|
||||
run: |
|
||||
SDKMANAGER=$(find ${ANDROID_SDK_ROOT}/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -1)
|
||||
echo "y" | sudo ${SDKMANAGER} --install "ndk;26.1.10909125" --sdk_root=${ANDROID_SDK_ROOT}
|
||||
NDK_PACKAGE="ndk;26.1.10909125"
|
||||
|
||||
# sdkmanager's download of the ~1GB NDK zip from Google's CDN occasionally comes back truncated/corrupted
|
||||
# ("Error on ZipFile unknown archive") with no built-in retry, taking down the whole build for a purely
|
||||
# transient network blip. Retry with cleanup between attempts: sdkmanager can otherwise resume from the
|
||||
# same corrupted partial file instead of re-fetching, making a naive retry fail identically every time.
|
||||
for attempt in 1 2 3; do
|
||||
echo "NDK install attempt $attempt/3"
|
||||
if echo "y" | sudo ${SDKMANAGER} --install "$NDK_PACKAGE" --sdk_root=${ANDROID_SDK_ROOT}; then
|
||||
echo "NDK installed successfully"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Attempt $attempt failed - clearing any partial/corrupted download before retrying"
|
||||
sudo rm -rf "${ANDROID_SDK_ROOT}/ndk/26.1.10909125"
|
||||
sudo find "${ANDROID_SDK_ROOT}" -maxdepth 1 -name "tmp*" -exec rm -rf {} +
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "NDK install failed after 3 attempts"
|
||||
exit 1
|
||||
shell: bash
|
||||
|
||||
- name: Set ndk.dir in local.properties
|
||||
|
||||
@@ -324,6 +324,7 @@ dependencies {
|
||||
androidTestImplementation androidTestRunnerDep
|
||||
androidTestImplementation androidTestRulesDep
|
||||
androidTestImplementation androidJunitDep
|
||||
androidTestImplementation scalarsConverterDep
|
||||
|
||||
androidTestImplementation allureKotlinModel
|
||||
androidTestImplementation allureKotlinCommons
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package io.novafoundation.nova.balances
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.google.gson.Gson
|
||||
import io.novafoundation.nova.common.di.FeatureUtils
|
||||
import io.novafoundation.nova.common.utils.fromJson
|
||||
import io.novafoundation.nova.core.model.CryptoType
|
||||
import io.novafoundation.nova.core_db.dao.AssetDao
|
||||
import io.novafoundation.nova.core_db.dao.ChainAssetDao
|
||||
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||
import io.novafoundation.nova.core_db.di.DbApi
|
||||
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
|
||||
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
|
||||
import io.novafoundation.nova.runtime.BuildConfig.TEST_ASSETS_URL
|
||||
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.net.URL
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private data class AssetFixture(val chainId: String, val chainName: String, val assetId: Int, val symbol: String)
|
||||
private data class AssetsFixtureFile(val account: String, val assets: List<AssetFixture>)
|
||||
|
||||
/**
|
||||
* Exercises the ACTUAL production balance-sync pipeline (BalancesUpdateSystem -> AssetCache/AssetDao) end to
|
||||
* end, unlike [BalancesIntegrationTest] which bypasses it entirely via a direct low-level storage query. This
|
||||
* is meant to answer one question with hard evidence, not speculation: for a real, well-funded mainnet Founder
|
||||
* account, does the app's real, running background sync ever write an `assets` cache row for every asset in
|
||||
* wallet-utils' pezkuwi_assets_for_testBalance.json (HEZ/PEZ/USDT/DOT/ETH/BTC across Pezkuwi's chains) - not
|
||||
* just the native balance on one chain, which is all the older [BalancesIntegrationTest] fixture covers.
|
||||
*
|
||||
* A single watch-only account is created and selected once, so a single BalancesUpdateSystem run has to
|
||||
* successfully sync every asset in the fixture - this is what actually caught the 2026-07-09 HEZ-on-Asset-Hub
|
||||
* silent sync failure (5 of 6 assets on that chain synced fine; only HEZ silently never did).
|
||||
*
|
||||
* BalancesUpdateSystem.start() is a cold flow - in production it's only ever collected by RootInteractor,
|
||||
* which is wired to the root Activity/ViewModel lifecycle. A bare instrumented test never launches that
|
||||
* Activity, so we collect it ourselves here via the same AssetsFeatureApi.updateSystem instance the real app
|
||||
* uses, instead of relying on app UI lifecycle to start it.
|
||||
*
|
||||
* If this test fails, the standard per-updater error logs already wired into BalancesUpdateSystem/
|
||||
* FullSyncPaymentUpdater/NativeAssetBalance/StatemineAssetBalance show exactly which decision branch or
|
||||
* exception is responsible - not another layer of inference from silence.
|
||||
*/
|
||||
class PezkuwiFullArchitectureBalancesTest {
|
||||
|
||||
// Standard BIP44 Ethereum derivation (m/44'/60'/0'/0/0) from the same already-verified Founder mnemonic
|
||||
// used for the substrate address below - this is exactly what Nova/Pezkuwi Wallet's own unified account
|
||||
// creation derives when a single seed produces both a substrate and an Ethereum account. No dedicated
|
||||
// "founder EVM wallet" record exists anywhere else, so this is the correct, non-fabricated way to get a
|
||||
// real EVM address to test USDT-on-Ethereum sync with - it doesn't need to hold any balance, since this
|
||||
// test only asserts a row gets written (see below), not that the balance is non-zero.
|
||||
private val founderEthereumAddress = "0x1aa2EA1292c62BdC6E49E0C12134263efc73713A"
|
||||
private val ethereumChainId = "eip155:1"
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
private val dbApi = FeatureUtils.getFeature<DbApi>(context, DbApi::class.java)
|
||||
private val metaAccountDao = dbApi.metaAccountDao()
|
||||
private val assetDao: AssetDao = dbApi.provideAssetDao()
|
||||
private val chainAssetDao: ChainAssetDao = dbApi.chainAssetDao()
|
||||
|
||||
private val assetsFeatureApi = FeatureUtils.getFeature<AssetsFeatureApi>(context, AssetsFeatureApi::class.java)
|
||||
|
||||
@Test
|
||||
fun testPezkuwiEcosystemAssetsActuallySync() = runBlocking {
|
||||
val fixture: AssetsFixtureFile = Gson().fromJson(URL(TEST_ASSETS_URL).readText())
|
||||
|
||||
val updateSystemJob = launch { assetsFeatureApi.updateSystem.start().collect {} }
|
||||
|
||||
try {
|
||||
val metaId = insertAndSelectWatchAccount(metaAccountDao, fixture.account, founderEthereumAddress)
|
||||
|
||||
// Ethereum's USDT isn't in the JSON fixture because its assetId isn't a fixed, known integer like
|
||||
// Substrate assets - EvmAssetsSyncService computes it as a hash of the ERC20 contract address at
|
||||
// sync time (see chainAssetIdOfErc20Token()), so it has to be resolved dynamically here instead of
|
||||
// hardcoded. This closes out the third of the three originally-reported symptoms (Tron disabled,
|
||||
// Pezkuwi tokens missing, USDT on Polkadot AH/Ethereum missing) - the first two are already covered
|
||||
// by the fixture-driven assets above.
|
||||
val ethereumUsdtAssetId = withTimeoutOrNull(30.seconds) {
|
||||
var assetId: Int? = null
|
||||
while (assetId == null) {
|
||||
assetId = chainAssetDao.getEnabledAssets()
|
||||
.firstOrNull { it.chainId == ethereumChainId && it.symbol == "USDT" }
|
||||
?.id
|
||||
if (assetId == null) delay(2.seconds)
|
||||
}
|
||||
assetId
|
||||
}
|
||||
assertTrue(
|
||||
"USDT was never registered as an enabled asset on Ethereum (chainId=$ethereumChainId) within 30s - " +
|
||||
"EvmAssetsSyncService may have failed to sync from EVM_ASSETS_URL.",
|
||||
ethereumUsdtAssetId != null
|
||||
)
|
||||
|
||||
val stillMissing = (
|
||||
fixture.assets +
|
||||
AssetFixture(ethereumChainId, "Ethereum", ethereumUsdtAssetId!!, "USDT")
|
||||
).toMutableList()
|
||||
|
||||
withTimeoutOrNull(120.seconds) {
|
||||
while (stillMissing.isNotEmpty()) {
|
||||
val found = stillMissing.filter { asset ->
|
||||
assetDao.getAsset(metaId, asset.chainId, asset.assetId) != null
|
||||
}
|
||||
stillMissing.removeAll(found)
|
||||
if (stillMissing.isNotEmpty()) delay(2.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"No `assets` row was ever written for: ${stillMissing.joinToString { "${it.symbol} on ${it.chainName}" }} " +
|
||||
"(metaId=$metaId) within 120s, out of ${fixture.assets.size + 1} total. The real BalancesUpdateSystem " +
|
||||
"pipeline never completed a sync for these - check the standard FullSyncPaymentUpdater/" +
|
||||
"NativeAssetBalance/StatemineAssetBalance/EvmErc20AssetBalance error logs in logcat.",
|
||||
stillMissing.isEmpty()
|
||||
)
|
||||
} finally {
|
||||
updateSystemJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun insertAndSelectWatchAccount(dao: MetaAccountDao, substrateAddress: String, ethereumAddress: String): Long {
|
||||
val accountId = substrateAddress.toAccountId()
|
||||
val evmAddress = ethereumAddress.removePrefix("0x").fromHex()
|
||||
|
||||
val metaAccount = MetaAccountLocal(
|
||||
substratePublicKey = accountId,
|
||||
substrateCryptoType = CryptoType.SR25519,
|
||||
substrateAccountId = accountId,
|
||||
ethereumPublicKey = null,
|
||||
ethereumAddress = evmAddress,
|
||||
name = "PezkuwiFullArchitectureBalancesTest",
|
||||
parentMetaId = null,
|
||||
isSelected = false,
|
||||
position = 0,
|
||||
type = MetaAccountLocal.Type.WATCH_ONLY,
|
||||
status = MetaAccountLocal.Status.ACTIVE,
|
||||
globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(),
|
||||
typeExtras = null
|
||||
)
|
||||
|
||||
val metaId = dao.insertMetaAccount(metaAccount)
|
||||
dao.selectMetaAccount(metaId)
|
||||
|
||||
return metaId
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package io.novafoundation.nova.balances
|
||||
|
||||
import io.novafoundation.nova.common.utils.tronAddressToAccountId
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.converter.scalars.ScalarsConverterFactory
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Tron is a REST API (TronGrid), not a Substrate runtime - it has no ChainConnection/RuntimeProvider and isn't
|
||||
* reachable through [BalancesIntegrationTest]'s chainRegistry-based mechanism at all. This exercises the exact
|
||||
* same [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi] the production app uses for
|
||||
* balance reads (see TronNativeAssetBalance/Trc20AssetBalance), just via a standalone Retrofit client instead of
|
||||
* the full DI graph, since TronGridApi isn't exposed through a public feature API for tests to reach.
|
||||
*
|
||||
* Test account is the mainnet Founder's Tron address, verified live via TronGrid's public API on 2026-07-09 to
|
||||
* hold a substantial non-zero balance of both native TRX and TRC-20 USDT - not a guessed or empty account.
|
||||
*/
|
||||
class TronBalancesIntegrationTest {
|
||||
|
||||
private val testAddress = "TDGZ4GfvCRe1d8oksj8fBD77ZHw4bkCPBA"
|
||||
private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
private val baseUrl = "https://api.trongrid.io"
|
||||
|
||||
// A real wallet that received exactly 5 USDT-TRC20 (2026-07-11) and has NEVER had any native TRX/other
|
||||
// on-chain activity - confirmed live to have no Account object at all (`/v1/accounts` returns `data: []`)
|
||||
// despite genuinely holding the token (`balanceOf` correctly returns 5000000). Regression coverage for the
|
||||
// exact bug this uncovered: fetchTrc20Balance used to read through `/v1/accounts` and silently returned 0
|
||||
// for any address in this state, well after it was live and had already deceived a real user mid-transfer.
|
||||
private val unactivatedHolderAddress = "TUdvwdGeqcag51XkhgRK21KmhH2qw37LZG"
|
||||
private val unactivatedHolderExpectedUsdtBalance = BigInteger.valueOf(5_000_000L)
|
||||
|
||||
private val maxAmount = BigInteger.valueOf(10).pow(30)
|
||||
|
||||
private val tronGridApi = run {
|
||||
val retrofit = Retrofit.Builder()
|
||||
.client(OkHttpClient.Builder().build())
|
||||
.baseUrl(baseUrl)
|
||||
.addConverterFactory(ScalarsConverterFactory.create())
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
RealTronGridApi(retrofit.create(RetrofitTronGridApi::class.java))
|
||||
}
|
||||
|
||||
// TronGrid's public (no API key) endpoint rate-limits aggressively, and this test now runs on every PR
|
||||
// (see balances_test.yml) in addition to its own 2 calls back-to-back - a bare 429 previously failed the
|
||||
// whole run for a transient, infrastructure-level reason unrelated to whether the wallet's code is correct.
|
||||
// Retry with backoff instead of just tolerating the flake.
|
||||
private suspend fun <T> retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T {
|
||||
repeat(maxAttempts - 1) { attempt ->
|
||||
try {
|
||||
return block()
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() != 429) throw e
|
||||
delay(2_000L * (attempt + 1))
|
||||
}
|
||||
}
|
||||
return block()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNativeTrxBalanceLoading() = runBlocking {
|
||||
val freeBalance = retryOn429 { tronGridApi.fetchNativeBalance(baseUrl, testAddress) }
|
||||
|
||||
assertTrue("TRX balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance)
|
||||
assertTrue("TRX balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTrc20UsdtBalanceLoading() = runBlocking {
|
||||
val freeBalance = retryOn429 { tronGridApi.fetchTrc20Balance(baseUrl, testAddress.tronAddressToAccountId(), usdtContractAddress) }
|
||||
|
||||
assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance)
|
||||
assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTrc20BalanceLoadingForNeverActivatedHolder() = runBlocking {
|
||||
val freeBalance = retryOn429 {
|
||||
tronGridApi.fetchTrc20Balance(baseUrl, unactivatedHolderAddress.tronAddressToAccountId(), usdtContractAddress)
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"USDT-TRC20 balance for a never-activated holder: expected $unactivatedHolderExpectedUsdtBalance, got $freeBalance",
|
||||
freeBalance == unactivatedHolderExpectedUsdtBalance
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
buildscript {
|
||||
ext {
|
||||
// App version
|
||||
versionName = '1.1.1'
|
||||
versionName = '1.1.2'
|
||||
versionCode = 1
|
||||
|
||||
applicationId = "io.pezkuwichain.wallet"
|
||||
|
||||
+13
-7
@@ -156,24 +156,28 @@ val AccountSecrets.isChainAccountSecrets
|
||||
suspend fun SecretStoreV2.getMetaAccountKeypair(
|
||||
metaId: Long,
|
||||
isEthereum: Boolean,
|
||||
isTron: Boolean = false,
|
||||
isBitcoin: Boolean = false,
|
||||
): Keypair = withContext(Dispatchers.Default) {
|
||||
val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId)
|
||||
|
||||
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isBitcoin)
|
||||
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin)
|
||||
}
|
||||
|
||||
fun mapMetaAccountSecretsToKeypair(
|
||||
secrets: EncodableStruct<MetaAccountSecrets>,
|
||||
ethereum: Boolean,
|
||||
tron: Boolean = false,
|
||||
bitcoin: Boolean = false,
|
||||
): Keypair {
|
||||
val keypairStruct = if (bitcoin) {
|
||||
secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret()
|
||||
} else if (ethereum) {
|
||||
secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
|
||||
} else {
|
||||
secrets[MetaAccountSecrets.SubstrateKeypair]
|
||||
// Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different
|
||||
// SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get
|
||||
// signed with the wrong (Ethereum) private key.
|
||||
val keypairStruct = when {
|
||||
tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret()
|
||||
bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret()
|
||||
ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
|
||||
else -> secrets[MetaAccountSecrets.SubstrateKeypair]
|
||||
}
|
||||
|
||||
return mapKeypairStructToKeypair(keypairStruct)
|
||||
@@ -207,6 +211,8 @@ private fun noEthereumSecret(): Nothing = error("No ethereum keypair found")
|
||||
|
||||
private fun noBitcoinSecret(): Nothing = error("No bitcoin keypair found")
|
||||
|
||||
private fun noTronSecret(): Nothing = error("No tron keypair found")
|
||||
|
||||
fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair {
|
||||
return Keypair(
|
||||
publicKey = struct[KeyPairSchema.PublicKey],
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.novafoundation.nova.common.utils
|
||||
|
||||
import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey
|
||||
import io.novasama.substrate_sdk_android.extensions.toAccountId
|
||||
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||
import java.math.BigInteger
|
||||
|
||||
@@ -113,3 +114,21 @@ fun String.tronAddressToAccountId(): AccountId {
|
||||
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
|
||||
|
||||
fun emptyTronAccountId() = ByteArray(20) { 1 }
|
||||
|
||||
/**
|
||||
* Hex form of a Tron address (`0x41` prefix byte ++ accountId, hex-encoded, no `0x` prefix), e.g.
|
||||
* `41a614f803b6fd780986a42c78ec9c7f77e6ded13c`. This is the format TronGrid's `/wallet/` transaction
|
||||
* construction/broadcast endpoints expect when called with `"visible": false` (as opposed to the human-facing
|
||||
* Base58Check form used by the `/v1/accounts/{address}` balance endpoint and by [toTronAddress]).
|
||||
*/
|
||||
fun AccountId.toTronHexAddress(): String {
|
||||
require(size == 20) { "Tron account id must be 20 bytes, got $size" }
|
||||
|
||||
return byteArrayOf(TRON_ADDRESS_PREFIX_BYTE).toHexString(withPrefix = false) + toHexString(withPrefix = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a human-facing Base58Check Tron address (e.g. a TRC20 `contractAddress` from chain config) directly
|
||||
* into the hex form described in [toTronHexAddress].
|
||||
*/
|
||||
fun String.tronAddressToHexAddress(): String = tronAddressToAccountId().toTronHexAddress()
|
||||
|
||||
@@ -44,6 +44,18 @@ class TronAddressTest {
|
||||
assertTrue(decodedBack.contentEquals(accountId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toTronHexAddress should produce the known hex form`() {
|
||||
val accountId = knownTronAddressHex.fromHex().copyOfRange(1, 21)
|
||||
|
||||
assertEquals(knownTronAddressHex, accountId.toTronHexAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tronAddressToHexAddress should produce the known hex form directly from a Base58 address`() {
|
||||
assertEquals(knownTronAddressHex, knownTronAddress.tronAddressToHexAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isValidTronAddress should accept known good address`() {
|
||||
assertTrue(knownTronAddress.isValidTronAddress())
|
||||
|
||||
@@ -164,7 +164,12 @@ abstract class ChainDao {
|
||||
|
||||
// ------- Queries ------
|
||||
|
||||
@Query("SELECT * FROM chains")
|
||||
// ORDER BY rowid: without an explicit order, SQLite gives no guarantee about row order for `SELECT *`.
|
||||
// The merged chains.json lists Pezkuwi's own chains first (see wallet-utils' merge_chains()), and that
|
||||
// order is what gets inserted first on initial sync - rowid tracks insertion order, so ordering by it
|
||||
// means Pezkuwi's chains reliably register/connect first instead of being interleaved unpredictably
|
||||
// among the ~90+ Nova-inherited chains, where they could otherwise end up processed last.
|
||||
@Query("SELECT * FROM chains ORDER BY rowid")
|
||||
@Transaction
|
||||
abstract suspend fun getJoinChainInfo(): List<JoinedChainInfo>
|
||||
|
||||
@@ -172,7 +177,7 @@ abstract class ChainDao {
|
||||
@Transaction
|
||||
abstract suspend fun getAllChainIds(): List<String>
|
||||
|
||||
@Query("SELECT * FROM chains")
|
||||
@Query("SELECT * FROM chains ORDER BY rowid")
|
||||
@Transaction
|
||||
abstract fun joinChainInfoFlow(): Flow<List<JoinedChainInfo>>
|
||||
|
||||
|
||||
+26
@@ -131,6 +131,32 @@ class MetaAccountLocal(
|
||||
}
|
||||
}
|
||||
|
||||
// We do not use copy as we need explicitly set id
|
||||
fun addTronAccount(
|
||||
tronPublicKey: ByteArray,
|
||||
tronAddress: ByteArray,
|
||||
): MetaAccountLocal {
|
||||
return MetaAccountLocal(
|
||||
substratePublicKey = substratePublicKey,
|
||||
substrateCryptoType = substrateCryptoType,
|
||||
substrateAccountId = substrateAccountId,
|
||||
ethereumPublicKey = ethereumPublicKey,
|
||||
ethereumAddress = ethereumAddress,
|
||||
name = name,
|
||||
parentMetaId = parentMetaId,
|
||||
isSelected = isSelected,
|
||||
position = position,
|
||||
type = type,
|
||||
status = status,
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
typeExtras = typeExtras,
|
||||
tronPublicKey = tronPublicKey,
|
||||
tronAddress = tronAddress
|
||||
).also {
|
||||
it.id = id
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is MetaAccountLocal) return false
|
||||
|
||||
+13
@@ -75,6 +75,19 @@ class BitcoinFee(
|
||||
override val asset: Chain.Asset
|
||||
) : Fee
|
||||
|
||||
/**
|
||||
* Fee for a Tron transaction (native TRX or TRC-20), always denominated in TRX (sun), regardless of which asset
|
||||
* is being sent - Tron has no separate "gas token" concept, network resources (bandwidth/energy) are always
|
||||
* burned as TRX. [amount] is this client's own estimate of that burn (see `RealTronTransactionService`); the
|
||||
* network only ever burns what it actually uses, so the real cost can be lower, but never higher than what this
|
||||
* client authorized via `fee_limit` when submitting.
|
||||
*/
|
||||
class TronFee(
|
||||
override val amount: BigInteger,
|
||||
override val submissionOrigin: SubmissionOrigin,
|
||||
override val asset: Chain.Asset
|
||||
) : Fee
|
||||
|
||||
class SubstrateFeeBase(
|
||||
override val amount: BigInteger,
|
||||
override val asset: Chain.Asset
|
||||
|
||||
@@ -13,6 +13,16 @@ android {
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
// android.util.Log.* throws "Method ... not mocked" by default in plain JVM unit tests (no real
|
||||
// Android framework, no Robolectric) - TronAddressBackfillMigrationTest is the first test in this
|
||||
// module to exercise code that calls Log.d/Log.e. This makes those stubbed calls return harmless
|
||||
// defaults instead of throwing, which is what the framework's own stubs are meant for in this context.
|
||||
returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
+22
-3
@@ -19,6 +19,8 @@ import io.novafoundation.nova.common.data.secrets.v2.publicKey
|
||||
import io.novafoundation.nova.common.data.secrets.v2.seed
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
|
||||
import io.novafoundation.nova.common.utils.filterNotNull
|
||||
import io.novafoundation.nova.common.utils.findById
|
||||
import io.novafoundation.nova.common.utils.mapToSet
|
||||
@@ -93,6 +95,7 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
ethereum = baseSecrets.getEthereumBackupSecrets(),
|
||||
chainAccounts = emptyList(),
|
||||
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
|
||||
tron = baseSecrets.getTronBackupSecrets(),
|
||||
)
|
||||
|
||||
return CloudBackup(
|
||||
@@ -361,6 +364,7 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
ethereum = baseSecrets.getEthereumBackupSecrets(),
|
||||
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
|
||||
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
|
||||
tron = baseSecrets.getTronBackupSecrets(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -472,7 +476,9 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(),
|
||||
ethereumDerivationPath = ethereum?.derivationPath,
|
||||
bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(),
|
||||
bitcoinDerivationPath = bitcoin?.derivationPath
|
||||
bitcoinDerivationPath = bitcoin?.derivationPath,
|
||||
tronKeypair = tron?.keypair?.toLocalKeyPair(),
|
||||
tronDerivationPath = tron?.derivationPath
|
||||
)
|
||||
}
|
||||
|
||||
@@ -494,6 +500,15 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
)
|
||||
}
|
||||
|
||||
private fun EncodableStruct<MetaAccountSecrets>?.getTronBackupSecrets(): CloudBackup.WalletPrivateInfo.TronSecrets? {
|
||||
if (this == null) return null
|
||||
|
||||
return CloudBackup.WalletPrivateInfo.TronSecrets(
|
||||
keypair = tronKeypair?.toBackupKeypairSecrets() ?: return null,
|
||||
derivationPath = tronDerivationPath
|
||||
)
|
||||
}
|
||||
|
||||
private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? {
|
||||
if (this == null) return null
|
||||
|
||||
@@ -537,7 +552,9 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
type = metaAccount.type.toBackupWalletType() ?: return null,
|
||||
chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) },
|
||||
bitcoinAddress = metaAccount.bitcoinAddress,
|
||||
bitcoinPublicKey = metaAccount.bitcoinPublicKey
|
||||
bitcoinPublicKey = metaAccount.bitcoinPublicKey,
|
||||
tronAddress = metaAccount.tronAddress,
|
||||
tronPublicKey = metaAccount.tronPublicKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -561,7 +578,9 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
status = MetaAccountLocal.Status.ACTIVE,
|
||||
typeExtras = null,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
).also {
|
||||
if (localIdOverwrite != null) {
|
||||
it.id = localIdOverwrite
|
||||
|
||||
+31
-7
@@ -31,6 +31,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTy
|
||||
import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData
|
||||
import io.novafoundation.nova.runtime.ext.accountIdOf
|
||||
@@ -65,17 +66,40 @@ class AccountDataSourceImpl(
|
||||
private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
|
||||
secretStoreV1: SecretStoreV1,
|
||||
accountDataMigration: AccountDataMigration,
|
||||
private val bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
|
||||
tronAddressBackfillMigration: TronAddressBackfillMigration,
|
||||
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
|
||||
) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
|
||||
|
||||
init {
|
||||
migrateIfNeeded(accountDataMigration)
|
||||
async { bitcoinAddressBackfillMigration.migrate() }
|
||||
}
|
||||
// Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin backfills read
|
||||
// accounts/secrets that the legacy migration may still be in the middle of writing for very old
|
||||
// (pre-MetaAccount) installs, and separate GlobalScope.launch calls give no ordering guarantee
|
||||
// relative to each other.
|
||||
async {
|
||||
Log.d("AccountDataSourceImpl", "migrations block starting")
|
||||
|
||||
private fun migrateIfNeeded(migration: AccountDataMigration) = async {
|
||||
if (migration.migrationNeeded()) {
|
||||
migration.migrate(::saveSecuritySource)
|
||||
if (accountDataMigration.migrationNeeded()) {
|
||||
accountDataMigration.migrate(::saveSecuritySource)
|
||||
}
|
||||
|
||||
Log.d("AccountDataSourceImpl", "about to run tronAddressBackfillMigration")
|
||||
|
||||
tronAddressBackfillMigration.migrate()
|
||||
|
||||
Log.d("AccountDataSourceImpl", "about to run bitcoinAddressBackfillMigration")
|
||||
|
||||
bitcoinAddressBackfillMigration.migrate()
|
||||
|
||||
Log.d("AccountDataSourceImpl", "migrations block done")
|
||||
|
||||
metaAccountDao.getMetaAccounts().forEach {
|
||||
Log.d(
|
||||
"TronDiag",
|
||||
"metaId=${it.id} name=${it.name} type=${it.type} isSelected=${it.isSelected} " +
|
||||
"tronAddress=${it.tronAddress?.joinToString("") { b -> "%02x".format(b) } ?: "NULL"} " +
|
||||
"tronPublicKey=${if (it.tronPublicKey != null) "present(${it.tronPublicKey!!.size}B)" else "NULL"}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
|
||||
|
||||
import android.util.Log
|
||||
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
|
||||
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
|
||||
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
||||
import io.novafoundation.nova.common.data.secrets.v2.entropy
|
||||
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.seed
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
|
||||
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
|
||||
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||
import io.novafoundation.nova.core_db.dao.updateMetaAccount
|
||||
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.TRON_DEFAULT_DERIVATION_PATH
|
||||
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val TAG = "TronAddressBackfill"
|
||||
|
||||
/**
|
||||
* Idempotent, per-account backfill for accounts that don't yet have a Tron keypair - both accounts created
|
||||
* before Tron support existed, AND accounts whose Tron keypair was lost some other way (e.g. the cloud-backup
|
||||
* schema round trip that used to silently drop it before every wallet had a `tron` field to serialize into -
|
||||
* see CloudBackup.kt). `73_74_AddTronSupport` (the migration that added `meta_accounts.tronPublicKey`/
|
||||
* `tronAddress`) is, like every other migration in this codebase, pure `ALTER TABLE` - it never derives a value
|
||||
* for pre-existing rows.
|
||||
*
|
||||
* Deliberately has NO "have I already run once" flag: an earlier version of this class gated itself behind a
|
||||
* one-shot SharedPreferences flag, which meant that once it ran and marked itself done - even for an account
|
||||
* that legitimately still lacked a Tron keypair afterwards (e.g. because a *different*, since-fixed bug kept
|
||||
* re-losing it) - it would never run again for that account, ever, on that install. [backfillIfNeeded] is cheap
|
||||
* to call for an account that doesn't need it (a handful of null-checks, no derivation), so this just runs
|
||||
* unconditionally on every app start instead: self-healing by construction, no stuck-flag failure mode possible.
|
||||
*
|
||||
* Only touches accounts that are:
|
||||
* - `Type.SECRETS` (mnemonic-derived) - watch-only/Ledger/Json/multisig/proxied accounts never had a
|
||||
* Tron-capable mnemonic and are correctly left with `tronAddress == null` forever, same as they already are
|
||||
* for Ethereum.
|
||||
* - still holding their `Entropy` in [SecretStoreV2] - an account imported from a raw seed/keypair rather than
|
||||
* a mnemonic has no entropy either and is likewise correctly left alone.
|
||||
* - missing a `TronKeypair` - i.e. not already backfilled and not created after Tron support shipped.
|
||||
*
|
||||
* The Tron keypair is derived via [AccountSecretsFactory.chainAccountSecrets] with `isEthereum = true` (Tron
|
||||
* reuses the exact same secp256k1/BIP32 derivation as Ethereum, just under its own SLIP-44 coin-type-195 path -
|
||||
* see that class's own doc comment) at [TRON_DEFAULT_DERIVATION_PATH], the same call this codebase's own
|
||||
* `metaAccountSecrets()` makes for a fresh account - so a backfilled account ends up with byte-for-byte the
|
||||
* same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation.
|
||||
*/
|
||||
class TronAddressBackfillMigration(
|
||||
private val secretStoreV2: SecretStoreV2,
|
||||
private val metaAccountDao: MetaAccountDao,
|
||||
private val accountSecretsFactory: AccountSecretsFactory,
|
||||
) {
|
||||
|
||||
suspend fun migrate() = withContext(Dispatchers.Default) {
|
||||
val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS }
|
||||
Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}")
|
||||
|
||||
secretsAccounts.forEach { account ->
|
||||
try {
|
||||
backfillIfNeeded(account)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "migrate() done")
|
||||
}
|
||||
|
||||
private suspend fun backfillIfNeeded(account: MetaAccountLocal) {
|
||||
val secrets = secretStoreV2.getMetaAccountSecrets(account.id)
|
||||
if (secrets == null) {
|
||||
Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping")
|
||||
return
|
||||
}
|
||||
val entropy = secrets.entropy
|
||||
if (entropy == null) {
|
||||
Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping")
|
||||
return
|
||||
}
|
||||
if (secrets.tronKeypair != null) {
|
||||
Log.d(TAG, "metaId=${account.id}: already has a TronKeypair - skipping")
|
||||
return
|
||||
}
|
||||
val substrateCryptoType = account.substrateCryptoType
|
||||
if (substrateCryptoType == null) {
|
||||
Log.d(TAG, "metaId=${account.id}: substrateCryptoType is null - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "metaId=${account.id}: deriving Tron keypair")
|
||||
|
||||
val mnemonic = MnemonicCreator.fromEntropy(entropy).words
|
||||
|
||||
val tronChainSecrets = accountSecretsFactory.chainAccountSecrets(
|
||||
derivationPath = TRON_DEFAULT_DERIVATION_PATH,
|
||||
accountSource = AccountSecretsFactory.AccountSource.Mnemonic(substrateCryptoType, mnemonic),
|
||||
isEthereum = true
|
||||
).secrets
|
||||
|
||||
val tronKeypair = mapKeypairStructToKeypair(tronChainSecrets[ChainAccountSecrets.Keypair])
|
||||
|
||||
val updatedSecrets = MetaAccountSecrets(
|
||||
substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair),
|
||||
entropy = secrets.entropy,
|
||||
substrateSeed = secrets.seed,
|
||||
substrateDerivationPath = secrets.substrateDerivationPath,
|
||||
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
|
||||
ethereumDerivationPath = secrets.ethereumDerivationPath,
|
||||
tronKeypair = tronKeypair,
|
||||
tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH
|
||||
)
|
||||
|
||||
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
|
||||
|
||||
val tronAccountId = tronKeypair.publicKey.tronPublicKeyToAccountId()
|
||||
metaAccountDao.updateMetaAccount(account.id) { it.addTronAccount(tronKeypair.publicKey, tronAccountId) }
|
||||
|
||||
Log.d(TAG, "metaId=${account.id}: backfilled successfully, tronAddress set (${tronAccountId.size} bytes)")
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -142,12 +142,15 @@ class SecretsSigner(
|
||||
private suspend fun getKeypair(accountId: AccountId): Keypair {
|
||||
val chainsById = chainRegistry.chainsById()
|
||||
val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!!
|
||||
val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true
|
||||
val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
|
||||
|
||||
return secretStoreV2.getKeypair(
|
||||
metaAccount = metaAccount,
|
||||
accountId = accountId,
|
||||
isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum,
|
||||
isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
|
||||
isTronBased = isTronBased,
|
||||
isBitcoinBased = isBitcoinBased
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,11 +164,12 @@ class SecretsSigner(
|
||||
metaAccount: MetaAccount,
|
||||
accountId: AccountId,
|
||||
isEthereumBased: Boolean,
|
||||
isTronBased: Boolean = false,
|
||||
isBitcoinBased: Boolean = false,
|
||||
) = if (hasChainSecrets(metaAccount.id, accountId)) {
|
||||
getChainAccountKeypair(metaAccount.id, accountId)
|
||||
} else {
|
||||
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isBitcoinBased)
|
||||
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +179,11 @@ class SecretsSigner(
|
||||
return when {
|
||||
substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
||||
ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum
|
||||
// Tron and Bitcoin both reuse the exact same secp256k1 signing scheme as Ethereum - they just have
|
||||
// their own accountId (different SLIP-44 derivation path), so neither matches ethereumAccountId()
|
||||
// above and was falling through to the chainAccounts lookup, which doesn't cover them either ->
|
||||
// null -> NPE on `!!`.
|
||||
tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
|
||||
bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
|
||||
else -> {
|
||||
val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null
|
||||
|
||||
+13
@@ -105,6 +105,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Re
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
|
||||
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
|
||||
import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule
|
||||
@@ -403,6 +404,7 @@ class AccountFeatureModule {
|
||||
nodeDao: NodeDao,
|
||||
secretStoreV1: SecretStoreV1,
|
||||
accountDataMigration: AccountDataMigration,
|
||||
tronAddressBackfillMigration: TronAddressBackfillMigration,
|
||||
metaAccountDao: MetaAccountDao,
|
||||
secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
|
||||
secretStoreV2: SecretStoreV2,
|
||||
@@ -419,6 +421,7 @@ class AccountFeatureModule {
|
||||
secretsMetaAccountLocalFactory,
|
||||
secretStoreV1,
|
||||
accountDataMigration,
|
||||
tronAddressBackfillMigration,
|
||||
bitcoinAddressBackfillMigration
|
||||
)
|
||||
}
|
||||
@@ -452,6 +455,16 @@ class AccountFeatureModule {
|
||||
return AccountDataMigration(preferences, encryptedPreferences, accountDao)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideTronAddressBackfillMigration(
|
||||
secretStoreV2: SecretStoreV2,
|
||||
metaAccountDao: MetaAccountDao,
|
||||
accountSecretsFactory: AccountSecretsFactory,
|
||||
): TronAddressBackfillMigration {
|
||||
return TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideExternalAccountActions(
|
||||
|
||||
+8
-1
@@ -53,7 +53,10 @@ class RealSecretsMetaAccount(
|
||||
hasChainAccountIn(chain.id) -> {
|
||||
val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null
|
||||
|
||||
if (chain.isEthereumBased) {
|
||||
// Tron and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see
|
||||
// RealTronTransactionService/RealBitcoinTransactionService's use of
|
||||
// Signer.sign(MultiChainEncryption.Ethereum, ...).
|
||||
if (chain.isEthereumBased || chain.isTronBased || chain.isBitcoinBased) {
|
||||
MultiChainEncryption.Ethereum
|
||||
} else {
|
||||
MultiChainEncryption.substrateFrom(cryptoType)
|
||||
@@ -62,6 +65,10 @@ class RealSecretsMetaAccount(
|
||||
|
||||
chain.isEthereumBased -> MultiChainEncryption.Ethereum
|
||||
|
||||
chain.isTronBased -> MultiChainEncryption.Ethereum
|
||||
|
||||
chain.isBitcoinBased -> MultiChainEncryption.Ethereum
|
||||
|
||||
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -7,6 +7,8 @@ import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
|
||||
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
|
||||
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
||||
import io.novafoundation.nova.common.data.secrets.v2.entropy
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
|
||||
import io.novafoundation.nova.core.model.CryptoType
|
||||
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||
import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal
|
||||
@@ -536,6 +538,64 @@ class RealLocalAccountsCloudBackupFacadeTest {
|
||||
verifyEvent(expectedEvent)
|
||||
}
|
||||
|
||||
// Regression test for a wallet's Tron address/keypair being silently dropped on a cloud backup round trip:
|
||||
// WalletPublicInfo/WalletPrivateInfo had no tron field at all until this fix, so a backup written from a
|
||||
// wallet that genuinely had a Tron address, once applied back to local (e.g. after "clear all data" +
|
||||
// restore, or on a fresh install created via the cloud-backup-first-wallet flow), landed with
|
||||
// tronAddress/tronPublicKey/tronKeypair = null - found via real-device testing, not by this test.
|
||||
@Test
|
||||
fun shouldApplyAddAccountDiffWithTronSecrets() = runBlocking {
|
||||
LocalAccountsMocker.setupMocks(metaAccountDao) {}
|
||||
SecretStoreMocker.setupMocks(secretStore) {}
|
||||
|
||||
allChainsAreEvm(false)
|
||||
|
||||
val localBackup = buildTestCloudBackup {
|
||||
publicData { }
|
||||
privateData { }
|
||||
}
|
||||
|
||||
val bytes32 = bytes32of(0)
|
||||
val tronAddressBytes = bytes20of(1)
|
||||
val tronDerivationPath = "//44//195//0/0/0"
|
||||
|
||||
val cloudBackup = buildTestCloudBackup {
|
||||
publicData {
|
||||
wallet(walletUUid(0)) {
|
||||
substrateAccountId(bytes32)
|
||||
substrateCryptoType(CryptoType.SR25519)
|
||||
substratePublicKey(bytes32)
|
||||
|
||||
tronPublicKey(bytes32)
|
||||
tronAddress(tronAddressBytes)
|
||||
}
|
||||
}
|
||||
|
||||
privateData {
|
||||
wallet(walletUUid(0)) {
|
||||
entropy(bytes32)
|
||||
|
||||
substrate {
|
||||
seed(bytes32)
|
||||
keypair(KeyPairSecrets(bytes32, bytes32, bytes32))
|
||||
}
|
||||
|
||||
tron {
|
||||
derivationPath(tronDerivationPath)
|
||||
keypair(KeyPairSecrets(bytes32, bytes32, nonce = null))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal())
|
||||
|
||||
facade.applyBackupDiff(diff, cloudBackup)
|
||||
|
||||
verify(metaAccountDao).insertMetaAccount(metaAccountWithTronAddress(tronAddressBytes))
|
||||
verify(secretStore).putMetaAccountSecrets(eq(0), metaAccountSecretsWithTronDerivationPath(tronDerivationPath))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldApplyRemoveAccountDiff(): Unit = runBlocking {
|
||||
allChainsAreEvm(false)
|
||||
@@ -1205,6 +1265,14 @@ class RealLocalAccountsCloudBackupFacadeTest {
|
||||
return argThat { it.globallyUniqueId == id }
|
||||
}
|
||||
|
||||
private fun metaAccountWithTronAddress(tronAddress: ByteArray): MetaAccountLocal {
|
||||
return argThat { it.tronAddress.contentEquals(tronAddress) }
|
||||
}
|
||||
|
||||
private fun metaAccountSecretsWithTronDerivationPath(derivationPath: String): EncodableStruct<MetaAccountSecrets> {
|
||||
return argThat { it.tronDerivationPath == derivationPath && it.tronKeypair != null }
|
||||
}
|
||||
|
||||
private suspend fun verifyNoAdditionalSecretsInserted() {
|
||||
verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any())
|
||||
}
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
|
||||
|
||||
import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema
|
||||
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
|
||||
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
||||
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
|
||||
import io.novafoundation.nova.common.utils.invoke
|
||||
import io.novafoundation.nova.common.utils.tronAddressToAccountId
|
||||
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
|
||||
import io.novafoundation.nova.core.model.CryptoType
|
||||
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
|
||||
import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder
|
||||
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
|
||||
import io.novasama.substrate_sdk_android.scale.EncodableStruct
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.ArgumentMatcher
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.junit.MockitoJUnitRunner
|
||||
|
||||
// Same guaranteed-non-null-return wrappers as RealTronTransactionServiceTest, and for the same reason: the
|
||||
// shared test_shared eq()/any() helpers crash with "eq(...) must not be null" once Mockito's genuinely-null
|
||||
// runtime return flows into a Kotlin non-null-typed parameter - see that test's class doc for the full writeup.
|
||||
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> any(): T {
|
||||
Mockito.any<T>()
|
||||
return null as T
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> argThat(matcher: (T) -> Boolean): T {
|
||||
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
|
||||
return null as T
|
||||
}
|
||||
|
||||
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
|
||||
|
||||
/**
|
||||
* This is the money-safety-critical half of the Tron backfill fix (see TronAddressBackfillMigration's class
|
||||
* doc for the full context): a wrong derivation here would silently give a pre-existing wallet a Tron address
|
||||
* it does NOT actually control, or fail to skip an account it shouldn't touch. Uses a real (non-mocked)
|
||||
* AccountSecretsFactory so the actual BIP32/secp256k1 derivation math runs for real, and asserts against the
|
||||
* same live-TronGrid-cross-validated reference address TronDerivationTest already established for the standard
|
||||
* BIP39 test mnemonic, so this test and that one are pinned to the same known-good vector.
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner::class)
|
||||
class TronAddressBackfillMigrationTest {
|
||||
|
||||
@Mock
|
||||
lateinit var secretStoreV2: SecretStoreV2
|
||||
|
||||
@Mock
|
||||
lateinit var metaAccountDao: MetaAccountDao
|
||||
|
||||
@Mock
|
||||
lateinit var jsonDecoder: JsonDecoder
|
||||
|
||||
private lateinit var subject: TronAddressBackfillMigration
|
||||
|
||||
private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
private val expectedTronAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Real factory, not a mock - the whole point of this test is to exercise the actual derivation.
|
||||
val accountSecretsFactory = AccountSecretsFactory(jsonDecoder)
|
||||
subject = TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate should derive and persist the well-known reference Tron address for a pre-existing mnemonic account`(): Unit = runBlocking {
|
||||
val account = secretsAccount(id = 42, substrateCryptoType = CryptoType.SR25519)
|
||||
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = null)
|
||||
|
||||
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||
whenever(metaAccountDao.getMetaAccount(eq(42L))).thenReturn(account)
|
||||
whenever(secretStoreV2.getMetaAccountSecrets(eq(42L))).thenReturn(secrets)
|
||||
|
||||
subject.migrate()
|
||||
|
||||
verify(metaAccountDao).updateMetaAccount(
|
||||
argThat<MetaAccountLocal> { updated ->
|
||||
updated.id == 42L && updated.tronAddress.contentEquals(expectedTronAccountId)
|
||||
}
|
||||
)
|
||||
|
||||
verify(secretStoreV2).putMetaAccountSecrets(
|
||||
eq(42L),
|
||||
argThat<EncodableStruct<MetaAccountSecrets>> { updatedSecrets ->
|
||||
val tronKeypairStruct = updatedSecrets.tronKeypair
|
||||
tronKeypairStruct != null &&
|
||||
mapKeypairStructToKeypair(tronKeypairStruct).publicKey.tronPublicKeyToAccountId().contentEquals(expectedTronAccountId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate should skip an account that already has a Tron keypair`(): Unit = runBlocking {
|
||||
val account = secretsAccount(id = 7, substrateCryptoType = CryptoType.SR25519)
|
||||
val existingTronKeypair = KeyPairSchema { keypair ->
|
||||
keypair[KeyPairSchema.PublicKey] = ByteArray(33) { 9 }
|
||||
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 8 }
|
||||
keypair[KeyPairSchema.Nonce] = null
|
||||
}
|
||||
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = existingTronKeypair)
|
||||
|
||||
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||
whenever(secretStoreV2.getMetaAccountSecrets(eq(7L))).thenReturn(secrets)
|
||||
|
||||
subject.migrate()
|
||||
|
||||
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
|
||||
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate should skip an account with no entropy (raw-seed import, not a mnemonic)`(): Unit = runBlocking {
|
||||
val account = secretsAccount(id = 11, substrateCryptoType = CryptoType.SR25519)
|
||||
val secrets = accountSecrets(entropy = null, tronKeypair = null)
|
||||
|
||||
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||
whenever(secretStoreV2.getMetaAccountSecrets(eq(11L))).thenReturn(secrets)
|
||||
|
||||
subject.migrate()
|
||||
|
||||
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
|
||||
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate should skip an account with no stored secrets at all (watch-only, Ledger, etc)`(): Unit = runBlocking {
|
||||
val account = secretsAccount(id = 13, substrateCryptoType = CryptoType.SR25519)
|
||||
|
||||
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||
whenever(secretStoreV2.getMetaAccountSecrets(eq(13L))).thenReturn(null)
|
||||
|
||||
subject.migrate()
|
||||
|
||||
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
|
||||
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||
}
|
||||
|
||||
private fun secretsAccount(id: Long, substrateCryptoType: CryptoType): MetaAccountLocal {
|
||||
return MetaAccountLocal(
|
||||
substratePublicKey = ByteArray(32) { 1 },
|
||||
substrateCryptoType = substrateCryptoType,
|
||||
substrateAccountId = ByteArray(32) { 2 },
|
||||
ethereumPublicKey = null,
|
||||
ethereumAddress = null,
|
||||
name = "Test account",
|
||||
parentMetaId = null,
|
||||
isSelected = true,
|
||||
position = 0,
|
||||
type = MetaAccountLocal.Type.SECRETS,
|
||||
status = MetaAccountLocal.Status.ACTIVE,
|
||||
globallyUniqueId = "test-guid-$id",
|
||||
typeExtras = null
|
||||
).also { it.id = id }
|
||||
}
|
||||
|
||||
private fun accountSecrets(entropy: ByteArray?, tronKeypair: EncodableStruct<KeyPairSchema>?): EncodableStruct<MetaAccountSecrets> {
|
||||
val dummySubstrateKeypair = KeyPairSchema { keypair ->
|
||||
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 5 }
|
||||
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 6 }
|
||||
keypair[KeyPairSchema.Nonce] = ByteArray(8) { 7 }
|
||||
}
|
||||
|
||||
return MetaAccountSecrets(
|
||||
substrateKeyPair = mapKeypairStructToKeypair(dummySubstrateKeypair),
|
||||
entropy = entropy,
|
||||
substrateSeed = null,
|
||||
substrateDerivationPath = null,
|
||||
ethereumKeypair = null,
|
||||
ethereumDerivationPath = null,
|
||||
tronKeypair = tronKeypair?.let(::mapKeypairStructToKeypair),
|
||||
tronDerivationPath = null
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -39,12 +39,24 @@ class BalancesUpdateSystem(
|
||||
override fun start(): Flow<Updater.SideEffect> {
|
||||
return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount ->
|
||||
chainRegistry.currentChains.transformLatestDiffed { chain ->
|
||||
if (chain.isTronBased) {
|
||||
Log.d(LOG_TAG, "TronDebug: currentChains delivered chain=${chain.id} name=${chain.name}")
|
||||
}
|
||||
emitAll(balancesSync(chain, metaAccount))
|
||||
}
|
||||
}.flowOn(Dispatchers.Default)
|
||||
}
|
||||
|
||||
private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow<Updater.SideEffect> {
|
||||
if (chain.isTronBased) {
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"TronDebug: gate check chain=${chain.id} name=${chain.name} hasAccountIn=${metaAccount.hasAccountIn(chain)} " +
|
||||
"connectionState=${chain.connectionState} isDisabled=${chain.connectionState.isDisabled} " +
|
||||
"hasSubstrateRuntime=${chain.hasSubstrateRuntime} canPerformFullSync=${chain.canPerformFullSync()}"
|
||||
)
|
||||
}
|
||||
|
||||
return when {
|
||||
!metaAccount.hasAccountIn(chain) -> emptyFlow()
|
||||
chain.connectionState.isDisabled -> emptyFlow()
|
||||
@@ -89,6 +101,10 @@ class BalancesUpdateSystem(
|
||||
try {
|
||||
updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) }
|
||||
} catch (e: Exception) {
|
||||
// Was silently swallowed here with zero logging - listenForUpdates() itself is a suspend
|
||||
// call that can throw synchronously (e.g. FullSyncPaymentUpdater.listenForUpdates() calling
|
||||
// requireAccountIdIn(chain)), before ever returning a flow for the .catch{} above to guard.
|
||||
Log.e(LOG_TAG, "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e)
|
||||
emptyFlow()
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -6,7 +6,8 @@ import io.novafoundation.nova.common.presentation.AssetIconProvider
|
||||
import io.novafoundation.nova.common.presentation.getAssetIconOrFallback
|
||||
import io.novafoundation.nova.common.utils.formatting.formatAsChange
|
||||
import io.novafoundation.nova.common.utils.orZero
|
||||
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
|
||||
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
|
||||
import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard
|
||||
import io.novafoundation.nova.feature_assets.R
|
||||
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
|
||||
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
|
||||
@@ -90,7 +91,14 @@ class TokenAssetFormatter(
|
||||
group.getId(),
|
||||
mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)),
|
||||
assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration),
|
||||
mapChainToUi(it.chain)
|
||||
// Not mapChainToUi() here - this row's subtitle needs to disambiguate which issuance of the
|
||||
// token this is (e.g. "Ethereum (ERC-20)" vs "Tron (TRC-20)"), which a bare chain name alone
|
||||
// doesn't when multiple ecosystems share the same symbol (USDT, USDC, etc.).
|
||||
ChainUi(
|
||||
id = it.chain.id,
|
||||
name = it.chain.displayNameWithAssetStandard(),
|
||||
icon = it.chain.icon
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-4
@@ -2,6 +2,8 @@ package io.novafoundation.nova.feature_assets.presentation.balance.list.view
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.transition.AutoTransition
|
||||
import android.transition.TransitionManager
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -29,6 +31,10 @@ class PezkuwiDashboardAdapter(
|
||||
private var model: PezkuwiDashboardModel? = null
|
||||
private var trackingLoading: Boolean = false
|
||||
|
||||
// Survives ViewHolder recycling (scroll) within the process, but not process restart —
|
||||
// resets to collapsed (false) whenever the app is freshly opened, by design.
|
||||
private var isExpanded: Boolean = false
|
||||
|
||||
fun setModel(model: PezkuwiDashboardModel) {
|
||||
this.model = model
|
||||
notifyChangedIfShown()
|
||||
@@ -41,11 +47,11 @@ class PezkuwiDashboardAdapter(
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PezkuwiDashboardHolder {
|
||||
val binding = ItemPezkuwiDashboardBinding.inflate(parent.inflater(), parent, false)
|
||||
return PezkuwiDashboardHolder(binding, handler)
|
||||
return PezkuwiDashboardHolder(binding, handler) { expanded -> isExpanded = expanded }
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PezkuwiDashboardHolder, position: Int) {
|
||||
model?.let { holder.bind(it, trackingLoading) }
|
||||
model?.let { holder.bind(it, trackingLoading, isExpanded) }
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
@@ -55,7 +61,8 @@ class PezkuwiDashboardAdapter(
|
||||
|
||||
class PezkuwiDashboardHolder(
|
||||
private val binder: ItemPezkuwiDashboardBinding,
|
||||
handler: PezkuwiDashboardAdapter.Handler
|
||||
handler: PezkuwiDashboardAdapter.Handler,
|
||||
private val onExpandedChanged: (Boolean) -> Unit
|
||||
) : RecyclerView.ViewHolder(binder.root) {
|
||||
|
||||
companion object : WithViewType {
|
||||
@@ -67,14 +74,30 @@ class PezkuwiDashboardHolder(
|
||||
binder.pezkuwiDashboardSignButton.setOnClickListener { handler.onSignClicked() }
|
||||
binder.pezkuwiDashboardShareButton.setOnClickListener { handler.onShareReferralClicked() }
|
||||
binder.pezkuwiDashboardStartTrackingButton.setOnClickListener { handler.onStartTrackingClicked() }
|
||||
|
||||
binder.pezkuwiDashboardCollapsedBar.setOnClickListener { setExpanded(true) }
|
||||
binder.pezkuwiDashboardCollapseButton.setOnClickListener { setExpanded(false) }
|
||||
}
|
||||
|
||||
fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false) {
|
||||
private fun setExpanded(expanded: Boolean) {
|
||||
TransitionManager.beginDelayedTransition(binder.pezkuwiDashboardRoot, AutoTransition().apply { duration = 200 })
|
||||
binder.pezkuwiDashboardCollapsedBar.visibility = if (expanded) View.GONE else View.VISIBLE
|
||||
binder.pezkuwiDashboardExpandedContent.visibility = if (expanded) View.VISIBLE else View.GONE
|
||||
onExpandedChanged(expanded)
|
||||
}
|
||||
|
||||
fun bind(model: PezkuwiDashboardModel, trackingLoading: Boolean = false, isExpanded: Boolean = false) {
|
||||
bindRoles(model.roles)
|
||||
binder.pezkuwiDashboardTrustValue.text = model.trustScore
|
||||
binder.pezkuwiDashboardTrustValueCollapsed.text = model.trustScore
|
||||
binder.pezkuwiDashboardWelatiCount.text = model.welatiCount
|
||||
bindButtons(model.citizenshipStatus)
|
||||
|
||||
// Reflect current expand state without animating (this runs on every bind/rebind,
|
||||
// e.g. after RecyclerView recycling — animation is only for user-initiated toggles).
|
||||
binder.pezkuwiDashboardCollapsedBar.visibility = if (isExpanded) View.GONE else View.VISIBLE
|
||||
binder.pezkuwiDashboardExpandedContent.visibility = if (isExpanded) View.VISIBLE else View.GONE
|
||||
|
||||
val showTracking = !model.isTrackingScore && model.citizenshipStatus == CitizenshipStatus.APPROVED
|
||||
binder.pezkuwiDashboardStartTrackingButton.visibility = if (showTracking) View.VISIBLE else View.GONE
|
||||
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@ import io.novafoundation.nova.feature_assets.presentation.flow.network.model.Net
|
||||
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
|
||||
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
|
||||
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig
|
||||
import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard
|
||||
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
||||
import io.novafoundation.nova.runtime.multiNetwork.asset
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -73,7 +74,7 @@ abstract class NetworkFlowViewModel(
|
||||
NetworkFlowRvItem(
|
||||
it.chain.id,
|
||||
it.asset.token.configuration.id,
|
||||
it.chain.name,
|
||||
it.chain.displayNameWithAssetStandard(),
|
||||
it.chain.icon,
|
||||
amountFormatter.formatAmountToAmountModel(
|
||||
amount = getAssetBalance(it).amount,
|
||||
|
||||
@@ -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">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/pezkuwiDashboardRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_pezkuwi_dashboard"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- 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
|
||||
android:id="@+id/pezkuwiDashboardCollapsedBar"
|
||||
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:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:src="@drawable/ic_nevroz_flame" />
|
||||
|
||||
<LinearLayout
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
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
|
||||
android:id="@+id/pezkuwiDashboardTitle"
|
||||
<TextView
|
||||
android:id="@+id/pezkuwiDashboardTrustValueCollapsed"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:src="@drawable/ic_chevron_down"
|
||||
app:tint="@android:color/white" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Expanded state: full card, hidden by default. -->
|
||||
<LinearLayout
|
||||
android:id="@+id/pezkuwiDashboardExpandedContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_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_height="wrap_content"
|
||||
android:text="@string/pezkuwi_dashboard_title"
|
||||
android:textColor="#E0FFFFFF"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
android:gravity="end"
|
||||
android:orientation="vertical">
|
||||
|
||||
<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" />
|
||||
<TextView
|
||||
android:id="@+id/pezkuwiDashboardWelatiCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:textColor="#2FC864"
|
||||
android:textSize="24sp"
|
||||
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>
|
||||
|
||||
<!-- Trust score -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:orientation="vertical">
|
||||
android:layout_marginTop="14dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pezkuwiDashboardWelatiCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:textColor="#2FC864"
|
||||
android:textSize="24sp"
|
||||
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" />
|
||||
|
||||
<TextView
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/pezkuwiDashboardStartTrackingButton"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
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" />
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:minWidth="0dp"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:text="@string/pezkuwi_dashboard_start_tracking"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="11sp"
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="#009639"
|
||||
app:cornerRadius="8dp" />
|
||||
</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
|
||||
android:id="@+id/pezkuwiDashboardStartTrackingButton"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:minWidth="0dp"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:text="@string/pezkuwi_dashboard_start_tracking"
|
||||
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:textSize="11sp"
|
||||
android:visibility="gone"
|
||||
android:textStyle="bold"
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
+38
-3
@@ -24,8 +24,16 @@ data class CloudBackup(
|
||||
val name: String,
|
||||
val type: Type,
|
||||
val chainAccounts: Set<ChainAccountInfo>,
|
||||
// Nullable and defaulted so that Gson deserializing a backup written before Bitcoin support existed -
|
||||
// which has no such field in its JSON at all - lands on null here rather than failing.
|
||||
// All below are nullable and defaulted so that Gson deserializing a backup written before that
|
||||
// particular chain family existed - which has no such field in its JSON at all - lands on null here
|
||||
// rather than failing. Solana has no derivation anywhere in this codebase yet (no native support,
|
||||
// unlike Tron/Bitcoin) - its fields are reserved now purely so that adding that support later never
|
||||
// needs another silent-drop-prone trip through every call site that touches this schema, the way
|
||||
// Tron/Bitcoin did when they were bolted onto a schema that only knew about substrate/ethereum.
|
||||
val tronAddress: ByteArray? = null,
|
||||
val tronPublicKey: ByteArray? = null,
|
||||
val solanaAddress: ByteArray? = null,
|
||||
val solanaPublicKey: ByteArray? = null,
|
||||
val bitcoinAddress: ByteArray? = null,
|
||||
val bitcoinPublicKey: ByteArray? = null,
|
||||
) : Identifiable {
|
||||
@@ -77,6 +85,10 @@ data class CloudBackup(
|
||||
name == other.name &&
|
||||
type == other.type &&
|
||||
chainAccounts == other.chainAccounts &&
|
||||
tronAddress.contentEquals(other.tronAddress) &&
|
||||
tronPublicKey.contentEquals(other.tronPublicKey) &&
|
||||
solanaAddress.contentEquals(other.solanaAddress) &&
|
||||
solanaPublicKey.contentEquals(other.solanaPublicKey) &&
|
||||
bitcoinAddress.contentEquals(other.bitcoinAddress) &&
|
||||
bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)
|
||||
}
|
||||
@@ -91,6 +103,10 @@ data class CloudBackup(
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + type.hashCode()
|
||||
result = 31 * result + chainAccounts.hashCode()
|
||||
result = 31 * result + (tronAddress?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (solanaAddress?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (solanaPublicKey?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
|
||||
result = 31 * result + identifier.hashCode()
|
||||
@@ -108,6 +124,10 @@ data class CloudBackup(
|
||||
val substrate: SubstrateSecrets?,
|
||||
val ethereum: EthereumSecrets?,
|
||||
val chainAccounts: List<ChainAccountSecrets>,
|
||||
// See the matching comment on WalletPublicInfo: tron/bitcoin are fully wired end to end, solana is a
|
||||
// schema-only reservation until native derivation for it exists.
|
||||
val tron: TronSecrets? = null,
|
||||
val solana: SolanaSecrets? = null,
|
||||
val bitcoin: BitcoinSecrets? = null,
|
||||
) : Identifiable {
|
||||
|
||||
@@ -127,6 +147,8 @@ data class CloudBackup(
|
||||
if (substrate != other.substrate) return false
|
||||
if (ethereum != other.ethereum) return false
|
||||
if (chainAccounts != other.chainAccounts) return false
|
||||
if (tron != other.tron) return false
|
||||
if (solana != other.solana) return false
|
||||
if (bitcoin != other.bitcoin) return false
|
||||
return identifier == other.identifier
|
||||
}
|
||||
@@ -137,6 +159,8 @@ data class CloudBackup(
|
||||
result = 31 * result + (substrate?.hashCode() ?: 0)
|
||||
result = 31 * result + (ethereum?.hashCode() ?: 0)
|
||||
result = 31 * result + chainAccounts.hashCode()
|
||||
result = 31 * result + (tron?.hashCode() ?: 0)
|
||||
result = 31 * result + (solana?.hashCode() ?: 0)
|
||||
result = 31 * result + (bitcoin?.hashCode() ?: 0)
|
||||
result = 31 * result + identifier.hashCode()
|
||||
return result
|
||||
@@ -212,6 +236,17 @@ data class CloudBackup(
|
||||
val derivationPath: String?,
|
||||
)
|
||||
|
||||
data class TronSecrets(
|
||||
val keypair: KeyPairSecrets,
|
||||
val derivationPath: String?,
|
||||
)
|
||||
|
||||
// Reserved shape for when native Solana derivation is added - unused until then, see the class-level comment.
|
||||
data class SolanaSecrets(
|
||||
val keypair: KeyPairSecrets,
|
||||
val derivationPath: String?,
|
||||
)
|
||||
|
||||
data class BitcoinSecrets(
|
||||
val keypair: KeyPairSecrets,
|
||||
val derivationPath: String?,
|
||||
@@ -250,5 +285,5 @@ data class CloudBackup(
|
||||
}
|
||||
|
||||
fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean {
|
||||
return entropy == null && substrate == null && ethereum == null && bitcoin == null && chainAccounts.isEmpty()
|
||||
return entropy == null && substrate == null && ethereum == null && tron == null && bitcoin == null && chainAccounts.isEmpty()
|
||||
}
|
||||
|
||||
+39
-1
@@ -7,6 +7,7 @@ import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.
|
||||
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.EthereumSecrets
|
||||
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.KeyPairSecrets
|
||||
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.SubstrateSecrets
|
||||
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.TronSecrets
|
||||
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo
|
||||
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo
|
||||
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType
|
||||
@@ -99,6 +100,7 @@ class WalletPrivateInfoBuilder(
|
||||
|
||||
private var substrate: SubstrateSecrets? = null
|
||||
private var ethereum: EthereumSecrets? = null
|
||||
private var tron: TronSecrets? = null
|
||||
|
||||
private val chainAccounts = mutableListOf<ChainAccountSecrets>()
|
||||
|
||||
@@ -114,6 +116,10 @@ class WalletPrivateInfoBuilder(
|
||||
ethereum = BackupEthereumSecretsBuilder().apply(builder).build()
|
||||
}
|
||||
|
||||
fun tron(builder: BackupTronSecretsBuilder.() -> Unit) {
|
||||
tron = BackupTronSecretsBuilder().apply(builder).build()
|
||||
}
|
||||
|
||||
fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) {
|
||||
val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build()
|
||||
chainAccounts.add(element)
|
||||
@@ -126,6 +132,7 @@ class WalletPrivateInfoBuilder(
|
||||
substrate = substrate,
|
||||
ethereum = ethereum,
|
||||
chainAccounts = chainAccounts,
|
||||
tron = tron,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -173,6 +180,25 @@ class BackupEthereumSecretsBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
@CloudBackupBuildDsl
|
||||
class BackupTronSecretsBuilder {
|
||||
|
||||
private var _keypair: KeyPairSecrets? = null
|
||||
private var _derivationPath: String? = null
|
||||
|
||||
fun derivationPath(value: String?) {
|
||||
_derivationPath = value
|
||||
}
|
||||
|
||||
fun keypair(keypair: KeyPairSecrets) {
|
||||
_keypair = keypair
|
||||
}
|
||||
|
||||
fun build(): TronSecrets {
|
||||
return TronSecrets(requireNotNull(_keypair), _derivationPath)
|
||||
}
|
||||
}
|
||||
|
||||
@CloudBackupBuildDsl
|
||||
class BackupChainAccountSecretsBuilder(private val accountId: AccountId) {
|
||||
|
||||
@@ -223,6 +249,8 @@ class WalletPublicInfoBuilder(
|
||||
private var _substrateAccountId: ByteArray? = null
|
||||
private var _ethereumPublicKey: ByteArray? = null
|
||||
private var _ethereumAddress: ByteArray? = null
|
||||
private var _tronPublicKey: ByteArray? = null
|
||||
private var _tronAddress: ByteArray? = null
|
||||
private var _name: String = ""
|
||||
private var _isSelected: Boolean = false
|
||||
private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS
|
||||
@@ -252,6 +280,14 @@ class WalletPublicInfoBuilder(
|
||||
_ethereumAddress = value
|
||||
}
|
||||
|
||||
fun tronPublicKey(value: ByteArray?) {
|
||||
_tronPublicKey = value
|
||||
}
|
||||
|
||||
fun tronAddress(value: ByteArray?) {
|
||||
_tronAddress = value
|
||||
}
|
||||
|
||||
fun name(value: String) {
|
||||
_name = value
|
||||
}
|
||||
@@ -274,7 +310,9 @@ class WalletPublicInfoBuilder(
|
||||
ethereumPublicKey = _ethereumPublicKey,
|
||||
name = _name,
|
||||
type = _type,
|
||||
chainAccounts = chainAccounts.toSet()
|
||||
chainAccounts = chainAccounts.toSet(),
|
||||
tronAddress = _tronAddress,
|
||||
tronPublicKey = _tronPublicKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -9,6 +9,7 @@ import io.novafoundation.nova.runtime.ext.isCustomNetwork
|
||||
import io.novafoundation.nova.runtime.ext.isDisabled
|
||||
import io.novafoundation.nova.runtime.ext.isEnabled
|
||||
import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull
|
||||
import io.novafoundation.nova.runtime.ext.httpNodes
|
||||
import io.novafoundation.nova.runtime.ext.wssNodes
|
||||
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||
@@ -132,7 +133,13 @@ class RealNetworkManagementChainInteractor(
|
||||
}
|
||||
|
||||
private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow<List<NodeHealthState>> {
|
||||
return chain.nodes.wssNodes().map {
|
||||
// wssNodes() alone leaves an HTTPS-only chain (Tron today - no wss endpoint at all) with an empty list
|
||||
// here, which silently renders as "nothing to show" rather than a real health state - fall back to the
|
||||
// http nodes only when there are no wss ones, since a chain that genuinely has wss nodes should still
|
||||
// prefer testing those.
|
||||
val nodesToCheck = chain.nodes.wssNodes().ifEmpty { chain.nodes.httpNodes() }
|
||||
|
||||
return nodesToCheck.map {
|
||||
nodeHealthState(chain, it, coroutineScope)
|
||||
}.combine()
|
||||
}
|
||||
|
||||
+6
@@ -61,6 +61,12 @@ class RealNetworkListAdapterItemFactory(
|
||||
private fun getConnectingState(network: NetworkState): ConnectionStateModel? {
|
||||
if (network.chain.isDisabled) return null
|
||||
|
||||
// Tron chains never get a ChainConnection/SocketService (TronGrid is a plain REST API, not
|
||||
// WSS JSON-RPC - see ChainRegistry.registerConnection()), so connectionState is always the
|
||||
// Disconnected default here, never Connected. Treat that as "nothing to show" rather than
|
||||
// falling into the generic "Connecting" state below, which would otherwise spin forever.
|
||||
if (network.chain.isTronBased) return null
|
||||
|
||||
return when (network.connectionState) {
|
||||
is SocketStateMachine.State.Connected -> null
|
||||
|
||||
|
||||
+2
@@ -10,4 +10,6 @@ sealed interface TransactionExecution {
|
||||
class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution
|
||||
|
||||
class Bitcoin(val hash: String) : TransactionExecution
|
||||
|
||||
class Tron(val hash: String) : TransactionExecution
|
||||
}
|
||||
|
||||
@@ -27,6 +27,17 @@ android {
|
||||
}
|
||||
namespace 'io.novafoundation.nova.feature_wallet_impl'
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
testLogging {
|
||||
events "failed"
|
||||
exceptionFormat "full"
|
||||
showCauses true
|
||||
showStackTraces true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
@@ -71,6 +82,7 @@ dependencies {
|
||||
|
||||
testImplementation jUnitDep
|
||||
testImplementation mockitoDep
|
||||
testImplementation project(':test-shared')
|
||||
|
||||
implementation substrateSdkDep
|
||||
|
||||
|
||||
+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(
|
||||
chain: Chain,
|
||||
chainAsset: Chain.Asset,
|
||||
@@ -138,40 +142,32 @@ class StatemineAssetBalance(
|
||||
accountId: AccountId,
|
||||
subscriptionBuilder: SharedRequestsBuilder
|
||||
): Flow<BalanceSyncUpdate> {
|
||||
return runCatching {
|
||||
val runtime = chainRegistry.getRuntime(chain.id)
|
||||
val runtime = chainRegistry.getRuntime(chain.id)
|
||||
|
||||
val statemineType = chainAsset.requireStatemine()
|
||||
val encodableAssetId = statemineType.prepareIdForEncoding(runtime)
|
||||
val statemineType = chainAsset.requireStatemine()
|
||||
val encodableAssetId = statemineType.prepareIdForEncoding(runtime)
|
||||
|
||||
val module = runtime.metadata.statemineModule(statemineType)
|
||||
val module = runtime.metadata.statemineModule(statemineType)
|
||||
|
||||
val assetAccountStorage = module.storage("Account")
|
||||
val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId)
|
||||
val assetAccountStorage = module.storage("Account")
|
||||
val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId)
|
||||
|
||||
val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder)
|
||||
val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder)
|
||||
|
||||
combine(
|
||||
subscriptionBuilder.subscribe(assetAccountKey),
|
||||
assetDetailsFlow.map { it.status.transfersFrozen }
|
||||
) { balanceStorageChange, isAssetFrozen ->
|
||||
val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime)
|
||||
val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded)
|
||||
return combine(
|
||||
subscriptionBuilder.subscribe(assetAccountKey),
|
||||
assetDetailsFlow.map { it.status.transfersFrozen }
|
||||
) { balanceStorageChange, isAssetFrozen ->
|
||||
val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime)
|
||||
val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded)
|
||||
|
||||
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount)
|
||||
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount)
|
||||
|
||||
if (assetChanged) {
|
||||
BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block)
|
||||
} else {
|
||||
BalanceSyncUpdate.NoCause
|
||||
}
|
||||
}.catch { error ->
|
||||
Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
|
||||
emit(BalanceSyncUpdate.NoCause)
|
||||
if (assetChanged) {
|
||||
BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block)
|
||||
} else {
|
||||
BalanceSyncUpdate.NoCause
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
Log.e(LOG_TAG, "Failed to start balance sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
|
||||
emptyFlow()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-8
@@ -10,7 +10,6 @@ import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.b
|
||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.pollingBalanceFlow
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
|
||||
import io.novafoundation.nova.runtime.ext.addressOf
|
||||
import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl
|
||||
import io.novafoundation.nova.runtime.ext.requireTrc20
|
||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||
@@ -21,9 +20,9 @@ import kotlinx.coroutines.flow.map
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via TronGrid's REST API (the same
|
||||
* `/v1/accounts/{address}` endpoint used for native TRX - TronGrid returns both in one response) and polls for
|
||||
* updates. No transfer/history support here - see `TronAssetsModule`.
|
||||
* TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via an on-chain `balanceOf` contract
|
||||
* call (see [TronGridApi.fetchTrc20Balance] for why this can't reuse the `/v1/accounts` endpoint that native
|
||||
* TRX balance reads from) and polls for updates. No transfer/history support here - see `TronAssetsModule`.
|
||||
*/
|
||||
class Trc20AssetBalance(
|
||||
private val assetCache: AssetCache,
|
||||
@@ -52,9 +51,8 @@ class Trc20AssetBalance(
|
||||
|
||||
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
|
||||
val contractAddress = chainAsset.requireTrc20().contractAddress
|
||||
val address = chain.addressOf(accountId)
|
||||
|
||||
val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), address, contractAddress)
|
||||
val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), accountId, contractAddress)
|
||||
|
||||
return ChainAssetBalance.fromFree(chainAsset, balance)
|
||||
}
|
||||
@@ -77,9 +75,8 @@ class Trc20AssetBalance(
|
||||
): Flow<BalanceSyncUpdate> {
|
||||
val contractAddress = chainAsset.requireTrc20().contractAddress
|
||||
val baseUrl = chain.requireTronGridBaseUrl()
|
||||
val address = chain.addressOf(accountId)
|
||||
|
||||
return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, address, contractAddress) }
|
||||
return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, accountId, contractAddress) }
|
||||
.map { balance ->
|
||||
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
|
||||
|
||||
|
||||
+13
-2
@@ -1,17 +1,26 @@
|
||||
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative
|
||||
|
||||
import android.util.Log
|
||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
private const val TRON_BALANCE_POLLING_INTERVAL_MS = 30_000L
|
||||
private const val LOG_TAG = "TronBalancePolling"
|
||||
|
||||
/**
|
||||
* TronGrid is a plain REST API with no push/subscription mechanism (unlike Ethereum nodes, which expose a
|
||||
* `newHeads`-style websocket subscription EVM balance sync piggybacks on). So balance updates for Tron-based
|
||||
* assets are polled instead of pushed: fetch immediately, then re-fetch on an interval, only emitting when the
|
||||
* balance actually changed.
|
||||
*
|
||||
* A failed fetch() must not escape this loop: any uncaught exception here cancels the whole flow permanently
|
||||
* (the collector - FullSyncPaymentUpdater - only logs and gives up, it doesn't resubscribe), which meant a
|
||||
* single transient failure (DNS hiccup, timeout, momentary connectivity loss during app cold start) could
|
||||
* silently and permanently blackhole a Tron asset - no balance write ever happens, so it never even gets a row
|
||||
* in the local DB and disappears from every UI surface with no visible error. Swallow and retry next interval
|
||||
* instead.
|
||||
*/
|
||||
internal fun pollingBalanceFlow(
|
||||
intervalMs: Long = TRON_BALANCE_POLLING_INTERVAL_MS,
|
||||
@@ -20,9 +29,11 @@ internal fun pollingBalanceFlow(
|
||||
var lastEmitted: Balance? = null
|
||||
|
||||
while (true) {
|
||||
val latest = fetch()
|
||||
val latest = runCatching { fetch() }
|
||||
.onFailure { Log.e(LOG_TAG, "Tron balance fetch failed, will retry in ${intervalMs}ms", it) }
|
||||
.getOrNull()
|
||||
|
||||
if (latest != lastEmitted) {
|
||||
if (latest != null && latest != lastEmitted) {
|
||||
lastEmitted = latest
|
||||
emit(latest)
|
||||
}
|
||||
|
||||
+12
-11
@@ -151,6 +151,17 @@ class NativeAssetBalance(
|
||||
}
|
||||
}
|
||||
|
||||
// Setup/subscription failures are allowed to propagate rather than being swallowed into emptyFlow()/NoCause:
|
||||
// the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole call in a single retryWhen boundary meant
|
||||
// to catch and retry exactly these failures. Swallowing here would make that retry boundary never trigger.
|
||||
//
|
||||
// NOTE (2026-07-09): a prior attempt switched this to the typed remoteStorage.subscribe { metadata.system
|
||||
// .account... } DSL, on the theory that it would fix HEZ silently never syncing on Pezkuwi Asset Hub (see
|
||||
// git history). That attempt made things categorically worse - all assets across all 3 Pezkuwi chains
|
||||
// stopped syncing, with logs showing what looked like cross-chain key contamination on the shared
|
||||
// connection. Reverted back to the raw subscriptionBuilder.subscribe(key) form here, which is not broken
|
||||
// for any OTHER native asset on any OTHER chain - only Pezkuwi Asset Hub's HEZ specifically. That narrower
|
||||
// bug is still open; do not re-attempt the DSL swap without first understanding why it caused contamination.
|
||||
override suspend fun startSyncingBalance(
|
||||
chain: Chain,
|
||||
chainAsset: Chain.Asset,
|
||||
@@ -160,13 +171,7 @@ class NativeAssetBalance(
|
||||
): Flow<BalanceSyncUpdate> {
|
||||
val runtime = chainRegistry.getRuntime(chain.id)
|
||||
|
||||
val key = try {
|
||||
runtime.metadata.system().storage("Account").storageKey(runtime, accountId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "Failed to construct account storage key: ${e.message} in ${chain.name}")
|
||||
|
||||
return emptyFlow()
|
||||
}
|
||||
val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId)
|
||||
|
||||
return subscriptionBuilder.subscribe(key)
|
||||
.map { change ->
|
||||
@@ -179,10 +184,6 @@ class NativeAssetBalance(
|
||||
BalanceSyncUpdate.NoCause
|
||||
}
|
||||
}
|
||||
.catch { error ->
|
||||
Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
|
||||
emit(BalanceSyncUpdate.NoCause)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindBalanceHolds(dynamicInstance: Any?): List<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
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -1,14 +1,56 @@
|
||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
||||
|
||||
import io.novafoundation.nova.common.data.network.UserAgent
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronChainParametersResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
|
||||
import io.novafoundation.nova.runtime.BuildConfig
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Url
|
||||
|
||||
// TronGrid's anonymous rate limit is aggressive enough to surface under normal, human-paced app usage (see
|
||||
// RealTronGridApi.retryOn429's doc comment) - a free API key from trongrid.io raises it substantially. Sent on
|
||||
// every request rather than only when a 429 is hit, since the point is to avoid needing the retry in the first
|
||||
// place, not just to have a fallback.
|
||||
private const val TRON_API_KEY_HEADER = "TRON-PRO-API-KEY: " + BuildConfig.TRONGRID_API_KEY
|
||||
|
||||
interface RetrofitTronGridApi {
|
||||
|
||||
@GET
|
||||
@Headers(UserAgent.NOVA)
|
||||
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
|
||||
suspend fun getAccount(@Url url: String): TronAccountResponse
|
||||
|
||||
@POST
|
||||
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
|
||||
suspend fun createTransaction(@Url url: String, @Body body: TronCreateTransactionRequest): TronUnsignedTransactionResponse
|
||||
|
||||
@POST
|
||||
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
|
||||
suspend fun triggerConstantContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse
|
||||
|
||||
@POST
|
||||
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
|
||||
suspend fun triggerSmartContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse
|
||||
|
||||
@POST
|
||||
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
|
||||
suspend fun broadcastTransaction(@Url url: String, @Body body: TronBroadcastRequest): TronBroadcastResponse
|
||||
|
||||
@GET
|
||||
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
|
||||
suspend fun getChainParameters(@Url url: String): TronChainParametersResponse
|
||||
|
||||
@POST
|
||||
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
|
||||
suspend fun getAccountResource(@Url url: String, @Body body: TronAddressRequest): TronAccountResourceResponse
|
||||
}
|
||||
|
||||
+233
-9
@@ -1,39 +1,263 @@
|
||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
||||
|
||||
import io.novafoundation.nova.common.utils.toTronHexAddress
|
||||
import io.novafoundation.nova.common.utils.tronAddressToHexAddress
|
||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.Trc20TransferAbi
|
||||
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||
import kotlinx.coroutines.delay
|
||||
import retrofit2.HttpException
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Thrown whenever TronGrid reports a failure via an HTTP-200 body (rather than an HTTP error status), which is
|
||||
* how most `/wallet/` endpoints signal validation/execution failures, e.g.
|
||||
* `{"Error": "... no OwnerAccount."}` from `createtransaction`, or
|
||||
* `{"code": "CONTRACT_VALIDATE_ERROR", "message": "<hex>"}` from `broadcasttransaction`.
|
||||
*/
|
||||
class TronApiException(message: String) : Exception(message)
|
||||
|
||||
interface TronGridApi {
|
||||
|
||||
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
|
||||
|
||||
suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance
|
||||
/**
|
||||
* Reads via an on-chain `balanceOf(address)` call (`triggerconstantcontract`), NOT `/v1/accounts` - a
|
||||
* TRC-20 balance lives in the token contract's own storage, not in the holder's Account object, so an
|
||||
* address that has only ever received TRC-20 tokens (never native TRX, never otherwise "activated") has no
|
||||
* Account object at all and `/v1/accounts` returns empty for it regardless of its real token balance.
|
||||
* Confirmed live: a wallet holding exactly 5 USDT-TRC20 and zero TRX/activation history returned `data: []`
|
||||
* from `/v1/accounts` while `balanceOf` correctly returned 5000000.
|
||||
*/
|
||||
suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance
|
||||
|
||||
/**
|
||||
* Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`.
|
||||
* [ownerHexAddress]/[toHexAddress] must be in hex form (`41`-prefixed), matching `visible: false`.
|
||||
*
|
||||
* Note: TronGrid refuses to build this for an owner account that has never been activated on-chain
|
||||
* (confirmed live: returns `{"Error": "... no OwnerAccount."}`) - unlike [triggerSmartContract], which
|
||||
* happily builds a transaction for an unfunded/unactivated owner.
|
||||
*/
|
||||
suspend fun createNativeTransfer(baseUrl: String, ownerHexAddress: String, toHexAddress: String, amountSun: BigInteger): TronUnsignedTransactionResponse
|
||||
|
||||
/**
|
||||
* Read-only dry run via `POST /wallet/triggerconstantcontract` - does not require the owner account to hold
|
||||
* any TRX and does not touch chain state. Used to estimate the `energy_used` an actual TRC-20 call would
|
||||
* cost (see [TronTriggerContractResponse.energyUsed]).
|
||||
*/
|
||||
suspend fun triggerConstantContract(
|
||||
baseUrl: String,
|
||||
ownerHexAddress: String,
|
||||
contractHexAddress: String,
|
||||
functionSelector: String,
|
||||
parameterHex: String
|
||||
): TronTriggerContractResponse
|
||||
|
||||
/**
|
||||
* Builds an unsigned TRC-20 contract call via `POST /wallet/triggersmartcontract`. Unlike
|
||||
* [createNativeTransfer], this works even for an owner account that has never been activated on-chain
|
||||
* (confirmed live).
|
||||
*/
|
||||
suspend fun triggerSmartContract(
|
||||
baseUrl: String,
|
||||
ownerHexAddress: String,
|
||||
contractHexAddress: String,
|
||||
functionSelector: String,
|
||||
parameterHex: String,
|
||||
feeLimitSun: BigInteger
|
||||
): TronTriggerContractResponse
|
||||
|
||||
/**
|
||||
* Signs-and-submits via `POST /wallet/broadcasttransaction`. The full [unsigned] transaction (including its
|
||||
* `raw_data` object, not just `raw_data_hex`) must be echoed back verbatim alongside the signature - sending
|
||||
* only `raw_data_hex` + `signature` was confirmed live to fail with a deserialization error on TronGrid's side.
|
||||
*
|
||||
* @return the transaction hash (`txID`) on success.
|
||||
* @throws TronApiException if TronGrid rejects the broadcast (invalid signature, insufficient balance, etc.)
|
||||
*/
|
||||
suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String
|
||||
|
||||
/** `key -> value` map from `GET /wallet/getchainparameters`, e.g. `getEnergyFee` (sun/energy), `getTransactionFee` (sun/byte). */
|
||||
suspend fun getChainParameters(baseUrl: String): Map<String, Long>
|
||||
|
||||
suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse
|
||||
}
|
||||
|
||||
class RealTronGridApi(
|
||||
private val retrofitApi: RetrofitTronGridApi
|
||||
) : TronGridApi {
|
||||
|
||||
// TronGrid's public (no API key) endpoint rate-limits aggressively - confirmed live to return a bare HTTP
|
||||
// 429 under normal, human-paced usage (not just load testing) once a handful of requests land in a short
|
||||
// window. Without this, a 429 on any call in the send flow (fee estimation re-runs on every keystroke,
|
||||
// broadcast, etc.) surfaced straight to the user as a raw error dialog, and the only way through was to
|
||||
// keep tapping Confirm until a request happened to land outside the rate-limit window. Retrying here means
|
||||
// every TronGrid call gets this transparently, not just the ones a caller remembered to wrap.
|
||||
private suspend fun <T> retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T {
|
||||
repeat(maxAttempts - 1) { attempt ->
|
||||
try {
|
||||
return block()
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() != 429) throw e
|
||||
delay(1_000L * (attempt + 1))
|
||||
}
|
||||
}
|
||||
return block()
|
||||
}
|
||||
|
||||
override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance {
|
||||
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
|
||||
|
||||
return accountData.balance?.toBigInteger() ?: BigInteger.ZERO
|
||||
}
|
||||
|
||||
override suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance {
|
||||
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
|
||||
override suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance {
|
||||
val response = triggerConstantContract(
|
||||
baseUrl = baseUrl,
|
||||
ownerHexAddress = holderAccountId.toTronHexAddress(),
|
||||
contractHexAddress = contractAddress.tronAddressToHexAddress(),
|
||||
functionSelector = Trc20TransferAbi.BALANCE_OF_FUNCTION_SELECTOR,
|
||||
parameterHex = Trc20TransferAbi.encodeBalanceOfParameters(holderAccountId)
|
||||
)
|
||||
|
||||
val rawBalance = accountData.trc20.orEmpty()
|
||||
.firstNotNullOfOrNull { entry -> entry[contractAddress] }
|
||||
val resultHex = response.constantResult?.firstOrNull() ?: return BigInteger.ZERO
|
||||
|
||||
return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO
|
||||
return runCatching { BigInteger(resultHex, 16) }.getOrDefault(BigInteger.ZERO)
|
||||
}
|
||||
|
||||
private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount(
|
||||
url = accountUrl(baseUrl, address)
|
||||
).data?.firstOrNull()
|
||||
override suspend fun createNativeTransfer(
|
||||
baseUrl: String,
|
||||
ownerHexAddress: String,
|
||||
toHexAddress: String,
|
||||
amountSun: BigInteger
|
||||
): TronUnsignedTransactionResponse {
|
||||
val request = TronCreateTransactionRequest(
|
||||
ownerAddress = ownerHexAddress,
|
||||
toAddress = toHexAddress,
|
||||
amount = amountSun.toLongExactOrThrow("amount")
|
||||
)
|
||||
|
||||
val response = retryOn429 { retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request) }
|
||||
|
||||
return response.requireConstructed()
|
||||
}
|
||||
|
||||
override suspend fun triggerConstantContract(
|
||||
baseUrl: String,
|
||||
ownerHexAddress: String,
|
||||
contractHexAddress: String,
|
||||
functionSelector: String,
|
||||
parameterHex: String
|
||||
): TronTriggerContractResponse {
|
||||
val request = TronTriggerContractRequest(
|
||||
ownerAddress = ownerHexAddress,
|
||||
contractAddress = contractHexAddress,
|
||||
functionSelector = functionSelector,
|
||||
parameter = parameterHex
|
||||
)
|
||||
|
||||
return retryOn429 { retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request) }
|
||||
}
|
||||
|
||||
override suspend fun triggerSmartContract(
|
||||
baseUrl: String,
|
||||
ownerHexAddress: String,
|
||||
contractHexAddress: String,
|
||||
functionSelector: String,
|
||||
parameterHex: String,
|
||||
feeLimitSun: BigInteger
|
||||
): TronTriggerContractResponse {
|
||||
val request = TronTriggerContractRequest(
|
||||
ownerAddress = ownerHexAddress,
|
||||
contractAddress = contractHexAddress,
|
||||
functionSelector = functionSelector,
|
||||
parameter = parameterHex,
|
||||
feeLimit = feeLimitSun.toLongExactOrThrow("feeLimit")
|
||||
)
|
||||
|
||||
val response = retryOn429 { retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request) }
|
||||
|
||||
if (response.result?.result != true) {
|
||||
throw TronApiException(response.result?.message ?: response.result?.code ?: "triggersmartcontract failed without a message")
|
||||
}
|
||||
|
||||
// Only validate that a transaction was actually returned - callers read [TronTriggerContractResponse.transaction] themselves.
|
||||
response.transaction?.requireConstructed()
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
override suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String {
|
||||
val txId = requireNotNull(unsigned.txID) { "Cannot broadcast a transaction without a txID" }
|
||||
|
||||
val request = TronBroadcastRequest(
|
||||
visible = unsigned.visible ?: false,
|
||||
txID = txId,
|
||||
rawData = requireNotNull(unsigned.rawData) { "Cannot broadcast a transaction without raw_data" },
|
||||
rawDataHex = requireNotNull(unsigned.rawDataHex) { "Cannot broadcast a transaction without raw_data_hex" },
|
||||
signature = listOf(signatureHex)
|
||||
)
|
||||
|
||||
// Safe to retry on 429 specifically: a 429 means TronGrid rejected the request before processing it
|
||||
// (rate limit), not that the transaction may have already been broadcast - unlike a timeout, it can't
|
||||
// cause a double-send.
|
||||
val response = retryOn429 { retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request) }
|
||||
|
||||
if (response.result != true) {
|
||||
throw TronApiException(response.decodeErrorMessage())
|
||||
}
|
||||
|
||||
return response.txid ?: txId
|
||||
}
|
||||
|
||||
override suspend fun getChainParameters(baseUrl: String): Map<String, Long> {
|
||||
return retryOn429 { retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters")) }
|
||||
.chainParameter
|
||||
.associate { it.key to it.value }
|
||||
}
|
||||
|
||||
override suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse {
|
||||
return retryOn429 { retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex)) }
|
||||
}
|
||||
|
||||
private suspend fun fetchAccountData(baseUrl: String, address: String) = retryOn429 {
|
||||
retrofitApi.getAccount(url = accountUrl(baseUrl, address))
|
||||
}.data?.firstOrNull()
|
||||
|
||||
private fun accountUrl(baseUrl: String, address: String): String {
|
||||
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,
|
||||
)
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
|
||||
|
||||
import io.novafoundation.nova.common.utils.castOrNull
|
||||
import io.novafoundation.nova.common.utils.sha256
|
||||
import io.novafoundation.nova.common.utils.toEcdsaSignatureData
|
||||
import io.novafoundation.nova.common.utils.toTronHexAddress
|
||||
import io.novafoundation.nova.common.utils.tronAddressToHexAddress
|
||||
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
|
||||
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
|
||||
import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin
|
||||
import io.novafoundation.nova.feature_account_api.data.model.Fee
|
||||
import io.novafoundation.nova.feature_account_api.data.model.TronFee
|
||||
import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType
|
||||
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
|
||||
import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy
|
||||
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
||||
import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor
|
||||
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
|
||||
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
|
||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
|
||||
import io.novafoundation.nova.runtime.ext.commissionAsset
|
||||
import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl
|
||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
|
||||
import java.math.BigInteger
|
||||
|
||||
private const val ENERGY_FEE_PARAM_KEY = "getEnergyFee"
|
||||
private const val TRANSACTION_FEE_PARAM_KEY = "getTransactionFee"
|
||||
|
||||
// Fallbacks only used if a live `getchainparameters` call fails or is missing the expected key - both values are
|
||||
// what was observed live on Shasta testnet at implementation time, which also match Tron's long-standing mainnet
|
||||
// defaults; the primary path always fetches live values.
|
||||
private val FALLBACK_ENERGY_FEE_SUN = BigInteger.valueOf(420)
|
||||
private val FALLBACK_BANDWIDTH_FEE_SUN = BigInteger.valueOf(1000)
|
||||
|
||||
// Used only if a triggerconstantcontract dry run fails outright (e.g. transient network error) and returns no
|
||||
// energy_used at all - a conservative (intentionally high) stand-in for a simple TRC-20 transfer, which in
|
||||
// practice costs on the order of 15-30k energy. Mirrors EvmErc20AssetTransfers' ERC_20_UPPER_GAS_LIMIT fallback.
|
||||
private val FALLBACK_TRC20_ENERGY_UNITS = 65_000L
|
||||
private val FALLBACK_TRC20_TX_SIZE_BYTES = 350L
|
||||
|
||||
// fee_limit sent with triggersmartcontract: Tron only ever burns what a call actually uses (up to this cap), so
|
||||
// setting this generously above our own estimate does not cost the user more - it only avoids an OUT_OF_ENERGY
|
||||
// failure if our estimate undershoots. Bounded above as a sanity guard against a runaway estimate.
|
||||
private val MIN_FEE_LIMIT_SUN = BigInteger.valueOf(15_000_000) // 15 TRX
|
||||
private val MAX_FEE_LIMIT_SUN = BigInteger.valueOf(100_000_000) // 100 TRX
|
||||
|
||||
private val EMPTY_RESOURCE = TronAccountResourceResponse()
|
||||
|
||||
/**
|
||||
* Builds, signs and broadcasts Tron transactions (native TRX and TRC-20) using TronGrid's own REST endpoints for
|
||||
* construction/broadcast, and this app's existing ECDSA signing primitive for signing - no Tron protobuf
|
||||
* (`Transaction`/`TransferContract`/`TriggerSmartContract`) encoding and no new crypto library were added.
|
||||
*
|
||||
* ## Construction
|
||||
* - Native TRX: `POST /wallet/createtransaction` with `{owner_address, to_address, amount}` (all hex-encoded,
|
||||
* `visible: false`). Requires the owner account to already be activated on-chain (confirmed live: an
|
||||
* unactivated owner gets `{"Error": "... no OwnerAccount."}`) - in practice this is never hit here, since a
|
||||
* user can only reach the send flow with a positive TRX balance to send from, which itself implies the account
|
||||
* was already activated by an earlier incoming transfer.
|
||||
* - TRC-20: `POST /wallet/triggersmartcontract` with the ABI-encoded `transfer(address,uint256)` call (see
|
||||
* [Trc20TransferAbi]). Unlike `createtransaction`, this was confirmed live to work even for a
|
||||
* never-activated owner account.
|
||||
*
|
||||
* ## Signing
|
||||
* Tron's signature is `ECDSA_sign(privateKey, sha256(raw_data))` over secp256k1 - the same curve/primitive this
|
||||
* app already uses for Ethereum. [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw]
|
||||
* (backed by `substrate_sdk_android`'s `Signer.sign(MultiChainEncryption.Ethereum, message, keypair, skipHashing)`
|
||||
* -> `web3j`'s `Sign.signMessage(hash, keyPair, needToHash = false)`) already supports signing a pre-computed
|
||||
* hash directly via `SignerPayloadRaw.skipMessageHashing = true` - this is exactly the primitive Ethereum-style
|
||||
* raw-hash signing needs, and it is reused as-is here. No new cryptographic code or library was added; only the
|
||||
* hash fed into it differs from the EVM path (`sha256(raw_data)` here vs. an EIP-155 RLP-based digest there).
|
||||
*
|
||||
* `web3j`'s `Sign.signMessage` always left-pads `r`/`s` to exactly 32 bytes and encodes `v` as `27/28` (confirmed
|
||||
* by reading `web3j`'s `Sign.java` source) - which is byte-for-byte the same `r(32) + s(32) + v(1)` = 65-byte
|
||||
* compact signature format Tron expects (confirmed against `tronweb`'s own `ECKeySign` implementation, and
|
||||
* independently confirmed live against Shasta testnet - see the Phase 2 implementation notes).
|
||||
*
|
||||
* ## Broadcast
|
||||
* `POST /wallet/broadcasttransaction` with the full unsigned transaction object (not just `raw_data_hex`) plus
|
||||
* `signature: [<65-byte hex signature>]`.
|
||||
*/
|
||||
class RealTronTransactionService(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val signerProvider: SignerProvider,
|
||||
private val tronGridApi: TronGridApi,
|
||||
) : TronTransactionService {
|
||||
|
||||
override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee {
|
||||
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
|
||||
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
|
||||
val baseUrl = chain.requireTronGridBaseUrl()
|
||||
val ownerHex = ownerAccountId.toTronHexAddress()
|
||||
|
||||
val feeSun = when (intent) {
|
||||
is TronTransactionIntent.Native -> estimateNativeFee(baseUrl, ownerHex, recipient, intent.amountSun)
|
||||
is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex(
|
||||
baseUrl,
|
||||
ownerHex,
|
||||
recipient,
|
||||
intent.contractAddress.tronAddressToHexAddress(),
|
||||
intent.amountSun
|
||||
)
|
||||
}
|
||||
|
||||
return TronFee(feeSun, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset)
|
||||
}
|
||||
|
||||
override suspend fun transact(
|
||||
chain: Chain,
|
||||
origin: TransactionOrigin,
|
||||
recipient: AccountId,
|
||||
presetFee: Fee?,
|
||||
intent: TronTransactionIntent
|
||||
): Result<ExtrinsicSubmission> = runCatching {
|
||||
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
|
||||
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
|
||||
val baseUrl = chain.requireTronGridBaseUrl()
|
||||
val ownerHex = ownerAccountId.toTronHexAddress()
|
||||
val recipientHex = recipient.toTronHexAddress()
|
||||
|
||||
val unsigned = when (intent) {
|
||||
is TronTransactionIntent.Native -> tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipientHex, intent.amountSun)
|
||||
|
||||
is TronTransactionIntent.Trc20Transfer -> {
|
||||
val contractHex = intent.contractAddress.tronAddressToHexAddress()
|
||||
val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, intent.amountSun)
|
||||
val feeLimit = feeLimitFor(presetFee, baseUrl, ownerHex, recipient, contractHex, intent.amountSun)
|
||||
|
||||
tronGridApi.triggerSmartContract(
|
||||
baseUrl = baseUrl,
|
||||
ownerHexAddress = ownerHex,
|
||||
contractHexAddress = contractHex,
|
||||
functionSelector = Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR,
|
||||
parameterHex = parameterHex,
|
||||
feeLimitSun = feeLimit
|
||||
).transaction ?: error("TronGrid returned no transaction from triggersmartcontract")
|
||||
}
|
||||
}
|
||||
|
||||
val txHash = signAndBroadcast(baseUrl, unsigned, submittingMetaAccount, ownerAccountId)
|
||||
|
||||
ExtrinsicSubmission(
|
||||
hash = txHash,
|
||||
submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId),
|
||||
callExecutionType = CallExecutionType.IMMEDIATE,
|
||||
submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun transactAndAwaitExecution(
|
||||
chain: Chain,
|
||||
origin: TransactionOrigin,
|
||||
recipient: AccountId,
|
||||
presetFee: Fee?,
|
||||
intent: TronTransactionIntent
|
||||
): Result<TransactionExecution> {
|
||||
// Tron transactions execute atomically with inclusion (no separate "prepare" step, same as EVM) - so
|
||||
// successful broadcast is already a strong signal. We do not poll for block confirmation here since
|
||||
// this method sits outside the primary send flow's critical path (`SendInteractor`/`RealSendUseCase`
|
||||
// only ever call `transact`, not this) - see Phase 2 implementation notes for what remains unverified.
|
||||
return transact(chain, origin, recipient, presetFee, intent).map { TransactionExecution.Tron(it.hash) }
|
||||
}
|
||||
|
||||
private suspend fun signAndBroadcast(
|
||||
baseUrl: String,
|
||||
unsigned: TronUnsignedTransactionResponse,
|
||||
metaAccount: MetaAccount,
|
||||
ownerAccountId: AccountId
|
||||
): String {
|
||||
val rawDataHex = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }
|
||||
val messageHash = rawDataHex.fromHex().sha256()
|
||||
|
||||
check(unsigned.txID == null || messageHash.toHexString(withPrefix = false) == unsigned.txID) {
|
||||
"sha256(raw_data) does not match the txID TronGrid reported - refusing to sign a possibly-tampered transaction"
|
||||
}
|
||||
|
||||
val signer = signerProvider.rootSignerFor(metaAccount)
|
||||
val signedRaw = signer.signRaw(SignerPayloadRaw(message = messageHash, accountId = ownerAccountId, skipMessageHashing = true))
|
||||
val signature = signedRaw.toEcdsaSignatureData()
|
||||
|
||||
// Tron's compact signature format is r(32) + s(32) + v(1), v = 27/28 - byte-for-byte identical to what
|
||||
// web3j's Sign.SignatureData already produces for Ethereum signing (see class doc for verification notes).
|
||||
val signatureBytes = signature.r + signature.s + signature.v
|
||||
val signatureHex = signatureBytes.toHexString(withPrefix = false)
|
||||
|
||||
return tronGridApi.broadcastTransaction(baseUrl, unsigned, signatureHex)
|
||||
}
|
||||
|
||||
private suspend fun feeLimitFor(
|
||||
presetFee: Fee?,
|
||||
baseUrl: String,
|
||||
ownerHex: String,
|
||||
recipient: AccountId,
|
||||
contractHex: String,
|
||||
amountSun: BigInteger
|
||||
): BigInteger {
|
||||
val estimatedFee = presetFee?.castOrNull<TronFee>()?.amount
|
||||
?: estimateTrc20FeeFromContractHex(baseUrl, ownerHex, recipient, contractHex, amountSun)
|
||||
|
||||
return (estimatedFee * BigInteger.valueOf(3)).coerceIn(MIN_FEE_LIMIT_SUN, MAX_FEE_LIMIT_SUN)
|
||||
}
|
||||
|
||||
private suspend fun estimateNativeFee(baseUrl: String, ownerHex: String, recipient: AccountId, amountSun: BigInteger): BigInteger {
|
||||
// TronGrid's createtransaction rejects amount=0 outright with a ContractValidateException (confirmed
|
||||
// live) - the send screen's fee loader calls calculateFee reactively as the user types, including before
|
||||
// any amount has been entered. Substitute a minimal placeholder purely for this dry-run construction call;
|
||||
// it does not affect the real amount used when the transfer is actually built in transact().
|
||||
val dryRunAmountSun = amountSun.takeIf { it > BigInteger.ZERO } ?: BigInteger.ONE
|
||||
val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), dryRunAmountSun)
|
||||
val txSizeBytes = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }.length / 2
|
||||
|
||||
val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE)
|
||||
val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN)
|
||||
|
||||
val bandwidthShortfall = shortfall(txSizeBytes.toLong(), resource.availableBandwidth())
|
||||
|
||||
return bandwidthShortfall.toBigInteger() * bandwidthPrice
|
||||
}
|
||||
|
||||
private suspend fun estimateTrc20FeeFromContractHex(
|
||||
baseUrl: String,
|
||||
ownerHex: String,
|
||||
recipient: AccountId,
|
||||
contractHex: String,
|
||||
amountSun: BigInteger
|
||||
): BigInteger {
|
||||
val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, amountSun)
|
||||
|
||||
val dryRun = runCatching {
|
||||
tronGridApi.triggerConstantContract(baseUrl, ownerHex, contractHex, Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, parameterHex)
|
||||
}.getOrNull()
|
||||
|
||||
// A dry-run revert (e.g. the sender doesn't yet hold the token) still reports the energy spent up to the
|
||||
// revert point, which remains a meaningful (if slightly different) estimate - it is used as-is rather
|
||||
// than special-cased, only a wholly-failed HTTP call falls back to the conservative constant.
|
||||
val energyUsed = dryRun?.energyUsed ?: FALLBACK_TRC20_ENERGY_UNITS
|
||||
val txSizeBytes = dryRun?.transaction?.rawDataHex?.let { it.length / 2L } ?: FALLBACK_TRC20_TX_SIZE_BYTES
|
||||
|
||||
val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE)
|
||||
val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN)
|
||||
val energyPrice = chainParameterOrDefault(baseUrl, ENERGY_FEE_PARAM_KEY, FALLBACK_ENERGY_FEE_SUN)
|
||||
|
||||
val bandwidthShortfall = shortfall(txSizeBytes, resource.availableBandwidth())
|
||||
val energyShortfall = shortfall(energyUsed, resource.availableEnergy())
|
||||
|
||||
return bandwidthShortfall.toBigInteger() * bandwidthPrice + energyShortfall.toBigInteger() * energyPrice
|
||||
}
|
||||
|
||||
private suspend fun chainParameterOrDefault(baseUrl: String, key: String, default: BigInteger): BigInteger {
|
||||
val params = runCatching { tronGridApi.getChainParameters(baseUrl) }.getOrNull()
|
||||
|
||||
return params?.get(key)?.toBigInteger() ?: default
|
||||
}
|
||||
|
||||
private fun shortfall(needed: Long, available: Long): Long = (needed - available.coerceAtLeast(0)).coerceAtLeast(0)
|
||||
|
||||
private fun TronAccountResourceResponse.availableBandwidth(): Long =
|
||||
(freeNetLimit - freeNetUsed).coerceAtLeast(0) + (netLimit - netUsed).coerceAtLeast(0)
|
||||
|
||||
private fun TronAccountResourceResponse.availableEnergy(): Long =
|
||||
(energyLimit - energyUsed).coerceAtLeast(0)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
|
||||
|
||||
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Minimal, hand-written Solidity ABI encoding for the two calls this client makes to a TRC-20 contract:
|
||||
* `transfer(address,uint256)` (sending) and `balanceOf(address)` (reading a balance - `Trc20AssetBalance` can't
|
||||
* use TronGrid's `/v1/accounts` REST endpoint for this: a TRC-20 balance lives in the token contract's own
|
||||
* storage, not the holder's Account object, so an address that has only ever received TRC-20 tokens has no
|
||||
* Account object at all and `/v1/accounts` silently returns empty for it regardless of its real balance).
|
||||
*
|
||||
* Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad
|
||||
* each to 32 bytes and concatenate" - no dynamic-type/offset table is needed. The 4-byte function selector is
|
||||
* intentionally NOT computed client-side: TronGrid accepts the human-readable `function_selector` string
|
||||
* directly and hashes it server-side (confirmed live against Shasta testnet - a call with
|
||||
* `function_selector: "transfer(address,uint256)"` and no client-computed selector correctly resolved to the
|
||||
* standard `a9059cbb` selector in the resulting `raw_data`), which avoids needing a keccak256 implementation here.
|
||||
*/
|
||||
object Trc20TransferAbi {
|
||||
|
||||
const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)"
|
||||
const val BALANCE_OF_FUNCTION_SELECTOR = "balanceOf(address)"
|
||||
|
||||
/**
|
||||
* @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address -
|
||||
* ABI-encoded Solidity `address` parameters use the bare 20-byte form, confirmed live).
|
||||
*/
|
||||
fun encodeTransferParameters(recipient: AccountId, amountSun: BigInteger): String {
|
||||
require(recipient.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${recipient.size}" }
|
||||
require(amountSun.signum() >= 0) { "Amount must not be negative, got $amountSun" }
|
||||
|
||||
val addressParam = recipient.toHexString(withPrefix = false).padStart(64, '0')
|
||||
val amountParam = amountSun.toString(16).padStart(64, '0')
|
||||
|
||||
return addressParam + amountParam
|
||||
}
|
||||
|
||||
/**
|
||||
* @param holder raw 20-byte Ethereum/Tron-style account id - same encoding as [encodeTransferParameters]'s
|
||||
* `recipient`.
|
||||
*/
|
||||
fun encodeBalanceOfParameters(holder: AccountId): String {
|
||||
require(holder.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${holder.size}" }
|
||||
|
||||
return holder.toHexString(withPrefix = false).padStart(64, '0')
|
||||
}
|
||||
}
|
||||
+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>
|
||||
}
|
||||
+2
@@ -75,6 +75,8 @@ interface WalletFeatureDependencies {
|
||||
|
||||
val evmTransactionService: EvmTransactionService
|
||||
|
||||
val signerProvider: SignerProvider
|
||||
|
||||
val chainAssetDao: ChainAssetDao
|
||||
|
||||
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
|
||||
|
||||
+47
-9
@@ -4,16 +4,22 @@ import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.novafoundation.nova.common.data.network.NetworkApiCreator
|
||||
import io.novafoundation.nova.common.di.scope.FeatureScope
|
||||
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
|
||||
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
||||
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
|
||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource
|
||||
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.trc20.Trc20AssetBalance
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.TronNativeAssetBalance
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.UnsupportedAssetTransfers
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.trc20.Trc20AssetTransfers
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.tronNative.TronNativeAssetTransfers
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.RealTronTransactionService
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@@ -23,11 +29,17 @@ annotation class TronNativeAssets
|
||||
annotation class Trc20Assets
|
||||
|
||||
/**
|
||||
* Tron/TRC-20 support - Phase 1, read-only.
|
||||
* Tron/TRC-20 support.
|
||||
*
|
||||
* Only `balance` is implemented for real; `transfers`/`history` reuse the same `Unsupported*` stubs the rest of
|
||||
* the app uses for asset types with no send/history support yet (see `UnsupportedAssetsModule`). This is
|
||||
* intentional: no transaction-building/signing code exists for Tron yet - that is future, separate work.
|
||||
* Phase 1 (read-only): `balance`.
|
||||
* Phase 2 (send/transfer, this module): `transfers`, via [TronTransactionService] - construction/broadcast
|
||||
* through TronGrid's own REST endpoints, signing through the app's existing Ethereum-style ECDSA signer (see
|
||||
* `RealTronTransactionService` for the full verification notes). No Energy/Bandwidth staking or rental: only
|
||||
* Tron's default protocol behavior (auto-burning TRX when free resources are insufficient) is estimated and
|
||||
* enforced.
|
||||
*
|
||||
* `history` remains unsupported (out of scope for this phase, same as the rest of the app's `Unsupported*`
|
||||
* stubs used for asset types without history support).
|
||||
*/
|
||||
@Module
|
||||
class TronAssetsModule {
|
||||
@@ -42,6 +54,18 @@ class TronAssetsModule {
|
||||
@FeatureScope
|
||||
fun provideTronGridApi(retrofitTronGridApi: RetrofitTronGridApi): TronGridApi = RealTronGridApi(retrofitTronGridApi)
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideTronTransactionService(
|
||||
accountRepository: AccountRepository,
|
||||
signerProvider: SignerProvider,
|
||||
tronGridApi: TronGridApi,
|
||||
): TronTransactionService = RealTronTransactionService(
|
||||
accountRepository = accountRepository,
|
||||
signerProvider = signerProvider,
|
||||
tronGridApi = tronGridApi
|
||||
)
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi)
|
||||
@@ -50,15 +74,29 @@ class TronAssetsModule {
|
||||
@FeatureScope
|
||||
fun provideTrc20Balance(assetCache: AssetCache, tronGridApi: TronGridApi) = Trc20AssetBalance(assetCache, tronGridApi)
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideTronNativeAssetTransfers(
|
||||
tronTransactionService: TronTransactionService,
|
||||
assetSourceRegistry: AssetSourceRegistry,
|
||||
) = TronNativeAssetTransfers(tronTransactionService, assetSourceRegistry)
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideTrc20AssetTransfers(
|
||||
tronTransactionService: TronTransactionService,
|
||||
assetSourceRegistry: AssetSourceRegistry,
|
||||
) = Trc20AssetTransfers(tronTransactionService, assetSourceRegistry)
|
||||
|
||||
@Provides
|
||||
@TronNativeAssets
|
||||
@FeatureScope
|
||||
fun provideTronNativeAssetSource(
|
||||
tronNativeAssetBalance: TronNativeAssetBalance,
|
||||
unsupportedAssetTransfers: UnsupportedAssetTransfers,
|
||||
tronNativeAssetTransfers: TronNativeAssetTransfers,
|
||||
unsupportedAssetHistory: UnsupportedAssetHistory,
|
||||
): AssetSource = StaticAssetSource(
|
||||
transfers = unsupportedAssetTransfers,
|
||||
transfers = tronNativeAssetTransfers,
|
||||
balance = tronNativeAssetBalance,
|
||||
history = unsupportedAssetHistory
|
||||
)
|
||||
@@ -68,10 +106,10 @@ class TronAssetsModule {
|
||||
@FeatureScope
|
||||
fun provideTrc20AssetSource(
|
||||
trc20AssetBalance: Trc20AssetBalance,
|
||||
unsupportedAssetTransfers: UnsupportedAssetTransfers,
|
||||
trc20AssetTransfers: Trc20AssetTransfers,
|
||||
unsupportedAssetHistory: UnsupportedAssetHistory,
|
||||
): AssetSource = StaticAssetSource(
|
||||
transfers = unsupportedAssetTransfers,
|
||||
transfers = trc20AssetTransfers,
|
||||
balance = trc20AssetBalance,
|
||||
history = unsupportedAssetHistory
|
||||
)
|
||||
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
import io.novafoundation.nova.common.utils.Precision
|
||||
import io.novafoundation.nova.common.utils.TokenSymbol
|
||||
import io.novafoundation.nova.common.utils.sha256
|
||||
import io.novafoundation.nova.common.utils.toTronHexAddress
|
||||
import io.novafoundation.nova.common.utils.tronAddressToAccountId
|
||||
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
|
||||
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
|
||||
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
|
||||
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
||||
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
|
||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
|
||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
|
||||
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
|
||||
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.ArgumentMatcher
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.junit.MockitoJUnitRunner
|
||||
import java.math.BigInteger
|
||||
|
||||
// eq()/any()/argThat() genuinely return null at runtime - that's how Mockito's matcher-stack recording works,
|
||||
// regardless of whether they're called via test_shared's Kotlin wrappers or the raw org.mockito.Mockito statics
|
||||
// directly (confirmed: switching to the raw statics did not avoid the "eq(...) must not be null" crash once that
|
||||
// null flows into a Kotlin non-null-typed parameter somewhere downstream of the call site). Instead of returning
|
||||
// the real (null) value, these local wrappers fall back to a definitely-non-null stand-in - the real value itself
|
||||
// for eq() (harmless: the matcher was already recorded on Mockito's thread-local stack by the time this returns,
|
||||
// so the fallback value is never actually used for matching) and an unchecked-cast dummy for any()/argThat()
|
||||
// (mirrors mockito-kotlin's own internal implementation of the same helpers).
|
||||
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> any(): T {
|
||||
Mockito.any<T>()
|
||||
return null as T
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> argThat(matcher: (T) -> Boolean): T {
|
||||
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
|
||||
return null as T
|
||||
}
|
||||
|
||||
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
|
||||
|
||||
/**
|
||||
* Covers the native-TRX branch of [RealTronTransactionService.transact] - the sign/broadcast pipeline this class'
|
||||
* own doc comment describes (sha256(raw_data) signed via the existing Ethereum-style ECDSA-raw-hash primitive,
|
||||
* assembled as a 65-byte r+s+v signature) had no test at all before this: [Trc20TransferAbiTest] only covers the
|
||||
* TRC-20 ABI-encoding side, and [TronDerivationTest]/[io.novafoundation.nova.common.utils.TronAddressTest] only
|
||||
* cover address derivation/formatting, not transaction construction or signing.
|
||||
*
|
||||
* The owner address used throughout is the same one [TronDerivationTest] cross-validated against the standard
|
||||
* BIP39 test mnemonic ("abandon x11 about" at the coin-195 path) and against live TronGrid data - reusing it here
|
||||
* keeps every Tron test in this codebase anchored to a single, independently-verified real-world address instead
|
||||
* of an arbitrary one. The recipient and raw_data_hex/txID pair are self-consistent fixtures created for this
|
||||
* test only (unlike [Trc20TransferAbiTest]'s ABI vector, this raw_data_hex was not captured live against
|
||||
* TronGrid - constructing a real `TransferContract` protobuf is out of scope here, since this class deliberately
|
||||
* never encodes one itself, see its class doc) - what matters for these tests is that this service correctly
|
||||
* hashes/signs/forwards whatever raw_data TronGrid returns, which a self-consistent fixture exercises just as
|
||||
* well as a live-captured one.
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner::class)
|
||||
class RealTronTransactionServiceTest {
|
||||
|
||||
@Mock
|
||||
lateinit var accountRepository: AccountRepository
|
||||
|
||||
@Mock
|
||||
lateinit var signerProvider: SignerProvider
|
||||
|
||||
@Mock
|
||||
lateinit var tronGridApi: TronGridApi
|
||||
|
||||
@Mock
|
||||
lateinit var metaAccount: MetaAccount
|
||||
|
||||
@Mock
|
||||
lateinit var signer: NovaSigner
|
||||
|
||||
private lateinit var subject: RealTronTransactionService
|
||||
|
||||
private val ownerAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId()
|
||||
private val ownerHex = ownerAccountId.toTronHexAddress()
|
||||
|
||||
private val recipientAccountId = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex()
|
||||
private val recipientHex = recipientAccountId.toTronHexAddress()
|
||||
|
||||
private val amountSun = BigInteger.valueOf(1_000_000)
|
||||
|
||||
private val baseUrl = "https://api.trongrid.io"
|
||||
private val chain = tronChain(baseUrl)
|
||||
|
||||
// Self-consistent fixture: rawDataHex ++ its own sha256 as txID - see class doc.
|
||||
private val rawDataHex = "0a027a1e2208d1e2b3f4a5b6c7d840e8c896e8b7325a67080112630a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412320a1541dfd8703a5c753e17ed52a96a29cea9d425538dfe1215415d10da10f5c60a8e2d5e3c0a70e1e7f3c1b2a3e41880ade20470a08fc9c9e8b732"
|
||||
private val expectedTxId = rawDataHex.fromHex().sha256().toHexString(withPrefix = false)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
subject = RealTronTransactionService(accountRepository, signerProvider, tronGridApi)
|
||||
|
||||
whenever(metaAccount.accountIdIn(eq(chain))).thenReturn(ownerAccountId)
|
||||
whenever(signerProvider.rootSignerFor(eq(metaAccount))).thenReturn(signer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transact with Native intent should build, sign with sha256(raw_data), and broadcast a 65-byte r+s+v signature`(): Unit = runBlocking {
|
||||
val unsigned = TronUnsignedTransactionResponse(
|
||||
visible = false,
|
||||
txID = expectedTxId,
|
||||
rawData = JsonObject(),
|
||||
rawDataHex = rawDataHex
|
||||
)
|
||||
|
||||
val r = ByteArray(32) { (it + 1).toByte() }
|
||||
val s = ByteArray(32) { (it + 33).toByte() }
|
||||
val v = byteArrayOf(27)
|
||||
val fakeSignedRaw = SignedRaw(
|
||||
SignerPayloadRaw(message = expectedTxId.fromHex(), accountId = ownerAccountId, skipMessageHashing = true),
|
||||
SignatureWrapper.Ecdsa(v = v, r = r, s = s)
|
||||
)
|
||||
|
||||
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
|
||||
whenever(signer.signRaw(any())).thenReturn(fakeSignedRaw)
|
||||
whenever(tronGridApi.broadcastTransaction(eq(baseUrl), eq(unsigned), any())).thenReturn("some-broadcast-tx-hash")
|
||||
|
||||
val result = subject.transact(
|
||||
chain = chain,
|
||||
origin = TransactionOrigin.Wallet(metaAccount),
|
||||
recipient = recipientAccountId,
|
||||
presetFee = null,
|
||||
intent = TronTransactionIntent.Native(amountSun)
|
||||
)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("some-broadcast-tx-hash", result.getOrThrow().hash)
|
||||
|
||||
// The message actually handed to the signer must be sha256(raw_data), not raw_data or txID itself - a
|
||||
// regression here would silently produce a signature over the wrong bytes. ByteArray has reference
|
||||
// equality in Kotlin, so this must compare contents (contentEquals), not rely on SignerPayloadRaw.equals().
|
||||
val expectedMessage = rawDataHex.fromHex().sha256()
|
||||
verify(signer).signRaw(
|
||||
argThat<SignerPayloadRaw> { payload: SignerPayloadRaw ->
|
||||
payload.message.contentEquals(expectedMessage) &&
|
||||
payload.accountId.contentEquals(ownerAccountId) &&
|
||||
payload.skipMessageHashing
|
||||
}
|
||||
)
|
||||
|
||||
// Tron expects a flat 65-byte r(32)+s(32)+v(1) hex signature - assembled by hand in production code, not
|
||||
// by any library, so this is the one place that byte order/length could silently regress.
|
||||
val expectedSignatureHex = (r + s + v).toHexString(withPrefix = false)
|
||||
verify(tronGridApi).broadcastTransaction(baseUrl, unsigned, expectedSignatureHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transact should refuse to sign when TronGrid's txID does not match sha256(raw_data)`(): Unit = runBlocking {
|
||||
val tamperedTxId = "0".repeat(64) // deliberately wrong - does not match sha256(rawDataHex)
|
||||
val unsigned = TronUnsignedTransactionResponse(
|
||||
visible = false,
|
||||
txID = tamperedTxId,
|
||||
rawData = JsonObject(),
|
||||
rawDataHex = rawDataHex
|
||||
)
|
||||
|
||||
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
|
||||
|
||||
val result = subject.transact(
|
||||
chain = chain,
|
||||
origin = TransactionOrigin.Wallet(metaAccount),
|
||||
recipient = recipientAccountId,
|
||||
presetFee = null,
|
||||
intent = TronTransactionIntent.Native(amountSun)
|
||||
)
|
||||
|
||||
assertTrue("expected a failed Result when txID doesn't match sha256(raw_data)", result.isFailure)
|
||||
|
||||
// Signing (and therefore broadcasting) must never be attempted once the txID/raw_data mismatch is
|
||||
// detected - this is the guard that stops a tampered/malicious response from getting silently signed.
|
||||
verify(signer, never()).signRaw(any())
|
||||
verify(tronGridApi, never()).broadcastTransaction(any(), any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculateFee for Native intent should charge bandwidth shortfall at the fallback price when TronGrid's own resource_endpoints are unavailable`(): Unit = runBlocking {
|
||||
val unsigned = TronUnsignedTransactionResponse(
|
||||
visible = false,
|
||||
txID = expectedTxId,
|
||||
rawData = JsonObject(),
|
||||
rawDataHex = rawDataHex
|
||||
)
|
||||
val txSizeBytes = rawDataHex.length / 2
|
||||
|
||||
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
|
||||
whenever(tronGridApi.getAccountResource(eq(baseUrl), eq(ownerHex))).thenReturn(TronAccountResourceResponse())
|
||||
whenever(tronGridApi.getChainParameters(eq(baseUrl))).thenReturn(emptyMap<String, Long>())
|
||||
|
||||
val fee = subject.calculateFee(
|
||||
chain = chain,
|
||||
origin = TransactionOrigin.Wallet(metaAccount),
|
||||
recipient = recipientAccountId,
|
||||
intent = TronTransactionIntent.Native(amountSun)
|
||||
)
|
||||
|
||||
// Zero available bandwidth (empty TronAccountResourceResponse) -> the whole tx size is billed, at the
|
||||
// fallback bandwidth price (1000 sun/byte) since getChainParameters returned no getTransactionFee entry.
|
||||
val expectedFeeSun = BigInteger.valueOf(txSizeBytes.toLong()) * BigInteger.valueOf(1000)
|
||||
assertEquals(expectedFeeSun, fee.amount)
|
||||
}
|
||||
|
||||
private fun tronChain(baseUrl: String): Chain {
|
||||
val trxAsset = Chain.Asset(
|
||||
icon = null,
|
||||
id = 0,
|
||||
priceId = "tron",
|
||||
chainId = "tron:mainnet",
|
||||
symbol = TokenSymbol("TRX"),
|
||||
precision = Precision(6),
|
||||
buyProviders = emptyMap(),
|
||||
sellProviders = emptyMap(),
|
||||
staking = emptyList(),
|
||||
type = Chain.Asset.Type.TronNative,
|
||||
source = Chain.Asset.Source.DEFAULT,
|
||||
name = "Tron",
|
||||
enabled = true
|
||||
)
|
||||
|
||||
return Chain(
|
||||
id = "tron:mainnet",
|
||||
name = "Tron",
|
||||
assets = listOf(trxAsset),
|
||||
nodes = Chain.Nodes(
|
||||
autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN,
|
||||
wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance,
|
||||
nodes = listOf(Chain.Node(chainId = "tron:mainnet", unformattedUrl = baseUrl, name = "TronGrid", orderId = 0, isCustom = false))
|
||||
),
|
||||
explorers = emptyList(),
|
||||
externalApis = emptyList(),
|
||||
icon = null,
|
||||
addressPrefix = 0,
|
||||
legacyAddressPrefix = null,
|
||||
types = null,
|
||||
isEthereumBased = false,
|
||||
isTronBased = true,
|
||||
isTestNet = false,
|
||||
source = Chain.Source.DEFAULT,
|
||||
hasSubstrateRuntime = false,
|
||||
pushSupport = false,
|
||||
hasCrowdloans = false,
|
||||
supportProxy = false,
|
||||
governance = emptyList(),
|
||||
swap = emptyList(),
|
||||
customFee = emptyList(),
|
||||
multisigSupport = false,
|
||||
connectionState = Chain.ConnectionState.FULL_SYNC,
|
||||
parentId = null,
|
||||
additional = null
|
||||
)
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,12 @@ android {
|
||||
buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/chains.json\""
|
||||
buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/details\""
|
||||
|
||||
buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\""
|
||||
buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/chains_for_testBalance.json\""
|
||||
buildConfigField "String", "TEST_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/pezkuwi_assets_for_testBalance.json\""
|
||||
|
||||
buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY")
|
||||
buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY")
|
||||
buildConfigField "String", "TRONGRID_API_KEY", readStringSecret("TRONGRID_API_KEY")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -39,6 +39,7 @@ import io.novasama.substrate_sdk_android.wsrpc.SocketService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.web3j.protocol.http.HttpService
|
||||
import javax.inject.Provider
|
||||
|
||||
@@ -162,13 +163,17 @@ class ChainRegistryModule {
|
||||
bulkRetriever: BulkRetriever,
|
||||
connectionSecrets: ConnectionSecrets,
|
||||
web3ApiFactory: Web3ApiFactory,
|
||||
httpClient: OkHttpClient,
|
||||
) = NodeHealthStateTesterFactory(
|
||||
socketProvider,
|
||||
connectionSecrets,
|
||||
bulkRetriever,
|
||||
web3ApiFactory,
|
||||
httpClient
|
||||
// A short-lived, minimally-configured client is enough for a health-check ping - unlike Web3ApiFactory's
|
||||
// client, this never needs to survive/reuse connections across a long-lived RPC session.
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -10,14 +10,11 @@ import io.novafoundation.nova.core_db.dao.ChainAssetDao
|
||||
import io.novafoundation.nova.core_db.dao.ChainDao
|
||||
import io.novafoundation.nova.core_db.dao.StorageDao
|
||||
import io.novasama.substrate_sdk_android.wsrpc.SocketService
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
interface RuntimeDependencies {
|
||||
|
||||
fun networkApiCreator(): NetworkApiCreator
|
||||
|
||||
fun okHttpClient(): OkHttpClient
|
||||
|
||||
fun socketServiceCreator(): SocketService
|
||||
|
||||
fun gson(): Gson
|
||||
|
||||
@@ -19,6 +19,7 @@ import io.novafoundation.nova.common.utils.formatNamed
|
||||
import io.novafoundation.nova.common.utils.isValidBitcoinAddress
|
||||
import io.novafoundation.nova.common.utils.removeHexPrefix
|
||||
import io.novafoundation.nova.common.utils.emptyTronAccountId
|
||||
import io.novafoundation.nova.common.utils.isValidTronAddress
|
||||
import io.novafoundation.nova.common.utils.substrateAccountId
|
||||
import io.novafoundation.nova.common.utils.toBitcoinAddress
|
||||
import io.novafoundation.nova.common.utils.toTronAddress
|
||||
@@ -372,7 +373,13 @@ fun Chain.isValidAddress(address: String): Boolean {
|
||||
return runCatching {
|
||||
when {
|
||||
isBitcoinBased -> address.isValidBitcoinAddress()
|
||||
|
||||
// Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two
|
||||
// branches below would ever accept them, so this needs its own dedicated check.
|
||||
isTronBased -> address.isValidTronAddress()
|
||||
|
||||
isEthereumBased -> address.asEthereumAddress().isValid()
|
||||
|
||||
else -> {
|
||||
address.toAccountId() // verify supplied address can be converted to account id
|
||||
|
||||
@@ -492,6 +499,7 @@ object ChainGeneses {
|
||||
object ChainIds {
|
||||
|
||||
const val ETHEREUM = "$EIP_155_PREFIX:1"
|
||||
const val TRON = "tron:0x2b6653dc"
|
||||
|
||||
const val MOONBEAM = ChainGeneses.MOONBEAM
|
||||
const val MOONRIVER = ChainGeneses.MOONRIVER
|
||||
@@ -503,6 +511,33 @@ val Chain.Companion.Geneses
|
||||
val Chain.Companion.Ids
|
||||
get() = ChainIds
|
||||
|
||||
/**
|
||||
* A short, user-facing token-standard label for chains where disambiguating "which token standard is this"
|
||||
* is actually useful (multiple ecosystems all issue their own USDT/USDC etc., so a bare chain name isn't
|
||||
* always enough context). Deliberately NOT derived from [Chain.Asset.Type] (e.g. every Statemine-type chain
|
||||
* would get the same label) - this is chain-specific by design, matching exactly which labels are
|
||||
* recognizable/expected by users (PEZ-20, ERC-20, TRC-20), not a mechanical one-label-per-asset-type mapping.
|
||||
*/
|
||||
val Chain.assetStandardLabelOrNull: String?
|
||||
get() = when {
|
||||
genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> "PEZ-20"
|
||||
id == Chain.Ids.ETHEREUM -> "ERC-20"
|
||||
id == Chain.Ids.TRON -> "TRC-20"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain display name with its token-standard label appended where [assetStandardLabelOrNull] applies, e.g.
|
||||
* "Ethereum (ERC-20)". Shared across every screen that lists the same token symbol once per chain (the
|
||||
* Send/Receive/etc. network picker, the main balance list's per-token chain breakdown) - a bare chain name
|
||||
* alone doesn't convey which issuance this is when multiple ecosystems share the same symbol.
|
||||
*/
|
||||
fun Chain.displayNameWithAssetStandard(): String {
|
||||
val standardLabel = assetStandardLabelOrNull ?: return name
|
||||
|
||||
return "$name ($standardLabel)"
|
||||
}
|
||||
|
||||
fun Chain.Asset.requireStatemine(): Type.Statemine {
|
||||
require(type is Type.Statemine)
|
||||
|
||||
|
||||
@@ -3,19 +3,23 @@ package io.novafoundation.nova.runtime.ext
|
||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||
|
||||
val Chain.mainChainsFirstAscendingOrder
|
||||
get() = when (genesisHash) {
|
||||
get() = when {
|
||||
// Pezkuwi ecosystem first
|
||||
Chain.Geneses.PEZKUWI -> 0
|
||||
Chain.Geneses.PEZKUWI_ASSET_HUB -> 1
|
||||
Chain.Geneses.PEZKUWI_PEOPLE -> 2
|
||||
genesisHash == Chain.Geneses.PEZKUWI -> 0
|
||||
genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> 1
|
||||
genesisHash == Chain.Geneses.PEZKUWI_PEOPLE -> 2
|
||||
// Then Polkadot ecosystem
|
||||
Chain.Geneses.POLKADOT -> 3
|
||||
Chain.Geneses.POLKADOT_ASSET_HUB -> 4
|
||||
genesisHash == Chain.Geneses.POLKADOT -> 3
|
||||
genesisHash == Chain.Geneses.POLKADOT_ASSET_HUB -> 4
|
||||
// Then Kusama ecosystem
|
||||
Chain.Geneses.KUSAMA -> 5
|
||||
Chain.Geneses.KUSAMA_ASSET_HUB -> 6
|
||||
genesisHash == Chain.Geneses.KUSAMA -> 5
|
||||
genesisHash == Chain.Geneses.KUSAMA_ASSET_HUB -> 6
|
||||
// Then Ethereum, then Tron - not identified by genesisHash (that's substrate-only), so this can't
|
||||
// stay a `when (genesisHash)` subject match once these two are added
|
||||
id == Chain.Ids.ETHEREUM -> 7
|
||||
id == Chain.Ids.TRON -> 8
|
||||
// Everything else
|
||||
else -> 7
|
||||
else -> 9
|
||||
}
|
||||
|
||||
val Chain.testnetsLastAscendingOrder
|
||||
|
||||
@@ -10,7 +10,14 @@ val TokenSymbol.mainTokensFirstAscendingOrder
|
||||
"DOT" -> 3
|
||||
"KSM" -> 4
|
||||
"USDC" -> 5
|
||||
else -> 6
|
||||
"TRX" -> 6
|
||||
"BTC" -> 7
|
||||
"ETH" -> 8
|
||||
"BNB" -> 9
|
||||
"AVAX" -> 10
|
||||
"LINK" -> 11
|
||||
"TAO" -> 12
|
||||
else -> 13
|
||||
}
|
||||
|
||||
val TokenSymbol.alphabeticalOrder
|
||||
|
||||
@@ -7,7 +7,7 @@ import io.novafoundation.nova.common.utils.RuntimeContext
|
||||
import io.novafoundation.nova.common.utils.diffed
|
||||
import io.novafoundation.nova.common.utils.filterList
|
||||
import io.novafoundation.nova.common.utils.inBackground
|
||||
import io.novafoundation.nova.common.utils.mapList
|
||||
import io.novafoundation.nova.common.utils.mapListNotNull
|
||||
import io.novafoundation.nova.common.utils.mapNotNullToSet
|
||||
import io.novafoundation.nova.common.utils.provideContext
|
||||
import io.novafoundation.nova.common.utils.removeHexPrefix
|
||||
@@ -41,6 +41,7 @@ import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchro
|
||||
import io.novasama.substrate_sdk_android.wsrpc.SocketService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -67,14 +68,37 @@ class ChainRegistry(
|
||||
private val runtimeSyncService: RuntimeSyncService,
|
||||
private val web3ApiPool: Web3ApiPool,
|
||||
private val gson: Gson
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
// SupervisorJob, not the plain Job a bare CoroutineScope(Dispatchers.Default) would give: without it, an
|
||||
// uncaught exception in ANY coroutine sharing this scope (e.g. currentChains'/chainsById's shareIn, or any
|
||||
// launch{} below) cancels every sibling, including the other one - a single malformed/leftover chain row
|
||||
// would then permanently kill sync for every chain, not just the offending one.
|
||||
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
|
||||
val currentChains = chainDao.joinChainInfoFlow()
|
||||
.mapList { mapChainLocalToChain(it, gson) }
|
||||
// mapListNotNull, not mapList: mapChainLocalToChain() can throw on a single malformed row (e.g. a
|
||||
// gson.fromJson() failure on the chain's `additional` JSON blob) - since this whole step runs as ONE
|
||||
// transform over the ENTIRE chain list, one bad chain would previously throw out of this operator and
|
||||
// permanently kill this Eagerly-shared flow for every chain, not just the offending one. Skip and log
|
||||
// instead, matching the per-chain isolation already applied to registerChain/unregisterChain below.
|
||||
.mapListNotNull { chainLocal ->
|
||||
runCatching { mapChainLocalToChain(chainLocal, gson) }
|
||||
.onFailure { Log.e(LOG_TAG, "Failed to map chain ${chainLocal.chain.id} (${chainLocal.chain.name}) from local DB", it) }
|
||||
.getOrNull()
|
||||
}
|
||||
.diffed()
|
||||
.map { diff ->
|
||||
diff.removed.forEach { unregisterChain(it) }
|
||||
diff.newOrUpdated.forEach { chain -> registerChain(chain) }
|
||||
// Each chain's register/unregister is isolated: one malformed/leftover row (e.g. a chain persisted
|
||||
// as disabled from an earlier session) must not throw out of this operator and kill this flow for
|
||||
// every other chain - shareIn(..., Eagerly) never restarts once its upstream completes/throws, so
|
||||
// any single unhandled exception here would silently and permanently break sync for the whole app.
|
||||
diff.removed.forEach { chain ->
|
||||
runCatching { unregisterChain(chain) }
|
||||
.onFailure { Log.e(LOG_TAG, "Failed to unregister chain ${chain.name} (${chain.id})", it) }
|
||||
}
|
||||
diff.newOrUpdated.forEach { chain ->
|
||||
runCatching { registerChain(chain) }
|
||||
.onFailure { Log.e(LOG_TAG, "Failed to register chain ${chain.name} (${chain.id})", it) }
|
||||
}
|
||||
|
||||
diff.all
|
||||
}
|
||||
|
||||
+14
@@ -1,7 +1,9 @@
|
||||
package io.novafoundation.nova.runtime.multiNetwork.asset
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import io.novafoundation.nova.common.utils.CollectionDiffer
|
||||
import io.novafoundation.nova.common.utils.LOG_TAG
|
||||
import io.novafoundation.nova.common.utils.retryUntilDone
|
||||
import io.novafoundation.nova.core_db.dao.ChainAssetDao
|
||||
import io.novafoundation.nova.core_db.dao.ChainDao
|
||||
@@ -40,6 +42,18 @@ class EvmAssetsSyncService(
|
||||
new.copy(enabled = old?.enabled ?: ENABLED_DEFAULT_BOOL)
|
||||
}
|
||||
|
||||
// Same defensive guard as ChainSyncService: a transient upstream issue can make the fetch return
|
||||
// successfully with a suspiciously small/empty list. Diffing that against a populated local DB would
|
||||
// delete most or all of the user's ERC20 tokens (e.g. USDT-ERC20) - skip instead of wiping good data.
|
||||
if (oldAssets.isNotEmpty() && newAssets.size < oldAssets.size / 2) {
|
||||
Log.e(
|
||||
LOG_TAG,
|
||||
"Refusing to apply EVM asset sync: remote returned ${newAssets.size} assets vs ${oldAssets.size} currently stored " +
|
||||
"(would remove more than half). Likely a transient fetch issue - skipping this sync cycle."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val diff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false)
|
||||
chainAssetDao.updateAssets(diff)
|
||||
}
|
||||
|
||||
+42
-9
@@ -1,7 +1,9 @@
|
||||
package io.novafoundation.nova.runtime.multiNetwork.chain
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import io.novafoundation.nova.common.utils.CollectionDiffer
|
||||
import io.novafoundation.nova.common.utils.LOG_TAG
|
||||
import io.novafoundation.nova.common.utils.retryUntilDone
|
||||
import io.novafoundation.nova.core_db.dao.ChainDao
|
||||
import io.novafoundation.nova.core_db.dao.FullAssetIdLocal
|
||||
@@ -40,17 +42,48 @@ class ChainSyncService(
|
||||
|
||||
val remoteChains = retryUntilDone { chainFetcher.getChains() }
|
||||
|
||||
val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) }
|
||||
val newAssets = remoteChains.flatMap { chain ->
|
||||
chain.assets.map {
|
||||
val fullAssetId = FullAssetIdLocal(chain.chainId, it.assetId)
|
||||
val oldAsset = associatedOldAssets[fullAssetId]
|
||||
mapRemoteAssetToLocal(chain, it, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL)
|
||||
// A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make
|
||||
// chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of
|
||||
// throwing. Applying that as a diff against a populated local DB would delete most or all of the
|
||||
// user's chains/assets - not a sync failure, but active data loss, for something that self-heals on
|
||||
// the next successful sync if we just skip applying it. Only guard when we HAD data: an empty result
|
||||
// on a genuinely first-ever sync is normal and must proceed.
|
||||
if (oldChains.isNotEmpty() && remoteChains.size < oldChains.size / 2) {
|
||||
Log.e(
|
||||
LOG_TAG,
|
||||
"Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " +
|
||||
"(would remove more than half). Likely a transient fetch issue - skipping this sync cycle."
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// One malformed/incompatible chain (a new field the app's mapper doesn't understand yet, a bad
|
||||
// publish, etc.) must not take down sync for every other chain - a plain .map{} here means a single
|
||||
// throwing chain aborts before chainDao.applyDiff() is ever called, leaving a brand new install with
|
||||
// zero locally-cached chains forever (a completely empty tokens list), since nothing else in this
|
||||
// function ever gets a chance to run. Isolate failures per chain, and per asset within a chain that
|
||||
// otherwise mapped fine, instead.
|
||||
val remoteChainsWithLocal = remoteChains.mapNotNull { chainRemote ->
|
||||
runCatching { chainRemote to mapRemoteChainToLocal(chainRemote, oldChainsById[chainRemote.chainId], source = ChainLocal.Source.DEFAULT, gson) }
|
||||
.onFailure { Log.e(LOG_TAG, "Failed to map remote chain ${chainRemote.chainId} (${chainRemote.name}), skipping it for this sync cycle", it) }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
val newChains = remoteChainsWithLocal.map { (_, chainLocal) -> chainLocal }
|
||||
val newAssets = remoteChainsWithLocal.flatMap { (chain, _) ->
|
||||
chain.assets.mapNotNull { assetRemote ->
|
||||
runCatching {
|
||||
val fullAssetId = FullAssetIdLocal(chain.chainId, assetRemote.assetId)
|
||||
val oldAsset = associatedOldAssets[fullAssetId]
|
||||
mapRemoteAssetToLocal(chain, assetRemote, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL)
|
||||
}.onFailure {
|
||||
Log.e(LOG_TAG, "Failed to map asset ${assetRemote.assetId} (${assetRemote.symbol}) on chain ${chain.chainId}, skipping it", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
val newNodes = remoteChains.flatMap(::mapRemoteNodesToLocal)
|
||||
val newExplorers = remoteChains.flatMap(::mapRemoteExplorersToLocal)
|
||||
val newExternalApis = remoteChains.flatMap(::mapExternalApisToLocal)
|
||||
val newNodes = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteNodesToLocal(chain) }
|
||||
val newExplorers = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteExplorersToLocal(chain) }
|
||||
val newExternalApis = remoteChainsWithLocal.flatMap { (chain, _) -> mapExternalApisToLocal(chain) }
|
||||
val newNodeSelectionPreferences = nodeSelectionPreferencesFor(newChains, oldNodeSelectionPreferences)
|
||||
|
||||
val chainsDiff = CollectionDiffer.findDiff(newChains, oldChains, forceUseNewItems = false)
|
||||
|
||||
+5
@@ -27,6 +27,11 @@ class NodeHealthStateTesterFactory(
|
||||
httpClient = httpClient
|
||||
)
|
||||
|
||||
chain.isTronBased -> TronNodeHealthStateTester(
|
||||
node = node,
|
||||
httpClient = httpClient
|
||||
)
|
||||
|
||||
chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester(
|
||||
chain = chain,
|
||||
socketService = socketServiceProvider.get(),
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState
|
||||
|
||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTime
|
||||
|
||||
/**
|
||||
* TronGrid speaks a plain REST API, not Ethereum JSON-RPC - reusing [EthereumNodeHealthStateTester] against it
|
||||
* (as this codebase used to, before Tron nodes were included in health checks at all) would send an
|
||||
* `eth_getBalance` call TronGrid doesn't understand, always reporting the node as unreachable regardless of its
|
||||
* actual health. `GET /wallet/getchainparameters` needs no account/address context and is cheap on TronGrid's
|
||||
* side, making it a good generic liveness ping - same endpoint this codebase already uses elsewhere
|
||||
* (`TronGridApi.getChainParameters`), just called directly here since `runtime` cannot depend on
|
||||
* `feature-wallet-impl` (wrong direction) to reuse that Retrofit interface.
|
||||
*/
|
||||
class TronNodeHealthStateTester(
|
||||
private val node: Chain.Node,
|
||||
private val httpClient: OkHttpClient,
|
||||
) : NodeHealthStateTester {
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override suspend fun testNodeHealthState(): Result<Long> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${node.unformattedUrl.trimEnd('/')}/wallet/getchainparameters")
|
||||
.build()
|
||||
|
||||
val duration = measureTime {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
check(response.isSuccessful) { "HTTP ${response.code}" }
|
||||
}
|
||||
}
|
||||
|
||||
duration.inWholeMilliseconds
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-22
@@ -14,7 +14,9 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlin.coroutines.coroutineContext
|
||||
@@ -24,35 +26,51 @@ abstract class ChainUpdaterGroupUpdateSystem(
|
||||
private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
|
||||
) : UpdateSystem {
|
||||
|
||||
// Callers (MultiChainUpdateSystem, SingleChainUpdateSystem) merge several chains' runUpdaters() results
|
||||
// into one flow. chainRegistry.getRuntime(chain.id) throws for a chain whose runtime isn't ready yet
|
||||
// (including a disabled chain, via DisabledChainException) - if that throw escapes this function
|
||||
// uncaught, it propagates through the merge and kills governance/staking/crowdloan sync for every OTHER
|
||||
// chain in the group too, not just the failing one. Wrapping the whole body in flow{} + catch isolates
|
||||
// that failure to this chain alone.
|
||||
protected suspend fun runUpdaters(chain: Chain, updaters: Collection<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 selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
|
||||
val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
|
||||
val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
|
||||
|
||||
val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) ->
|
||||
scope.invalidationFlow().flatMapLatest { scopeValue ->
|
||||
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
|
||||
val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) ->
|
||||
scope.invalidationFlow().flatMapLatest { scopeValue ->
|
||||
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
|
||||
|
||||
val updatersFlow = scopeUpdaters
|
||||
.filter { it.requiredModules.all(runtimeMetadata::hasModule) }
|
||||
.map { updater ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(updater as Updater<Any?>).listenForUpdates(subscriptionBuilder, scopeValue)
|
||||
.catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
val updatersFlow = scopeUpdaters
|
||||
.filter { it.requiredModules.all(runtimeMetadata::hasModule) }
|
||||
.map { updater ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(updater as Updater<Any?>).listenForUpdates(subscriptionBuilder, scopeValue)
|
||||
.catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
}
|
||||
|
||||
if (updatersFlow.isNotEmpty()) {
|
||||
subscriptionBuilder.subscribe(coroutineContext)
|
||||
|
||||
updatersFlow.merge()
|
||||
} else {
|
||||
emptyFlow()
|
||||
}
|
||||
|
||||
if (updatersFlow.isNotEmpty()) {
|
||||
subscriptionBuilder.subscribe(coroutineContext)
|
||||
|
||||
updatersFlow.merge()
|
||||
} else {
|
||||
emptyFlow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopeFlows.merge()
|
||||
emitAll(scopeFlows.merge())
|
||||
}.catch { error ->
|
||||
// Explicitly qualified: unqualified LOG_TAG here would resolve against the nearest implicit
|
||||
// receiver, which is this catch lambda's FlowCollector, not this class - Any.LOG_TAG applies to
|
||||
// any receiver, so it would silently compile but log the wrong (unhelpful) tag.
|
||||
val outerLogTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
|
||||
val outerSelfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
|
||||
|
||||
Log.e(outerLogTag, "Failed to start updaters in $outerSelfName for ${chain.name}", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user