Add native Solana support: address derivation, accounts, signing, backfill migration, CloudBackup wiring

Same read/send-capable chain-family pattern as Tron/Bitcoin, but for
Ed25519/SLIP-0010 instead of secp256k1/BIP32 (Solana's account id IS its
raw public key, so no separate address-hash derivation step is needed).

- SLIP-0010 Ed25519 keypair derivation (Bip32Ed25519KeypairFactory),
  cross-validated against an independent slip10 reference implementation
  and pinned to a known-good test vector.
- Chain.isSolanaBased / Chain.Asset.Type.SolanaNative wired through every
  chain-mapper, account-model, and signer dispatch site (mirrors the
  existing Tron/Bitcoin wiring exactly).
- SolanaAddressBackfillMigration derives Solana keys for wallets created
  before this support existed, same self-healing no-stuck-flag design as
  the Tron/Bitcoin backfills - also fixed a latent bug in those two
  migrations where MetaAccountSecrets/MetaAccountLocal.addXAccount()
  dropped sibling chain-family fields on a re-run.
- Room migration 75->76 adds isSolanaBased/solanaPublicKey/solanaAddress
  columns.
- CloudBackup's already-reserved solanaAddress/SolanaSecrets fields are
  now actually read/written by RealLocalAccountsCloudBackupFacade; also
  fixed isCompletelyEmpty() to check solana, so a Solana-only wallet
  doesn't get silently dropped from backup.
This commit is contained in:
2026-07-16 18:49:09 -07:00
parent 6fd7a56382
commit d35687f2ce
40 changed files with 861 additions and 24 deletions
@@ -25,7 +25,18 @@ suspend fun SecretStoreV2.getAccountSecrets(
fun AccountSecrets.keypair(chain: Chain): Keypair {
return fold(
left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) },
// `tron` was missing here already (found while adding `solana`) - without it, exporting a
// Tron account's private key from this call site would have silently returned the wrong
// (Ethereum) keypair instead of the Tron-specific one. Fixed alongside, not a separate change.
left = {
mapMetaAccountSecretsToKeypair(
it,
ethereum = chain.isEthereumBased,
tron = chain.isTronBased,
bitcoin = chain.isBitcoinBased,
solana = chain.isSolanaBased,
)
},
right = { mapChainAccountSecretsToKeypair(it) }
)
}
@@ -56,6 +56,10 @@ interface LightMetaAccount {
/** Bitcoin account id (HASH160 of the compressed pubkey - see [io.novafoundation.nova.common.utils.hash160]). */
val bitcoinAddress: ByteArray?
val bitcoinPublicKey: ByteArray?
/** Solana account id, which IS the raw Ed25519 public key itself - see SolanaAddress.kt's doc. */
val solanaAddress: ByteArray?
val solanaPublicKey: ByteArray?
val isSelected: Boolean
val name: String
val type: Type
@@ -96,6 +100,8 @@ fun LightMetaAccount(
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) = object : LightMetaAccount {
override val id: Long = id
override val globallyUniqueId: String = globallyUniqueId
@@ -108,6 +114,8 @@ fun LightMetaAccount(
override val tronPublicKey: ByteArray? = tronPublicKey
override val bitcoinAddress: ByteArray? = bitcoinAddress
override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey
override val solanaAddress: ByteArray? = solanaAddress
override val solanaPublicKey: ByteArray? = solanaPublicKey
override val isSelected: Boolean = isSelected
override val name: String = name
override val type: LightMetaAccount.Type = type