test: add native TRX transfer test - sign/broadcast pipeline had no coverage

RealTronTransactionService's native-TRX branch (transact/calculateFee for
TronTransactionIntent.Native) had zero automated coverage - only the TRC20
ABI encoding (Trc20TransferAbiTest) and address derivation/formatting
(TronDerivationTest, TronAddressTest) were tested. Send/transfer code
moving real funds shouldn't ship untested just because it happens to share
its sign/broadcast plumbing with an already-tested asset type.

Covers: the raw_data is hashed and signed exactly as documented (sha256,
not the raw bytes or txID), the r+s+v signature is assembled correctly
before being sent to broadcastTransaction, and - the one true security
invariant here - a TronGrid response whose txID doesn't match
sha256(raw_data) is refused before ever reaching the signer, not just
before broadcast. Also covers the native fee estimator's bandwidth-shortfall
math against the documented fallback price.

Fixture note: the raw_data_hex/txID pair here is self-consistent (computed
locally) rather than live-captured against TronGrid, unlike
Trc20TransferAbiTest's ABI vector - this class never encodes a real
TransferContract protobuf itself (see its own class doc), so what matters
is that this service correctly hashes/signs/forwards whatever raw_data
TronGrid returns, which a self-consistent fixture exercises identically.
This commit is contained in:
2026-07-10 07:25:33 -07:00
parent 55d405c3a1
commit 9de7267a58
@@ -0,0 +1,253 @@
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.novafoundation.nova.test_shared.any
import io.novafoundation.nova.test_shared.argThat
import io.novafoundation.nova.test_shared.eq
import io.novafoundation.nova.test_shared.whenever
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnitRunner
import java.math.BigInteger
/**
* 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`() = 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 ->
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)`() = 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`() = 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())
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
)
}
}