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<T>.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).
This commit is contained in:
2026-07-17 02:19:10 -07:00
parent db9962a00e
commit 9a11dc2c3d
@@ -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<KeyPairSchema>() {
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<T>(private val dataType: DataType<T>) : DataType<T?>() {
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<MetaAccountSecrets>() {
val Entropy by byteArray().optional()
val SubstrateSeed by byteArray().optional()
@@ -26,14 +60,18 @@ 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()
// 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<ChainAccountSecrets>() {