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:
2026-07-12 19:02:08 -07:00
62 changed files with 2851 additions and 265 deletions
+12
View File
@@ -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
@@ -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()
}
}
@@ -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)
@@ -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)
}
@@ -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>? {
@@ -0,0 +1,99 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.trc20
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionIntent
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService
import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novafoundation.nova.runtime.ext.requireTrc20
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import kotlinx.coroutines.CoroutineScope
/**
* TRC-20 token transfer (e.g. USDT-TRC20). The fee is always denominated in native TRX, never in the TRC-20
* token being sent - same pattern as an ERC-20 transfer's fee being paid in ETH, not the ERC-20 token (compare
* [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmErc20.EvmErc20AssetTransfers]).
* This falls out for free from [TronTransactionService.calculateFee] always returning a [io.novafoundation.nova.feature_account_api.data.model.TronFee]
* denominated in `chain.commissionAsset` (native TRX), combined with the fully-generic
* [sufficientTransferableBalanceToPayOriginFee] validation checking that commission asset's balance regardless
* of which asset is actually being transferred.
*/
class Trc20AssetTransfers(
private val tronTransactionService: TronTransactionService,
private val assetSourceRegistry: AssetSourceRegistry,
) : AssetTransfers {
override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem {
validAddress()
recipientIsNotSystemAccount()
positiveAmount()
sufficientBalanceInUsedAsset()
sufficientTransferableBalanceToPayOriginFee()
recipientCanAcceptTransfer(assetSourceRegistry)
checkForFeeChanges(assetSourceRegistry, coroutineScope)
}
override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee {
return tronTransactionService.calculateFee(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
intent = transfer.intoTrc20Intent()
)
}
override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<ExtrinsicSubmission> {
return tronTransactionService.transact(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
presetFee = transfer.fee.submissionFee,
intent = transfer.intoTrc20Intent()
)
}
override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<TransactionExecution> {
return tronTransactionService.transactAndAwaitExecution(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
presetFee = transfer.fee.submissionFee,
intent = transfer.intoTrc20Intent()
)
}
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
return null
}
private fun AssetTransfer.intoTrc20Intent(): TronTransactionIntent.Trc20Transfer {
val trc20 = originChainAsset.requireTrc20()
return TronTransactionIntent.Trc20Transfer(trc20.contractAddress, amountInPlanks)
}
}
@@ -0,0 +1,90 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.tronNative
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionIntent
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService
import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import kotlinx.coroutines.CoroutineScope
/**
* Native TRX transfer. No Energy/Bandwidth staking or rental is implemented (out of scope for this send-only
* phase) - Tron's default protocol behavior (automatically burning TRX when free Bandwidth is insufficient) is
* all that's needed; [TronTransactionService] estimates that burn and reports it as [Fee], and the generic
* [sufficientTransferableBalanceToPayOriginFee] validation (same one EVM native/ERC-20 transfers already reuse)
* blocks the send if the TRX balance can't cover it.
*/
class TronNativeAssetTransfers(
private val tronTransactionService: TronTransactionService,
private val assetSourceRegistry: AssetSourceRegistry,
) : AssetTransfers {
override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem {
validAddress()
recipientIsNotSystemAccount()
positiveAmount()
sufficientBalanceInUsedAsset()
sufficientTransferableBalanceToPayOriginFee()
recipientCanAcceptTransfer(assetSourceRegistry)
checkForFeeChanges(assetSourceRegistry, coroutineScope)
}
override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee {
return tronTransactionService.calculateFee(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
intent = TronTransactionIntent.Native(transfer.amountInPlanks)
)
}
override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<ExtrinsicSubmission> {
return tronTransactionService.transact(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
presetFee = transfer.fee.submissionFee,
intent = TronTransactionIntent.Native(transfer.amountInPlanks)
)
}
override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<TransactionExecution> {
return tronTransactionService.transactAndAwaitExecution(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipient = transfer.originChain.accountIdOrDefault(transfer.recipient),
presetFee = transfer.fee.submissionFee,
intent = TronTransactionIntent.Native(transfer.amountInPlanks)
)
}
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
return null
}
}
@@ -1,14 +1,56 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
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
}
@@ -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") }
}
}
@@ -0,0 +1,116 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.model
import com.google.gson.JsonObject
import com.google.gson.annotations.SerializedName
/**
* Request/response shapes for TronGrid's transaction-construction/broadcast endpoints (`/wallet/`).
*
* All requests are sent with `"visible": false`, i.e. addresses are hex-encoded (`41` prefix byte ++ 20-byte
* accountId, see `toTronHexAddress`) rather than Base58Check. Every shape below was confirmed against
* TronGrid's Shasta testnet (`https://api.shasta.trongrid.io`) with live HTTP calls - see the Phase 2
* implementation notes for the exact request/response pairs that were captured.
*/
class TronCreateTransactionRequest(
@SerializedName("owner_address") val ownerAddress: String,
@SerializedName("to_address") val toAddress: String,
val amount: Long,
val visible: Boolean = false,
)
class TronTriggerContractRequest(
@SerializedName("owner_address") val ownerAddress: String,
@SerializedName("contract_address") val contractAddress: String,
@SerializedName("function_selector") val functionSelector: String,
val parameter: String,
@SerializedName("fee_limit") val feeLimit: Long? = null,
@SerializedName("call_value") val callValue: Long = 0,
val visible: Boolean = false,
)
class TronAddressRequest(
val address: String,
val visible: Boolean = false,
)
/**
* Shape of the unsigned transaction returned by both `/wallet/createtransaction` (flat, at the top level) and
* `/wallet/triggersmartcontract`/`/wallet/triggerconstantcontract` (nested under a `transaction` key - see
* [TronTriggerContractResponse]).
*
* `rawData` is kept as an opaque [JsonObject] rather than being modeled field-by-field: its contents differ
* between contract types (`TransferContract` vs `TriggerSmartContract`) and it is never interpreted by this
* client - it is only ever echoed back verbatim into the broadcast request alongside the signature. The
* cryptographically-authoritative value is [rawDataHex] (`txID == sha256(rawDataHex bytes)`, confirmed live).
*
* `error` is populated (HTTP 200, not an HTTP error) when construction fails, e.g. an unactivated owner account
* trying to build a native TRX transfer returns `{"Error": "... no OwnerAccount."}`.
*/
class TronUnsignedTransactionResponse(
val visible: Boolean? = null,
val txID: String? = null,
@SerializedName("raw_data") val rawData: JsonObject? = null,
@SerializedName("raw_data_hex") val rawDataHex: String? = null,
@SerializedName("Error") val error: String? = null,
)
class TronContractCallResult(
val result: Boolean = false,
val code: String? = null,
val message: String? = null,
)
/**
* Response of both `/wallet/triggerconstantcontract` (read-only dry run, used for TRC20 fee/energy estimation)
* and `/wallet/triggersmartcontract` (real construction, used for the actual TRC20 transfer).
*/
class TronTriggerContractResponse(
val result: TronContractCallResult? = null,
@SerializedName("energy_used") val energyUsed: Long? = null,
@SerializedName("constant_result") val constantResult: List<String>? = null,
val transaction: TronUnsignedTransactionResponse? = null,
)
class TronBroadcastRequest(
val visible: Boolean,
val txID: String,
@SerializedName("raw_data") val rawData: JsonObject,
@SerializedName("raw_data_hex") val rawDataHex: String,
val signature: List<String>,
)
/**
* On success: `{"result": true, "txid": "..."}`.
* On failure: `{"code": "CONTRACT_VALIDATE_ERROR", "txid": "...", "message": "<hex-encoded ascii>"}` - confirmed
* live by broadcasting a validly-signed but unfunded-account transaction, e.g.
* `message` hex-decodes to `"Contract validate error : account [...] does not exist"`.
*/
class TronBroadcastResponse(
val result: Boolean? = null,
val txid: String? = null,
val code: String? = null,
val message: String? = null,
)
class TronChainParameter(
val key: String,
val value: Long = 0,
)
class TronChainParametersResponse(
@SerializedName("chainParameter") val chainParameter: List<TronChainParameter> = emptyList(),
)
/**
* Subset of `/wallet/getaccountresource` fields relevant to fee estimation. Fields are omitted by TronGrid
* (rather than sent as `0`) when their value is zero - confirmed live - hence all default to `0`.
*/
class TronAccountResourceResponse(
val freeNetLimit: Long = 0,
val freeNetUsed: Long = 0,
@SerializedName("NetLimit") val netLimit: Long = 0,
@SerializedName("NetUsed") val netUsed: Long = 0,
@SerializedName("EnergyLimit") val energyLimit: Long = 0,
@SerializedName("EnergyUsed") val energyUsed: Long = 0,
)
@@ -0,0 +1,269 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import io.novafoundation.nova.common.utils.castOrNull
import io.novafoundation.nova.common.utils.sha256
import io.novafoundation.nova.common.utils.toEcdsaSignatureData
import io.novafoundation.nova.common.utils.toTronHexAddress
import io.novafoundation.nova.common.utils.tronAddressToHexAddress
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.data.model.TronFee
import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
import io.novafoundation.nova.runtime.ext.commissionAsset
import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import java.math.BigInteger
private const val ENERGY_FEE_PARAM_KEY = "getEnergyFee"
private const val TRANSACTION_FEE_PARAM_KEY = "getTransactionFee"
// Fallbacks only used if a live `getchainparameters` call fails or is missing the expected key - both values are
// what was observed live on Shasta testnet at implementation time, which also match Tron's long-standing mainnet
// defaults; the primary path always fetches live values.
private val FALLBACK_ENERGY_FEE_SUN = BigInteger.valueOf(420)
private val FALLBACK_BANDWIDTH_FEE_SUN = BigInteger.valueOf(1000)
// Used only if a triggerconstantcontract dry run fails outright (e.g. transient network error) and returns no
// energy_used at all - a conservative (intentionally high) stand-in for a simple TRC-20 transfer, which in
// practice costs on the order of 15-30k energy. Mirrors EvmErc20AssetTransfers' ERC_20_UPPER_GAS_LIMIT fallback.
private val FALLBACK_TRC20_ENERGY_UNITS = 65_000L
private val FALLBACK_TRC20_TX_SIZE_BYTES = 350L
// fee_limit sent with triggersmartcontract: Tron only ever burns what a call actually uses (up to this cap), so
// setting this generously above our own estimate does not cost the user more - it only avoids an OUT_OF_ENERGY
// failure if our estimate undershoots. Bounded above as a sanity guard against a runaway estimate.
private val MIN_FEE_LIMIT_SUN = BigInteger.valueOf(15_000_000) // 15 TRX
private val MAX_FEE_LIMIT_SUN = BigInteger.valueOf(100_000_000) // 100 TRX
private val EMPTY_RESOURCE = TronAccountResourceResponse()
/**
* Builds, signs and broadcasts Tron transactions (native TRX and TRC-20) using TronGrid's own REST endpoints for
* construction/broadcast, and this app's existing ECDSA signing primitive for signing - no Tron protobuf
* (`Transaction`/`TransferContract`/`TriggerSmartContract`) encoding and no new crypto library were added.
*
* ## Construction
* - Native TRX: `POST /wallet/createtransaction` with `{owner_address, to_address, amount}` (all hex-encoded,
* `visible: false`). Requires the owner account to already be activated on-chain (confirmed live: an
* unactivated owner gets `{"Error": "... no OwnerAccount."}`) - in practice this is never hit here, since a
* user can only reach the send flow with a positive TRX balance to send from, which itself implies the account
* was already activated by an earlier incoming transfer.
* - TRC-20: `POST /wallet/triggersmartcontract` with the ABI-encoded `transfer(address,uint256)` call (see
* [Trc20TransferAbi]). Unlike `createtransaction`, this was confirmed live to work even for a
* never-activated owner account.
*
* ## Signing
* Tron's signature is `ECDSA_sign(privateKey, sha256(raw_data))` over secp256k1 - the same curve/primitive this
* app already uses for Ethereum. [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw]
* (backed by `substrate_sdk_android`'s `Signer.sign(MultiChainEncryption.Ethereum, message, keypair, skipHashing)`
* -> `web3j`'s `Sign.signMessage(hash, keyPair, needToHash = false)`) already supports signing a pre-computed
* hash directly via `SignerPayloadRaw.skipMessageHashing = true` - this is exactly the primitive Ethereum-style
* raw-hash signing needs, and it is reused as-is here. No new cryptographic code or library was added; only the
* hash fed into it differs from the EVM path (`sha256(raw_data)` here vs. an EIP-155 RLP-based digest there).
*
* `web3j`'s `Sign.signMessage` always left-pads `r`/`s` to exactly 32 bytes and encodes `v` as `27/28` (confirmed
* by reading `web3j`'s `Sign.java` source) - which is byte-for-byte the same `r(32) + s(32) + v(1)` = 65-byte
* compact signature format Tron expects (confirmed against `tronweb`'s own `ECKeySign` implementation, and
* independently confirmed live against Shasta testnet - see the Phase 2 implementation notes).
*
* ## Broadcast
* `POST /wallet/broadcasttransaction` with the full unsigned transaction object (not just `raw_data_hex`) plus
* `signature: [<65-byte hex signature>]`.
*/
class RealTronTransactionService(
private val accountRepository: AccountRepository,
private val signerProvider: SignerProvider,
private val tronGridApi: TronGridApi,
) : TronTransactionService {
override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee {
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
val baseUrl = chain.requireTronGridBaseUrl()
val ownerHex = ownerAccountId.toTronHexAddress()
val feeSun = when (intent) {
is TronTransactionIntent.Native -> estimateNativeFee(baseUrl, ownerHex, recipient, intent.amountSun)
is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex(
baseUrl,
ownerHex,
recipient,
intent.contractAddress.tronAddressToHexAddress(),
intent.amountSun
)
}
return TronFee(feeSun, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset)
}
override suspend fun transact(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
presetFee: Fee?,
intent: TronTransactionIntent
): Result<ExtrinsicSubmission> = runCatching {
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
val baseUrl = chain.requireTronGridBaseUrl()
val ownerHex = ownerAccountId.toTronHexAddress()
val recipientHex = recipient.toTronHexAddress()
val unsigned = when (intent) {
is TronTransactionIntent.Native -> tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipientHex, intent.amountSun)
is TronTransactionIntent.Trc20Transfer -> {
val contractHex = intent.contractAddress.tronAddressToHexAddress()
val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, intent.amountSun)
val feeLimit = feeLimitFor(presetFee, baseUrl, ownerHex, recipient, contractHex, intent.amountSun)
tronGridApi.triggerSmartContract(
baseUrl = baseUrl,
ownerHexAddress = ownerHex,
contractHexAddress = contractHex,
functionSelector = Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR,
parameterHex = parameterHex,
feeLimitSun = feeLimit
).transaction ?: error("TronGrid returned no transaction from triggersmartcontract")
}
}
val txHash = signAndBroadcast(baseUrl, unsigned, submittingMetaAccount, ownerAccountId)
ExtrinsicSubmission(
hash = txHash,
submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId),
callExecutionType = CallExecutionType.IMMEDIATE,
submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE)
)
}
override suspend fun transactAndAwaitExecution(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
presetFee: Fee?,
intent: TronTransactionIntent
): Result<TransactionExecution> {
// Tron transactions execute atomically with inclusion (no separate "prepare" step, same as EVM) - so
// successful broadcast is already a strong signal. We do not poll for block confirmation here since
// this method sits outside the primary send flow's critical path (`SendInteractor`/`RealSendUseCase`
// only ever call `transact`, not this) - see Phase 2 implementation notes for what remains unverified.
return transact(chain, origin, recipient, presetFee, intent).map { TransactionExecution.Tron(it.hash) }
}
private suspend fun signAndBroadcast(
baseUrl: String,
unsigned: TronUnsignedTransactionResponse,
metaAccount: MetaAccount,
ownerAccountId: AccountId
): String {
val rawDataHex = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }
val messageHash = rawDataHex.fromHex().sha256()
check(unsigned.txID == null || messageHash.toHexString(withPrefix = false) == unsigned.txID) {
"sha256(raw_data) does not match the txID TronGrid reported - refusing to sign a possibly-tampered transaction"
}
val signer = signerProvider.rootSignerFor(metaAccount)
val signedRaw = signer.signRaw(SignerPayloadRaw(message = messageHash, accountId = ownerAccountId, skipMessageHashing = true))
val signature = signedRaw.toEcdsaSignatureData()
// Tron's compact signature format is r(32) + s(32) + v(1), v = 27/28 - byte-for-byte identical to what
// web3j's Sign.SignatureData already produces for Ethereum signing (see class doc for verification notes).
val signatureBytes = signature.r + signature.s + signature.v
val signatureHex = signatureBytes.toHexString(withPrefix = false)
return tronGridApi.broadcastTransaction(baseUrl, unsigned, signatureHex)
}
private suspend fun feeLimitFor(
presetFee: Fee?,
baseUrl: String,
ownerHex: String,
recipient: AccountId,
contractHex: String,
amountSun: BigInteger
): BigInteger {
val estimatedFee = presetFee?.castOrNull<TronFee>()?.amount
?: estimateTrc20FeeFromContractHex(baseUrl, ownerHex, recipient, contractHex, amountSun)
return (estimatedFee * BigInteger.valueOf(3)).coerceIn(MIN_FEE_LIMIT_SUN, MAX_FEE_LIMIT_SUN)
}
private suspend fun estimateNativeFee(baseUrl: String, ownerHex: String, recipient: AccountId, amountSun: BigInteger): BigInteger {
// TronGrid's createtransaction rejects amount=0 outright with a ContractValidateException (confirmed
// live) - the send screen's fee loader calls calculateFee reactively as the user types, including before
// any amount has been entered. Substitute a minimal placeholder purely for this dry-run construction call;
// it does not affect the real amount used when the transfer is actually built in transact().
val dryRunAmountSun = amountSun.takeIf { it > BigInteger.ZERO } ?: BigInteger.ONE
val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), dryRunAmountSun)
val txSizeBytes = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }.length / 2
val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE)
val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN)
val bandwidthShortfall = shortfall(txSizeBytes.toLong(), resource.availableBandwidth())
return bandwidthShortfall.toBigInteger() * bandwidthPrice
}
private suspend fun estimateTrc20FeeFromContractHex(
baseUrl: String,
ownerHex: String,
recipient: AccountId,
contractHex: String,
amountSun: BigInteger
): BigInteger {
val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, amountSun)
val dryRun = runCatching {
tronGridApi.triggerConstantContract(baseUrl, ownerHex, contractHex, Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, parameterHex)
}.getOrNull()
// A dry-run revert (e.g. the sender doesn't yet hold the token) still reports the energy spent up to the
// revert point, which remains a meaningful (if slightly different) estimate - it is used as-is rather
// than special-cased, only a wholly-failed HTTP call falls back to the conservative constant.
val energyUsed = dryRun?.energyUsed ?: FALLBACK_TRC20_ENERGY_UNITS
val txSizeBytes = dryRun?.transaction?.rawDataHex?.let { it.length / 2L } ?: FALLBACK_TRC20_TX_SIZE_BYTES
val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE)
val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN)
val energyPrice = chainParameterOrDefault(baseUrl, ENERGY_FEE_PARAM_KEY, FALLBACK_ENERGY_FEE_SUN)
val bandwidthShortfall = shortfall(txSizeBytes, resource.availableBandwidth())
val energyShortfall = shortfall(energyUsed, resource.availableEnergy())
return bandwidthShortfall.toBigInteger() * bandwidthPrice + energyShortfall.toBigInteger() * energyPrice
}
private suspend fun chainParameterOrDefault(baseUrl: String, key: String, default: BigInteger): BigInteger {
val params = runCatching { tronGridApi.getChainParameters(baseUrl) }.getOrNull()
return params?.get(key)?.toBigInteger() ?: default
}
private fun shortfall(needed: Long, available: Long): Long = (needed - available.coerceAtLeast(0)).coerceAtLeast(0)
private fun TronAccountResourceResponse.availableBandwidth(): Long =
(freeNetLimit - freeNetUsed).coerceAtLeast(0) + (netLimit - netUsed).coerceAtLeast(0)
private fun TronAccountResourceResponse.availableEnergy(): Long =
(energyLimit - energyUsed).coerceAtLeast(0)
}
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger
/**
* Minimal, hand-written Solidity ABI encoding for the two calls this client makes to a TRC-20 contract:
* `transfer(address,uint256)` (sending) and `balanceOf(address)` (reading a balance - `Trc20AssetBalance` can't
* use TronGrid's `/v1/accounts` REST endpoint for this: a TRC-20 balance lives in the token contract's own
* storage, not the holder's Account object, so an address that has only ever received TRC-20 tokens has no
* Account object at all and `/v1/accounts` silently returns empty for it regardless of its real balance).
*
* Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad
* each to 32 bytes and concatenate" - no dynamic-type/offset table is needed. The 4-byte function selector is
* intentionally NOT computed client-side: TronGrid accepts the human-readable `function_selector` string
* directly and hashes it server-side (confirmed live against Shasta testnet - a call with
* `function_selector: "transfer(address,uint256)"` and no client-computed selector correctly resolved to the
* standard `a9059cbb` selector in the resulting `raw_data`), which avoids needing a keccak256 implementation here.
*/
object Trc20TransferAbi {
const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)"
const val BALANCE_OF_FUNCTION_SELECTOR = "balanceOf(address)"
/**
* @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address -
* ABI-encoded Solidity `address` parameters use the bare 20-byte form, confirmed live).
*/
fun encodeTransferParameters(recipient: AccountId, amountSun: BigInteger): String {
require(recipient.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${recipient.size}" }
require(amountSun.signum() >= 0) { "Amount must not be negative, got $amountSun" }
val addressParam = recipient.toHexString(withPrefix = false).padStart(64, '0')
val amountParam = amountSun.toString(16).padStart(64, '0')
return addressParam + amountParam
}
/**
* @param holder raw 20-byte Ethereum/Tron-style account id - same encoding as [encodeTransferParameters]'s
* `recipient`.
*/
fun encodeBalanceOfParameters(holder: AccountId): String {
require(holder.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${holder.size}" }
return holder.toHexString(withPrefix = false).padStart(64, '0')
}
}
@@ -0,0 +1,55 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger
/**
* What kind of Tron transaction to build. Both cases ultimately burn TRX for bandwidth/energy per Tron's default
* protocol behavior - this service never stakes/rents Energy or Bandwidth, it only estimates the automatic burn
* and lets the caller (asset transfer validation) block the send if the user's TRX balance can't cover it.
*/
sealed class TronTransactionIntent {
class Native(val amountSun: BigInteger) : TronTransactionIntent()
/** @param contractAddress Base58Check TRC-20 contract address, as stored in chain config (`Type.Trc20.contractAddress`). */
class Trc20Transfer(val contractAddress: String, val amountSun: BigInteger) : TronTransactionIntent()
}
/**
* Mirrors [io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService]'s shape
* (calculateFee/transact/transactAndAwaitExecution over a sending origin), but for Tron. Unlike the EVM service,
* this lives entirely in `feature-wallet-impl` rather than being split across `feature-account-api`/`-impl`,
* since (for now, Phase 2 send-only scope) it is only ever consumed by `TronNativeAssetTransfers`/
* `Trc20AssetTransfers` in this module, and it needs [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi],
* which itself lives in this module (feature-account-impl cannot depend on feature-wallet-impl).
*
* Construction goes through TronGrid's own `/wallet/createtransaction` and `/wallet/triggersmartcontract`
* endpoints rather than hand-rolled protobuf encoding - see `RealTronTransactionService` for details and the
* live-testnet verification notes.
*/
interface TronTransactionService {
suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee
suspend fun transact(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
presetFee: Fee?,
intent: TronTransactionIntent
): Result<ExtrinsicSubmission>
suspend fun transactAndAwaitExecution(
chain: Chain,
origin: TransactionOrigin,
recipient: AccountId,
presetFee: Fee?,
intent: TronTransactionIntent
): Result<TransactionExecution>
}
@@ -75,6 +75,8 @@ interface WalletFeatureDependencies {
val evmTransactionService: EvmTransactionService
val signerProvider: SignerProvider
val chainAssetDao: ChainAssetDao
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
@@ -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
)
@@ -0,0 +1,275 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import com.google.gson.JsonObject
import io.novafoundation.nova.common.utils.Precision
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.common.utils.sha256
import io.novafoundation.nova.common.utils.toTronHexAddress
import io.novafoundation.nova.common.utils.tronAddressToAccountId
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatcher
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnitRunner
import java.math.BigInteger
// eq()/any()/argThat() genuinely return null at runtime - that's how Mockito's matcher-stack recording works,
// regardless of whether they're called via test_shared's Kotlin wrappers or the raw org.mockito.Mockito statics
// directly (confirmed: switching to the raw statics did not avoid the "eq(...) must not be null" crash once that
// null flows into a Kotlin non-null-typed parameter somewhere downstream of the call site). Instead of returning
// the real (null) value, these local wrappers fall back to a definitely-non-null stand-in - the real value itself
// for eq() (harmless: the matcher was already recorded on Mockito's thread-local stack by the time this returns,
// so the fallback value is never actually used for matching) and an unchecked-cast dummy for any()/argThat()
// (mirrors mockito-kotlin's own internal implementation of the same helpers).
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
@Suppress("UNCHECKED_CAST")
private fun <T> any(): T {
Mockito.any<T>()
return null as T
}
@Suppress("UNCHECKED_CAST")
private fun <T> argThat(matcher: (T) -> Boolean): T {
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
return null as T
}
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
/**
* Covers the native-TRX branch of [RealTronTransactionService.transact] - the sign/broadcast pipeline this class'
* own doc comment describes (sha256(raw_data) signed via the existing Ethereum-style ECDSA-raw-hash primitive,
* assembled as a 65-byte r+s+v signature) had no test at all before this: [Trc20TransferAbiTest] only covers the
* TRC-20 ABI-encoding side, and [TronDerivationTest]/[io.novafoundation.nova.common.utils.TronAddressTest] only
* cover address derivation/formatting, not transaction construction or signing.
*
* The owner address used throughout is the same one [TronDerivationTest] cross-validated against the standard
* BIP39 test mnemonic ("abandon x11 about" at the coin-195 path) and against live TronGrid data - reusing it here
* keeps every Tron test in this codebase anchored to a single, independently-verified real-world address instead
* of an arbitrary one. The recipient and raw_data_hex/txID pair are self-consistent fixtures created for this
* test only (unlike [Trc20TransferAbiTest]'s ABI vector, this raw_data_hex was not captured live against
* TronGrid - constructing a real `TransferContract` protobuf is out of scope here, since this class deliberately
* never encodes one itself, see its class doc) - what matters for these tests is that this service correctly
* hashes/signs/forwards whatever raw_data TronGrid returns, which a self-consistent fixture exercises just as
* well as a live-captured one.
*/
@RunWith(MockitoJUnitRunner::class)
class RealTronTransactionServiceTest {
@Mock
lateinit var accountRepository: AccountRepository
@Mock
lateinit var signerProvider: SignerProvider
@Mock
lateinit var tronGridApi: TronGridApi
@Mock
lateinit var metaAccount: MetaAccount
@Mock
lateinit var signer: NovaSigner
private lateinit var subject: RealTronTransactionService
private val ownerAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId()
private val ownerHex = ownerAccountId.toTronHexAddress()
private val recipientAccountId = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex()
private val recipientHex = recipientAccountId.toTronHexAddress()
private val amountSun = BigInteger.valueOf(1_000_000)
private val baseUrl = "https://api.trongrid.io"
private val chain = tronChain(baseUrl)
// Self-consistent fixture: rawDataHex ++ its own sha256 as txID - see class doc.
private val rawDataHex = "0a027a1e2208d1e2b3f4a5b6c7d840e8c896e8b7325a67080112630a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412320a1541dfd8703a5c753e17ed52a96a29cea9d425538dfe1215415d10da10f5c60a8e2d5e3c0a70e1e7f3c1b2a3e41880ade20470a08fc9c9e8b732"
private val expectedTxId = rawDataHex.fromHex().sha256().toHexString(withPrefix = false)
@Before
fun setup() {
subject = RealTronTransactionService(accountRepository, signerProvider, tronGridApi)
whenever(metaAccount.accountIdIn(eq(chain))).thenReturn(ownerAccountId)
whenever(signerProvider.rootSignerFor(eq(metaAccount))).thenReturn(signer)
}
@Test
fun `transact with Native intent should build, sign with sha256(raw_data), and broadcast a 65-byte r+s+v signature`(): Unit = runBlocking {
val unsigned = TronUnsignedTransactionResponse(
visible = false,
txID = expectedTxId,
rawData = JsonObject(),
rawDataHex = rawDataHex
)
val r = ByteArray(32) { (it + 1).toByte() }
val s = ByteArray(32) { (it + 33).toByte() }
val v = byteArrayOf(27)
val fakeSignedRaw = SignedRaw(
SignerPayloadRaw(message = expectedTxId.fromHex(), accountId = ownerAccountId, skipMessageHashing = true),
SignatureWrapper.Ecdsa(v = v, r = r, s = s)
)
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
whenever(signer.signRaw(any())).thenReturn(fakeSignedRaw)
whenever(tronGridApi.broadcastTransaction(eq(baseUrl), eq(unsigned), any())).thenReturn("some-broadcast-tx-hash")
val result = subject.transact(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipient = recipientAccountId,
presetFee = null,
intent = TronTransactionIntent.Native(amountSun)
)
assertTrue(result.isSuccess)
assertEquals("some-broadcast-tx-hash", result.getOrThrow().hash)
// The message actually handed to the signer must be sha256(raw_data), not raw_data or txID itself - a
// regression here would silently produce a signature over the wrong bytes. ByteArray has reference
// equality in Kotlin, so this must compare contents (contentEquals), not rely on SignerPayloadRaw.equals().
val expectedMessage = rawDataHex.fromHex().sha256()
verify(signer).signRaw(
argThat<SignerPayloadRaw> { payload: SignerPayloadRaw ->
payload.message.contentEquals(expectedMessage) &&
payload.accountId.contentEquals(ownerAccountId) &&
payload.skipMessageHashing
}
)
// Tron expects a flat 65-byte r(32)+s(32)+v(1) hex signature - assembled by hand in production code, not
// by any library, so this is the one place that byte order/length could silently regress.
val expectedSignatureHex = (r + s + v).toHexString(withPrefix = false)
verify(tronGridApi).broadcastTransaction(baseUrl, unsigned, expectedSignatureHex)
}
@Test
fun `transact should refuse to sign when TronGrid's txID does not match sha256(raw_data)`(): Unit = runBlocking {
val tamperedTxId = "0".repeat(64) // deliberately wrong - does not match sha256(rawDataHex)
val unsigned = TronUnsignedTransactionResponse(
visible = false,
txID = tamperedTxId,
rawData = JsonObject(),
rawDataHex = rawDataHex
)
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
val result = subject.transact(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipient = recipientAccountId,
presetFee = null,
intent = TronTransactionIntent.Native(amountSun)
)
assertTrue("expected a failed Result when txID doesn't match sha256(raw_data)", result.isFailure)
// Signing (and therefore broadcasting) must never be attempted once the txID/raw_data mismatch is
// detected - this is the guard that stops a tampered/malicious response from getting silently signed.
verify(signer, never()).signRaw(any())
verify(tronGridApi, never()).broadcastTransaction(any(), any(), any())
}
@Test
fun `calculateFee for Native intent should charge bandwidth shortfall at the fallback price when TronGrid's own resource_endpoints are unavailable`(): Unit = runBlocking {
val unsigned = TronUnsignedTransactionResponse(
visible = false,
txID = expectedTxId,
rawData = JsonObject(),
rawDataHex = rawDataHex
)
val txSizeBytes = rawDataHex.length / 2
whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned)
whenever(tronGridApi.getAccountResource(eq(baseUrl), eq(ownerHex))).thenReturn(TronAccountResourceResponse())
whenever(tronGridApi.getChainParameters(eq(baseUrl))).thenReturn(emptyMap<String, Long>())
val fee = subject.calculateFee(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipient = recipientAccountId,
intent = TronTransactionIntent.Native(amountSun)
)
// Zero available bandwidth (empty TronAccountResourceResponse) -> the whole tx size is billed, at the
// fallback bandwidth price (1000 sun/byte) since getChainParameters returned no getTransactionFee entry.
val expectedFeeSun = BigInteger.valueOf(txSizeBytes.toLong()) * BigInteger.valueOf(1000)
assertEquals(expectedFeeSun, fee.amount)
}
private fun tronChain(baseUrl: String): Chain {
val trxAsset = Chain.Asset(
icon = null,
id = 0,
priceId = "tron",
chainId = "tron:mainnet",
symbol = TokenSymbol("TRX"),
precision = Precision(6),
buyProviders = emptyMap(),
sellProviders = emptyMap(),
staking = emptyList(),
type = Chain.Asset.Type.TronNative,
source = Chain.Asset.Source.DEFAULT,
name = "Tron",
enabled = true
)
return Chain(
id = "tron:mainnet",
name = "Tron",
assets = listOf(trxAsset),
nodes = Chain.Nodes(
autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN,
wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance,
nodes = listOf(Chain.Node(chainId = "tron:mainnet", unformattedUrl = baseUrl, name = "TronGrid", orderId = 0, isCustom = false))
),
explorers = emptyList(),
externalApis = emptyList(),
icon = null,
addressPrefix = 0,
legacyAddressPrefix = null,
types = null,
isEthereumBased = false,
isTronBased = true,
isTestNet = false,
source = Chain.Source.DEFAULT,
hasSubstrateRuntime = false,
pushSupport = false,
hasCrowdloans = false,
supportProxy = false,
governance = emptyList(),
swap = emptyList(),
customFee = emptyList(),
multisigSupport = false,
connectionState = Chain.ConnectionState.FULL_SYNC,
parentId = null,
additional = null
)
}
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction
import io.novasama.substrate_sdk_android.extensions.fromHex
import org.junit.Assert.assertEquals
import org.junit.Test
import java.math.BigInteger
class Trc20TransferAbiTest {
/**
* Real request/response pair captured live against TronGrid's Shasta testnet
* (`POST https://api.shasta.trongrid.io/wallet/triggerconstantcontract`) for a `transfer(address,uint256)`
* call with recipient accountId `dfd8703a5c753e17ed52a96a29cea9d425538dfe` and amount `1000000` (sun) -
* TronGrid accepted this exact `parameter` value and correctly resolved `function_selector` to the standard
* `a9059cbb` selector in the resulting `raw_data.contract[0].parameter.value.data`.
*/
@Test
fun `encodeTransferParameters should match a live-verified TronGrid request`() {
val recipient = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex()
val amountSun = BigInteger.valueOf(1_000_000)
val expectedParameter = "000000000000000000000000dfd8703a5c753e17ed52a96a29cea9d425538dfe" +
"00000000000000000000000000000000000000000000000000000000000f4240"
assertEquals(expectedParameter, Trc20TransferAbi.encodeTransferParameters(recipient, amountSun))
}
@Test
fun `encodeTransferParameters should reject a negative amount`() {
val recipient = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex()
assertThrowsIllegalArgument {
Trc20TransferAbi.encodeTransferParameters(recipient, BigInteger.valueOf(-1))
}
}
@Test
fun `encodeTransferParameters should reject a non-20-byte account id`() {
assertThrowsIllegalArgument {
Trc20TransferAbi.encodeTransferParameters(ByteArray(19), BigInteger.ONE)
}
}
private fun assertThrowsIllegalArgument(block: () -> Unit) {
try {
block()
throw AssertionError("Expected IllegalArgumentException")
} catch (expected: IllegalArgumentException) {
// expected
}
}
}