Skip to content
Draft
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-contrib:$espressoVersion") {
exclude(group = "com.android.support", module = "support-annotations")
exclude(group = "org.checkerframework", module = "checker")
exclude(group = "com.google.protobuf", module = "protobuf-lite")
}
androidTestImplementation("androidx.test.espresso:espresso-idling-resource:$espressoVersion")
androidTestImplementation("androidx.test.ext:junit:$androidxJunitVersion")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package one.mixin.android.crypto

import androidx.test.ext.junit.runners.AndroidJUnit4
import one.mixin.android.Constants
import one.mixin.android.extension.hexStringToByteArray
import one.mixin.android.tip.privateKeyToAddress
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MnemonicAddressDerivationTest {
private data class DerivedAddresses(
val eth: String,
val sol: String,
val btc: String,
)

@Test
fun derivesClassicAddressesForMnemonicIndexesZeroThroughNine() {
val mnemonic = "blur staff nurse happy palm neutral inflict inform soup almost always canal"
val expectedAddresses = listOf(
DerivedAddresses("0x59076a300E56dFd7652ab561CE749AAB4E09087c", "APwPqNnVoatwPi95tA8rfBnLp6JZeyXGHmHh779zhwBH", "bc1q0l3ln6utuqrlmp2n9zy33skgyeevfvuezk9t5c"),
DerivedAddresses("0xA4bf0a3E4A2Ee036bb905D5A5250B4E9056Ff918", "AZaxkQSnaqNm6RjnASCm7BjJc66kpJa1m95p9jHvEqnY", "bc1qhs8kfw8hj8kf90qw4elagz9dzr3xfclp6stz54"),
DerivedAddresses("0xEa2fdc703c119141E4f3a5260b13fcECf1cD1aA7", "EFiCUQshpopmtXttsfEa2fBQBWfyUWoUuW2wjgnkbyQm", "bc1qpvzhk029gp2fe30esu98l9j6dfgtw2aalqlcqp"),
DerivedAddresses("0xC7CBccfd6adCAfFA619A0e253F94B9C35B134888", "7rWxEkLhhcdSJg8ZD8KbLb8YVcWHVLdTwCEkzpQfa9cV", "bc1qv62cf3qty5am852wezjk2sqsah04mczfkn8x60"),
DerivedAddresses("0xf45977F3C7902a491b49f5b21a74CDFDEA6F4E12", "EGGX4hRgiv93EYYtJZLZwfTPThnbuEBPgGzRrCpdRfF5", "bc1qh4dgf9aq4p2nzl7w4u3hgka8z4axtnktk65nf5"),
DerivedAddresses("0xE9a92CD1EF14e7516d9F3dc0ea7a0B47eb39ae03", "CArQMDKcZDYS45KhpjAMF1kmVrisCqssb252oUGsMBQW", "bc1qpkyzkkdmrwj2vmw040kw6k2x4h4v9czvpqjd4w"),
DerivedAddresses("0xaC5acDE406cbFdC95e8eC2c1381038d7d8d26ce9", "32SWu9NCQxqHHuzUrDRebVniav9KS8qzT8WiTaXwwLqz", "bc1qq49wpvcxyxg2tde0xpn4k3ahhvv48p5eupnq4q"),
DerivedAddresses("0x5e31953d983E35f09f0D06E170E40D7bA966e27e", "FEMJvTfw5Ee7wm8tbRF2kFfjTzgTtnUyHyuPvvgPm3Ja", "bc1qenq8pydqq6tpjsf3dre0mv7kal05hex2wvexzk"),
DerivedAddresses("0xeDbd4271861b35bBF31E98F1d83F3AC837501885", "4mZudB4KN8ZGexbkEm7Ao2MPcAz8BnSNEEzARA4SGwdn", "bc1q8gtw92uaapwpl00hhkr5ghvrdc7xggw2r5zgmg"),
DerivedAddresses("0x3614E12EB2c47A7108C18AdA669FCebFBc3cA081", "FAX3U7tUDZbz7ggEe8LWYHE8udVzobHQuur5W5hQYfqF", "bc1q7mnlczmtwckrhsr9ezk04pvcqcnar7cy527hup"),
)

expectedAddresses.forEachIndexed { index, expected ->
assertEquals(expected.eth, CryptoWalletHelper.mnemonicToEthereumWallet(mnemonic, index = index).address)
assertEquals(expected.sol, CryptoWalletHelper.mnemonicToSolanaWallet(mnemonic, index = index).address)
assertEquals(expected.btc, CryptoWalletHelper.mnemonicToBitcoinSegwitWallet(mnemonic, index = index).address)
}
}

@Test
fun derivesCommonAddressesFromTipSpendKeyIndexesZeroThroughNine() {
val spendKey = "00e39d185e883a18949c48c834bc0b8347c6466dd0925fa084f3493d6727e0f9".hexStringToByteArray()
val expectedAddresses = listOf(
DerivedAddresses("0x2dbb8EBAF233b6ad93b7DFc5616A1D1971e7D264", "AutN9EZHypXoCAgi982s45pjMekzkS4jRC7UXXi9Kyub", "bc1qrwa7al55klllsqmwltgsym5jjqwujgwp34ztqa"),
DerivedAddresses("0x1ada924ca6801447F8aca00cFD527dBbE8967871", "HaFiYPDbw4bYAthRFFURWyYUefyBgzKFR1GJiywK9fSn", "bc1q4vrhsrk0fj3hmnt55tqcsr085smvkzap462efe"),
DerivedAddresses("0x6d81282bf22Cfe8D30205662cb5E19e6ad279fb9", "H3eQGvhWTHJLZjMPudYeSuqgzPErwL2Fk7Nn6FJNuZ47", "bc1qwquhyx7vs9ua3fxxxwgdrwglcrr34vrgewyanj"),
DerivedAddresses("0xf2436459a303c289e9184633b8a56F6E3B62Ad02", "5vxZYUmz8CGasKXz3htAd5VZg8cscVicxqLqHRNfDV2d", "bc1qartkcn5lju05tz3jgepzqe30pnjvyged2drmqn"),
DerivedAddresses("0xa4fb1b08f60B1Db00C319f25644580dF21de0F07", "8Qjc6hy3d3vWD9fUDipNWKaoR9g3FmgvgbCtszjAKJFz", "bc1qyhqppl6rlpg7gdcnsm8amg04srs5jck3cyhlj7"),
DerivedAddresses("0x92cB37C836be4d86C0FEF08d09c6996Ce7d9b52A", "Fnn6JzEh555qyqZrgVF4WVVsRrFDV73zB3ucNc5cKWKf", "bc1qgez94trpc2afa9kx7722v245j0gucsed89dwaq"),
DerivedAddresses("0xA88Ec0865A332297308AeAdd700591AcBA9dB20B", "CFYNVzxBxXePTG8HFAAyfC5uKHnCENBjFtBA7i4iBQZw", "bc1qc97rnp664jl7l970rf7xq7v4pcge0qtfdczhfp"),
DerivedAddresses("0xaE77832c9070d526892D1a122cA420417E53BCaF", "2jFvCSFiVRFWCzUg6cDvPgGWqRmPodowpvGLNWEAtd6x", "bc1qxtcl50k03kkd73hmjyvkzq42h5u9qk0ew95y3l"),
DerivedAddresses("0x16E918943416d10cB65d0955C24Ad2BAa2A79284", "9rdsZZn4fbVcPJ7YSxQsThfg8z1mQ1ErJSrULX86NZij", "bc1qsjld2m55cyzy42av9rsrfnq596wqpsljmr66t6"),
DerivedAddresses("0x7586a05503f308A74F971De2461855edB96fF9b4", "9AmBTyKD4u8E2SWWA2hV3aGRDvynSCqrwgJRqfWPUDHN", "bc1qtrjarylf3vhdrglmrjsy7xn5zgs0q4qh0x77re"),
)

expectedAddresses.forEachIndexed { index, expected ->
assertEquals(expected.eth, privateKeyToAddress(spendKey, Constants.ChainId.ETHEREUM_CHAIN_ID, index))
assertEquals(expected.sol, privateKeyToAddress(spendKey, Constants.ChainId.SOLANA_CHAIN_ID, index))
assertEquals(expected.btc, privateKeyToAddress(spendKey, Constants.ChainId.BITCOIN_CHAIN_ID, index))
}
}
}
1 change: 1 addition & 0 deletions app/src/main/java/one/mixin/android/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ object Constants {
const val ALIAS_TIP_PRIV = "alias_tip_priv"

const val MNEMONIC = "mnemonic"
const val PENDING_IMPORT_MNEMONIC = "pending_import_mnemonic"

const val SPEND_SALT = "spend_salt"
const val ALIAS_SPEND_SALT = "alias_spend_salt"
Expand Down
55 changes: 42 additions & 13 deletions app/src/main/java/one/mixin/android/crypto/CryptoWalletHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@ object CryptoWalletHelper {
return aesGcmEncrypt(originalEntropy, aesKeyBytes).base64RawURLEncode()
}

fun saveMnemonicWithSpendKey(
context: Context,
spendKey: ByteArray,
walletId: String,
mnemonicWords: List<String>,
): Boolean {
return try {
val encryptedString = encryptMnemonicWithSpendKey(spendKey, mnemonicWords)
saveWeb3PrivateKey(context, walletId, encryptedString)
hasPrivateKey(context, walletId)
} catch (e: Exception) {
Timber.e(e, "Failed to save web3 mnemonic")
false
}
}

fun decryptMnemonicWithSpendKey(
spendKey: ByteArray,
base64EncryptedData: String
Expand Down Expand Up @@ -286,7 +302,14 @@ object CryptoWalletHelper {
currentCategory == WalletCategory.CLASSIC.value || currentWalletId.isEmpty() -> {
val derivationIndex = extractIndexFromPath(Web3Signer.path) ?: 0
Timber.d("currentWalletId: ${Web3Signer.currentWalletId}, currentWalletCategory: ${Web3Signer.currentWalletCategory}, evmAddress: ${Web3Signer.evmAddress}, solanaAddress: ${Web3Signer.solanaAddress} derivationIndex: $derivationIndex")
tipPrivToPrivateKey(spendKey, chainId, derivationIndex)
val mnemonic = currentWalletId.takeIf { it.isNotEmpty() }?.let {
getWeb3Mnemonic(context, spendKey, it)
}
if (mnemonic != null) {
getMnemonicPrivateKey(mnemonic, chainId, derivationIndex)
} else {
tipPrivToPrivateKey(spendKey, chainId, derivationIndex)
}
}

currentCategory == WalletCategory.IMPORTED_PRIVATE_KEY.value -> {
Expand All @@ -297,18 +320,7 @@ object CryptoWalletHelper {
val mnemonic = getWeb3Mnemonic(context, spendKey, currentWalletId)
?: return null
val derivationIndex = requireNotNull(extractIndexFromPath(Web3Signer.path))

when (chainId) {
Constants.ChainId.SOLANA_CHAIN_ID -> {
SolanaKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, index = derivationIndex)
}
Constants.ChainId.BITCOIN_CHAIN_ID -> {
BitcoinKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, "", derivationIndex)
}
else -> {
EthKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, index = derivationIndex)
}
}
getMnemonicPrivateKey(mnemonic, chainId, derivationIndex)
}
}
} catch (e: Exception) {
Expand All @@ -317,6 +329,23 @@ object CryptoWalletHelper {
}
}

private fun getMnemonicPrivateKey(
mnemonic: String,
chainId: String,
derivationIndex: Int,
): ByteArray? =
when (chainId) {
Constants.ChainId.SOLANA_CHAIN_ID -> {
SolanaKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, index = derivationIndex)
}
Constants.ChainId.BITCOIN_CHAIN_ID -> {
BitcoinKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, "", derivationIndex)
}
else -> {
EthKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, index = derivationIndex)
}
}

fun saveWeb3PrivateKey(context: Context, walletId: String, encryptedString: String) {
secureStorage(context)?.putString(walletId, encryptedString)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package one.mixin.android.crypto

import android.content.Context
import one.mixin.android.Constants

fun savePendingImportMnemonic(context: Context, words: List<String>) {
storeValueInEncryptedPreferences(context, Constants.Tip.PENDING_IMPORT_MNEMONIC, toEntropy(words))
}

fun getPendingImportMnemonic(context: Context): String? {
val entropy = getValueFromEncryptedPreferences(context, Constants.Tip.PENDING_IMPORT_MNEMONIC) ?: return null
return runCatching { toMnemonic(entropy) }.getOrNull()
}

fun hasPendingImportMnemonic(context: Context): Boolean {
return getValueFromEncryptedPreferences(context, Constants.Tip.PENDING_IMPORT_MNEMONIC) != null
}

fun clearPendingImportMnemonic(context: Context) {
removeValueFromEncryptedPreferences(context, Constants.Tip.PENDING_IMPORT_MNEMONIC)
}
55 changes: 55 additions & 0 deletions app/src/main/java/one/mixin/android/repository/Web3Repository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,12 @@ import one.mixin.android.db.web3.vo.Web3TransactionItem
import one.mixin.android.db.web3.vo.Web3Wallet
import one.mixin.android.db.web3.vo.WalletItem
import one.mixin.android.db.web3.vo.isWatch
import one.mixin.android.extension.defaultSharedPreferences
import one.mixin.android.extension.hexStringToByteArray
import one.mixin.android.extension.nowInUtc
import one.mixin.android.extension.putBoolean
import one.mixin.android.ui.wallet.Web3FilterParams
import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI
import one.mixin.android.vo.WalletCategory
import one.mixin.android.vo.route.Order
import one.mixin.android.vo.safe.toWeb3TokenItem
Expand Down Expand Up @@ -302,6 +305,58 @@ constructor(

suspend fun updateWallet(walletId: String, request: WalletRequest) = routeService.updateWallet(walletId, request)

suspend fun syncWalletsFromRoute(): List<Web3Wallet>? {
return requestRouteAPI(
invokeNetwork = { routeService.getWallets() },
successBlock = { response ->
val wallets = response.data ?: emptyList()
if (wallets.isEmpty()) {
return@requestRouteAPI wallets
}
web3WalletDao.insertListSuspend(wallets)
wallets.forEach { wallet ->
val embeddedAddresses = wallet.addresses
if (embeddedAddresses.isNullOrEmpty()) {
syncWalletAddressesFromRoute(wallet.id)
} else {
web3AddressDao.insertListSuspend(embeddedAddresses)
context.defaultSharedPreferences.putBoolean(Constants.Account.PREF_WEB3_ADDRESSES_SYNCED, true)
}
}
wallets
},
failureBlock = { response ->
Timber.e("Failed to sync wallets from route ${response.errorCode} - ${response.errorDescription}")
false
},
requestSession = { ids ->
userRepository.fetchSessionsSuspend(ids)
},
defaultErrorHandle = {},
)
}

private suspend fun syncWalletAddressesFromRoute(walletId: String) {
requestRouteAPI(
invokeNetwork = { routeService.getWalletAddresses(walletId) },
successBlock = { response ->
val addresses = response.data ?: emptyList()
if (addresses.isNotEmpty()) {
web3AddressDao.insertListSuspend(addresses)
context.defaultSharedPreferences.putBoolean(Constants.Account.PREF_WEB3_ADDRESSES_SYNCED, true)
}
},
failureBlock = { response ->
Timber.e("Failed to sync wallet addresses from route ${response.errorCode} - ${response.errorDescription}")
false
},
requestSession = { ids ->
userRepository.fetchSessionsSuspend(ids)
},
defaultErrorHandle = {},
)
}

suspend fun insertWallet(wallet: Web3Wallet) = web3WalletDao.insertSuspend(wallet)

suspend fun updateWalletName(walletId: String, newName: String) = web3WalletDao.updateWalletName(walletId, newName)
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/one/mixin/android/tip/TipSpendKey.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package one.mixin.android.tip

import android.content.Context

suspend fun Tip.getSpendKeyFromPin(
context: Context,
pin: String,
): ByteArray {
val tipPriv = getOrRecoverTipPriv(context, pin).getOrThrow()
return getSpendPrivFromEncryptedSalt(
getMnemonicFromEncryptedPreferences(context),
getEncryptedSalt(context),
pin,
tipPriv,
)
}
Loading