From 9a11dc2c3d9f8380208cd67e22277c815c12a337 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 17 Jul 2026 02:19:10 -0700 Subject: [PATCH] Fix crash reading MetaAccountSecrets for accounts predating Tron/Bitcoin/Solana Found on a real device: opening the backup/export screen for an existing wallet crashed with java.lang.IndexOutOfBoundsException: Cannot read 412 of 412 at .../scale/dataType/optional.read at .../scale/Schema.read (x3) at SecretStoreV2$getMetaAccountSecrets$2.invokeSuspend Root cause: MetaAccountSecrets is a positional (SCALE) binary schema. optional.read() unconditionally reads a presence-flag byte with no EOF handling - reading a blob that predates a trailing field (written before that field existed) throws instead of yielding null. This is also why Solana's own address was never getting backfilled: the migration's own read of the account's stored secrets was throwing on every attempted run, silently caught by its own per-account try/catch. Confirmed via substrate-sdk-android's actual source (Dsl.kt/Delegate.kt) that Tron/Bitcoin's identical schema-growth pattern carried the exact same latent risk - it likely only avoided crashing so far by chance byte-alignment in previously-stored blobs, not because the format tolerates it. Fix: read Tron/Bitcoin/Solana's keypair/derivationPath fields (every field added after the original Substrate+Ethereum schema) through a SafeOptional DataType that catches a failed read and returns null, via the library's own custom() field-declaration escape hatch. Write behavior is byte-for-byte unchanged - only reading a shorter/older blob now correctly yields null for fields it doesn't have, instead of crashing every caller of getMetaAccountSecrets (balance sync, signing, backup/export, this Solana backfill migration itself). --- .../data/secrets/v2/MetaAccountSecrets.kt | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt index 3acc1eaf..a72702c3 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt @@ -1,11 +1,18 @@ package io.novafoundation.nova.common.data.secrets.v2 +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import io.emeraldpay.polkaj.scale.ScaleCodecWriter import io.novafoundation.nova.common.utils.invoke import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519Keypair import io.novasama.substrate_sdk_android.scale.EncodableStruct import io.novasama.substrate_sdk_android.scale.Schema import io.novasama.substrate_sdk_android.scale.byteArray +import io.novasama.substrate_sdk_android.scale.custom +import io.novasama.substrate_sdk_android.scale.dataType.DataType +import io.novasama.substrate_sdk_android.scale.dataType.optional +import io.novasama.substrate_sdk_android.scale.dataType.scalable +import io.novasama.substrate_sdk_android.scale.dataType.string as stringDataType import io.novasama.substrate_sdk_android.scale.schema import io.novasama.substrate_sdk_android.scale.string @@ -16,6 +23,33 @@ object KeyPairSchema : Schema() { val Nonce by byteArray().optional() } +/** + * [io.novasama.substrate_sdk_android.scale.dataType.optional]'ın `read()`'i, arabellek (stored blob) bu alan + * eklenmeden ÖNCE yazılmış eski verilerde tükendiğinde - yani alan gerçekten "yok" demek istediğinde - zarifçe + * null dönmek yerine çöküyor (`reader.readBoolean()` EOF'ta istisna fırlatıyor, yakalama yok). Bu, Tron/Bitcoin + * eklenirken de aynı riskti; muhtemelen şans eseri (byte hizalaması tesadüfen tutmuş) hiç yüzeye çıkmamıştı - + * Solana'nın 2 yeni alanı arabelleği gerçekten aştırdı ve gerçek cihazda "Cannot read 412 of 412" ile çöktü + * (secrets okunan HER yerde: bakiye senkronu, backup ekranı, vs). + * + * Yazma tarafı [optional]'ın kendisiyle byte-byte aynı kalır - sadece okuma, bu alanın zaten taşıması gereken + * anlamı (eskiden hiç yazılmamışsa = yok = null) gerçekten sağlar, kütüphanenin eksik bıraktığı yerde. + */ +private class SafeOptional(private val dataType: DataType) : DataType() { + override fun read(reader: ScaleCodecReader): T? { + return try { + optional(dataType).read(reader) + } catch (e: Exception) { + null + } + } + + override fun write(writer: ScaleCodecWriter, value: T?) { + optional(dataType).write(writer, value) + } + + override fun conformsType(value: Any?) = value == null || dataType.conformsType(value) +} + object MetaAccountSecrets : Schema() { val Entropy by byteArray().optional() val SubstrateSeed by byteArray().optional() @@ -26,14 +60,18 @@ object MetaAccountSecrets : Schema() { val EthereumKeypair by schema(KeyPairSchema).optional() val EthereumDerivationPath by string().optional() - val TronKeypair by schema(KeyPairSchema).optional() - val TronDerivationPath by string().optional() + // Tron/Bitcoin/Solana are every chain-family added after the original Substrate+Ethereum schema - each is a + // trailing addition an old, pre-existing stored blob may simply not have any bytes for at all. custom() + // + SafeOptional (not the bare `.optional()` used above) makes reading such a blob correctly yield null for + // all of them, instead of crashing on the first one whose bytes don't exist - see SafeOptional's doc. + val TronKeypair by custom(SafeOptional(scalable(KeyPairSchema))) + val TronDerivationPath by custom(SafeOptional(stringDataType)) - val BitcoinKeypair by schema(KeyPairSchema).optional() - val BitcoinDerivationPath by string().optional() + val BitcoinKeypair by custom(SafeOptional(scalable(KeyPairSchema))) + val BitcoinDerivationPath by custom(SafeOptional(stringDataType)) - val SolanaKeypair by schema(KeyPairSchema).optional() - val SolanaDerivationPath by string().optional() + val SolanaKeypair by custom(SafeOptional(scalable(KeyPairSchema))) + val SolanaDerivationPath by custom(SafeOptional(stringDataType)) } object ChainAccountSecrets : Schema() {