feat: Add bizinikiwi signing context for Pezkuwi ecosystem

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
This commit is contained in:
2026-02-07 03:41:52 +03:00
parent 7542046890
commit c12dd79c74
1354 changed files with 86971 additions and 6 deletions
@@ -0,0 +1,430 @@
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) }
}
}