From cf0d3ebc2377e437f58511e82b09b80ea185a9f4 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Mon, 3 Aug 2026 00:09:05 -0700 Subject: [PATCH 1/3] diag: probe both era encodings when building the multisig weight-estimation extrinsic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approving a multisig operation on Pezkuwi Asset Hub fails with "Failed to encode extension CheckMortality". The on-device stack puts it in wrapInFakeExtrinsic, reached only from estimateCallWeight — asMulti needs a max_weight, so a throwaway signed extrinsic is built to measure the inner call. Plain transfers never reach that path, which is why sending HEZ works while approving does not. The same failure on Polkadot Asset Hub was fixed by gating the era encoding on chain identity, and Pezkuwi Asset Hub satisfies that gate: its genesis hash matches the constant, so PezkuwiCheckImmortal should already be in use. Reading the code cannot say which side actually fails. So build the extrinsic both ways and report each outcome with its full cause chain. One reproduction then names the encoding the chain accepts, and the permanent fix follows from that rather than from another guess. Diagnostic only — the probe and the DIAG constant come out with the real fix. --- .../data/extrinsic/ExtrinsicSplitter.kt | 98 ++++++++++++++----- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt index b9644056..21c9c934 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt @@ -47,6 +47,9 @@ private typealias CallWeightsByType = Map> private const val LEAVE_SOME_SPACE_MULTIPLIER = 0.8 +// DIAGNOSTIC BUILD — remove with the probe in wrapInFakeExtrinsic. +private const val DIAG = "PezMsigDiag" + @FeatureScope internal class RealExtrinsicSplitter @Inject constructor( private val rpcCalls: RpcCalls, @@ -135,6 +138,19 @@ internal class RealExtrinsicSplitter @Inject constructor( return split } + /** + * DIAGNOSTIC BUILD — not for release. + * + * "Failed to encode extension CheckMortality" reproduces here, and only here, when + * approving a multisig operation on Pezkuwi Asset Hub. This path is multisig-only: + * asMulti needs a max_weight, so a throwaway signed extrinsic is built to measure + * the inner call. Plain transfers never reach it, which is why they succeed. + * + * Static reading could not tell which side of the isPezkuwiChain gate fails, so this + * build tries BOTH era encodings and reports the outcome of each. Read the PezMsigDiag + * lines to see which one the chain accepts, then wire that choice in permanently and + * delete this. + */ private suspend fun wrapInFakeExtrinsic( signer: NovaSigner, call: GenericCall.Instance, @@ -142,35 +158,69 @@ internal class RealExtrinsicSplitter @Inject constructor( chain: Chain ): SendableExtrinsic { val genesisHash = chain.requireGenesisHash().fromHex() + val isPezkuwi = chain.isPezkuwiChain - val builder = ExtrinsicBuilder( - runtime = runtime, - extrinsicVersion = ExtrinsicVersion.V4, - batchMode = BatchMode.BATCH, - ).apply { - // Use custom CheckMortality for Pezkuwi chains to avoid DictEnum type lookup issues. - // Gated on chain identity (not signed-extension presence): both Pezkuwi and Polkadot - // Asset Hub declare "AuthorizeCall", so that alone can't tell the chains apart, and - // PezkuwiCheckImmortal's raw DictEnum value fails Polkadot's own Era type codec. - if (chain.isPezkuwiChain) { - setTransactionExtension(PezkuwiCheckImmortal(genesisHash)) - } else { - setTransactionExtension(CheckMortality(Era.Immortal, genesisHash)) - } - setTransactionExtension(CheckGenesis(chain.requireGenesisHash().fromHex())) - setTransactionExtension(ChargeTransactionPayment(BigInteger.ZERO)) - setTransactionExtension(CheckMetadataHash(CheckMetadataHashMode.Disabled)) - setTransactionExtension(CheckSpecVersion(0)) - setTransactionExtension(CheckTxVersion(0)) + android.util.Log.e( + DIAG, + "chain='${chain.name}' id=${chain.id} isPezkuwiChain=$isPezkuwi " + + "genesis=${chain.requireGenesisHash()}" + ) + android.util.Log.e( + DIAG, + "signedExtensions=${runtime.metadata.extrinsic.signedExtensions.map { it.id }}" + ) - CustomTransactionExtensions.defaultValues(runtime).forEach(::setTransactionExtension) + // Builds the fake extrinsic with one specific era encoding. Kept as a local so the + // two attempts differ in exactly one thing and nothing else. + suspend fun attempt(usePezkuwiEra: Boolean): Result = runCatching { + ExtrinsicBuilder( + runtime = runtime, + extrinsicVersion = ExtrinsicVersion.V4, + batchMode = BatchMode.BATCH, + ).apply { + if (usePezkuwiEra) { + setTransactionExtension(PezkuwiCheckImmortal(genesisHash)) + } else { + setTransactionExtension(CheckMortality(Era.Immortal, genesisHash)) + } + setTransactionExtension(CheckGenesis(chain.requireGenesisHash().fromHex())) + setTransactionExtension(ChargeTransactionPayment(BigInteger.ZERO)) + setTransactionExtension(CheckMetadataHash(CheckMetadataHashMode.Disabled)) + setTransactionExtension(CheckSpecVersion(0)) + setTransactionExtension(CheckTxVersion(0)) - call(call) + CustomTransactionExtensions.defaultValues(runtime).forEach(::setTransactionExtension) - val signingContext = signingContextFactory.default(chain) - signer.setSignerDataForFee(signingContext) + call(call) + + val signingContext = signingContextFactory.default(chain) + signer.setSignerDataForFee(signingContext) + }.buildExtrinsic() } - return builder.buildExtrinsic() + fun report(label: String, result: Result) { + result.fold( + onSuccess = { android.util.Log.e(DIAG, "$label -> OK") }, + onFailure = { e -> + // The message alone has been the whole diagnosis so far; the cause chain + // is what actually names the failing type. + val causes = generateSequence(e) { it.cause }.joinToString(" <- ") { + "${it::class.java.simpleName}: ${it.message}" + } + android.util.Log.e(DIAG, "$label -> FAIL $causes", e) + } + ) + } + + // Preferred first: whatever the current gate would have chosen on its own. + val preferred = attempt(usePezkuwiEra = isPezkuwi) + report(if (isPezkuwi) "PezkuwiCheckImmortal(gate choice)" else "CheckMortality(gate choice)", preferred) + preferred.getOrNull()?.let { return it } + + val alternative = attempt(usePezkuwiEra = !isPezkuwi) + report(if (isPezkuwi) "CheckMortality(alternative)" else "PezkuwiCheckImmortal(alternative)", alternative) + + return alternative.getOrElse { throw preferred.exceptionOrNull()!! } } + } From 1034d09ea7d414d0657466a9b33c0bc163e887f1 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Mon, 3 Aug 2026 00:31:10 -0700 Subject: [PATCH 2/3] style: drop the blank line ktlint flags before the closing brace --- .../feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt index 21c9c934..a1e06566 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt @@ -222,5 +222,4 @@ internal class RealExtrinsicSplitter @Inject constructor( return alternative.getOrElse { throw preferred.exceptionOrNull()!! } } - } From 3d0ccf657a716e436df6359a5a50199d135dc957 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Mon, 3 Aug 2026 01:30:47 -0700 Subject: [PATCH 3/3] fix(multisig): approve operations on Pezkuwi chains again by dropping the custom era extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approving a multisig operation on Pezkuwi Asset Hub failed with "Failed to encode extension CheckMortality", so no signatory could approve from the wallet — the bridge treasury included. Initiating an operation worked, which made the break look narrower than it was. Only multisig reaches the failing code. asMulti needs a max_weight, so estimateCallWeight builds a throwaway signed extrinsic to measure the inner call. Plain transfers never build one, which is why sending HEZ kept working. PezkuwiCheckImmortal was the cause, not the cure. It passes a raw DictEnum.Entry("Immortal", null) as the era, and the SDK rejects it: EncodeDecodeException: Entry(name=Immortal, value=null) (Entry) is not a valid instance of Era (EraType) The premise it was written on — that Pezkuwi's pezsp_runtime Era breaks the standard codec — does not hold. Measured on device against Pezkuwi Asset Hub, the standard CheckMortality(Era.Immortal) encodes without complaint, and ExtrinsicBuilderFactory has been signing every ordinary transfer through it with a mortal era all along. The era type resolves from metadata by index, so the renamed module path never mattered. So the chain gate goes and both custom extensions go with it: PezkuwiCheckImmortal had one caller, PezkuwiCheckMortality had none. One path for every chain. Verified on device (Pezkuwi Asset Hub, versionCode 340): a build that tried both encodings logged the custom one failing and the standard one succeeding, then carried a real 3-of-5 approval through to MultisigExecuted with the inner call Ok and 10 HEZ leaving the multisig. --- .../data/extrinsic/ExtrinsicSplitter.kt | 91 ++++--------------- .../extensions/PezkuwiCheckImmortal.kt | 23 ----- .../extensions/PezkuwiCheckMortality.kt | 68 -------------- 3 files changed, 19 insertions(+), 163 deletions(-) delete mode 100644 runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckImmortal.kt delete mode 100644 runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt index a1e06566..b3c93aec 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt @@ -8,10 +8,8 @@ import io.novafoundation.nova.feature_account_api.data.signer.SigningContext import io.novafoundation.nova.common.utils.min import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter import io.novafoundation.nova.feature_account_api.data.extrinsic.SplitCalls -import io.novafoundation.nova.runtime.ext.isPezkuwiChain import io.novafoundation.nova.runtime.ext.requireGenesisHash import io.novafoundation.nova.runtime.extrinsic.CustomTransactionExtensions -import io.novafoundation.nova.runtime.extrinsic.extensions.PezkuwiCheckImmortal import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain @@ -47,9 +45,6 @@ private typealias CallWeightsByType = Map> private const val LEAVE_SOME_SPACE_MULTIPLIER = 0.8 -// DIAGNOSTIC BUILD — remove with the probe in wrapInFakeExtrinsic. -private const val DIAG = "PezMsigDiag" - @FeatureScope internal class RealExtrinsicSplitter @Inject constructor( private val rpcCalls: RpcCalls, @@ -139,17 +134,10 @@ internal class RealExtrinsicSplitter @Inject constructor( } /** - * DIAGNOSTIC BUILD — not for release. + * A throwaway signed extrinsic, built only to measure the weight of `call`. * - * "Failed to encode extension CheckMortality" reproduces here, and only here, when - * approving a multisig operation on Pezkuwi Asset Hub. This path is multisig-only: - * asMulti needs a max_weight, so a throwaway signed extrinsic is built to measure - * the inner call. Plain transfers never reach it, which is why they succeed. - * - * Static reading could not tell which side of the isPezkuwiChain gate fails, so this - * build tries BOTH era encodings and reports the outcome of each. Read the PezMsigDiag - * lines to see which one the chain accepts, then wire that choice in permanently and - * delete this. + * Immortal era: this extrinsic is never submitted, so there is nothing for a mortal + * era to protect, and an immortal one needs no block hash lookup. */ private suspend fun wrapInFakeExtrinsic( signer: NovaSigner, @@ -158,68 +146,27 @@ internal class RealExtrinsicSplitter @Inject constructor( chain: Chain ): SendableExtrinsic { val genesisHash = chain.requireGenesisHash().fromHex() - val isPezkuwi = chain.isPezkuwiChain - android.util.Log.e( - DIAG, - "chain='${chain.name}' id=${chain.id} isPezkuwiChain=$isPezkuwi " + - "genesis=${chain.requireGenesisHash()}" - ) - android.util.Log.e( - DIAG, - "signedExtensions=${runtime.metadata.extrinsic.signedExtensions.map { it.id }}" - ) + val builder = ExtrinsicBuilder( + runtime = runtime, + extrinsicVersion = ExtrinsicVersion.V4, + batchMode = BatchMode.BATCH, + ).apply { + setTransactionExtension(CheckMortality(Era.Immortal, genesisHash)) + setTransactionExtension(CheckGenesis(chain.requireGenesisHash().fromHex())) + setTransactionExtension(ChargeTransactionPayment(BigInteger.ZERO)) + setTransactionExtension(CheckMetadataHash(CheckMetadataHashMode.Disabled)) + setTransactionExtension(CheckSpecVersion(0)) + setTransactionExtension(CheckTxVersion(0)) - // Builds the fake extrinsic with one specific era encoding. Kept as a local so the - // two attempts differ in exactly one thing and nothing else. - suspend fun attempt(usePezkuwiEra: Boolean): Result = runCatching { - ExtrinsicBuilder( - runtime = runtime, - extrinsicVersion = ExtrinsicVersion.V4, - batchMode = BatchMode.BATCH, - ).apply { - if (usePezkuwiEra) { - setTransactionExtension(PezkuwiCheckImmortal(genesisHash)) - } else { - setTransactionExtension(CheckMortality(Era.Immortal, genesisHash)) - } - setTransactionExtension(CheckGenesis(chain.requireGenesisHash().fromHex())) - setTransactionExtension(ChargeTransactionPayment(BigInteger.ZERO)) - setTransactionExtension(CheckMetadataHash(CheckMetadataHashMode.Disabled)) - setTransactionExtension(CheckSpecVersion(0)) - setTransactionExtension(CheckTxVersion(0)) + CustomTransactionExtensions.defaultValues(runtime).forEach(::setTransactionExtension) - CustomTransactionExtensions.defaultValues(runtime).forEach(::setTransactionExtension) + call(call) - call(call) - - val signingContext = signingContextFactory.default(chain) - signer.setSignerDataForFee(signingContext) - }.buildExtrinsic() + val signingContext = signingContextFactory.default(chain) + signer.setSignerDataForFee(signingContext) } - fun report(label: String, result: Result) { - result.fold( - onSuccess = { android.util.Log.e(DIAG, "$label -> OK") }, - onFailure = { e -> - // The message alone has been the whole diagnosis so far; the cause chain - // is what actually names the failing type. - val causes = generateSequence(e) { it.cause }.joinToString(" <- ") { - "${it::class.java.simpleName}: ${it.message}" - } - android.util.Log.e(DIAG, "$label -> FAIL $causes", e) - } - ) - } - - // Preferred first: whatever the current gate would have chosen on its own. - val preferred = attempt(usePezkuwiEra = isPezkuwi) - report(if (isPezkuwi) "PezkuwiCheckImmortal(gate choice)" else "CheckMortality(gate choice)", preferred) - preferred.getOrNull()?.let { return it } - - val alternative = attempt(usePezkuwiEra = !isPezkuwi) - report(if (isPezkuwi) "CheckMortality(alternative)" else "PezkuwiCheckImmortal(alternative)", alternative) - - return alternative.getOrElse { throw preferred.exceptionOrNull()!! } + return builder.buildExtrinsic() } } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckImmortal.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckImmortal.kt deleted file mode 100644 index 1cb3872d..00000000 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckImmortal.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.novafoundation.nova.runtime.extrinsic.extensions - -import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum -import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension - -/** - * Custom CheckMortality extension for Pezkuwi chains using IMMORTAL era. - * - * Pezkuwi uses pezsp_runtime.generic.era.Era which is a DictEnum with variants: - * - Immortal (encoded as 0x00) - * - Mortal1(u8), Mortal2(u8), ..., Mortal255(u8) - * - * This extension uses Immortal era with genesis hash, which matches how @pezkuwi/api signs. - * - * @param genesisHash The chain's genesis hash (32 bytes) for the signer payload - */ -class PezkuwiCheckImmortal( - genesisHash: ByteArray -) : FixedValueTransactionExtension( - name = "CheckMortality", - implicit = genesisHash, // Genesis hash goes into signer payload for immortal transactions - explicit = DictEnum.Entry("Immortal", null) // Immortal variant - unit type with no value -) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt deleted file mode 100644 index 520fbe78..00000000 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt +++ /dev/null @@ -1,68 +0,0 @@ -package io.novafoundation.nova.runtime.extrinsic.extensions - -import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum -import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Era -import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension -import java.math.BigInteger - -/** - * Custom CheckMortality extension for Pezkuwi chains. - * - * Pezkuwi uses pezsp_runtime.generic.era.Era which is a DictEnum with variants: - * - Immortal - * - Mortal1(u8), Mortal2(u8), ..., Mortal255(u8) - * - * The variant name is "MortalX" where X is the first byte of the encoded era, - * and the variant's value is the second byte (u8). - * - * @param era The mortal era from MortalityConstructor - * @param blockHash The block hash (32 bytes) for the signer payload - */ -class PezkuwiCheckMortality( - era: Era.Mortal, - blockHash: ByteArray -) : FixedValueTransactionExtension( - name = "CheckMortality", - implicit = blockHash, // blockHash goes into signer payload - explicit = createEraEntry(era) // Era as DictEnum.Entry -) { - companion object { - /** - * Creates a DictEnum.Entry for the Era. - * - * Standard Era encoding produces 2 bytes: - * - First byte determines the variant name (Mortal1, Mortal2, ..., Mortal255) - * - Second byte is the variant's value (u8) - */ - private fun createEraEntry(era: Era.Mortal): DictEnum.Entry { - val period = era.period.toLong() - val phase = era.phase.toLong() - val quantizeFactor = maxOf(period shr 12, 1) - - // Calculate the two-byte encoding - val encoded = ((countTrailingZeroBits(period) - 1).coerceIn(1, 15)) or - ((phase / quantizeFactor).toInt() shl 4) - - val firstByte = encoded and 0xFF - val secondByte = (encoded shr 8) and 0xFF - - // DictEnum variant: "MortalX" where X is the first byte - // Variant value: second byte as u8 (BigInteger) - return DictEnum.Entry( - name = "Mortal$firstByte", - value = BigInteger.valueOf(secondByte.toLong()) - ) - } - - private fun countTrailingZeroBits(value: Long): Int { - if (value == 0L) return 64 - var n = 0 - var x = value - while ((x and 1L) == 0L) { - n++ - x = x shr 1 - } - return n - } - } -}