Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
}
56 changes: 50 additions & 6 deletions Android/wallet/src/main/java/com/flow/wallet/wallet/BaseWallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,7 +49,7 @@ interface Wallet {
val type: WalletType
val accounts: Map<ChainId, List<Account>>
val accountsFlow: StateFlow<Map<ChainId, List<Account>>>
val eoaAddresses: StateFlow<Set<String>>
val eoaAddressMap: StateFlow<Map<Int, String>>
val networks: Set<ChainId>
val storage: StorageProtocol
val isLoading: StateFlow<Boolean>
Expand All @@ -63,6 +65,7 @@ interface Wallet {
suspend fun fetchAccountsForNetwork(network: ChainId): List<FlowAccount>
suspend fun fetchAccountByAddress(address: String, network: ChainId)
suspend fun fetchAccountByCreationTxId(txId: String, network: ChainId): Account
suspend fun getEOAAccounts(indexes: List<Int> = listOf(0)): List<EOAAccount>
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
Expand Down Expand Up @@ -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
Expand All @@ -113,8 +117,8 @@ abstract class BaseWallet(
// Accounts as flow for reactive updates
internal val _accountsFlow = MutableStateFlow<Map<ChainId, List<Account>>>(emptyMap())
override val accountsFlow: StateFlow<Map<ChainId, List<Account>>> = _accountsFlow.asStateFlow()
private val _eoaAddresses = MutableStateFlow<Set<String>>(emptySet())
override val eoaAddresses: StateFlow<Set<String>> = _eoaAddresses.asStateFlow()
private val _eoaAddressMap = MutableStateFlow<Map<Int, String>>(emptyMap())
override val eoaAddressMap: StateFlow<Map<Int, String>> = _eoaAddressMap.asStateFlow()

// Accounts as map (legacy support)
override val accounts: Map<ChainId, List<Account>>
Expand Down Expand Up @@ -323,10 +327,23 @@ abstract class BaseWallet(
_accountsFlow.value = _accounts.toMap()
}

override suspend fun getEOAAccounts(indexes: List<Int>): List<EOAAccount> {
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
}

Expand Down Expand Up @@ -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<Map<String, String>>(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?
Expand Down
64 changes: 64 additions & 0 deletions Android/wallet/src/main/java/com/flow/wallet/wallet/EOAAccount.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Comment on lines +54 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The privateKey is cleared from the builder but the local privateKey variable is not cleared from memory after use. Consider using Arrays.fill(privateKey, 0.toByte()) or similar to ensure the private key is securely wiped from memory.


/** Raw 32-byte secp256k1 private key. Caller is responsible for secure handling. */
fun privateKeyData(): ByteArray = key.ethPrivateKey(index)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading