From 2fc4c8288c3a1dac2292377d30821f2cdfa159be Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 12:04:59 +0100 Subject: [PATCH 01/21] docs: add Solana roadmap --- docs/roadmap.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 739fd9fe52..d2a1d88170 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -94,6 +94,32 @@ Priority: P0 (must-do), P1 (should-do), P2 (nice-to-have) - Create `strings.xml` entries and a small mapper to user-friendly messages. - Replace TODOs with resource lookups. +7) Solana compatibility & memecoin tagging in wallet +- Why: Expand beyond Substrate/EVM by letting users add/manage a Solana account while keeping a single wallet UI; Solana SPL memecoins must be discoverable and visually distinct to reduce scams. +- Files: `runtime/.../chains/ChainRegistry.kt`, `feature-account-impl/.../AccountsViewModel.kt`, `feature-wallet-impl/.../WalletViewModel.kt`, asset metadata ingestion scripts. +- Acceptance: + - “Add account” entry points inside Substrate/EVM wallet tabs list Solana as a peer option and successfully derive/store a Solana keypair (ed25519/BIP44) in meta-accounts. + - Chain Registry exposes Solana under “All Networks” with RPC/WebSocket endpoints validated; balances load for SOL and at least three SPL tokens. + - Assets flagged as memecoins display a leading 🐸 emoji anywhere tickers appear (wallet list, search, send selector) without mislabeling non-meme tokens. + - No crashes for existing users without Solana data; migrations skip cleanly. +- Prompt: + 1) Extend chain metadata JSON + ChainRegistry parsing for Solana (chainId, symbol precision, RPC health checks) and add integration tests. + 2) Add Solana key derivation/signing utilities (libsodium/ed25519) and hook into `MetaAccount` creation/import flows plus backups. + 3) Merge Solana balances into wallet/all-networks viewmodels, gate UI by capability flag until RPC connectivity verified, and drive Compose/View adapters to show the 🐸 tag when `isMemecoin=true`. + 4) Seed token metadata source with SPL memecoins plus flag propagation, add UI unit tests ensuring emoji rendering, and document QA steps (create/import, list assets, send SOL/memecoin). + +8) Solana DeFi actions with XOR maintenance fee +- Why: Deliver parity DeFi features (swap/liquidity/staking) on Solana while funding maintenance via a transparent 0.5% fee routed through XOR, aligning with existing Polkaswap economics. +- Files: `feature-liquiditypools-impl/...`, `feature-wallet-impl/.../defi`, pricing/fee services, telemetry. +- Acceptance: + - Solana DeFi sheets list available pools/strategies; executing a transaction shows gross amount, the 0.5% maintenance fee converted to XOR, and the final net before submit. + - Fee engine automatically swaps the 0.5% share to XOR (via Polkaswap/Sora liquidity) and transfers it to the maintenance wallet within the same confirmation flow; failures block submission with actionable errors. + - Tests cover route quoting, fee math, and XOR transfer orchestration; analytics log each fee event. +- Prompt: + 1) Integrate a Solana aggregator SDK (e.g., Jupiter) behind existing DeFi abstractions and add mocks for unit tests. + 2) Build a fee service that calculates 0.5% of the Solana transaction value, performs (or schedules) the XOR conversion/transfer, and surfaces the maintenance destination for transparency. + 3) Update confirmation UI to surface fee line items, add telemetry/alerting for missing XOR settlements, and expand QA checklist (swap SOL↔SPL, provide liquidity, collect XOR fee evidence). + ## P1 — Medium Priority 6) Cleanup or implement `SocketSingleRequestExecutor` From 9a57b331e2d562f0543cab3effaf16fc5465a1b8 Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 13:56:31 +0100 Subject: [PATCH 02/21] feat: bootstrap solana chain metadata --- .../common/address/AddressIconGenerator.kt | 9 +- .../common/compose/component/AssetListItem.kt | 2 +- .../viewstate/AssetListItemViewState.kt | 9 +- .../soramitsu/common/model/WalletEcosystem.kt | 4 +- .../data/repository/AccountRepositoryImpl.kt | 4 + .../details/AccountDetailsViewModel.kt | 3 +- .../importing/source/view/JsonImportView.kt | 3 +- .../importing/source/view/SeedImportView.kt | 3 +- .../OptionsEcosystemAccountsViewModel.kt | 2 + .../view/advanced/AdvancedBlockView.kt | 3 +- .../wallet/impl/domain/ChainInteractor.kt | 8 +- .../impl/domain/model/MemecoinTagger.kt | 13 ++ .../list/model/BalanceListItemModel.kt | 4 +- .../impl/domain/model/MemecoinTaggerTest.kt | 24 +++ .../chain/solana/SolanaChainDefinition.kt | 162 ++++++++++++++++++ 15 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/model/MemecoinTagger.kt create mode 100644 feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/domain/model/MemecoinTaggerTest.kt create mode 100644 runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaChainDefinition.kt diff --git a/common/src/main/java/jp/co/soramitsu/common/address/AddressIconGenerator.kt b/common/src/main/java/jp/co/soramitsu/common/address/AddressIconGenerator.kt index 9709a44e3a..ae3a7e1609 100644 --- a/common/src/main/java/jp/co/soramitsu/common/address/AddressIconGenerator.kt +++ b/common/src/main/java/jp/co/soramitsu/common/address/AddressIconGenerator.kt @@ -103,7 +103,8 @@ suspend fun AddressIconGenerator.createAddressIcon(supportedEcosystemWithAddress val address = supportedEcosystemWithAddress.toList().sortedBy { when (it.first) { WalletEcosystem.Substrate -> 1 - WalletEcosystem.Ethereum -> 2 + WalletEcosystem.Ethereum, + WalletEcosystem.Solana -> 2 WalletEcosystem.Ton -> 3 } }[0].second @@ -122,7 +123,8 @@ suspend fun AddressIconGenerator.createAddressModel(supportedEcosystemWithAddres val address = supportedEcosystemWithAddress.toList().sortedBy { when (it.first) { WalletEcosystem.Substrate -> 1 - WalletEcosystem.Ethereum -> 2 + WalletEcosystem.Ethereum, + WalletEcosystem.Solana -> 2 WalletEcosystem.Ton -> 3 } }[0].second @@ -214,7 +216,8 @@ class StatelessAddressIconGenerator( val sizeInPx = resourceManager.measureInPx(sizeInDp) val icon = when (ecosystem) { WalletEcosystem.Substrate -> iconGenerator.getSubstrateWalletIcon(sizeInPx) - WalletEcosystem.Ethereum -> iconGenerator.getEvmWalletIcon(sizeInPx) + WalletEcosystem.Ethereum, + WalletEcosystem.Solana -> iconGenerator.getEvmWalletIcon(sizeInPx) WalletEcosystem.Ton -> iconGenerator.getTonWalletIcon(sizeInPx) } icon diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/AssetListItem.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/AssetListItem.kt index a639b1e327..ac6b76ab38 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/AssetListItem.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/AssetListItem.kt @@ -126,7 +126,7 @@ fun AssetListItem( } Row { Text( - text = state.assetSymbol.uppercase(), + text = state.decoratedSymbol, style = MaterialTheme.customTypography.header3, modifier = Modifier .padding(vertical = 4.dp) diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/viewstate/AssetListItemViewState.kt b/common/src/main/java/jp/co/soramitsu/common/compose/viewstate/AssetListItemViewState.kt index f1ee3f5c66..b97bfec9b1 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/viewstate/AssetListItemViewState.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/viewstate/AssetListItemViewState.kt @@ -15,7 +15,14 @@ data class AssetListItemViewState( val chainAssetId: String, val isSupported: Boolean, val isHidden: Boolean, - val isTestnet: Boolean + val isTestnet: Boolean, + val isMemecoin: Boolean = false ) { val key = listOf(index ?: 0, chainAssetId, chainId, isHidden).joinToString() + + val decoratedSymbol: String + get() = buildString { + if (isMemecoin) append("\uD83D\uDC38 ") + append(assetSymbol.uppercase()) + } } diff --git a/common/src/main/java/jp/co/soramitsu/common/model/WalletEcosystem.kt b/common/src/main/java/jp/co/soramitsu/common/model/WalletEcosystem.kt index dd7534eb8f..dfcb9f0865 100644 --- a/common/src/main/java/jp/co/soramitsu/common/model/WalletEcosystem.kt +++ b/common/src/main/java/jp/co/soramitsu/common/model/WalletEcosystem.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.common.model import jp.co.soramitsu.core.models.Ecosystem enum class WalletEcosystem { - Substrate, Ethereum, Ton + Substrate, Ethereum, Ton, Solana } fun Ecosystem.toAccountType() = when (this) { @@ -11,4 +11,4 @@ fun Ecosystem.toAccountType() = when (this) { Ecosystem.Ton -> WalletEcosystem.Ton Ecosystem.EthereumBased, Ecosystem.Ethereum -> WalletEcosystem.Ethereum -} \ No newline at end of file +} diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt index 3a4ba66776..638f886a00 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt @@ -612,6 +612,10 @@ class AccountRepositoryImpl( return BackupAccountType.PASSPHRASE } } + + WalletEcosystem.Solana -> { + // Solana backups will be handled once dedicated secret storage is available. + } } return null } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt index 63f8b6de8f..fec8b34dda 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt @@ -121,7 +121,8 @@ class AccountDetailsViewModel @Inject constructor( private fun mapTypeToTitle(type: WalletEcosystem): String { val resId = when (type) { WalletEcosystem.Substrate -> R.string.connected_accounts_substrate_title - WalletEcosystem.Ethereum -> R.string.connected_accounts_ethereum_title + WalletEcosystem.Ethereum, + WalletEcosystem.Solana -> R.string.connected_accounts_ethereum_title WalletEcosystem.Ton -> R.string.connected_accounts_ton_title } return resourceManager.getString(resId) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/JsonImportView.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/JsonImportView.kt index bec43d6b6f..604254e3f9 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/JsonImportView.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/JsonImportView.kt @@ -44,7 +44,8 @@ class JsonImportView @JvmOverloads constructor( private fun setImportAccountType(type: WalletEcosystem) { when (type) { WalletEcosystem.Substrate -> binding.importJsonContent.setLabel(R.string.import_substrate_recovery) - WalletEcosystem.Ethereum -> binding.importJsonContent.setLabel(R.string.import_ethereum_recovery) + WalletEcosystem.Ethereum, + WalletEcosystem.Solana -> binding.importJsonContent.setLabel(R.string.import_ethereum_recovery) WalletEcosystem.Ton -> { /* not implemented */ } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/SeedImportView.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/SeedImportView.kt index 6408857a65..9ca6b2704b 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/SeedImportView.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/SeedImportView.kt @@ -43,7 +43,8 @@ class SeedImportView @JvmOverloads constructor( private fun setImportAccountType(type: WalletEcosystem) { when (type) { WalletEcosystem.Substrate -> binding.importSeedTitle.setText(R.string.account_import_substrate_raw_seed_placeholder) - WalletEcosystem.Ethereum -> binding.importSeedTitle.setText(R.string.account_import_ethereum_raw_seed_placeholder) + WalletEcosystem.Ethereum, + WalletEcosystem.Solana -> binding.importSeedTitle.setText(R.string.account_import_ethereum_raw_seed_placeholder) WalletEcosystem.Ton -> { /* not applicable */ } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_ecosystem_accounts/OptionsEcosystemAccountsViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_ecosystem_accounts/OptionsEcosystemAccountsViewModel.kt index 6ee93d7a5c..a4c99192be 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_ecosystem_accounts/OptionsEcosystemAccountsViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_ecosystem_accounts/OptionsEcosystemAccountsViewModel.kt @@ -14,6 +14,7 @@ import jp.co.soramitsu.feature_account_impl.R import jp.co.soramitsu.runtime.multiNetwork.chain.model.ethereumChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.tonChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn @@ -53,6 +54,7 @@ class OptionsEcosystemAccountsViewModel @Inject constructor( WalletEcosystem.Substrate -> polkadotChainId WalletEcosystem.Ethereum -> ethereumChainId WalletEcosystem.Ton -> tonChainId + WalletEcosystem.Solana -> SolanaChainDefinition.CHAIN_ID } when (it) { BackupAccountType.PASSPHRASE -> { diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/view/advanced/AdvancedBlockView.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/view/advanced/AdvancedBlockView.kt index 667a2f79cf..014561f96d 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/view/advanced/AdvancedBlockView.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/view/advanced/AdvancedBlockView.kt @@ -151,7 +151,8 @@ class AdvancedBlockView @JvmOverloads constructor( configureSubstrate(FieldState.NORMAL) configureEthereum(FieldState.HIDDEN) } - WalletEcosystem.Ethereum -> { + WalletEcosystem.Ethereum, + WalletEcosystem.Solana -> { configure(FieldState.HIDDEN) configure(ethereumEncryptionTypeField, FieldState.DISABLED) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/ChainInteractor.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/ChainInteractor.kt index 0d2209c651..29b42a9722 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/ChainInteractor.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/ChainInteractor.kt @@ -9,6 +9,7 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.mapChainLocalToChain import jp.co.soramitsu.runtime.multiNetwork.chain.mapToPriceProvider import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition import jp.co.soramitsu.wallet.api.domain.model.XcmChainType import jp.co.soramitsu.xcm.domain.XcmEntitiesFetcher import kotlinx.coroutines.Dispatchers @@ -21,8 +22,11 @@ class ChainInteractor( private val chainDao: ChainDao, private val xcmEntitiesFetcher: XcmEntitiesFetcher ) { - fun getChainsFlow() = chainDao.joinChainInfoFlow().mapList { mapChainLocalToChain(it) }.map { - it.sortedWith(chainDefaultSort()) + fun getChainsFlow() = chainDao.joinChainInfoFlow().mapList { mapChainLocalToChain(it) }.map { chains -> + val hasSolana = chains.any { it.id == SolanaChainDefinition.CHAIN_ID } + val augmented = if (hasSolana) chains else chains + SolanaChainDefinition.chain + + augmented.sortedWith(chainDefaultSort()) } suspend fun getChainAssets(): List { diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/model/MemecoinTagger.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/model/MemecoinTagger.kt new file mode 100644 index 0000000000..a3b23ec876 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/model/MemecoinTagger.kt @@ -0,0 +1,13 @@ +package jp.co.soramitsu.wallet.impl.domain.model + +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition + +object MemecoinTagger { + + fun isMemecoin(chainId: ChainId, chainAssetId: String): Boolean { + if (chainId != SolanaChainDefinition.CHAIN_ID) return false + + return chainAssetId in SolanaChainDefinition.memecoinAssetIds + } +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/model/BalanceListItemModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/model/BalanceListItemModel.kt index 2eca530c3f..45da2e5cb5 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/model/BalanceListItemModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/model/BalanceListItemModel.kt @@ -8,6 +8,7 @@ import jp.co.soramitsu.common.utils.formatFiat import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import jp.co.soramitsu.wallet.impl.domain.model.MemecoinTagger import jp.co.soramitsu.wallet.impl.domain.model.Token data class BalanceListItemModel( @@ -35,5 +36,6 @@ fun BalanceListItemModel.toAssetState(index: Int? = null) = AssetListItemViewSta chainAssetId = asset.id, isSupported = chain?.isSupported != false, isHidden = isHidden, - isTestnet = chain?.isTestNet == true + isTestnet = chain?.isTestNet == true, + isMemecoin = chain?.id?.let { MemecoinTagger.isMemecoin(it, asset.id) } == true ) diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/domain/model/MemecoinTaggerTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/domain/model/MemecoinTaggerTest.kt new file mode 100644 index 0000000000..6a875acbb3 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/domain/model/MemecoinTaggerTest.kt @@ -0,0 +1,24 @@ +package jp.co.soramitsu.wallet.impl.domain.model + +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MemecoinTaggerTest { + + @Test + fun `marks listed solana assets as memecoins`() { + assertTrue(MemecoinTagger.isMemecoin(SolanaChainDefinition.CHAIN_ID, "BONK")) + } + + @Test + fun `does not mark sol as memecoin`() { + assertFalse(MemecoinTagger.isMemecoin(SolanaChainDefinition.CHAIN_ID, "SOL")) + } + + @Test + fun `ignores other chains`() { + assertFalse(MemecoinTagger.isMemecoin("polkadot", "BONK")) + } +} diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaChainDefinition.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaChainDefinition.kt new file mode 100644 index 0000000000..564a32cc55 --- /dev/null +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaChainDefinition.kt @@ -0,0 +1,162 @@ +package jp.co.soramitsu.runtime.multiNetwork.chain.solana + +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.Asset.StakingType +import jp.co.soramitsu.core.models.ChainNode +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId + +object SolanaChainDefinition { + + const val CHAIN_ID: ChainId = "solana-mainnet" + private const val ICON_URL = "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/solana/info/logo.png" + private const val EXPLORER_URL = "https://solscan.io/" + + private val nodes = listOf( + ChainNode( + url = "https://rpc.ankr.com/solana", + name = "Ankr RPC", + isActive = true, + isDefault = true + ), + ChainNode( + url = "https://api.mainnet-beta.solana.com", + name = "Solana Labs RPC", + isActive = false, + isDefault = true + ) + ) + + private val baseAsset = Asset( + id = "SOL", + name = "Solana", + symbol = "SOL", + iconUrl = ICON_URL, + chainId = CHAIN_ID, + priceId = "solana", + precision = 9, + staking = StakingType.UNSUPPORTED, + purchaseProviders = emptyList(), + chainName = "Solana", + chainIcon = ICON_URL, + isTestNet = false, + supportStakingPool = false, + isUtility = true, + type = null, + currencyId = null, + existentialDeposit = "0.0001", + color = "#9945FF", + isNative = true, + priceProvider = null, + coinbaseUrl = null + ) + + private val memecoinAssets = listOf( + Asset( + id = "BONK", + name = "Bonk", + symbol = "BONK", + iconUrl = "https://assets.coingecko.com/coins/images/28566/small/bonk.jpg", + chainId = CHAIN_ID, + priceId = "bonk", + precision = 5, + staking = StakingType.UNSUPPORTED, + purchaseProviders = emptyList(), + chainName = "Solana", + chainIcon = ICON_URL, + isTestNet = false, + supportStakingPool = false, + isUtility = false, + type = null, + currencyId = null, + existentialDeposit = null, + color = "#F7931A", + isNative = false, + priceProvider = null, + coinbaseUrl = null + ), + Asset( + id = "WIF", + name = "dogwifhat", + symbol = "WIF", + iconUrl = "https://assets.coingecko.com/coins/images/34289/small/wif.png", + chainId = CHAIN_ID, + priceId = "dogwifhat", + precision = 6, + staking = StakingType.UNSUPPORTED, + purchaseProviders = emptyList(), + chainName = "Solana", + chainIcon = ICON_URL, + isTestNet = false, + supportStakingPool = false, + isUtility = false, + type = null, + currencyId = null, + existentialDeposit = null, + color = "#FFC107", + isNative = false, + priceProvider = null, + coinbaseUrl = null + ), + Asset( + id = "PEPE", + name = "Pepe Solana", + symbol = "PEPE", + iconUrl = "https://assets.coingecko.com/coins/images/29850/small/pepe.jpg", + chainId = CHAIN_ID, + priceId = "pepe", + precision = 6, + staking = StakingType.UNSUPPORTED, + purchaseProviders = emptyList(), + chainName = "Solana", + chainIcon = ICON_URL, + isTestNet = false, + supportStakingPool = false, + isUtility = false, + type = null, + currencyId = null, + existentialDeposit = null, + color = "#00A86B", + isNative = false, + priceProvider = null, + coinbaseUrl = null + ) + ) + + val memecoinAssetIds = memecoinAssets.map { it.id }.toSet() + + val chain: Chain = Chain( + id = CHAIN_ID, + paraId = null, + rank = 25, + name = "Solana", + minSupportedVersion = null, + assets = listOf(baseAsset) + memecoinAssets, + nodes = nodes, + explorers = listOf( + Chain.Explorer( + type = Chain.Explorer.Type.UNKNOWN, + types = listOf("account", "transaction"), + url = EXPLORER_URL + ) + ), + externalApi = null, + icon = ICON_URL, + addressPrefix = 0, + isEthereumBased = false, + isTestNet = false, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = true, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Ethereum, + androidMinAppVersion = null, + remoteAssetsSource = null, + tonBridgeUrl = null + ) +} From b032dc21cf08148e580d9c9c57ac9860fa89db57 Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 14:50:47 +0100 Subject: [PATCH 03/21] feat: add Solana account foundation --- common/build.gradle | 1 + .../data/secrets/v3/SolanaSecretStore.kt | 41 +++++++++ .../common/di/modules/CommonModule.kt | 7 ++ .../common/domain/SolanaConstants.kt | 4 + .../jp/co/soramitsu/common/utils/Base58Ext.kt | 2 + .../common/utils/solana/SolanaKeyFactory.kt | 92 +++++++++++++++++++ common/src/main/res/values/strings.xml | 3 +- .../utils/solana/SolanaKeyFactoryTest.kt | 27 ++++++ .../coredb/dao/MetaAccountDaoTest.kt | 9 +- .../coredb/migrations/V2MigrationTest.kt | 3 +- .../jp/co/soramitsu/coredb/AppDatabase.kt | 4 +- .../soramitsu/coredb/migrations/Migrations.kt | 6 ++ .../coredb/migrations/V2Migration.kt | 1 + .../coredb/model/MetaAccountLocal.kt | 6 +- .../domain/interfaces/AccountRepository.kt | 2 + .../api/domain/model/AddAccountPayload.kt | 4 +- .../account/api/domain/model/MetaAccount.kt | 36 ++++++-- .../account/impl/data/mappers/Mappers.kt | 4 +- .../repository/AccountRepositoryDelegate.kt | 29 +++++- .../data/repository/AccountRepositoryImpl.kt | 22 ++++- .../impl/data/repository/KeyPairRepository.kt | 11 ++- .../account/impl/di/AccountFeatureModule.kt | 13 ++- .../impl/domain/AccountInteractorImpl.kt | 16 +++- .../details/AccountDetailsInteractor.kt | 37 +++++--- .../account/create/CreateAccountViewModel.kt | 4 +- .../details/AccountDetailsViewModel.kt | 4 +- .../backup_wallet/BackupWalletViewModel.kt | 2 +- .../backup/BackupMnemonicViewModel.kt | 4 +- .../confirm/ConfirmMnemonicPayload.kt | 4 +- .../confirm/ConfirmMnemonicViewModel.kt | 6 +- .../balance/list/BalanceListViewModel.kt | 8 +- gradle/libs.versions.toml | 2 + .../chain/solana/SolanaChainDefinition.kt | 3 +- 33 files changed, 363 insertions(+), 54 deletions(-) create mode 100644 common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SolanaSecretStore.kt create mode 100644 common/src/main/java/jp/co/soramitsu/common/domain/SolanaConstants.kt create mode 100644 common/src/main/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactory.kt create mode 100644 common/src/test/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactoryTest.kt diff --git a/common/build.gradle b/common/build.gradle index 73444da142..e80c165bfe 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -141,6 +141,7 @@ dependencies { implementation libs.zxing.embedded implementation libs.bundles.ton + implementation libs.solanaj implementation libs.progressbutton implementation libs.nv.websocket.client diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SolanaSecretStore.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SolanaSecretStore.kt new file mode 100644 index 0000000000..b2817f7dea --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SolanaSecretStore.kt @@ -0,0 +1,41 @@ +package jp.co.soramitsu.common.data.secrets.v3 + +import jp.co.soramitsu.common.data.storage.encrypt.EncryptedPreferences +import jp.co.soramitsu.common.utils.invoke +import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.shared_utils.scale.EncodableStruct +import jp.co.soramitsu.shared_utils.scale.Schema +import jp.co.soramitsu.shared_utils.scale.byteArray +import jp.co.soramitsu.shared_utils.scale.string +import jp.co.soramitsu.shared_utils.scale.toHexString + +private const val SOLANA_SECRETS = "SOLANA_SECRETS" + +class SolanaSecretStore(private val encryptedPreferences: EncryptedPreferences) : SecretStore { + override fun put(metaId: Long, secrets: EncodableStruct) { + encryptedPreferences.putEncryptedString("$metaId:$SOLANA_SECRETS", secrets.toHexString()) + } + + override fun get(metaId: Long): EncodableStruct? { + return encryptedPreferences.getDecryptedString("$metaId:$SOLANA_SECRETS") + ?.let(SolanaSecrets::read) + } +} + +object SolanaSecrets : Schema() { + val Seed by byteArray() + val PrivateKey by byteArray() + val PublicKey by byteArray() + val DerivationPath by string() +} + +fun SolanaSecrets( + seed: ByteArray, + solanaKeypair: Keypair, + derivationPath: String +): EncodableStruct = SolanaSecrets { secrets -> + secrets[Seed] = seed + secrets[PrivateKey] = solanaKeypair.privateKey + secrets[PublicKey] = solanaKeypair.publicKey + secrets[DerivationPath] = derivationPath +} diff --git a/common/src/main/java/jp/co/soramitsu/common/di/modules/CommonModule.kt b/common/src/main/java/jp/co/soramitsu/common/di/modules/CommonModule.kt index c61b0b92e3..6a2dd61add 100644 --- a/common/src/main/java/jp/co/soramitsu/common/di/modules/CommonModule.kt +++ b/common/src/main/java/jp/co/soramitsu/common/di/modules/CommonModule.kt @@ -22,6 +22,7 @@ import jp.co.soramitsu.common.data.secrets.v1.SecretStoreV1 import jp.co.soramitsu.common.data.secrets.v1.SecretStoreV1Impl import jp.co.soramitsu.common.data.secrets.v2.SecretStoreV2 import jp.co.soramitsu.common.data.secrets.v3.EthereumSecretStore +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecretStore import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecretStore import jp.co.soramitsu.common.data.secrets.v3.TonSecretStore import jp.co.soramitsu.common.data.storage.Preferences @@ -236,6 +237,12 @@ class CommonModule { encryptedPreferences: EncryptedPreferences ) = TonSecretStore(encryptedPreferences) + @Provides + @Singleton + fun provideSolanaSecretStore( + encryptedPreferences: EncryptedPreferences + ) = SolanaSecretStore(encryptedPreferences) + @Provides @Singleton fun provideNetworkStateMixin() = NetworkStateService() diff --git a/common/src/main/java/jp/co/soramitsu/common/domain/SolanaConstants.kt b/common/src/main/java/jp/co/soramitsu/common/domain/SolanaConstants.kt new file mode 100644 index 0000000000..4b4e7ad7a9 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/domain/SolanaConstants.kt @@ -0,0 +1,4 @@ +package jp.co.soramitsu.common.domain + +const val SOLANA_CHAIN_ID = "solana-mainnet" +const val SOLANA_DEFAULT_PATH = "m/44'/501'/0'/0'" diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt b/common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt index 34a048cf15..56843f9f2a 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt @@ -8,4 +8,6 @@ object Base58Ext { private val base58 = Base58() fun String.fromBase58Check() = base58.decodeChecked(this) + + fun ByteArray.toBase58(): String = base58.encode(this) } diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactory.kt b/common/src/main/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactory.kt new file mode 100644 index 0000000000..94668b2869 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactory.kt @@ -0,0 +1,92 @@ +package jp.co.soramitsu.common.utils.solana + +import jp.co.soramitsu.common.domain.SOLANA_DEFAULT_PATH +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import org.ton.api.pk.PrivateKeyEd25519 +import org.ton.mnemonic.Mnemonic + +data class SolanaKeypair( + val privateKey: ByteArray, + val publicKey: ByteArray +) + +object SolanaKeyFactory { + + private const val HARDENED_BIT = 0x80000000.toInt() + private val ED25519_SEED = "ed25519 seed".toByteArray() + + fun deriveKeypair( + mnemonicWords: List, + derivationPath: String = SOLANA_DEFAULT_PATH + ): SolanaKeypair { + val seed = Mnemonic.toSeed(mnemonicWords) + val master = slip10Master(seed) + val indices = parsePath(derivationPath) + + val finalNode = indices.fold(master) { current, index -> + deriveChild(current, index) + } + + val privateKey = finalNode.key + val keypair = PrivateKeyEd25519(privateKey) + val publicKey = keypair.publicKey().key.toByteArray() + + return SolanaKeypair( + privateKey = privateKey, + publicKey = publicKey + ) + } + + private fun slip10Master(seed: ByteArray): Slip10Node { + val digest = hmacSha512(ED25519_SEED, seed) + val key = digest.copyOfRange(0, 32) + val chainCode = digest.copyOfRange(32, 64) + return Slip10Node(key, chainCode) + } + + private fun deriveChild(parent: Slip10Node, index: Int): Slip10Node { + val indexBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(index).array() + val data = ByteArray(1 + parent.key.size + 4) + data[0] = 0 + parent.key.copyInto(data, destinationOffset = 1) + indexBytes.copyInto(data, destinationOffset = 1 + parent.key.size) + + val digest = hmacSha512(parent.chainCode, data) + val key = digest.copyOfRange(0, 32) + val chainCode = digest.copyOfRange(32, 64) + + return Slip10Node(key, chainCode) + } + + private fun parsePath(path: String): List { + val raw = path.removePrefix("m/") + if (raw.isEmpty()) return emptyList() + + return raw.split("/") + .filter { it.isNotEmpty() } + .map { component -> + val hardened = component.endsWith("'") + val number = component.removeSuffix("'").toInt() + if (hardened) { + number or HARDENED_BIT + } else { + number + } + } + } + + private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA512") + val secretKeySpec = SecretKeySpec(key, "HmacSHA512") + mac.init(secretKeySpec) + return mac.doFinal(data) + } + + private data class Slip10Node( + val key: ByteArray, + val chainCode: ByteArray + ) +} diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index e9d3accc4d..03d4dc2c63 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -357,6 +357,7 @@ Connected Accounts EVM chain accounts Substrate chain accounts + Solana accounts TON chain accounts Cannot proceed: Your wallet does not have the necessary accounts. Please make sure you have the required accounts set up before continuing The node has been added previously. Please try another node. @@ -1248,4 +1249,4 @@ What accounts in the wallet do you want to export? Yesterday Your collator - \ No newline at end of file + diff --git a/common/src/test/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactoryTest.kt b/common/src/test/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactoryTest.kt new file mode 100644 index 0000000000..fdce175728 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactoryTest.kt @@ -0,0 +1,27 @@ +package jp.co.soramitsu.common.utils.solana + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SolanaKeyFactoryTest { + + @Test + fun `should derive deterministic keypair`() { + val mnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow".split(" ") + + val solanaKeypair = SolanaKeyFactory.deriveKeypair(mnemonic) + + assertEquals(32, solanaKeypair.privateKey.size) + assertEquals(32, solanaKeypair.publicKey.size) + + val defaultKeypair = SolanaKeyFactory.deriveKeypair(mnemonic) + val differentAccountKeypair = SolanaKeyFactory.deriveKeypair( + mnemonicWords = mnemonic, + derivationPath = "m/44'/501'/1'/0'" + ) + + assertEquals(defaultKeypair.publicKey.toList(), solanaKeypair.publicKey.toList()) + assertNotEquals(defaultKeypair.publicKey.toList(), differentAccountKeypair.publicKey.toList()) + } +} diff --git a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/MetaAccountDaoTest.kt b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/MetaAccountDaoTest.kt index c50d38c2ca..99dba31585 100644 --- a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/MetaAccountDaoTest.kt +++ b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/MetaAccountDaoTest.kt @@ -44,17 +44,18 @@ class MetaAccountDaoTest : DaoTest(AppDatabase::metaAccountDao) } private fun testMetaAccount() = MetaAccountLocal( - tonPublicKey = null, substratePublicKey = byteArrayOf(), substrateCryptoType = CryptoType.SR25519, + substrateAccountId = byteArrayOf(), ethereumPublicKey = null, + ethereumAddress = null, + tonPublicKey = null, + solanaPublicKey = null, name = "Test", isSelected = false, - substrateAccountId = byteArrayOf(), - ethereumAddress = null, position = 0, - googleBackupAddress = null, isBackedUp = false, + googleBackupAddress = null, initialized = false ) } diff --git a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/migrations/V2MigrationTest.kt b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/migrations/V2MigrationTest.kt index 781bb3c90a..eb26c32b80 100644 --- a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/migrations/V2MigrationTest.kt +++ b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/migrations/V2MigrationTest.kt @@ -233,12 +233,13 @@ class V2MigrationTest { return cursor.map { val metaAccount = MetaAccountLocal( - substratePublicKey= getBlob(getColumnIndex(Column.SUBSTRATE_PUBKEY)), + substratePublicKey = getBlob(getColumnIndex(Column.SUBSTRATE_PUBKEY)), substrateCryptoType = CryptoType.valueOf(getString(getColumnIndex(Column.SUBSTRATE_CRYPTO_TYPE))), substrateAccountId = getBlob(getColumnIndex(Column.SUBSTRATE_ACCOUNT_ID)), ethereumPublicKey = getBlob(getColumnIndex(Column.ETHEREUM_PUBKEY)), ethereumAddress = getBlob(getColumnIndex(Column.ETHEREUM_ADDRESS)), tonPublicKey = null, + solanaPublicKey = null, name = getString(getColumnIndex(Column.NAME)), isSelected = getInt(getColumnIndex(Column.IS_SELECTED)) == 1, position = getInt(getColumnIndex(Column.POSITION)), diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt index 9c2fef84d6..8cee6bc5c1 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt @@ -81,6 +81,7 @@ import jp.co.soramitsu.coredb.migrations.Migration_69_70 import jp.co.soramitsu.coredb.migrations.Migration_70_71 import jp.co.soramitsu.coredb.migrations.Migration_72_73 import jp.co.soramitsu.coredb.migrations.Migration_73_74 +import jp.co.soramitsu.coredb.migrations.Migration_74_75 import jp.co.soramitsu.coredb.migrations.RemoveAccountForeignKeyFromAsset_17_18 import jp.co.soramitsu.coredb.migrations.RemoveLegacyData_35_36 import jp.co.soramitsu.coredb.migrations.RemoveStakingRewardsTable_22_23 @@ -110,7 +111,7 @@ import jp.co.soramitsu.coredb.model.chain.ChainTypesLocal import jp.co.soramitsu.coredb.model.chain.FavoriteChainLocal @Database( - version = 74, + version = 75, entities = [ AddressBookContact::class, AssetLocal::class, @@ -211,6 +212,7 @@ abstract class AppDatabase : RoomDatabase() { .addMigrations(TonMigration(storeV2, substrateSecretStore, ethereumSecretStore, encryptedPreferences)) .addMigrations(Migration_72_73) .addMigrations(Migration_73_74) + .addMigrations(Migration_74_75) .build() } return instance!! diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt index 59d912228f..9284620d56 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt @@ -3,6 +3,12 @@ package jp.co.soramitsu.coredb.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +val Migration_74_75 = object : Migration(74, 75) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE meta_accounts ADD COLUMN `solanaPublicKey` BLOB NULL DEFAULT NULL") + } +} + val Migration_73_74 = object : Migration(73, 74) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE chain_assets ADD COLUMN `coinbaseUrl` TEXT NULL DEFAULT NULL") diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt index 2c98bbb3e9..e310fa72c1 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt @@ -85,6 +85,7 @@ class V2Migration( ethereumPublicKey = ethereumKeypair?.publicKey, ethereumAddress = ethereumKeypair?.publicKey?.ethereumAddressFromPublicKey(), tonPublicKey = null, + solanaPublicKey = null, name = account.name, isSelected = isSelected, position = index, diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt index f6fac5794b..14aa013f7f 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt @@ -6,6 +6,7 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation +import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.models.IChain @@ -17,7 +18,8 @@ import jp.co.soramitsu.coredb.model.chain.FavoriteChainLocal indices = [ Index(value = ["substrateAccountId"]), Index(value = ["ethereumAddress"]), - Index(value = ["tonPublicKey"]) + Index(value = ["tonPublicKey"]), + Index(value = ["solanaPublicKey"]) ] ) class MetaAccountLocal( @@ -27,6 +29,7 @@ class MetaAccountLocal( val ethereumPublicKey: ByteArray?, val ethereumAddress: ByteArray?, val tonPublicKey: ByteArray?, + val solanaPublicKey: ByteArray?, val name: String, val isSelected: Boolean, val position: Int, @@ -119,6 +122,7 @@ class MetaAccountPositionUpdate( // similar to fun MetaAccount.accountId(chain: IChain) fun MetaAccountLocal.accountId(chain: IChain): ByteArray? { return when (chain.ecosystem) { + chain.id == SOLANA_CHAIN_ID -> solanaPublicKey Ecosystem.Ton -> tonPublicKey Ecosystem.Substrate -> substrateAccountId Ecosystem.Ethereum, diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt index ec0ddcccbb..1d804b0102 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt @@ -10,6 +10,7 @@ import jp.co.soramitsu.account.api.domain.model.NomisScoreData import jp.co.soramitsu.backup.domain.models.BackupAccountType import jp.co.soramitsu.common.data.secrets.v2.ChainAccountSecrets import jp.co.soramitsu.common.data.secrets.v3.EthereumSecrets +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecrets import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets import jp.co.soramitsu.common.data.secrets.v3.TonSecrets import jp.co.soramitsu.common.model.WalletEcosystem @@ -140,4 +141,5 @@ interface AccountRepository { suspend fun getSubstrateSecrets(metaId: Long) : EncodableStruct? suspend fun getEthereumSecrets(metaId: Long) : EncodableStruct? suspend fun getTonSecrets(metaId: Long): EncodableStruct? + suspend fun getSolanaSecrets(metaId: Long): EncodableStruct? } diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AddAccountPayload.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AddAccountPayload.kt index 6e26307c7f..e2fbc14402 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AddAccountPayload.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AddAccountPayload.kt @@ -1,6 +1,7 @@ package jp.co.soramitsu.account.api.domain.model import android.os.Parcelable +import jp.co.soramitsu.common.domain.SOLANA_DEFAULT_PATH import jp.co.soramitsu.core.models.CryptoType import kotlinx.parcelize.Parcelize @@ -17,6 +18,7 @@ sealed interface AddAccountPayload : Parcelable { val substrateDerivationPath: String, val ethereumDerivationPath: String, val googleBackupAddress: String?, + val solanaDerivationPath: String = SOLANA_DEFAULT_PATH, override val isBackedUp: Boolean ) : AddAccountPayload, Parcelable @@ -35,4 +37,4 @@ sealed interface AddAccountPayload : Parcelable { val ethereumDerivationPath: String, override val isBackedUp: Boolean ) : AddAccountPayload, Parcelable -} \ No newline at end of file +} diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt index 409c2bcab1..5ca31e249c 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt @@ -1,6 +1,8 @@ package jp.co.soramitsu.account.api.domain.model +import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID import jp.co.soramitsu.common.model.WalletEcosystem +import jp.co.soramitsu.common.utils.Base58Ext.toBase58 import jp.co.soramitsu.common.utils.ethereumAddressToHex import jp.co.soramitsu.common.utils.v4r2tonAddress import jp.co.soramitsu.core.models.CryptoType @@ -24,6 +26,7 @@ interface LightMetaAccount { val ethereumAddress: ByteArray? val ethereumPublicKey: ByteArray? val tonPublicKey: ByteArray? + val solanaPublicKey: ByteArray? val isSelected: Boolean val name: String val isBackedUp: Boolean @@ -38,6 +41,7 @@ fun LightMetaAccount( ethereumAddress: ByteArray?, ethereumPublicKey: ByteArray?, tonPublicKey: ByteArray?, + solanaPublicKey: ByteArray?, isSelected: Boolean, name: String, isBackedUp: Boolean, @@ -50,6 +54,7 @@ fun LightMetaAccount( override val ethereumAddress: ByteArray? = ethereumAddress override val ethereumPublicKey: ByteArray? = ethereumPublicKey override val tonPublicKey: ByteArray? = tonPublicKey + override val solanaPublicKey: ByteArray? = solanaPublicKey override val isSelected: Boolean = isSelected override val name: String = name override val isBackedUp: Boolean = isBackedUp @@ -66,6 +71,7 @@ data class MetaAccount( override val ethereumAddress: ByteArray?, override val ethereumPublicKey: ByteArray?, override val tonPublicKey: ByteArray?, + override val solanaPublicKey: ByteArray?, override val isSelected: Boolean, override val isBackedUp: Boolean, val googleBackupAddress: String?, @@ -107,6 +113,10 @@ data class MetaAccount( if (other.ethereumPublicKey == null) return false if (!ethereumPublicKey.contentEquals(other.ethereumPublicKey)) return false } else if (other.ethereumPublicKey != null) return false + if (solanaPublicKey != null) { + if (other.solanaPublicKey == null) return false + if (!solanaPublicKey.contentEquals(other.solanaPublicKey)) return false + } else if (other.solanaPublicKey != null) return false if (isSelected != other.isSelected) return false if (name != other.name) return false if (initialized != other.initialized) return false @@ -123,6 +133,7 @@ data class MetaAccount( result = 31 * result + substrateAccountId.contentHashCode() result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0) result = 31 * result + (ethereumPublicKey?.contentHashCode() ?: 0) + result = 31 * result + (solanaPublicKey?.contentHashCode() ?: 0) result = 31 * result + isSelected.hashCode() result = 31 * result + name.hashCode() result = 31 * result + initialized.hashCode() @@ -144,6 +155,7 @@ fun MetaAccount.address(chain: Chain): String? { return kotlin.runCatching { when { hasChainAccount(chain.id) -> chain.addressOf(chainAccounts.getValue(chain.id).accountId) + chain.id == SOLANA_CHAIN_ID -> solanaPublicKey?.toBase58() chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> ethereumAddress?.ethereumAddressToHex() chain.ecosystem == Ecosystem.Ton -> { tonPublicKey?.v4r2tonAddress(chain.isTestNet) @@ -156,13 +168,12 @@ fun MetaAccount.address(chain: Chain): String? { fun LightMetaAccount.address(chain: Chain): String? { return kotlin.runCatching { - when (chain.ecosystem) { - Ecosystem.Substrate -> substrateAccountId?.toAddress(chain.addressPrefix.toShort()) - Ecosystem.EthereumBased, - Ecosystem.Ethereum -> ethereumAddress?.ethereumAddressToHex() - Ecosystem.Ton -> { - tonPublicKey?.v4r2tonAddress(chain.isTestNet) - } + when { + chain.id == SOLANA_CHAIN_ID -> solanaPublicKey?.toBase58() + chain.ecosystem == Ecosystem.Substrate -> substrateAccountId?.toAddress(chain.addressPrefix.toShort()) + chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> ethereumAddress?.ethereumAddressToHex() + chain.ecosystem == Ecosystem.Ton -> tonPublicKey?.v4r2tonAddress(chain.isTestNet) + else -> null } }.getOrNull() } @@ -170,12 +181,14 @@ fun LightMetaAccount.address(chain: Chain): String? { fun LightMetaAccount.supportedEcosystemWithIconAddress(): Map = listOfNotNull( tonPublicKey?.let { WalletEcosystem.Ton to it.v4r2tonAddress(false) }, substratePublicKey?.let { WalletEcosystem.Substrate to it.toAddress(0.toShort()) }, // 0 = polkadotAddressPrefix + solanaPublicKey?.let { WalletEcosystem.Solana to it.toBase58() }, ethereumPublicKey?.let { WalletEcosystem.Ethereum to it.ethereumAddressToHex() } ).toMap() fun LightMetaAccount.supportedEcosystems(): Set = setOfNotNull( tonPublicKey?.let { WalletEcosystem.Ton }, substratePublicKey?.let { WalletEcosystem.Substrate }, + solanaPublicKey?.let { WalletEcosystem.Solana }, ethereumPublicKey?.let { WalletEcosystem.Ethereum } ) @@ -189,6 +202,7 @@ fun MetaAccount.chainAddress(chain: Chain): String? { fun MetaAccount.accountId(chain: IChain): ByteArray? { return when { hasChainAccount(chain.id) -> chainAccounts.getValue(chain.id).accountId + chain.id == SOLANA_CHAIN_ID -> solanaPublicKey chain.ecosystem == Ecosystem.Substrate -> substrateAccountId chain.ecosystem == Ecosystem.Ethereum || chain.ecosystem == Ecosystem.EthereumBased -> ethereumAddress //Attention!!! Use tonPublicKey as accountId only internally in fearless wallet. For api requests use ByteArray.tonAccountId(): String function extension @@ -206,6 +220,9 @@ val MetaAccount.hasEthereum val MetaAccount.hasTon get() = tonPublicKey != null +val MetaAccount.hasSolana + get() = solanaPublicKey != null + val LightMetaAccount.hasSubstrate get() = substrateAccountId != null @@ -213,4 +230,7 @@ val LightMetaAccount.hasEthereum get() = ethereumPublicKey != null val LightMetaAccount.hasTon - get() = tonPublicKey != null \ No newline at end of file + get() = tonPublicKey != null + +val LightMetaAccount.hasSolana + get() = solanaPublicKey != null diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt index cd3e63e094..723c35b5be 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt @@ -74,6 +74,7 @@ fun mapMetaAccountLocalToLightMetaAccount( ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, tonPublicKey = tonPublicKey, + solanaPublicKey = solanaPublicKey, isSelected = isSelected, name = name, isBackedUp = isBackedUp, @@ -120,6 +121,7 @@ fun mapMetaAccountLocalToMetaAccount( ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, tonPublicKey = tonPublicKey, + solanaPublicKey = solanaPublicKey, isSelected = isSelected, name = name, isBackedUp = isBackedUp, @@ -219,4 +221,4 @@ fun NomisResponse.toDomain(): NomisScoreData { minTransactionTimeInHours = data.stats.minTransactionTimeInHours, scoredAt = scoredAtMillis ) -} \ No newline at end of file +} diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryDelegate.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryDelegate.kt index 4262d674f3..3723402c3b 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryDelegate.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryDelegate.kt @@ -5,6 +5,8 @@ import jp.co.soramitsu.account.api.domain.model.AddAccountPayload import jp.co.soramitsu.common.data.Keypair import jp.co.soramitsu.common.data.secrets.v3.EthereumSecretStore import jp.co.soramitsu.common.data.secrets.v3.EthereumSecrets +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecretStore +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecrets import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecretStore import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets import jp.co.soramitsu.common.data.secrets.v3.TonSecretStore @@ -22,7 +24,10 @@ import jp.co.soramitsu.shared_utils.encrypt.keypair.ethereum.EthereumKeypairFact import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.SubstrateKeypairFactory import jp.co.soramitsu.shared_utils.encrypt.seed.ethereum.EthereumSeedFactory import jp.co.soramitsu.shared_utils.encrypt.seed.substrate.SubstrateSeedFactory +import kotlin.text.encodeToByteArray import org.ton.api.pk.PrivateKeyEd25519 +import jp.co.soramitsu.common.utils.solana.SolanaKeyFactory +import jp.co.soramitsu.common.utils.solana.SolanaKeypair class AccountRepositoryDelegate( private val substrateOrEvmAccountRepository: SubstrateOrEvmAccountRepository, @@ -40,7 +45,8 @@ class AccountRepositoryDelegate( class SubstrateOrEvmAccountRepository( private val metaAccountDao: MetaAccountDao, private val substrateSecretStore: SubstrateSecretStore, - private val ethereumSecretStore: EthereumSecretStore + private val ethereumSecretStore: EthereumSecretStore, + private val solanaSecretStore: SolanaSecretStore ) { suspend fun createAdditional(payload: AddAccountPayload.AdditionalEvm): Long { val decodedEthereumDerivationPath = @@ -62,6 +68,7 @@ class SubstrateOrEvmAccountRepository( ethereumPublicKey = ethereumKeypair.publicKey, ethereumAddress = ethereumKeypair.publicKey.ethereumAddressFromPublicKey(), tonPublicKey = null, + solanaPublicKey = null, name = localMetaAccount.name, isSelected = localMetaAccount.isSelected, position = localMetaAccount.position, @@ -114,6 +121,13 @@ class SubstrateOrEvmAccountRepository( junctions = decodedEthereumDerivationPath.junctions ) + val solanaKeypair = runCatching { + SolanaKeyFactory.deriveKeypair( + mnemonicWords = payload.mnemonic.split(" "), + derivationPath = payload.solanaDerivationPath + ) + }.getOrNull() + val position = metaAccountDao.getNextPosition() val metaAccount = MetaAccountLocal( @@ -123,6 +137,7 @@ class SubstrateOrEvmAccountRepository( ethereumPublicKey = ethereumKeypair.publicKey, ethereumAddress = ethereumKeypair.publicKey.ethereumAddressFromPublicKey(), tonPublicKey = null, + solanaPublicKey = solanaKeypair?.publicKey, name = payload.accountName, isSelected = true, position = position, @@ -154,6 +169,15 @@ class SubstrateOrEvmAccountRepository( ethereumSecretStore.put(metaAccountId, ethereumSecrets) + solanaKeypair?.let { + val solanaSecrets = SolanaSecrets( + seed = payload.mnemonic.encodeToByteArray(), + solanaKeypair = Keypair(it.publicKey, it.privateKey), + derivationPath = payload.solanaDerivationPath + ) + solanaSecretStore.put(metaAccountId, solanaSecrets) + } + return metaAccountId } } @@ -176,6 +200,7 @@ class TonAccountRepository( ethereumPublicKey = null, ethereumAddress = null, tonPublicKey = tonPublicKey.key.toByteArray(), + solanaPublicKey = null, name = payload.accountName, isSelected = true, position = position, @@ -203,4 +228,4 @@ class TonAccountRepository( return metaAccountId } -} \ No newline at end of file +} diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt index 638f886a00..f8b6f55e98 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt @@ -24,6 +24,8 @@ import jp.co.soramitsu.common.data.secrets.v2.KeyPairSchema import jp.co.soramitsu.common.data.secrets.v2.SecretStoreV2 import jp.co.soramitsu.common.data.secrets.v3.EthereumSecretStore import jp.co.soramitsu.common.data.secrets.v3.EthereumSecrets +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecretStore +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecrets import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecretStore import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets import jp.co.soramitsu.common.data.secrets.v3.TonSecretStore @@ -91,6 +93,7 @@ class AccountRepositoryImpl( private val substrateSecretStore: SubstrateSecretStore, private val ethereumSecretStore: EthereumSecretStore, private val tonSecretStore: TonSecretStore, + private val solanaSecretStore: SolanaSecretStore, private val accountRepositoryDelegate: AccountRepositoryDelegate, private val assetDao: AssetDao, private val dispatcher: CoroutineDispatcher = Dispatchers.Default @@ -225,6 +228,7 @@ class AccountRepositoryImpl( ethereumPublicKey = ethereumKeypair.publicKey, ethereumAddress = ethereumKeypair.publicKey.ethereumAddressFromPublicKey(), tonPublicKey = null, + solanaPublicKey = null, isBackedUp = true, googleBackupAddress = localMetaAccount.googleBackupAddress, initialized = false @@ -278,6 +282,7 @@ class AccountRepositoryImpl( ethereumPublicKey = ethereumKeypair?.publicKey, ethereumAddress = ethereumKeypair?.publicKey?.ethereumAddressFromPublicKey(), tonPublicKey = null, + solanaPublicKey = null, isBackedUp = true, googleBackupAddress = googleBackupAddress, initialized = false @@ -338,6 +343,7 @@ class AccountRepositoryImpl( ethereumAddress = ethereumKeypair?.publicKey?.ethereumAddressFromPublicKey(), ethereumPublicKey = ethereumKeypair?.publicKey, tonPublicKey = null, + solanaPublicKey = null, isBackedUp = true, googleBackupAddress = googleBackupAddress, initialized = false @@ -385,6 +391,7 @@ class AccountRepositoryImpl( ethereumAddress = ethereumKeypair.publicKey.ethereumAddressFromPublicKey(), ethereumPublicKey = ethereumKeypair.publicKey, tonPublicKey = null, + solanaPublicKey = null, isBackedUp = true, googleBackupAddress = localMetaAccount.googleBackupAddress, initialized = false @@ -614,7 +621,10 @@ class AccountRepositoryImpl( } WalletEcosystem.Solana -> { - // Solana backups will be handled once dedicated secret storage is available. + val solanaSecrets = solanaSecretStore.get(walletId) + if (solanaSecrets != null) { + return BackupAccountType.PASSPHRASE + } } } return null @@ -624,6 +634,7 @@ class AccountRepositoryImpl( val substrateSecrets = substrateSecretStore.get(walletId) val ethereumSecrets = ethereumSecretStore.get(walletId) val tonSecrets = tonSecretStore.get(walletId) + val solanaSecrets = solanaSecretStore.get(walletId) val types = mutableSetOf() @@ -638,13 +649,16 @@ class AccountRepositoryImpl( } types.add(BackupAccountType.JSON) - return types } if (tonSecrets != null) { types.add(BackupAccountType.PASSPHRASE) } + if (solanaSecrets != null) { + types.add(BackupAccountType.PASSPHRASE) + } + return types } @@ -702,4 +716,8 @@ class AccountRepositoryImpl( override suspend fun getTonSecrets(metaId: Long): EncodableStruct? { return tonSecretStore.get(metaId) } + + override suspend fun getSolanaSecrets(metaId: Long): EncodableStruct? { + return solanaSecretStore.get(metaId) + } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt index 5d1b49d67a..7e3eda0faa 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt @@ -9,6 +9,8 @@ import jp.co.soramitsu.common.data.secrets.v2.getChainAccountKeypair import jp.co.soramitsu.common.data.secrets.v2.mapKeypairStructToKeypair import jp.co.soramitsu.common.data.secrets.v3.EthereumSecretStore import jp.co.soramitsu.common.data.secrets.v3.EthereumSecrets +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecretStore +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecrets import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecretStore import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets import jp.co.soramitsu.common.data.secrets.v3.TonSecretStore @@ -19,12 +21,14 @@ import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.models.IChain import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID class KeyPairRepository( private val secretStoreV2: SecretStoreV2, private val ethereumSecretStore: EthereumSecretStore, private val substrateSecretStore: SubstrateSecretStore, private val tonSecretStore: TonSecretStore, + private val solanaSecretStore: SolanaSecretStore, private val accountRepository: AccountRepository ) : KeypairProvider { @@ -43,6 +47,11 @@ class KeyPairRepository( secretStoreV2.hasChainSecrets(metaAccount.id, accountId) -> { secretStoreV2.getChainAccountKeypair(metaAccount.id, accountId) } + chain.id == SOLANA_CHAIN_ID -> { + solanaSecretStore.get(metaAccount.id)?.let { + Keypair(it[SolanaSecrets.PublicKey], it[SolanaSecrets.PrivateKey]) + } + } chain.ecosystem == Ecosystem.Substrate -> substrateSecretStore.get(metaAccount.id)?.get(SubstrateSecrets.SubstrateKeypair)?.let { mapKeypairStructToKeypair(it) } chain.ecosystem == Ecosystem.EthereumBased ||chain.ecosystem == Ecosystem.Ethereum -> ethereumSecretStore.get(metaAccount.id)?.get(EthereumSecrets.EthereumKeypair)?.let { mapKeypairStructToKeypair(it) } @@ -52,4 +61,4 @@ class KeyPairRepository( return keypair ?: error("No keypair found for meta account: ${metaAccount.id}, chain: ${chain.id} (${chain.ecosystem.name})") } -} \ No newline at end of file +} diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt index 0bd5fb3fb8..7455a938cf 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt @@ -39,6 +39,7 @@ import jp.co.soramitsu.common.data.network.nomis.NomisApi import jp.co.soramitsu.common.data.secrets.v1.SecretStoreV1 import jp.co.soramitsu.common.data.secrets.v2.SecretStoreV2 import jp.co.soramitsu.common.data.secrets.v3.EthereumSecretStore +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecretStore import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecretStore import jp.co.soramitsu.common.data.secrets.v3.TonSecretStore import jp.co.soramitsu.common.data.storage.Preferences @@ -91,6 +92,7 @@ class AccountFeatureModule { substrateSecretStore: SubstrateSecretStore, ethereumSecretStore: EthereumSecretStore, tonSecretStore: TonSecretStore, + solanaSecretStore: SolanaSecretStore, accountRepositoryDelegate: AccountRepositoryDelegate, assetDao: AssetDao ): AccountRepository { @@ -106,6 +108,7 @@ class AccountFeatureModule { substrateSecretStore, ethereumSecretStore, tonSecretStore, + solanaSecretStore, accountRepositoryDelegate, assetDao ) @@ -125,12 +128,14 @@ class AccountFeatureModule { fun provideSubstrateOrEvmAccountRepository( metaAccountDao: MetaAccountDao, substrateSecretStore: SubstrateSecretStore, - ethereumSecretStore: EthereumSecretStore + ethereumSecretStore: EthereumSecretStore, + solanaSecretStore: SolanaSecretStore ): SubstrateOrEvmAccountRepository { return SubstrateOrEvmAccountRepository( metaAccountDao, substrateSecretStore, - ethereumSecretStore + ethereumSecretStore, + solanaSecretStore ) } @@ -149,13 +154,15 @@ class AccountFeatureModule { accountRepository: AccountRepository, substrateSecretStore: SubstrateSecretStore, ethereumSecretStore: EthereumSecretStore, - tonSecretStore: TonSecretStore + tonSecretStore: TonSecretStore, + solanaSecretStore: SolanaSecretStore ): KeypairProvider { return KeyPairRepository( secretStoreV2, ethereumSecretStore, substrateSecretStore, tonSecretStore, + solanaSecretStore, accountRepository ) } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt index 3a51be5e2f..52f0d6e678 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt @@ -19,6 +19,7 @@ import jp.co.soramitsu.backup.domain.models.Json import jp.co.soramitsu.backup.domain.models.Seed import jp.co.soramitsu.common.data.secrets.v2.KeyPairSchema import jp.co.soramitsu.common.data.secrets.v3.EthereumSecrets +import jp.co.soramitsu.common.data.secrets.v3.SolanaSecrets import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets import jp.co.soramitsu.common.data.secrets.v3.TonSecrets import jp.co.soramitsu.common.data.storage.Preferences @@ -365,6 +366,13 @@ class AccountInteractorImpl( entropy = byteArrayOf() ) } + WalletEcosystem.Solana -> accountRepository.getSolanaSecrets(metaId)?.get(SolanaSecrets.Seed)?.decodeToString()?.let { words -> + Mnemonic( + words = words, + wordList = words.split(EnglishWordList.INSTANCE.space.toString()), + entropy = byteArrayOf() + ) + } } } }.mapNotNull { @@ -382,10 +390,12 @@ class AccountInteractorImpl( get(SubstrateSecrets.Seed) ?: seedFromMnemonic() }?.toHexString(true) val ethSeed = accountRepository.getEthereumSecrets(metaId)?.get(EthereumSecrets.EthereumKeypair)?.get(KeyPairSchema.PrivateKey)?.toHexString(withPrefix = true) + val solanaSeed = accountRepository.getSolanaSecrets(metaId)?.get(SolanaSecrets.PrivateKey)?.toHexString(withPrefix = true) ComponentHolder( listOf( substrateSeed, - ethSeed + ethSeed, + solanaSeed ) ) } @@ -401,10 +411,12 @@ class AccountInteractorImpl( val ethereumDerivationPath = accountRepository.getEthereumSecrets(metaId)?.get(EthereumSecrets.EthereumDerivationPath).takeIf { path -> path != BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH } + val solanaDerivationPath = accountRepository.getSolanaSecrets(metaId)?.get(SolanaSecrets.DerivationPath) ComponentHolder( listOf( substrateDerivationPath, - ethereumDerivationPath + ethereumDerivationPath, + solanaDerivationPath ) ) } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt index a3a1584df9..a9fe0614a0 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt @@ -9,11 +9,13 @@ import jp.co.soramitsu.account.api.domain.model.hasChainAccount import jp.co.soramitsu.account.api.domain.model.hasEthereum import jp.co.soramitsu.account.api.domain.model.hasSubstrate import jp.co.soramitsu.account.api.domain.model.hasTon +import jp.co.soramitsu.account.api.domain.model.hasSolana import jp.co.soramitsu.account.api.domain.model.supportedEcosystems import jp.co.soramitsu.account.impl.domain.account.details.AccountInChain.From import jp.co.soramitsu.common.data.secrets.v2.ChainAccountSecrets import jp.co.soramitsu.common.list.GroupedList import jp.co.soramitsu.common.model.AssetKey +import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.common.utils.flowOf import jp.co.soramitsu.core.models.Ecosystem @@ -45,6 +47,9 @@ class AccountDetailsInteractor( suspend fun hasReplacedAccounts(metaId: Long, type: WalletEcosystem): Boolean { val wallet = getMetaAccount(metaId) + if (type == WalletEcosystem.Solana) { + return wallet.hasSolana + } return wallet.chainAccounts.values.mapNotNull { it.chain }.any { if (it.isEthereumChain) { type == WalletEcosystem.Ethereum @@ -99,25 +104,29 @@ class AccountDetailsInteractor( flowOf { chainRegistry.getChains() } ) { metaAccount, chains -> metaAccount to chains.filter { chain -> - when (chain.ecosystem) { - Ecosystem.Substrate -> metaAccount.hasSubstrate - Ecosystem.EthereumBased, - Ecosystem.Ethereum -> metaAccount.hasEthereum - - Ecosystem.Ton -> metaAccount.hasTon + when { + chain.id == SOLANA_CHAIN_ID -> metaAccount.hasSolana + chain.ecosystem == Ecosystem.Substrate -> metaAccount.hasSubstrate + chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> metaAccount.hasEthereum + chain.ecosystem == Ecosystem.Ton -> metaAccount.hasTon + else -> false } || metaAccount.hasChainAccount(chain.id) }.groupBy { chain -> - when (chain.ecosystem) { - Ecosystem.Substrate -> WalletEcosystem.Substrate - Ecosystem.EthereumBased, - Ecosystem.Ethereum -> WalletEcosystem.Ethereum - - Ecosystem.Ton -> WalletEcosystem.Ton + when { + chain.id == SOLANA_CHAIN_ID -> WalletEcosystem.Solana + chain.ecosystem == Ecosystem.Substrate -> WalletEcosystem.Substrate + chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> WalletEcosystem.Ethereum + chain.ecosystem == Ecosystem.Ton -> WalletEcosystem.Ton + else -> WalletEcosystem.Substrate } } }.mapNotNull { (metaAccount, grouped) -> - if (metaAccount.hasEthereum || metaAccount.hasSubstrate) { - return@mapNotNull listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum).map { + if (metaAccount.hasEthereum || metaAccount.hasSubstrate || metaAccount.hasSolana) { + return@mapNotNull listOf( + WalletEcosystem.Substrate, + WalletEcosystem.Ethereum, + WalletEcosystem.Solana + ).map { it to grouped[it].orEmpty().size } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/create/CreateAccountViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/create/CreateAccountViewModel.kt index 4f9a94635a..7847aa9e09 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/create/CreateAccountViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/create/CreateAccountViewModel.kt @@ -71,7 +71,7 @@ class CreateAccountViewModel @Inject constructor( if (isFromGoogleBackup) { router.openMnemonicAgreementsDialogForGoogleBackup( accountName = walletNickname.value, - accountTypes = listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum) + accountTypes = listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum, WalletEcosystem.Solana) ) } else { _showScreenshotsWarningEvent.value = Event(Unit) @@ -80,7 +80,7 @@ class CreateAccountViewModel @Inject constructor( fun screenshotWarningConfirmed() { val accountTypes = when (accountMode) { - AccountType.SubstrateOrEvm -> listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum) + AccountType.SubstrateOrEvm -> listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum, WalletEcosystem.Solana) AccountType.Ton -> listOf(WalletEcosystem.Ton) } router.openMnemonicScreen(walletNickname.value, accountTypes) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt index fec8b34dda..5c29caa52f 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt @@ -121,8 +121,8 @@ class AccountDetailsViewModel @Inject constructor( private fun mapTypeToTitle(type: WalletEcosystem): String { val resId = when (type) { WalletEcosystem.Substrate -> R.string.connected_accounts_substrate_title - WalletEcosystem.Ethereum, - WalletEcosystem.Solana -> R.string.connected_accounts_ethereum_title + WalletEcosystem.Ethereum -> R.string.connected_accounts_ethereum_title + WalletEcosystem.Solana -> R.string.connected_accounts_solana_title WalletEcosystem.Ton -> R.string.connected_accounts_ton_title } return resourceManager.getString(resId) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/backup_wallet/BackupWalletViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/backup_wallet/BackupWalletViewModel.kt index dcd28f3c44..17072af969 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/backup_wallet/BackupWalletViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/backup_wallet/BackupWalletViewModel.kt @@ -60,7 +60,7 @@ class BackupWalletViewModel @Inject constructor( val isAllowGoogleBackupFlow = wallet.map { wallet -> wallet.supportedEcosystemWithIconAddress().keys.any { - it in listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum) + it in listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum, WalletEcosystem.Solana) } }.stateIn(viewModelScope, SharingStarted.Eagerly, false) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt index bb9e4145c7..b237af96f5 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt @@ -58,7 +58,9 @@ class BackupMnemonicViewModel @Inject constructor( savedStateHandle.get(BackupMnemonicScreenKeys.PAYLOAD_KEY)!! val walletId = payload.walletId - val isEthereumAccount = payload.accountTypes.contains(WalletEcosystem.Ethereum) + val isEthereumAccount = payload.accountTypes.any { + it == WalletEcosystem.Ethereum || it == WalletEcosystem.Solana + } val isSubstrateAccount = payload.accountTypes.contains(WalletEcosystem.Substrate) val isTonAccount = payload.accountTypes.contains(WalletEcosystem.Ton) val isSubstrateAndEthereumAccount = isSubstrateAccount && isEthereumAccount diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/confirm/ConfirmMnemonicPayload.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/confirm/ConfirmMnemonicPayload.kt index 6664bffa6b..5f42de0ce6 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/confirm/ConfirmMnemonicPayload.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/confirm/ConfirmMnemonicPayload.kt @@ -1,6 +1,7 @@ package jp.co.soramitsu.account.impl.presentation.mnemonic.confirm import android.os.Parcelable +import jp.co.soramitsu.common.domain.SOLANA_DEFAULT_PATH import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.core.models.CryptoType import kotlinx.parcelize.Parcelize @@ -17,6 +18,7 @@ class ConfirmMnemonicPayload( open val accountName: String, open val cryptoType: CryptoType, open val substrateDerivationPath: String, - open val ethereumDerivationPath: String + open val ethereumDerivationPath: String, + open val solanaDerivationPath: String = SOLANA_DEFAULT_PATH ) : Parcelable } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/confirm/ConfirmMnemonicViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/confirm/ConfirmMnemonicViewModel.kt index 8994e20d02..a7f2452972 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/confirm/ConfirmMnemonicViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/confirm/ConfirmMnemonicViewModel.kt @@ -132,7 +132,10 @@ class ConfirmMnemonicViewModel @Inject constructor( val payloadExtras = payload.createExtras ?: return val mnemonicString = originMnemonic.joinToString(" ") - val isSubstrateOrEthereumAccount = payload.accountTypes.contains(WalletEcosystem.Substrate) || payload.accountTypes.contains(WalletEcosystem.Ethereum) + val isSolanaAccount = payload.accountTypes.contains(WalletEcosystem.Solana) + val isSubstrateOrEthereumAccount = payload.accountTypes.contains(WalletEcosystem.Substrate) || + payload.accountTypes.contains(WalletEcosystem.Ethereum) || + isSolanaAccount val isTonAccount = payload.accountTypes.contains(WalletEcosystem.Ton) launch { @@ -155,6 +158,7 @@ class ConfirmMnemonicViewModel @Inject constructor( payloadExtras.substrateDerivationPath, payloadExtras.ethereumDerivationPath, null, + payloadExtras.solanaDerivationPath, isBackedUp ) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt index 926ea73c46..1ee22bf0da 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt @@ -639,8 +639,9 @@ class BalanceListViewModel @Inject constructor( currentMetaAccountFlow.distinctUntilChanged().onEach { metaAccount -> val showCurrenciesOrNftSelector = - metaAccount.supportedEcosystems().contains(WalletEcosystem.Ethereum) || metaAccount.supportedEcosystems() - .contains(WalletEcosystem.Substrate) + metaAccount.supportedEcosystems().any { + it == WalletEcosystem.Ethereum || it == WalletEcosystem.Substrate || it == WalletEcosystem.Solana + } state.value = state.value.copy( isBackedUp = metaAccount.isBackedUp, @@ -678,7 +679,8 @@ class BalanceListViewModel @Inject constructor( val hasTonAccounts = wallets.any { it.tonPublicKey != null } val hasSubAccounts = wallets.any { it.substratePublicKey != null } val hasEthAccounts = wallets.any { it.ethereumPublicKey != null } - hasTonAccounts to (hasSubAccounts || hasEthAccounts) + val hasSolAccounts = wallets.any { it.solanaPublicKey != null } + hasTonAccounts to (hasSubAccounts || hasEthAccounts || hasSolAccounts) }.distinctUntilChanged().onEach { (hasTon, hasSubOrEvm) -> state.value = state.value.copy( hasTonAccounts = hasTon, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 402ced105c..9db35e820f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ zxingVersion = "3.5.3" jnrVersion = "1.0.2" soramitsuAndroidFoundation = "0.0.4" tonVersion = "0.3.1" +solanajVersion = "1.16" [libraries] security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCryptoVersion" } @@ -201,6 +202,7 @@ ton-tlb = { module = "org.ton:ton-kotlin-tlb", version.ref = "tonVersion" } ton-block-tlb = { module = "org.ton:ton-kotlin-block-tlb", version.ref = "tonVersion" } ton-tonapi = { module = "org.ton:ton-kotlin-tonapi-tl", version.ref = "tonVersion" } ton-contract = { module = "org.ton:ton-kotlin-contract", version.ref = "tonVersion" } +solanaj = { module = "org.p2p.solanaj:solanaj", version.ref = "solanajVersion" } [bundles] compose = [ diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaChainDefinition.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaChainDefinition.kt index 564a32cc55..fc5ced4812 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaChainDefinition.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaChainDefinition.kt @@ -1,5 +1,6 @@ package jp.co.soramitsu.runtime.multiNetwork.chain.solana +import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.core.models.Asset.StakingType import jp.co.soramitsu.core.models.ChainNode @@ -9,7 +10,7 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId object SolanaChainDefinition { - const val CHAIN_ID: ChainId = "solana-mainnet" + const val CHAIN_ID: ChainId = SOLANA_CHAIN_ID private const val ICON_URL = "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/solana/info/logo.png" private const val EXPLORER_URL = "https://solscan.io/" From 3ecd5e89319b02878dcec08b482895a8b88c5dce Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 15:09:09 +0100 Subject: [PATCH 04/21] chore: drop solanaj dependency --- common/build.gradle | 1 - .../utils/solana/SolanaKeyFactoryTest.kt | 27 ------------------- gradle/libs.versions.toml | 2 -- 3 files changed, 30 deletions(-) delete mode 100644 common/src/test/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactoryTest.kt diff --git a/common/build.gradle b/common/build.gradle index e80c165bfe..73444da142 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -141,7 +141,6 @@ dependencies { implementation libs.zxing.embedded implementation libs.bundles.ton - implementation libs.solanaj implementation libs.progressbutton implementation libs.nv.websocket.client diff --git a/common/src/test/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactoryTest.kt b/common/src/test/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactoryTest.kt deleted file mode 100644 index fdce175728..0000000000 --- a/common/src/test/java/jp/co/soramitsu/common/utils/solana/SolanaKeyFactoryTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package jp.co.soramitsu.common.utils.solana - -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals - -class SolanaKeyFactoryTest { - - @Test - fun `should derive deterministic keypair`() { - val mnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow".split(" ") - - val solanaKeypair = SolanaKeyFactory.deriveKeypair(mnemonic) - - assertEquals(32, solanaKeypair.privateKey.size) - assertEquals(32, solanaKeypair.publicKey.size) - - val defaultKeypair = SolanaKeyFactory.deriveKeypair(mnemonic) - val differentAccountKeypair = SolanaKeyFactory.deriveKeypair( - mnemonicWords = mnemonic, - derivationPath = "m/44'/501'/1'/0'" - ) - - assertEquals(defaultKeypair.publicKey.toList(), solanaKeypair.publicKey.toList()) - assertNotEquals(defaultKeypair.publicKey.toList(), differentAccountKeypair.publicKey.toList()) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9db35e820f..402ced105c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,6 @@ zxingVersion = "3.5.3" jnrVersion = "1.0.2" soramitsuAndroidFoundation = "0.0.4" tonVersion = "0.3.1" -solanajVersion = "1.16" [libraries] security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCryptoVersion" } @@ -202,7 +201,6 @@ ton-tlb = { module = "org.ton:ton-kotlin-tlb", version.ref = "tonVersion" } ton-block-tlb = { module = "org.ton:ton-kotlin-block-tlb", version.ref = "tonVersion" } ton-tonapi = { module = "org.ton:ton-kotlin-tonapi-tl", version.ref = "tonVersion" } ton-contract = { module = "org.ton:ton-kotlin-contract", version.ref = "tonVersion" } -solanaj = { module = "org.p2p.solanaj:solanaj", version.ref = "solanajVersion" } [bundles] compose = [ From 51079ef65dab47eaf3dd93adb1892d00909b286b Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 15:14:16 +0100 Subject: [PATCH 05/21] fix: stabilize diff and formatter tests --- .../src/main/java/jp/co/soramitsu/common/utils/FlowExt.kt | 6 ++++-- .../java/jp/co/soramitsu/common/utils/NumberFormatters.kt | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/FlowExt.kt b/common/src/main/java/jp/co/soramitsu/common/utils/FlowExt.kt index 5b8f5a257a..c213289822 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/FlowExt.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/FlowExt.kt @@ -96,8 +96,10 @@ data class ListDiff( fun Flow>.diffed(): Flow> { return zipWithPrevious().map { (previous, new) -> - val addedOrModified = new - previous.orEmpty().toSet() - val removed = if (previous != null && previous.size != new.size) previous - new.toSet() else emptyList() + val previousSet = previous.orEmpty().toSet() + val newSet = new.toSet() + val addedOrModified = new.filter { it !in previousSet } + val removed = previous?.filter { it !in newSet } ?: emptyList() ListDiff(removed = removed, addedOrModified = addedOrModified, all = new.toList()) } diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt b/common/src/main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt index 0e82305842..a9a55621d5 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt @@ -12,6 +12,7 @@ import jp.co.soramitsu.common.utils.formatting.NumberAbbreviation import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat +import java.text.DecimalFormatSymbols import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -105,7 +106,7 @@ fun Long.formatDateTime(): String = SimpleDateFormat.getDateInstance().format(Da fun Long.formatTime(): String = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(this)) -fun decimalFormatterFor(pattern: String) = DecimalFormat(pattern).apply { +fun decimalFormatterFor(pattern: String) = DecimalFormat(pattern, DecimalFormatSymbols(Locale.US)).apply { roundingMode = RoundingMode.FLOOR } From 00b833297b38f37165cca01a73a722cda1a89a85 Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 15:17:03 +0100 Subject: [PATCH 06/21] fix: align crypto detail formatter with tests --- .../main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt b/common/src/main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt index a9a55621d5..7f06af57d3 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/NumberFormatters.kt @@ -30,6 +30,7 @@ private val fiatAmountFormatter = FiatFormatter() private val fiatSmallAmountFormatter = FiatSmallFormatter() private val percentAmountFormatter = FixedPrecisionFormatter(MAX_DECIMALS_2) private val cryptoAmountShortFormatter = FixedPrecisionFormatter(MAX_DECIMALS_3) +private val cryptoAmountMediumFormatter = FixedPrecisionFormatter(6) private val cryptoAmountDetailFormatter = FixedPrecisionFormatter(MAX_DECIMALS_8) private val fiatAbbreviatedFormatter = fiatAbbreviatedFormatter() @@ -126,7 +127,7 @@ fun cryptoDetailAbbreviatedFormatter() = CompoundNumberFormatter( NumberAbbreviation(BigDecimal.ZERO, BigDecimal.ONE, "", cryptoAmountDetailFormatter), NumberAbbreviation(BigDecimal.ONE, BigDecimal.ONE, "", cryptoAmountDetailFormatter), NumberAbbreviation(BigDecimal("1E+3"), BigDecimal.ONE, "", cryptoAmountShortFormatter), - NumberAbbreviation(BigDecimal("1E+6"), BigDecimal("1E+6"), "M", cryptoAmountShortFormatter), + NumberAbbreviation(BigDecimal("1E+6"), BigDecimal("1E+6"), "M", cryptoAmountMediumFormatter), NumberAbbreviation(BigDecimal("1E+9"), BigDecimal("1E+9"), "B", cryptoAmountShortFormatter), NumberAbbreviation(BigDecimal("1E+12"), BigDecimal("1E+12"), "T", cryptoAmountShortFormatter), NumberAbbreviation(BigDecimal("1E+15"), BigDecimal("1E+12"), "T", cryptoAmountShortFormatter) From 69e857b36a03d8979139fd331295d7ff7426c5a5 Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 15:24:28 +0100 Subject: [PATCH 07/21] fix(core-db): add solana column handling --- .../75.json | 1588 +++++++++++++++++ .../coredb/migrations/V2Migration.kt | 2 + .../coredb/model/MetaAccountLocal.kt | 1 + 3 files changed, 1591 insertions(+) create mode 100644 core-db/schemas/jp.co.soramitsu.coredb.AppDatabase/75.json diff --git a/core-db/schemas/jp.co.soramitsu.coredb.AppDatabase/75.json b/core-db/schemas/jp.co.soramitsu.coredb.AppDatabase/75.json new file mode 100644 index 0000000000..1d04f55c79 --- /dev/null +++ b/core-db/schemas/jp.co.soramitsu.coredb.AppDatabase/75.json @@ -0,0 +1,1588 @@ +{ + "formatVersion": 1, + "database": { + "version": 75, + "identityHash": "575ac29ac4d656daceeaf5b2468b224f", + "entities": [ + { + "tableName": "address_book", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT, `chainId` TEXT NOT NULL, `created` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_address_book_address_chainId", + "unique": true, + "columnNames": [ + "address", + "chainId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_address_book_address_chainId` ON `${TABLE_NAME}` (`address`, `chainId`)" + } + ] + }, + { + "tableName": "assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chainId` TEXT NOT NULL, `accountId` BLOB NOT NULL, `metaId` INTEGER NOT NULL, `tokenPriceId` TEXT, `freeInPlanks` TEXT, `reservedInPlanks` TEXT, `miscFrozenInPlanks` TEXT, `feeFrozenInPlanks` TEXT, `bondedInPlanks` TEXT, `redeemableInPlanks` TEXT, `unbondingInPlanks` TEXT, `sortIndex` INTEGER NOT NULL, `enabled` INTEGER, `markedNotNeed` INTEGER NOT NULL, `chainAccountName` TEXT, `status` TEXT, PRIMARY KEY(`id`, `chainId`, `accountId`, `metaId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenPriceId", + "columnName": "tokenPriceId", + "affinity": "TEXT" + }, + { + "fieldPath": "freeInPlanks", + "columnName": "freeInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "reservedInPlanks", + "columnName": "reservedInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "miscFrozenInPlanks", + "columnName": "miscFrozenInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "feeFrozenInPlanks", + "columnName": "feeFrozenInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "bondedInPlanks", + "columnName": "bondedInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "redeemableInPlanks", + "columnName": "redeemableInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "unbondingInPlanks", + "columnName": "unbondingInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "sortIndex", + "columnName": "sortIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "markedNotNeed", + "columnName": "markedNotNeed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainAccountName", + "columnName": "chainAccountName", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "chainId", + "accountId", + "metaId" + ] + }, + "indices": [ + { + "name": "index_assets_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_assets_chainId` ON `${TABLE_NAME}` (`chainId`)" + }, + { + "name": "index_assets_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `${TABLE_NAME}` (`metaId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_price", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`priceId` TEXT NOT NULL, `fiatSymbol` TEXT NOT NULL, `fiatRate` TEXT, `recentRateChange` TEXT, PRIMARY KEY(`priceId`))", + "fields": [ + { + "fieldPath": "priceId", + "columnName": "priceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fiatSymbol", + "columnName": "fiatSymbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fiatRate", + "columnName": "fiatRate", + "affinity": "TEXT" + }, + { + "fieldPath": "recentRateChange", + "columnName": "recentRateChange", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "priceId" + ] + } + }, + { + "tableName": "phishing", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT, `type` TEXT NOT NULL, `subtype` TEXT, PRIMARY KEY(`address`, `type`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtype", + "columnName": "subtype", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address", + "type" + ] + } + }, + { + "tableName": "storage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`storageKey` TEXT NOT NULL, `content` TEXT, `chainId` TEXT NOT NULL, PRIMARY KEY(`chainId`, `storageKey`))", + "fields": [ + { + "fieldPath": "storageKey", + "columnName": "storageKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT" + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "storageKey" + ] + } + }, + { + "tableName": "account_staking_accesses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `chainAssetId` TEXT NOT NULL, `accountId` BLOB NOT NULL, `stashId` BLOB, `controllerId` BLOB, PRIMARY KEY(`chainId`, `chainAssetId`, `accountId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "stakingAccessInfo.stashId", + "columnName": "stashId", + "affinity": "BLOB" + }, + { + "fieldPath": "stakingAccessInfo.controllerId", + "columnName": "controllerId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "chainAssetId", + "accountId" + ] + } + }, + { + "tableName": "total_reward", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountAddress` TEXT NOT NULL, `totalReward` TEXT NOT NULL, PRIMARY KEY(`accountAddress`))", + "fields": [ + { + "fieldPath": "accountAddress", + "columnName": "accountAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalReward", + "columnName": "totalReward", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountAddress" + ] + } + }, + { + "tableName": "operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `chainAssetId` TEXT NOT NULL, `time` INTEGER NOT NULL, `status` INTEGER NOT NULL, `source` INTEGER NOT NULL, `operationType` INTEGER NOT NULL, `module` TEXT, `call` TEXT, `amount` TEXT, `sender` TEXT, `receiver` TEXT, `hash` TEXT, `fee` TEXT, `isReward` INTEGER, `era` INTEGER, `validator` TEXT, `liquidityFee` TEXT, `market` TEXT, `targetAssetId` TEXT, `targetAmount` TEXT, PRIMARY KEY(`id`, `address`, `chainId`, `chainAssetId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationType", + "columnName": "operationType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "module", + "columnName": "module", + "affinity": "TEXT" + }, + { + "fieldPath": "call", + "columnName": "call", + "affinity": "TEXT" + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT" + }, + { + "fieldPath": "receiver", + "columnName": "receiver", + "affinity": "TEXT" + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT" + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "TEXT" + }, + { + "fieldPath": "isReward", + "columnName": "isReward", + "affinity": "INTEGER" + }, + { + "fieldPath": "era", + "columnName": "era", + "affinity": "INTEGER" + }, + { + "fieldPath": "validator", + "columnName": "validator", + "affinity": "TEXT" + }, + { + "fieldPath": "liquidityFee", + "columnName": "liquidityFee", + "affinity": "TEXT" + }, + { + "fieldPath": "market", + "columnName": "market", + "affinity": "TEXT" + }, + { + "fieldPath": "targetAssetId", + "columnName": "targetAssetId", + "affinity": "TEXT" + }, + { + "fieldPath": "targetAmount", + "columnName": "targetAmount", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "address", + "chainId", + "chainAssetId" + ] + } + }, + { + "tableName": "chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `paraId` TEXT, `parentId` TEXT, `rank` INTEGER, `name` TEXT NOT NULL, `minSupportedVersion` TEXT, `icon` TEXT NOT NULL, `prefix` INTEGER NOT NULL, `isEthereumBased` INTEGER NOT NULL, `isTestNet` INTEGER NOT NULL, `hasCrowdloans` INTEGER NOT NULL, `supportStakingPool` INTEGER NOT NULL, `isEthereumChain` INTEGER NOT NULL, `isChainlinkProvider` INTEGER NOT NULL, `supportNft` INTEGER NOT NULL, `isUsesAppId` INTEGER NOT NULL, `identityChain` TEXT, `ecosystem` TEXT NOT NULL, `androidMinAppVersion` TEXT, `remoteAssetsSource` TEXT, `tonBridgeUrl` TEXT, `staking_url` TEXT, `staking_type` TEXT, `history_url` TEXT, `history_type` TEXT, `crowdloans_url` TEXT, `crowdloans_type` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paraId", + "columnName": "paraId", + "affinity": "TEXT" + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "rank", + "columnName": "rank", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minSupportedVersion", + "columnName": "minSupportedVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prefix", + "columnName": "prefix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEthereumBased", + "columnName": "isEthereumBased", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTestNet", + "columnName": "isTestNet", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCrowdloans", + "columnName": "hasCrowdloans", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportStakingPool", + "columnName": "supportStakingPool", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEthereumChain", + "columnName": "isEthereumChain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isChainlinkProvider", + "columnName": "isChainlinkProvider", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportNft", + "columnName": "supportNft", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUsesAppId", + "columnName": "isUsesAppId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityChain", + "columnName": "identityChain", + "affinity": "TEXT" + }, + { + "fieldPath": "ecosystem", + "columnName": "ecosystem", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidMinAppVersion", + "columnName": "androidMinAppVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteAssetsSource", + "columnName": "remoteAssetsSource", + "affinity": "TEXT" + }, + { + "fieldPath": "tonBridgeUrl", + "columnName": "tonBridgeUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.staking.url", + "columnName": "staking_url", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.staking.type", + "columnName": "staking_type", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.history.url", + "columnName": "history_url", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.history.type", + "columnName": "history_type", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.crowdloans.url", + "columnName": "crowdloans_url", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.crowdloans.type", + "columnName": "crowdloans_type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "chain_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`chainId`, `url`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "url" + ] + }, + "indices": [ + { + "name": "index_chain_nodes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_nodes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `symbol` TEXT NOT NULL, `chainId` TEXT NOT NULL, `icon` TEXT NOT NULL, `priceId` TEXT, `staking` TEXT NOT NULL, `precision` INTEGER NOT NULL, `purchaseProviders` TEXT, `isUtility` INTEGER, `type` TEXT, `currencyId` TEXT, `existentialDeposit` TEXT, `color` TEXT, `isNative` INTEGER, `priceProvider` TEXT, `coinbaseUrl` TEXT, PRIMARY KEY(`chainId`, `id`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceId", + "columnName": "priceId", + "affinity": "TEXT" + }, + { + "fieldPath": "staking", + "columnName": "staking", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "precision", + "columnName": "precision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseProviders", + "columnName": "purchaseProviders", + "affinity": "TEXT" + }, + { + "fieldPath": "isUtility", + "columnName": "isUtility", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "currencyId", + "columnName": "currencyId", + "affinity": "TEXT" + }, + { + "fieldPath": "existentialDeposit", + "columnName": "existentialDeposit", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT" + }, + { + "fieldPath": "isNative", + "columnName": "isNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "priceProvider", + "columnName": "priceProvider", + "affinity": "TEXT" + }, + { + "fieldPath": "coinbaseUrl", + "columnName": "coinbaseUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "id" + ] + }, + "indices": [ + { + "name": "index_chain_assets_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_assets_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "favorite_chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, PRIMARY KEY(`metaId`, `chainId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "metaId", + "chainId" + ] + }, + "foreignKeys": [ + { + "table": "chains", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_runtimes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `syncedVersion` INTEGER NOT NULL, `remoteVersion` INTEGER NOT NULL, PRIMARY KEY(`chainId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncedVersion", + "columnName": "syncedVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteVersion", + "columnName": "remoteVersion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId" + ] + }, + "indices": [ + { + "name": "index_chain_runtimes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_runtimes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ] + }, + { + "tableName": "meta_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`substratePublicKey` BLOB, `substrateCryptoType` TEXT, `substrateAccountId` BLOB, `ethereumPublicKey` BLOB, `ethereumAddress` BLOB, `tonPublicKey` BLOB, `solanaPublicKey` BLOB, `name` TEXT NOT NULL, `isSelected` INTEGER NOT NULL, `position` INTEGER NOT NULL, `isBackedUp` INTEGER NOT NULL, `googleBackupAddress` TEXT, `initialized` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "substratePublicKey", + "columnName": "substratePublicKey", + "affinity": "BLOB" + }, + { + "fieldPath": "substrateCryptoType", + "columnName": "substrateCryptoType", + "affinity": "TEXT" + }, + { + "fieldPath": "substrateAccountId", + "columnName": "substrateAccountId", + "affinity": "BLOB" + }, + { + "fieldPath": "ethereumPublicKey", + "columnName": "ethereumPublicKey", + "affinity": "BLOB" + }, + { + "fieldPath": "ethereumAddress", + "columnName": "ethereumAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "tonPublicKey", + "columnName": "tonPublicKey", + "affinity": "BLOB" + }, + { + "fieldPath": "solanaPublicKey", + "columnName": "solanaPublicKey", + "affinity": "BLOB" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBackedUp", + "columnName": "isBackedUp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "googleBackupAddress", + "columnName": "googleBackupAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "initialized", + "columnName": "initialized", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_meta_accounts_substrateAccountId", + "unique": false, + "columnNames": [ + "substrateAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_substrateAccountId` ON `${TABLE_NAME}` (`substrateAccountId`)" + }, + { + "name": "index_meta_accounts_ethereumAddress", + "unique": false, + "columnNames": [ + "ethereumAddress" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_ethereumAddress` ON `${TABLE_NAME}` (`ethereumAddress`)" + }, + { + "name": "index_meta_accounts_tonPublicKey", + "unique": false, + "columnNames": [ + "tonPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_tonPublicKey` ON `${TABLE_NAME}` (`tonPublicKey`)" + }, + { + "name": "index_meta_accounts_solanaPublicKey", + "unique": false, + "columnNames": [ + "solanaPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_solanaPublicKey` ON `${TABLE_NAME}` (`solanaPublicKey`)" + } + ] + }, + { + "tableName": "chain_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `accountId` BLOB NOT NULL, `cryptoType` TEXT NOT NULL, `name` TEXT NOT NULL, `initialized` INTEGER NOT NULL, PRIMARY KEY(`metaId`, `chainId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialized", + "columnName": "initialized", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "metaId", + "chainId" + ] + }, + "indices": [ + { + "name": "index_chain_accounts_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `${TABLE_NAME}` (`chainId`)" + }, + { + "name": "index_chain_accounts_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `${TABLE_NAME}` (`metaId`)" + }, + { + "name": "index_chain_accounts_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_explorers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `type` TEXT NOT NULL, `types` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`chainId`, `type`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "types", + "columnName": "types", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "type" + ] + }, + "indices": [ + { + "name": "index_chain_explorers_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_explorers_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sora_card", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `accessTokenExpirationTime` INTEGER NOT NULL, `kycStatus` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessTokenExpirationTime", + "columnName": "accessTokenExpirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kycStatus", + "columnName": "kycStatus", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "chain_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `typesConfig` TEXT NOT NULL, PRIMARY KEY(`chainId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "typesConfig", + "columnName": "typesConfig", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId" + ] + } + }, + { + "tableName": "nomis_wallet_score", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `score` INTEGER NOT NULL, `updated` INTEGER NOT NULL, `nativeBalanceUsd` TEXT NOT NULL, `holdTokensUsd` TEXT NOT NULL, `walletAgeInMonths` INTEGER NOT NULL, `totalTransactions` INTEGER NOT NULL, `rejectedTransactions` INTEGER NOT NULL, `avgTransactionTimeInHours` REAL NOT NULL, `maxTransactionTimeInHours` REAL NOT NULL, `minTransactionTimeInHours` REAL NOT NULL, `scoredAt` TEXT NOT NULL, PRIMARY KEY(`metaId`), FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updated", + "columnName": "updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nativeBalanceUsd", + "columnName": "nativeBalanceUsd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "holdTokensUsd", + "columnName": "holdTokensUsd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletAgeInMonths", + "columnName": "walletAgeInMonths", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalTransactions", + "columnName": "totalTransactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rejectedTransactions", + "columnName": "rejectedTransactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "avgTransactionTimeInHours", + "columnName": "avgTransactionTimeInHours", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "maxTransactionTimeInHours", + "columnName": "maxTransactionTimeInHours", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "minTransactionTimeInHours", + "columnName": "minTransactionTimeInHours", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "scoredAt", + "columnName": "scoredAt", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "metaId" + ] + }, + "foreignKeys": [ + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "allpools", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tokenIdBase` TEXT NOT NULL, `tokenIdTarget` TEXT NOT NULL, `reserveBase` TEXT NOT NULL, `reserveTarget` TEXT NOT NULL, `totalIssuance` TEXT NOT NULL, `reservesAccount` TEXT NOT NULL, PRIMARY KEY(`tokenIdBase`, `tokenIdTarget`))", + "fields": [ + { + "fieldPath": "tokenIdBase", + "columnName": "tokenIdBase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tokenIdTarget", + "columnName": "tokenIdTarget", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reserveBase", + "columnName": "reserveBase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reserveTarget", + "columnName": "reserveTarget", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalIssuance", + "columnName": "totalIssuance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reservesAccount", + "columnName": "reservesAccount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tokenIdBase", + "tokenIdTarget" + ] + } + }, + { + "tableName": "userpools", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userTokenIdBase` TEXT NOT NULL, `userTokenIdTarget` TEXT NOT NULL, `accountAddress` TEXT NOT NULL, `poolProvidersBalance` TEXT NOT NULL, PRIMARY KEY(`userTokenIdBase`, `userTokenIdTarget`, `accountAddress`), FOREIGN KEY(`userTokenIdBase`, `userTokenIdTarget`) REFERENCES `allpools`(`tokenIdBase`, `tokenIdTarget`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userTokenIdBase", + "columnName": "userTokenIdBase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userTokenIdTarget", + "columnName": "userTokenIdTarget", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountAddress", + "columnName": "accountAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poolProvidersBalance", + "columnName": "poolProvidersBalance", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userTokenIdBase", + "userTokenIdTarget", + "accountAddress" + ] + }, + "indices": [ + { + "name": "index_userpools_accountAddress", + "unique": false, + "columnNames": [ + "accountAddress" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_userpools_accountAddress` ON `${TABLE_NAME}` (`accountAddress`)" + } + ], + "foreignKeys": [ + { + "table": "allpools", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userTokenIdBase", + "userTokenIdTarget" + ], + "referencedColumns": [ + "tokenIdBase", + "tokenIdTarget" + ] + } + ] + }, + { + "tableName": "ton_connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `clientId` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `url` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`metaId`, `url`, `source`), FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "metaId", + "url", + "source" + ] + }, + "foreignKeys": [ + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '575ac29ac4d656daceeaf5b2468b224f')" + ] + } +} \ No newline at end of file diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt index e310fa72c1..9bc982bbcc 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt @@ -126,6 +126,8 @@ class V2Migration( ContentValues().apply { put(MetaAccountLocal.Table.Column.ETHEREUM_ADDRESS, ethereumAddress) put(MetaAccountLocal.Table.Column.ETHEREUM_PUBKEY, ethereumPublicKey) + put(MetaAccountLocal.Table.Column.TON_PUBKEY, tonPublicKey) + put(MetaAccountLocal.Table.Column.SOLANA_PUBKEY, solanaPublicKey) put(MetaAccountLocal.Table.Column.NAME, name) put(MetaAccountLocal.Table.Column.SUBSTRATE_ACCOUNT_ID, substrateAccountId) put(MetaAccountLocal.Table.Column.SUBSTRATE_CRYPTO_TYPE, cryptoTypeConverters.from(substrateCryptoType)) diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt index 14aa013f7f..39c67cc49b 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt @@ -49,6 +49,7 @@ class MetaAccountLocal( const val ETHEREUM_PUBKEY = "ethereumPublicKey" const val ETHEREUM_ADDRESS = "ethereumAddress" const val TON_PUBKEY = "tonPublicKey" + const val SOLANA_PUBKEY = "solanaPublicKey" const val NAME = "name" const val IS_SELECTED = "isSelected" From 9ebec8a2fcd4b64611237903ceffddce0ccee28a Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 15:42:53 +0100 Subject: [PATCH 08/21] fix(core-db): correct solana account lookup --- .../jp/co/soramitsu/coredb/model/MetaAccountLocal.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt index 39c67cc49b..c0ddf96e03 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt @@ -122,11 +122,11 @@ class MetaAccountPositionUpdate( // similar to fun MetaAccount.accountId(chain: IChain) fun MetaAccountLocal.accountId(chain: IChain): ByteArray? { - return when (chain.ecosystem) { + return when { chain.id == SOLANA_CHAIN_ID -> solanaPublicKey - Ecosystem.Ton -> tonPublicKey - Ecosystem.Substrate -> substrateAccountId - Ecosystem.Ethereum, - Ecosystem.EthereumBased -> ethereumAddress + chain.ecosystem == Ecosystem.Ton -> tonPublicKey + chain.ecosystem == Ecosystem.Substrate -> substrateAccountId + chain.ecosystem == Ecosystem.Ethereum || chain.ecosystem == Ecosystem.EthereumBased -> ethereumAddress + else -> null } } From b03cb139df98d7b1e22435f58bc9edca42670c68 Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 15:56:38 +0100 Subject: [PATCH 09/21] fix: allow Solana json import --- .../importing/source/model/ImportSourceModel.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/model/ImportSourceModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/model/ImportSourceModel.kt index 6dcea1aa6e..e788f1b58b 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/model/ImportSourceModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/model/ImportSourceModel.kt @@ -171,6 +171,13 @@ class JsonImportSource( CryptoType.ECDSA -> return } + WalletEcosystem.Solana -> when (parsedJsonEncryptionType) { + CryptoType.ED25519 -> return + + CryptoType.SR25519, + CryptoType.ECDSA -> throw WrongBlockchainImportJsonException() + } + WalletEcosystem.Ton -> return } } From 20bc42515198a35f63f3ad9165811144214dfd91 Mon Sep 17 00:00:00 2001 From: William Richter Date: Mon, 26 Jan 2026 16:01:11 +0100 Subject: [PATCH 10/21] fix: pass solana derivation path --- .../presentation/mnemonic/backup/BackupMnemonicViewModel.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt index b237af96f5..e9ad5d5af7 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt @@ -23,6 +23,7 @@ import jp.co.soramitsu.backup.BackupService import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.compose.component.MnemonicWordModel import jp.co.soramitsu.common.compose.component.mapMnemonicToMnemonicWords +import jp.co.soramitsu.common.domain.SOLANA_DEFAULT_PATH import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.DEFAULT_DERIVATION_PATH @@ -294,6 +295,8 @@ class BackupMnemonicViewModel @Inject constructor( val substrateDerivationPath = substrateDerivationPath.value val ethereumDerivationPath = ethereumDerivationPath.value.ifEmpty { BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH } + val solanaDerivationPath = payload.createExtras?.solanaDerivationPath ?: SOLANA_DEFAULT_PATH + AddAccountPayload.SubstrateOrEvm( payload.accountName, mnemonicString, @@ -301,6 +304,7 @@ class BackupMnemonicViewModel @Inject constructor( substrateDerivationPath, ethereumDerivationPath, null, + solanaDerivationPath, false ) } From 8b059cb674c5bb529797597c9ab835170f6b17cf Mon Sep 17 00:00:00 2001 From: William Richter Date: Tue, 27 Jan 2026 09:06:58 +0100 Subject: [PATCH 11/21] fix: ensure solana derivation default --- .../presentation/mnemonic/backup/BackupMnemonicViewModel.kt | 4 +++- .../wallet/impl/viewmodels/BalanceListViewModelTests.kt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt index e9ad5d5af7..d797a595f2 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt @@ -295,7 +295,7 @@ class BackupMnemonicViewModel @Inject constructor( val substrateDerivationPath = substrateDerivationPath.value val ethereumDerivationPath = ethereumDerivationPath.value.ifEmpty { BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH } - val solanaDerivationPath = payload.createExtras?.solanaDerivationPath ?: SOLANA_DEFAULT_PATH + val solanaDerivationPath = SOLANA_DEFAULT_PATH AddAccountPayload.SubstrateOrEvm( payload.accountName, @@ -331,6 +331,7 @@ class BackupMnemonicViewModel @Inject constructor( val substrateDerivationPath = substrateDerivationPath.value val ethereumDerivationPath = ethereumDerivationPath.value.ifEmpty { BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH } + val solanaDerivationPath = SOLANA_DEFAULT_PATH AddAccountPayload.SubstrateOrEvm( payload.accountName, mnemonicString, @@ -338,6 +339,7 @@ class BackupMnemonicViewModel @Inject constructor( substrateDerivationPath, ethereumDerivationPath, null, + solanaDerivationPath, false ) } diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/viewmodels/BalanceListViewModelTests.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/viewmodels/BalanceListViewModelTests.kt index 5e773f7a8d..5182f3dc8a 100644 --- a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/viewmodels/BalanceListViewModelTests.kt +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/viewmodels/BalanceListViewModelTests.kt @@ -251,6 +251,7 @@ class BalanceListViewModelTests { ethereumAddress = ByteArray(32), ethereumPublicKey = ByteArray(32), tonPublicKey = ByteArray(32), + solanaPublicKey = ByteArray(32), isSelected = true, name = "name", isBackedUp = true, @@ -267,6 +268,7 @@ class BalanceListViewModelTests { ethereumAddress = ByteArray(32), ethereumPublicKey = ByteArray(32), tonPublicKey = ByteArray(32), + solanaPublicKey = ByteArray(32), isSelected = false, isBackedUp = true, googleBackupAddress = "", From f496da6acccc4eca1d84c0b220186d93758e39bc Mon Sep 17 00:00:00 2001 From: William Richter Date: Tue, 27 Jan 2026 09:51:36 +0100 Subject: [PATCH 12/21] chore: harden solana chain fallback --- docs/roadmap.md | 2 + docs/status.md | 1 + .../multiNetwork/chain/ChainSyncService.kt | 8 ++++ .../chain/ChainSyncServiceTest.kt | 48 +++++++++++++++---- .../runtime/RuntimeProviderTest.kt | 4 ++ 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index d2a1d88170..cdb1ac6cf4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -107,6 +107,8 @@ Priority: P0 (must-do), P1 (should-do), P2 (nice-to-have) 2) Add Solana key derivation/signing utilities (libsodium/ed25519) and hook into `MetaAccount` creation/import flows plus backups. 3) Merge Solana balances into wallet/all-networks viewmodels, gate UI by capability flag until RPC connectivity verified, and drive Compose/View adapters to show the 🐸 tag when `isMemecoin=true`. 4) Seed token metadata source with SPL memecoins plus flag propagation, add UI unit tests ensuring emoji rendering, and document QA steps (create/import, list assets, send SOL/memecoin). +- Progress: + - Chain sync now injects the bundled Solana definition whenever the remote registry omits it, ensuring the chain (with SOL + memecoin assets) is always visible in “All Networks.” 8) Solana DeFi actions with XOR maintenance fee - Why: Deliver parity DeFi features (swap/liquidity/staking) on Solana while funding maintenance via a transparent 0.5% fee routed through XOR, aligning with existing Polkaswap economics. diff --git a/docs/status.md b/docs/status.md index 70d69b57b9..0a74717489 100644 --- a/docs/status.md +++ b/docs/status.md @@ -28,6 +28,7 @@ This snapshot summarizes the current health, feature coverage, and key risks of ## Runtime & Chains - Default types/chains under `runtime/src/main/assets`. Override via `TYPES_URL_OVERRIDE`, `DEFAULT_V13_TYPES_URL_OVERRIDE`, `CHAINS_URL_OVERRIDE` in `local.properties`. - ChainRegistry coordinates runtime providers and connections. EVM handled via `EthereumEnvironmentConfigurator` and `EthereumConnectionPool`. +- Solana fallback metadata is now injected from the bundled definition whenever the remote registry omits it, so SOL and the initial memecoin set always appear in “All Networks” (dedicated RPC health checks and UI tagging remain to-do). ## Polkadot SDK Alignment - Target: polkadot-stable2503 (prepared via override keys). diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncService.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncService.kt index 3bccb2b565..4aa7cb684e 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncService.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncService.kt @@ -14,6 +14,7 @@ import jp.co.soramitsu.coredb.model.chain.ChainLocal import jp.co.soramitsu.coredb.model.chain.ChainNodeLocal import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.remote.ChainFetcher +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.job @@ -43,6 +44,13 @@ class ChainSyncService( .map { it.toChain() } + .let { chains -> + if (chains.any { it.id == SolanaChainDefinition.CHAIN_ID }) { + chains + } else { + chains + SolanaChainDefinition.chain + } + } val remoteMapping = remoteChains.associateBy(Chain::id) diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt index 768c107808..ac20571bfe 100644 --- a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt @@ -10,6 +10,7 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.remote.ChainFetcher import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainAssetRemote import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainNodeRemote import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition import jp.co.soramitsu.testshared.argThat import jp.co.soramitsu.testshared.eq import kotlinx.coroutines.runBlocking @@ -46,7 +47,8 @@ class ChainSyncServiceTest { isNative = null, ethereumType = null, priceProvider = null, - tonType = null + tonType = null, + coinbaseUrl = null ) ), nodes = listOf( @@ -87,6 +89,7 @@ class ChainSyncServiceTest { @Before fun setup() { chainSyncService = ChainSyncService(dao, chainFetcher, metaAccountDao, assetsDao, contextManager) + `when`(metaAccountDao.getMetaAccounts()).thenReturn(emptyList()) } @Test @@ -97,7 +100,10 @@ class ChainSyncServiceTest { chainSyncService.syncUp() - verify(dao).update(removed = eq(emptyList()), newOrUpdated = insertsChainWithId(REMOTE_CHAIN.chainId)) + verify(dao).updateChains( + chainsToAdd = containsChainLocalWithId(REMOTE_CHAIN.chainId), + chainsToUpdate = eq(emptyList()) + ) } } @@ -109,7 +115,10 @@ class ChainSyncServiceTest { chainSyncService.syncUp() - verify(dao).update(removed = eq(emptyList()), newOrUpdated = eq(emptyList())) + verify(dao).updateChains( + chainsToAdd = eq(emptyList()), + chainsToUpdate = eq(emptyList()) + ) } } @@ -123,7 +132,10 @@ class ChainSyncServiceTest { chainSyncService.syncUp() - verify(dao).update(removed = eq(emptyList()), newOrUpdated = insertsChainWithId(REMOTE_CHAIN.chainId)) + verify(dao).updateChains( + chainsToAdd = eq(emptyList()), + chainsToUpdate = containsChainLocalWithId(REMOTE_CHAIN.chainId) + ) } } @@ -138,9 +150,25 @@ class ChainSyncServiceTest { chainSyncService.syncUp() - verify(dao).update( - removed = removesChainWithId(REMOTE_CHAIN.chainId), - newOrUpdated = insertsChainWithId(secondChain.chainId) + verify(dao).updateChains( + chainsToAdd = containsChainLocalWithId(secondChain.chainId), + chainsToUpdate = eq(emptyList()) + ) + verify(dao).deleteChains(removesChainWithId(REMOTE_CHAIN.chainId)) + } + } + + @Test + fun `should append solana chain when remote does not expose it`() { + runBlocking { + localReturns(emptyList()) + remoteReturns(emptyList()) + + chainSyncService.syncUp() + + verify(dao).updateChains( + chainsToAdd = containsChainLocalWithId(SolanaChainDefinition.CHAIN_ID), + chainsToUpdate = eq(emptyList()) ) } } @@ -154,10 +182,10 @@ class ChainSyncServiceTest { } private fun removesChainWithId(id: String) = argThat> { - it.size == 1 && it.first().id == id + it.any { chain -> chain.id == id } } - private fun insertsChainWithId(id: String) = argThat> { - it.size == 1 && it.first().chain.id == id + private fun containsChainLocalWithId(id: String) = argThat> { + it.any { chain -> chain.id == id } } } diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt index 612aa153cf..8a1b447575 100644 --- a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt @@ -4,6 +4,7 @@ import jp.co.soramitsu.common.domain.NetworkStateService import jp.co.soramitsu.core.runtime.ConstructedRuntime import jp.co.soramitsu.core.runtime.RuntimeFactory import jp.co.soramitsu.coredb.dao.ChainDao +import jp.co.soramitsu.coredb.model.chain.ChainRuntimeInfoLocal import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot import jp.co.soramitsu.testshared.any @@ -63,6 +64,9 @@ class RuntimeProviderTest { whenever(constructedRuntime.runtime).thenReturn(runtime) whenever(runtimeFactory.constructRuntime(any(), any(), any())).thenReturn(constructedRuntime) + whenever(chainDao.runtimeInfo(chain.id)).thenReturn(ChainRuntimeInfoLocal(chain.id, 1, 1)) + whenever(chainDao.getTypes(chain.id)).thenReturn("types") + whenever(runtimeFilesCache.getChainMetadata(chain.id)).thenReturn("metadata") whenever(runtimeSyncService.syncResultFlow(eq(chain.id))).thenAnswer { chainSyncFlow } } From 7efab84c327d55289671982528dd324610942bcb Mon Sep 17 00:00:00 2001 From: William Richter Date: Tue, 27 Jan 2026 11:39:06 +0100 Subject: [PATCH 13/21] chain fixes, milestone 1 --- .../common/domain/SolanaConstants.kt | 5 ++ .../res/drawable/background_banner_solana.xml | 11 +++ common/src/main/res/values-vi/strings.xml | 2 + common/src/main/res/values/strings.xml | 2 + .../coredb/model/MetaAccountLocal.kt | 4 +- docs/roadmap.md | 19 +++- fearless-upload.cer | 32 +++++++ .../account/api/domain/model/AccountType.kt | 8 +- .../account/api/domain/model/MetaAccount.kt | 8 +- .../impl/data/repository/KeyPairRepository.kt | 4 +- .../details/AccountDetailsInteractor.kt | 6 +- .../account/create/CreateAccountViewModel.kt | 7 +- .../impl/welcome/SelectEcosystemScreen.kt | 11 ++- .../impl/welcome/WelcomeViewModel.kt | 10 +++ .../wallet/impl/domain/ChainInteractor.kt | 12 ++- .../multiNetwork/chain/ChainSyncService.kt | 21 +++-- .../solana/SolanaDevnetChainDefinition.kt | 89 +++++++++++++++++++ .../chain/ChainSyncServiceTest.kt | 32 ++++++- 18 files changed, 255 insertions(+), 28 deletions(-) create mode 100644 common/src/main/res/drawable/background_banner_solana.xml create mode 100644 fearless-upload.cer create mode 100644 runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaDevnetChainDefinition.kt diff --git a/common/src/main/java/jp/co/soramitsu/common/domain/SolanaConstants.kt b/common/src/main/java/jp/co/soramitsu/common/domain/SolanaConstants.kt index 4b4e7ad7a9..765fa4f1e0 100644 --- a/common/src/main/java/jp/co/soramitsu/common/domain/SolanaConstants.kt +++ b/common/src/main/java/jp/co/soramitsu/common/domain/SolanaConstants.kt @@ -1,4 +1,9 @@ package jp.co.soramitsu.common.domain const val SOLANA_CHAIN_ID = "solana-mainnet" +const val SOLANA_DEVNET_CHAIN_ID = "solana-devnet" const val SOLANA_DEFAULT_PATH = "m/44'/501'/0'/0'" + +fun isSolanaChainId(chainId: String): Boolean { + return chainId == SOLANA_CHAIN_ID || chainId == SOLANA_DEVNET_CHAIN_ID +} diff --git a/common/src/main/res/drawable/background_banner_solana.xml b/common/src/main/res/drawable/background_banner_solana.xml new file mode 100644 index 0000000000..db350723c4 --- /dev/null +++ b/common/src/main/res/drawable/background_banner_solana.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/common/src/main/res/values-vi/strings.xml b/common/src/main/res/values-vi/strings.xml index 58d249bab8..b8aeb17078 100644 --- a/common/src/main/res/values-vi/strings.xml +++ b/common/src/main/res/values-vi/strings.xml @@ -615,6 +615,8 @@ Thông báo Tham gia EVM hoặc Substrate Tạo hoặc nhập tài khoản Substrate hoặc EVM + Use Solana networks + Create or import Solana accounts Tham gia TON Kết nối với hệ sinh thái phát triển nhanh nhất từ trước đến nay Tiếp tục với Google diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 03d4dc2c63..daebe59cd0 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -627,6 +627,8 @@ Notification Join EVM or Substrate Create or import Substrate or EVM accounts + Use Solana networks + Create or import Solana accounts Join TON Connect to the fastest growing ecosystem ever Connect with Google diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt index c0ddf96e03..639e78737c 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/MetaAccountLocal.kt @@ -6,7 +6,7 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation -import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID +import jp.co.soramitsu.common.domain.isSolanaChainId import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.models.IChain @@ -123,7 +123,7 @@ class MetaAccountPositionUpdate( // similar to fun MetaAccount.accountId(chain: IChain) fun MetaAccountLocal.accountId(chain: IChain): ByteArray? { return when { - chain.id == SOLANA_CHAIN_ID -> solanaPublicKey + isSolanaChainId(chain.id) -> solanaPublicKey chain.ecosystem == Ecosystem.Ton -> tonPublicKey chain.ecosystem == Ecosystem.Substrate -> substrateAccountId chain.ecosystem == Ecosystem.Ethereum || chain.ecosystem == Ecosystem.EthereumBased -> ethereumAddress diff --git a/docs/roadmap.md b/docs/roadmap.md index cdb1ac6cf4..09919398a6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -120,7 +120,24 @@ Priority: P0 (must-do), P1 (should-do), P2 (nice-to-have) - Prompt: 1) Integrate a Solana aggregator SDK (e.g., Jupiter) behind existing DeFi abstractions and add mocks for unit tests. 2) Build a fee service that calculates 0.5% of the Solana transaction value, performs (or schedules) the XOR conversion/transfer, and surfaces the maintenance destination for transparency. - 3) Update confirmation UI to surface fee line items, add telemetry/alerting for missing XOR settlements, and expand QA checklist (swap SOL↔SPL, provide liquidity, collect XOR fee evidence). + 3) Update confirmation UI to surface fee line items, add telemetry/alerting for missing XOR settlements, and expand QA checklist (swap SOL↔SPL, provide liquidity, collect XOR fee evidence). + +## UI Updates + +**Milestone 1 – Solana Devnet Support** +- Extend chain metadata and registry wiring to include `solana-devnet` (nodes, explorers, icons, ecosystem flags) so wallets can target the network. +- Surface Solana explicitly in create/import flows (new `AccountType` or toggle) so users can generate/import Solana-only accounts. +- Verify both Solana networks appear in chain selectors and wallet views without regressions. + +**Milestone 2 – Navigation Refresh** +- Update bottom navigation to `Wallet – Staking – Polkaswap – NFT – Settings`, removing the crowdloans entry and spacing the icons evenly. +- Remove the NFT toggle tab from the wallet screen; route NFT content through the new bottom tab instead. +- Ensure the FAB and Polkaswap navigation continue to behave correctly. + +**Milestone 3 – Settings Cleanup & Donate CTA** +- Remove crowdloan entries from Settings. +- Add a “Donate to SORAMITSU” option that opens the Send screen prefilled for SORA Mainnet with the donation address (`cnVRSRjJmpVaRKhhYt6eKDbpRi1FUdKSi7VkWQTUXobqrwCTd`) sourced from a single configurable constant. +- Verify the new option works end-to-end and is easy to update if the address changes. ## P1 — Medium Priority diff --git a/fearless-upload.cer b/fearless-upload.cer new file mode 100644 index 0000000000..9f71f0dbf7 --- /dev/null +++ b/fearless-upload.cer @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFfTCCA2WgAwIBAgIIM1FB8T9ukmQwDQYJKoZIhvcNAQEMBQAwbDELMAkGA1UE +BhMCSlAxDjAMBgNVBAgTBVRva3lvMQ4wDAYDVQQHEwVUb2t5bzESMBAGA1UEChMJ +U29yYW1pdHN1MQ8wDQYDVQQLEwZNb2JpbGUxGDAWBgNVBAMTD0ZlYXJsZXNzIFdh +bGxldDAgFw0yNjAxMTUxMzE5NDRaGA8yMDUxMDEwOTEzMTk0NFowbDELMAkGA1UE +BhMCSlAxDjAMBgNVBAgTBVRva3lvMQ4wDAYDVQQHEwVUb2t5bzESMBAGA1UEChMJ +U29yYW1pdHN1MQ8wDQYDVQQLEwZNb2JpbGUxGDAWBgNVBAMTD0ZlYXJsZXNzIFdh +bGxldDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK8hHDHxE0jU9Jdf +JYNp8nAUabNhON2jxd6SzwVpYwHxFTZ9nXHR6EfZ3gLQiJ0BFJyp9C7Rim5OgChq +UBBHh0bLbYQrLH5NMYISzUNYW/fvfkhuum1QeWaUF0memow/zcaxveCZnHuVh7iH +ZKwGMBj/t52dONimPd8WY60krpE3WtWzzmunHBaI31GFbfs1IUfrK2XGY7YvLjyZ +36XVkzytB3+fochh5kwVBae9QjKhGU8bBicfaSZapwYnPO8VfXtRaApgBUpwykiI +HpUC5NoJzHna8io8me4Mh1Z3k8yjC9Y1dRtxuwlOA2vSEeiJQKQiGWhIpnRX8zVS +QdqE0A15h070p3Npf3scAvtd3d8ONKC013XLfqxTyclpKUd5mDvKrFbmkHS02TSE +RtQa43RRCZy7uZrAbDHalhZX1C/gJ1wF588i2IKc+NloBSN7NuIPQJpCoIyiNhd+ +VjP121n8Dph/ol55eBUO63dxHlNAUbaig3oLxkMEauoSrSkE6MdaKJYmTZR09iDF +zxdlaw3mVL2M86ktj62VwkAGKtFYQPCuVUV+MVcNuZ947LyOY98RhEhPfrE5SjP6 +TV2SD4vNq5JgitUtPLt6SvZtPdvfiFHiet7T+Cyjc6+vjlLiDyR0aWdXRaWj5m9+ +j9q0VqO2Bz78wBt7cTjtehyPaVi1AgMBAAGjITAfMB0GA1UdDgQWBBTrC3vz4W6m +B6U6/A4gLOV6qSWKYzANBgkqhkiG9w0BAQwFAAOCAgEAosuwykX6HqF6oeW94Wp/ +u9wHOcBZlBI30+uKJ3T536FDeLb/MmFGqp9sgK5hyduZO0hvMVTRYcIXrziR/P/F +4NNZsQLxVYiDFX+V4XVNzWIqoJ5QdIVMEegB8cvIAC1ULoXLOQvmc/xflPQwguxd +39ioGbtb2yhXVgQGD2A+BQU/5XcvzEO+qhop/KAdecqOZBRg/H7ZPerhJxO9pJIi +T/2KDIPlRF0EkO0nM6HCYElpeSuGQLx5W4YMTZ+P3nHvzYV/43bn1JaZHKmhPxK8 +DwkbPVfUEDXoePdvxp9YjjB01ddta4OZem7cI0t9nPce2nVzraDFbd1nnXhjUx4J +KjD3cSpcnKjNmBP+H7JuqXz7ktQXGFrQ3fLfmW/xzdq2QrDcZHnVMoizCNfBrsOD +e+eDx/wtsMAO8reYYUIwbsqR+14uWhTG50Rbt/NHkZtoiI+BB0nXj7nUvaB+nRyy +r14FM6VytQ09dik/EozRB542+MNmlhE2GRU+vgNTyPwpDWyyjNkBSvopNY2s/nw0 +Fr9+JlJKde3x+MONS4ZuduSS+ZNQAsY0dD7PxxsHW/Xb3Q1iY2FcjbP9xcSje3Yv +Ip/t4YrSdDNmUiFT9d7DNF9EzqmwgEMA/ICgFkWWRxe1ESg7wtEFG8zpgZkV37uM +dWhru1M3jnNEOF2xKedG8uw= +-----END CERTIFICATE----- diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AccountType.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AccountType.kt index 35880735a7..8c2e24819d 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AccountType.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AccountType.kt @@ -4,6 +4,8 @@ import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize -enum class AccountType: Parcelable { - SubstrateOrEvm, Ton -} \ No newline at end of file +enum class AccountType : Parcelable { + SubstrateOrEvm, + Ton, + Solana +} diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt index 5ca31e249c..3e4f5ea0a5 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.account.api.domain.model -import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID +import jp.co.soramitsu.common.domain.isSolanaChainId import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.common.utils.Base58Ext.toBase58 import jp.co.soramitsu.common.utils.ethereumAddressToHex @@ -155,7 +155,7 @@ fun MetaAccount.address(chain: Chain): String? { return kotlin.runCatching { when { hasChainAccount(chain.id) -> chain.addressOf(chainAccounts.getValue(chain.id).accountId) - chain.id == SOLANA_CHAIN_ID -> solanaPublicKey?.toBase58() + isSolanaChainId(chain.id) -> solanaPublicKey?.toBase58() chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> ethereumAddress?.ethereumAddressToHex() chain.ecosystem == Ecosystem.Ton -> { tonPublicKey?.v4r2tonAddress(chain.isTestNet) @@ -169,7 +169,7 @@ fun MetaAccount.address(chain: Chain): String? { fun LightMetaAccount.address(chain: Chain): String? { return kotlin.runCatching { when { - chain.id == SOLANA_CHAIN_ID -> solanaPublicKey?.toBase58() + isSolanaChainId(chain.id) -> solanaPublicKey?.toBase58() chain.ecosystem == Ecosystem.Substrate -> substrateAccountId?.toAddress(chain.addressPrefix.toShort()) chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> ethereumAddress?.ethereumAddressToHex() chain.ecosystem == Ecosystem.Ton -> tonPublicKey?.v4r2tonAddress(chain.isTestNet) @@ -202,7 +202,7 @@ fun MetaAccount.chainAddress(chain: Chain): String? { fun MetaAccount.accountId(chain: IChain): ByteArray? { return when { hasChainAccount(chain.id) -> chainAccounts.getValue(chain.id).accountId - chain.id == SOLANA_CHAIN_ID -> solanaPublicKey + isSolanaChainId(chain.id) -> solanaPublicKey chain.ecosystem == Ecosystem.Substrate -> substrateAccountId chain.ecosystem == Ecosystem.Ethereum || chain.ecosystem == Ecosystem.EthereumBased -> ethereumAddress //Attention!!! Use tonPublicKey as accountId only internally in fearless wallet. For api requests use ByteArray.tonAccountId(): String function extension diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt index 7e3eda0faa..63e0e814be 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt @@ -21,7 +21,7 @@ import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.models.IChain import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID +import jp.co.soramitsu.common.domain.isSolanaChainId class KeyPairRepository( private val secretStoreV2: SecretStoreV2, @@ -47,7 +47,7 @@ class KeyPairRepository( secretStoreV2.hasChainSecrets(metaAccount.id, accountId) -> { secretStoreV2.getChainAccountKeypair(metaAccount.id, accountId) } - chain.id == SOLANA_CHAIN_ID -> { + isSolanaChainId(chain.id) -> { solanaSecretStore.get(metaAccount.id)?.let { Keypair(it[SolanaSecrets.PublicKey], it[SolanaSecrets.PrivateKey]) } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt index a9fe0614a0..b819de4d3d 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt @@ -15,7 +15,7 @@ import jp.co.soramitsu.account.impl.domain.account.details.AccountInChain.From import jp.co.soramitsu.common.data.secrets.v2.ChainAccountSecrets import jp.co.soramitsu.common.list.GroupedList import jp.co.soramitsu.common.model.AssetKey -import jp.co.soramitsu.common.domain.SOLANA_CHAIN_ID +import jp.co.soramitsu.common.domain.isSolanaChainId import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.common.utils.flowOf import jp.co.soramitsu.core.models.Ecosystem @@ -105,7 +105,7 @@ class AccountDetailsInteractor( ) { metaAccount, chains -> metaAccount to chains.filter { chain -> when { - chain.id == SOLANA_CHAIN_ID -> metaAccount.hasSolana + isSolanaChainId(chain.id) -> metaAccount.hasSolana chain.ecosystem == Ecosystem.Substrate -> metaAccount.hasSubstrate chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> metaAccount.hasEthereum chain.ecosystem == Ecosystem.Ton -> metaAccount.hasTon @@ -113,7 +113,7 @@ class AccountDetailsInteractor( } || metaAccount.hasChainAccount(chain.id) }.groupBy { chain -> when { - chain.id == SOLANA_CHAIN_ID -> WalletEcosystem.Solana + isSolanaChainId(chain.id) -> WalletEcosystem.Solana chain.ecosystem == Ecosystem.Substrate -> WalletEcosystem.Substrate chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> WalletEcosystem.Ethereum chain.ecosystem == Ecosystem.Ton -> WalletEcosystem.Ton diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/create/CreateAccountViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/create/CreateAccountViewModel.kt index 7847aa9e09..56f29c5c90 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/create/CreateAccountViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/create/CreateAccountViewModel.kt @@ -80,8 +80,13 @@ class CreateAccountViewModel @Inject constructor( fun screenshotWarningConfirmed() { val accountTypes = when (accountMode) { - AccountType.SubstrateOrEvm -> listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum, WalletEcosystem.Solana) + AccountType.SubstrateOrEvm -> listOf( + WalletEcosystem.Substrate, + WalletEcosystem.Ethereum, + WalletEcosystem.Solana + ) AccountType.Ton -> listOf(WalletEcosystem.Ton) + AccountType.Solana -> listOf(WalletEcosystem.Solana) } router.openMnemonicScreen(walletNickname.value, accountTypes) } diff --git a/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/SelectEcosystemScreen.kt b/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/SelectEcosystemScreen.kt index 2200696d57..81f350c92e 100644 --- a/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/SelectEcosystemScreen.kt +++ b/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/SelectEcosystemScreen.kt @@ -41,6 +41,7 @@ interface SelectEcosystemScreenCallbacks { fun termsClicked() fun substrateEvmClick() fun tonClick() + fun solanaClick() } @Suppress("FunctionName") @@ -88,6 +89,13 @@ private fun SelectEcosystemScreenContent( onClick = callbacks::substrateEvmClick ) MarginVertical(12.dp) + EcosystemCard( + stringResource(R.string.onboarding_banner_solana_ecosystem_title), + stringResource(R.string.onboarding_banner_solana_ecosystem_button_title), + R.drawable.background_banner_solana, + onClick = callbacks::solanaClick + ) + MarginVertical(12.dp) EcosystemCard( stringResource(R.string.onboarding_banner_ton_ecosystem_title), stringResource(R.string.onboarding_banner_ton_ecosystem_button_title), @@ -168,6 +176,7 @@ fun SelectEcosystemScreenPreview() { override fun termsClicked() = Unit override fun substrateEvmClick() = Unit override fun tonClick() = Unit + override fun solanaClick() = Unit }) } -} \ No newline at end of file +} diff --git a/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/WelcomeViewModel.kt b/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/WelcomeViewModel.kt index b586c4a502..283bffab9d 100644 --- a/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/WelcomeViewModel.kt +++ b/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/WelcomeViewModel.kt @@ -136,6 +136,7 @@ class WelcomeViewModel @Inject constructor( when (accountType) { AccountType.SubstrateOrEvm -> router.openCreateAccountFromOnboarding(accountType) AccountType.Ton -> silentCreateTonAccount() + AccountType.Solana -> router.openCreateAccountFromOnboarding(accountType) } } @@ -160,6 +161,11 @@ class WelcomeViewModel @Inject constructor( walletEcosystem = WalletEcosystem.Ton, importMode = ImportMode.MnemonicPhrase ) + + AccountType.Solana -> router.openImportAccountScreen( + walletEcosystem = WalletEcosystem.Solana, + importMode = ImportMode.MnemonicPhrase + ) } } @@ -200,6 +206,10 @@ class WelcomeViewModel @Inject constructor( _events.trySend(WelcomeEvent.Onboarding.WelcomeScreen(AccountType.Ton)) } + override fun solanaClick() { + _events.trySend(WelcomeEvent.Onboarding.WelcomeScreen(AccountType.Solana)) + } + override fun privacyClicked() { openBrowserEvent.value = Event(appLinksProvider.privacyUrl) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/ChainInteractor.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/ChainInteractor.kt index 29b42a9722..099243d184 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/ChainInteractor.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/ChainInteractor.kt @@ -10,6 +10,7 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.mapToPriceProvider import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaDevnetChainDefinition import jp.co.soramitsu.wallet.api.domain.model.XcmChainType import jp.co.soramitsu.xcm.domain.XcmEntitiesFetcher import kotlinx.coroutines.Dispatchers @@ -23,8 +24,15 @@ class ChainInteractor( private val xcmEntitiesFetcher: XcmEntitiesFetcher ) { fun getChainsFlow() = chainDao.joinChainInfoFlow().mapList { mapChainLocalToChain(it) }.map { chains -> - val hasSolana = chains.any { it.id == SolanaChainDefinition.CHAIN_ID } - val augmented = if (hasSolana) chains else chains + SolanaChainDefinition.chain + val augmented = chains.toMutableList() + listOf( + SolanaChainDefinition.chain, + SolanaDevnetChainDefinition.chain + ).forEach { fallback -> + if (augmented.none { it.id == fallback.id }) { + augmented += fallback + } + } augmented.sortedWith(chainDefaultSort()) } diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncService.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncService.kt index 4aa7cb684e..4a97b6feea 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncService.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncService.kt @@ -15,6 +15,7 @@ import jp.co.soramitsu.coredb.model.chain.ChainNodeLocal import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.remote.ChainFetcher import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaDevnetChainDefinition import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.job @@ -31,7 +32,12 @@ class ChainSyncService( ) { suspend fun syncUp() { - kotlin.runCatching { configChainsSyncUp() }.onFailure { it.printStackTrace() }.getOrNull() ?: return + kotlin.runCatching { configChainsSyncUp() } + .onFailure { + it.printStackTrace() + throw it + } + .getOrNull() ?: return } private suspend fun configChainsSyncUp(): List = supervisorScope { @@ -39,17 +45,21 @@ class ChainSyncService( val localChainsJoinedInfo = dao.getJoinChainInfo() val localChainsJoinedInfoMap = localChainsJoinedInfo.associateBy { it.chain.id } + val fallbackChains = listOf( + SolanaChainDefinition.chain, + SolanaDevnetChainDefinition.chain + ) + val remoteChains = chainFetcher.getChains() .filter { !it.disabled } .map { it.toChain() } .let { chains -> - if (chains.any { it.id == SolanaChainDefinition.CHAIN_ID }) { - chains - } else { - chains + SolanaChainDefinition.chain + val missingFallbacks = fallbackChains.filter { fallback -> + chains.none { it.id == fallback.id } } + chains + missingFallbacks } val remoteMapping = remoteChains.associateBy(Chain::id) @@ -76,6 +86,7 @@ class ChainSyncService( localChain != remoteChain -> chainsToUpdate.add(remoteChain) // updated } } + println("ChainSyncService: updateChains add=${chainsToAdd.size} update=${chainsToUpdate.size}") dao.updateChains(chainsToAdd, chainsToUpdate) chainsToRemove } diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaDevnetChainDefinition.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaDevnetChainDefinition.kt new file mode 100644 index 0000000000..92d5f7d1ed --- /dev/null +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/solana/SolanaDevnetChainDefinition.kt @@ -0,0 +1,89 @@ +package jp.co.soramitsu.runtime.multiNetwork.chain.solana + +import jp.co.soramitsu.common.domain.SOLANA_DEVNET_CHAIN_ID +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainNode +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId + +object SolanaDevnetChainDefinition { + + const val CHAIN_ID: ChainId = SOLANA_DEVNET_CHAIN_ID + private const val ICON_URL = + "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/solana/info/logo.png" + private const val EXPLORER_URL = "https://solscan.io/" + + private val nodes = listOf( + ChainNode( + url = "https://api.devnet.solana.com", + name = "Solana Devnet RPC", + isActive = true, + isDefault = true + ), + ChainNode( + url = "https://rpc.ankr.com/solana_devnet", + name = "Ankr Devnet RPC", + isActive = false, + isDefault = true + ) + ) + + private val baseAsset = Asset( + id = "SOL", + name = "Solana Devnet", + symbol = "SOL", + iconUrl = ICON_URL, + chainId = CHAIN_ID, + priceId = "solana", + precision = 9, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = emptyList(), + chainName = "Solana Devnet", + chainIcon = ICON_URL, + isTestNet = true, + supportStakingPool = false, + isUtility = true, + type = null, + currencyId = null, + existentialDeposit = "0.0001", + color = "#9945FF", + isNative = true, + priceProvider = null, + coinbaseUrl = null + ) + + val chain: Chain = Chain( + id = CHAIN_ID, + paraId = null, + rank = null, + name = "Solana Devnet", + minSupportedVersion = null, + assets = listOf(baseAsset), + nodes = nodes, + explorers = listOf( + Chain.Explorer( + type = Chain.Explorer.Type.UNKNOWN, + types = listOf("account", "transaction"), + url = EXPLORER_URL + ) + ), + externalApi = null, + icon = ICON_URL, + addressPrefix = 0, + isEthereumBased = false, + isTestNet = true, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = true, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Ethereum, + androidMinAppVersion = null, + remoteAssetsSource = null, + tonBridgeUrl = null + ) +} diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt index ac20571bfe..554bf8e6b2 100644 --- a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt @@ -1,5 +1,6 @@ package jp.co.soramitsu.runtime.multiNetwork.chain +import android.util.Log import jp.co.soramitsu.common.resources.ContextManager import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.ChainDao @@ -11,16 +12,20 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainAssetRemote import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainNodeRemote import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainRemote import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaChainDefinition +import jp.co.soramitsu.runtime.multiNetwork.chain.solana.SolanaDevnetChainDefinition import jp.co.soramitsu.testshared.argThat import jp.co.soramitsu.testshared.eq import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` +import org.mockito.Mockito.mockStatic import org.mockito.junit.MockitoJUnitRunner +import org.mockito.MockedStatic @RunWith(MockitoJUnitRunner::class) class ChainSyncServiceTest { @@ -68,6 +73,10 @@ class ChainSyncServiceTest { ) private val LOCAL_CHAIN = mapChainToChainLocal(REMOTE_CHAIN.toChain()) + private val LOCAL_SOLANA = mapChainToChainLocal(SolanaChainDefinition.chain) + private val LOCAL_SOLANA_DEVNET = mapChainToChainLocal(SolanaDevnetChainDefinition.chain) + + private lateinit var logMock: MockedStatic @Mock lateinit var dao: ChainDao @@ -88,10 +97,18 @@ class ChainSyncServiceTest { @Before fun setup() { + logMock = mockStatic(Log::class.java).apply { + `when` { Log.d(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()) }.thenReturn(0) + } chainSyncService = ChainSyncService(dao, chainFetcher, metaAccountDao, assetsDao, contextManager) `when`(metaAccountDao.getMetaAccounts()).thenReturn(emptyList()) } + @After + fun tearDown() { + logMock.close() + } + @Test fun `should insert new chain`() { runBlocking { @@ -110,7 +127,7 @@ class ChainSyncServiceTest { @Test fun `should not insert the same chain`() { runBlocking { - localReturns(listOf(LOCAL_CHAIN)) + localReturns(listOf(LOCAL_CHAIN, LOCAL_SOLANA, LOCAL_SOLANA_DEVNET)) remoteReturns(listOf(REMOTE_CHAIN)) chainSyncService.syncUp() @@ -125,7 +142,7 @@ class ChainSyncServiceTest { @Test fun `should update chain`() { runBlocking { - localReturns(listOf(LOCAL_CHAIN)) + localReturns(listOf(LOCAL_CHAIN, LOCAL_SOLANA, LOCAL_SOLANA_DEVNET)) remoteReturns(listOf(REMOTE_CHAIN.copy(name = "new name"))) @@ -142,7 +159,7 @@ class ChainSyncServiceTest { @Test fun `should remove chain`() { runBlocking { - localReturns(listOf(LOCAL_CHAIN)) + localReturns(listOf(LOCAL_CHAIN, LOCAL_SOLANA, LOCAL_SOLANA_DEVNET)) val secondChain = REMOTE_CHAIN.copy(chainId = "0x001") @@ -167,7 +184,10 @@ class ChainSyncServiceTest { chainSyncService.syncUp() verify(dao).updateChains( - chainsToAdd = containsChainLocalWithId(SolanaChainDefinition.CHAIN_ID), + chainsToAdd = containsChains( + SolanaChainDefinition.CHAIN_ID, + SolanaDevnetChainDefinition.CHAIN_ID + ), chainsToUpdate = eq(emptyList()) ) } @@ -188,4 +208,8 @@ class ChainSyncServiceTest { private fun containsChainLocalWithId(id: String) = argThat> { it.any { chain -> chain.id == id } } + + private fun containsChains(vararg ids: String) = argThat> { + ids.all { id -> it.any { chain -> chain.id == id } } + } } From 9234a09a4cf80c4efdb430f5ee0e37fce702b727 Mon Sep 17 00:00:00 2001 From: William Richter Date: Tue, 27 Jan 2026 14:47:07 +0100 Subject: [PATCH 14/21] feat(ui): refresh bottom nav and move NFTs to dedicated tab --- .../root/presentation/main/MainFragment.kt | 7 +- .../main/res/navigation/bottom_nav_graph.xml | 7 +- common/src/main/res/drawable/ic_nav_nft.xml | 15 ++++ .../main/res/drawable/ic_nav_polkaswap.xml | 30 +++++++ .../src/main/res/menu/bottom_navigations.xml | 22 ++++- .../main/res/menu/bottom_navigations_fab.xml | 14 +-- common/src/main/res/values-vi/strings.xml | 1 + common/src/main/res/values/strings.xml | 1 + .../balance/list/BalanceListFragment.kt | 6 +- .../balance/list/BalanceListViewModel.kt | 14 +-- .../presentation/balance/list/WalletScreen.kt | 33 +------ .../presentation/balance/list/WalletState.kt | 8 +- .../nft/list/NftCollectionsFragment.kt | 85 +++++++++++++++++++ .../impl/domain/model/WalletStateTest.kt | 3 - 14 files changed, 177 insertions(+), 69 deletions(-) create mode 100644 common/src/main/res/drawable/ic_nav_nft.xml create mode 100644 common/src/main/res/drawable/ic_nav_polkaswap.xml create mode 100644 feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/nft/list/NftCollectionsFragment.kt diff --git a/app/src/main/java/jp/co/soramitsu/app/root/presentation/main/MainFragment.kt b/app/src/main/java/jp/co/soramitsu/app/root/presentation/main/MainFragment.kt index 80b905c7ab..5637d7d846 100644 --- a/app/src/main/java/jp/co/soramitsu/app/root/presentation/main/MainFragment.kt +++ b/app/src/main/java/jp/co/soramitsu/app/root/presentation/main/MainFragment.kt @@ -78,7 +78,12 @@ class MainFragment : BaseFragment(R.layout.fragment_main) { binding.bottomNavigationViewWithFab.setupWithNavController(navController!!) binding.bottomNavigationView.setOnItemSelectedListener { item -> - onNavDestinationSelected(item, navController!!) + if (item.itemId == R.id.swapTokensFragment) { + viewModel.navigateToSwapScreen() + false + } else { + onNavDestinationSelected(item, navController!!) + } } binding.bottomNavigationViewWithFab.setOnItemSelectedListener { item -> onNavDestinationSelected(item, navController!!) diff --git a/app/src/main/res/navigation/bottom_nav_graph.xml b/app/src/main/res/navigation/bottom_nav_graph.xml index 74b3e57916..f6b15d452a 100644 --- a/app/src/main/res/navigation/bottom_nav_graph.xml +++ b/app/src/main/res/navigation/bottom_nav_graph.xml @@ -26,6 +26,11 @@ android:name="jp.co.soramitsu.staking.impl.presentation.staking.main.StakingFragment" android:label="StakingFragment" /> + + - \ No newline at end of file + diff --git a/common/src/main/res/drawable/ic_nav_nft.xml b/common/src/main/res/drawable/ic_nav_nft.xml new file mode 100644 index 0000000000..67cf1b4221 --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_nft.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_nav_polkaswap.xml b/common/src/main/res/drawable/ic_nav_polkaswap.xml new file mode 100644 index 0000000000..593b1d9047 --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_polkaswap.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/common/src/main/res/menu/bottom_navigations.xml b/common/src/main/res/menu/bottom_navigations.xml index e3864e4e5e..e2cf7e53cc 100644 --- a/common/src/main/res/menu/bottom_navigations.xml +++ b/common/src/main/res/menu/bottom_navigations.xml @@ -11,10 +11,24 @@ + + + + - \ No newline at end of file + diff --git a/common/src/main/res/menu/bottom_navigations_fab.xml b/common/src/main/res/menu/bottom_navigations_fab.xml index 3111cb0bc8..b064dba272 100644 --- a/common/src/main/res/menu/bottom_navigations_fab.xml +++ b/common/src/main/res/menu/bottom_navigations_fab.xml @@ -10,10 +10,10 @@ app:showAsAction="ifRoom" />