diff --git a/Android/wallet/src/androidTest/java/com/flow/wallet/wallet/EOAAccountTest.kt b/Android/wallet/src/androidTest/java/com/flow/wallet/wallet/EOAAccountTest.kt new file mode 100644 index 0000000..08dd9c3 --- /dev/null +++ b/Android/wallet/src/androidTest/java/com/flow/wallet/wallet/EOAAccountTest.kt @@ -0,0 +1,196 @@ +package com.flow.wallet.wallet + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flow.wallet.NativeLibraryManager +import com.flow.wallet.crypto.HasherImpl +import com.flow.wallet.keys.SeedPhraseKey +import com.flow.wallet.storage.InMemoryStorage +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EOAAccountTest { + + private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + // Standard BIP44 m/44'/60'/0'/0/0 address for this mnemonic + private val expectedIndex0Address = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + + private lateinit var storage: InMemoryStorage + private lateinit var seedPhraseKey: SeedPhraseKey + + @Before + fun setup() { + NativeLibraryManager.ensureLibraryLoaded() + storage = InMemoryStorage() + seedPhraseKey = SeedPhraseKey( + testMnemonic, "", SeedPhraseKey.DEFAULT_DERIVATION_PATH, storage + ) + } + + // MARK: - Multi EOA Derivation + + @Test + fun testDeriveMultipleEOAAccounts() { + val accounts = listOf(0, 1, 2).map { index -> + EOAAccount( + address = seedPhraseKey.ethAddress(index), + index = index, + publicKey = seedPhraseKey.ethPublicKey(index), + key = seedPhraseKey + ) + } + + assertEquals(3, accounts.size) + // Each account should have a unique address + val addresses = accounts.map { it.address }.toSet() + assertEquals("Each derivation index should produce a unique address", 3, addresses.size) + + // Verify indexes match + assertEquals(0, accounts[0].index) + assertEquals(1, accounts[1].index) + assertEquals(2, accounts[2].index) + + // Index 0 should match known address + assertEquals(expectedIndex0Address, accounts[0].address) + } + + @Test + fun testEOAAccountSignDigest() { + val account = EOAAccount( + address = seedPhraseKey.ethAddress(0), + index = 0, + publicKey = seedPhraseKey.ethPublicKey(0), + key = seedPhraseKey + ) + + val digest = HasherImpl.keccak256("hello world".toByteArray()) + val signature = account.signDigest(digest) + + assertEquals(65, signature.size) + val v = signature[64].toInt() and 0xFF + assertTrue("v should be 27 or 28", v == 27 || v == 28) + + // Should match direct key signing + val directSignature = seedPhraseKey.ethSignDigest(digest, 0) + assertArrayEquals(signature, directSignature) + } + + @Test + fun testEOAAccountPersonalSign() { + val account = EOAAccount( + address = seedPhraseKey.ethAddress(0), + index = 0, + publicKey = seedPhraseKey.ethPublicKey(0), + key = seedPhraseKey + ) + + val message = "Flow Wallet".toByteArray() + val signature = account.signPersonalMessage(message) + + assertEquals(65, signature.size) + + // Manually compute expected: prefix + keccak256 + sign + val prefix = "\u0019Ethereum Signed Message:\n${message.size}".toByteArray(Charsets.UTF_8) + val payload = prefix + message + val digest = HasherImpl.keccak256(payload) + val directSignature = seedPhraseKey.ethSignDigest(digest, 0) + assertArrayEquals(signature, directSignature) + } + + @Test + fun testDifferentIndexesProduceDifferentSignatures() { + val account0 = EOAAccount( + address = seedPhraseKey.ethAddress(0), index = 0, + publicKey = seedPhraseKey.ethPublicKey(0), key = seedPhraseKey + ) + val account1 = EOAAccount( + address = seedPhraseKey.ethAddress(1), index = 1, + publicKey = seedPhraseKey.ethPublicKey(1), key = seedPhraseKey + ) + + val digest = HasherImpl.keccak256("test message".toByteArray()) + val sig0 = account0.signDigest(digest) + val sig1 = account1.signDigest(digest) + + assertFalse("Different derivation indexes should produce different signatures", sig0.contentEquals(sig1)) + } + + @Test + fun testEOAAccountPublicKey() { + val account = EOAAccount( + address = seedPhraseKey.ethAddress(0), index = 0, + publicKey = seedPhraseKey.ethPublicKey(0), key = seedPhraseKey + ) + + // Uncompressed secp256k1 public key is 65 bytes (04 prefix + 32x + 32y) + assertEquals(65, account.publicKey.size) + assertEquals(0x04.toByte(), account.publicKey[0]) + } + + @Test + fun testEOAAccountPrivateKey() { + val account0 = EOAAccount( + address = seedPhraseKey.ethAddress(0), index = 0, + publicKey = seedPhraseKey.ethPublicKey(0), key = seedPhraseKey + ) + val account1 = EOAAccount( + address = seedPhraseKey.ethAddress(1), index = 1, + publicKey = seedPhraseKey.ethPublicKey(1), key = seedPhraseKey + ) + + val pk0 = account0.privateKeyData() + val pk1 = account1.privateKeyData() + + assertEquals(32, pk0.size) + assertEquals(32, pk1.size) + assertFalse(pk0.contentEquals(pk1)) + } + + @Test + fun testLargeDerivationIndex() { + val account = EOAAccount( + address = seedPhraseKey.ethAddress(100), index = 100, + publicKey = seedPhraseKey.ethPublicKey(100), key = seedPhraseKey + ) + + assertEquals(100, account.index) + assertTrue(account.address.startsWith("0x")) + assertEquals(42, account.address.length) + } + + // MARK: - Wallet integration (via KeyWallet) + + @Test + fun testKeyWalletGetEOAAccounts() = runBlocking { + val wallet = KeyWallet( + key = seedPhraseKey, + networks = mutableSetOf(), + storage = storage + ) + + val accounts = wallet.getEOAAccounts(listOf(0, 1, 2)) + + assertEquals(3, accounts.size) + assertEquals(expectedIndex0Address, accounts[0].address) + assertEquals(0, accounts[0].index) + assertEquals(1, accounts[1].index) + assertEquals(2, accounts[2].index) + } + + @Test + fun testKeyWalletGetEOAAccountsDefaultIndex() = runBlocking { + val wallet = KeyWallet( + key = seedPhraseKey, + networks = mutableSetOf(), + storage = storage + ) + + val accounts = wallet.getEOAAccounts() + + assertEquals(1, accounts.size) + assertEquals(0, accounts[0].index) + } +} diff --git a/Android/wallet/src/main/java/com/flow/wallet/wallet/BaseWallet.kt b/Android/wallet/src/main/java/com/flow/wallet/wallet/BaseWallet.kt index d1c5b83..a7b50c8 100644 --- a/Android/wallet/src/main/java/com/flow/wallet/wallet/BaseWallet.kt +++ b/Android/wallet/src/main/java/com/flow/wallet/wallet/BaseWallet.kt @@ -18,6 +18,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.onflow.flow.ChainId import org.onflow.flow.FlowApi import org.onflow.flow.getCreatedAccountAddress @@ -47,7 +49,7 @@ interface Wallet { val type: WalletType val accounts: Map> val accountsFlow: StateFlow>> - val eoaAddresses: StateFlow> + val eoaAddressMap: StateFlow> val networks: Set val storage: StorageProtocol val isLoading: StateFlow @@ -63,6 +65,7 @@ interface Wallet { suspend fun fetchAccountsForNetwork(network: ChainId): List suspend fun fetchAccountByAddress(address: String, network: ChainId) suspend fun fetchAccountByCreationTxId(txId: String, network: ChainId): Account + suspend fun getEOAAccounts(indexes: List = listOf(0)): List suspend fun ethAddress(index: Int = 0): String suspend fun ethSignDigest(digest: ByteArray, index: Int = 0): ByteArray suspend fun ethSignPersonalMessage(message: ByteArray, index: Int = 0): ByteArray @@ -98,6 +101,7 @@ abstract class BaseWallet( companion object { private const val CACHE_PREFIX = "Accounts" + private const val EOA_MAP_CACHE_PREFIX = "EOAMap" } // Loading state management @@ -113,8 +117,8 @@ abstract class BaseWallet( // Accounts as flow for reactive updates internal val _accountsFlow = MutableStateFlow>>(emptyMap()) override val accountsFlow: StateFlow>> = _accountsFlow.asStateFlow() - private val _eoaAddresses = MutableStateFlow>(emptySet()) - override val eoaAddresses: StateFlow> = _eoaAddresses.asStateFlow() + private val _eoaAddressMap = MutableStateFlow>(emptyMap()) + override val eoaAddressMap: StateFlow> = _eoaAddressMap.asStateFlow() // Accounts as map (legacy support) override val accounts: Map> @@ -323,10 +327,23 @@ abstract class BaseWallet( _accountsFlow.value = _accounts.toMap() } + override suspend fun getEOAAccounts(indexes: List): List { + val key = resolveEthereumKey() + val effectiveIndexes = if (indexes.isEmpty()) listOf(0) else indexes + val accounts = effectiveIndexes.map { index -> + val address = key.ethAddress(index) + val publicKey = key.ethPublicKey(index) + updateEoaCache(index, address) + EOAAccount(address = address, index = index, publicKey = publicKey, key = key) + } + cacheEOAAddressMap() + return accounts + } + override suspend fun ethAddress(index: Int): String { val key = resolveEthereumKey() val address = key.ethAddress(index) - updateEoaCache(address) + updateEoaCache(index, address) return address } @@ -409,8 +426,35 @@ abstract class BaseWallet( return key } - private fun updateEoaCache(address: String) { - _eoaAddresses.value = _eoaAddresses.value + address + private fun updateEoaCache(index: Int, address: String) { + _eoaAddressMap.value = _eoaAddressMap.value + (index to address) + } + + private val eoaMapCacheId: String + get() = "$EOA_MAP_CACHE_PREFIX/${type.name}/${getKeyForAccount()?.id ?: "unknown"}" + + /** Persist eoaAddressMap to storage. */ + fun cacheEOAAddressMap() { + try { + val json = Json.encodeToString(_eoaAddressMap.value.mapKeys { it.key.toString() }) + storage.set(eoaMapCacheId, json.toByteArray()) + } catch (e: Exception) { + Log.w("BaseWallet", "Failed to cache EOA address map: ${e.message}") + } + } + + /** Load eoaAddressMap from storage. */ + fun loadCachedEOAAddressMap() { + try { + val data = storage.get(eoaMapCacheId) ?: return + val stringKeyed = Json.decodeFromString>(String(data)) + val intKeyed = stringKeyed.mapNotNull { (k, v) -> + k.toIntOrNull()?.let { it to v } + }.toMap() + _eoaAddressMap.value = intKeyed + } catch (e: Exception) { + Log.w("BaseWallet", "Failed to load cached EOA address map: ${e.message}") + } } protected abstract fun getKeyForAccount(): KeyProtocol? diff --git a/Android/wallet/src/main/java/com/flow/wallet/wallet/EOAAccount.kt b/Android/wallet/src/main/java/com/flow/wallet/wallet/EOAAccount.kt new file mode 100644 index 0000000..c36b093 --- /dev/null +++ b/Android/wallet/src/main/java/com/flow/wallet/wallet/EOAAccount.kt @@ -0,0 +1,64 @@ +package com.flow.wallet.wallet + +import com.flow.wallet.crypto.HasherImpl +import com.flow.wallet.errors.WalletError +import com.flow.wallet.keys.EthereumKeyProtocol +import com.flow.wallet.keys.EthereumSignatureUtils +import com.google.protobuf.ByteString +import wallet.core.java.AnySigner +import wallet.core.jni.CoinType +import wallet.core.jni.EthereumAbi +import wallet.core.jni.proto.Ethereum + +/** + * Represents a single Ethereum EOA derived from a BIP44 path. + * Bundles the address, derivation index, public key, and signing capabilities. + * + * @property address EIP-55 checksummed Ethereum address + * @property index BIP44 derivation index (m/44'/60'/0'/0/{index}) + * @property publicKey Uncompressed secp256k1 public key bytes + */ +class EOAAccount( + val address: String, + val index: Int, + val publicKey: ByteArray, + private val key: EthereumKeyProtocol +) { + /** Sign a pre-hashed 32-byte digest. Returns [r(32)|s(32)|v(1)]. */ + fun signDigest(digest: ByteArray): ByteArray { + return key.ethSignDigest(digest, index) + } + + /** EIP-191 personal_sign. */ + fun signPersonalMessage(message: ByteArray): ByteArray { + val prefix = "\u0019Ethereum Signed Message:\n${message.size}".toByteArray(Charsets.UTF_8) + val payload = prefix + message + val digest = HasherImpl.keccak256(payload) + return signDigest(digest) + } + + /** EIP-712 signTypedData. */ + fun signTypedData(json: String): ByteArray { + val digest = EthereumAbi.encodeTyped(json) + if (digest.size != 32) { + throw WalletError.InvalidEthereumTypedData + } + return signDigest(digest) + } + + /** Sign an Ethereum transaction via WalletCore AnySigner. */ + fun signTransaction(input: Ethereum.SigningInput): Ethereum.SigningOutput { + val privateKey = key.ethPrivateKey(index) + val builder = input.toBuilder() + builder.privateKey = ByteString.copyFrom(privateKey) + return try { + AnySigner.sign(builder.build(), CoinType.ETHEREUM, Ethereum.SigningOutput.parser()) + } finally { + builder.clearPrivateKey() + privateKey.fill(0) + } + } + + /** Raw 32-byte secp256k1 private key. Caller is responsible for secure handling. */ + fun privateKeyData(): ByteArray = key.ethPrivateKey(index) +} diff --git a/Android/wallet/src/main/java/com/flow/wallet/wallet/KeyWallet.kt b/Android/wallet/src/main/java/com/flow/wallet/wallet/KeyWallet.kt index 0f57a3e..b745388 100644 --- a/Android/wallet/src/main/java/com/flow/wallet/wallet/KeyWallet.kt +++ b/Android/wallet/src/main/java/com/flow/wallet/wallet/KeyWallet.kt @@ -41,7 +41,8 @@ class KeyWallet( Log.e(TAG, "Cannot initialize KeyWallet: TrustWalletCore not available") throw WalletError.InitHDWalletFailed } - + + loadCachedEOAAddressMap() Log.d(TAG, "Initializing KeyWallet with networks: ${networks.joinToString()}") // Initialize wallet by fetching accounts with proper error handling scope.launch { diff --git a/iOS/FlowWalletKit/Sources/Keys/SeedPhraseKey.swift b/iOS/FlowWalletKit/Sources/Keys/SeedPhraseKey.swift index 43b77e9..cc94563 100644 --- a/iOS/FlowWalletKit/Sources/Keys/SeedPhraseKey.swift +++ b/iOS/FlowWalletKit/Sources/Keys/SeedPhraseKey.swift @@ -207,7 +207,7 @@ public class SeedPhraseKey: KeyProtocol { throw FWKError.initChaChapolyFailed } let model = KeyData(mnemonic: hdWallet.mnemonic, - derivationPath: SeedPhraseKey.derivationPath, + derivationPath: self.derivationPath, passphrase: passphrase) let data = try JSONEncoder().encode(model) let encrypted = try cipher.encrypt(data: data) @@ -247,7 +247,7 @@ public class SeedPhraseKey: KeyProtocol { guard let curve = signAlgo.WCCurve else { return nil } - var pk = hdWallet.getKeyByCurve(curve: curve, derivationPath: SeedPhraseKey.derivationPath) + var pk = hdWallet.getKeyByCurve(curve: curve, derivationPath: self.derivationPath) defer { pk = WalletCore.PrivateKey() } return pk.data } @@ -266,7 +266,7 @@ public class SeedPhraseKey: KeyProtocol { throw FWKError.unsupportSignatureAlgorithm } - var pk = hdWallet.getKeyByCurve(curve: curve, derivationPath: SeedPhraseKey.derivationPath) + var pk = hdWallet.getKeyByCurve(curve: curve, derivationPath: self.derivationPath) defer { pk = WalletCore.PrivateKey() } guard let signature = pk.sign(digest: hashed, curve: curve) else { throw FWKError.signError diff --git a/iOS/FlowWalletKit/Sources/Wallet/EOAAccount.swift b/iOS/FlowWalletKit/Sources/Wallet/EOAAccount.swift new file mode 100644 index 0000000..726ea82 --- /dev/null +++ b/iOS/FlowWalletKit/Sources/Wallet/EOAAccount.swift @@ -0,0 +1,67 @@ +import Foundation +import WalletCore + +/// Represents a single Ethereum EOA derived from a BIP44 path. +/// Bundles the address, derivation index, public key, and signing capabilities. +public class EOAAccount { + /// EIP-55 checksummed Ethereum address + public let address: String + /// BIP44 derivation index (m/44'/60'/0'/0/{index}) + public let index: UInt32 + /// Uncompressed secp256k1 public key + public let publicKey: Data + + private let key: EthereumKeyProtocol + + init(address: String, index: UInt32, publicKey: Data, key: EthereumKeyProtocol) { + self.address = address + self.index = index + self.publicKey = publicKey + self.key = key + } + + // MARK: - Signing + + /// Sign a pre-hashed 32-byte digest. Returns [r(32)|s(32)|v(1)]. + public func sign(digest: Data) throws -> Data { + try key.ethSign(digest: digest, index: index) + } + + /// EIP-191 personal_sign. + public func signPersonalMessage(_ message: Data) throws -> Data { + let prefixString = "\u{19}Ethereum Signed Message:\n\(message.count)" + guard let prefix = prefixString.data(using: .utf8) else { + throw FWKError.invalidEthereumMessage + } + var payload = Data() + payload.append(prefix) + payload.append(message) + let digest = Hash.keccak256(data: payload) + return try sign(digest: digest) + } + + /// EIP-712 signTypedData. + public func signTypedData(json: String) throws -> Data { + let digest = EthereumAbi.encodeTyped(messageJson: json) + guard digest.count == 32 else { + throw FWKError.invalidEthereumTypedData + } + return try sign(digest: digest) + } + + /// Sign an Ethereum transaction via WalletCore AnySigner. + public func signTransaction(_ input: EthereumSigningInput) throws -> EthereumSigningOutput { + var signingInput = input + signingInput.privateKey = try key.ethPrivateKey(index: index) + defer { signingInput.privateKey = Data() } + var output: EthereumSigningOutput = AnySigner.sign(input: signingInput, coin: .ethereum) + let transactionHash = Hash.keccak256(data: output.encoded) + output.preHash = transactionHash + return output + } + + /// Raw 32-byte secp256k1 private key. Caller is responsible for secure handling. + public func privateKeyData() throws -> Data { + try key.ethPrivateKey(index: index) + } +} diff --git a/iOS/FlowWalletKit/Sources/Wallet/Wallet+EOA.swift b/iOS/FlowWalletKit/Sources/Wallet/Wallet+EOA.swift index 8122ec6..7865fcb 100644 --- a/iOS/FlowWalletKit/Sources/Wallet/Wallet+EOA.swift +++ b/iOS/FlowWalletKit/Sources/Wallet/Wallet+EOA.swift @@ -16,9 +16,28 @@ extension Wallet { let key = try resolveEthereumKey() let normalizedIndexes = indexes?.isEmpty == false ? indexes! : [0] let addresses = try deriveEOAAddresses(from: key, indexes: normalizedIndexes) - updateEOAAddressCache(with: addresses) + for (i, addr) in zip(normalizedIndexes, addresses) { + eoaAddressMap[i] = addr.description + } return addresses } + + /// Derive multiple EOA accounts from the wallet's seed phrase. + /// Each returned `EOAAccount` carries its own signing context. + /// - Parameter indexes: BIP44 address indexes to derive (defaults to [0]). + /// - Returns: Array of `EOAAccount` instances with signing capabilities. + public func getEOAAccounts(indexes: [UInt32]? = nil) throws -> [EOAAccount] { + let key = try resolveEthereumKey() + let normalizedIndexes = indexes?.isEmpty == false ? indexes! : [0] + let accounts = try normalizedIndexes.map { index in + let address = try key.ethAddress(index: index) + let publicKey = try key.ethPublicKey(index: index) + eoaAddressMap[index] = address + return EOAAccount(address: address, index: index, publicKey: publicKey, key: key) + } + try? cacheEOAAddressMap() + return accounts + } /// Returns the Ethereum address for the given derivation index (default index 0). public func ethAddress(index: UInt32 = 0) throws -> String { @@ -91,18 +110,18 @@ extension Wallet { public func refreshEOAAddresses() { guard let key = try? resolveEthereumKey() else { - eoaAddress = nil + eoaAddressMap = [:] return } - + do { - let addresses = try deriveEOAAddresses(from: key, indexes: [0]) - updateEOAAddressCache(with: addresses) + let address = try key.ethAddress(index: 0) + eoaAddressMap[0] = address } catch { - eoaAddress = nil + eoaAddressMap = [:] } } - + private func deriveEOAAddresses(from key: EthereumKeyProtocol, indexes: [UInt32]) throws -> [AnyAddress] { var results: [AnyAddress] = [] @@ -116,15 +135,34 @@ extension Wallet { return results } - private func updateEOAAddressCache(with addresses: [AnyAddress]) { - if addresses.isEmpty { - eoaAddress = nil + // MARK: - EOA Address Map Cache + + private static let eoaMapCachePrefix = "EOAMap" + + private var eoaMapCacheId: String { + [Wallet.cachePrefix, Self.eoaMapCachePrefix, type.id].joined(separator: "-") + } + + /// Persist the current eoaAddressMap to storage. + public func cacheEOAAddressMap() throws { + let stringKeyed = Dictionary(uniqueKeysWithValues: eoaAddressMap.map { (String($0.key), $0.value) }) + let data = try JSONEncoder().encode(stringKeyed) + try cacheStorage.set(eoaMapCacheId, value: data) + } + + /// Load eoaAddressMap from storage. Called during init. + func loadCachedEOAAddressMap() { + guard let data = try? cacheStorage.get(eoaMapCacheId), + let stringKeyed = try? JSONDecoder().decode([String: String].self, from: data) else { return } - let set = Set(addresses.map { $0.description }) - eoaAddress = set.isEmpty ? nil : set + for (key, value) in stringKeyed { + if let index = UInt32(key) { + eoaAddressMap[index] = value + } + } } - + private func resolveEthereumKey() throws -> EthereumKeyProtocol { guard case let .key(rawKey) = type, let ethereumKey = rawKey as? EthereumKeyProtocol else { diff --git a/iOS/FlowWalletKit/Sources/Wallet/Wallet.swift b/iOS/FlowWalletKit/Sources/Wallet/Wallet.swift index b882c59..5a83266 100644 --- a/iOS/FlowWalletKit/Sources/Wallet/Wallet.swift +++ b/iOS/FlowWalletKit/Sources/Wallet/Wallet.swift @@ -54,8 +54,9 @@ public class Wallet: ObservableObject { @Published public var accounts: [Flow.ChainID: [Account]]? = nil + /// Map of BIP44 derivation index → EIP-55 checksummed EOA address. @Published - public var eoaAddress: Set? + public var eoaAddressMap: [UInt32: String] = [:] /// Raw Flow accounts data, used for caching /// This property stores the underlying Flow.Account objects @@ -92,6 +93,7 @@ public class Wallet: ObservableObject { self.cacheStorage = cacheStorage } try? loadCachedAccount() + loadCachedEOAAddressMap() refreshEOAAddresses() } diff --git a/iOS/FlowWalletKit/Tests/FlowWalletKitTests/EOAAccountTests.swift b/iOS/FlowWalletKit/Tests/FlowWalletKitTests/EOAAccountTests.swift new file mode 100644 index 0000000..4270c75 --- /dev/null +++ b/iOS/FlowWalletKit/Tests/FlowWalletKitTests/EOAAccountTests.swift @@ -0,0 +1,267 @@ +@testable import FlowWalletKit +import WalletCore +import XCTest + +final class EOAAccountTests: XCTestCase { + private let testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + // Standard BIP44 m/44'/60'/0'/0/0 address for this mnemonic + private let expectedIndex0Address = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + + override func setUp() { + super.setUp() + SeedPhraseKey.ethBaseDerivationPath = "m/44'/60'/0'/0/0" + } + + // MARK: - Multi EOA Derivation + + func testDeriveMultipleEOAAccounts() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [0, 1, 2]) + + XCTAssertEqual(accounts.count, 3) + // Each account should have a unique address + let addresses = Set(accounts.map { $0.address }) + XCTAssertEqual(addresses.count, 3, "Each derivation index should produce a unique address") + + // Verify indexes match + XCTAssertEqual(accounts[0].index, 0) + XCTAssertEqual(accounts[1].index, 1) + XCTAssertEqual(accounts[2].index, 2) + + // Index 0 should match known address + XCTAssertEqual(accounts[0].address, expectedIndex0Address) + } + + func testDefaultIndexIsZero() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts() + + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(accounts[0].index, 0) + XCTAssertEqual(accounts[0].address, expectedIndex0Address) + } + + // MARK: - EOAAccount Signing + + func testEOAAccountSignDigest() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [0]) + let account = accounts[0] + + let digest = Hash.keccak256(data: Data("hello world".utf8)) + let signature = try account.sign(digest: digest) + + XCTAssertEqual(signature.count, 65) + XCTAssertTrue(signature.last == 27 || signature.last == 28) + + // Should match direct key signing + let directSignature = try key.ethSign(digest: digest, index: 0) + XCTAssertEqual(signature, directSignature) + } + + func testEOAAccountPersonalSign() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [0]) + let account = accounts[0] + + let message = Data("Flow Wallet".utf8) + let signature = try account.signPersonalMessage(message) + + XCTAssertEqual(signature.count, 65) + + // Should match wallet-level personal sign + let walletSignature = try wallet.ethSignPersonalMessage(message, index: 0) + XCTAssertEqual(signature, walletSignature) + } + + func testEOAAccountTypedDataSign() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [0]) + let account = accounts[0] + + let typedData = """ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"} + ], + "Test": [ + {"name": "value", "type": "string"} + ] + }, + "primaryType": "Test", + "domain": { + "name": "Test", + "version": "1", + "chainId": 1 + }, + "message": { + "value": "hello" + } +} +""" + + let signature = try account.signTypedData(json: typedData) + XCTAssertEqual(signature.count, 65) + + // Should match wallet-level typed data sign + let walletSignature = try wallet.ethSignTypedData(json: typedData, index: 0) + XCTAssertEqual(signature, walletSignature) + } + + func testDifferentIndexesProduceDifferentSignatures() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [0, 1]) + + let digest = Hash.keccak256(data: Data("test message".utf8)) + let sig0 = try accounts[0].sign(digest: digest) + let sig1 = try accounts[1].sign(digest: digest) + + XCTAssertNotEqual(sig0, sig1, "Different derivation indexes should produce different signatures") + } + + func testEOAAccountPublicKey() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [0]) + let account = accounts[0] + + // Uncompressed secp256k1 public key is 65 bytes (04 prefix + 32x + 32y) + XCTAssertEqual(account.publicKey.count, 65) + XCTAssertEqual(account.publicKey.first, 0x04) + } + + func testEOAAccountTransactionSigning() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [0]) + let account = accounts[0] + + var input = EthereumSigningInput() + input.chainID = Data([0x01]) + input.nonce = Data([0x09]) + input.gasPrice = Data([0x04, 0xa8, 0x17, 0xc8, 0x00]) + input.gasLimit = Data([0x52, 0x08]) + input.toAddress = "0x3535353535353535353535353535353535353535" + input.transaction = EthereumTransaction.with { + $0.transfer = EthereumTransaction.Transfer.with { + $0.amount = Data([0x0d, 0xe0, 0xb6, 0xb3, 0xa7, 0x64, 0x00, 0x00]) + } + } + + let output = try account.signTransaction(input) + XCTAssertFalse(output.encoded.isEmpty) + + // Should match wallet-level signing + let walletOutput = try wallet.ethSignTransaction(input, index: 0) + XCTAssertEqual(output.encoded, walletOutput.encoded) + } + + func testEOAAccountPrivateKey() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [0, 1]) + + let pk0 = try accounts[0].privateKeyData() + let pk1 = try accounts[1].privateKeyData() + + XCTAssertEqual(pk0.count, 32) + XCTAssertEqual(pk1.count, 32) + XCTAssertNotEqual(pk0, pk1) + } + + // MARK: - Address Map + + func testEOAAddressMapUpdatedOnDerive() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + // Map should have index 0 from init (refreshEOAAddresses) + XCTAssertEqual(wallet.eoaAddressMap[0], expectedIndex0Address) + + // Derive more accounts + let accounts = try wallet.getEOAAccounts(indexes: [0, 1, 2]) + + XCTAssertEqual(wallet.eoaAddressMap.count, 3) + XCTAssertEqual(wallet.eoaAddressMap[0], accounts[0].address) + XCTAssertEqual(wallet.eoaAddressMap[1], accounts[1].address) + XCTAssertEqual(wallet.eoaAddressMap[2], accounts[2].address) + } + + func testEOAAddressMapLookupByIndex() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + _ = try wallet.getEOAAccounts(indexes: [0, 5, 10]) + + // Can look up any derived address by index + XCTAssertNotNil(wallet.eoaAddressMap[0]) + XCTAssertNotNil(wallet.eoaAddressMap[5]) + XCTAssertNotNil(wallet.eoaAddressMap[10]) + // Non-derived index should be nil + XCTAssertNil(wallet.eoaAddressMap[3]) + } + + // MARK: - Edge Cases + + func testEmptyIndexesDefaultsToZero() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: []) + + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(accounts[0].index, 0) + } + + func testLargeDerivationIndex() throws { + let storage = makeEphemeralStorage() + let key = try SeedPhraseKey.create(testMnemonic, storage: storage) + let wallet = Wallet(type: .key(key), networks: [.mainnet], cacheStorage: storage) + + let accounts = try wallet.getEOAAccounts(indexes: [100]) + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(accounts[0].index, 100) + // Should still produce a valid address (0x-prefixed, 42 chars) + XCTAssertTrue(accounts[0].address.hasPrefix("0x")) + XCTAssertEqual(accounts[0].address.count, 42) + } + + // MARK: - Helpers + + private func makeEphemeralStorage() -> FileSystemStorage { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil) + return FileSystemStorage(type: .documentDirectory, directory: temporaryDirectory) + } +} diff --git a/iOS/FlowWalletKit/Tests/FlowWalletKitTests/EOATests.swift b/iOS/FlowWalletKit/Tests/FlowWalletKitTests/EOATests.swift index 565fda1..e1f8de0 100644 --- a/iOS/FlowWalletKit/Tests/FlowWalletKitTests/EOATests.swift +++ b/iOS/FlowWalletKit/Tests/FlowWalletKitTests/EOATests.swift @@ -68,7 +68,7 @@ final class EOATests: XCTestCase { XCTAssertEqual(addresses.count, 1) XCTAssertEqual(addresses.first?.description, expectedPrivateKeyEthAddress) - XCTAssertEqual(wallet.eoaAddress, Set([expectedPrivateKeyEthAddress])) + XCTAssertEqual(wallet.eoaAddressMap[0], expectedPrivateKeyEthAddress) } func testWalletPersonalSignMatchesDirectSignature() throws {