mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-04-22 05:38:02 +00:00
c12dd79c74
Pezkuwi chains use "bizinikiwi" signing context instead of standard "substrate" context used by Polkadot ecosystem. This was causing "bad signature" errors on HEZ transfers. Changes: - Add sr25519-bizinikiwi native Rust binding with bizinikiwi context - Add isPezkuwiChain extension to detect Pezkuwi ecosystem chains - Add PezkuwiKeyPairSigner for signing with bizinikiwi context - Modify SecretsSigner to use correct context based on chain: - Pezkuwi chains (3) -> BizinikiwSr25519 (bizinikiwi) - Other chains (98+) -> KeyPairSigner (substrate) - Add live transfer test for Pezkuwi mainnet Tested with successful HEZ transfer on Pezkuwi mainnet: TX: 0xe25a4eaaeaa04122cca130582dba3cacb2280dea5d908924b45757ea67c27996
431 lines
19 KiB
Kotlin
431 lines
19 KiB
Kotlin
package io.novafoundation.nova
|
|
|
|
import android.content.Context
|
|
import android.util.Log
|
|
import androidx.test.core.app.ApplicationProvider
|
|
import io.novafoundation.nova.common.address.AccountIdKey
|
|
import io.novafoundation.nova.common.di.FeatureUtils
|
|
import io.novafoundation.nova.common.utils.deriveSeed32
|
|
import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType
|
|
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
|
|
import io.novafoundation.nova.feature_account_api.data.signer.SigningContext
|
|
import io.novafoundation.nova.feature_account_api.data.signer.SigningMode
|
|
import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy
|
|
import io.novafoundation.nova.feature_account_api.data.signer.setSignerData
|
|
import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount
|
|
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
|
|
import io.novafoundation.nova.feature_account_impl.domain.account.model.DefaultMetaAccount
|
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.TransferMode
|
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.nativeTransfer
|
|
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
|
|
import io.novafoundation.nova.runtime.ext.Geneses
|
|
import io.novafoundation.nova.runtime.ext.requireGenesisHash
|
|
import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory
|
|
import io.novasama.substrate_sdk_android.extensions.fromHex
|
|
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
|
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
|
|
import io.novafoundation.nova.runtime.network.rpc.RpcCalls
|
|
import io.novasama.substrate_sdk_android.encrypt.EncryptionType
|
|
import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption
|
|
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
|
|
import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair
|
|
import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.SubstrateKeypairFactory
|
|
import io.novasama.substrate_sdk_android.encrypt.seed.substrate.SubstrateSeedFactory
|
|
import io.novasama.substrate_sdk_android.runtime.AccountId
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.Nonce
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.KeyPairSigner
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckNonce.Companion.setNonce
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.GeneralTransactionSigner
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.VerifySignature.Companion.setVerifySignature
|
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.signingPayload
|
|
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
|
|
import io.novafoundation.nova.sr25519.BizinikiwSr25519
|
|
import org.junit.Assert.assertNotNull
|
|
import org.junit.Assert.fail
|
|
import org.junit.Test
|
|
import java.math.BigInteger
|
|
|
|
/**
|
|
* LIVE TRANSFER TEST - Transfers real HEZ tokens on Pezkuwi mainnet!
|
|
*
|
|
* Sender: 5DXv3Dc5xELckTgcYa2dm1TSZPgqDPxVDW3Cid4ALWpVjY3w
|
|
* Recipient: 5HdY6U2UQF8wPwczP3SoQz28kQu1WJSBqxKGePUKG4M5QYdV
|
|
* Amount: 5 HEZ
|
|
*
|
|
* Run with: ./gradlew :app:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=io.novafoundation.nova.PezkuwiLiveTransferTest
|
|
*/
|
|
class PezkuwiLiveTransferTest : BaseIntegrationTest() {
|
|
|
|
companion object {
|
|
// Test wallet mnemonic
|
|
private const val TEST_MNEMONIC = "crucial surge north silly divert throw habit fury zebra fabric tank output"
|
|
|
|
// Sender address (derived from mnemonic)
|
|
private const val SENDER_ADDRESS = "5DXv3Dc5xELckTgcYa2dm1TSZPgqDPxVDW3Cid4ALWpVjY3w"
|
|
|
|
// Recipient address
|
|
private const val RECIPIENT_ADDRESS = "5HdY6U2UQF8wPwczP3SoQz28kQu1WJSBqxKGePUKG4M5QYdV"
|
|
|
|
// Amount: 5 HEZ (with 12 decimals)
|
|
private val TRANSFER_AMOUNT = BigInteger("5000000000000") // 5 * 10^12
|
|
}
|
|
|
|
private val walletApi = FeatureUtils.getFeature<WalletFeatureApi>(
|
|
ApplicationProvider.getApplicationContext<Context>(),
|
|
WalletFeatureApi::class.java
|
|
)
|
|
|
|
private val extrinsicBuilderFactory = runtimeApi.provideExtrinsicBuilderFactory()
|
|
private val rpcCalls = runtimeApi.rpcCalls()
|
|
|
|
/**
|
|
* LIVE TEST: Build and submit a real transfer on Pezkuwi mainnet
|
|
*/
|
|
@Test(timeout = 120000) // 2 minute timeout
|
|
fun testLiveTransfer5HEZ() = runTest {
|
|
Log.d("LiveTransferTest", "=== STARTING LIVE TRANSFER TEST ===")
|
|
Log.d("LiveTransferTest", "Sender: $SENDER_ADDRESS")
|
|
Log.d("LiveTransferTest", "Recipient: $RECIPIENT_ADDRESS")
|
|
Log.d("LiveTransferTest", "Amount: 5 HEZ")
|
|
|
|
// Request full sync for Pezkuwi chain specifically
|
|
Log.d("LiveTransferTest", "Requesting full sync for Pezkuwi chain...")
|
|
chainRegistry.enableFullSync(Chain.Geneses.PEZKUWI)
|
|
|
|
val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI)
|
|
Log.d("LiveTransferTest", "Chain: ${chain.name}")
|
|
|
|
// Create keypair from mnemonic
|
|
val keypair = createKeypairFromMnemonic(TEST_MNEMONIC)
|
|
Log.d("LiveTransferTest", "Keypair created, public key: ${keypair.publicKey.toHexString()}")
|
|
|
|
// Create signer
|
|
val signer = RealSigner(keypair, chain)
|
|
Log.d("LiveTransferTest", "Signer created")
|
|
|
|
// Get recipient account ID
|
|
val recipientAccountId = RECIPIENT_ADDRESS.toAccountId()
|
|
Log.d("LiveTransferTest", "Recipient AccountId: ${recipientAccountId.toHexString()}")
|
|
|
|
// Get current nonce using sender's SS58 address
|
|
val nonce = try {
|
|
rpcCalls.getNonce(chain.id, SENDER_ADDRESS)
|
|
} catch (e: Exception) {
|
|
Log.e("LiveTransferTest", "Failed to get nonce, using 0", e)
|
|
BigInteger.ZERO
|
|
}
|
|
Log.d("LiveTransferTest", "Current nonce: $nonce")
|
|
|
|
// Create extrinsic builder
|
|
val builder = extrinsicBuilderFactory.create(
|
|
chain = chain,
|
|
options = ExtrinsicBuilderFactory.Options(BatchMode.BATCH)
|
|
)
|
|
Log.d("LiveTransferTest", "ExtrinsicBuilder created")
|
|
|
|
// Use default MORTAL era (same as @pezkuwi/api)
|
|
Log.d("LiveTransferTest", "Using MORTAL era (default, same as @pezkuwi/api)")
|
|
|
|
// Add transfer call with KEEP_ALIVE mode (same as @pezkuwi/api uses)
|
|
builder.nativeTransfer(accountId = recipientAccountId, amount = TRANSFER_AMOUNT, mode = TransferMode.KEEP_ALIVE)
|
|
Log.d("LiveTransferTest", "Transfer call added")
|
|
|
|
// Set signer data for SUBMISSION (this is where TypeReference errors occur!)
|
|
try {
|
|
with(builder) {
|
|
signer.setSignerData(RealSigningContext(chain, nonce), SigningMode.SUBMISSION)
|
|
}
|
|
Log.d("LiveTransferTest", "Signer data set successfully")
|
|
} catch (e: Exception) {
|
|
Log.e("LiveTransferTest", "FAILED to set signer data!", e)
|
|
fail("Failed to set signer data: ${e.message}\nCause: ${e.cause?.message}\nStack: ${e.stackTraceToString()}")
|
|
return@runTest
|
|
}
|
|
|
|
// Build the extrinsic
|
|
val extrinsic = try {
|
|
builder.buildExtrinsic()
|
|
} catch (e: Exception) {
|
|
Log.e("LiveTransferTest", "FAILED to build extrinsic!", e)
|
|
fail("Failed to build extrinsic: ${e.message}\nCause: ${e.cause?.message}\nStack: ${e.stackTraceToString()}")
|
|
return@runTest
|
|
}
|
|
|
|
assertNotNull("Extrinsic should not be null", extrinsic)
|
|
Log.d("LiveTransferTest", "Extrinsic built: ${extrinsic.extrinsicHex}")
|
|
|
|
// Submit the extrinsic
|
|
Log.d("LiveTransferTest", "Submitting extrinsic to network...")
|
|
try {
|
|
val hash = rpcCalls.submitExtrinsic(chain.id, extrinsic)
|
|
Log.d("LiveTransferTest", "=== TRANSFER SUBMITTED SUCCESSFULLY ===")
|
|
Log.d("LiveTransferTest", "Transaction hash: $hash")
|
|
println("LIVE TRANSFER SUCCESS! TX Hash: $hash")
|
|
} catch (e: Exception) {
|
|
Log.e("LiveTransferTest", "FAILED to submit extrinsic!", e)
|
|
fail("Failed to submit extrinsic: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test to check type resolution in the runtime
|
|
*/
|
|
@Test(timeout = 120000)
|
|
fun testTypeResolution() = runTest {
|
|
Log.d("LiveTransferTest", "=== TESTING TYPE RESOLUTION ===")
|
|
|
|
// Request full sync for Pezkuwi chain
|
|
chainRegistry.enableFullSync(Chain.Geneses.PEZKUWI)
|
|
val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI)
|
|
val runtime = chainRegistry.getRuntime(chain.id)
|
|
|
|
// Check critical types for extrinsic encoding
|
|
val typesToCheck = listOf(
|
|
"Address",
|
|
"MultiAddress",
|
|
"GenericMultiAddress",
|
|
"ExtrinsicSignature",
|
|
"MultiSignature",
|
|
"pezsp_runtime::multiaddress::MultiAddress",
|
|
"pezsp_runtime::MultiSignature",
|
|
"pezsp_runtime.multiaddress.MultiAddress",
|
|
"pezsp_runtime.MultiSignature",
|
|
"GenericExtrinsic",
|
|
"Extrinsic"
|
|
)
|
|
|
|
val results = mutableListOf<String>()
|
|
for (typeName in typesToCheck) {
|
|
val type = runtime.typeRegistry[typeName]
|
|
val resolved = type?.let {
|
|
try {
|
|
// Try to get the actual type, not just alias
|
|
it.toString()
|
|
} catch (e: Exception) {
|
|
"ERROR: ${e.message}"
|
|
}
|
|
}
|
|
val status = if (type != null) "FOUND: $resolved" else "MISSING"
|
|
results.add(" $typeName: $status")
|
|
Log.d("LiveTransferTest", "$typeName: $status")
|
|
}
|
|
|
|
// Check if extrinsic signature type is defined in metadata
|
|
val extrinsicMeta = runtime.metadata.extrinsic
|
|
Log.d("LiveTransferTest", "Extrinsic version: ${extrinsicMeta.version}")
|
|
Log.d("LiveTransferTest", "Signed extensions: ${extrinsicMeta.signedExtensions.map { it.id }}")
|
|
|
|
// Log signed extension IDs
|
|
for (ext in extrinsicMeta.signedExtensions) {
|
|
Log.d("LiveTransferTest", "Extension: ${ext.id}")
|
|
}
|
|
|
|
// Just log the extension names - type access might be restricted
|
|
Log.d("LiveTransferTest", "Signed extensions count: ${extrinsicMeta.signedExtensions.size}")
|
|
|
|
// Log the extrinsic address type if available
|
|
Log.d("LiveTransferTest", "RuntimeFactory diagnostics: ${io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFactory.lastDiagnostics}")
|
|
|
|
println("Type resolution results:\n${results.joinToString("\n")}")
|
|
}
|
|
|
|
/**
|
|
* Test fee calculation (doesn't submit, just builds for fee estimation)
|
|
*/
|
|
@Test(timeout = 120000)
|
|
fun testFeeCalculation() = runTest {
|
|
Log.d("LiveTransferTest", "=== TESTING FEE CALCULATION ===")
|
|
|
|
// Request full sync for Pezkuwi chain
|
|
chainRegistry.enableFullSync(Chain.Geneses.PEZKUWI)
|
|
val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI)
|
|
|
|
// First, log type registry state
|
|
val runtime = chainRegistry.getRuntime(chain.id)
|
|
Log.d("LiveTransferTest", "TypeRegistry has ExtrinsicSignature: ${runtime.typeRegistry["ExtrinsicSignature"] != null}")
|
|
Log.d("LiveTransferTest", "TypeRegistry has MultiSignature: ${runtime.typeRegistry["MultiSignature"] != null}")
|
|
Log.d("LiveTransferTest", "TypeRegistry has Address: ${runtime.typeRegistry["Address"] != null}")
|
|
Log.d("LiveTransferTest", "TypeRegistry has MultiAddress: ${runtime.typeRegistry["MultiAddress"] != null}")
|
|
|
|
val keypair = createKeypairFromMnemonic(TEST_MNEMONIC)
|
|
val signer = RealSigner(keypair, chain)
|
|
val recipientAccountId = RECIPIENT_ADDRESS.toAccountId()
|
|
|
|
val builder = extrinsicBuilderFactory.create(
|
|
chain = chain,
|
|
options = ExtrinsicBuilderFactory.Options(BatchMode.BATCH)
|
|
)
|
|
|
|
builder.nativeTransfer(accountId = recipientAccountId, amount = TRANSFER_AMOUNT)
|
|
|
|
// Set signer data for FEE mode
|
|
try {
|
|
with(builder) {
|
|
signer.setSignerData(RealSigningContext(chain, BigInteger.ZERO), SigningMode.FEE)
|
|
}
|
|
Log.d("LiveTransferTest", "Signer data set, building extrinsic...")
|
|
val extrinsic = builder.buildExtrinsic()
|
|
assertNotNull("Fee extrinsic should not be null", extrinsic)
|
|
Log.d("LiveTransferTest", "Extrinsic built, getting hex...")
|
|
|
|
// The error happens when accessing extrinsicHex
|
|
try {
|
|
val hex = extrinsic.extrinsicHex
|
|
Log.d("LiveTransferTest", "Fee extrinsic built: $hex")
|
|
println("Fee calculation test PASSED!")
|
|
} catch (e: Exception) {
|
|
Log.e("LiveTransferTest", "FAILED accessing extrinsicHex!", e)
|
|
fail("Failed to get extrinsic hex: ${e.message}\nCause: ${e.cause?.message}\nStack: ${e.stackTraceToString()}")
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e("LiveTransferTest", "Fee calculation FAILED!", e)
|
|
fail("Fee calculation failed: ${e.message}\nCause: ${e.cause?.message}")
|
|
}
|
|
}
|
|
|
|
// Helper to create keypair from mnemonic
|
|
private fun createKeypairFromMnemonic(mnemonic: String): Keypair {
|
|
val seedResult = SubstrateSeedFactory.deriveSeed32(mnemonic, password = null)
|
|
return SubstrateKeypairFactory.generate(EncryptionType.SR25519, seedResult.seed)
|
|
}
|
|
|
|
// Real signer using actual keypair with bizinikiwi context
|
|
private inner class RealSigner(
|
|
private val keypair: Keypair,
|
|
private val chain: Chain
|
|
) : NovaSigner, GeneralTransactionSigner {
|
|
|
|
val accountId: ByteArray = keypair.publicKey
|
|
|
|
// Generate proper 96-byte keypair using BizinikiwSr25519 native library
|
|
// This gives us the correct 64-byte secret key format for signing
|
|
private val bizinikiwKeypair: ByteArray by lazy {
|
|
val seedResult = SubstrateSeedFactory.deriveSeed32(TEST_MNEMONIC, password = null)
|
|
BizinikiwSr25519.keypairFromSeed(seedResult.seed)
|
|
}
|
|
|
|
// Extract 64-byte secret key (32-byte scalar + 32-byte nonce)
|
|
private val bizinikiwSecretKey: ByteArray by lazy {
|
|
BizinikiwSr25519.secretKeyFromKeypair(bizinikiwKeypair)
|
|
}
|
|
|
|
// Extract 32-byte public key
|
|
private val bizinikiwPublicKey: ByteArray by lazy {
|
|
BizinikiwSr25519.publicKeyFromKeypair(bizinikiwKeypair)
|
|
}
|
|
|
|
private val keyPairSigner = KeyPairSigner(
|
|
keypair,
|
|
MultiChainEncryption.Substrate(EncryptionType.SR25519)
|
|
)
|
|
|
|
override suspend fun callExecutionType(): CallExecutionType {
|
|
return CallExecutionType.IMMEDIATE
|
|
}
|
|
|
|
override val metaAccount: MetaAccount = DefaultMetaAccount(
|
|
id = 0,
|
|
globallyUniqueId = "test-wallet",
|
|
substrateAccountId = accountId,
|
|
substrateCryptoType = null,
|
|
substratePublicKey = keypair.publicKey,
|
|
ethereumAddress = null,
|
|
ethereumPublicKey = null,
|
|
isSelected = true,
|
|
name = "Test Wallet",
|
|
type = LightMetaAccount.Type.SECRETS,
|
|
chainAccounts = emptyMap(),
|
|
status = LightMetaAccount.Status.ACTIVE,
|
|
parentMetaId = null
|
|
)
|
|
|
|
override suspend fun getSigningHierarchy(): SubmissionHierarchy {
|
|
return SubmissionHierarchy(metaAccount, CallExecutionType.IMMEDIATE)
|
|
}
|
|
|
|
override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw {
|
|
return keyPairSigner.signRaw(payload)
|
|
}
|
|
|
|
context(ExtrinsicBuilder)
|
|
override suspend fun setSignerDataForSubmission(context: SigningContext) {
|
|
val nonce = context.getNonce(AccountIdKey(accountId))
|
|
setNonce(nonce)
|
|
setVerifySignature(this@RealSigner, accountId)
|
|
}
|
|
|
|
context(ExtrinsicBuilder)
|
|
override suspend fun setSignerDataForFee(context: SigningContext) {
|
|
setSignerDataForSubmission(context)
|
|
}
|
|
|
|
override suspend fun submissionSignerAccountId(chain: Chain): AccountId {
|
|
return accountId
|
|
}
|
|
|
|
override suspend fun maxCallsPerTransaction(): Int? {
|
|
return null
|
|
}
|
|
|
|
override suspend fun signInheritedImplication(
|
|
inheritedImplication: InheritedImplication,
|
|
accountId: AccountId
|
|
): SignatureWrapper {
|
|
// Get the SDK's signing payload (SCALE format - same as @pezkuwi/api)
|
|
val sdkPayloadBytes = inheritedImplication.signingPayload()
|
|
|
|
Log.d("LiveTransferTest", "=== SIGNING PAYLOAD (SDK - SCALE) ===")
|
|
Log.d("LiveTransferTest", "SDK Payload hex: ${sdkPayloadBytes.toHexString()}")
|
|
Log.d("LiveTransferTest", "SDK Payload length: ${sdkPayloadBytes.size} bytes")
|
|
|
|
// Debug: show first bytes to verify format
|
|
if (sdkPayloadBytes.size >= 42) {
|
|
val callData = sdkPayloadBytes.copyOfRange(0, 42)
|
|
val extensions = sdkPayloadBytes.copyOfRange(42, sdkPayloadBytes.size)
|
|
Log.d("LiveTransferTest", "Call data (42 bytes): ${callData.toHexString()}")
|
|
Log.d("LiveTransferTest", "Extensions (${extensions.size} bytes): ${extensions.toHexString()}")
|
|
}
|
|
|
|
// Use BizinikiwSr25519 native library with "bizinikiwi" signing context
|
|
Log.d("LiveTransferTest", "=== USING BIZINIKIWI CONTEXT ===")
|
|
Log.d("LiveTransferTest", "Bizinikiwi public key: ${bizinikiwPublicKey.toHexString()}")
|
|
Log.d("LiveTransferTest", "Bizinikiwi secret key size: ${bizinikiwSecretKey.size} bytes")
|
|
|
|
val signatureBytes = BizinikiwSr25519.sign(
|
|
publicKey = bizinikiwPublicKey,
|
|
secretKey = bizinikiwSecretKey,
|
|
message = sdkPayloadBytes
|
|
)
|
|
|
|
Log.d("LiveTransferTest", "=== SIGNATURE PRODUCED ===")
|
|
Log.d("LiveTransferTest", "Signature bytes: ${signatureBytes.toHexString()}")
|
|
Log.d("LiveTransferTest", "Signature length: ${signatureBytes.size} bytes")
|
|
|
|
// Verify the signature locally before sending
|
|
val verifyResult = BizinikiwSr25519.verify(signatureBytes, sdkPayloadBytes, bizinikiwPublicKey)
|
|
Log.d("LiveTransferTest", "Local verification: $verifyResult")
|
|
|
|
return SignatureWrapper.Sr25519(signatureBytes)
|
|
}
|
|
}
|
|
|
|
private class RealSigningContext(
|
|
override val chain: Chain,
|
|
private val nonceValue: BigInteger
|
|
) : SigningContext {
|
|
override suspend fun getNonce(accountId: AccountIdKey): Nonce {
|
|
return Nonce.ZERO + nonceValue
|
|
}
|
|
}
|
|
|
|
private fun ByteArray.toHexString(): String {
|
|
return joinToString("") { "%02x".format(it) }
|
|
}
|
|
}
|