mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 13:45:48 +00:00
Add read-only Tron (TRC20) chain family support (#9)
* Add read-only Tron (TRC20) chain family support Adds Tron as a third chain family alongside Substrate and EVM: address derivation (BIP44 coin type 195, secp256k1 reused from the existing Ethereum path, Base58Check encoding), TronGrid-backed TRX and TRC20 USDT balance display, and a Room migration for the new per-account Tron key/address columns. Read-only only — no send, signing, or broadcast support yet. * fix: exhaustive when branches for Trc20/TronNative asset types ChainExt.kt's onChainAssetId and Common.kt's existentialDepositError both switch exhaustively over Chain.Asset.Type and didn't have cases for the new Trc20/TronNative variants, breaking compilation. Trc20 mirrors EvmErc20 (contract address as the on-chain id, dust burns rather than transfers on removal); TronNative mirrors EvmNative.
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ android {
|
||||
buildConfigField "String", "TERMS_URL", "\"https://pezkuwichain.io/terms.html\""
|
||||
buildConfigField "String", "GITHUB_URL", "\"https://github.com/pezkuwichain\""
|
||||
buildConfigField "String", "TELEGRAM_URL", "\"https://t.me/pezkuwichainBot\""
|
||||
buildConfigField "String", "TWITTER_URL", "\"https://twitter.com/pezkuwichain\""
|
||||
buildConfigField "String", "TWITTER_URL", "\"https://x.com/bizinikiwi\""
|
||||
buildConfigField "String", "RATE_URL", "\"market://details?id=${rootProject.applicationId}.${releaseApplicationSuffix}\""
|
||||
buildConfigField "String", "EMAIL", "\"support@pezkuwichain.io\""
|
||||
buildConfigField "String", "YOUTUBE_URL", "\"https://www.youtube.com/@SatoshiQazi\""
|
||||
|
||||
+20
@@ -25,6 +25,9 @@ object MetaAccountSecrets : Schema<MetaAccountSecrets>() {
|
||||
|
||||
val EthereumKeypair by schema(KeyPairSchema).optional()
|
||||
val EthereumDerivationPath by string().optional()
|
||||
|
||||
val TronKeypair by schema(KeyPairSchema).optional()
|
||||
val TronDerivationPath by string().optional()
|
||||
}
|
||||
|
||||
object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
|
||||
@@ -42,6 +45,8 @@ fun MetaAccountSecrets(
|
||||
substrateDerivationPath: String? = null,
|
||||
ethereumKeypair: Keypair? = null,
|
||||
ethereumDerivationPath: String? = null,
|
||||
tronKeypair: Keypair? = null,
|
||||
tronDerivationPath: String? = null,
|
||||
): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets ->
|
||||
secrets[Entropy] = entropy
|
||||
secrets[SubstrateSeed] = substrateSeed
|
||||
@@ -61,6 +66,15 @@ fun MetaAccountSecrets(
|
||||
}
|
||||
}
|
||||
secrets[EthereumDerivationPath] = ethereumDerivationPath
|
||||
|
||||
secrets[TronKeypair] = tronKeypair?.let {
|
||||
KeyPairSchema { keypair ->
|
||||
keypair[PublicKey] = it.publicKey
|
||||
keypair[PrivateKey] = it.privateKey
|
||||
keypair[Nonce] = null // tron uses secp256k1 (like ethereum), so nonce is always null
|
||||
}
|
||||
}
|
||||
secrets[TronDerivationPath] = tronDerivationPath
|
||||
}
|
||||
|
||||
fun ChainAccountSecrets(
|
||||
@@ -86,6 +100,9 @@ val EncodableStruct<MetaAccountSecrets>.substrateDerivationPath
|
||||
val EncodableStruct<MetaAccountSecrets>.ethereumDerivationPath
|
||||
get() = get(MetaAccountSecrets.EthereumDerivationPath)
|
||||
|
||||
val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
|
||||
get() = get(MetaAccountSecrets.TronDerivationPath)
|
||||
|
||||
val EncodableStruct<MetaAccountSecrets>.entropy
|
||||
get() = get(MetaAccountSecrets.Entropy)
|
||||
|
||||
@@ -98,6 +115,9 @@ val EncodableStruct<MetaAccountSecrets>.substrateKeypair
|
||||
val EncodableStruct<MetaAccountSecrets>.ethereumKeypair
|
||||
get() = get(MetaAccountSecrets.EthereumKeypair)
|
||||
|
||||
val EncodableStruct<MetaAccountSecrets>.tronKeypair
|
||||
get() = get(MetaAccountSecrets.TronKeypair)
|
||||
|
||||
val EncodableStruct<ChainAccountSecrets>.derivationPath
|
||||
get() = get(ChainAccountSecrets.DerivationPath)
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package io.novafoundation.nova.common.utils
|
||||
|
||||
import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey
|
||||
import io.novasama.substrate_sdk_android.extensions.toAccountId
|
||||
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Tron address format:
|
||||
* Base58Check(0x41 ++ accountId), where `accountId` is the 20-byte keccak256-derived id
|
||||
* (the exact same derivation Ethereum uses: keccak256(uncompressed pubkey without the 0x04 prefix)[-20:]).
|
||||
*
|
||||
* We deliberately reuse the existing Ethereum-style pubkey -> accountId derivation from `substrate_sdk_android`
|
||||
* (`asEthereumPublicKey().toAccountId()`) instead of re-implementing keccak/secp256k1 ourselves, since that math
|
||||
* is identical for Tron - only the final string encoding differs (Base58Check with a 0x41 prefix, instead of
|
||||
* checksummed hex with a 0x prefix).
|
||||
*
|
||||
* Base58Check itself (this file's [Base58]/[Base58Check] objects) is hand-implemented since no Base58 library is
|
||||
* currently on this project's classpath. It is a deterministic text encoding (not a secret-dependent cryptographic
|
||||
* primitive) and has been cross-checked against the well-known USDT-TRC20 contract address
|
||||
* (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t <-> hex 41a614f803b6fd780986a42c78ec9c7f77e6ded13c) - see [TronAddressTest].
|
||||
*/
|
||||
private const val TRON_ADDRESS_PREFIX_BYTE: Byte = 0x41
|
||||
|
||||
object Base58 {
|
||||
|
||||
private const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
private val BASE = BigInteger.valueOf(58)
|
||||
|
||||
fun encode(input: ByteArray): String {
|
||||
if (input.isEmpty()) return ""
|
||||
|
||||
var value = BigInteger(1, input)
|
||||
|
||||
val sb = StringBuilder()
|
||||
while (value > BigInteger.ZERO) {
|
||||
val (div, rem) = value.divideAndRemainder(BASE)
|
||||
sb.append(ALPHABET[rem.toInt()])
|
||||
value = div
|
||||
}
|
||||
|
||||
// Every leading zero byte of the input must be represented as a leading '1' in the output,
|
||||
// since a plain big-integer encoding would otherwise drop them.
|
||||
val leadingZeroBytes = input.takeWhile { it == 0.toByte() }.size
|
||||
repeat(leadingZeroBytes) { sb.append(ALPHABET[0]) }
|
||||
|
||||
return sb.reverse().toString()
|
||||
}
|
||||
|
||||
fun decode(input: String): ByteArray {
|
||||
if (input.isEmpty()) return ByteArray(0)
|
||||
|
||||
var value = BigInteger.ZERO
|
||||
for (char in input) {
|
||||
val digit = ALPHABET.indexOf(char)
|
||||
require(digit >= 0) { "Invalid Base58 character: '$char'" }
|
||||
|
||||
value = value.multiply(BASE).add(BigInteger.valueOf(digit.toLong()))
|
||||
}
|
||||
|
||||
var bytes = if (value == BigInteger.ZERO) ByteArray(0) else value.toByteArray()
|
||||
// BigInteger#toByteArray may prepend a single zero sign byte for an otherwise-positive value; drop it.
|
||||
if (bytes.size > 1 && bytes[0] == 0.toByte()) {
|
||||
bytes = bytes.copyOfRange(1, bytes.size)
|
||||
}
|
||||
|
||||
val leadingOnes = input.takeWhile { it == ALPHABET[0] }.length
|
||||
|
||||
return ByteArray(leadingOnes) + bytes
|
||||
}
|
||||
}
|
||||
|
||||
object Base58Check {
|
||||
|
||||
private const val CHECKSUM_SIZE = 4
|
||||
|
||||
fun encode(payload: ByteArray): String {
|
||||
val checksum = payload.sha256().sha256().copyOfRange(0, CHECKSUM_SIZE)
|
||||
|
||||
return Base58.encode(payload + checksum)
|
||||
}
|
||||
|
||||
fun decode(input: String): ByteArray {
|
||||
val full = Base58.decode(input)
|
||||
require(full.size > CHECKSUM_SIZE) { "Base58Check payload too short: $input" }
|
||||
|
||||
val payload = full.copyOfRange(0, full.size - CHECKSUM_SIZE)
|
||||
val checksum = full.copyOfRange(full.size - CHECKSUM_SIZE, full.size)
|
||||
|
||||
val expectedChecksum = payload.sha256().sha256().copyOfRange(0, CHECKSUM_SIZE)
|
||||
require(checksum.contentEquals(expectedChecksum)) { "Invalid Base58Check checksum: $input" }
|
||||
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
// See the file-level doc above for why it's correct to reuse the Ethereum pubkey->accountId derivation here.
|
||||
fun ByteArray.tronPublicKeyToAccountId(): AccountId = asEthereumPublicKey().toAccountId().value
|
||||
|
||||
fun AccountId.toTronAddress(): String {
|
||||
require(size == 20) { "Tron account id must be 20 bytes, got $size" }
|
||||
|
||||
return Base58Check.encode(byteArrayOf(TRON_ADDRESS_PREFIX_BYTE) + this)
|
||||
}
|
||||
|
||||
fun String.tronAddressToAccountId(): AccountId {
|
||||
val decoded = Base58Check.decode(this)
|
||||
require(decoded.size == 21 && decoded[0] == TRON_ADDRESS_PREFIX_BYTE) { "Not a valid Tron address: $this" }
|
||||
|
||||
return decoded.copyOfRange(1, decoded.size)
|
||||
}
|
||||
|
||||
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
|
||||
|
||||
fun emptyTronAccountId() = ByteArray(20) { 1 }
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.novafoundation.nova.common.utils
|
||||
|
||||
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TronAddressTest {
|
||||
|
||||
/**
|
||||
* TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t is the well-known Tron mainnet USDT (TRC-20) contract address
|
||||
* (the same one used in this app's Tron chain config). Its raw 21-byte Base58Check payload
|
||||
* (0x41 prefix ++ 20-byte account id) is publicly documented as
|
||||
* 41a614f803b6fd780986a42c78ec9c7f77e6ded13c - this is an independently-verifiable, real-world
|
||||
* test vector (not a value invented for this test).
|
||||
*/
|
||||
private val knownTronAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
private val knownTronAddressHex = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
|
||||
@Test
|
||||
fun `should decode known Tron address to expected raw bytes`() {
|
||||
val decodedPayload = Base58Check.decode(knownTronAddress)
|
||||
|
||||
assertEquals(knownTronAddressHex, decodedPayload.toHexString(withPrefix = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should re-encode known raw bytes back to the exact known Tron address`() {
|
||||
val payload = knownTronAddressHex.fromHex()
|
||||
|
||||
assertEquals(knownTronAddress, Base58Check.encode(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accountId to address and back should round trip`() {
|
||||
val accountId = knownTronAddressHex.fromHex().copyOfRange(1, 21)
|
||||
|
||||
val address = accountId.toTronAddress()
|
||||
assertEquals(knownTronAddress, address)
|
||||
|
||||
val decodedBack = address.tronAddressToAccountId()
|
||||
assertTrue(decodedBack.contentEquals(accountId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isValidTronAddress should accept known good address`() {
|
||||
assertTrue(knownTronAddress.isValidTronAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isValidTronAddress should reject corrupted address`() {
|
||||
val corrupted = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6u" // last char changed -> checksum mismatch
|
||||
assertFalse(corrupted.isValidTronAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isValidTronAddress should reject a plain Ethereum-style address`() {
|
||||
assertFalse("0x9858EfFD232B4033E47d90003D41EC34EcaEda94".isValidTronAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Base58 encode should preserve leading zero bytes as leading 1s`() {
|
||||
val input = byteArrayOf(0, 0, 1, 2, 3)
|
||||
|
||||
val encoded = Base58.encode(input)
|
||||
assertTrue(encoded.startsWith("11"))
|
||||
|
||||
val decoded = Base58.decode(encoded)
|
||||
assertTrue(decoded.contentEquals(input))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Base58 encode-decode should round trip for empty input`() {
|
||||
assertEquals("", Base58.encode(ByteArray(0)))
|
||||
assertTrue(Base58.decode("").isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user