diff --git a/Gem/Navigation/NavigationHandler.swift b/Gem/Navigation/NavigationHandler.swift index 22e7be516..cf293d6be 100644 --- a/Gem/Navigation/NavigationHandler.swift +++ b/Gem/Navigation/NavigationHandler.swift @@ -146,7 +146,7 @@ extension NavigationHandler { } private func navigateToTransaction(walletIndex: Int?, walletId: String, assetId: AssetId, transaction: Primitives.Transaction) async throws { - guard let walletId = walletService.walletId(walletIndex: walletIndex, walletTypeId: walletId) else { + guard let walletId = walletService.walletId(walletIndex: walletIndex, walletId: walletId) else { throw AnyError("Wallet not found") } let asset = try await assetsService.getOrFetchAsset(for: assetId) diff --git a/GemTests/unit_frameworks.xctestplan b/GemTests/unit_frameworks.xctestplan index 26310015d..e5d88ff04 100644 --- a/GemTests/unit_frameworks.xctestplan +++ b/GemTests/unit_frameworks.xctestplan @@ -18,72 +18,79 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Features\/NFT", - "identifier" : "NFTTests", - "name" : "NFTTests" + "containerPath" : "container:Packages\/Style", + "identifier" : "StyleTests", + "name" : "StyleTests" } }, { "target" : { - "containerPath" : "container:Packages\/Style", - "identifier" : "StyleTests", - "name" : "StyleTests" + "containerPath" : "container:Packages\/Keystore", + "identifier" : "KeystoreTests", + "name" : "KeystoreTests" } }, { "target" : { - "containerPath" : "container:Features\/FiatConnect", - "identifier" : "FiatConnectTests", - "name" : "FiatConnectTests" + "containerPath" : "container:Packages\/WalletCore", + "identifier" : "WalletCorePrimitivesTests", + "name" : "WalletCorePrimitivesTests" } }, { "target" : { - "containerPath" : "container:Features\/LockManager", - "identifier" : "LockManagerTests", - "name" : "LockManagerTests" + "containerPath" : "container:Features\/Staking", + "identifier" : "StakingTests", + "name" : "StakingTests" } }, { "target" : { - "containerPath" : "container:Packages\/Validators", - "identifier" : "ValidatorsTests", - "name" : "ValidatorsTests" + "containerPath" : "container:Features\/ManageWallets", + "identifier" : "ManageWalletsTests", + "name" : "ManageWalletsTests" } }, { "target" : { - "containerPath" : "container:Features\/Perpetuals", - "identifier" : "PerpetualsTests", - "name" : "PerpetualsTests" + "containerPath" : "container:Packages\/Primitives", + "identifier" : "PrimitivesTests", + "name" : "PrimitivesTests" } }, { "target" : { - "containerPath" : "container:Packages\/Signer", - "identifier" : "SignerTests", - "name" : "SignerTests" + "containerPath" : "container:Packages\/Preferences", + "identifier" : "PreferencesTest", + "name" : "PreferencesTest" } }, { "target" : { - "containerPath" : "container:Packages\/Keystore", - "identifier" : "KeystoreTests", - "name" : "KeystoreTests" + "containerPath" : "container:Packages\/SwiftHTTPClient", + "identifier" : "WebSocketClientTests", + "name" : "WebSocketClientTests" } }, { "target" : { - "containerPath" : "container:Packages\/Store", - "identifier" : "StoreTests", - "name" : "StoreTests" + "containerPath" : "container:Packages\/Validators", + "identifier" : "ValidatorsTests", + "name" : "ValidatorsTests" } }, { "target" : { - "containerPath" : "container:Features\/Staking", - "identifier" : "StakingTests", - "name" : "StakingTests" + "containerPath" : "container:Features\/Onboarding", + "identifier" : "OnboardingTest", + "name" : "OnboardingTest" + } + }, + { + "target" : { + "containerPath" : "container:Features\/WalletConnector", + "identifier" : "WalletConnectorTests", + "name" : "WalletConnectorTests" } }, { @@ -94,106 +101,106 @@ } }, { + "skippedTests" : { + "testFunctions" : [ + "example()" + ] + }, "target" : { - "containerPath" : "container:Features\/PriceAlerts", - "identifier" : "PriceAlertsTests", - "name" : "PriceAlertsTests" + "containerPath" : "container:Features\/Transactions", + "identifier" : "TransactionsTests", + "name" : "TransactionsTests" } }, { "target" : { - "containerPath" : "container:Features\/Settings", - "identifier" : "SettingsTests", - "name" : "SettingsTests" + "containerPath" : "container:Features\/Perpetuals", + "identifier" : "PerpetualsTests", + "name" : "PerpetualsTests" } }, { "target" : { - "containerPath" : "container:Packages\/Formatters", - "identifier" : "FormattersTests", - "name" : "FormattersTests" + "containerPath" : "container:Features\/WalletTab", + "identifier" : "WalletTabTests", + "name" : "WalletTabTests" } }, { "target" : { - "containerPath" : "container:Packages\/Primitives", - "identifier" : "PrimitivesTests", - "name" : "PrimitivesTests" + "containerPath" : "container:Packages\/GemAPI", + "identifier" : "GemAPITests", + "name" : "GemAPITests" } }, { - "skippedTests" : { - "testFunctions" : [ - "example()" - ] - }, "target" : { - "containerPath" : "container:Features\/Transactions", - "identifier" : "TransactionsTests", - "name" : "TransactionsTests" + "containerPath" : "container:Packages\/FeatureServices", + "identifier" : "WalletServiceTests", + "name" : "WalletServiceTests" } }, { "target" : { - "containerPath" : "container:Features\/Swap", - "identifier" : "SwapTests", - "name" : "SwapTests" + "containerPath" : "container:Features\/PriceAlerts", + "identifier" : "PriceAlertsTests", + "name" : "PriceAlertsTests" } }, { "target" : { - "containerPath" : "container:Packages\/Components", - "identifier" : "ComponentsTests", - "name" : "ComponentsTests" + "containerPath" : "container:Features\/Support", + "identifier" : "SupportTests", + "name" : "SupportTests" } }, { "target" : { - "containerPath" : "container:Features\/Transfer", - "identifier" : "TransferTests", - "name" : "TransferTests" + "containerPath" : "container:Packages\/FeatureServices", + "identifier" : "WalletsServiceTests", + "name" : "WalletsServiceTests" } }, { "target" : { - "containerPath" : "container:Packages\/GemAPI", - "identifier" : "GemAPITests", - "name" : "GemAPITests" + "containerPath" : "container:Features\/Swap", + "identifier" : "SwapTests", + "name" : "SwapTests" } }, { "target" : { - "containerPath" : "container:Packages\/Blockchain", - "identifier" : "BlockchainTests", - "name" : "BlockchainTests" + "containerPath" : "container:Packages\/Formatters", + "identifier" : "FormattersTests", + "name" : "FormattersTests" } }, { "target" : { - "containerPath" : "container:Features\/WalletConnector", - "identifier" : "WalletConnectorTests", - "name" : "WalletConnectorTests" + "containerPath" : "container:Features\/Assets", + "identifier" : "AssetsTests", + "name" : "AssetsTests" } }, { "target" : { - "containerPath" : "container:Features\/WalletTab", - "identifier" : "WalletTabTests", - "name" : "WalletTabTests" + "containerPath" : "container:Packages\/FeatureServices", + "identifier" : "BannerServiceTests", + "name" : "BannerServiceTests" } }, { "target" : { - "containerPath" : "container:Features\/Onboarding", - "identifier" : "OnboardingTest", - "name" : "OnboardingTest" + "containerPath" : "container:Features\/NFT", + "identifier" : "NFTTests", + "name" : "NFTTests" } }, { "target" : { - "containerPath" : "container:Packages\/WalletCore", - "identifier" : "WalletCorePrimitivesTests", - "name" : "WalletCorePrimitivesTests" + "containerPath" : "container:Packages\/FeatureServices", + "identifier" : "WalletSessionServiceTests", + "name" : "WalletSessionServiceTests" } }, { @@ -205,100 +212,93 @@ }, { "target" : { - "containerPath" : "container:Packages\/Preferences", - "identifier" : "PreferencesTest", - "name" : "PreferencesTest" - } - }, - { - "target" : { - "containerPath" : "container:Packages\/GemstonePrimitives", - "identifier" : "GemstonePrimitivesTests", - "name" : "GemstonePrimitivesTests" + "containerPath" : "container:Features\/Settings", + "identifier" : "SettingsTests", + "name" : "SettingsTests" } }, { "target" : { - "containerPath" : "container:Features\/Assets", - "identifier" : "AssetsTests", - "name" : "AssetsTests" + "containerPath" : "container:Packages\/Signer", + "identifier" : "SignerTests", + "name" : "SignerTests" } }, { "target" : { - "containerPath" : "container:Features\/QRScanner", - "identifier" : "QRScannerTests", - "name" : "QRScannerTests" + "containerPath" : "container:Features\/FiatConnect", + "identifier" : "FiatConnectTests", + "name" : "FiatConnectTests" } }, { "target" : { - "containerPath" : "container:Packages\/PrimitivesComponents", - "identifier" : "PrimitivesComponentsTests", - "name" : "PrimitivesComponentsTests" + "containerPath" : "container:Packages\/FeatureServices", + "identifier" : "AppServiceTests", + "name" : "AppServiceTests" } }, { "target" : { - "containerPath" : "container:Packages\/FeatureServices", - "identifier" : "PriceAlertServiceTests", - "name" : "PriceAlertServiceTests" + "containerPath" : "container:Features\/LockManager", + "identifier" : "LockManagerTests", + "name" : "LockManagerTests" } }, { "target" : { - "containerPath" : "container:Packages\/FeatureServices", - "identifier" : "WalletsServiceTests", - "name" : "WalletsServiceTests" + "containerPath" : "container:Packages\/Store", + "identifier" : "StoreTests", + "name" : "StoreTests" } }, { "target" : { - "containerPath" : "container:Features\/ManageWallets", - "identifier" : "ManageWalletsTests", - "name" : "ManageWalletsTests" + "containerPath" : "container:Features\/Transfer", + "identifier" : "TransferTests", + "name" : "TransferTests" } }, { "target" : { "containerPath" : "container:Packages\/FeatureServices", - "identifier" : "BannerServiceTests", - "name" : "BannerServiceTests" + "identifier" : "PriceAlertServiceTests", + "name" : "PriceAlertServiceTests" } }, { "target" : { - "containerPath" : "container:Packages\/Keychain", - "identifier" : "KeychainTests", - "name" : "KeychainTests" + "containerPath" : "container:Packages\/Blockchain", + "identifier" : "BlockchainTests", + "name" : "BlockchainTests" } }, { "target" : { - "containerPath" : "container:Features\/Support", - "identifier" : "SupportTests", - "name" : "SupportTests" + "containerPath" : "container:Packages\/PrimitivesComponents", + "identifier" : "PrimitivesComponentsTests", + "name" : "PrimitivesComponentsTests" } }, { "target" : { - "containerPath" : "container:Packages\/FeatureServices", - "identifier" : "WalletServiceTests", - "name" : "WalletServiceTests" + "containerPath" : "container:Packages\/Components", + "identifier" : "ComponentsTests", + "name" : "ComponentsTests" } }, { "target" : { - "containerPath" : "container:Packages\/FeatureServices", - "identifier" : "WalletSessionServiceTests", - "name" : "WalletSessionServiceTests" + "containerPath" : "container:Features\/QRScanner", + "identifier" : "QRScannerTests", + "name" : "QRScannerTests" } }, { "target" : { - "containerPath" : "container:Packages\/FeatureServices", - "identifier" : "AppServiceTests", - "name" : "AppServiceTests" + "containerPath" : "container:Packages\/GemstonePrimitives", + "identifier" : "GemstonePrimitivesTests", + "name" : "GemstonePrimitivesTests" } } ], diff --git a/Packages/FeatureServices/NotificationService/InAppNotificationService.swift b/Packages/FeatureServices/NotificationService/InAppNotificationService.swift index 27d14d15f..c735c8659 100644 --- a/Packages/FeatureServices/NotificationService/InAppNotificationService.swift +++ b/Packages/FeatureServices/NotificationService/InAppNotificationService.swift @@ -35,10 +35,7 @@ public struct InAppNotificationService: Sendable { deviceId: deviceId, fromTimestamp: preferences.notificationsTimestamp ) - let walletNotifications = notifications.compactMap { notification in - walletService.walletId(walletIndex: nil, walletTypeId: notification.walletId).map { ($0, notification) } - } - try store.addNotifications(walletNotifications) + try store.addNotifications(notifications) preferences.notificationsTimestamp = newTimestamp } diff --git a/Packages/FeatureServices/WalletService/WalletService.swift b/Packages/FeatureServices/WalletService/WalletService.swift index 7bb7e749e..2e5c0bea2 100644 --- a/Packages/FeatureServices/WalletService/WalletService.swift +++ b/Packages/FeatureServices/WalletService/WalletService.swift @@ -48,8 +48,8 @@ public struct WalletService: Sendable { try walletStore.nextWalletIndex() } - public func walletId(walletIndex: Int?, walletTypeId: String) -> WalletId? { - if !walletTypeId.isEmpty, let wallet = walletSessionService.wallets.first(where: { (try? $0.walletIdentifier().id) == walletTypeId }) { + public func walletId(walletIndex: Int?, walletId: String) -> WalletId? { + if !walletId.isEmpty, let wallet = walletSessionService.wallets.first(where: { $0.id == walletId }) { return wallet.walletId } if let index = walletIndex { diff --git a/Packages/Keystore/Sources/Extensions/Wallet+Keystore.swift b/Packages/Keystore/Sources/Extensions/Wallet+Keystore.swift new file mode 100644 index 000000000..6203d2064 --- /dev/null +++ b/Packages/Keystore/Sources/Extensions/Wallet+Keystore.swift @@ -0,0 +1,9 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives + +extension Wallet { + var keystoreId: String { + externalId ?? id + } +} diff --git a/Packages/Keystore/Sources/LocalKeystore.swift b/Packages/Keystore/Sources/LocalKeystore.swift index d4053cce6..621495f12 100644 --- a/Packages/Keystore/Sources/LocalKeystore.swift +++ b/Packages/Keystore/Sources/LocalKeystore.swift @@ -40,15 +40,16 @@ public final class LocalKeystore: Keystore, @unchecked Sendable { source: WalletSource ) async throws -> Primitives.Wallet { let password = try await getOrCreatePassword(createPasswordIfNone: isWalletsEmpty) + let walletIdentifier = try ImportIdentifier.from(type).walletIdentifier() return try await queue.asyncTask { [walletKeyStore] in switch type { case .phrase(let words, let chains): - try walletKeyStore.importWallet(type: .multicoin, name: name, words: words, chains: chains, password: password, source: source) + try walletKeyStore.importWallet(id: walletIdentifier, name: name, words: words, chains: chains, password: password, source: source) case .single(let words, let chain): - try walletKeyStore.importWallet(type: .single, name: name, words: words, chains: [chain], password: password, source: source) + try walletKeyStore.importWallet(id: walletIdentifier, name: name, words: words, chains: [chain], password: password, source: source) case .privateKey(let text, let chain): - try walletKeyStore.importPrivateKey(name: name, key: text, chain: chain, password: password, source: source) + try walletKeyStore.importPrivateKey(id: walletIdentifier, name: name, key: text, chain: chain, password: password, source: source) case .address(let address, let chain): Wallet.makeView(name: name, chain: chain, address: address) } @@ -86,7 +87,7 @@ public final class LocalKeystore: Keystore, @unchecked Sendable { let password = try await getPassword() try await queue.asyncTask { [walletKeyStore] in do { - try walletKeyStore.deleteWallet(id: wallet.id, password: password) + try walletKeyStore.deleteWallet(id: wallet.keystoreId, password: password) } catch let error as KeystoreError { // in some cases wallet already deleted, just ignore switch error { @@ -107,7 +108,7 @@ public final class LocalKeystore: Keystore, @unchecked Sendable { public func getPrivateKey(wallet: Primitives.Wallet, chain: Chain) async throws -> Data { let password = try await getPassword() return try await queue.asyncTask { [walletKeyStore] in - try walletKeyStore.getPrivateKey(id: wallet.id, type: wallet.type, chain: chain, password: password) + try walletKeyStore.getPrivateKey(id: wallet.keystoreId, type: wallet.type, chain: chain, password: password) } } @@ -127,7 +128,7 @@ public final class LocalKeystore: Keystore, @unchecked Sendable { public func getMnemonic(wallet: Primitives.Wallet) async throws -> [String] { let password = try await getPassword() return try await queue.asyncTask { [walletKeyStore] in - try walletKeyStore.getMnemonic(wallet: wallet, password: password) + try walletKeyStore.getMnemonic(walletId: wallet.keystoreId, password: password) } } @@ -140,7 +141,7 @@ public final class LocalKeystore: Keystore, @unchecked Sendable { return try await queue.asyncTask { [walletKeyStore] in try walletKeyStore.sign( hash: hash, - walletId: wallet.id, + walletId: wallet.keystoreId, type: wallet.type, password: password, chain: chain diff --git a/Packages/Keystore/Sources/Store/WalletKeyStore.swift b/Packages/Keystore/Sources/Store/WalletKeyStore.swift index ef3af1d0d..dc337bfa8 100644 --- a/Packages/Keystore/Sources/Store/WalletKeyStore.swift +++ b/Packages/Keystore/Sources/Store/WalletKeyStore.swift @@ -23,7 +23,7 @@ public struct WalletKeyStore: Sendable { } public func importWallet( - type: Primitives.WalletType, + id: WalletIdentifier, name: String, words: [String], chains: [Chain], @@ -36,7 +36,7 @@ public struct WalletKeyStore: Sendable { encryptPassword: password, coins: [] ) - return try addCoins(type: type, wallet: wallet, existingChains: [], newChains: chains, password: password, source: source) + return try addCoins(id: id, wallet: wallet, existingChains: [], newChains: chains, password: password, source: source) } public static func decodeKey(_ key: String, chain: Chain) throws -> PrivateKey { @@ -90,21 +90,23 @@ public struct WalletKeyStore: Sendable { } } - public func importPrivateKey(name: String, key: String, chain: Chain, password: String, source: WalletSource) throws -> Primitives.Wallet { + public func importPrivateKey(id: WalletIdentifier, name: String, key: String, chain: Chain, password: String, source: WalletSource) throws -> Primitives.Wallet { let privateKey = try Self.decodeKey(key, chain: chain) let wallet = try keyStore.import(privateKey: privateKey, name: name, password: password, coin: chain.coinType) + let address = chain.coinType.deriveAddress(privateKey: privateKey) let account = Primitives.Account( chain: chain, - address: chain.coinType.deriveAddress(privateKey: privateKey), + address: address, derivationPath: chain.coinType.derivationPath(), // not applicable extendedPublicKey: nil ) return Primitives.Wallet( - id: wallet.id, + id: id.id, + externalId: wallet.id, name: wallet.key.name, index: 0, - type: .privateKey, + type: id.walletType, accounts: [account], order: 0, isPinned: false, @@ -114,7 +116,7 @@ public struct WalletKeyStore: Sendable { } public func addCoins( - type: Primitives.WalletType, + id: WalletIdentifier, wallet: WalletCore.Wallet, existingChains: [Chain], newChains: [Chain], @@ -139,16 +141,17 @@ public struct WalletKeyStore: Sendable { if newChains.isNotEmpty && newCoinTypes.subtracting(existingCoinTypes).isNotEmpty { let _ = try keyStore.addAccounts(wallet: wallet, coins: coins, password: password) } - + let accounts = allChains.compactMap { chain in wallet.accounts.filter({ $0.coin == chain.coinType }).first?.mapToAccount(chain: chain) } return Wallet( - id: wallet.id, + id: id.id, + externalId: wallet.id, name: wallet.key.name, index: 0, - type: type, + type: id.walletType, accounts: accounts, order: 0, isPinned: false, @@ -164,8 +167,8 @@ public struct WalletKeyStore: Sendable { password: String ) throws -> Primitives.Wallet { try addCoins( - type: wallet.type, - wallet: try getWallet(id: wallet.id), + id: try WalletIdentifier.from(id: wallet.id), + wallet: try getWallet(id: wallet.keystoreId), existingChains: existingChains, newChains: newChains, password: password, @@ -207,8 +210,8 @@ public struct WalletKeyStore: Sendable { } } - func getMnemonic(wallet: Primitives.Wallet, password: String) throws -> [String] { - let wallet = try getWallet(id: wallet.id) + func getMnemonic(walletId: String, password: String) throws -> [String] { + let wallet = try getWallet(id: walletId) guard let hdwallet = wallet.key.wallet(password: Data(password.utf8)) else { diff --git a/Packages/Keystore/Tests/WalletKeyStoreTests.swift b/Packages/Keystore/Tests/WalletKeyStoreTests.swift index 17d8851c6..9d9f395d2 100644 --- a/Packages/Keystore/Tests/WalletKeyStoreTests.swift +++ b/Packages/Keystore/Tests/WalletKeyStoreTests.swift @@ -11,10 +11,24 @@ final class WalletKeyStoreTests { let words = "panda eternal chronic student column crumble endorse cushion whisper space carpet pitch praise tribe audit wing boil firm pink umbrella senior venture crouch confirm" let password = "test" + private var hdWallet: HDWallet { + HDWallet(mnemonic: words, passphrase: "")! + } + + private var ethereumAddress: String { + CoinType.ethereum.deriveAddress(privateKey: hdWallet.getKeyForCoin(coin: .ethereum)) + } + + private func address(for coinType: CoinType) -> String { + coinType.deriveAddress(privateKey: hdWallet.getKeyForCoin(coin: coinType)) + } + @Test func testImportPrivateKey() throws { let store = WalletKeyStore.mock() + let expectedAddress = "JSTURBrew3zGaJjtk7qcvd7gapeExX3GC7DiQBaCKzU" let wallet = try store.importPrivateKey( + id: .privateKey(chain: .solana, address: expectedAddress), name: "test", key: testBase58Key, chain: .solana, @@ -26,7 +40,7 @@ final class WalletKeyStoreTests { #expect(wallet.accounts == [ Primitives.Account( chain: .solana, - address: "JSTURBrew3zGaJjtk7qcvd7gapeExX3GC7DiQBaCKzU", + address: expectedAddress, derivationPath: "m/44\'/501\'/0\'", extendedPublicKey: .none ) @@ -70,84 +84,84 @@ final class WalletKeyStoreTests { @Test func addImportWallet() async throws { let store = WalletKeyStore.mock() let newWallet = try store.importWallet( - type: .multicoin, + id: .multicoin(address: ethereumAddress), name: "", - words: words.components(separatedBy: ", "), + words: words.components(separatedBy: " "), chains: [.bitcoin, .ethereum], password: password, source: .import ) - + #expect(newWallet.accounts.map { $0.chain } == [.bitcoin, .ethereum]) } @Test func addCoinsMany() async throws { let store = WalletKeyStore.mock() - let newWallet = try store.importWallet(type: .multicoin, name: "", words: words.components(separatedBy: ", "), chains: [], password: password, source: .create) - + let newWallet = try store.importWallet(id: .multicoin(address: ethereumAddress), name: "", words: words.components(separatedBy: " "), chains: [], password: password, source: .create) + let wallet = try store.addChains( wallet: newWallet, existingChains: [], newChains: [.ethereum, .bitcoin], password: password ) - + #expect(wallet.accounts.map { $0.chain } == [.ethereum, .bitcoin]) } @Test func addCoinsEmptyChain() async throws { let store = WalletKeyStore.mock() - let newWallet = try store.importWallet(type: .multicoin, name: "", words: words.components(separatedBy: ", "), chains: [], password: password, source: .create) - + let newWallet = try store.importWallet(id: .multicoin(address: ethereumAddress), name: "", words: words.components(separatedBy: " "), chains: [], password: password, source: .create) + let wallet = try store.addChains(wallet: newWallet, existingChains: [], newChains: [], password: password) - + #expect(wallet.accounts.isEmpty) } @Test func addCoinsSingleChain() async throws { let store = WalletKeyStore.mock() - let newWallet = try store.importWallet(type: .single, name: "", words: words.components(separatedBy: ", "), chains: [], password: password, source: .create) - + let newWallet = try store.importWallet(id: .single(chain: .algorand, address: address(for: .algorand)), name: "", words: words.components(separatedBy: " "), chains: [], password: password, source: .create) + let wallet = try store.addChains( wallet: newWallet, existingChains: [], newChains: [.algorand], password: password ) - + #expect(wallet.accounts.map { $0.chain } == [.algorand]) } @Test func addCoinsWhenSolana() async throws { let store = WalletKeyStore.mock() - let newWallet = try store.importWallet(type: .multicoin, name: "", words: words.components(separatedBy: ", "), chains: [], password: password, source: .create) - + let newWallet = try store.importWallet(id: .multicoin(address: ethereumAddress), name: "", words: words.components(separatedBy: " "), chains: [], password: password, source: .create) + let wallet = try store.addChains(wallet: newWallet, existingChains: [], newChains: [.solana], password: password) - + #expect(wallet.accounts.map { $0.chain } == [.solana]) #expect(wallet.accounts.map { $0.address } == ["9fb52fTJpTzYqV4be7u31TxxFfzs9ub9RehwLYuxhP6C"]) } @Test func addCoinsManyTries() async throws { let store = WalletKeyStore.mock() - let newWallet = try store.importWallet(type: .multicoin, name: "", words: words.components(separatedBy: ", "), chains: [], password: password, source: .create) - + let newWallet = try store.importWallet(id: .multicoin(address: ethereumAddress), name: "", words: words.components(separatedBy: " "), chains: [], password: password, source: .create) + let wallet = try store.addChains( wallet: newWallet, existingChains: [], newChains: [.ethereum], password: password ) - + #expect(wallet.accounts.map { $0.chain } == [.ethereum]) - + let wallet2 = try store.addChains( wallet: newWallet, existingChains: [.bitcoin], newChains: [.ethereum], password: password ) - + #expect(wallet2.accounts.map { $0.chain } == [.bitcoin, .ethereum]) } } diff --git a/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift b/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift index 90be63fc2..ead651284 100644 --- a/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift +++ b/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift @@ -22,18 +22,7 @@ public extension Wallet { } func walletIdentifier() throws -> WalletIdentifier { - switch type { - case .multicoin: - guard let address = accounts.first(where: { $0.chain == .ethereum })?.address else { - throw AnyError("multicoin wallet requires an ethereum account") - } - return .multicoin(address: address) - case .single, .privateKey, .view: - guard let account = accounts.first else { - throw AnyError("\(type) wallet requires at least one account") - } - return WalletIdentifier.make(walletType: type, chain: account.chain, address: account.address) - } + try WalletIdentifier.from(type: type, accounts: accounts) } var hasTokenSupport: Bool { @@ -55,8 +44,10 @@ public extension Wallet { // factory public extension Wallet { static func makeView(name: String, chain: Chain, address: String) -> Wallet { + let id = WalletIdentifier.make(walletType: .view, chain: chain, address: address).id return Wallet( - id: NSUUID().uuidString, + id: id, + externalId: nil, name: name, index: 0, type: .view, diff --git a/Packages/Primitives/Sources/Wallet.swift b/Packages/Primitives/Sources/Wallet.swift index 7d7dbbd16..a6dce34af 100644 --- a/Packages/Primitives/Sources/Wallet.swift +++ b/Packages/Primitives/Sources/Wallet.swift @@ -11,6 +11,7 @@ public enum WalletSource: String, Codable, Equatable, Hashable, Sendable { public struct Wallet: Codable, Equatable, Hashable, Sendable { public let id: String + public let externalId: String? public let name: String public let index: Int32 public let type: WalletType @@ -20,8 +21,9 @@ public struct Wallet: Codable, Equatable, Hashable, Sendable { public let imageUrl: String? public let source: WalletSource - public init(id: String, name: String, index: Int32, type: WalletType, accounts: [Account], order: Int32, isPinned: Bool, imageUrl: String?, source: WalletSource) { + public init(id: String, externalId: String?, name: String, index: Int32, type: WalletType, accounts: [Account], order: Int32, isPinned: Bool, imageUrl: String?, source: WalletSource) { self.id = id + self.externalId = externalId self.name = name self.index = index self.type = type diff --git a/Packages/Primitives/Sources/WalletIdentifier.swift b/Packages/Primitives/Sources/WalletIdentifier.swift index cc5246f34..607fcac83 100644 --- a/Packages/Primitives/Sources/WalletIdentifier.swift +++ b/Packages/Primitives/Sources/WalletIdentifier.swift @@ -81,4 +81,19 @@ public enum WalletIdentifier: Equatable, Hashable, Sendable { case .view: .view(chain: chain, address: address) } } + + public static func from(type: WalletType, accounts: [Account]) throws -> WalletIdentifier { + switch type { + case .multicoin: + guard let address = accounts.first(where: { $0.chain == .ethereum })?.address else { + throw AnyError("multicoin wallet requires an ethereum account") + } + return .multicoin(address: address) + case .single, .privateKey, .view: + guard let account = accounts.first else { + throw AnyError("\(type) wallet requires at least one account") + } + return make(walletType: type, chain: account.chain, address: account.address) + } + } } diff --git a/Packages/Primitives/TestKit/Wallet+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/Wallet+PrimitivesTestKit.swift index 3a080ad54..238505531 100644 --- a/Packages/Primitives/TestKit/Wallet+PrimitivesTestKit.swift +++ b/Packages/Primitives/TestKit/Wallet+PrimitivesTestKit.swift @@ -6,6 +6,7 @@ import Primitives public extension Wallet { static func mock( id: String = "", + externalId: String? = nil, name: String = "", type: WalletType = .multicoin, accounts: [Account] = [], @@ -16,6 +17,7 @@ public extension Wallet { ) -> Wallet { Wallet( id: id, + externalId: externalId, name: name, index: index.asInt32, type: type, diff --git a/Packages/PrimitivesComponents/Sources/Components/WalletBarView.swift b/Packages/PrimitivesComponents/Sources/Components/WalletBarView.swift index 5cd521ada..5ef415440 100644 --- a/Packages/PrimitivesComponents/Sources/Components/WalletBarView.swift +++ b/Packages/PrimitivesComponents/Sources/Components/WalletBarView.swift @@ -49,6 +49,7 @@ public struct WalletBarView: View { name: WalletViewModel( wallet: .init( id: "", + externalId: nil, name: "Wallet #1", index: 1, type: .multicoin, diff --git a/Packages/Store/Sources/Migrations.swift b/Packages/Store/Sources/Migrations.swift index c59ce7d8a..8c2c09c13 100644 --- a/Packages/Store/Sources/Migrations.swift +++ b/Packages/Store/Sources/Migrations.swift @@ -375,6 +375,13 @@ public struct Migrations { } } + migrator.registerMigration("Migrate wallet IDs to WalletIdentifier format") { db in + try? db.alter(table: WalletRecord.databaseTableName) { + $0.add(column: WalletRecord.Columns.externalId.name, .text) + } + try WalletIdMigration.migrate(db: db) + } + try migrator.migrate(dbQueue) } } diff --git a/Packages/Store/Sources/Migrations/WalletIdMigration.swift b/Packages/Store/Sources/Migrations/WalletIdMigration.swift new file mode 100644 index 000000000..67d7ee067 --- /dev/null +++ b/Packages/Store/Sources/Migrations/WalletIdMigration.swift @@ -0,0 +1,125 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +struct WalletIdMigration { + + private static let childTables = [ + AccountRecord.databaseTableName, + BalanceRecord.databaseTableName, + BannerRecord.databaseTableName, + NFTAssetAssociationRecord.databaseTableName, + NotificationRecord.databaseTableName, + PerpetualPositionRecord.databaseTableName, + RecentActivityRecord.databaseTableName, + StakeDelegationRecord.databaseTableName, + TransactionRecord.databaseTableName, + WalletConnectionRecord.databaseTableName, + ] + + private static let currentWalletKey = "currentWallet" + + static func migrate(db: Database, userDefaults: UserDefaults = .standard) throws { + let mappings = try buildWalletMappings(db: db) + if mappings.isEmpty { + return + } + + try db.execute(sql: "PRAGMA foreign_keys = OFF") + + let groups = Dictionary(grouping: mappings, by: { $0.newId }) + for (_, wallets) in groups where wallets.count > 1 { + let sorted = wallets.sorted { $0.order < $1.order } + let losers = sorted.dropFirst() + for loser in losers { + for table in childTables { + try? db.execute(sql: "DELETE FROM \(table) WHERE walletId = ?", arguments: [loser.oldId]) + } + try db.execute(sql: "DELETE FROM \(WalletRecord.databaseTableName) WHERE id = ?", arguments: [loser.oldId]) + } + } + + let remainingMappings = try buildWalletMappings(db: db) + for mapping in remainingMappings where mapping.oldId != mapping.newId { + try db.execute(sql: "UPDATE \(WalletRecord.databaseTableName) SET externalId = id, id = ? WHERE id = ?", arguments: [mapping.newId, mapping.oldId]) + + for table in childTables { + try? db.execute(sql: "UPDATE \(table) SET walletId = ? WHERE walletId = ?", arguments: [mapping.newId, mapping.oldId]) + } + + migrateWalletPreferences(oldId: mapping.oldId, newId: mapping.newId) + } + + try db.execute(sql: "PRAGMA foreign_keys = ON") + + migrateCurrentWalletPreference(mappings: remainingMappings, userDefaults: userDefaults) + } + + private static func migrateWalletPreferences(oldId: String, newId: String) { + let oldSuiteName = "wallet_preferences_\(oldId)_v2" + let newSuiteName = "wallet_preferences_\(newId)_v2" + + guard let oldDefaults = UserDefaults(suiteName: oldSuiteName), + let newDefaults = UserDefaults(suiteName: newSuiteName) else { return } + + for (key, value) in oldDefaults.dictionaryRepresentation() { + newDefaults.set(value, forKey: key) + } + + oldDefaults.removePersistentDomain(forName: oldSuiteName) + } + + private static func migrateCurrentWalletPreference(mappings: [WalletMapping], userDefaults: UserDefaults) { + let firstWallet = mappings.min(by: { $0.order < $1.order }) + + guard let currentId = userDefaults.string(forKey: currentWalletKey) else { + if let first = firstWallet { + userDefaults.set(first.newId, forKey: currentWalletKey) + } + return + } + + if let mapping = mappings.first(where: { $0.oldId == currentId && $0.oldId != $0.newId }) { + userDefaults.set(mapping.newId, forKey: currentWalletKey) + } else if let first = firstWallet { + userDefaults.set(first.newId, forKey: currentWalletKey) + } + } + + private static func buildWalletMappings(db: Database) throws -> [WalletMapping] { + try Row.fetchAll(db, sql: """ + SELECT w.id, w.type, w."order", + (SELECT a.address FROM \(AccountRecord.databaseTableName) a + WHERE a.walletId = w.id AND a.chain = 'ethereum' LIMIT 1) as ethereumAddress, + (SELECT a.address FROM \(AccountRecord.databaseTableName) a + WHERE a.walletId = w.id LIMIT 1) as address, + (SELECT a.chain FROM \(AccountRecord.databaseTableName) a + WHERE a.walletId = w.id LIMIT 1) as chain + FROM \(WalletRecord.databaseTableName) w + """).compactMap { row -> WalletMapping? in + guard let oldId: String = row["id"], + let typeRaw: String = row["type"], + let type = WalletType(rawValue: typeRaw) else { return nil } + + let newId: String + switch type { + case .multicoin: + guard let address: String = row["ethereumAddress"] else { return nil } + newId = "multicoin_\(address)" + case .single, .privateKey, .view: + guard let address: String = row["address"], + let chain: String = row["chain"] else { return nil } + newId = "\(type.rawValue)_\(chain)_\(address)" + } + return WalletMapping(oldId: oldId, newId: newId, order: row["order"] ?? 0) + } + } + + struct WalletMapping { + let oldId: String + let newId: String + let order: Int + } +} diff --git a/Packages/Store/Sources/Models/BannerRecord.swift b/Packages/Store/Sources/Models/BannerRecord.swift index 833f1cc7b..6ca659ccc 100644 --- a/Packages/Store/Sources/Models/BannerRecord.swift +++ b/Packages/Store/Sources/Models/BannerRecord.swift @@ -75,7 +75,7 @@ extension NewBanner { var record: BannerRecord { let wallet: Wallet? = { if let walletId { - return Wallet(id: walletId, name: "", index: 0, type: .multicoin, accounts: [], order: 0, isPinned: false, imageUrl: nil, source: .create) + return Wallet(id: walletId, externalId: nil, name: "", index: 0, type: .multicoin, accounts: [], order: 0, isPinned: false, imageUrl: nil, source: .create) } return .none }() diff --git a/Packages/Store/Sources/Models/NotificationRecord.swift b/Packages/Store/Sources/Models/NotificationRecord.swift index c25b61905..34a845ee7 100644 --- a/Packages/Store/Sources/Models/NotificationRecord.swift +++ b/Packages/Store/Sources/Models/NotificationRecord.swift @@ -53,10 +53,10 @@ extension NotificationRecord { } extension Primitives.InAppNotification { - func record(walletId: WalletId) -> NotificationRecord { + func record() -> NotificationRecord { NotificationRecord( id: item.id, - walletId: walletId.id, + walletId: walletId, readAt: readAt, createdAt: createdAt, item: item diff --git a/Packages/Store/Sources/Models/WalletRecord.swift b/Packages/Store/Sources/Models/WalletRecord.swift index 863f46bd0..87cae0750 100644 --- a/Packages/Store/Sources/Models/WalletRecord.swift +++ b/Packages/Store/Sources/Models/WalletRecord.swift @@ -9,6 +9,7 @@ public struct WalletRecord: Codable, TableRecord, FetchableRecord, PersistableRe public struct Columns { static let id = Column("id") + static let externalId = Column("externalId") static let name = Column("name") static let index = Column("index") static let type = Column("type") @@ -20,6 +21,7 @@ public struct WalletRecord: Codable, TableRecord, FetchableRecord, PersistableRe } public var id: String + public var externalId: String? public var name: String public var type: WalletType public var index: Int @@ -39,6 +41,7 @@ extension WalletRecord: CreateTable { $0.column(Columns.id.name, .text) .primaryKey() .notNull() + $0.column(Columns.externalId.name, .text) $0.column(Columns.name.name, .text) .notNull() $0.column(Columns.type.name, .text) diff --git a/Packages/Store/Sources/Stores/InAppNotificationStore.swift b/Packages/Store/Sources/Stores/InAppNotificationStore.swift index c8a64c2bf..c6d36bee3 100644 --- a/Packages/Store/Sources/Stores/InAppNotificationStore.swift +++ b/Packages/Store/Sources/Stores/InAppNotificationStore.swift @@ -11,10 +11,10 @@ public struct InAppNotificationStore: Sendable { self.db = db.dbQueue } - public func addNotifications(_ notifications: [(WalletId, Primitives.InAppNotification)]) throws { + public func addNotifications(_ notifications: [InAppNotification]) throws { try db.write { db in - for (walletId, notification) in notifications { - try notification.record(walletId: walletId).upsert(db) + for notification in notifications { + try notification.record().upsert(db) } } } diff --git a/Packages/Store/Sources/Stores/WalletStore.swift b/Packages/Store/Sources/Stores/WalletStore.swift index 5ee19d6a9..5c1c4a4f4 100644 --- a/Packages/Store/Sources/Stores/WalletStore.swift +++ b/Packages/Store/Sources/Stores/WalletStore.swift @@ -124,12 +124,21 @@ public struct WalletStore: Sendable { .updateAll(db, assignments) } } + + public func setOrder(walletId: String, order: Int) throws { + let _ = try db.write { db in + try WalletRecord + .filter(WalletRecord.Columns.id == walletId) + .updateAll(db, WalletRecord.Columns.order.set(to: order)) + } + } } extension WalletRecord { func mapToWallet() -> Wallet { return Wallet( id: id, + externalId: externalId, name: name, index: index.asInt32, type: type, @@ -146,8 +155,9 @@ extension Wallet { var record: WalletRecord { return WalletRecord( id: id, + externalId: externalId, name: name, - type: type, + type: type, index: index.asInt, order: 0, isPinned: false, diff --git a/Packages/Store/Sources/Types/WalletRecordInfo.swift b/Packages/Store/Sources/Types/WalletRecordInfo.swift index 1d055fb10..8435a6ce5 100644 --- a/Packages/Store/Sources/Types/WalletRecordInfo.swift +++ b/Packages/Store/Sources/Types/WalletRecordInfo.swift @@ -13,6 +13,7 @@ extension WalletRecordInfo { func mapToWallet() -> Wallet? { return Wallet( id: wallet.id, + externalId: wallet.externalId, name: wallet.name, index: wallet.index.asInt32, type: wallet.type, diff --git a/Packages/Store/Tests/StoreTests/WalletIdMigrationTests.swift b/Packages/Store/Tests/StoreTests/WalletIdMigrationTests.swift new file mode 100644 index 000000000..e75c689b8 --- /dev/null +++ b/Packages/Store/Tests/StoreTests/WalletIdMigrationTests.swift @@ -0,0 +1,486 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import Foundation +import GRDB +import Primitives +import PrimitivesTestKit +import StoreTestKit +@testable import Store + +private extension UserDefaults { + static func mock() -> UserDefaults { + let suiteName = UUID().uuidString + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + +@Suite(.serialized) +struct WalletIdMigrationTests { + + private let currentWalletKey = "currentWallet" + + @Test + func migrateMulticoinWallet() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let oldId = "uuid-multicoin-1" + let ethAddress = "0x1234567890abcdef" + let wallet = Wallet.mock( + id: oldId, + type: .multicoin, + accounts: [.mock(chain: .ethereum, address: ethAddress), .mock(chain: .bitcoin)] + ) + try walletStore.addWallet(wallet) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.id == "multicoin_\(ethAddress)") + #expect(wallets.first?.externalId == oldId) + } + + @Test + func migrateViewWallet() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let oldId = "uuid-view-1" + let address = "0xviewaddress" + let wallet = Wallet.mock( + id: oldId, + type: .view, + accounts: [.mock(chain: .ethereum, address: address)] + ) + try walletStore.addWallet(wallet) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.id == "view_ethereum_\(address)") + #expect(wallets.first?.externalId == oldId) + } + + @Test + func migrateSingleWallet() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let oldId = "uuid-single-1" + let address = "bc1qsingleaddress" + let wallet = Wallet.mock( + id: oldId, + type: .single, + accounts: [.mock(chain: .bitcoin, address: address)] + ) + try walletStore.addWallet(wallet) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.id == "single_bitcoin_\(address)") + #expect(wallets.first?.externalId == oldId) + } + + @Test + func migratePrivateKeyWallet() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let oldId = "uuid-pk-1" + let address = "0xprivatekey" + let wallet = Wallet.mock( + id: oldId, + type: .privateKey, + accounts: [.mock(chain: .ethereum, address: address)] + ) + try walletStore.addWallet(wallet) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.id == "privateKey_ethereum_\(address)") + #expect(wallets.first?.externalId == oldId) + } + + @Test + func removeDuplicateMulticoinWallets() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let ethAddress = "0xsameaddress" + + let wallet1 = Wallet.mock( + id: "uuid-1", + type: .multicoin, + accounts: [.mock(chain: .ethereum, address: ethAddress)], + order: 0 + ) + let wallet2 = Wallet.mock( + id: "uuid-2", + type: .multicoin, + accounts: [.mock(chain: .ethereum, address: ethAddress)], + order: 1 + ) + let wallet3 = Wallet.mock( + id: "uuid-3", + type: .multicoin, + accounts: [.mock(chain: .ethereum, address: ethAddress)], + order: 2 + ) + + try walletStore.addWallet(wallet1) + try walletStore.addWallet(wallet2) + try walletStore.addWallet(wallet3) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.id == "multicoin_\(ethAddress)") + #expect(wallets.first?.externalId == "uuid-1") + } + + @Test + func mixedWalletTypes() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let multicoin = Wallet.mock( + id: "uuid-multicoin", + type: .multicoin, + accounts: [.mock(chain: .ethereum, address: "0xmulti")], + order: 0 + ) + let single = Wallet.mock( + id: "uuid-single", + type: .single, + accounts: [.mock(chain: .bitcoin, address: "bc1single")], + order: 1 + ) + let view = Wallet.mock( + id: "uuid-view", + type: .view, + accounts: [.mock(chain: .solana, address: "solview")], + order: 2 + ) + + try walletStore.addWallet(multicoin) + try walletStore.addWallet(single) + try walletStore.addWallet(view) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 3) + + let ids = Set(wallets.map { $0.id }) + #expect(ids.contains("multicoin_0xmulti")) + #expect(ids.contains("single_bitcoin_bc1single")) + #expect(ids.contains("view_solana_solview")) + } + + @Test + func updateChildTableReferences() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + let balanceStore = BalanceStore(db: db) + let assetStore = AssetStore(db: db) + + let oldId = "uuid-with-balances" + let ethAddress = "0xwithbalances" + let wallet = Wallet.mock( + id: oldId, + type: .multicoin, + accounts: [.mock(chain: .ethereum, address: ethAddress)] + ) + try walletStore.addWallet(wallet) + + let asset = AssetBasic.mock(asset: .mock(id: .mockEthereum())) + try assetStore.add(assets: [asset]) + try balanceStore.addBalance([AddBalance(assetId: asset.asset.id, isEnabled: true)], for: wallet.walletId) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let newWalletId = WalletId(id: "multicoin_\(ethAddress)") + let balances = try balanceStore.getBalances(walletId: newWalletId, assetIds: [asset.asset.id]) + #expect(balances.count == 1) + } + + @Test + func multipleDuplicateGroups() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let address1 = "0xaddress1" + try walletStore.addWallet(.mock(id: "uuid-1a", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address1)], order: 0)) + try walletStore.addWallet(.mock(id: "uuid-1b", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address1)], order: 1)) + try walletStore.addWallet(.mock(id: "uuid-1c", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address1)], order: 2)) + + let address2 = "0xaddress2" + try walletStore.addWallet(.mock(id: "uuid-2a", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address2)], order: 3)) + try walletStore.addWallet(.mock(id: "uuid-2b", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address2)], order: 4)) + + let address3 = "0xaddress3" + try walletStore.addWallet(.mock(id: "uuid-3", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address3)], order: 5)) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 3) + + let ids = Set(wallets.map { $0.id }) + #expect(ids.contains("multicoin_\(address1)")) + #expect(ids.contains("multicoin_\(address2)")) + #expect(ids.contains("multicoin_\(address3)")) + + let wallet1 = wallets.first { $0.id == "multicoin_\(address1)" } + #expect(wallet1?.externalId == "uuid-1a") + + let wallet2 = wallets.first { $0.id == "multicoin_\(address2)" } + #expect(wallet2?.externalId == "uuid-2a") + } + + @Test + func duplicateViewWallets() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let address = "0xviewaddr" + try walletStore.addWallet(.mock(id: "uuid-view-1", type: .view, accounts: [.mock(chain: .ethereum, address: address)])) + try walletStore.addWallet(.mock(id: "uuid-view-2", type: .view, accounts: [.mock(chain: .ethereum, address: address)])) + try walletStore.setOrder(walletId: "uuid-view-1", order: 1) + try walletStore.setOrder(walletId: "uuid-view-2", order: 0) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.id == "view_ethereum_\(address)") + #expect(wallets.first?.externalId == "uuid-view-2") + } + + @Test + func duplicateSingleWallets() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let address = "bc1qsingle" + try walletStore.addWallet(.mock(id: "uuid-single-1", type: .single, accounts: [.mock(chain: .bitcoin, address: address)])) + try walletStore.addWallet(.mock(id: "uuid-single-2", type: .single, accounts: [.mock(chain: .bitcoin, address: address)])) + try walletStore.addWallet(.mock(id: "uuid-single-3", type: .single, accounts: [.mock(chain: .bitcoin, address: address)])) + try walletStore.setOrder(walletId: "uuid-single-1", order: 2) + try walletStore.setOrder(walletId: "uuid-single-2", order: 0) + try walletStore.setOrder(walletId: "uuid-single-3", order: 1) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.id == "single_bitcoin_\(address)") + #expect(wallets.first?.externalId == "uuid-single-2") + } + + @Test + func keepWalletWithLowestOrder() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let address = "0xordertest" + try walletStore.addWallet(.mock(id: "uuid-order-5", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address)])) + try walletStore.addWallet(.mock(id: "uuid-order-2", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address)])) + try walletStore.addWallet(.mock(id: "uuid-order-8", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address)])) + try walletStore.addWallet(.mock(id: "uuid-order-1", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address)])) + try walletStore.addWallet(.mock(id: "uuid-order-3", type: .multicoin, accounts: [.mock(chain: .ethereum, address: address)])) + try walletStore.setOrder(walletId: "uuid-order-5", order: 5) + try walletStore.setOrder(walletId: "uuid-order-2", order: 2) + try walletStore.setOrder(walletId: "uuid-order-8", order: 8) + try walletStore.setOrder(walletId: "uuid-order-1", order: 1) + try walletStore.setOrder(walletId: "uuid-order-3", order: 3) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.externalId == "uuid-order-1") + } + + @Test + func accountsUpdatedAfterMigration() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let oldId = "uuid-accounts" + let ethAddress = "0xaccounts" + let btcAddress = "bc1accounts" + let wallet = Wallet.mock( + id: oldId, + type: .multicoin, + accounts: [ + .mock(chain: .ethereum, address: ethAddress), + .mock(chain: .bitcoin, address: btcAddress) + ] + ) + try walletStore.addWallet(wallet) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.accounts.count == 2) + + let chains = Set(wallets.first?.accounts.map { $0.chain } ?? []) + #expect(chains.contains(.ethereum)) + #expect(chains.contains(.bitcoin)) + } + + @Test + func walletAlreadyInNewFormat() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let ethAddress = "0xalreadymigrated" + let newFormatId = "multicoin_\(ethAddress)" + let wallet = Wallet.mock( + id: newFormatId, + externalId: "old-uuid", + type: .multicoin, + accounts: [.mock(chain: .ethereum, address: ethAddress)] + ) + try walletStore.addWallet(wallet) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let wallets = try walletStore.getWallets() + #expect(wallets.count == 1) + #expect(wallets.first?.id == newFormatId) + #expect(wallets.first?.externalId == "old-uuid") + } + + @Test + func emptyDatabase() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let walletStore = WalletStore(db: db) + let wallets = try walletStore.getWallets() + #expect(wallets.isEmpty) + } +} + +@Suite(.serialized) +struct WalletIdMigrationPreferenceTests { + + private let currentWalletKey = "currentWallet" + + @Test + func migrateCurrentWalletPreference() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + let oldId = "uuid-current" + let ethAddress = "0xcurrent" + let wallet = Wallet.mock( + id: oldId, + type: .multicoin, + accounts: [.mock(chain: .ethereum, address: ethAddress)] + ) + try walletStore.addWallet(wallet) + + userDefaults.set(oldId, forKey: currentWalletKey) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let newCurrentWalletId = userDefaults.string(forKey: currentWalletKey) + #expect(newCurrentWalletId == "multicoin_\(ethAddress)") + } + + @Test + func setCurrentWalletWhenNoneSet() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + try walletStore.addWallet(.mock(id: "uuid-first", type: .multicoin, accounts: [.mock(chain: .ethereum, address: "0xfirst")])) + try walletStore.setOrder(walletId: "uuid-first", order: 0) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let currentWalletId = userDefaults.string(forKey: currentWalletKey) + #expect(currentWalletId == "multicoin_0xfirst") + } + + @Test + func fallbackCurrentWalletWhenInvalid() throws { + let userDefaults = UserDefaults.mock() + let db = DB.mock() + let walletStore = WalletStore(db: db) + + userDefaults.set("deleted-wallet-id", forKey: currentWalletKey) + + try walletStore.addWallet(.mock(id: "uuid-fallback", type: .multicoin, accounts: [.mock(chain: .ethereum, address: "0xfallback")])) + try walletStore.setOrder(walletId: "uuid-fallback", order: 0) + + try db.dbQueue.write { db in + try WalletIdMigration.migrate(db: db, userDefaults: userDefaults) + } + + let currentWalletId = userDefaults.string(forKey: currentWalletKey) + #expect(currentWalletId == "multicoin_0xfallback") + } +} diff --git a/core b/core index 9693266d4..7cc3801f1 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 9693266d4d28ab1913bd2b77bc4996d77c4ad58e +Subproject commit 7cc3801f1e45995d5dbbda3d92bdbdc4d404ab9a